Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/datatable-add-column-filtering.md
Original file line number Diff line number Diff line change
@@ -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<Data>`,
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.
56 changes: 56 additions & 0 deletions packages/react/src/DataTable/DataTable.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<string, string>",
"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<string, string>",
"required": false,
"description": "Uncontrolled initial filter state, keyed by column id. Ignored when `filters` is provided.",
"defaultValue": ""
},
{
"name": "onFilterChange",
"type": "(filters: Record<string, string>) => 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": [
Expand Down Expand Up @@ -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'",
Expand Down
99 changes: 99 additions & 0 deletions packages/react/src/DataTable/DataTable.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1715,3 +1715,102 @@ export const WithNetworkError = () => {
</Table.Container>
)
}

export const WithFiltering = () => (
<Table.Container>
<Table.Title as="h2" id="repositories">
Repositories
</Table.Title>
<Table.Subtitle as="p" id="repositories-subtitle">
Type into any column header to filter rows. Filtering is case-insensitive.
</Table.Subtitle>
<DataTable
aria-labelledby="repositories"
aria-describedby="repositories-subtitle"
data={data}
columns={[
{
header: 'Repository',
field: 'name',
rowHeader: true,
filterBy: true,
sortBy: 'alphanumeric',
},
{
header: 'Type',
field: 'type',
filterBy: 'startsWith',
},
{
header: 'Updated',
field: 'updatedAt',
renderCell: row => <RelativeTime date={new Date(row.updatedAt)} />,
},
]}
filterable
/>
</Table.Container>
)

export const WithControlledFilters = () => {
const [filters, setFilters] = React.useState<Record<string, string>>({type: 'p'})

return (
<Table.Container>
<Table.Title as="h2" id="repositories">
Repositories
</Table.Title>
<Table.Subtitle as="p" id="repositories-subtitle">
Filter values are owned by the parent component. The starting value for `Type` is &quot;p&quot;.
</Table.Subtitle>
<DataTable
aria-labelledby="repositories"
aria-describedby="repositories-subtitle"
data={data}
columns={[
{header: 'Repository', field: 'name', rowHeader: true, filterBy: true},
{header: 'Type', field: 'type', filterBy: 'startsWith'},
]}
filterable
filters={filters}
onFilterChange={next => {
action('onFilterChange')(next)
setFilters(next)
}}
/>
</Table.Container>
)
}

export const WithCustomFilter = () => (
<Table.Container>
<Table.Title as="h2" id="repositories">
Repositories
</Table.Title>
<Table.Subtitle as="p" id="repositories-subtitle">
The `Repository` column uses a regex-based custom filter strategy. Try typing &quot;^codeql&quot;.
</Table.Subtitle>
<DataTable
aria-labelledby="repositories"
aria-describedby="repositories-subtitle"
data={data}
columns={[
{
header: 'Repository',
field: 'name',
rowHeader: true,
filterBy: (value, query) => {
try {
return new RegExp(query, 'i').test(String(value ?? ''))
} catch {
return true
}
},
},
{header: 'Type', field: 'type', filterBy: 'startsWith'},
]}
filterable
filterPlaceholder="Regex"
/>
</Table.Container>
)
72 changes: 70 additions & 2 deletions packages/react/src/DataTable/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,6 +73,44 @@ export type DataTableProps<Data extends UniqueRow> = {
* (never `"NONE"`).
*/
onToggleSort?: (columnId: ObjectPaths<Data> | string | number, direction: Exclude<SortDirection, 'NONE'>) => 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<string, string>

/**
* Uncontrolled initial filter state. Ignored when `filters` is provided.
*/
defaultFilters?: Record<string, string>

/**
* Called whenever any column's filter query changes. Receives the next
* full filter map (controlled and uncontrolled mode both fire this).
*/
onFilterChange?: (filters: Record<string, string>) => 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<D extends UniqueRow>(row: D) {
Expand All @@ -88,18 +126,40 @@ function DataTable<Data extends UniqueRow>({
initialSortColumn,
initialSortDirection,
externalSorting,
externalFiltering,
filterable,
filters,
defaultFilters,
onFilterChange,
filterPlaceholder,
getRowId = defaultGetRowId,
onToggleSort,
}: DataTableProps<Data>) {
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 (
<Table
aria-labelledby={labelledby}
Expand Down Expand Up @@ -134,6 +194,14 @@ function DataTable<Data extends UniqueRow>({
)
})}
</TableRow>
{showFilterRow ? (
<TableFilterRow
headers={headers}
filters={tableFilters}
onChange={actions.setFilter}
placeholder={filterPlaceholder}
/>
) : null}
</TableHead>
<TableBody>
{rows.map(row => {
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/DataTable/Table.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -289,3 +289,28 @@
.PlaceholderText {
color: var(--fgColor-muted);
}

/* 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
* 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;
}
Comment on lines +293 to +312

.TableFilterInput {
width: 100%;
}
Loading
Loading