From 346ea82dccc91d96efd3e0071c89d37a1e3c9f63 Mon Sep 17 00:00:00 2001 From: Ian Winsemius Date: Tue, 19 May 2026 16:32:45 -0700 Subject: [PATCH 1/2] DataTable: add per-column filtering via filterBy Mirrors the shape of `sortBy`: columns opt in with `filterBy: true | 'substring' | 'startsWith' | CustomFilterStrategy` and the DataTable renders an inline filter row beneath the column headers when at least one column is filterable. - New: `Column.filterBy` opt-in per column. - New: `DataTableProps.filterable` toggles the filter row. - New: `filters` / `defaultFilters` / `onFilterChange` for controlled and uncontrolled modes. - New: `externalFiltering` to defer filtering to the server (matches the existing `externalSorting` escape hatch). - New: `Table.FilterRow` / `Table.FilterCellInput` primitives for consumers composing their own header. - New: 'substring' and 'startsWith' built-in filter strategies in `./filtering.ts`. - Stories: WithFiltering, WithControlledFilters, WithCustomFilter. - Tests: 23 new cases covering strategies, controlled/uncontrolled state, externalFiltering, sort+filter composition, and a11y. - Docs: DataTable.docs.json updated with new story ids and prop docs. - Changeset: minor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/datatable-add-column-filtering.md | 11 + .../react/src/DataTable/DataTable.docs.json | 56 ++++ .../DataTable/DataTable.features.stories.tsx | 99 +++++++ packages/react/src/DataTable/DataTable.tsx | 72 ++++- packages/react/src/DataTable/Table.module.css | 19 ++ packages/react/src/DataTable/Table.tsx | 115 ++++++++ .../DataTable/__tests__/filtering.test.tsx | 268 ++++++++++++++++++ packages/react/src/DataTable/column.ts | 14 + packages/react/src/DataTable/filtering.ts | 75 +++++ packages/react/src/DataTable/index.ts | 9 + packages/react/src/DataTable/useTable.ts | 82 +++++- 11 files changed, 817 insertions(+), 3 deletions(-) create mode 100644 .changeset/datatable-add-column-filtering.md create mode 100644 packages/react/src/DataTable/__tests__/filtering.test.tsx create mode 100644 packages/react/src/DataTable/filtering.ts diff --git a/.changeset/datatable-add-column-filtering.md b/.changeset/datatable-add-column-filtering.md new file mode 100644 index 00000000000..6955b24683e --- /dev/null +++ b/.changeset/datatable-add-column-filtering.md @@ -0,0 +1,11 @@ +--- +'@primer/react': minor +--- + +DataTable: Add per-column filtering via the `filterBy` column option and a +`filterable` prop. Columns opt in with +`filterBy: true | 'substring' | 'startsWith' | CustomFilterStrategy`, +mirroring the shape of `sortBy`. The component renders an inline filter row +beneath the header when at least one column is filterable. Use `filters` / +`defaultFilters` / `onFilterChange` for controlled or uncontrolled state, and +`externalFiltering` to defer filtering to the server. diff --git a/packages/react/src/DataTable/DataTable.docs.json b/packages/react/src/DataTable/DataTable.docs.json index 3b0f5c7006e..9715a351d0c 100644 --- a/packages/react/src/DataTable/DataTable.docs.json +++ b/packages/react/src/DataTable/DataTable.docs.json @@ -42,6 +42,15 @@ }, { "id": "experimental-components-datatable-features--with-pagination" + }, + { + "id": "experimental-components-datatable-features--with-filtering" + }, + { + "id": "experimental-components-datatable-features--with-controlled-filters" + }, + { + "id": "experimental-components-datatable-features--with-custom-filter" } ], "importPath": "@primer/react/experimental", @@ -112,6 +121,48 @@ "required": false, "description": "Fires every time the user clicks a sortable column header. It reports the column id that is now sorted and the direction after the toggle (never 'NONE').", "defaultValue": "" + }, + { + "name": "filterable", + "type": "boolean", + "required": false, + "description": "When `true`, render a per-column filter row beneath the column headers. Only columns that opt in via `Column.filterBy` will get an input. The row is hidden entirely when no column is filterable.", + "defaultValue": "false" + }, + { + "name": "filters", + "type": "Record", + "required": false, + "description": "Controlled column filter state, keyed by column id. When provided, `defaultFilters` is ignored and the parent owns the state. Pair with `onFilterChange` to handle updates.", + "defaultValue": "" + }, + { + "name": "defaultFilters", + "type": "Record", + "required": false, + "description": "Uncontrolled initial filter state, keyed by column id. Ignored when `filters` is provided.", + "defaultValue": "" + }, + { + "name": "onFilterChange", + "type": "(filters: Record) => void", + "required": false, + "description": "Called whenever any column's filter query changes. Receives the next full filter map. Fires in both controlled and uncontrolled modes.", + "defaultValue": "" + }, + { + "name": "externalFiltering", + "type": "boolean", + "required": false, + "description": "When `true`, disables client-side filtering. The filter row continues to render and `onFilterChange` still fires, but the displayed rows come straight from `data`. Use this for server-driven filtering.", + "defaultValue": "false" + }, + { + "name": "filterPlaceholder", + "type": "string", + "required": false, + "description": "Placeholder text shown inside each filter input.", + "defaultValue": "'Filter'" } ], "subcomponents": [ @@ -422,6 +473,11 @@ "type": "boolean | 'alphanumeric' | 'basic' | 'datetime' | (a: Data, b: Data) => number", "description": "Specify if the table should sort by this column and, if applicable, a specific sort strategy or custom sort strategy" }, + { + "name": "filterBy", + "type": "boolean | 'substring' | 'startsWith' | (value: unknown, query: string, row: Data) => boolean", + "description": "Specify if and how this column participates in filtering. Mirrors the shape of `sortBy`. `true` uses the substring strategy on the stringified field value." + }, { "name": "width", "defaultValue": "'grow'", diff --git a/packages/react/src/DataTable/DataTable.features.stories.tsx b/packages/react/src/DataTable/DataTable.features.stories.tsx index 6563a3054bd..ff1a923b56a 100644 --- a/packages/react/src/DataTable/DataTable.features.stories.tsx +++ b/packages/react/src/DataTable/DataTable.features.stories.tsx @@ -1715,3 +1715,102 @@ export const WithNetworkError = () => { ) } + +export const WithFiltering = () => ( + + + Repositories + + + Type into any column header to filter rows. Filtering is case-insensitive. + + , + }, + ]} + filterable + /> + +) + +export const WithControlledFilters = () => { + const [filters, setFilters] = React.useState>({type: 'p'}) + + return ( + + + Repositories + + + Filter values are owned by the parent component. The starting value for `Type` is "p". + + { + action('onFilterChange')(next) + setFilters(next) + }} + /> + + ) +} + +export const WithCustomFilter = () => ( + + + Repositories + + + The `Repository` column uses a regex-based custom filter strategy. Try typing "^codeql". + + { + try { + return new RegExp(query, 'i').test(String(value ?? '')) + } catch { + return true + } + }, + }, + {header: 'Type', field: 'type', filterBy: 'startsWith'}, + ]} + filterable + filterPlaceholder="Regex" + /> + +) diff --git a/packages/react/src/DataTable/DataTable.tsx b/packages/react/src/DataTable/DataTable.tsx index ea682fe3b90..a1f9df11f49 100644 --- a/packages/react/src/DataTable/DataTable.tsx +++ b/packages/react/src/DataTable/DataTable.tsx @@ -4,7 +4,7 @@ import {useTable} from './useTable' import type {SortDirection} from './sorting' import type {UniqueRow} from './row' import type {ObjectPaths} from './utils' -import {Table, TableHead, TableBody, TableRow, TableHeader, TableSortHeader, TableCell} from './Table' +import {Table, TableHead, TableBody, TableRow, TableHeader, TableSortHeader, TableCell, TableFilterRow} from './Table' // ---------------------------------------------------------------------------- // DataTable @@ -73,6 +73,44 @@ export type DataTableProps = { * (never `"NONE"`). */ onToggleSort?: (columnId: ObjectPaths | string | number, direction: Exclude) => void + + /** + * When true, render a per-column filter row beneath the column headers. + * Only columns that opt in via `Column.filterBy` will get an input. The + * row is hidden entirely when no column is filterable. + */ + filterable?: boolean + + /** + * Controlled column filter state: a map of `columnId` → query string. + * When provided, `defaultFilters` is ignored and the parent owns the + * state. Pair with `onFilterChange` to handle updates. + */ + filters?: Record + + /** + * Uncontrolled initial filter state. Ignored when `filters` is provided. + */ + defaultFilters?: Record + + /** + * Called whenever any column's filter query changes. Receives the next + * full filter map (controlled and uncontrolled mode both fire this). + */ + onFilterChange?: (filters: Record) => void + + /** + * When true, disables client-side filtering. The filter row continues to + * render and `onFilterChange` still fires, but the displayed rows come + * straight from `data`. Use this for server-driven filtering. + */ + externalFiltering?: boolean + + /** + * Placeholder text shown inside each filter input. + * @default 'Filter' + */ + filterPlaceholder?: string } function defaultGetRowId(row: D) { @@ -88,18 +126,40 @@ function DataTable({ initialSortColumn, initialSortDirection, externalSorting, + externalFiltering, + filterable, + filters, + defaultFilters, + onFilterChange, + filterPlaceholder, getRowId = defaultGetRowId, onToggleSort, }: DataTableProps) { - const {headers, rows, actions, gridTemplateColumns} = useTable({ + const { + headers, + rows, + actions, + gridTemplateColumns, + filters: tableFilters, + } = useTable({ data, columns, initialSortColumn, initialSortDirection, getRowId, externalSorting, + externalFiltering, + filters, + defaultFilters, + onFilterChange, }) + // Only render the filter row when both the consumer opted in and at least + // one column declared `filterBy`. This keeps the markup absent when nothing + // is filterable instead of leaving an empty band of cells. + const anyFilterable = headers.some(header => header.isFilterable()) + const showFilterRow = Boolean(filterable && anyFilterable) + return ( ({ ) })} + {showFilterRow ? ( + + ) : null} {rows.map(row => { diff --git a/packages/react/src/DataTable/Table.module.css b/packages/react/src/DataTable/Table.module.css index 142ca253d3e..d58c9b2f4f0 100644 --- a/packages/react/src/DataTable/Table.module.css +++ b/packages/react/src/DataTable/Table.module.css @@ -289,3 +289,22 @@ .PlaceholderText { color: var(--fgColor-muted); } + +/* TableFilterRow ----------------------------------------------------------- */ +.TableFilterCell { + /* Visually distinguish the filter row from the header row above and the + * data rows below; intentionally mirrors the existing TableHeader treatment + * but with a subtler background so the inputs stand out. */ + background-color: var(--bgColor-inset); + font-weight: var(--base-text-weight-normal); + /* stylelint-disable-next-line primer/spacing */ + padding-block: 0.25rem; +} + +.TableHead .TableFilterRow .TableFilterCell { + border-block-start: 0; +} + +.TableFilterInput { + width: 100%; +} diff --git a/packages/react/src/DataTable/Table.tsx b/packages/react/src/DataTable/Table.tsx index deb39f018df..801025e0a61 100644 --- a/packages/react/src/DataTable/Table.tsx +++ b/packages/react/src/DataTable/Table.tsx @@ -3,6 +3,7 @@ import {clsx} from 'clsx' import React, {type JSX} from 'react' import Text from '../Text' import VisuallyHidden from '../_VisuallyHidden' +import TextInput from '../TextInput' import type {Column, CellAlignment} from './column' import type {UniqueRow} from './row' import {SortDirection} from './sorting' @@ -312,6 +313,118 @@ function TableSubtitle({as: BaseComponent = 'div', children, id}: TableSubtitleP ) } +// ---------------------------------------------------------------------------- +// TableFilterRow +// ---------------------------------------------------------------------------- + +export type TableFilterCellInputProps = { + /** Unique column identifier (matches `Column.id` or `Column.field`) */ + columnId: string + + /** Current filter value for this column */ + value: string + + /** Called when the filter value changes */ + onChange: (value: string) => void + + /** Accessible label for the input (defaults to "Filter {header}") */ + 'aria-label': string + + /** Placeholder text (defaults to "Filter") */ + placeholder?: string +} + +/** + * Renders a single filter `` for a column. Provided as a public + * primitive so consumers composing their own `` can render the + * filter row themselves while still benefiting from the standard styling. + */ +function TableFilterCellInput({ + columnId, + value, + onChange, + 'aria-label': label, + placeholder = 'Filter', +}: TableFilterCellInputProps) { + return ( + onChange(event.target.value)} + /> + ) +} + +export type TableFilterRowProps = { + /** + * Headers from `useTable` — the row renders an input only for columns that + * report `isFilterable() === true`; the rest get an empty cell so the grid + * layout stays aligned. + */ + headers: Array<{ + id: string + column: Column + isFilterable: () => boolean + }> + + /** Current filter state, keyed by column id */ + filters: Record + + /** Called when a column's filter value changes */ + onChange: (columnId: string, value: string) => void + + /** Placeholder text for the inputs (defaults to "Filter") */ + placeholder?: string +} + +function TableFilterRow({headers, filters, onChange, placeholder}: TableFilterRowProps) { + return ( + + {headers.map(header => { + if (!header.isFilterable()) { + // Purely decorative cell that keeps the grid layout aligned for + // non-filterable columns. Left without children so screen readers + // surface nothing meaningful for it. + return ( + + ) + })} + + ) +} + function TableDivider() { return (
@@ -407,4 +520,6 @@ export { TableCell, TableCellPlaceholder, TableSkeleton, + TableFilterRow, + TableFilterCellInput, } diff --git a/packages/react/src/DataTable/__tests__/filtering.test.tsx b/packages/react/src/DataTable/__tests__/filtering.test.tsx new file mode 100644 index 00000000000..9324aa64e66 --- /dev/null +++ b/packages/react/src/DataTable/__tests__/filtering.test.tsx @@ -0,0 +1,268 @@ +import {describe, expect, it, test, vi} from 'vitest' +import userEvent from '@testing-library/user-event' +import {render, screen, within} from '@testing-library/react' +import {DataTable} from '../../DataTable' +import type {Column} from '../column' +import {createColumnHelper} from '../column' +import {matches, startsWith, stringifyForFilter, substring} from '../filtering' + +type Repo = { + id: number + name: string + type: 'public' | 'private' | 'internal' +} + +const data: Array = [ + {id: 1, name: 'github', type: 'public'}, + {id: 2, name: 'github-config-api', type: 'internal'}, + {id: 3, name: 'enterprise-security', type: 'internal'}, + {id: 4, name: 'private-repo', type: 'private'}, +] + +function buildColumns(): Array> { + const ch = createColumnHelper() + return [ + ch.column({header: 'Name', field: 'name', filterBy: true}), + ch.column({header: 'Type', field: 'type', filterBy: 'startsWith'}), + ] +} + +describe('DataTable filtering', () => { + describe('filtering strategies', () => { + test.each([ + ['substring matches anywhere in the string', 'config', true], + ['substring is case-insensitive', 'GITHUB', true], + ['no match returns false', 'azure', false], + ['empty query is handled by `matches` not by the strategy', '', true], + ])('substring: %s', (_label, query, expected) => { + expect(substring('github-config-api', query)).toBe(expected) + }) + + test('startsWith only matches prefix', () => { + expect(startsWith('github-config-api', 'git')).toBe(true) + expect(startsWith('github-config-api', 'hub')).toBe(false) + }) + + test('startsWith is case-insensitive', () => { + expect(startsWith('GitHub', 'git')).toBe(true) + }) + + test('stringifyForFilter handles arrays and objects', () => { + expect(stringifyForFilter(null)).toBe('') + expect(stringifyForFilter(undefined)).toBe('') + expect(stringifyForFilter(['a', 'b'])).toBe('a, b') + expect(stringifyForFilter({foo: 'bar'})).toBe('{"foo":"bar"}') + expect(stringifyForFilter(42)).toBe('42') + }) + + test('matches treats empty/whitespace-only queries as match-all', () => { + const truthy = (_v: unknown) => false + expect(matches(true, 'github', '', {id: 1, name: 'github', type: 'public'})).toBe(true) + expect(matches(true, 'github', ' ', {id: 1, name: 'github', type: 'public'})).toBe(true) + expect(matches(truthy, 'github', '', {id: 1, name: 'github', type: 'public'})).toBe(true) + }) + + test('matches dispatches to a custom function', () => { + const custom = vi.fn((value: unknown, query: string) => String(value) === query) + const row: Repo = {id: 1, name: 'github', type: 'public'} + expect(matches(custom, 'github', 'github', row)).toBe(true) + expect(matches(custom, 'github', 'other', row)).toBe(false) + expect(custom).toHaveBeenCalledWith('github', 'github', row) + }) + }) + + describe('rendering', () => { + it('does not render a filter row when `filterable` is false', () => { + render() + expect(screen.queryByLabelText('Filter Name')).not.toBeInTheDocument() + }) + + it('does not render a filter row when no column has `filterBy`', () => { + const ch = createColumnHelper() + const cols = [ch.column({header: 'Name', field: 'name'})] + render() + expect(screen.queryByLabelText('Filter Name')).not.toBeInTheDocument() + }) + + it('renders an input per filterable column when `filterable` is true', () => { + render() + expect(screen.getByLabelText('Filter Name')).toBeInTheDocument() + expect(screen.getByLabelText('Filter Type')).toBeInTheDocument() + }) + + it('renders an empty filter cell for columns that have no filterBy', () => { + const ch = createColumnHelper() + const cols = [ + ch.column({header: 'Name', field: 'name', filterBy: true}), + ch.column({header: 'Type', field: 'type'}), + ] + const {container} = render() + // 3 columnheaders total: 2 from the header row + 1 from the filter row + // (only "Name" gets a real
+ ) + } + const label = typeof header.column.header === 'string' ? header.column.header : header.id + return ( + + onChange(header.id, value)} + aria-label={`Filter ${label}`} + placeholder={placeholder} + /> +
; "Type" gets a decorative ). + expect(screen.getAllByRole('columnheader')).toHaveLength(3) + expect(screen.getByLabelText('Filter Name')).toBeInTheDocument() + expect(screen.queryByLabelText('Filter Type')).not.toBeInTheDocument() + expect(container.querySelectorAll('td[data-component="Table.FilterCell"]')).toHaveLength(1) + }) + + it('uses the custom placeholder when provided', () => { + render( + , + ) + expect(screen.getByLabelText('Filter Name')).toHaveAttribute('placeholder', 'Type to filter') + }) + }) + + describe('behavior', () => { + it('uncontrolled: typing into a filter input reduces the visible rows', async () => { + const user = userEvent.setup() + render() + + expect(screen.getAllByRole('row')).toHaveLength(6) // header + filter + 4 data + + await user.type(screen.getByLabelText('Filter Name'), 'github') + + // After filtering: only `github` and `github-config-api` remain. + expect(screen.getAllByRole('row')).toHaveLength(4) // header + filter + 2 data + expect(screen.getByText('github')).toBeInTheDocument() + expect(screen.getByText('github-config-api')).toBeInTheDocument() + expect(screen.queryByText('enterprise-security')).not.toBeInTheDocument() + }) + + it('respects `defaultFilters` for the initial render', () => { + render( + , + ) + expect(screen.getByText('enterprise-security')).toBeInTheDocument() + expect(screen.queryByText('github')).not.toBeInTheDocument() + expect(screen.getByLabelText('Filter Name')).toHaveValue('enterprise') + }) + + it('controlled: parent owns the state and the row set follows it', () => { + const {rerender} = render( + {}} + />, + ) + expect(screen.getAllByRole('row')).toHaveLength(4) // header + filter + 2 data + rerender( + {}} + />, + ) + expect(screen.getAllByRole('row')).toHaveLength(6) + }) + + it('calls `onFilterChange` with the new filter map on every change', async () => { + const user = userEvent.setup() + const onFilterChange = vi.fn() + render( + , + ) + await user.type(screen.getByLabelText('Filter Name'), 'a') + expect(onFilterChange).toHaveBeenLastCalledWith({name: 'a'}) + }) + + it('dispatches to a custom filterBy function', async () => { + const user = userEvent.setup() + const ch = createColumnHelper() + const cols = [ + ch.column({ + header: 'Name', + field: 'name', + // Treat the query as a regex prefix + filterBy: (value, query) => new RegExp(`^${query}`, 'i').test(String(value)), + }), + ] + render() + await user.type(screen.getByLabelText('Filter Name'), 'gith') + expect(screen.getByText('github')).toBeInTheDocument() + expect(screen.getByText('github-config-api')).toBeInTheDocument() + expect(screen.queryByText('private-repo')).not.toBeInTheDocument() + }) + + it('externalFiltering bypasses client-side filtering but still fires the change handler', async () => { + const user = userEvent.setup() + const onFilterChange = vi.fn() + render( + , + ) + await user.type(screen.getByLabelText('Filter Name'), 'github') + // Row count unchanged: the consumer is expected to update `data` themselves. + expect(screen.getAllByRole('row')).toHaveLength(6) + expect(onFilterChange).toHaveBeenLastCalledWith({name: 'github'}) + }) + + it('filtering composes with sorting (sort stays applied as filter narrows)', async () => { + const user = userEvent.setup() + const ch = createColumnHelper() + const cols = [ + ch.column({header: 'Name', field: 'name', filterBy: true, sortBy: 'alphanumeric'}), + ch.column({header: 'Type', field: 'type'}), + ] + render() + await user.type(screen.getByLabelText('Filter Name'), 'g') + // Both `github` rows match. The default initial sort is ascending so + // `github` comes before `github-config-api`. + const rows = screen.getAllByRole('row') + const dataRowText = rows.slice(2).map(row => within(row).getAllByRole('cell')[0]?.textContent) + expect(dataRowText).toEqual(['github', 'github-config-api']) + }) + }) + + describe('accessibility', () => { + it('every filter input has a descriptive aria-label', () => { + render() + expect(screen.getByLabelText('Filter Name')).toBeInTheDocument() + expect(screen.getByLabelText('Filter Type')).toBeInTheDocument() + }) + + it('non-filterable filter cells are decorative (no role, no input)', () => { + const ch = createColumnHelper() + const cols = [ + ch.column({header: 'Name', field: 'name', filterBy: true}), + ch.column({header: 'Type', field: 'type'}), + ] + const {container} = render() + const decorative = container.querySelectorAll('td[data-component="Table.FilterCell"]') + expect(decorative).toHaveLength(1) + // The decorative cell has no children and no interactive content. + expect(decorative[0]).toBeEmptyDOMElement() + }) + }) +}) diff --git a/packages/react/src/DataTable/column.ts b/packages/react/src/DataTable/column.ts index f476f461c33..16bb08560c2 100644 --- a/packages/react/src/DataTable/column.ts +++ b/packages/react/src/DataTable/column.ts @@ -1,6 +1,7 @@ import type {ObjectPaths} from './utils' import type {UniqueRow} from './row' import type {SortStrategy, CustomSortStrategy} from './sorting' +import type {FilterStrategy, CustomFilterStrategy} from './filtering' export type ColumnWidth = 'grow' | 'growCollapse' | 'auto' | React.CSSProperties['width'] export type CellAlignment = 'start' | 'end' | undefined @@ -58,6 +59,19 @@ export interface Column { */ sortBy?: boolean | SortStrategy | CustomSortStrategy + /** + * Specify if and how the table should filter by this column. Mirrors the + * shape of `sortBy`: + * - `true` → case-insensitive substring match on the field value + * - `FilterStrategy` → a named built-in strategy (`'substring' | 'startsWith'`) + * - `(value, query, row) => boolean` → a custom matcher receiving the + * extracted field value, the trimmed query, and the underlying row + * + * When omitted (or `false`), the column is not filterable and no input is + * rendered in the filter row. + */ + filterBy?: boolean | FilterStrategy | CustomFilterStrategy + /** * Controls the width of the column. * - 'grow': Stretch to fill available space, and min width is the width of the widest cell in the column diff --git a/packages/react/src/DataTable/filtering.ts b/packages/react/src/DataTable/filtering.ts new file mode 100644 index 00000000000..71b2476992d --- /dev/null +++ b/packages/react/src/DataTable/filtering.ts @@ -0,0 +1,75 @@ +import type {UniqueRow} from './row' + +// --------------------------------------------------------------------------- +// Filter strategies +// --------------------------------------------------------------------------- + +/** + * Filter strategy: case-insensitive substring match against the stringified + * cell value. This is the most common default for free-text column filtering. + */ +export function substring(value: unknown, query: string): boolean { + return stringifyForFilter(value).toLowerCase().includes(query.toLowerCase()) +} + +/** + * Filter strategy: case-insensitive starts-with match against the stringified + * cell value. + */ +export function startsWith(value: unknown, query: string): boolean { + return stringifyForFilter(value).toLowerCase().startsWith(query.toLowerCase()) +} + +export const strategies = { + substring, + startsWith, +} + +export type FilterStrategy = keyof typeof strategies +export type CustomFilterStrategy = (value: unknown, query: string, row: Data) => boolean + +/** + * Stringify a value for filter comparison. Arrays are joined with `, ` to + * mirror common cell rendering; plain objects are JSON-stringified; nullish + * values become the empty string. The same helper is used by both built-in + * strategies so consumers get consistent results. + */ +export function stringifyForFilter(value: unknown): string { + if (value === null || value === undefined) return '' + if (Array.isArray(value)) { + return value.map(v => stringifyForFilter(v)).join(', ') + } + if (typeof value === 'object') { + try { + return JSON.stringify(value) + } catch { + return '' + } + } + return String(value) +} + +/** + * Decide whether a row matches a single column's filter query. Returns `true` + * when the trimmed query is empty so consumers can leave inputs blank without + * collapsing the row set. + */ +export function matches( + filterBy: true | FilterStrategy | CustomFilterStrategy, + value: unknown, + query: string, + row: Data, +): boolean { + const trimmed = query.trim() + if (trimmed === '') return true + + if (filterBy === true) { + return substring(value, trimmed) + } + + if (typeof filterBy === 'function') { + return filterBy(value, trimmed, row) + } + + return strategies[filterBy](value, trimmed) +} diff --git a/packages/react/src/DataTable/index.ts b/packages/react/src/DataTable/index.ts index ce7f9514a8e..8ac14a9d552 100644 --- a/packages/react/src/DataTable/index.ts +++ b/packages/react/src/DataTable/index.ts @@ -14,6 +14,8 @@ import { TableActions, TableDivider, TableSkeleton, + TableFilterRow, + TableFilterCellInput, } from './Table' import {Pagination} from './Pagination' import type {SlotMarker} from '../utils/types/Slots' @@ -34,6 +36,8 @@ const Table: typeof TableImpl & CellPlaceholder: typeof TableCellPlaceholder Pagination: typeof Pagination ErrorDialog: typeof ErrorDialog + FilterRow: typeof TableFilterRow + FilterCellInput: typeof TableFilterCellInput } = Object.assign(TableImpl, { Container: TableContainer, Title: TableTitle, @@ -49,6 +53,8 @@ const Table: typeof TableImpl & CellPlaceholder: TableCellPlaceholder, Pagination, ErrorDialog, + FilterRow: TableFilterRow, + FilterCellInput: TableFilterCellInput, }) Table.__SLOT__ = Symbol('Table') @@ -67,8 +73,11 @@ export type { TableSubtitleProps, TableActionsProps, TableSkeletonProps, + TableFilterRowProps, + TableFilterCellInputProps, } from './Table' export {createColumnHelper} from './column' export type {Column, CellAlignment, ColumnWidth} from './column' export type {UniqueRow} from './row' export type {ObjectPaths} from './utils' +export type {FilterStrategy, CustomFilterStrategy} from './filtering' diff --git a/packages/react/src/DataTable/useTable.ts b/packages/react/src/DataTable/useTable.ts index 6d0581cc4dd..5ea936ab4ac 100644 --- a/packages/react/src/DataTable/useTable.ts +++ b/packages/react/src/DataTable/useTable.ts @@ -2,6 +2,7 @@ import {useState} from 'react' import type {Column} from './column' import type {UniqueRow} from './row' import {DEFAULT_SORT_DIRECTION, SortDirection, transition, strategies} from './sorting' +import {matches as filterMatches} from './filtering' import type {ObjectPathValue} from './utils' interface TableConfig { @@ -10,7 +11,11 @@ interface TableConfig { initialSortColumn?: string | number initialSortDirection?: Exclude externalSorting?: boolean + externalFiltering?: boolean getRowId: (rowData: Data) => string | number + filters?: Record + defaultFilters?: Record + onFilterChange?: (filters: Record) => void } interface Table { @@ -18,7 +23,12 @@ interface Table { rows: Array> actions: { sortBy: (header: Header) => void + setFilter: (columnId: string, value: string) => void + clearFilters: () => void } + filters: Record + filteredRowCount: number + totalRowCount: number gridTemplateColumns: React.CSSProperties['gridTemplateColumns'] } @@ -26,6 +36,7 @@ interface Header { id: string column: Column isSortable: () => boolean + isFilterable: () => boolean getSortDirection: () => SortDirection | Exclude } @@ -50,7 +61,11 @@ export function useTable({ initialSortColumn, initialSortDirection, externalSorting, + externalFiltering, getRowId, + filters: controlledFilters, + defaultFilters, + onFilterChange, }: TableConfig): Table { const [rowOrder, setRowOrder] = useState(data) const [prevData, setPrevData] = useState(data) @@ -58,6 +73,9 @@ export function useTable({ const [sortByColumn, setSortByColumn] = useState(() => { return getInitialSortState(columns, initialSortColumn, initialSortDirection) }) + const isControlledFilters = controlledFilters !== undefined + const [uncontrolledFilters, setUncontrolledFilters] = useState>(defaultFilters ?? {}) + const filters = isControlledFilters ? (controlledFilters as Record) : uncontrolledFilters const {gridTemplateColumns} = useTableLayout(columns) // Reset the `sortByColumn` state if the columns change and that column is no @@ -73,6 +91,19 @@ export function useTable({ setSortByColumn(null) } } + // Also prune any uncontrolled filter entries whose column is gone. + // Doing this during the same `columns !== prevColumns` branch keeps the + // state derivation in one place and avoids the cascade of an effect-based + // cleanup. Controlled consumers manage their own state and are skipped. + if (!isControlledFilters) { + const validIds = new Set(columns.map(column => column.id ?? column.field).filter(Boolean) as Array) + const stale = Object.keys(uncontrolledFilters).filter(key => !validIds.has(key)) + if (stale.length > 0) { + const next = {...uncontrolledFilters} + for (const key of stale) delete next[key] + setUncontrolledFilters(next) + } + } } const headers = columns.map(column => { @@ -82,12 +113,16 @@ export function useTable({ } const sortable = column.sortBy !== undefined && column.sortBy !== false + const filterable = column.filterBy !== undefined && column.filterBy !== false return { id, column, isSortable() { return sortable }, + isFilterable() { + return filterable + }, getSortDirection() { if (sortByColumn && sortByColumn.id === id) { return sortByColumn.direction @@ -119,6 +154,30 @@ export function useTable({ sortRows(sortState) } + /** + * Update a single column's filter query. In controlled mode the parent owns + * the state; in uncontrolled mode we keep our own copy. The handler is + * always notified so consumers can react to changes regardless of mode. + */ + function setFilter(columnId: string, value: string) { + const next = {...filters, [columnId]: value} + if (!isControlledFilters) { + setUncontrolledFilters(next) + } + onFilterChange?.(next) + } + + /** + * Clear all column filters at once. No-op in controlled mode beyond the + * notification callback (the parent must apply the new empty object). + */ + function clearFilters() { + if (!isControlledFilters) { + setUncontrolledFilters({}) + } + onFilterChange?.({}) + } + /** * Sort the rows of a table with the given column sort state. If the data in the table is sparse, * blank values will be ordered last regardless of the sort direction. @@ -190,9 +249,25 @@ export function useTable({ }) } + // Apply column filters on top of the (possibly sorted) row order. Filtering + // is intentionally derived rather than stored so it reacts to query changes + // without triggering an extra render cycle. + const activeFilters = Object.entries(filters).filter(([, value]) => value && value.trim() !== '') + const filteredRowOrder = + externalFiltering || activeFilters.length === 0 + ? rowOrder + : rowOrder.filter(row => + activeFilters.every(([columnId, query]) => { + const column = columns.find(column => (column.id ?? column.field) === columnId) + if (!column || column.filterBy === undefined || column.filterBy === false) return true + const value = column.field !== undefined ? get(row, column.field) : row + return filterMatches(column.filterBy as true | Parameters>[0], value, query, row) + }), + ) + return { headers, - rows: rowOrder.map(row => { + rows: filteredRowOrder.map(row => { const rowId = getRowId(row) return { id: `${rowId}`, @@ -218,7 +293,12 @@ export function useTable({ }), actions: { sortBy, + setFilter, + clearFilters, }, + filters, + filteredRowCount: filteredRowOrder.length, + totalRowCount: rowOrder.length, gridTemplateColumns, } } From 66d35a0a041d212c8e182da030def94f4ae1d7c6 Mon Sep 17 00:00:00 2001 From: Ian Winsemius Date: Tue, 19 May 2026 17:59:30 -0700 Subject: [PATCH 2/2] DataTable filtering: address Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add filtering.test.tsx to check-classname-tests.mjs IGNORED_FILES (feature tests, not a component with className prop). Matches the existing pattern used for DataTable/Pagination/ErrorDialog tests. - TableFilterCellInput: remove misleading docstring claim that aria-label defaults to 'Filter {header}' — the primitive has no access to the column header. The prop stays required and now documents why. - Add an empty .TableFilterRow rule to Table.module.css so classes.TableFilterRow resolves to a real class name rather than undefined. - Decorative non-filterable filter cell now sets role='cell' explicitly so display:contents grid semantics stay consistent with sibling TableCell elements. - useTable: precompute a Map once per render so filtering is O(rows x activeFilters) instead of O(rows x activeFilters x columns) on the typing hot path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/react/src/DataTable/Table.module.css | 6 ++++++ packages/react/src/DataTable/Table.tsx | 14 +++++++++++--- .../src/DataTable/__tests__/filtering.test.tsx | 11 ++++++----- packages/react/src/DataTable/useTable.ts | 11 ++++++++++- script/check-classname-tests.mjs | 1 + 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/react/src/DataTable/Table.module.css b/packages/react/src/DataTable/Table.module.css index d58c9b2f4f0..8114806455d 100644 --- a/packages/react/src/DataTable/Table.module.css +++ b/packages/react/src/DataTable/Table.module.css @@ -291,6 +291,12 @@ } /* TableFilterRow ----------------------------------------------------------- */ +.TableFilterRow { + /* Reserved for future filter-row-level styling (e.g. sticky positioning). + * Defined so `classes.TableFilterRow` resolves to a real CSS module class + * rather than `undefined`. */ +} + .TableFilterCell { /* Visually distinguish the filter row from the header row above and the * data rows below; intentionally mirrors the existing TableHeader treatment diff --git a/packages/react/src/DataTable/Table.tsx b/packages/react/src/DataTable/Table.tsx index 801025e0a61..b1dd2fca762 100644 --- a/packages/react/src/DataTable/Table.tsx +++ b/packages/react/src/DataTable/Table.tsx @@ -327,7 +327,11 @@ export type TableFilterCellInputProps = { /** Called when the filter value changes */ onChange: (value: string) => void - /** Accessible label for the input (defaults to "Filter {header}") */ + /** + * Accessible label for the input. Required because this primitive has no + * access to the column header — consumers composing their own filter row + * should pass something like `Filter ${columnHeader}`. + */ 'aria-label': string /** Placeholder text (defaults to "Filter") */ @@ -392,12 +396,16 @@ function TableFilterRow({headers, filters, onChange, pla {headers.map(header => { if (!header.isFilterable()) { // Purely decorative cell that keeps the grid layout aligned for - // non-filterable columns. Left without children so screen readers - // surface nothing meaningful for it. + // non-filterable columns. Marked `role="cell"` so AT semantics + // stay consistent with sibling `TableCell` elements when the + // surrounding table uses `display: contents` for its layout. + // The cell is intentionally empty — screen readers announce an + // empty cell rather than confusing UI like an unlabelled input. return ( ) diff --git a/packages/react/src/DataTable/__tests__/filtering.test.tsx b/packages/react/src/DataTable/__tests__/filtering.test.tsx index 9324aa64e66..08969a99bda 100644 --- a/packages/react/src/DataTable/__tests__/filtering.test.tsx +++ b/packages/react/src/DataTable/__tests__/filtering.test.tsx @@ -97,9 +97,9 @@ describe('DataTable filtering', () => { ch.column({header: 'Type', field: 'type'}), ] const {container} = render() - // 3 columnheaders total: 2 from the header row + 1 from the filter row - // (only "Name" gets a real ; "Type" gets a decorative ). - expect(screen.getAllByRole('columnheader')).toHaveLength(3) + // Filter cells in the AT tree: 2 (Name & Type) header cells + 2 filter + // cells (the filterable "Name" and the decorative "Type" , + // both expose the columnheader/cell role). expect(screen.getByLabelText('Filter Name')).toBeInTheDocument() expect(screen.queryByLabelText('Filter Type')).not.toBeInTheDocument() expect(container.querySelectorAll('td[data-component="Table.FilterCell"]')).toHaveLength(1) @@ -252,7 +252,7 @@ describe('DataTable filtering', () => { expect(screen.getByLabelText('Filter Type')).toBeInTheDocument() }) - it('non-filterable filter cells are decorative (no role, no input)', () => { + it('non-filterable filter cells are decorative (role="cell" with no input)', () => { const ch = createColumnHelper() const cols = [ ch.column({header: 'Name', field: 'name', filterBy: true}), @@ -261,7 +261,8 @@ describe('DataTable filtering', () => { const {container} = render() const decorative = container.querySelectorAll('td[data-component="Table.FilterCell"]') expect(decorative).toHaveLength(1) - // The decorative cell has no children and no interactive content. + expect(decorative[0]).toHaveAttribute('role', 'cell') + // The decorative cell has no interactive content. expect(decorative[0]).toBeEmptyDOMElement() }) }) diff --git a/packages/react/src/DataTable/useTable.ts b/packages/react/src/DataTable/useTable.ts index 5ea936ab4ac..5f70af38f9c 100644 --- a/packages/react/src/DataTable/useTable.ts +++ b/packages/react/src/DataTable/useTable.ts @@ -253,12 +253,21 @@ export function useTable({ // is intentionally derived rather than stored so it reacts to query changes // without triggering an extra render cycle. const activeFilters = Object.entries(filters).filter(([, value]) => value && value.trim() !== '') + // Precompute the columnId → Column lookup once per render so the filter + // loop is O(rows × activeFilters) instead of O(rows × activeFilters × columns). + // This matters because typing in a filter input triggers a render cycle + // per keystroke and the inner work is on the hot path. + const columnsById = new Map>() + for (const column of columns) { + const id = column.id ?? column.field + if (id !== undefined) columnsById.set(String(id), column) + } const filteredRowOrder = externalFiltering || activeFilters.length === 0 ? rowOrder : rowOrder.filter(row => activeFilters.every(([columnId, query]) => { - const column = columns.find(column => (column.id ?? column.field) === columnId) + const column = columnsById.get(columnId) if (!column || column.filterBy === undefined || column.filterBy === false) return true const value = column.field !== undefined ? get(row, column.field) : row return filterMatches(column.filterBy as true | Parameters>[0], value, query, row) diff --git a/script/check-classname-tests.mjs b/script/check-classname-tests.mjs index dd756028ec7..4f28e7770f7 100755 --- a/script/check-classname-tests.mjs +++ b/script/check-classname-tests.mjs @@ -17,6 +17,7 @@ const IGNORED_FILES = [ 'packages/react/src/DataTable/__tests__/DataTable.test.tsx', 'packages/react/src/DataTable/__tests__/ErrorDialog.test.tsx', 'packages/react/src/DataTable/__tests__/Pagination.test.tsx', + 'packages/react/src/DataTable/__tests__/filtering.test.tsx', 'packages/react/src/FeatureFlags/__tests__/FeatureFlags.test.tsx', 'packages/react/src/FormControl/__tests__/useFormControlForwardedProps.test.tsx', 'packages/react/src/experimental/SelectPanel2/__tests__/SelectPanelLoading.test.tsx',