From 03950665763a6994775a208ae48f650bb0e8382b Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Thu, 23 Apr 2026 21:53:01 -0400 Subject: [PATCH 01/15] Add tbl_explorer() interactive widget --- great_docs/_tbl_explorer.py | 669 ++++++++++++++++++++++++++++++++++++ 1 file changed, 669 insertions(+) create mode 100644 great_docs/_tbl_explorer.py diff --git a/great_docs/_tbl_explorer.py b/great_docs/_tbl_explorer.py new file mode 100644 index 0000000..812d242 --- /dev/null +++ b/great_docs/_tbl_explorer.py @@ -0,0 +1,669 @@ +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(" 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 tabular data. + + Produces a self-contained HTML widget with a static table baseline that is progressively + enhanced by JavaScript to add sorting, filtering, pagination, column toggling, + copy-to-clipboard, and CSV download. + + Parameters + ---------- + data + The table to explore. Accepts the same inputs as `~great_docs.tbl_preview`: Polars/Pandas + DataFrames, PyArrow Tables, file paths (CSV/TSV/JSONL/Parquet/Feather/Arrow IPC), + column-oriented dicts, or row-oriented lists of dicts. + columns + Subset of columns to show, by default `None` (all columns). + show_row_numbers + Display a row-number gutter column on the left. + show_dtypes + Display short dtype labels beneath column names. + show_dimensions + Display the header banner with source-type badge and row/column counts. + max_col_width + Maximum pixel width for any column. + min_tbl_width + Minimum total table width in pixels. + caption + Optional caption text displayed above the column headers. + highlight_missing + Highlight `None`/`NaN`/`NA` values in red. + page_size + Number of rows per page. Set to `0` to disable pagination and show all rows at once. + sortable + Enable click-to-sort on column headers. + filterable + Enable the global filter text input. + column_toggle + Enable the column-visibility dropdown. + copyable + Enable the copy-to-clipboard button. + downloadable + Enable the CSV download button. + resizable + Enable column drag-resize (reserved for future use). + sticky_header + Make column headers sticky on vertical scroll. + search_highlight + Highlight matching cell text when filtering. + id + HTML `id` for the container. Auto-generated if `None`. + + Returns + ------- + TblExplorer + A rendered interactive table object with `_repr_html_()` support. + + Examples + -------- + ```{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"], + }) + ``` + """ + 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"{colgroup}\n" + f"\n{header}\n{column_labels}\n\n" + f"{body}\n" + f"
\n" + f"\n" + f"
" + ) + + return TblExplorer(html) From e8302d307d65a385ffa61af3713903d5914cb1b3 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Thu, 23 Apr 2026 21:53:22 -0400 Subject: [PATCH 02/15] Export tbl_explorer() from package init --- great_docs/__init__.py | 2 ++ 1 file changed, 2 insertions(+) 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", ] From 3ab0efb45c7a5edf00b4204754a550da52f73677 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Thu, 23 Apr 2026 21:53:48 -0400 Subject: [PATCH 03/15] Add tbl-explorer Quarto shortcode extension --- .../_extensions/tbl-explorer/_extension.yml | 7 ++ .../tbl-explorer/_tbl_explorer_shortcode.py | 88 +++++++++++++++++ .../_extensions/tbl-explorer/tbl-explorer.lua | 94 +++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 great_docs/assets/_extensions/tbl-explorer/_extension.yml create mode 100644 great_docs/assets/_extensions/tbl-explorer/_tbl_explorer_shortcode.py create mode 100644 great_docs/assets/_extensions/tbl-explorer/tbl-explorer.lua 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 +} From 34d8cb321bb23af218d61f59c83999c134d019e6 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Thu, 23 Apr 2026 21:54:28 -0400 Subject: [PATCH 04/15] Add interactive table explorer JS --- great_docs/assets/tbl-explorer.js | 735 ++++++++++++++++++++++++++++++ 1 file changed, 735 insertions(+) create mode 100644 great_docs/assets/tbl-explorer.js diff --git a/great_docs/assets/tbl-explorer.js b/great_docs/assets/tbl-explorer.js new file mode 100644 index 0000000..712c27b --- /dev/null +++ b/great_docs/assets/tbl-explorer.js @@ -0,0 +1,735 @@ +/** + * 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.filterQuery = ""; + this.visibleCols = this.columns.map(function (_, i) { return i; }); + this.currentPage = 1; + this.pageSize = this.cfg.pageSize || 20; + } + + // ── 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 input = document.createElement("input"); + input.type = "search"; + input.className = "gd-tbl-filter"; + input.placeholder = "Filter all columns\u2026"; + input.setAttribute("aria-label", "Filter all columns"); + input.addEventListener("input", debounce(function () { + state.filterQuery = input.value; + state.currentPage = 1; + applyFilter(state); + applyState(el, state); + }, DEBOUNCE_MS)); + bar.appendChild(input); + } + + 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 table + var tbl = el.querySelector("table"); + if (tbl) { + el.insertBefore(bar, tbl); + } + } + + // ── Column Toggle ────────────────────────────────────────── + + function buildColumnToggle(el, state) { + var wrap = document.createElement("span"); + wrap.className = "gd-tbl-col-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 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(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.filterQuery) { + state.filteredRows = state.allRows.slice(); + } else { + var q = state.filterQuery.toLowerCase(); + state.filteredRows = state.allRows.filter(function (row) { + for (var i = 0; i < state.visibleCols.length; i++) { + var ci = state.visibleCols[i]; + var v = row[ci]; + if (v != null && String(v).toLowerCase().indexOf(q) !== -1) { + return true; + } + } + return false; + }); + } + // Re-apply sort after filter + if (state.sortCols.length > 0) { + applySort(state); + } + } + + // ── 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.sortCols = []; + state.currentPage = 1; + state.visibleCols = state.columns.map(function (_, i) { return i; }); + state.filteredRows = state.allRows.slice(); + + // Reset filter input + var input = el.querySelector(".gd-tbl-filter"); + if (input) input.value = ""; + + // 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); + + if (state.filterQuery && state.cfg.searchHighlight && !isMissing) { + td.innerHTML = highlightText(escapeHTML(cellText), state.filterQuery); + } 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(); + } +})(); From b6754722e966f7ced5cdf30bdd7de61e35771083 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Thu, 23 Apr 2026 21:54:57 -0400 Subject: [PATCH 05/15] Add gdtest_tbl_explorer showcase spec --- test-packages/synthetic/catalog.py | 11 + .../synthetic/specs/gdtest_tbl_explorer.py | 669 ++++++++++++++++++ 2 files changed, 680 insertions(+) create mode 100644 test-packages/synthetic/specs/gdtest_tbl_explorer.py 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" + ), + }, +} From da295a8c56c141a318c9be823475a94a421f24a3 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Thu, 23 Apr 2026 23:05:05 -0400 Subject: [PATCH 06/15] Refactor table filter UI & add filter wizard --- great_docs/_tbl_explorer.py | 303 ++++++++++++++++++++++++++++++++++-- 1 file changed, 289 insertions(+), 14 deletions(-) diff --git a/great_docs/_tbl_explorer.py b/great_docs/_tbl_explorer.py index 812d242..04d8cad 100644 --- a/great_docs/_tbl_explorer.py +++ b/great_docs/_tbl_explorer.py @@ -116,23 +116,221 @@ def _render_explorer_css(uid: str) -> str: font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif; font-size: 13px; }} -{s} .gd-tbl-filter {{ +/* ── Filter bar ──────────────────────────────────── */ +{s} .gd-tbl-filter-bar {{ flex: 1 1 200px; - min-width: 150px; - padding: 6px 10px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + min-height: 30px; + padding: 3px 6px; border: 1px solid #ccc; border-radius: 4px; - font-size: 13px; + background: #fff; + position: relative; +}} +{s} .gd-tbl-filter-tokens {{ + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +}} +{s} .gd-tbl-filter-token {{ + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px 4px 2px 8px; + background: #e8f0fe; + border: 1px solid #c4d9f2; + border-radius: 12px; + font-size: 11px; + color: #1a3a5c; + white-space: nowrap; + max-width: 260px; + line-height: 1.4; +}} +{s} .gd-tbl-filter-token-text {{ + overflow: hidden; + text-overflow: ellipsis; +}} +{s} .gd-tbl-filter-token-x {{ + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: none; + background: #d0dfef; + color: #4a6a8a; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border-radius: 50%; + padding: 0; + padding-bottom: 2px; + line-height: 1; + flex-shrink: 0; + transition: background 0.1s, color 0.1s; +}} +{s} .gd-tbl-filter-token-x:hover {{ + background: #a0bdd8; + color: #1a3a5c; +}} +{s} .gd-tbl-filter-token-case {{ + font-size: 9px; + font-weight: 700; + color: #4477aa; + border: 1px solid #a0bdd8; + border-radius: 3px; + padding: 0 3px; + line-height: 1.4; + flex-shrink: 0; + font-family: 'IBM Plex Sans', system-ui, sans-serif; +}} +{s} .gd-tbl-filter-add {{ + flex-shrink: 0; + border: none; + background: none; + padding: 3px; + color: #6699CC; +}} +{s} .gd-tbl-filter-add:hover {{ + background: #eef3fb; + border-radius: 3px; +}} +/* ── Filter wizard dropdown ──────────────────────── */ +{s} .gd-tbl-filter-wizard {{ + position: absolute; + top: calc(100% + 2px); + left: 0; + z-index: 200; + background: #fff; + border: 1px solid #ccc; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.12); + padding: 8px 0; + min-width: 200px; + max-width: 320px; + max-height: 300px; + overflow-y: auto; + font-size: 12px; +}} +{s} .gd-tbl-fw-label {{ + display: block; + padding: 4px 12px 4px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #888; + font-weight: 600; +}} +{s} .gd-tbl-fw-options {{ + display: flex; + flex-direction: column; +}} +{s} .gd-tbl-fw-option {{ + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 12px; + border: none; + background: none; + text-align: left; + font-size: 12px; + font-family: inherit; + color: #333; + cursor: pointer; + transition: background 0.1s; +}} +{s} .gd-tbl-fw-option:hover {{ + background: #f0f4fb; +}} +{s} .gd-tbl-fw-dtype {{ + font-size: 9px; + color: #999; + background: #f0f0f0; + padding: 1px 5px; + border-radius: 3px; + margin-left: 8px; + font-family: 'IBM Plex Mono', ui-monospace, monospace; +}} +{s} .gd-tbl-fw-input {{ + margin: 4px 12px; + padding: 5px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 12px; font-family: inherit; background: #fff; color: #333; outline: none; - transition: border-color 0.15s; + width: calc(100% - 24px); + box-sizing: border-box; }} -{s} .gd-tbl-filter:focus {{ +{s} .gd-tbl-fw-input:focus {{ border-color: #6699CC; box-shadow: 0 0 0 2px rgba(102,153,204,0.2); }} +{s} .gd-tbl-fw-between {{ + display: flex; + align-items: center; + gap: 0; + padding: 0 4px; +}} +{s} .gd-tbl-fw-between .gd-tbl-fw-input {{ + flex: 1; + margin: 4px; + min-width: 60px; +}} +{s} .gd-tbl-fw-sep {{ + font-size: 11px; + color: #888; + flex-shrink: 0; +}} +{s} .gd-tbl-fw-commit {{ + margin: 4px 12px 6px; + font-size: 11px; + padding: 4px 14px; +}} +{s} .gd-tbl-fw-input-row {{ + display: flex; + align-items: center; + gap: 0; + padding: 0 8px; +}} +{s} .gd-tbl-fw-input-row .gd-tbl-fw-input {{ + flex: 1; + margin: 4px 0; + width: auto; +}} +{s} .gd-tbl-fw-case {{ + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 26px; + margin-left: 4px; + border: 1px solid #ccc; + border-radius: 4px; + background: #f8f8f8; + color: #999; + font-size: 11px; + font-weight: 700; + font-family: 'IBM Plex Sans', system-ui, sans-serif; + cursor: pointer; + flex-shrink: 0; + transition: all 0.15s; +}} +{s} .gd-tbl-fw-case:hover {{ + border-color: #999; + color: #666; +}} +{s} .gd-tbl-fw-case.active {{ + background: #e0edff; + border-color: #6699CC; + color: #336699; +}} {s} .gd-tbl-btn {{ display: inline-flex; align-items: center; @@ -197,8 +395,7 @@ def _render_explorer_css(uid: str) -> str: }} /* Keep tooltip from overflowing right edge */ {s} .gd-tbl-btn-wrap:last-child .gd-tbl-tooltip {{ - left: auto; - right: 0; + left: 0; transform: none; }} {s} .gd-tbl-btn-wrap:hover .gd-tbl-tooltip {{ @@ -324,16 +521,94 @@ def _render_explorer_css(uid: str) -> str: color: #999; }} /* ── Dark mode ───────────────────────────────────── */ -body.quarto-dark {s} .gd-tbl-filter, -html.quarto-dark {s} .gd-tbl-filter, -:root[data-bs-theme="dark"] {s} .gd-tbl-filter {{ +body.quarto-dark {s} .gd-tbl-filter-bar, +html.quarto-dark {s} .gd-tbl-filter-bar, +:root[data-bs-theme="dark"] {s} .gd-tbl-filter-bar {{ background-color: #2a2a3e; border-color: #444; +}} +body.quarto-dark {s} .gd-tbl-filter-token, +html.quarto-dark {s} .gd-tbl-filter-token, +:root[data-bs-theme="dark"] {s} .gd-tbl-filter-token {{ + background: #2d3a50; + border-color: #3d5070; + color: #b0ccee; +}} +body.quarto-dark {s} .gd-tbl-filter-token-x:hover, +html.quarto-dark {s} .gd-tbl-filter-token-x:hover, +:root[data-bs-theme="dark"] {s} .gd-tbl-filter-token-x:hover {{ + background: #3d5070; + color: #e0e8f0; +}} +body.quarto-dark {s} .gd-tbl-filter-token-case, +html.quarto-dark {s} .gd-tbl-filter-token-case, +:root[data-bs-theme="dark"] {s} .gd-tbl-filter-token-case {{ + color: #88bbee; + border-color: #4d6888; +}} +body.quarto-dark {s} .gd-tbl-fw-case, +html.quarto-dark {s} .gd-tbl-fw-case, +:root[data-bs-theme="dark"] {s} .gd-tbl-fw-case {{ + background: #2a2a3e; + border-color: #555; + color: #888; +}} +body.quarto-dark {s} .gd-tbl-fw-case:hover, +html.quarto-dark {s} .gd-tbl-fw-case:hover, +:root[data-bs-theme="dark"] {s} .gd-tbl-fw-case:hover {{ + border-color: #888; + color: #bbb; +}} +body.quarto-dark {s} .gd-tbl-fw-case.active, +html.quarto-dark {s} .gd-tbl-fw-case.active, +:root[data-bs-theme="dark"] {s} .gd-tbl-fw-case.active {{ + background: #2d3a50; + border-color: #6699CC; + color: #88bbee; +}} +body.quarto-dark {s} .gd-tbl-filter-add, +html.quarto-dark {s} .gd-tbl-filter-add, +:root[data-bs-theme="dark"] {s} .gd-tbl-filter-add {{ + color: #88bbee; +}} +body.quarto-dark {s} .gd-tbl-filter-add:hover, +html.quarto-dark {s} .gd-tbl-filter-add:hover, +:root[data-bs-theme="dark"] {s} .gd-tbl-filter-add:hover {{ + background: #353550; +}} +body.quarto-dark {s} .gd-tbl-filter-wizard, +html.quarto-dark {s} .gd-tbl-filter-wizard, +:root[data-bs-theme="dark"] {s} .gd-tbl-filter-wizard {{ + background: #1e1e32; + border-color: #444; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); +}} +body.quarto-dark {s} .gd-tbl-fw-option, +html.quarto-dark {s} .gd-tbl-fw-option, +:root[data-bs-theme="dark"] {s} .gd-tbl-fw-option {{ + color: #ddd; +}} +body.quarto-dark {s} .gd-tbl-fw-option:hover, +html.quarto-dark {s} .gd-tbl-fw-option:hover, +:root[data-bs-theme="dark"] {s} .gd-tbl-fw-option:hover {{ + background: #2a2a44; +}} +body.quarto-dark {s} .gd-tbl-fw-dtype, +html.quarto-dark {s} .gd-tbl-fw-dtype, +:root[data-bs-theme="dark"] {s} .gd-tbl-fw-dtype {{ + background: #333; + color: #aaa; +}} +body.quarto-dark {s} .gd-tbl-fw-input, +html.quarto-dark {s} .gd-tbl-fw-input, +:root[data-bs-theme="dark"] {s} .gd-tbl-fw-input {{ + background: #2a2a3e; + border-color: #555; color: #e0e0e0; }} -body.quarto-dark {s} .gd-tbl-filter:focus, -html.quarto-dark {s} .gd-tbl-filter:focus, -:root[data-bs-theme="dark"] {s} .gd-tbl-filter:focus {{ +body.quarto-dark {s} .gd-tbl-fw-input:focus, +html.quarto-dark {s} .gd-tbl-fw-input:focus, +:root[data-bs-theme="dark"] {s} .gd-tbl-fw-input:focus {{ border-color: #6699CC; box-shadow: 0 0 0 2px rgba(102,153,204,0.3); }} From 12e014d60971ebc013390685536a275274275d1e Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Thu, 23 Apr 2026 23:05:23 -0400 Subject: [PATCH 07/15] Add token-based advanced column filtering UI --- great_docs/assets/tbl-explorer.js | 459 ++++++++++++++++++++++++++++-- 1 file changed, 431 insertions(+), 28 deletions(-) diff --git a/great_docs/assets/tbl-explorer.js b/great_docs/assets/tbl-explorer.js index 712c27b..1e5c227 100644 --- a/great_docs/assets/tbl-explorer.js +++ b/great_docs/assets/tbl-explorer.js @@ -64,10 +64,63 @@ this.filteredRows = this.allRows.slice(); this.sortCols = []; - this.filterQuery = ""; + 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 ─────────────────────────────────────────────────── @@ -115,18 +168,24 @@ bar.setAttribute("aria-label", "Table controls"); if (state.cfg.filterable) { - var input = document.createElement("input"); - input.type = "search"; - input.className = "gd-tbl-filter"; - input.placeholder = "Filter all columns\u2026"; - input.setAttribute("aria-label", "Filter all columns"); - input.addEventListener("input", debounce(function () { - state.filterQuery = input.value; - state.currentPage = 1; - applyFilter(state); - applyState(el, state); - }, DEBOUNCE_MS)); - bar.appendChild(input); + 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) { @@ -163,11 +222,280 @@ } } + // ── 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"; + wrap.className = "gd-tbl-col-wrap gd-tbl-btn-wrap"; var btn = document.createElement("button"); btn.className = "gd-tbl-btn"; @@ -175,6 +503,10 @@ 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"); @@ -235,6 +567,7 @@ }); wrap.appendChild(btn); + wrap.appendChild(tip); wrap.appendChild(menu); return wrap; } @@ -375,19 +708,14 @@ // ── Filtering ────────────────────────────────────────────── function applyFilter(state) { - if (!state.filterQuery) { + if (state.filterTokens.length === 0) { state.filteredRows = state.allRows.slice(); } else { - var q = state.filterQuery.toLowerCase(); state.filteredRows = state.allRows.filter(function (row) { - for (var i = 0; i < state.visibleCols.length; i++) { - var ci = state.visibleCols[i]; - var v = row[ci]; - if (v != null && String(v).toLowerCase().indexOf(q) !== -1) { - return true; - } + for (var i = 0; i < state.filterTokens.length; i++) { + if (!evalToken(state.filterTokens[i], row)) return false; } - return false; + return true; }); } // Re-apply sort after filter @@ -396,6 +724,74 @@ } } + 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) { @@ -472,14 +868,19 @@ 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 input - var input = el.querySelector(".gd-tbl-filter"); - if (input) input.value = ""; + // 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]"); @@ -539,8 +940,10 @@ var cellText = formatCell(val); - if (state.filterQuery && state.cfg.searchHighlight && !isMissing) { - td.innerHTML = highlightText(escapeHTML(cellText), state.filterQuery); + // 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; } From e9f80771f5a532d9a7aa9fe32d8311389ce870e5 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Thu, 23 Apr 2026 23:34:47 -0400 Subject: [PATCH 08/15] Add horizontal scroll wrapper for table explorer --- great_docs/_tbl_explorer.py | 10 +++++++++- great_docs/assets/tbl-explorer.js | 9 +++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/great_docs/_tbl_explorer.py b/great_docs/_tbl_explorer.py index 04d8cad..98dd551 100644 --- a/great_docs/_tbl_explorer.py +++ b/great_docs/_tbl_explorer.py @@ -106,6 +106,12 @@ def _render_explorer_css(uid: str) -> str: """Return CSS for the interactive toolbar, sort indicators, and pagination.""" s = f"#gd-tbl-{uid}" return f"""