diff --git a/assets/data/employees.csv b/assets/data/employees.csv
new file mode 100644
index 0000000..3b7e2fc
--- /dev/null
+++ b/assets/data/employees.csv
@@ -0,0 +1,25 @@
+name,department,salary,years,rating
+Alice,Engineering,95000,5,4.5
+Bob,Marketing,72000,3,3.8
+Charlie,Engineering,105000,8,4.9
+Diana,Sales,68000,2,3.2
+Eve,Marketing,88000,6,4.1
+Frank,Sales,71000,4,3.7
+Grace,Engineering,92000,7,4.6
+Hank,Marketing,76000,3,3.5
+Iris,Sales,64000,1,2.9
+Jack,Engineering,110000,9,4.8
+Karen,Marketing,81000,5,4.0
+Leo,Sales,69000,3,3.4
+Mona,Engineering,98000,6,4.3
+Nate,Marketing,74000,2,3.6
+Olivia,Sales,67000,4,3.1
+Paul,Engineering,103000,8,4.7
+Quinn,Marketing,85000,5,4.2
+Rita,Sales,70000,3,3.3
+Sam,Engineering,91000,7,4.4
+Tina,Marketing,78000,4,3.9
+Uma,Sales,66000,2,3.0
+Vic,Engineering,107000,10,4.8
+Wendy,Marketing,83000,6,4.1
+Xander,Sales,72000,3,3.5
diff --git a/assets/data/employees.feather b/assets/data/employees.feather
new file mode 100644
index 0000000..bd6be01
Binary files /dev/null and b/assets/data/employees.feather differ
diff --git a/assets/data/products.parquet b/assets/data/products.parquet
new file mode 100644
index 0000000..e43c9cf
Binary files /dev/null and b/assets/data/products.parquet differ
diff --git a/assets/data/products.tsv b/assets/data/products.tsv
new file mode 100644
index 0000000..81dac3d
--- /dev/null
+++ b/assets/data/products.tsv
@@ -0,0 +1,8 @@
+product category price stock rating
+Widget Electronics 29.99 150 4.5
+Gadget Tools 49.50 80 3.8
+Gizmo Kitchen 12.00 300 4.9
+Doohickey Garden 8.75 0 4.2
+Thingamajig Office 199.99 25 2.1
+Contraption Electronics 65.00 44 3.5
+Apparatus Tools 120.00 12 4.7
diff --git a/assets/data/server_logs.jsonl b/assets/data/server_logs.jsonl
new file mode 100644
index 0000000..e0774e2
--- /dev/null
+++ b/assets/data/server_logs.jsonl
@@ -0,0 +1,6 @@
+{"timestamp": "2025-01-15T08:30:00", "level": "INFO", "module": "auth", "message": "User login successful"}
+{"timestamp": "2025-01-15T08:31:12", "level": "WARNING", "module": "db", "message": "Slow query detected (3.2s)"}
+{"timestamp": "2025-01-15T08:32:45", "level": "ERROR", "module": "api", "message": "Request timeout on /v2/users"}
+{"timestamp": "2025-01-15T08:33:01", "level": "INFO", "module": "cache", "message": "Cache miss for key user:42"}
+{"timestamp": "2025-01-15T08:34:20", "level": "DEBUG", "module": "auth", "message": "Token refresh for session abc123"}
+{"timestamp": "2025-01-15T08:35:55", "level": "ERROR", "module": "db", "message": "Connection pool exhausted"}
diff --git a/assets/data/students.csv b/assets/data/students.csv
new file mode 100644
index 0000000..410a3d4
--- /dev/null
+++ b/assets/data/students.csv
@@ -0,0 +1,11 @@
+name,subject,score,grade,passed
+Alice,Math,95.5,A,true
+Bob,Science,82.0,B,true
+Charlie,English,71.3,C,true
+Diana,History,60.0,D,true
+Eve,Art,55.8,F,false
+Frank,Math,88.2,B+,true
+Grace,Science,79.9,C+,true
+Hank,English,91.0,A-,true
+Iris,History,66.4,D+,true
+Jack,Art,73.7,C,true
diff --git a/great-docs.yml b/great-docs.yml
index 81f75fb..b8731be 100644
--- a/great-docs.yml
+++ b/great-docs.yml
@@ -254,6 +254,7 @@ nav_icons:
Versioned Docs: git-branch
Color Swatches: pipette
Table Previews: table
+ Table Explorer: telescope
# Author Information
# ------------------
@@ -320,3 +321,8 @@ reference:
- tbl_preview
- enable_tbl_preview
- disable_tbl_preview
+
+ - title: Table Explorer
+ desc: Generate interactive HTML table explorers with sorting, filtering, and pagination
+ contents:
+ - tbl_explorer
diff --git a/great_docs/__init__.py b/great_docs/__init__.py
index da2a4bd..929d664 100644
--- a/great_docs/__init__.py
+++ b/great_docs/__init__.py
@@ -9,6 +9,7 @@
__version__ = "0.0.0"
from ._tbl_display import disable_tbl_preview, enable_tbl_preview
+from ._tbl_explorer import tbl_explorer
from ._tbl_preview import tbl_preview
from .cli import main
from .config import Config, create_default_config, load_config
@@ -23,6 +24,7 @@
"load_config",
"main",
"render_evolution_table",
+ "tbl_explorer",
"tbl_preview",
]
diff --git a/great_docs/_tbl_explorer.py b/great_docs/_tbl_explorer.py
new file mode 100644
index 0000000..e9084b0
--- /dev/null
+++ b/great_docs/_tbl_explorer.py
@@ -0,0 +1,1075 @@
+from __future__ import annotations
+
+import json
+import secrets
+from pathlib import Path
+from typing import Any
+
+from ._tbl_preview import (
+ _apply_column_subset,
+ _compute_col_widths,
+ _detect_alignments,
+ _normalize_data,
+ _render_body_html,
+ _render_colgroup_html,
+ _render_column_labels_html,
+ _render_header_html,
+ _render_scoped_css,
+)
+
+# ---------------------------------------------------------------------------
+# Public result class
+# ---------------------------------------------------------------------------
+
+
+class TblExplorer:
+ """Interactive table explorer with `_repr_html_()` support."""
+
+ def __init__(self, html: str) -> None:
+ self._html = html
+
+ def _repr_html_(self) -> str: # noqa: N802
+ return self._html
+
+ def as_html(self) -> str:
+ """Return the raw HTML string (includes ` injection
+ return raw.replace("", r"<\/")
+
+
+# ---------------------------------------------------------------------------
+# Explorer-specific CSS (toolbar + pagination + sort indicators)
+# ---------------------------------------------------------------------------
+
+
+def _render_explorer_css(uid: str) -> str:
+ """Return CSS for the interactive toolbar, sort indicators, and pagination."""
+ s = f"#gd-tbl-{uid}"
+ return f""""""
+
+
+# ---------------------------------------------------------------------------
+# Inline JS — reads from the companion asset file or embeds inline
+# ---------------------------------------------------------------------------
+
+_JS_ASSET_NAME = "tbl-explorer.js"
+
+
+def _get_js_source() -> str:
+ """Load the tbl-explorer.js source from the assets directory."""
+ asset_path = Path(__file__).parent / "assets" / _JS_ASSET_NAME
+ if asset_path.exists():
+ return asset_path.read_text(encoding="utf-8")
+ raise FileNotFoundError(
+ f"Cannot find {_JS_ASSET_NAME} at {asset_path}. "
+ "Ensure the great_docs/assets/ directory contains the file."
+ )
+
+
+# Cache the JS source after first load
+_js_cache: str | None = None
+
+
+def _get_js_inline() -> str:
+ """Return the JS source, cached after first load."""
+ global _js_cache # noqa: PLW0603
+ if _js_cache is None:
+ _js_cache = _get_js_source()
+ return _js_cache
+
+
+# ---------------------------------------------------------------------------
+# Main entry point
+# ---------------------------------------------------------------------------
+
+# Threshold for emitting a size warning (rows)
+_LARGE_DATASET_THRESHOLD = 10_000
+
+
+def tbl_explorer(
+ data: Any,
+ columns: list[str] | None = None,
+ show_row_numbers: bool = True,
+ show_dtypes: bool = True,
+ show_dimensions: bool = True,
+ max_col_width: int = 250,
+ min_tbl_width: int = 500,
+ caption: str | None = None,
+ highlight_missing: bool = True,
+ page_size: int = 20,
+ sortable: bool = True,
+ filterable: bool = True,
+ column_toggle: bool = True,
+ copyable: bool = True,
+ downloadable: bool = True,
+ resizable: bool = False,
+ sticky_header: bool = True,
+ search_highlight: bool = True,
+ id: str | None = None,
+) -> TblExplorer:
+ """
+ Generate an interactive table explorer from almost any tabular data source.
+
+ The `tbl_explorer()` function creates a self-contained, interactive HTML table widget from
+ tabular data. Pass it a Polars DataFrame, a Pandas DataFrame, a PyArrow Table, a file path to a
+ CSV / TSV / JSONL / Parquet / Feather file, a column-oriented dictionary, or a list of row
+ dictionaries—and get back an interactive table with sorting, token-based filtering, pagination,
+ column toggling, copy-to-clipboard, and CSV download.
+
+ The output uses **progressive enhancement**: the initial HTML contains a fully rendered static
+ table (the first page of data) that is readable without JavaScript. When JavaScript is available,
+ the static table is enhanced with interactive controls. All row data is embedded as inline JSON
+ within the HTML, so the widget is completely self-contained with no external dependencies.
+
+ The interactive toolbar includes a token-based filter bar (with type-aware operators for string,
+ numeric, and boolean columns), a column visibility dropdown, copy and download buttons, and a
+ reset control. Column headers are clickable for single-column sorting, and shift-click enables
+ multi-column sorting. Pagination is enabled by default at 20 rows per page.
+
+ The output is a :class:`TblExplorer` object with `_repr_html_()` support, so it displays
+ automatically in Jupyter notebooks and Quarto code cells. All CSS is scoped to a unique id, and
+ the table includes full dark-mode support.
+
+ Parameters
+ ----------
+ data
+ The table to explore. This can be a Polars DataFrame, a Pandas DataFrame, a PyArrow Table,
+ a file path (as a string or `pathlib.Path` object), a column-oriented dictionary, or a list
+ of row dictionaries. When providing a file path, the extension determines the loader:
+ `.csv`, `.tsv`, `.jsonl` (or `.ndjson`), `.parquet`, `.feather`, and `.arrow` (Arrow IPC)
+ are all supported. Read the *Supported Input Data Types* section for details on each
+ accepted format.
+ columns
+ The columns to display in the explorer, by default `None` (all columns are shown). This
+ can be a list of column name strings. If any name does not match a column in the table, a
+ `KeyError` is raised. This is useful for focusing on a subset of a wide dataset.
+ show_row_numbers
+ Should row numbers be shown? The numbers appear in a narrow gutter column on the left side
+ of the table, separated from the data columns by a subtle blue vertical line. By default,
+ this is set to `True`.
+ show_dtypes
+ Should data type labels be displayed beneath each column name? The labels use short
+ abbreviations (e.g., `i64` for 64-bit integer, `str` for string, `f64` for 64-bit float).
+ By default, this is set to `True`.
+ show_dimensions
+ Should the header banner be shown? The banner displays a colored badge identifying the data
+ source type alongside row and column counts in labeled pill badges. By default, this is set
+ to `True`.
+ max_col_width
+ The maximum width of any single column in pixels. Column widths are computed automatically
+ to fit their content up to this ceiling, beyond which cell text is truncated with an
+ ellipsis. The default value is `250` pixels.
+ min_tbl_width
+ The minimum total width of the table in pixels. If the sum of the computed column widths is
+ less than this value, columns are proportionally widened to fill the available space. The
+ default value is `500` pixels.
+ caption
+ An optional caption string displayed below the header banner and above the column headers.
+ Useful for labeling an explorer with a dataset name or description. By default, no caption
+ is shown.
+ highlight_missing
+ Should missing values (`None`, `NaN`, `NA`) be highlighted? When `True` (the default),
+ missing cells are displayed in red text on a light red background so they stand out at a
+ glance.
+ page_size
+ The number of rows to display per page. The default value is `20`. Set to `0` to disable
+ pagination entirely and display all rows at once. The pagination bar shows the current range
+ (e.g., "Showing 1–20 of 150 rows") and page navigation buttons.
+ sortable
+ Should column sorting be enabled? When `True` (the default), clicking a column header
+ cycles through ascending → descending → unsorted. Hold **Shift** and click to add
+ multi-column sorting. Sort indicators appear as SVG arrows next to column names.
+ filterable
+ Should the token-based filter bar be shown? When `True` (the default), a filter bar appears
+ in the toolbar with a **+** button to add structured filters. Each filter is a token with a
+ column, operator, and optional value. Available operators depend on the column type:
+ string columns offer contains, starts with, ends with, etc.; numeric columns offer
+ comparison operators including between; boolean columns offer is true/is false. Filters
+ support case-sensitive matching via an **Aa** toggle.
+ column_toggle
+ Should the column visibility dropdown be shown? When `True` (the default), a **Columns**
+ button appears in the toolbar. Clicking it opens a dropdown with checkboxes for each
+ column. At least one column must remain visible.
+ copyable
+ Should the copy-to-clipboard button be shown? When `True` (the default), a clipboard icon
+ appears in the toolbar. Clicking it copies the currently visible page of data as
+ tab-separated values. On success, the icon briefly changes to a green checkmark.
+ downloadable
+ Should the CSV download button be shown? When `True` (the default), a download icon appears
+ in the toolbar. Clicking it downloads the full filtered dataset (all pages) as a CSV file.
+ resizable
+ Should column drag-resize be enabled? Reserved for future use. Currently has no effect.
+ The default value is `False`.
+ sticky_header
+ Should column headers remain visible when scrolling vertically? When `True` (the default),
+ the header row sticks to the top of the table container as the user scrolls through rows.
+ search_highlight
+ Should matching cell text be highlighted when a "contains" filter is active? When `True`
+ (the default), text matching the filter value is highlighted with a colored background. Set
+ to `False` to disable highlighting.
+ id
+ An HTML `id` attribute for the outer `
` container. If `None` (the default), a unique
+ ID is auto-generated using `secrets.token_hex(4)`. Providing your own ID is useful when you
+ need to target the table with custom CSS or JavaScript.
+
+ Returns
+ -------
+ TblExplorer
+ A rendered interactive table object. The object has `_repr_html_()` for automatic notebook
+ display.
+
+ Supported Input Data Types
+ --------------------------
+ The `data` parameter accepts any of the following:
+
+ - **Polars DataFrame** — displays a blue *Polars* badge
+ - **Pandas DataFrame** — displays a dark purple *Pandas* badge
+ - **PyArrow Table** — displays an indigo *Arrow* badge
+ - **CSV file** (`.csv`) — loaded automatically; displays a cream *CSV* badge
+ - **TSV file** (`.tsv`) — loaded automatically; displays a green *TSV* badge
+ - **JSONL file** (`.jsonl` or `.ndjson`) — loaded line-by-line; displays a blue *JSONL*
+ badge
+ - **Parquet file** (`.parquet`) — requires `polars`, `pandas`, or `pyarrow`; displays
+ a purple *Parquet* badge
+ - **Feather / Arrow IPC file** (`.feather` or `.arrow`) — requires `polars`, `pandas`,
+ or `pyarrow`; displays an orange *Feather* badge
+ - **Dictionary** (column-oriented, `dict[str, list]`) — displays a gray *Table* badge
+ - **List of dictionaries** (row-oriented, `list[dict]`) — displays a gray *Table* badge
+
+ For file-based inputs, pass a string or `pathlib.Path` object. The file extension is used to
+ determine the format. Polars is preferred for loading when available; Pandas and PyArrow are
+ used as fallbacks.
+
+ Examples
+ --------
+ The simplest way to explore a table is to pass a Python dictionary:
+
+ ```{python}
+ from great_docs import tbl_explorer
+
+ tbl_explorer({
+ "city": ["Tokyo", "Paris", "New York", "London", "Sydney"],
+ "population": [13960000, 2161000, 8336000, 8982000, 5312000],
+ "country": ["Japan", "France", "USA", "UK", "Australia"],
+ })
+ ```
+
+ You can also pass a Polars DataFrame:
+
+ ```{python}
+ import polars as pl
+
+ df = pl.DataFrame({
+ "product": ["Widget", "Gadget", "Gizmo", "Doohickey", "Thingamajig"],
+ "category": ["Electronics", "Tools", "Kitchen", "Garden", "Office"],
+ "price": [29.99, 49.50, 12.00, 8.75, 199.99],
+ "in_stock": [True, False, True, True, False],
+ })
+
+ tbl_explorer(df)
+ ```
+
+ Load a CSV file and show only specific columns:
+
+ ```python
+ tbl_explorer("data/sales.csv", columns=["product", "revenue", "units"])
+ ```
+
+ Disable pagination to show all rows at once:
+
+ ```python
+ tbl_explorer(df, page_size=0)
+ ```
+
+ Create a minimal, sort-only table with no toolbar controls:
+
+ ```python
+ tbl_explorer(
+ df,
+ sortable=True,
+ filterable=False,
+ column_toggle=False,
+ copyable=False,
+ downloadable=False,
+ page_size=0,
+ )
+ ```
+ """
+ import warnings
+
+ # 1. Normalize input data
+ col_names, col_dtypes, all_rows, total_rows, tbl_type = _normalize_data(data)
+ original_n_cols = len(col_names)
+
+ if total_rows > _LARGE_DATASET_THRESHOLD:
+ warnings.warn(
+ f"tbl_explorer() is embedding {total_rows:,} rows as inline JSON. "
+ f"For datasets larger than {_LARGE_DATASET_THRESHOLD:,} rows, consider "
+ f"using tbl_preview() with n_head/n_tail instead.",
+ UserWarning,
+ stacklevel=2,
+ )
+
+ # 2. Apply column subset
+ col_names, col_dtypes, all_rows = _apply_column_subset(col_names, col_dtypes, all_rows, columns)
+
+ # 3. Detect alignments
+ alignments = _detect_alignments(col_dtypes)
+
+ # 4. Build the first page of rows for the static fallback table
+ if page_size > 0 and total_rows > page_size:
+ fallback_rows = all_rows[:page_size]
+ fallback_row_numbers = list(range(page_size))
+ is_full = False
+ n_head_fallback = page_size
+ else:
+ fallback_rows = all_rows
+ fallback_row_numbers = list(range(total_rows))
+ is_full = True
+ n_head_fallback = total_rows
+
+ # 5. Compute column widths (based on fallback rows for initial render)
+ col_widths, rownum_width = _compute_col_widths(
+ col_names,
+ col_dtypes,
+ fallback_rows,
+ max_col_width,
+ min_tbl_width,
+ show_row_numbers,
+ fallback_row_numbers,
+ )
+
+ # 6. Generate unique ID
+ uid = id or secrets.token_hex(4)
+
+ total_cols = len(col_names) + (1 if show_row_numbers else 0)
+
+ # 7. Config dict for the JSON blob
+ config = {
+ "pageSize": page_size,
+ "sortable": sortable,
+ "filterable": filterable,
+ "columnToggle": column_toggle,
+ "copyable": copyable,
+ "downloadable": downloadable,
+ "resizable": resizable,
+ "stickyHeader": sticky_header,
+ "searchHighlight": search_highlight,
+ "showRowNumbers": show_row_numbers,
+ "showDtypes": show_dtypes,
+ "highlightMissing": highlight_missing,
+ }
+
+ # 8. Serialize full data as JSON
+ data_json = _serialize_data_blob(
+ col_names, col_dtypes, alignments, all_rows, total_rows, tbl_type, config
+ )
+
+ # 9. Render static fallback HTML (same structure as tbl_preview)
+ base_css = _render_scoped_css(uid)
+ explorer_css = _render_explorer_css(uid)
+
+ header = _render_header_html(
+ uid, tbl_type, total_rows, original_n_cols, caption, show_dimensions, total_cols
+ )
+ colgroup = _render_colgroup_html(col_widths, rownum_width, show_row_numbers)
+ column_labels = _render_column_labels_html(
+ col_names, col_dtypes, alignments, show_dtypes, show_row_numbers
+ )
+ body = _render_body_html(
+ fallback_rows,
+ fallback_row_numbers,
+ col_names,
+ alignments,
+ col_widths,
+ n_head_fallback,
+ is_full,
+ show_row_numbers,
+ highlight_missing,
+ )
+
+ # 10. Load JS
+ js_source = _get_js_inline()
+
+ # 11. Assemble
+ html = (
+ f'
\n'
+ f"{base_css}\n"
+ f"{explorer_css}\n"
+ f'\n'
+ f'
\n"
+ f"\n"
+ f"
"
+ )
+
+ return TblExplorer(html)
diff --git a/great_docs/assets/_extensions/tbl-explorer/_extension.yml b/great_docs/assets/_extensions/tbl-explorer/_extension.yml
new file mode 100644
index 0000000..a9fb86d
--- /dev/null
+++ b/great_docs/assets/_extensions/tbl-explorer/_extension.yml
@@ -0,0 +1,7 @@
+title: Table Explorer
+author: Great Docs
+version: 1.0.0
+quarto-required: ">=1.3.0"
+contributes:
+ shortcodes:
+ - tbl-explorer.lua
diff --git a/great_docs/assets/_extensions/tbl-explorer/_tbl_explorer_shortcode.py b/great_docs/assets/_extensions/tbl-explorer/_tbl_explorer_shortcode.py
new file mode 100644
index 0000000..9203d61
--- /dev/null
+++ b/great_docs/assets/_extensions/tbl-explorer/_tbl_explorer_shortcode.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+"""CLI helper for the tbl-explorer Quarto shortcode."""
+
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import sys
+from pathlib import Path
+
+
+def _load_tbl_explorer():
+ """Import tbl_explorer without triggering great_docs.__init__."""
+ try:
+ from great_docs._tbl_explorer import tbl_explorer
+
+ return tbl_explorer
+ except (ImportError, ModuleNotFoundError):
+ here = Path(__file__).resolve().parent
+ p = here
+ while p != p.parent:
+ candidate = p / "great_docs" / "_tbl_explorer.py"
+ if candidate.exists():
+ spec = importlib.util.spec_from_file_location("_tbl_explorer", candidate)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod.tbl_explorer
+ p = p.parent
+ raise ImportError("Cannot find _tbl_explorer.py")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Render an interactive table explorer.")
+ parser.add_argument(
+ "file", help="Path to data file (CSV, TSV, JSONL, Parquet, Feather, Arrow IPC)"
+ )
+ parser.add_argument("--columns", default=None, help="Comma-separated column names")
+ parser.add_argument("--page_size", type=int, default=20)
+ parser.add_argument("--sortable", default="true")
+ parser.add_argument("--filterable", default="true")
+ parser.add_argument("--column_toggle", default="true")
+ parser.add_argument("--copyable", default="true")
+ parser.add_argument("--downloadable", default="true")
+ parser.add_argument("--resizable", default="false")
+ parser.add_argument("--sticky_header", default="true")
+ parser.add_argument("--search_highlight", default="true")
+ parser.add_argument("--show_row_numbers", default="true")
+ parser.add_argument("--show_dtypes", default="true")
+ parser.add_argument("--show_dimensions", default="true")
+ parser.add_argument("--max_col_width", type=int, default=250)
+ parser.add_argument("--min_tbl_width", type=int, default=500)
+ parser.add_argument("--caption", default=None)
+ parser.add_argument("--highlight_missing", default="true")
+ args = parser.parse_args()
+
+ tbl_explorer = _load_tbl_explorer()
+
+ columns = [c.strip() for c in args.columns.split(",")] if args.columns else None
+
+ def _to_bool(s: str) -> bool:
+ return s.lower() in ("true", "1", "yes")
+
+ result = tbl_explorer(
+ data=args.file,
+ columns=columns,
+ page_size=args.page_size,
+ sortable=_to_bool(args.sortable),
+ filterable=_to_bool(args.filterable),
+ column_toggle=_to_bool(args.column_toggle),
+ copyable=_to_bool(args.copyable),
+ downloadable=_to_bool(args.downloadable),
+ resizable=_to_bool(args.resizable),
+ sticky_header=_to_bool(args.sticky_header),
+ search_highlight=_to_bool(args.search_highlight),
+ show_row_numbers=_to_bool(args.show_row_numbers),
+ show_dtypes=_to_bool(args.show_dtypes),
+ show_dimensions=_to_bool(args.show_dimensions),
+ max_col_width=args.max_col_width,
+ min_tbl_width=args.min_tbl_width,
+ caption=args.caption,
+ highlight_missing=_to_bool(args.highlight_missing),
+ )
+
+ sys.stdout.write(result.as_html())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/great_docs/assets/_extensions/tbl-explorer/tbl-explorer.lua b/great_docs/assets/_extensions/tbl-explorer/tbl-explorer.lua
new file mode 100644
index 0000000..89b9dff
--- /dev/null
+++ b/great_docs/assets/_extensions/tbl-explorer/tbl-explorer.lua
@@ -0,0 +1,94 @@
+-- tbl-explorer.lua — Quarto shortcode for interactive table explorer
+--
+-- Usage in .qmd files:
+--
+-- {{< tbl-explorer file="data/example.csv" >}}
+-- {{< tbl-explorer file="data.csv" page_size="25" sortable="true" >}}
+-- {{< tbl-explorer file="data.csv" column_toggle="false" downloadable="false" >}}
+--
+-- Calls the companion _tbl_explorer_shortcode.py script, which imports
+-- tbl_explorer() from great_docs._tbl_explorer and prints the resulting
+-- HTML to stdout.
+
+local function kwarg_str(kwargs, key)
+ local raw = kwargs[key]
+ if raw == nil then return "" end
+ local s = pandoc.utils.stringify(raw)
+ return s or ""
+end
+
+return {
+ ["tbl-explorer"] = function(args, kwargs)
+ -- File path can be a positional arg or named kwarg
+ local file = kwarg_str(kwargs, "file")
+ if file == "" and #args > 0 then
+ file = pandoc.utils.stringify(args[1])
+ end
+
+ if file == "" then
+ return pandoc.RawBlock(
+ "html",
+ ""
+ )
+ end
+
+ -- Locate the helper script (lives alongside this .lua file)
+ local script_dir = debug.getinfo(1, "S").source:match("@?(.*/)") or "./"
+ local helper = script_dir .. "_tbl_explorer_shortcode.py"
+
+ -- Resolve relative file paths against the Quarto project root
+ if file:sub(1, 1) ~= "/" then
+ local project_root = script_dir .. "../../"
+ file = project_root .. file
+ end
+
+ -- Build CLI arguments
+ local cmd_args = { "python3", helper, file }
+
+ -- Forward optional keyword arguments
+ local forwarded = {
+ "columns", "page_size", "sortable", "filterable",
+ "column_toggle", "copyable", "downloadable", "resizable",
+ "sticky_header", "search_highlight",
+ "show_row_numbers", "show_dtypes", "show_dimensions",
+ "max_col_width", "min_tbl_width", "caption",
+ "highlight_missing",
+ }
+ for _, key in ipairs(forwarded) do
+ local val = kwarg_str(kwargs, key)
+ if val ~= "" then
+ table.insert(cmd_args, "--" .. key)
+ table.insert(cmd_args, val)
+ end
+ end
+
+ -- Build shell command (quote each argument)
+ local parts = {}
+ for _, arg in ipairs(cmd_args) do
+ local escaped = arg:gsub("'", "'\\''")
+ table.insert(parts, "'" .. escaped .. "'")
+ end
+ local cmd = table.concat(parts, " ") .. " 2>&1"
+
+ local handle = io.popen(cmd)
+ if not handle then
+ return pandoc.RawBlock(
+ "html",
+ ""
+ )
+ end
+
+ local result = handle:read("*a")
+ local success = handle:close()
+
+ if not success or result == "" then
+ return pandoc.RawBlock(
+ "html",
+ ""
+ )
+ end
+
+ return pandoc.RawBlock("html", result)
+ end
+}
diff --git a/great_docs/assets/tbl-explorer.js b/great_docs/assets/tbl-explorer.js
new file mode 100644
index 0000000..fc8fb9a
--- /dev/null
+++ b/great_docs/assets/tbl-explorer.js
@@ -0,0 +1,1139 @@
+/**
+ * Great Docs Table Explorer — Interactive table enhancement
+ *
+ * Progressive enhancement for .gd-tbl-explorer tables.
+ * Zero external dependencies. Works in all modern browsers.
+ *
+ * Features: sorting, filtering, pagination, column toggling,
+ * copy-to-clipboard, CSV download, search highlighting, sticky header.
+ */
+(function () {
+ "use strict";
+
+ var DEBOUNCE_MS = 200;
+ var COPIED_MS = 2000;
+ var PAGE_WINDOW = 2;
+
+ // SVG sort indicator icons (all same viewBox for consistent width)
+ var SORT_W = 10, SORT_H = 14;
+ var SVG_SORT_NONE = '
' +
+ ' ';
+ var SVG_SORT_ASC = '
' +
+ ' ';
+ var SVG_SORT_DESC = '
' +
+ ' ';
+
+ function setSortIcon(iconEl, dir) {
+ if (dir === "asc") iconEl.innerHTML = SVG_SORT_ASC;
+ else if (dir === "desc") iconEl.innerHTML = SVG_SORT_DESC;
+ else iconEl.innerHTML = SVG_SORT_NONE;
+ }
+
+ // SVG toolbar button icons (all 14×14, viewBox 0 0 24 24, stroke style)
+ var ICON_ATTRS = ' width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
+ var SVG_COPY = '
';
+ var SVG_CHECK = '
';
+ var SVG_DOWNLOAD = '
';
+ var SVG_RESET = '
';
+
+ /** Create an icon button with a tooltip that appears below, anchored left. */
+ function makeIconBtn(svgHtml, ariaLabel, tooltipText) {
+ var wrap = document.createElement("span");
+ wrap.className = "gd-tbl-btn-wrap";
+ var btn = document.createElement("button");
+ btn.className = "gd-tbl-btn gd-tbl-btn-icon";
+ btn.innerHTML = svgHtml;
+ btn.setAttribute("aria-label", ariaLabel);
+ var tip = document.createElement("span");
+ tip.className = "gd-tbl-tooltip";
+ tip.textContent = tooltipText;
+ wrap.appendChild(btn);
+ wrap.appendChild(tip);
+ return { wrap: wrap, btn: btn, tip: tip };
+ }
+
+ // ── State ──────────────────────────────────────────────────
+
+ function TableState(id, data) {
+ this.id = id;
+ this.columns = data.columns;
+ this.allRows = data.rows;
+ this.totalRows = data.totalRows;
+ this.tableType = data.tableType;
+ this.cfg = data.config || {};
+
+ this.filteredRows = this.allRows.slice();
+ this.sortCols = [];
+ this.filterTokens = []; // [{colIdx, op, value, id}]
+ this.filterQuery = ""; // kept for search highlight compat
+ this.visibleCols = this.columns.map(function (_, i) { return i; });
+ this.currentPage = 1;
+ this.pageSize = this.cfg.pageSize || 20;
+ this._nextFilterId = 1;
+ }
+
+ // ── Filter operator definitions ────────────────────────────
+
+ var NUMERIC_DTYPES = {
+ i8:1,i16:1,i32:1,i64:1,u8:1,u16:1,u32:1,u64:1,
+ f16:1,f32:1,f64:1,dec:1
+ };
+
+ function isNumeric(dtype) { return !!NUMERIC_DTYPES[dtype]; }
+ function isBool(dtype) { return dtype === "bool"; }
+
+ // {key, label, needsValue, appliesTo(dtype)}
+ var FILTER_OPS = [
+ // String ops
+ {key:"contains", label:"contains", needsValue:true, appliesTo:function(d){return !isNumeric(d) && !isBool(d);}},
+ {key:"not_contains",label:"doesn\u2019t contain",needsValue:true,appliesTo:function(d){return !isNumeric(d) && !isBool(d);}},
+ {key:"starts_with", label:"starts with", needsValue:true, appliesTo:function(d){return !isNumeric(d) && !isBool(d);}},
+ {key:"ends_with", label:"ends with", needsValue:true, appliesTo:function(d){return !isNumeric(d) && !isBool(d);}},
+ {key:"eq_str", label:"equals", needsValue:true, appliesTo:function(d){return !isNumeric(d) && !isBool(d);}},
+ {key:"is_empty", label:"is empty", needsValue:false, appliesTo:function(d){return !isNumeric(d) && !isBool(d);}},
+ {key:"not_empty", label:"is not empty", needsValue:false, appliesTo:function(d){return !isNumeric(d) && !isBool(d);}},
+ // Numeric ops
+ {key:"eq", label:"\u003D", needsValue:true, appliesTo:isNumeric},
+ {key:"neq", label:"\u2260", needsValue:true, appliesTo:isNumeric},
+ {key:"lt", label:"\u003C", needsValue:true, appliesTo:isNumeric},
+ {key:"lte", label:"\u2264", needsValue:true, appliesTo:isNumeric},
+ {key:"gt", label:"\u003E", needsValue:true, appliesTo:isNumeric},
+ {key:"gte", label:"\u2265", needsValue:true, appliesTo:isNumeric},
+ {key:"between",label:"between",needsValue:"two",appliesTo:isNumeric},
+ // Bool ops
+ {key:"is_true", label:"is true", needsValue:false, appliesTo:isBool},
+ {key:"is_false", label:"is false", needsValue:false, appliesTo:isBool},
+ // Universal ops
+ {key:"is_null", label:"is null", needsValue:false, appliesTo:function(){return true;}},
+ {key:"is_not_null", label:"is not null", needsValue:false, appliesTo:function(){return true;}}
+ ];
+
+ function getOpsForDtype(dtype) {
+ var ops = [];
+ for (var i = 0; i < FILTER_OPS.length; i++) {
+ if (FILTER_OPS[i].appliesTo(dtype)) ops.push(FILTER_OPS[i]);
+ }
+ return ops;
+ }
+
+ function findOp(key) {
+ for (var i = 0; i < FILTER_OPS.length; i++) {
+ if (FILTER_OPS[i].key === key) return FILTER_OPS[i];
+ }
+ return null;
+ }
+
+ // ── Init ───────────────────────────────────────────────────
+
+ function init() {
+ var containers = document.querySelectorAll(".gd-tbl-explorer");
+ for (var i = 0; i < containers.length; i++) {
+ enhance(containers[i]);
+ }
+ }
+
+ function enhance(el) {
+ // Guard: skip if already enhanced (multiple inline scripts on the same page)
+ if (el.dataset.gdEnhanced) return;
+ el.dataset.gdEnhanced = "1";
+
+ var jsonEl = el.querySelector("script.gd-tbl-data");
+ if (!jsonEl) return;
+ var data;
+ try {
+ data = JSON.parse(jsonEl.textContent);
+ } catch (e) {
+ return;
+ }
+ var state = new TableState(el.id, data);
+
+ if (state.cfg.filterable || state.cfg.columnToggle ||
+ state.cfg.copyable || state.cfg.downloadable) {
+ injectToolbar(el, state);
+ }
+
+ if (state.cfg.sortable) {
+ makeSortable(el, state);
+ }
+
+ applyState(el, state);
+ }
+
+ // ── Toolbar ────────────────────────────────────────────────
+
+ function injectToolbar(el, state) {
+ var bar = document.createElement("div");
+ bar.className = "gd-tbl-toolbar";
+ bar.setAttribute("role", "toolbar");
+ bar.setAttribute("aria-label", "Table controls");
+
+ if (state.cfg.filterable) {
+ var filterBar = document.createElement("div");
+ filterBar.className = "gd-tbl-filter-bar";
+
+ var tokenArea = document.createElement("span");
+ tokenArea.className = "gd-tbl-filter-tokens";
+ filterBar.appendChild(tokenArea);
+
+ var addBtn = document.createElement("button");
+ addBtn.className = "gd-tbl-btn gd-tbl-btn-icon gd-tbl-filter-add";
+ addBtn.innerHTML = '
';
+ addBtn.setAttribute("aria-label", "Add filter");
+ addBtn.addEventListener("click", function (e) {
+ e.stopPropagation();
+ startFilterWizard(el, state, filterBar, tokenArea);
+ });
+ filterBar.appendChild(addBtn);
+
+ bar.appendChild(filterBar);
+ }
+
+ if (state.cfg.columnToggle) {
+ bar.appendChild(buildColumnToggle(el, state));
+ }
+
+ if (state.cfg.copyable) {
+ var copy = makeIconBtn(SVG_COPY, "Copy table to clipboard", "Copy");
+ copy.btn.addEventListener("click", function () {
+ handleCopy(state, false, copy.btn);
+ });
+ bar.appendChild(copy.wrap);
+ }
+
+ if (state.cfg.downloadable) {
+ var dl = makeIconBtn(SVG_DOWNLOAD, "Download as CSV", "Download");
+ dl.btn.addEventListener("click", function () {
+ handleDownload(state);
+ });
+ bar.appendChild(dl.wrap);
+ }
+
+ // Reset button (always present if toolbar exists)
+ var reset = makeIconBtn(SVG_RESET, "Reset all filters and sorting", "Reset");
+ reset.btn.addEventListener("click", function () {
+ handleReset(el, state);
+ });
+ bar.appendChild(reset.wrap);
+
+ // Insert toolbar before the scroll wrapper (or table)
+ var scrollWrap = el.querySelector(".gd-tbl-scroll");
+ var insertRef = scrollWrap || el.querySelector("table");
+ if (insertRef) {
+ el.insertBefore(bar, insertRef);
+ }
+ }
+
+ // ── Filter Wizard (multi-step: column → operator → value) ──
+
+ function startFilterWizard(el, state, filterBar, tokenArea) {
+ // Remove any existing wizard
+ closeFilterWizard(filterBar);
+
+ var wizard = document.createElement("div");
+ wizard.className = "gd-tbl-filter-wizard";
+ wizard.addEventListener("click", function (e) { e.stopPropagation(); });
+
+ // Step 1: pick column
+ var heading = document.createElement("span");
+ heading.className = "gd-tbl-fw-label";
+ heading.textContent = "Column";
+ wizard.appendChild(heading);
+
+ var colList = document.createElement("div");
+ colList.className = "gd-tbl-fw-options";
+ state.columns.forEach(function (col, idx) {
+ var btn = document.createElement("button");
+ btn.className = "gd-tbl-fw-option";
+ btn.textContent = col.name;
+ var dtypeTag = document.createElement("span");
+ dtypeTag.className = "gd-tbl-fw-dtype";
+ dtypeTag.textContent = col.dtype;
+ btn.appendChild(dtypeTag);
+ btn.addEventListener("click", function () {
+ showOpStep(wizard, el, state, filterBar, tokenArea, idx);
+ });
+ colList.appendChild(btn);
+ });
+ wizard.appendChild(colList);
+
+ filterBar.appendChild(wizard);
+
+ // Close on outside click
+ function onDocClick() {
+ closeFilterWizard(filterBar);
+ document.removeEventListener("click", onDocClick);
+ }
+ setTimeout(function () {
+ document.addEventListener("click", onDocClick);
+ }, 0);
+ }
+
+ function showOpStep(wizard, el, state, filterBar, tokenArea, colIdx) {
+ var col = state.columns[colIdx];
+ var ops = getOpsForDtype(col.dtype);
+
+ // Clear wizard content
+ wizard.innerHTML = "";
+ var heading = document.createElement("span");
+ heading.className = "gd-tbl-fw-label";
+ heading.textContent = col.name;
+ wizard.appendChild(heading);
+
+ var opList = document.createElement("div");
+ opList.className = "gd-tbl-fw-options";
+ ops.forEach(function (op) {
+ var btn = document.createElement("button");
+ btn.className = "gd-tbl-fw-option";
+ btn.textContent = op.label;
+ btn.addEventListener("click", function () {
+ if (!op.needsValue) {
+ // No value needed — commit immediately
+ commitFilterToken(el, state, tokenArea, colIdx, op.key, null, null);
+ closeFilterWizard(filterBar);
+ } else if (op.needsValue === "two") {
+ showBetweenValueStep(wizard, el, state, filterBar, tokenArea, colIdx, op.key);
+ } else {
+ showValueStep(wizard, el, state, filterBar, tokenArea, colIdx, op.key);
+ }
+ });
+ opList.appendChild(btn);
+ });
+ wizard.appendChild(opList);
+ }
+
+ function showValueStep(wizard, el, state, filterBar, tokenArea, colIdx, opKey) {
+ var col = state.columns[colIdx];
+ var op = findOp(opKey);
+ var isText = !isNumeric(col.dtype) && !isBool(col.dtype);
+ var caseSensitive = false;
+ wizard.innerHTML = "";
+
+ var heading = document.createElement("span");
+ heading.className = "gd-tbl-fw-label";
+ heading.textContent = col.name + " " + op.label;
+ wizard.appendChild(heading);
+
+ var inputRow = document.createElement("div");
+ inputRow.className = "gd-tbl-fw-input-row";
+
+ var input = document.createElement("input");
+ input.type = isNumeric(col.dtype) ? "number" : "text";
+ input.className = "gd-tbl-fw-input";
+ input.placeholder = "Enter value\u2026";
+ input.setAttribute("aria-label", "Filter value");
+ inputRow.appendChild(input);
+
+ // Case-sensitivity toggle for text columns
+ var caseBtn = null;
+ if (isText) {
+ caseBtn = document.createElement("button");
+ caseBtn.className = "gd-tbl-fw-case";
+ caseBtn.textContent = "Aa";
+ caseBtn.setAttribute("aria-label", "Toggle case sensitivity");
+ caseBtn.title = "Case insensitive";
+ caseBtn.addEventListener("click", function () {
+ caseSensitive = !caseSensitive;
+ caseBtn.classList.toggle("active", caseSensitive);
+ caseBtn.title = caseSensitive ? "Case sensitive" : "Case insensitive";
+ });
+ inputRow.appendChild(caseBtn);
+ }
+ wizard.appendChild(inputRow);
+
+ var commitBtn = document.createElement("button");
+ commitBtn.className = "gd-tbl-btn gd-tbl-fw-commit";
+ commitBtn.textContent = "Apply";
+ commitBtn.addEventListener("click", function () {
+ var val = input.value.trim();
+ if (!val) return;
+ commitFilterToken(el, state, tokenArea, colIdx, opKey, val, null, caseSensitive);
+ closeFilterWizard(filterBar);
+ });
+ wizard.appendChild(commitBtn);
+
+ input.addEventListener("keydown", function (e) {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commitBtn.click();
+ }
+ if (e.key === "Escape") {
+ closeFilterWizard(filterBar);
+ }
+ });
+ setTimeout(function () { input.focus(); }, 0);
+ }
+
+ function showBetweenValueStep(wizard, el, state, filterBar, tokenArea, colIdx, opKey) {
+ var col = state.columns[colIdx];
+ wizard.innerHTML = "";
+
+ var heading = document.createElement("span");
+ heading.className = "gd-tbl-fw-label";
+ heading.textContent = col.name + " between";
+ wizard.appendChild(heading);
+
+ var row = document.createElement("span");
+ row.className = "gd-tbl-fw-between";
+
+ var inputLo = document.createElement("input");
+ inputLo.type = "number";
+ inputLo.className = "gd-tbl-fw-input";
+ inputLo.placeholder = "min";
+ inputLo.setAttribute("aria-label", "Minimum value");
+ row.appendChild(inputLo);
+
+ var sep = document.createElement("span");
+ sep.textContent = " and ";
+ sep.className = "gd-tbl-fw-sep";
+ row.appendChild(sep);
+
+ var inputHi = document.createElement("input");
+ inputHi.type = "number";
+ inputHi.className = "gd-tbl-fw-input";
+ inputHi.placeholder = "max";
+ inputHi.setAttribute("aria-label", "Maximum value");
+ row.appendChild(inputHi);
+ wizard.appendChild(row);
+
+ var commitBtn = document.createElement("button");
+ commitBtn.className = "gd-tbl-btn gd-tbl-fw-commit";
+ commitBtn.textContent = "Apply";
+ commitBtn.addEventListener("click", function () {
+ var lo = inputLo.value.trim();
+ var hi = inputHi.value.trim();
+ if (!lo || !hi) return;
+ commitFilterToken(el, state, tokenArea, colIdx, opKey, lo, hi);
+ closeFilterWizard(filterBar);
+ });
+ wizard.appendChild(commitBtn);
+
+ function onKey(e) {
+ if (e.key === "Enter") { e.preventDefault(); commitBtn.click(); }
+ if (e.key === "Escape") closeFilterWizard(filterBar);
+ }
+ inputLo.addEventListener("keydown", onKey);
+ inputHi.addEventListener("keydown", onKey);
+ setTimeout(function () { inputLo.focus(); }, 0);
+ }
+
+ function commitFilterToken(el, state, tokenArea, colIdx, opKey, value, value2, caseSensitive) {
+ var token = {
+ id: state._nextFilterId++,
+ colIdx: colIdx,
+ op: opKey,
+ value: value,
+ value2: value2,
+ caseSensitive: !!caseSensitive
+ };
+ state.filterTokens.push(token);
+ renderFilterTokens(el, state, tokenArea);
+ state.currentPage = 1;
+ applyFilter(state);
+ applyState(el, state);
+ }
+
+ function removeFilterToken(el, state, tokenArea, tokenId) {
+ state.filterTokens = state.filterTokens.filter(function (t) { return t.id !== tokenId; });
+ renderFilterTokens(el, state, tokenArea);
+ state.currentPage = 1;
+ applyFilter(state);
+ applyState(el, state);
+ }
+
+ function renderFilterTokens(el, state, tokenArea) {
+ tokenArea.innerHTML = "";
+ for (var i = 0; i < state.filterTokens.length; i++) {
+ var t = state.filterTokens[i];
+ var col = state.columns[t.colIdx];
+ var op = findOp(t.op);
+ var pill = document.createElement("span");
+ pill.className = "gd-tbl-filter-token";
+
+ var label = col.name + " " + (op ? op.label : t.op);
+ if (t.value != null) {
+ if (t.value2 != null) {
+ label += " " + t.value + "\u2013" + t.value2;
+ } else {
+ var isText = !isNumeric(col.dtype);
+ label += " " + (isText ? "\u2018" + t.value + "\u2019" : t.value);
+ }
+ }
+ var text = document.createElement("span");
+ text.className = "gd-tbl-filter-token-text";
+ text.textContent = label;
+ pill.appendChild(text);
+
+ // Show case-sensitivity badge when active
+ if (t.caseSensitive) {
+ var caseBadge = document.createElement("span");
+ caseBadge.className = "gd-tbl-filter-token-case";
+ caseBadge.textContent = "Aa";
+ caseBadge.title = "Case sensitive";
+ pill.appendChild(caseBadge);
+ }
+
+ var closeBtn = document.createElement("button");
+ closeBtn.className = "gd-tbl-filter-token-x";
+ closeBtn.innerHTML = "\u00D7";
+ closeBtn.setAttribute("aria-label", "Remove filter: " + label);
+ (function (tid) {
+ closeBtn.addEventListener("click", function (e) {
+ e.stopPropagation();
+ removeFilterToken(el, state, tokenArea, tid);
+ });
+ })(t.id);
+ pill.appendChild(closeBtn);
+ tokenArea.appendChild(pill);
+ }
+ }
+
+ function closeFilterWizard(filterBar) {
+ var w = filterBar.querySelector(".gd-tbl-filter-wizard");
+ if (w && w.parentNode) w.parentNode.removeChild(w);
+ }
+
+ // ── Column Toggle ──────────────────────────────────────────
+
+ function buildColumnToggle(el, state) {
+ var wrap = document.createElement("span");
+ wrap.className = "gd-tbl-col-wrap gd-tbl-btn-wrap";
+
+ var btn = document.createElement("button");
+ btn.className = "gd-tbl-btn";
+ btn.setAttribute("aria-haspopup", "true");
+ btn.setAttribute("aria-expanded", "false");
+ updateColBtnLabel(btn, state);
+
+ var tip = document.createElement("span");
+ tip.className = "gd-tbl-tooltip";
+ tip.textContent = "Select Columns";
+
+ var menu = document.createElement("div");
+ menu.className = "gd-tbl-col-menu";
+ menu.setAttribute("role", "menu");
+ menu.setAttribute("aria-label", "Toggle columns");
+
+ state.columns.forEach(function (col, idx) {
+ var label = document.createElement("label");
+ label.className = "gd-tbl-col-option";
+ label.setAttribute("role", "menuitemcheckbox");
+ label.setAttribute("aria-checked", "true");
+ var cb = document.createElement("input");
+ cb.type = "checkbox";
+ cb.checked = true;
+ cb.dataset.colIdx = idx;
+ cb.addEventListener("change", function () {
+ if (cb.checked) {
+ if (state.visibleCols.indexOf(idx) === -1) {
+ state.visibleCols.push(idx);
+ state.visibleCols.sort(function (a, b) { return a - b; });
+ }
+ } else {
+ if (state.visibleCols.length <= 1) {
+ cb.checked = true;
+ return;
+ }
+ state.visibleCols = state.visibleCols.filter(function (c) { return c !== idx; });
+ }
+ label.setAttribute("aria-checked", String(cb.checked));
+ updateColBtnLabel(btn, state);
+ applyFilter(state);
+ applyState(el, state);
+ });
+ label.appendChild(cb);
+ label.appendChild(document.createTextNode(" " + col.name));
+ menu.appendChild(label);
+ });
+
+ btn.addEventListener("click", function (e) {
+ e.stopPropagation();
+ var open = menu.classList.toggle("open");
+ btn.setAttribute("aria-expanded", String(open));
+ });
+
+ // Close on outside click
+ document.addEventListener("click", function () {
+ menu.classList.remove("open");
+ btn.setAttribute("aria-expanded", "false");
+ });
+ menu.addEventListener("click", function (e) { e.stopPropagation(); });
+
+ // Close on Escape
+ wrap.addEventListener("keydown", function (e) {
+ if (e.key === "Escape") {
+ menu.classList.remove("open");
+ btn.setAttribute("aria-expanded", "false");
+ btn.focus();
+ }
+ });
+
+ wrap.appendChild(btn);
+ wrap.appendChild(tip);
+ wrap.appendChild(menu);
+ return wrap;
+ }
+
+ function updateColBtnLabel(btn, state) {
+ var total = state.columns.length;
+ var vis = state.visibleCols.length;
+ btn.textContent = vis < total ? "Columns (" + vis + "/" + total + ")" : "Columns";
+ }
+
+ // ── Sorting ────────────────────────────────────────────────
+
+ function makeSortable(el, state) {
+ var ths = el.querySelectorAll("th.gt_col_heading");
+ // Skip the row-number header (first th if showRowNumbers)
+ var offset = state.cfg.showRowNumbers ? 1 : 0;
+ for (var i = offset; i < ths.length; i++) {
+ (function (th, colIdx) {
+ th.classList.add("gd-tbl-sortable", "gd-tbl-sort-none");
+ var icon = document.createElement("span");
+ icon.className = "gd-tbl-sort-icon";
+ setSortIcon(icon, "none");
+ th.appendChild(icon);
+ th.setAttribute("aria-sort", "none");
+ th.setAttribute("tabindex", "0");
+ th.setAttribute("role", "columnheader button");
+
+ function doSort(additive) {
+ // Capture current direction before any clearing
+ var existing = findSortCol(state.sortCols, colIdx);
+ var prevDir = existing ? existing.dir : null;
+
+ if (!additive) {
+ clearSortClasses(el, offset);
+ state.sortCols = [];
+ } else if (existing) {
+ // Remove existing entry; we'll re-add with new direction below
+ state.sortCols = state.sortCols.filter(function (s) { return s.idx !== colIdx; });
+ }
+
+ if (!prevDir) {
+ // Was unsorted → ascending
+ state.sortCols.push({ idx: colIdx, dir: "asc" });
+ th.classList.remove("gd-tbl-sort-none", "gd-tbl-sort-desc");
+ th.classList.add("gd-tbl-sort-asc");
+ th.setAttribute("aria-sort", "ascending");
+ setSortIcon(icon, "asc");
+ } else if (prevDir === "asc") {
+ // Was ascending → descending
+ state.sortCols.push({ idx: colIdx, dir: "desc" });
+ th.classList.remove("gd-tbl-sort-none", "gd-tbl-sort-asc");
+ th.classList.add("gd-tbl-sort-desc");
+ th.setAttribute("aria-sort", "descending");
+ setSortIcon(icon, "desc");
+ } else {
+ // Was descending → unsorted
+ th.classList.remove("gd-tbl-sort-desc", "gd-tbl-sort-asc");
+ th.classList.add("gd-tbl-sort-none");
+ th.setAttribute("aria-sort", "none");
+ setSortIcon(icon, "none");
+ }
+ state.currentPage = 1;
+ applySort(state);
+ applyState(el, state);
+ }
+
+ th.addEventListener("click", function (e) {
+ doSort(e.shiftKey);
+ });
+ th.addEventListener("keydown", function (e) {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ doSort(e.shiftKey);
+ }
+ });
+ })(ths[i], i - offset);
+ }
+ }
+
+ function findSortCol(sortCols, idx) {
+ for (var i = 0; i < sortCols.length; i++) {
+ if (sortCols[i].idx === idx) return sortCols[i];
+ }
+ return null;
+ }
+
+ function clearSortClasses(el, offset) {
+ var ths = el.querySelectorAll("th.gt_col_heading");
+ for (var i = offset; i < ths.length; i++) {
+ ths[i].classList.remove("gd-tbl-sort-asc", "gd-tbl-sort-desc");
+ ths[i].classList.add("gd-tbl-sort-none");
+ ths[i].setAttribute("aria-sort", "none");
+ var iconEl = ths[i].querySelector(".gd-tbl-sort-icon");
+ if (iconEl) setSortIcon(iconEl, "none");
+ }
+ }
+
+ function applySort(state) {
+ if (state.sortCols.length === 0) {
+ // Re-apply filter from original order
+ applyFilter(state);
+ return;
+ }
+ state.filteredRows.sort(function (a, b) {
+ for (var i = 0; i < state.sortCols.length; i++) {
+ var sc = state.sortCols[i];
+ var cmp = compareValues(a[sc.idx], b[sc.idx], state.columns[sc.idx].dtype);
+ if (cmp !== 0) return sc.dir === "asc" ? cmp : -cmp;
+ }
+ return 0;
+ });
+ }
+
+ function compareValues(a, b, dtype) {
+ // Nulls always last
+ if (a == null && b == null) return 0;
+ if (a == null) return 1;
+ if (b == null) return -1;
+
+ var numericTypes = {
+ i8: 1, i16: 1, i32: 1, i64: 1, u8: 1, u16: 1, u32: 1, u64: 1,
+ f16: 1, f32: 1, f64: 1, dec: 1
+ };
+ if (numericTypes[dtype]) {
+ return (a - b) || 0;
+ }
+ if (dtype === "bool") {
+ return (a === b) ? 0 : (a ? 1 : -1);
+ }
+ if (dtype === "date" || dtype === "dtime") {
+ var da = new Date(a), db = new Date(b);
+ return da.getTime() - db.getTime();
+ }
+ // String: locale-aware comparison
+ return String(a).localeCompare(String(b));
+ }
+
+ // ── Filtering ──────────────────────────────────────────────
+
+ function applyFilter(state) {
+ if (state.filterTokens.length === 0) {
+ state.filteredRows = state.allRows.slice();
+ } else {
+ state.filteredRows = state.allRows.filter(function (row) {
+ for (var i = 0; i < state.filterTokens.length; i++) {
+ if (!evalToken(state.filterTokens[i], row)) return false;
+ }
+ return true;
+ });
+ }
+ // Re-apply sort after filter
+ if (state.sortCols.length > 0) {
+ applySort(state);
+ }
+ }
+
+ function evalToken(tok, row) {
+ var v = row[tok.colIdx];
+ switch (tok.op) {
+ // Null checks
+ case "is_null": return v == null;
+ case "is_not_null": return v != null;
+ // Bool
+ case "is_true": return v === true;
+ case "is_false": return v === false;
+ // String ops (case-sensitive when tok.caseSensitive is set)
+ case "contains": {
+ if (v == null) return false;
+ var sv = String(v), fv = tok.value;
+ if (!tok.caseSensitive) { sv = sv.toLowerCase(); fv = fv.toLowerCase(); }
+ return sv.indexOf(fv) !== -1;
+ }
+ case "not_contains": {
+ if (v == null) return false;
+ var sv = String(v), fv = tok.value;
+ if (!tok.caseSensitive) { sv = sv.toLowerCase(); fv = fv.toLowerCase(); }
+ return sv.indexOf(fv) === -1;
+ }
+ case "starts_with": {
+ if (v == null) return false;
+ var sv = String(v), fv = tok.value;
+ if (!tok.caseSensitive) { sv = sv.toLowerCase(); fv = fv.toLowerCase(); }
+ return sv.indexOf(fv) === 0;
+ }
+ case "ends_with": {
+ if (v == null) return false;
+ var sv = String(v), fv = tok.value;
+ if (!tok.caseSensitive) { sv = sv.toLowerCase(); fv = fv.toLowerCase(); }
+ return sv.length >= fv.length && sv.lastIndexOf(fv) === sv.length - fv.length;
+ }
+ case "eq_str": {
+ if (v == null) return false;
+ var sv = String(v), fv = tok.value;
+ if (!tok.caseSensitive) { sv = sv.toLowerCase(); fv = fv.toLowerCase(); }
+ return sv === fv;
+ }
+ case "is_empty":
+ return v != null && String(v).trim() === "";
+ case "not_empty":
+ return v != null && String(v).trim() !== "";
+ // Numeric ops
+ case "eq": return v != null && Number(v) === Number(tok.value);
+ case "neq": return v != null && Number(v) !== Number(tok.value);
+ case "lt": return v != null && Number(v) < Number(tok.value);
+ case "lte": return v != null && Number(v) <= Number(tok.value);
+ case "gt": return v != null && Number(v) > Number(tok.value);
+ case "gte": return v != null && Number(v) >= Number(tok.value);
+ case "between":
+ return v != null && Number(v) >= Number(tok.value) && Number(v) <= Number(tok.value2);
+ default: return true;
+ }
+ }
+
+ /** Return a highlight substring for a given column, or "" if none. */
+ function getHighlightQuery(state, colIdx) {
+ for (var i = 0; i < state.filterTokens.length; i++) {
+ var t = state.filterTokens[i];
+ if (t.colIdx === colIdx && t.op === "contains" && t.value) {
+ return t.value;
+ }
+ }
+ return "";
+ }
+
+ // ── Copy ───────────────────────────────────────────────────
+
+ function handleCopy(state, allRows, btnEl) {
+ var rows = allRows ? state.allRows : getVisiblePageRows(state);
+ var tsv = rowsToTSV(rows, state.columns, state.visibleCols);
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(tsv).then(function () {
+ btnEl.innerHTML = SVG_CHECK;
+ btnEl.classList.add("gd-tbl-btn-copied");
+ setTimeout(function () {
+ btnEl.innerHTML = SVG_COPY;
+ btnEl.classList.remove("gd-tbl-btn-copied");
+ }, COPIED_MS);
+ });
+ }
+ }
+
+ function rowsToTSV(rows, columns, visibleCols) {
+ var header = visibleCols.map(function (ci) { return columns[ci].name; }).join("\t");
+ var lines = [header];
+ rows.forEach(function (row) {
+ var vals = visibleCols.map(function (ci) {
+ var v = row[ci];
+ return v == null ? "" : String(v);
+ });
+ lines.push(vals.join("\t"));
+ });
+ return lines.join("\n");
+ }
+
+ // ── Download ───────────────────────────────────────────────
+
+ function handleDownload(state) {
+ var rows = state.filteredRows;
+ var csv = rowsToCSV(rows, state.columns, state.visibleCols);
+ var blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
+ var url = URL.createObjectURL(blob);
+ var a = document.createElement("a");
+ var ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
+ a.href = url;
+ a.download = "table-" + ts + ".csv";
+ a.style.display = "none";
+ document.body.appendChild(a);
+ a.click();
+ setTimeout(function () {
+ URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ }, 100);
+ }
+
+ function rowsToCSV(rows, columns, visibleCols) {
+ var header = visibleCols.map(function (ci) {
+ return csvEscape(columns[ci].name);
+ }).join(",");
+ var lines = [header];
+ rows.forEach(function (row) {
+ var vals = visibleCols.map(function (ci) {
+ var v = row[ci];
+ return v == null ? "" : csvEscape(String(v));
+ });
+ lines.push(vals.join(","));
+ });
+ return lines.join("\r\n");
+ }
+
+ function csvEscape(s) {
+ if (/[,"\r\n]/.test(s)) {
+ return '"' + s.replace(/"/g, '""') + '"';
+ }
+ return s;
+ }
+
+ // ── Reset ──────────────────────────────────────────────────
+
+ function handleReset(el, state) {
+ state.filterQuery = "";
+ state.filterTokens = [];
+ state.sortCols = [];
+ state.currentPage = 1;
+ state.visibleCols = state.columns.map(function (_, i) { return i; });
+ state.filteredRows = state.allRows.slice();
+
+ // Reset filter tokens display
+ var tokenArea = el.querySelector(".gd-tbl-filter-tokens");
+ if (tokenArea) tokenArea.innerHTML = "";
+
+ // Close any open filter wizard
+ var filterBar = el.querySelector(".gd-tbl-filter-bar");
+ if (filterBar) closeFilterWizard(filterBar);
+
+ // Reset column checkboxes
+ var cbs = el.querySelectorAll(".gd-tbl-col-menu input[type=checkbox]");
+ for (var i = 0; i < cbs.length; i++) cbs[i].checked = true;
+
+ // Reset column button label
+ var colBtn = el.querySelector(".gd-tbl-col-wrap .gd-tbl-btn");
+ if (colBtn) updateColBtnLabel(colBtn, state);
+
+ // Reset sort classes
+ var offset = state.cfg.showRowNumbers ? 1 : 0;
+ clearSortClasses(el, offset);
+
+ applyState(el, state);
+ }
+
+ // ── Render ─────────────────────────────────────────────────
+
+ function applyState(el, state) {
+ renderBody(el, state);
+ renderPagination(el, state);
+ }
+
+ function renderBody(el, state) {
+ var tbl = el.querySelector("table");
+ if (!tbl) return;
+ var oldBody = tbl.querySelector("tbody");
+
+ var pageRows = getVisiblePageRows(state);
+ var startIdx = state.pageSize > 0 ? (state.currentPage - 1) * state.pageSize : 0;
+
+ var tbody = document.createElement("tbody");
+ tbody.className = "gt_table_body";
+
+ for (var r = 0; r < pageRows.length; r++) {
+ var row = pageRows[r];
+ var tr = document.createElement("tr");
+
+ if (state.cfg.showRowNumbers) {
+ var rnTd = document.createElement("td");
+ rnTd.className = "gt_row gt_right gd-tbl-rownum";
+ rnTd.textContent = String(startIdx + r);
+ tr.appendChild(rnTd);
+ }
+
+ for (var c = 0; c < state.visibleCols.length; c++) {
+ var ci = state.visibleCols[c];
+ var val = row[ci];
+ var td = document.createElement("td");
+ var align = state.columns[ci].align || "left";
+ td.className = "gt_row gt_" + align;
+
+ var isMissing = val == null;
+ if (isMissing && state.cfg.highlightMissing) {
+ td.classList.add("gd-tbl-missing");
+ }
+
+ var cellText = formatCell(val);
+
+ // Highlight matching "contains" filter values in relevant cells
+ var highlightQ = getHighlightQuery(state, ci);
+ if (highlightQ && state.cfg.searchHighlight && !isMissing) {
+ td.innerHTML = highlightText(escapeHTML(cellText), highlightQ);
+ } else {
+ td.textContent = cellText;
+ }
+ tr.appendChild(td);
+ }
+ tbody.appendChild(tr);
+ }
+
+ if (oldBody) {
+ tbl.replaceChild(tbody, oldBody);
+ } else {
+ tbl.appendChild(tbody);
+ }
+
+ // Update colgroup to hide toggled columns
+ updateColgroup(el, state);
+ // Update column headers visibility
+ updateHeaders(el, state);
+ }
+
+ function updateColgroup(el, state) {
+ var colgroup = el.querySelector("colgroup");
+ if (!colgroup) return;
+ var cols = colgroup.querySelectorAll("col");
+ var offset = state.cfg.showRowNumbers ? 1 : 0;
+ for (var i = offset; i < cols.length; i++) {
+ var dataIdx = i - offset;
+ cols[i].style.display = state.visibleCols.indexOf(dataIdx) !== -1 ? "" : "none";
+ }
+ }
+
+ function updateHeaders(el, state) {
+ var ths = el.querySelectorAll("th.gt_col_heading");
+ var offset = state.cfg.showRowNumbers ? 1 : 0;
+ for (var i = offset; i < ths.length; i++) {
+ var dataIdx = i - offset;
+ ths[i].style.display = state.visibleCols.indexOf(dataIdx) !== -1 ? "" : "none";
+ }
+ }
+
+ function formatCell(v) {
+ if (v == null) return "None";
+ if (typeof v === "boolean") return String(v);
+ if (typeof v === "number") {
+ if (!isFinite(v)) return v > 0 ? "Inf" : "-Inf";
+ // Match Python's %.12g
+ var s = v.toPrecision(12);
+ return String(parseFloat(s));
+ }
+ return String(v);
+ }
+
+ function escapeHTML(s) {
+ var d = document.createElement("div");
+ d.appendChild(document.createTextNode(s));
+ return d.innerHTML;
+ }
+
+ function highlightText(escapedHTML, query) {
+ if (!query) return escapedHTML;
+ var q = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ var re = new RegExp("(" + q + ")", "gi");
+ return escapedHTML.replace(re, '
$1 ');
+ }
+
+ // ── Pagination ─────────────────────────────────────────────
+
+ function getVisiblePageRows(state) {
+ if (state.pageSize <= 0) return state.filteredRows;
+ var start = (state.currentPage - 1) * state.pageSize;
+ return state.filteredRows.slice(start, start + state.pageSize);
+ }
+
+ function renderPagination(el, state) {
+ var existing = el.querySelector(".gd-tbl-pagination");
+ if (existing) existing.parentNode.removeChild(existing);
+
+ if (state.pageSize <= 0) return;
+
+ var totalFiltered = state.filteredRows.length;
+ var totalPages = Math.max(1, Math.ceil(totalFiltered / state.pageSize));
+
+ if (totalFiltered <= state.pageSize) return;
+
+ var nav = document.createElement("nav");
+ nav.className = "gd-tbl-pagination";
+ nav.setAttribute("aria-label", "Table pagination");
+
+ // Info
+ var start = (state.currentPage - 1) * state.pageSize + 1;
+ var end = Math.min(state.currentPage * state.pageSize, totalFiltered);
+ var info = document.createElement("span");
+ info.className = "gd-tbl-page-info";
+ info.textContent = "Showing " + fmtNum(start) + "\u2013" +
+ fmtNum(end) + " of " + fmtNum(totalFiltered) + " rows";
+ nav.appendChild(info);
+
+ // Page buttons
+ var btns = document.createElement("span");
+ btns.className = "gd-tbl-page-nav";
+
+ // Prev
+ var prev = makePageBtn("\u25C0", state.currentPage > 1, function () {
+ state.currentPage--;
+ applyState(el, state);
+ });
+ prev.setAttribute("aria-label", "Previous page");
+ btns.appendChild(prev);
+
+ // Page numbers with ellipsis
+ var range = getPageRange(state.currentPage, totalPages);
+ for (var i = 0; i < range.length; i++) {
+ if (range[i] === "...") {
+ var ell = document.createElement("span");
+ ell.className = "gd-tbl-page-ellipsis";
+ ell.textContent = "\u2026";
+ btns.appendChild(ell);
+ } else {
+ var pNum = range[i];
+ (function (p) {
+ var b = makePageBtn(String(p), true, function () {
+ state.currentPage = p;
+ applyState(el, state);
+ });
+ if (p === state.currentPage) b.classList.add("active");
+ b.setAttribute("aria-label", "Page " + p);
+ if (p === state.currentPage) b.setAttribute("aria-current", "page");
+ btns.appendChild(b);
+ })(pNum);
+ }
+ }
+
+ // Next
+ var next = makePageBtn("\u25B6", state.currentPage < totalPages, function () {
+ state.currentPage++;
+ applyState(el, state);
+ });
+ next.setAttribute("aria-label", "Next page");
+ btns.appendChild(next);
+
+ nav.appendChild(btns);
+ el.appendChild(nav);
+ }
+
+ function makePageBtn(text, enabled, onClick) {
+ var btn = document.createElement("button");
+ btn.className = "gd-tbl-page-btn";
+ btn.textContent = text;
+ btn.disabled = !enabled;
+ if (enabled) btn.addEventListener("click", onClick);
+ return btn;
+ }
+
+ function getPageRange(current, total) {
+ if (total <= 7) {
+ var r = [];
+ for (var i = 1; i <= total; i++) r.push(i);
+ return r;
+ }
+ var pages = [1];
+ var lo = Math.max(2, current - PAGE_WINDOW);
+ var hi = Math.min(total - 1, current + PAGE_WINDOW);
+ if (lo > 2) pages.push("...");
+ for (var j = lo; j <= hi; j++) pages.push(j);
+ if (hi < total - 1) pages.push("...");
+ pages.push(total);
+ return pages;
+ }
+
+ // ── Utilities ──────────────────────────────────────────────
+
+ function debounce(fn, ms) {
+ var timer;
+ return function () {
+ var args = arguments, ctx = this;
+ clearTimeout(timer);
+ timer = setTimeout(function () { fn.apply(ctx, args); }, ms);
+ };
+ }
+
+ function fmtNum(n) {
+ return n.toLocaleString();
+ }
+
+ // ── Boot ───────────────────────────────────────────────────
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init);
+ } else {
+ init();
+ }
+})();
diff --git a/test-packages/synthetic/catalog.py b/test-packages/synthetic/catalog.py
index be50e9a..bbfc9ac 100644
--- a/test-packages/synthetic/catalog.py
+++ b/test-packages/synthetic/catalog.py
@@ -361,6 +361,8 @@
"gdtest_tbl_preview", # 178
# 179: Table preview shortcode showcase
"gdtest_tbl_shortcode", # 179
+ # 180: Interactive table explorer showcase
+ "gdtest_tbl_explorer", # 180
]
@@ -2026,6 +2028,15 @@
"plus multi-table pages and wide-table horizontal scroll. No Python "
"code cells; all rendering is done via the shortcode."
),
+ "gdtest_tbl_explorer": (
+ "Interactive table explorer showcase exercising tbl_explorer() with "
+ "eight user-guide pages: basic sorting/filtering, pagination (custom "
+ "page sizes, no pagination), column toggling (wide tables, subsets, "
+ "toggle disabled), copy/download controls (various combinations), "
+ "missing-value highlighting, minimal chrome (hide row numbers, dtypes, "
+ "dimensions), tbl-explorer Quarto shortcode with CSV/TSV/JSONL files, "
+ "and a side-by-side comparison of tbl_preview() vs tbl_explorer()."
+ ),
}
diff --git a/test-packages/synthetic/specs/gdtest_tbl_explorer.py b/test-packages/synthetic/specs/gdtest_tbl_explorer.py
new file mode 100644
index 0000000..f88ef66
--- /dev/null
+++ b/test-packages/synthetic/specs/gdtest_tbl_explorer.py
@@ -0,0 +1,669 @@
+"""
+gdtest_tbl_explorer — Interactive table explorer showcase.
+
+Dimensions: A1, B1, C1, D1, M4, G1
+Focus: Exercises the ``tbl_explorer()`` function with many different table
+ shapes, data types, display options, and interactive feature toggles.
+ The user guide has eight pages:
+
+ 1. Basic explorer — dict data, default options, sorting & filtering.
+ 2. Large table — 200-row dataset, pagination, page navigation.
+ 3. Column toggling — wide table, hide/show columns, column subsets.
+ 4. Copy & download — copy to clipboard, CSV download.
+ 5. Missing values — None/NaN highlighting, mixed types.
+ 6. Minimal chrome — hide row numbers, dtypes, dimensions.
+ 7. Shortcode explorer — exercise the {{< tbl-explorer >}} shortcode.
+ 8. Side-by-side — tbl_preview() vs tbl_explorer() comparison.
+
+ The API reference documents helper functions that generate sample
+ data for the explorer demos.
+"""
+
+SPEC = {
+ "name": "gdtest_tbl_explorer",
+ "description": "Interactive table explorer showcase with sorting, filtering, pagination.",
+ "dimensions": ["A1", "B1", "C1", "D1", "M2", "G1"],
+ "pyproject_toml": {
+ "project": {
+ "name": "gdtest-tbl-explorer",
+ "version": "0.1.0",
+ "description": "Showcase for the tbl_explorer() interactive table feature",
+ "dependencies": ["great_docs"],
+ },
+ "build-system": {
+ "requires": ["setuptools"],
+ "build-backend": "setuptools.build_meta",
+ },
+ },
+ "config": {},
+ "files": {
+ # ── Project root ──────────────────────────────────────────────────
+ "README.md": (
+ "# gdtest-tbl-explorer\n\n"
+ "A showcase site demonstrating the `tbl_explorer()` function from\n"
+ "Great Docs. Each user-guide page exercises a different combination\n"
+ "of data sources, interactive features, and display options.\n"
+ ),
+ # ── Package source ────────────────────────────────────────────────
+ "gdtest_tbl_explorer/__init__.py": '''\
+ """Sample data generators for table explorer demos."""
+
+ __version__ = "0.1.0"
+ __all__ = [
+ "sample_cities",
+ "sample_products",
+ "sample_wide",
+ "sample_missing",
+ "sample_large",
+ ]
+
+ from .data import (
+ sample_cities,
+ sample_products,
+ sample_wide,
+ sample_missing,
+ sample_large,
+ )
+ ''',
+ "gdtest_tbl_explorer/data.py": '''\
+ """Functions that generate sample data for explorer demos."""
+
+ from __future__ import annotations
+
+
+ def sample_cities(n: int = 12) -> dict[str, list]:
+ """
+ Generate a world cities dataset.
+
+ Parameters
+ ----------
+ n
+ Number of rows.
+
+ Returns
+ -------
+ dict[str, list]
+ Column-oriented dict with city, country, population,
+ latitude, and longitude columns.
+
+ Examples
+ --------
+ >>> data = sample_cities(5)
+ >>> len(data["city"])
+ 5
+ """
+ cities = [
+ ("Tokyo", "Japan", 13960000, 35.6762, 139.6503),
+ ("Paris", "France", 2161000, 48.8566, 2.3522),
+ ("New York", "USA", 8336000, 40.7128, -74.0060),
+ ("London", "UK", 8982000, 51.5074, -0.1278),
+ ("Sydney", "Australia", 5312000, -33.8688, 151.2093),
+ ("Berlin", "Germany", 3645000, 52.5200, 13.4050),
+ ("Mumbai", "India", 20411000, 19.0760, 72.8777),
+ ("São Paulo", "Brazil", 12330000, -23.5505, -46.6333),
+ ("Cairo", "Egypt", 10230000, 30.0444, 31.2357),
+ ("Toronto", "Canada", 2930000, 43.6532, -79.3832),
+ ("Seoul", "South Korea", 9776000, 37.5665, 126.9780),
+ ("Mexico City", "Mexico", 9210000, 19.4326, -99.1332),
+ ("Lagos", "Nigeria", 15400000, 6.5244, 3.3792),
+ ("Bangkok", "Thailand", 10540000, 13.7563, 100.5018),
+ ("Istanbul", "Turkey", 15460000, 41.0082, 28.9784),
+ ]
+ rows = cities[:n]
+ return {
+ "city": [r[0] for r in rows],
+ "country": [r[1] for r in rows],
+ "population": [r[2] for r in rows],
+ "latitude": [r[3] for r in rows],
+ "longitude": [r[4] for r in rows],
+ }
+
+
+ def sample_products(n: int = 15) -> dict[str, list]:
+ """
+ Generate a product catalog dataset.
+
+ Parameters
+ ----------
+ n
+ Number of rows.
+
+ Returns
+ -------
+ dict[str, list]
+ Column-oriented dict with product, category, price,
+ stock, rating, and in_stock columns.
+
+ Examples
+ --------
+ >>> data = sample_products(5)
+ >>> len(data["product"])
+ 5
+ """
+ import random
+ random.seed(42)
+ products = [
+ "Widget", "Gadget", "Doohickey", "Thingamajig",
+ "Gizmo", "Whatchamacallit", "Contraption", "Apparatus",
+ "Device", "Instrument", "Mechanism", "Implement",
+ "Tool", "Utensil", "Component",
+ ]
+ categories = ["Electronics", "Tools", "Kitchen", "Garden", "Office"]
+ rows = []
+ for i in range(n):
+ name = products[i % len(products)]
+ cat = random.choice(categories)
+ price = round(random.uniform(5.0, 250.0), 2)
+ stock = random.randint(0, 500)
+ rating = round(random.uniform(1.0, 5.0), 1)
+ rows.append((name, cat, price, stock, rating, stock > 0))
+ return {
+ "product": [r[0] for r in rows],
+ "category": [r[1] for r in rows],
+ "price": [r[2] for r in rows],
+ "stock": [r[3] for r in rows],
+ "rating": [r[4] for r in rows],
+ "in_stock": [r[5] for r in rows],
+ }
+
+
+ def sample_wide(n_rows: int = 10, n_cols: int = 15) -> dict[str, list]:
+ """
+ Generate a wide dataset with many numeric columns.
+
+ Parameters
+ ----------
+ n_rows
+ Number of rows.
+ n_cols
+ Number of data columns (plus an id column).
+
+ Returns
+ -------
+ dict[str, list]
+ Column-oriented dict with an ``id`` column and
+ ``metric_001`` through ``metric_{n_cols:03d}``.
+
+ Examples
+ --------
+ >>> data = sample_wide(5, 8)
+ >>> len(data)
+ 9
+ """
+ import random
+ random.seed(7)
+ result = {"id": list(range(n_rows))}
+ for i in range(n_cols):
+ result[f"metric_{i+1:03d}"] = [
+ round(random.gauss(0, 1), 3) for _ in range(n_rows)
+ ]
+ return result
+
+
+ def sample_missing(n: int = 12) -> dict[str, list]:
+ """
+ Generate a dataset with scattered missing values.
+
+ Parameters
+ ----------
+ n
+ Number of rows.
+
+ Returns
+ -------
+ dict[str, list]
+ Column-oriented dict with name, value, category, and
+ score columns. Some cells are None.
+
+ Examples
+ --------
+ >>> data = sample_missing(5)
+ >>> None in data["value"]
+ True
+ """
+ import random
+ random.seed(13)
+ names = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon",
+ "Zeta", "Eta", "Theta", "Iota", "Kappa",
+ "Lambda", "Mu"]
+ categories = ["A", "B", "C", None]
+ rows_name = [names[i % len(names)] for i in range(n)]
+ rows_val = [
+ None if random.random() < 0.25
+ else round(random.uniform(10, 100), 1)
+ for _ in range(n)
+ ]
+ rows_cat = [random.choice(categories) for _ in range(n)]
+ rows_score = [
+ None if random.random() < 0.2
+ else random.randint(1, 100)
+ for _ in range(n)
+ ]
+ return {
+ "name": rows_name,
+ "value": rows_val,
+ "category": rows_cat,
+ "score": rows_score,
+ }
+
+
+ def sample_large(n: int = 200) -> dict[str, list]:
+ """
+ Generate a large dataset for pagination testing.
+
+ Parameters
+ ----------
+ n
+ Number of rows.
+
+ Returns
+ -------
+ dict[str, list]
+ Column-oriented dict with id, name, department, salary,
+ years, and active columns.
+
+ Examples
+ --------
+ >>> data = sample_large(50)
+ >>> len(data["id"])
+ 50
+ """
+ import random
+ random.seed(123)
+ first_names = [
+ "Alice", "Bob", "Charlie", "Diana", "Eve", "Frank",
+ "Grace", "Hank", "Iris", "Jack", "Karen", "Leo",
+ "Mona", "Nate", "Olivia", "Paul", "Quinn", "Rosa",
+ ]
+ depts = [
+ "Engineering", "Marketing", "Sales", "Finance",
+ "HR", "Operations", "Legal", "R&D",
+ ]
+ return {
+ "id": list(range(1, n + 1)),
+ "name": [random.choice(first_names) for _ in range(n)],
+ "department": [random.choice(depts) for _ in range(n)],
+ "salary": [random.randint(50000, 200000) for _ in range(n)],
+ "years": [random.randint(1, 30) for _ in range(n)],
+ "active": [random.random() > 0.15 for _ in range(n)],
+ }
+ ''',
+ # ── Data files for shortcode demos ────────────────────────────────
+ "assets/cities.csv": (
+ "city,country,population,latitude,longitude\n"
+ "Tokyo,Japan,13960000,35.6762,139.6503\n"
+ "Paris,France,2161000,48.8566,2.3522\n"
+ "New York,USA,8336000,40.7128,-74.0060\n"
+ "London,UK,8982000,51.5074,-0.1278\n"
+ "Sydney,Australia,5312000,-33.8688,151.2093\n"
+ "Berlin,Germany,3645000,52.5200,13.4050\n"
+ "Mumbai,India,20411000,19.0760,72.8777\n"
+ "São Paulo,Brazil,12330000,-23.5505,-46.6333\n"
+ "Cairo,Egypt,10230000,30.0444,31.2357\n"
+ "Toronto,Canada,2930000,43.6532,-79.3832\n"
+ "Seoul,South Korea,9776000,37.5665,126.9780\n"
+ "Mexico City,Mexico,9210000,19.4326,-99.1332\n"
+ ),
+ "assets/products.tsv": (
+ "product\tcategory\tprice\tstock\trating\n"
+ "Widget\tElectronics\t29.99\t150\t4.5\n"
+ "Gadget\tTools\t49.50\t80\t3.8\n"
+ "Gizmo\tKitchen\t12.00\t300\t4.9\n"
+ "Doohickey\tGarden\t8.75\t0\t4.2\n"
+ "Thingamajig\tOffice\t199.99\t25\t2.1\n"
+ "Contraption\tElectronics\t65.00\t44\t3.5\n"
+ "Apparatus\tTools\t120.00\t12\t4.7\n"
+ ),
+ "assets/logs.jsonl": (
+ '{"ts":"2025-01-15T08:30:00","level":"INFO","module":"auth","msg":"User login OK"}\n'
+ '{"ts":"2025-01-15T08:31:12","level":"WARN","module":"db","msg":"Slow query (3.2s)"}\n'
+ '{"ts":"2025-01-15T08:32:45","level":"ERROR","module":"api","msg":"Timeout /v2/users"}\n'
+ '{"ts":"2025-01-15T08:33:01","level":"INFO","module":"cache","msg":"Cache miss user:42"}\n'
+ '{"ts":"2025-01-15T08:34:20","level":"DEBUG","module":"auth","msg":"Token refresh abc123"}\n'
+ '{"ts":"2025-01-15T08:35:55","level":"ERROR","module":"db","msg":"Pool exhausted"}\n'
+ ),
+ # ── User guide pages ──────────────────────────────────────────────
+ "user_guide/01-basic-explorer.qmd": (
+ "---\n"
+ "title: Basic Explorer\n"
+ "---\n"
+ "\n"
+ "The `tbl_explorer()` function creates interactive tables with\n"
+ "sorting, filtering, pagination, and more.\n"
+ "\n"
+ "## Default Options\n"
+ "\n"
+ "Pass a dictionary and get a fully interactive table:\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "from great_docs import tbl_explorer\n"
+ "from gdtest_tbl_explorer import sample_cities\n"
+ "\n"
+ "tbl_explorer(sample_cities())\n"
+ "```\n"
+ "\n"
+ "Try clicking column headers to sort, or type in the filter box.\n"
+ "\n"
+ "## With Caption\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ 'tbl_explorer(sample_cities(), caption="World Cities")\n'
+ "```\n"
+ "\n"
+ "## Product Catalog\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "from gdtest_tbl_explorer import sample_products\n"
+ "\n"
+ 'tbl_explorer(sample_products(), caption="Product Catalog")\n'
+ "```\n"
+ ),
+ "user_guide/02-pagination.qmd": (
+ "---\n"
+ "title: Pagination\n"
+ "---\n"
+ "\n"
+ "With larger datasets, `tbl_explorer()` paginates automatically.\n"
+ "\n"
+ "## Default Page Size (20 rows)\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "from great_docs import tbl_explorer\n"
+ "from gdtest_tbl_explorer import sample_large\n"
+ "\n"
+ 'tbl_explorer(sample_large(200), caption="200 Employees")\n'
+ "```\n"
+ "\n"
+ "## Custom Page Size (10 rows)\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ 'tbl_explorer(sample_large(200), page_size=10, caption="10 per page")\n'
+ "```\n"
+ "\n"
+ "## Large Page Size (50 rows)\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ 'tbl_explorer(sample_large(200), page_size=50, caption="50 per page")\n'
+ "```\n"
+ "\n"
+ "## No Pagination\n"
+ "\n"
+ "Set `page_size=0` to show all rows at once:\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ 'tbl_explorer(sample_large(30), page_size=0, caption="All 30 rows")\n'
+ "```\n"
+ ),
+ "user_guide/03-column-toggle.qmd": (
+ "---\n"
+ "title: Column Toggling\n"
+ "---\n"
+ "\n"
+ "Use the **Columns** dropdown in the toolbar to show/hide columns.\n"
+ "\n"
+ "## Wide Table\n"
+ "\n"
+ "This table has 16 columns — try hiding some:\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "from great_docs import tbl_explorer\n"
+ "from gdtest_tbl_explorer import sample_wide\n"
+ "\n"
+ 'tbl_explorer(sample_wide(10, 15), caption="Wide Metrics Table")\n'
+ "```\n"
+ "\n"
+ "## Column Subset\n"
+ "\n"
+ "Pre-select specific columns with `columns=`:\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "from gdtest_tbl_explorer import sample_cities\n"
+ "\n"
+ "tbl_explorer(\n"
+ " sample_cities(),\n"
+ ' columns=["city", "country", "population"],\n'
+ ' caption="Cities — 3 columns only",\n'
+ ")\n"
+ "```\n"
+ "\n"
+ "## Toggle Disabled\n"
+ "\n"
+ "Set `column_toggle=False` to remove the dropdown:\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "tbl_explorer(\n"
+ " sample_cities(),\n"
+ " column_toggle=False,\n"
+ ' caption="No column toggle",\n'
+ ")\n"
+ "```\n"
+ ),
+ "user_guide/04-copy-download.qmd": (
+ "---\n"
+ "title: Copy & Download\n"
+ "---\n"
+ "\n"
+ "The toolbar includes **Copy** (TSV to clipboard) and **Download**\n"
+ "(CSV file) buttons.\n"
+ "\n"
+ "## Full Controls\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "from great_docs import tbl_explorer\n"
+ "from gdtest_tbl_explorer import sample_products\n"
+ "\n"
+ 'tbl_explorer(sample_products(), caption="Copy or download this table")\n'
+ "```\n"
+ "\n"
+ "## Copy Only (No Download)\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "tbl_explorer(\n"
+ " sample_products(),\n"
+ " downloadable=False,\n"
+ ' caption="Copy button only",\n'
+ ")\n"
+ "```\n"
+ "\n"
+ "## Download Only (No Copy)\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "tbl_explorer(\n"
+ " sample_products(),\n"
+ " copyable=False,\n"
+ ' caption="Download button only",\n'
+ ")\n"
+ "```\n"
+ "\n"
+ "## No Export Controls\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "tbl_explorer(\n"
+ " sample_products(),\n"
+ " copyable=False,\n"
+ " downloadable=False,\n"
+ ' caption="No export buttons",\n'
+ ")\n"
+ "```\n"
+ ),
+ "user_guide/05-missing-values.qmd": (
+ "---\n"
+ "title: Missing Values\n"
+ "---\n"
+ "\n"
+ "Missing values (`None`, `NaN`) are highlighted in red by default.\n"
+ "\n"
+ "## Default Highlighting\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "from great_docs import tbl_explorer\n"
+ "from gdtest_tbl_explorer import sample_missing\n"
+ "\n"
+ 'tbl_explorer(sample_missing(), caption="Missing values highlighted")\n'
+ "```\n"
+ "\n"
+ "## Highlighting Off\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "tbl_explorer(\n"
+ " sample_missing(),\n"
+ " highlight_missing=False,\n"
+ ' caption="Missing values NOT highlighted",\n'
+ ")\n"
+ "```\n"
+ ),
+ "user_guide/06-minimal-chrome.qmd": (
+ "---\n"
+ "title: Minimal Chrome\n"
+ "---\n"
+ "\n"
+ "Strip away table chrome for a clean, data-focused look.\n"
+ "\n"
+ "## No Row Numbers\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "from great_docs import tbl_explorer\n"
+ "from gdtest_tbl_explorer import sample_cities\n"
+ "\n"
+ "tbl_explorer(\n"
+ " sample_cities(),\n"
+ " show_row_numbers=False,\n"
+ ' caption="No row numbers",\n'
+ ")\n"
+ "```\n"
+ "\n"
+ "## No Dtype Labels\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "tbl_explorer(\n"
+ " sample_cities(),\n"
+ " show_dtypes=False,\n"
+ ' caption="No dtype labels",\n'
+ ")\n"
+ "```\n"
+ "\n"
+ "## No Header Banner\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "tbl_explorer(\n"
+ " sample_cities(),\n"
+ " show_dimensions=False,\n"
+ ' caption="No header banner",\n'
+ ")\n"
+ "```\n"
+ "\n"
+ "## Fully Minimal\n"
+ "\n"
+ "No row numbers, no dtypes, no dimensions — just the data and controls:\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "tbl_explorer(\n"
+ " sample_cities(),\n"
+ " show_row_numbers=False,\n"
+ " show_dtypes=False,\n"
+ " show_dimensions=False,\n"
+ ' caption="Fully minimal",\n'
+ ")\n"
+ "```\n"
+ "\n"
+ "## Filter Only (No Other Controls)\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "tbl_explorer(\n"
+ " sample_cities(),\n"
+ " sortable=False,\n"
+ " column_toggle=False,\n"
+ " copyable=False,\n"
+ " downloadable=False,\n"
+ ' caption="Filter only",\n'
+ ")\n"
+ "```\n"
+ ),
+ "user_guide/07-shortcode.qmd": (
+ "---\n"
+ "title: Shortcode Explorer\n"
+ "---\n"
+ "\n"
+ "The `{{< tbl-explorer >}}` shortcode embeds interactive tables\n"
+ "from data files — no Python code cells needed.\n"
+ "\n"
+ "## CSV File\n"
+ "\n"
+ '{{< tbl-explorer file="assets/cities.csv" >}}\n'
+ "\n"
+ "## With Caption\n"
+ "\n"
+ '{{< tbl-explorer file="assets/cities.csv" caption="World Cities (shortcode)" >}}\n'
+ "\n"
+ "## TSV File\n"
+ "\n"
+ '{{< tbl-explorer file="assets/products.tsv" caption="Products (TSV)" >}}\n'
+ "\n"
+ "## JSONL File\n"
+ "\n"
+ '{{< tbl-explorer file="assets/logs.jsonl" caption="Server Logs (JSONL)" >}}\n'
+ "\n"
+ "## Custom Options\n"
+ "\n"
+ '{{< tbl-explorer file="assets/cities.csv" page_size="5" '
+ 'caption="5 per page" >}}\n'
+ "\n"
+ "## No Filter\n"
+ "\n"
+ '{{< tbl-explorer file="assets/cities.csv" filterable="false" '
+ 'caption="No filter input" >}}\n'
+ ),
+ "user_guide/08-comparison.qmd": (
+ "---\n"
+ "title: Preview vs Explorer\n"
+ "---\n"
+ "\n"
+ "Compare the static `tbl_preview()` with the interactive\n"
+ "`tbl_explorer()` side by side.\n"
+ "\n"
+ "## Static Preview\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "from great_docs import tbl_preview\n"
+ "from gdtest_tbl_explorer import sample_cities\n"
+ "\n"
+ 'tbl_preview(sample_cities(), caption="Static preview")\n'
+ "```\n"
+ "\n"
+ "## Interactive Explorer\n"
+ "\n"
+ "```{python}\n"
+ "#| echo: false\n"
+ "from great_docs import tbl_explorer\n"
+ "\n"
+ 'tbl_explorer(sample_cities(), caption="Interactive explorer")\n'
+ "```\n"
+ "\n"
+ "The explorer adds a toolbar with filter, column toggle, copy,\n"
+ "download, and reset controls. Column headers are sortable.\n"
+ "The static preview is lighter and works without JavaScript.\n"
+ ),
+ },
+}
diff --git a/user_guide/30-table-previews.qmd b/user_guide/30-table-previews.qmd
index dc16ed9..ccb9109 100644
--- a/user_guide/30-table-previews.qmd
+++ b/user_guide/30-table-previews.qmd
@@ -13,73 +13,7 @@ Documentation sites for data-oriented packages often need to show what a dataset
Each preview includes a colored type badge (Polars, Pandas, CSV, Parquet, etc.), compact dtype labels beneath every column header, row-number gutters, head/tail splitting for large tables, and automatic highlighting of missing values. The output renders identically in light mode and dark mode, requires no JavaScript, and is safe to embed in RSS feeds, emails, or static HTML.
-This guide walks through every feature, starting from the simplest Python call and building up to the Quarto shortcode, file format support, and advanced display options.
-
-```{python}
-#| echo: false
-#| output: false
-# Setup: create sample data files used by examples below.
-import pathlib, json
-
-_d = pathlib.Path("assets/tbl-preview-data")
-_d.mkdir(parents=True, exist_ok=True)
-
-(_d / "students.csv").write_text(
- "name,subject,score,grade,passed\n"
- "Alice,Math,95.5,A,true\n"
- "Bob,Science,82.0,B,true\n"
- "Charlie,English,71.3,C,true\n"
- "Diana,History,60.0,D,true\n"
- "Eve,Art,55.8,F,false\n"
- "Frank,Math,88.2,B+,true\n"
- "Grace,Science,79.9,C+,true\n"
- "Hank,English,91.0,A-,true\n"
- "Iris,History,66.4,D+,true\n"
- "Jack,Art,73.7,C,true\n"
-)
-
-(_d / "products.tsv").write_text(
- "product\tcategory\tprice\tstock\trating\n"
- "Widget\tElectronics\t29.99\t150\t4.5\n"
- "Gadget\tTools\t49.50\t80\t3.8\n"
- "Gizmo\tKitchen\t12.00\t300\t4.9\n"
- "Doohickey\tGarden\t8.75\t0\t4.2\n"
- "Thingamajig\tOffice\t199.99\t25\t2.1\n"
- "Contraption\tElectronics\t65.00\t44\t3.5\n"
- "Apparatus\tTools\t120.00\t12\t4.7\n"
-)
-
-_logs = [
- {"timestamp": "2025-01-15T08:30:00", "level": "INFO", "module": "auth", "message": "User login successful"},
- {"timestamp": "2025-01-15T08:31:12", "level": "WARNING", "module": "db", "message": "Slow query detected (3.2s)"},
- {"timestamp": "2025-01-15T08:32:45", "level": "ERROR", "module": "api", "message": "Request timeout on /v2/users"},
- {"timestamp": "2025-01-15T08:33:01", "level": "INFO", "module": "cache", "message": "Cache miss for key user:42"},
- {"timestamp": "2025-01-15T08:34:20", "level": "DEBUG", "module": "auth", "message": "Token refresh for session abc123"},
- {"timestamp": "2025-01-15T08:35:55", "level": "ERROR", "module": "db", "message": "Connection pool exhausted"},
-]
-(_d / "server_logs.jsonl").write_text("\n".join(json.dumps(r) for r in _logs) + "\n")
-
-import polars as pl
-
-_products = pl.DataFrame({
- "product": ["Widget", "Gadget", "Gizmo", "Doohickey", "Thingamajig"],
- "category": ["Electronics", "Tools", "Kitchen", "Garden", "Office"],
- "price": [29.99, 49.50, 12.00, 8.75, 199.99],
- "in_stock": [True, False, True, True, False],
- "rating": [4.5, 3.8, 4.9, 4.2, 2.1],
-})
-_products.write_parquet(str(_d / "products.parquet"))
-
-_employees = pl.DataFrame({
- "name": ["Alice", "Bob", "Charlie", "Diana", "Eve", "Frank"],
- "department": ["Engineering", "Marketing", "Engineering", "Sales", "Marketing", "Sales"],
- "salary": [95000, 72000, 105000, 68000, 88000, 71000],
- "years": [5, 3, 8, 2, 6, 4],
-})
-_employees.write_ipc(str(_d / "employees.feather"))
-
-del _d, _logs, _products, _employees
-```
+This guide walks through every feature, starting from the simplest Python call and building up to the Quarto shortcode, file format support, and advanced display options. For interactive tables with sorting, filtering, and pagination, see the companion [Table Explorer](31-table-explorer.qmd) guide.
## Quick Start
@@ -176,7 +110,7 @@ Both Polars and Pandas DataFrames are detected automatically (no format flag is
Pass a file path (as a string or `pathlib.Path`) to any supported format and `tbl_preview()` reads it directly. CSV files get a warm yellow **CSV** badge:
```{python}
-tbl_preview("assets/tbl-preview-data/students.csv")
+tbl_preview("../assets/data/students.csv")
```
The file is read using Polars if available, falling back to Pandas. Column dtypes are inferred from the file contents.
@@ -186,7 +120,7 @@ The file is read using Polars if available, falling back to Pandas. Column dtype
Tab-separated files (`.tsv` or `.tab`) are auto-detected by extension. The badge shows **TSV** in green:
```{python}
-tbl_preview("assets/tbl-preview-data/products.tsv")
+tbl_preview("../assets/data/products.tsv")
```
TSV support uses the same reader as CSV with the delimiter set to tab, so all the same options (column subsets, head/tail, etc.) apply.
@@ -196,7 +130,7 @@ TSV support uses the same reader as CSV with the delimiter set to tab, so all th
Newline-delimited JSON files (`.jsonl` or `.ndjson`) are read line by line. Each line must be a valid JSON object. The badge shows **JSONL** in light blue:
```{python}
-tbl_preview("assets/tbl-preview-data/server_logs.jsonl", show_all=True)
+tbl_preview("../assets/data/server_logs.jsonl", show_all=True)
```
JSONL is a common format for log data and streaming pipelines, making `tbl_preview()` a convenient way to inspect log files in documentation.
@@ -206,7 +140,7 @@ JSONL is a common format for log data and streaming pipelines, making `tbl_previ
Apache Parquet files (`.parquet` or `.pq`) are read via Polars or PyArrow. The badge shows **Parquet** in purple:
```{python}
-tbl_preview("assets/tbl-preview-data/products.parquet", show_all=True)
+tbl_preview("../assets/data/products.parquet", show_all=True)
```
Parquet preserves the original column types from the file schema, so dtype labels are precise (e.g., `f64` rather than a generic `float`).
@@ -216,7 +150,7 @@ Parquet preserves the original column types from the file schema, so dtype label
Feather files (`.feather`) and Arrow IPC files (`.arrow`, `.ipc`) are both read as Arrow IPC format. Feather files get a **Feather** badge in orange, while `.arrow`/`.ipc` files get an **Arrow** badge in indigo:
```{python}
-tbl_preview("assets/tbl-preview-data/employees.feather", show_all=True)
+tbl_preview("../assets/data/employees.feather", show_all=True)
```
Arrow IPC is the recommended format for fast local reads when you need to preserve exact column types.
@@ -311,7 +245,7 @@ By default, all columns are shown. The `columns` parameter lets you pick a subse
```{python}
tbl_preview(
- "assets/tbl-preview-data/students.csv",
+ "../assets/data/students.csv",
columns=["name", "score", "grade"],
show_all=True,
)
diff --git a/user_guide/31-table-explorer.qmd b/user_guide/31-table-explorer.qmd
new file mode 100644
index 0000000..06a9f57
--- /dev/null
+++ b/user_guide/31-table-explorer.qmd
@@ -0,0 +1,291 @@
+---
+title: "Table Explorer"
+guide-section: "Site Content"
+tags: [Content, Extensions, Data]
+status: experimental
+upcoming: "0.10"
+versions: ">=0.9"
+---
+
+# Table Explorer
+
+The `tbl_explorer()` function generates an interactive table widget from tabular data. Where [`tbl_preview()`](30-table-previews.qmd) produces a static, JavaScript-free snapshot of a dataset, `tbl_explorer()` embeds all rows as inline JSON and progressively enhances a static fallback table with interactive controls: sorting, token-based filtering, pagination, column toggling, copy-to-clipboard, and CSV download.
+
+Like `tbl_preview()`, `tbl_explorer()` accepts any tabular data source—Polars/Pandas DataFrames, PyArrow Tables, CSV/TSV/JSONL/Parquet/Feather files, column-oriented dicts, or row-oriented lists of dicts. The output is a self-contained HTML block that works in Python code cells, Jupyter notebooks, and (via the `{{{< tbl-explorer >}}}` shortcode) directly in Quarto `.qmd` pages.
+
+::: {.callout-note}
+For datasets larger than 10,000 rows, `tbl_explorer()` emits a warning because all data is embedded as JSON, which increases page weight. For very large datasets, consider using `tbl_preview()` with `n_head`/`n_tail` splitting instead.
+:::
+
+```{python}
+#| echo: false
+#| output: false
+from great_docs import tbl_explorer
+```
+
+## Basic Usage
+
+Pass any supported data source to `tbl_explorer()` to get a fully interactive table:
+
+```{python}
+from great_docs import tbl_explorer
+
+tbl_explorer({
+ "city": ["Tokyo", "Paris", "New York", "London", "Sydney"],
+ "population": [13960000, 2161000, 8336000, 8982000, 5312000],
+ "country": ["Japan", "France", "USA", "UK", "Australia"],
+})
+```
+
+The result includes a toolbar with a filter bar, column toggle, copy, download, and reset buttons. Column headers are sortable, and pagination appears at the bottom when the dataset exceeds the page size.
+
+:::{.callout-tip}
+## Hiding the Code Cell
+
+Most of the time you'll want to show just the table itself, not the underlying generation code. Add `#| echo: false` at the top of the code cell to hide the source and display only the rendered explorer:
+
+````markdown
+```{{python}}
+#| echo: false
+tbl_explorer("data/students.csv")
+```
+````
+:::
+
+## Sorting
+
+Click any column header to cycle through three states: **ascending → descending → unsorted**. Sort indicators appear as SVG arrows next to column names.
+
+For multi-column sorting, hold **Shift** while clicking additional columns. Each column sorts within the groups established by prior sort columns. For example, shift-clicking *department* then *salary* will group by department and sort salaries within each department.
+
+```{python}
+#| echo: false
+#| output: false
+# This example is for reference; sorting is demonstrated interactively.
+```
+
+## Token-Based Filtering
+
+The filter system uses structured, token-based filters. Each filter is a discrete token displayed as a blue pill in the filter bar, and all active tokens are ANDed together.
+
+### Adding a Filter
+
+1. Click the **+** button in the filter bar
+2. Select a **column** from the dropdown—each column shows its dtype badge
+3. Choose an **operator**—the available operators depend on the column's data type:
+
+| Column type | Operators |
+|-------------|-----------|
+| String | contains, doesn't contain, starts with, ends with, equals, is empty, is not empty |
+| Numeric | =, ≠, <, ≤, >, ≥, between |
+| Boolean | is true, is false |
+| All types | is null, is not null |
+
+4. For operators that require a value, type it in the input field and press **Enter** or click **Apply**
+
+The *between* operator for numeric columns presents two input fields for the lower and upper bounds of the range.
+
+### Understanding Filter Tokens
+
+Each token shows the column name, operator, and value. Text values appear in single quotes (e.g., `city contains 'york'`). Numeric values are shown unquoted (e.g., `salary > 80000`). Click the **×** on any token to remove that individual filter.
+
+### Case Sensitivity
+
+Text filters are **case-insensitive** by default. When entering a filter value for a text column, an **Aa** toggle button appears next to the input field. Click it to enable case-sensitive matching for that specific filter. Case-sensitive tokens display a small bordered **Aa** badge on the pill.
+
+### Combining Filters
+
+Multiple filters are ANDed together—a row must match *all* active filters to be displayed. You can add multiple filters on the same column (e.g., `salary > 70000` AND `salary < 100000`) or across different columns (e.g., `department contains 'Eng'` AND `rating > 4.0`).
+
+### Search Highlighting
+
+When a "contains" filter is active, matching text in cells is highlighted with a colored background. This makes it easy to spot why each row matched the filter. Disable this with `search_highlight=False`.
+
+## Pagination
+
+Tables are paginated at **20 rows per page** by default. The pagination bar shows:
+
+- The current range (e.g., "Showing 1–20 of 150 rows")
+- **First**, **Previous**, **Next**, and **Last** navigation buttons
+- Page number buttons with ellipsis for large page counts
+
+The row count updates to reflect any active filters (e.g., "Showing 1–5 of 5 rows (filtered from 24)").
+
+To disable pagination and show all rows at once, set `page_size=0`:
+
+```{python}
+tbl_explorer(
+ "../assets/data/students.csv",
+ page_size=0,
+)
+```
+
+Or set a custom page size:
+
+```{python}
+tbl_explorer(
+ "../assets/data/employees.csv",
+ page_size=10,
+)
+```
+
+## Column Toggle
+
+Click the **Columns** button in the toolbar to open a dropdown with checkboxes for each column. Uncheck columns to hide them from the table; check them again to restore them. At least one column must remain visible—the last visible column's checkbox is disabled.
+
+This is useful for wide datasets where you want to focus on specific columns without modifying the underlying data.
+
+## Copy and Download
+
+The toolbar includes two data-export buttons:
+
+- **Copy** (clipboard icon) — copies the currently visible page of data as tab-separated values to the clipboard. On success, the icon briefly turns into a green checkmark. The copied data includes column headers and respects the current sort and filter state.
+- **Download** (download icon) — downloads the full filtered dataset (all pages, not just the current page) as a CSV file. The filename is based on the table's `id` attribute.
+
+## Reset
+
+The **Reset** button (↺ icon) restores the table to its initial state in one click, clearing all:
+
+- Sort states
+- Filter tokens
+- Column visibility changes
+- Pagination (returns to page 1)
+
+## From a File
+
+Like `tbl_preview()`, you can pass a file path directly. The file extension determines the loader:
+
+```{python}
+tbl_explorer("../assets/data/students.csv", page_size=5)
+```
+
+Supported file formats: `.csv`, `.tsv`, `.jsonl` (or `.ndjson`), `.parquet`, `.feather`, `.arrow`.
+
+:::{.callout-tip}
+## Where to Put Data Files
+
+We recommend placing data files in an `assets/data/` directory at the root of your project. This keeps them version-controlled, easy to reference from any `.qmd` page, and separate from images and other static assets:
+
+```
+my-package/
+├── assets/
+│ └── data/
+│ ├── students.csv
+│ └── products.tsv
+├── reference/
+└── user-guide/
+```
+
+Both `tbl_explorer()` and `tbl_preview()` resolve file paths relative to the working directory, which is typically the project root during a Quarto build.
+:::
+
+## From a DataFrame
+
+Pass a Polars or Pandas DataFrame directly:
+
+```{python}
+import polars as pl
+
+df = pl.DataFrame({
+ "product": ["Widget", "Gadget", "Gizmo", "Doohickey", "Thingamajig"],
+ "category": ["Electronics", "Tools", "Kitchen", "Garden", "Office"],
+ "price": [29.99, 49.50, 12.00, 8.75, 199.99],
+ "in_stock": [True, False, True, True, False],
+ "rating": [4.5, 3.8, 4.9, 4.2, 2.1],
+})
+
+tbl_explorer(df)
+```
+
+## Column Subset
+
+Use the `columns=` parameter to show only specific columns:
+
+```{python}
+tbl_explorer(
+ "../assets/data/employees.csv",
+ columns=["name", "department", "salary"],
+)
+```
+
+## Quarto Shortcode
+
+The `{{{< tbl-explorer >}}}` shortcode brings the interactive explorer to `.qmd` pages without writing any Python. The `file=` parameter specifies the data file path relative to the `.qmd` file:
+
+```{markdown}
+{{< tbl-explorer file="data/example.csv" >}}
+```
+
+All parameters can be set as shortcode attributes:
+
+```{markdown}
+{{{< tbl-explorer file="data.csv" page_size="25" sortable="true" >}}}
+{{{< tbl-explorer file="data.csv" column_toggle="false" downloadable="false" >}}}
+{{{< tbl-explorer file="data.csv" columns="name,department,salary" >}}}
+```
+
+Boolean parameters accept `"true"` or `"false"` as strings. The `columns=` parameter accepts a comma-separated list of column names.
+
+## Minimal Chrome
+
+You can selectively disable interactive features for a cleaner, read-only presentation. For example, to show a sortable-only table with no toolbar controls:
+
+```{python}
+tbl_explorer(
+ "../assets/data/products.tsv",
+ sortable=True,
+ filterable=False,
+ column_toggle=False,
+ copyable=False,
+ downloadable=False,
+ page_size=0,
+)
+```
+
+## Progressive Enhancement
+
+The `tbl_explorer()` output is designed with **progressive enhancement** in mind. The initial HTML contains a fully rendered static table (the first page of data) that is readable without JavaScript. When JavaScript is available, the static table is enhanced with interactive controls.
+
+This means:
+
+- **RSS feeds and email** — readers see a usable static table
+- **Search engines** — table content is indexable
+- **Accessibility** — the base table works with screen readers
+- **No-JS environments** — content is still visible
+
+## Choosing Between `tbl_preview()` and `tbl_explorer()`
+
+| | `tbl_preview()` | `tbl_explorer()` |
+|---|---|---|
+| **Interactivity** | None (static HTML) | Sorting, filtering, pagination, column toggle |
+| **JavaScript** | Not required | Required for interactivity; static fallback without it |
+| **Data embedding** | Head/tail rows only | All rows as inline JSON |
+| **Best for** | Quick dataset snapshots, lightweight pages | Exploratory data docs, dashboards, reference tables |
+| **Large datasets** | Efficient (shows only head + tail) | All data embedded (watch page weight) |
+| **Dark mode** | Full support | Full support |
+
+## Parameters Reference
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `data` | various | — | Data source: DataFrame, file path, dict, or list of dicts |
+| `columns` | `list[str]` | `None` | Column subset to display (all if `None`) |
+| `show_row_numbers` | `bool` | `True` | Show row-number gutter column |
+| `show_dtypes` | `bool` | `True` | Show dtype labels under column names |
+| `show_dimensions` | `bool` | `True` | Show header banner with type badge and counts |
+| `max_col_width` | `int` | `250` | Maximum column width in pixels |
+| `min_tbl_width` | `int` | `500` | Minimum total table width in pixels |
+| `caption` | `str` | `None` | Caption text above column headers |
+| `highlight_missing` | `bool` | `True` | Highlight `None`/`NaN`/`NA` in red |
+| `page_size` | `int` | `20` | Rows per page (`0` to disable pagination) |
+| `sortable` | `bool` | `True` | Enable click-to-sort on column headers |
+| `filterable` | `bool` | `True` | Enable the token-based filter bar |
+| `column_toggle` | `bool` | `True` | Enable column visibility dropdown |
+| `copyable` | `bool` | `True` | Enable copy-to-clipboard button |
+| `downloadable` | `bool` | `True` | Enable CSV download button |
+| `resizable` | `bool` | `False` | Enable column drag-resize (reserved for future use) |
+| `sticky_header` | `bool` | `True` | Make column headers sticky on vertical scroll |
+| `search_highlight` | `bool` | `True` | Highlight matching text for "contains" filters |
+| `id` | `str` | auto | Custom HTML `id` for the container |
+
+The return value is a `TblExplorer` object with `_repr_html_()` (for notebook display), `as_html()` (returns the HTML string), and `save(path)` (writes HTML to a file) methods.