DataTable: add per-column filtering via filterBy#7855
Conversation
Mirrors the shape of `sortBy`: columns opt in with `filterBy: true | 'substring' | 'startsWith' | CustomFilterStrategy<Data>` 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 detectedLatest commit: 66d35a0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
Adds first-class per-column filtering to the experimental DataTable by introducing a Column.filterBy API (mirroring sortBy) and rendering an optional inline filter row beneath the header. The implementation supports controlled/uncontrolled filter state, an externalFiltering escape hatch, and exports Table.FilterRow / Table.FilterCellInput primitives for custom compositions.
Changes:
- Add filtering strategies + matching helpers (
substring,startsWith, custom) and integrate filtering intouseTablerow derivation. - Render a filter row in
DataTablewhenfilterableis enabled and at least one column opts intofilterBy. - Update docs, stories, and add a dedicated unit test suite for filtering behavior and a11y.
Show a summary per file
| File | Description |
|---|---|
| packages/react/src/DataTable/useTable.ts | Adds controlled/uncontrolled filter state + derives filtered rows (and counts) on top of current row order. |
| packages/react/src/DataTable/Table.tsx | Introduces TableFilterRow and TableFilterCellInput primitives backed by TextInput. |
| packages/react/src/DataTable/Table.module.css | Adds styling hooks for filter cells/inputs. |
| packages/react/src/DataTable/index.ts | Exposes Table.FilterRow / Table.FilterCellInput and filter strategy types. |
| packages/react/src/DataTable/filtering.ts | New filtering strategy implementations and matches() dispatcher. |
| packages/react/src/DataTable/DataTable.tsx | Wires filterable UI + filter props into useTable and renders the filter row. |
| packages/react/src/DataTable/DataTable.features.stories.tsx | Adds stories demonstrating filtering, controlled filters, and custom strategy usage. |
| packages/react/src/DataTable/DataTable.docs.json | Documents new props and adds story IDs. |
| packages/react/src/DataTable/column.ts | Adds Column.filterBy type + docs. |
| packages/react/src/DataTable/tests/filtering.test.tsx | New test coverage for strategies, rendering rules, controlled/uncontrolled, external filtering, composition with sorting, and a11y. |
| .changeset/datatable-add-column-filtering.md | Minor changeset for the new filtering API surface. |
Copilot's findings
- Files reviewed: 11/11 changed files
- Comments generated: 5
| 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 | ||
| } |
| function TableFilterRow<Data extends UniqueRow>({headers, filters, onChange, placeholder}: TableFilterRowProps<Data>) { | ||
| return ( | ||
| <tr | ||
| className={clsx('TableRow', 'TableFilterRow', classes.TableRow, classes.TableFilterRow)} |
| // 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 ( | ||
| <td | ||
| key={`${header.id}-filter`} | ||
| className={clsx('TableFilterCell', classes.TableFilterCell)} |
| 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<typeof filterMatches<Data>>[0], value, query, row) | ||
| }), | ||
| ) |
| /* 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; | ||
| } |
- 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<columnId, Column> 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>
|
Note The failing |
|
Hi there @ianwinsemius! 👋 I think the filtering pattern for this that we would want to introduce would be what shows up in: https://primer.style/product/components/data-table/guidelines/#anatomy (we just haven't had a team reach out about this use-case before) Could you share more about your use-case for per-column filtering? 👀 |
Closes #
Adds first-class per-column filtering to the experimental
DataTable. Columns opt in with a newfilterByprop that mirrors the shape ofsortBy, and the table renders an inline filter row beneath the column headers when at least one column is filterable.Goals:
sortByergonomics (true| named strategy | custom function) so the column API feels native.filters/onFilterChange) and uncontrolled (defaultFilters) modes.externalFilteringescape hatch that parallels the existingexternalSortingprop.Table.FilterRow/Table.FilterCellInputprimitives for consumers composing their own header.Changelog
New
Column.filterBy?: boolean | 'substring' | 'startsWith' | CustomFilterStrategy<Data>— opts a column into filtering.DataTableProps.filterable— renders the per-column filter row.DataTableProps.filters/defaultFilters/onFilterChange— controlled and uncontrolled filter state.DataTableProps.externalFiltering— defer filtering to the server.DataTableProps.filterPlaceholder— customize the input placeholder.Table.FilterRow/Table.FilterCellInput— public primitives.FilterStrategy/CustomFilterStrategytype exports.WithFiltering,WithControlledFilters,WithCustomFilter.Changed
useTablenow derives filtered rows on top of the sorted row order; no impact on existing consumers.DataTable.docs.jsonupdated with new prop docs and story IDs.Removed
(none)
Rollout strategy
Testing & Reviewing
packages/react/src/DataTable/__tests__/filtering.test.tsxcover strategies (substring,startsWith, custom), rendering (filter row only when at least one column hasfilterBy), behaviour (controlled, uncontrolled,defaultFilters,onFilterChange,externalFiltering, sort+filter composition), and a11y (aria-labels, decorative non-filterable cells).npm run build,npm run type-check,npm run lint,npm run lint:css, andnpm test -- --run packages/react/src/DataTable/all pass locally.Merge checklist