Grid Filtering
Filtering
Operator-based column filters: the built-in header menu, per-column config, and the controlled filters API.
Every column is filterable by default. Hover a header row and a funnel button appears in each header; click it and a small menu opens with an operator select and a typed value control. On a touch device there is no hover, so the funnel is drawn all the time instead. Filters apply live as you type, combine across columns with AND, and survive row updates — set filterable: false on a column to opt it out.
Filters are the filters field of one typed query object alongside sort and rowGroups. The grid owns that query by default. To control it, pass the exact query and onQueryChange pair; partial ownership is intentionally rejected.
Hover a header for its funnel (on a phone it is already drawn), open the Status menu to watch its checklist load distinct values from the rows rather than a declared list, and set only one of Total's between bounds to see the column stay unfiltered until both are set:
One filterable column per type — text, number, enum, date, and boolean — reaching each operator family through the built-in funnel menu, with the enum column declaring no options so its checklist loads distinct values from the rows instead.
Column config
Three column fields shape filtering:
| Field | Type | Notes |
|---|---|---|
type | "text" | "number" | "date" | "enum" | "boolean" | picks the operator family and value control; defaults to "text" |
options | { value: string; label?: string }[] | the checklist for an enum column; omit to auto-derive from the rows |
filterable | boolean | false removes the funnel and makes the engine ignore filters for the id |
import type { PretableColumn } from "@pretable/react";
const columns: PretableColumn<Order>[] = [
{ id: "customer", header: "Customer" }, // text — the default
{ id: "total", header: "Total", type: "number" },
{
id: "status",
header: "Status",
type: "enum",
options: [
{ value: "open", label: "Open" },
{ value: "shipped", label: "Shipped" },
],
},
{ id: "placedAt", header: "Placed", type: "date" },
{ id: "actions", header: "", filterable: false },
];When an enum column omits options, the menu requests a bounded distinct-value page from the row model. Loading, errors, and cancellation are handled asynchronously; null and blank values are excluded by default.
Operators
Each type gets its own operator family, plus two shared operators available everywhere:
| Family | Operators |
|---|---|
text | contains, notContains, equals, notEquals, startsWith, endsWith |
number | equals, notEquals, gt (greater than), gte, lt (less than), lte, between |
date | on, before, after, dateBetween |
enum | isAnyOf, isNoneOf |
boolean | enum operators (isAnyOf, isNoneOf, plus the shared empty checks) over implicit True/False options |
| shared | isEmpty, isNotEmpty — no value; a cell is "empty" when it's null, undefined, NaN, or a blank string |
Semantics worth knowing:
- Evaluation keys on
type, not the operator name.equalson atextcolumn is case-insensitive string equality; on anumbercolumn it's numeric equality. An operator outside the column's family simply matches nothing. - Columns AND-combine. A row must pass every column's active filter. Within an
enumcolumn,isAnyOfis an OR across the selected values. - Text operators are case-insensitive. Both cell and value are lowercased before comparison.
- Boolean cells are coerced before matching.
"true"/1/"1"match True,"false"/0/"0"match False, and anything else falls back to plain truthiness — the same rule the editable checkbox renders from, so a cell that looks checked also matches the True filter. (isEmpty/isNotEmptyrun before coercion, sonullstill reads as empty rather than asfalse.) A boolean column'soptionsmay relabel the two states ({ value: "true", label: "Yes" }), but their values must remain"true"and"false"— those are the only strings a coerced cell can equal, so any other value matches nothing. between/dateBetweenare inclusive and order-tolerant — swapped bounds are normalized.- Dates compare at day resolution (calendar day, UTC), so
onmatches any timestamp within that day. Cells may beYYYY-MM-DDstrings, ISO datetimes,Dateinstances, or epoch milliseconds; a zone-less datetime ("2026-08-06T13:45:00", or the space-separated"2026-08-06 13:45:00"that SQL backends emit) is read as UTC — its literal date portion — while a zoned one buckets by the UTC day of that instant. Ambiguous shapes ("08/06/2026","2026-8-6") and calendar overflow ("2026-02-30") are not dates and match no date operator. The date editor reads cell values by exactly the same rule, so editing a cell can't move a row out from under its own filter. - Blank operands are inactive. A filter with an empty string, empty array, or
nullvalue is ignored — it's as if the column had no filter.
The built-in menu
The funnel button renders in every filterable header. With the @pretable/ui skin on a fine pointer it stays hidden until you hover the header row, focus it with the keyboard, or the column has an active filter — an active funnel stays visible and tinted with the accent color. On a coarse pointer it is always drawn; see The funnel on touch.
Clicking the funnel opens a popover (role="dialog", labeled Filter {header}) anchored under the button, with the operator <select> focused. From there:
- Filters apply live. Free-text and number typing is debounced (~200 ms); operator changes, date picks, and enum checkboxes apply immediately. Closing the menu flushes any pending keystrokes.
- Ranges wait for both bounds.
between/dateBetweenonly take effect once min and max are both set (and numeric, for numbers). An incomplete value clears the column's filter rather than half-applying it. - Enum checklists are permissive when empty. Zero boxes checked means no constraint, not zero rows.
- Clear resets the column's filter and the menu's controls in one click.
Escape, clicking outside, or scrolling closes the menu. Filters keep applying after it closes — the funnel's tint tells you which columns are constrained.
For styling or testing, the parts expose stable DOM hooks: the button carries data-pretable-filter-funnel, data-pretable-column-id, and data-pretable-filter-active; the menu carries data-pretable-filter-menu, with data-pretable-filter-operator, data-pretable-filter-value, data-pretable-filter-min / -max, data-pretable-filter-set, and data-pretable-filter-clear on its controls — the same funnel and menu you opened in the grid above.
The funnel on touch
The funnel is not a tab stop, and on a phone there is nothing to hover. Both routes to it are therefore explicit:
- By finger. Under
@media (pointer: coarse)the@pretable/uiskin draws the funnel atopacity: 1at rest, on every filterable column, whether or not a filter is active. Hover-reveal is not subtlety on a device with no hover — it is absence, and it made a control nobody could see. The fine-pointer reveal above is unchanged. The tap target is 24×24 (a transparent::after; the glyph itself stays 18×18, so the header box is the same size it was), which is WCAG 2.5.8 Level AA. - By keyboard.
↑from the first data row puts the focus cursor on that column's header, andAlt + ↓opens the funnel's popover from there.Escapecloses it and returns focus to the header. See Keyboard § The column header.
The column menu (⋮, rendered on data columns when groupPanel={{ enabled: true }}) follows the same two rules: always drawn on a coarse pointer, with a 24×24 target, and opened from the keyboard with Shift + F10.
The resize strip is the one header control that goes the other way — it is not rendered on a coarse pointer at all, which is where the space for the two 24px targets above comes from. See Column layout § Resizing is a pointer affordance.
Controlling the query
Omit both query props for uncontrolled filtering. To seed, observe, or persist filters, own the complete typed query and publish every returned query — the example above does exactly this: it seeds an initial status filter and echoes the active filters below the grid. Its ColumnFiltersGrid.tsx source (Code tab) shows the full pattern. With plain (non-createColumnHelper) columns, type the query by pulling PretableSurface's own query prop through ComponentProps rather than applying PretableQueryFor directly to typeof columns — those columns carry no accessor field, which PretableQueryFor needs to resolve anything but never.
Because you own the query, clear filters with setQuery((current) => ({ ...current, filters: [] })). Funnels and menus reflect the published value. An already-open menu keeps its in-progress draft and re-reads the controlled value the next time it opens.
Headless
Headless filtering is part of the row model's complete typed query:
const transition = rowModel.setQuery({
...rowModel.getState().snapshot.query,
filters: [{ columnId: "status", operator: "isAnyOf", value: ["open"] }],
});
await transition.finished;
const values = rowModel.distinctValues("status", { limit: 100 });
const page = await values.finished;Query publication is atomic and distinct-value lookup is asynchronous and cancellable. Render the required result window with snapshot.range(start, end). See the headless API reference.
See also
- Selection — selection state under a filtered row set.
- Row grouping and aggregation — choose whether aggregates use filtered or all rows.
<PretableSurface>— thestateprop and the controlled/uncontrolled pattern.- API reference —
ColumnFilter,FilterOperator,ColumnType,ColumnOptiontypes.