Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/syntax/tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions src/Elastic.Documentation.Site/Assets/markdown/table.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { FilterableTableElement } from './FilterableTable'

// The module registers <filterable-table> on import.
const TABLE_HTML = `
<filterable-table>
<div class="table-wrapper">
<table>
<thead><tr><th>Component</th><th>Support status</th></tr></thead>
<tbody>
<tr><td>filelogreceiver</td><td>Core</td></tr>
<tr><td>apachereceiver</td><td>Extended</td></tr>
<tr><td>k8sclusterreceiver</td><td>Core</td></tr>
<tr><td>hostmetricsreceiver</td><td>Core</td></tr>
</tbody>
</table>
</div>
</filterable-table>`

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<HTMLTableRowElement>('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<HTMLInputElement>(
'.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<HTMLSelectElement>(
'.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<HTMLInputElement>(
'.filterable-table-search'
)!
const select = document.querySelector<HTMLSelectElement>(
'.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')
})
})
Original file line number Diff line number Diff line change
@@ -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<string>()
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 }
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,17 @@ public class TableDirectiveBlock(DirectiveBlockParser parser, ParserContext cont
/// </summary>
public bool Matrix { get; private set; }

/// <summary>
/// When set, wraps the table in a <c>filterable-table</c> 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.
/// </summary>
public bool Filterable { get; private set; }

public override void FinalizeAndValidate(ParserContext context)
{
Matrix = PropBool("matrix");
Filterable = PropBool("filterable");

var widthsValue = Prop("widths")?.Trim();

Expand Down
Loading
Loading