From 4d59f0c346ec7ac10ba5f71ec953c62fc5c246d6 Mon Sep 17 00:00:00 2001 From: Aleksandra Spilkowska Date: Thu, 17 Sep 2026 12:42:32 +0200 Subject: [PATCH] prototype(table): add :filterable: option with search + auto-facets Opt-in :filterable: option on the {table} directive: - Parser (TableDirectiveBlock) reads PropBool("filterable"), mirroring :matrix:. - View-model wraps the server-rendered table in a host element (composes with :matrix: and :widths:). - Vanilla custom element (progressive enhancement, no framework/hydration cost) adds a search box + auto-generated facet dropdowns for low-cardinality columns, with AND semantics and an aria-live 'Showing N of M' count. - Jest suite (6 tests), CSS, and author docs in syntax/tables.md. Prototype to de-risk a site-wide filterable-table component. Co-authored-by: Cursor --- docs/syntax/tables.md | 44 ++++++ .../Assets/markdown/table.css | 45 ++++++ .../web-components/FilterableTable.test.ts | 100 ++++++++++++++ .../Assets/web-components/FilterableTable.ts | 129 ++++++++++++++++++ .../web-components/loadWebComponents.ts | 1 + .../Myst/Directives/DirectiveHtmlRenderer.cs | 3 +- .../Directives/Table/TableDirectiveBlock.cs | 8 ++ .../Table/TableDirectiveViewModel.cs | 35 ++++- 8 files changed, 357 insertions(+), 8 deletions(-) create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/FilterableTable.test.ts create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/FilterableTable.ts diff --git a/docs/syntax/tables.md b/docs/syntax/tables.md index 8412e993a4..5d881a712b 100644 --- a/docs/syntax/tables.md +++ b/docs/syntax/tables.md @@ -147,6 +147,50 @@ The `{table}` directive's `:matrix:` option highlights the whole row *and* colum ::::: +## Filterable table + +The `{table}` directive's `:filterable:` option adds a search box and per-column facet dropdowns above the table, so readers can narrow long tables without scrolling. Free-text search matches across every column, and facet dropdowns are generated automatically for columns that have a small number of repeated values (for example a type or status column). Facets combine with search using AND semantics, and a live "Showing N of M" count is announced to assistive technologies. + +The table is rendered server-side and stays fully usable without JavaScript; the controls are added only as a progressive enhancement and are hidden when printing. + +:::::{tab-set} + +::::{tab-item} Output +:::{table} +:filterable: + +| Component | Type | Support status | +|-----------|----------|----------------| +| filelogreceiver | Receiver | Core | +| hostmetricsreceiver | Receiver | Core | +| apachereceiver | Receiver | Extended | +| batchprocessor | Processor | Core | +| transformprocessor | Processor | Extended | +| otlpexporter | Exporter | Core | +| kafkaexporter | Exporter | Extended | +::: +:::: + +::::{tab-item} Markdown +```markdown +:::{table} +:filterable: + +| Component | Type | Support status | +|-----------|----------|----------------| +| filelogreceiver | Receiver | Core | +| hostmetricsreceiver | Receiver | Core | +| apachereceiver | Receiver | Extended | +| batchprocessor | Processor | Core | +| transformprocessor | Processor | Extended | +| otlpexporter | Exporter | Core | +| kafkaexporter | Exporter | Extended | +::: +``` +:::: + +::::: + ## Table directive with column widths The `{table}` directive wraps a pipe table and lets you control column widths using a 12-unit grid system (similar to Bootstrap). Use the `:widths:` option to specify how space is distributed across columns. diff --git a/src/Elastic.Documentation.Site/Assets/markdown/table.css b/src/Elastic.Documentation.Site/Assets/markdown/table.css index 31f4f2d8f0..93ccb51aaf 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/table.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/table.css @@ -241,4 +241,49 @@ .table-expand-btn { display: none !important; } + + /* Filter controls are interactive-only; hide them when printing. */ + .filterable-table-controls { + display: none !important; + } +} + +/* Controls injected by the filterable-table web component (`{table}` :filterable:). + Plain CSS (no @apply) so the prototype has no dependency on design tokens. */ +@layer components { + .filterable-table-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin: 0.75rem 0; + } + + .filterable-table-search { + min-width: 16rem; + padding: 0.375rem 0.625rem; + border: 1px solid var(--color-grey-30, #d3dae6); + border-radius: 6px; + font-size: 0.875rem; + } + + .filterable-table-facet { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.875rem; + } + + .filterable-table-facet select { + padding: 0.25rem 0.5rem; + border: 1px solid var(--color-grey-30, #d3dae6); + border-radius: 6px; + font-size: 0.875rem; + } + + .filterable-table-status { + margin-inline-start: auto; + font-size: 0.8125rem; + color: var(--color-grey-70, #69707d); + } } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/FilterableTable.test.ts b/src/Elastic.Documentation.Site/Assets/web-components/FilterableTable.test.ts new file mode 100644 index 0000000000..8549cba9c2 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/FilterableTable.test.ts @@ -0,0 +1,100 @@ +import { FilterableTableElement } from './FilterableTable' + +// The module registers on import. +const TABLE_HTML = ` + +
+ + + + + + + + +
ComponentSupport status
filelogreceiverCore
apachereceiverExtended
k8sclusterreceiverCore
hostmetricsreceiverCore
+
+
` + +function mount(): void { + document.body.innerHTML = TABLE_HTML + // Ensure the custom element is upgraded + connectedCallback ran. + customElements.upgrade(document.body) +} + +const dataRows = () => + Array.from(document.querySelectorAll('tbody tr')) +const visibleRows = () => dataRows().filter((r) => !r.hidden) + +describe('filterable-table', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('is registered as a custom element', () => { + expect(customElements.get('filterable-table')).toBe( + FilterableTableElement + ) + }) + + it('injects a search box and a status region', () => { + mount() + expect( + document.querySelector('.filterable-table-search') + ).not.toBeNull() + expect( + document.querySelector('.filterable-table-status')?.textContent + ).toBe('Showing 4 of 4') + }) + + it('auto-generates a facet only for the low-cardinality column', () => { + mount() + const facets = document.querySelectorAll('.filterable-table-facet') + // "Support status" (Core/Extended) qualifies; "Component" (all unique) does not. + expect(facets).toHaveLength(1) + expect(facets[0].textContent).toContain('Support status') + }) + + it('filters rows by free-text search across all columns', () => { + mount() + const search = document.querySelector( + '.filterable-table-search' + )! + search.value = 'apache' + search.dispatchEvent(new Event('input')) + expect(visibleRows()).toHaveLength(1) + expect(visibleRows()[0].textContent).toContain('apachereceiver') + expect( + document.querySelector('.filterable-table-status')?.textContent + ).toBe('Showing 1 of 4') + }) + + it('filters rows by facet selection', () => { + mount() + const select = document.querySelector( + '.filterable-table-facet select' + )! + select.value = 'Core' + select.dispatchEvent(new Event('change')) + expect(visibleRows()).toHaveLength(3) + expect( + visibleRows().every((r) => r.textContent?.includes('Core')) + ).toBe(true) + }) + + it('combines search and facet (AND semantics)', () => { + mount() + const search = document.querySelector( + '.filterable-table-search' + )! + const select = document.querySelector( + '.filterable-table-facet select' + )! + select.value = 'Core' + select.dispatchEvent(new Event('change')) + search.value = 'host' + search.dispatchEvent(new Event('input')) + expect(visibleRows()).toHaveLength(1) + expect(visibleRows()[0].textContent).toContain('hostmetricsreceiver') + }) +}) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/FilterableTable.ts b/src/Elastic.Documentation.Site/Assets/web-components/FilterableTable.ts new file mode 100644 index 0000000000..71c5a2b1cf --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/FilterableTable.ts @@ -0,0 +1,129 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +// Progressive-enhancement custom element for the `{table}` directive's +// `:filterable:` option. The server renders a normal, fully-usable table; when +// this module loads, it wraps that table with a search box and per-column facet +// dropdowns (auto-generated for low-cardinality columns) and shows/hides rows +// client-side. No framework is required, so there is no hydration cost and the +// table degrades gracefully when JavaScript is unavailable. + +// Columns with at most this many distinct values become facet dropdowns. +const FACET_MAX_DISTINCT = 12 + +type Facet = { colIndex: number; select: HTMLSelectElement } + +class FilterableTableElement extends HTMLElement { + private table: HTMLTableElement | null = null + private rows: HTMLTableRowElement[] = [] + private searchInput: HTMLInputElement | null = null + private facets: Facet[] = [] + private status: HTMLElement | null = null + + connectedCallback(): void { + // Guard against double-initialization (e.g. htmx re-scans). + if (this.dataset.enhanced === 'true') return + this.table = this.querySelector('table') + const tbody = this.table?.tBodies[0] + if (!this.table || !tbody) return + this.rows = Array.from(tbody.rows) + this.buildControls() + this.applyFilters() + this.dataset.enhanced = 'true' + } + + private headerLabels(): string[] { + const headRow = this.table?.tHead?.rows[0] + return headRow + ? Array.from(headRow.cells).map((c) => c.textContent?.trim() ?? '') + : [] + } + + private distinctValues(colIndex: number): string[] { + const values = new Set() + for (const row of this.rows) { + const v = row.cells[colIndex]?.textContent?.trim() ?? '' + if (v) values.add(v) + } + return Array.from(values).sort((a, b) => a.localeCompare(b)) + } + + private buildControls(): void { + const controls = document.createElement('div') + controls.className = 'filterable-table-controls' + + const search = document.createElement('input') + search.type = 'search' + search.placeholder = 'Filter table…' + search.className = 'filterable-table-search' + search.setAttribute('aria-label', 'Filter table') + search.addEventListener('input', () => this.applyFilters()) + controls.appendChild(search) + this.searchInput = search + + this.headerLabels().forEach((label, colIndex) => { + const distinct = this.distinctValues(colIndex) + // Only facet columns that partition the data meaningfully. + if ( + distinct.length < 2 || + distinct.length > FACET_MAX_DISTINCT || + distinct.length >= this.rows.length + ) + return + + const wrapper = document.createElement('label') + wrapper.className = 'filterable-table-facet' + wrapper.append(`${label}: `) + + const select = document.createElement('select') + select.setAttribute('aria-label', `Filter by ${label}`) + select.append(new Option('All', '')) + for (const value of distinct) + select.append(new Option(value, value)) + select.addEventListener('change', () => this.applyFilters()) + + wrapper.appendChild(select) + controls.appendChild(wrapper) + this.facets.push({ colIndex, select }) + }) + + const status = document.createElement('span') + status.className = 'filterable-table-status' + status.setAttribute('aria-live', 'polite') + controls.appendChild(status) + this.status = status + + this.insertBefore(controls, this.firstChild) + } + + private applyFilters(): void { + const query = this.searchInput?.value.trim().toLowerCase() ?? '' + const activeFacets = this.facets + .filter((f) => f.select.value !== '') + .map((f) => ({ colIndex: f.colIndex, value: f.select.value })) + + let visible = 0 + for (const row of this.rows) { + const matchesQuery = + query === '' || + (row.textContent?.toLowerCase().includes(query) ?? false) + const matchesFacets = activeFacets.every( + (f) => + (row.cells[f.colIndex]?.textContent?.trim() ?? '') === + f.value + ) + const show = matchesQuery && matchesFacets + row.hidden = !show + if (show) visible++ + } + + if (this.status) + this.status.textContent = `Showing ${visible} of ${this.rows.length}` + } +} + +if (!customElements.get('filterable-table')) + customElements.define('filterable-table', FilterableTableElement) + +export { FilterableTableElement } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/loadWebComponents.ts b/src/Elastic.Documentation.Site/Assets/web-components/loadWebComponents.ts index 359c94d6ad..d52a1bea0c 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/loadWebComponents.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/loadWebComponents.ts @@ -32,6 +32,7 @@ export function createWebComponentLoader(componentLoaders: ComponentLoaders) { } export const loadWebComponents = createWebComponentLoader({ + 'filterable-table': () => import('./FilterableTable'), 'version-dropdown': () => import('./VersionDropdown'), 'applies-to-popover': () => import('./AppliesToPopover'), 'page-feedback': () => import('./PageFeedback'), diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index bb99b4ace3..d5cce2a45b 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -432,7 +432,8 @@ private static void WriteTableDirective(HtmlRenderer renderer, TableDirectiveBlo { DirectiveBlock = block, ColumnWidths = block.ColumnWidths, - Matrix = block.Matrix + Matrix = block.Matrix, + Filterable = block.Filterable }); RenderRazorSlice(slice, renderer); } diff --git a/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveBlock.cs b/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveBlock.cs index 87f8923cf3..9147fdde46 100644 --- a/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveBlock.cs @@ -28,9 +28,17 @@ public class TableDirectiveBlock(DirectiveBlockParser parser, ParserContext cont /// public bool Matrix { get; private set; } + /// + /// When set, wraps the table in a filterable-table host element so the client + /// can progressively enhance it with a search box and per-column facet filters. + /// The server-rendered table stays fully usable without JavaScript. + /// + public bool Filterable { get; private set; } + public override void FinalizeAndValidate(ParserContext context) { Matrix = PropBool("matrix"); + Filterable = PropBool("filterable"); var widthsValue = Prop("widths")?.Trim(); diff --git a/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveViewModel.cs index b4c6a99b8a..018544890a 100644 --- a/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveViewModel.cs @@ -18,6 +18,12 @@ public partial class TableDirectiveViewModel : DirectiveViewModel public required bool Matrix { get; init; } + /// + /// When set, the rendered table is wrapped in a <filterable-table> custom + /// element for client-side search + facet filtering (progressive enhancement). + /// + public required bool Filterable { get; init; } + /// /// Renders the table content. When is specified, injects a colgroup and table-layout:fixed. /// When is set, adds the table-matrix class to the wrapper. @@ -27,16 +33,26 @@ public HtmlString RenderTableWithColumns() var html = RenderBlock().Value ?? string.Empty; if (Matrix) html = InjectMatrixClass(html); - if (ColumnWidths.Count == 0) - return new HtmlString(html.EnsureTrimmed()); + if (ColumnWidths.Count > 0) + html = InjectColumnWidths(html); + if (Filterable) + html = WrapFilterable(html); + return new HtmlString(html.EnsureTrimmed()); + } + /// + /// Injects a colgroup (and table-layout:fixed) when explicit column widths are + /// configured. Returns the html unchanged when no table tag is found. + /// + private string InjectColumnWidths(string html) + { var tableIndex = html.IndexOf("', tableIndex); if (bracketEnd < 0) - return new HtmlString(html.EnsureTrimmed()); + return html; var colgroup = "" + string.Join("", ColumnWidths.Select(w => string.Format(CultureInfo.InvariantCulture, "", w))) @@ -45,11 +61,16 @@ public HtmlString RenderTableWithColumns() var openingTag = html[tableIndex..bracketEnd]; var hasTableLayout = openingTag.Contains("table-layout", StringComparison.OrdinalIgnoreCase); var newOpening = hasTableLayout ? openingTag + ">" : AppendTableLayoutFixed(openingTag); - var result = html[..tableIndex] + newOpening + colgroup + html[(bracketEnd + 1)..]; - - return new HtmlString(result.EnsureTrimmed()); + return html[..tableIndex] + newOpening + colgroup + html[(bracketEnd + 1)..]; } + /// + /// Wraps the rendered table in a <filterable-table> custom element so the client + /// can progressively enhance it with search + facet filters. Without JavaScript the + /// table renders exactly as before. + /// + private static string WrapFilterable(string html) => "" + html + ""; + /// /// Adds the table-matrix class to the wrapper div's opening tag emitted by WrappedTableRenderer, /// locating that specific tag rather than replacing the class attribute wherever it appears.