Skip to content
Merged
6 changes: 4 additions & 2 deletions apps/bench/src/__tests__/ag-grid-adapter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const statusDataset = {

function filterPlan(
mode: "filter-metadata" | "filter-text",
filters: Record<string, string>,
filters: BenchInteractionPlan["filters"],
): BenchInteractionPlan {
return {
focusedRowId: null,
Expand Down Expand Up @@ -81,7 +81,9 @@ describe("AgGridAdapter", () => {
dataset={statusDataset as never}
runKey={0}
scriptName="filter-metadata"
interactionPlan={filterPlan("filter-metadata", { status: "running" })}
interactionPlan={filterPlan("filter-metadata", {
status: { operator: "contains", value: "running" },
})}
/>,
);

Expand Down
6 changes: 4 additions & 2 deletions apps/bench/src/__tests__/mui-adapter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const statusDataset = {

function filterPlan(
mode: "filter-metadata" | "filter-text",
filters: Record<string, string>,
filters: BenchInteractionPlan["filters"],
): BenchInteractionPlan {
return {
focusedRowId: null,
Expand Down Expand Up @@ -82,7 +82,9 @@ describe("MuiAdapter", () => {
dataset={statusDataset as never}
runKey={0}
scriptName="filter-metadata"
interactionPlan={filterPlan("filter-metadata", { status: "running" })}
interactionPlan={filterPlan("filter-metadata", {
status: { operator: "contains", value: "running" },
})}
/>,
);

Expand Down
6 changes: 4 additions & 2 deletions apps/bench/src/__tests__/tanstack-adapter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const statusDataset = {

function filterPlan(
mode: "filter-metadata" | "filter-text",
filters: Record<string, string>,
filters: BenchInteractionPlan["filters"],
): BenchInteractionPlan {
return {
focusedRowId: null,
Expand Down Expand Up @@ -118,7 +118,9 @@ describe("TanstackAdapter", () => {
dataset={statusDataset as never}
runKey={0}
scriptName="filter-metadata"
interactionPlan={filterPlan("filter-metadata", { status: "running" })}
interactionPlan={filterPlan("filter-metadata", {
status: { operator: "contains", value: "running" },
})}
/>,
);

Expand Down
4 changes: 2 additions & 2 deletions apps/bench/src/ag-grid-adapter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,12 @@ export function AgGridAdapter({
interactionPlan.mode === "filter-text"
) {
const model: Record<string, unknown> = {};
for (const [colId, value] of Object.entries(interactionPlan.filters)) {
for (const [colId, filter] of Object.entries(interactionPlan.filters)) {
model[colId] = {
filterType: "text",
type:
interactionPlan.mode === "filter-metadata" ? "equals" : "contains",
filter: value,
filter: filter.value,
};
}
api.setFilterModel(model);
Expand Down
13 changes: 10 additions & 3 deletions apps/bench/src/interaction-plan.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ColumnFilter } from "@pretable/react";
import type {
ScenarioDataset,
ScenarioRow,
Expand All @@ -7,7 +8,7 @@ import type { BenchQueryState } from "./bench-types";

export interface BenchInteractionPlan {
focusedRowId: string | null;
filters: Record<string, string>;
filters: Record<string, ColumnFilter>;
mode: Exclude<BenchQueryState["scriptName"], "initial" | "scroll">;
probeColumnId: string;
resultRowCount: number;
Expand Down Expand Up @@ -65,7 +66,10 @@ export function createBenchInteractionPlan(
return {
focusedRowId: probeRowId,
filters: {
[METADATA_FILTER.columnId]: METADATA_FILTER.value,
[METADATA_FILTER.columnId]: {
operator: "contains",
value: METADATA_FILTER.value,
},
},
mode: "filter-metadata",
probeColumnId: METADATA_FILTER.columnId,
Expand All @@ -88,7 +92,10 @@ export function createBenchInteractionPlan(
return {
focusedRowId: probeRowId,
filters: {
[TEXT_FILTER.columnId]: TEXT_FILTER.value,
[TEXT_FILTER.columnId]: {
operator: "contains",
value: TEXT_FILTER.value,
},
},
mode: "filter-text",
probeColumnId: TEXT_FILTER.columnId,
Expand Down
4 changes: 2 additions & 2 deletions apps/bench/src/mui-adapter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,11 @@ export function MuiAdapter({
interactionPlan.mode === "filter-text"
) {
const items = Object.entries(interactionPlan.filters).map(
([field, value]) => ({
([field, filter]) => ({
field,
operator:
interactionPlan.mode === "filter-metadata" ? "equals" : "contains",
value,
value: filter.value,
}),
);
api.setFilterModel({ items });
Expand Down
2 changes: 1 addition & 1 deletion apps/bench/src/tanstack-adapter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export function TanstackAdapter({
interactionPlan.mode === "filter-text"
) {
const filters = Object.entries(interactionPlan.filters).map(
([id, value]) => ({ id, value }),
([id, filter]) => ({ id, value: filter.value }),
);
t.setColumnFilters(filters);
}
Expand Down
12 changes: 6 additions & 6 deletions apps/website/app/components/heroGrid/__tests__/filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,20 @@ describe("buildFilters", () => {
it("is empty for the default state", () => {
expect(buildFilters({ search: "", sector: null })).toEqual({});
});
it("maps search to the symbol column", () => {
it("maps search to a contains filter on the symbol column", () => {
expect(buildFilters({ search: "nvda", sector: null })).toEqual({
symbol: "nvda",
symbol: { operator: "contains", value: "nvda" },
});
});
it("maps a sector chip to the sector column", () => {
it("maps a sector chip to an isAnyOf filter on the sector column", () => {
expect(buildFilters({ search: "", sector: "Energy" })).toEqual({
sector: "Energy",
sector: { operator: "isAnyOf", value: ["Energy"] },
});
});
it("composes both (AND)", () => {
expect(buildFilters({ search: "x", sector: "Technology" })).toEqual({
symbol: "x",
sector: "Technology",
symbol: { operator: "contains", value: "x" },
sector: { operator: "isAnyOf", value: ["Technology"] },
});
});
it("trims whitespace-only search to empty", () => {
Expand Down
12 changes: 8 additions & 4 deletions apps/website/app/components/heroGrid/filters.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ColumnFilter } from "@pretable/core";

export const SECTORS = [
"All",
"Technology",
Expand All @@ -12,10 +14,12 @@ export interface FilterState {
sector: string | null; // null or "All" → no sector filter
}

export function buildFilters(state: FilterState): Record<string, string> {
const out: Record<string, string> = {};
export function buildFilters(state: FilterState): Record<string, ColumnFilter> {
const out: Record<string, ColumnFilter> = {};
const search = state.search.trim();
if (search) out.symbol = search;
if (state.sector && state.sector !== "All") out.sector = state.sector;
if (search) out.symbol = { operator: "contains", value: search };
if (state.sector && state.sector !== "All") {
out.sector = { operator: "isAnyOf", value: [state.sector] };
}
return out;
}
2 changes: 2 additions & 0 deletions apps/website/app/components/heroGrid/positionColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function makePositionColumns(
header: "Symbol",
widthPx: 150,
pinned: "left",
filterType: "text",
value: (row) => `${row.symbol} ${row.name}`,
render: ({ row }) => (
<span className={styles.symbol}>
Expand All @@ -42,6 +43,7 @@ export function makePositionColumns(
id: "sector",
header: "Sector",
widthPx: 110,
filterType: "enum",
value: (row) => row.sector,
},
{
Expand Down
11 changes: 6 additions & 5 deletions apps/website/content/docs/grid/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ interface PretableHeaderRenderInput<TRow extends PretableRow = PretableRow> {
```ts
interface PretableSurfaceState {
sort?: PretableSortState;
filters?: Record<string, string>;
filters?: Record<string, ColumnFilter>;
selection?: PretableSelectionState;
focus?: PretableFocusState;
columnWidths?: Record<string, number>;
Expand Down Expand Up @@ -181,9 +181,10 @@ The `grid` returned by `usePretable`.
| `getSnapshot(): PretableGridSnapshot<TRow>` | Read the current state. |
| `subscribe(listener: () => void): () => void` | Subscribe to state changes. Returns an unsubscribe fn. |
| `setSort(columnId: string \| null, direction: "asc" \| "desc" \| null): void` | Set the sort state. Pass `null` to clear. |
| `setFilter(columnId: string, value: string): void` | Set a filter value for one column. |
| `setColumnFilter(columnId: string, filter: ColumnFilter \| null): void` | Set one column's filter. Pass `null` to remove it. |
| `clearFilters(): void` | Remove all filters. |
| `replaceFilters(map: Record<string, string>): void` | Replace all filters with a new map. |
| `replaceFilters(map: Record<string, ColumnFilter>): void` | Replace all filters with a new map. |
| `distinctColumnValues(columnId: string): string[]` | Sorted, de-duped non-empty cell values for a column (for enum filter options). |
| `setSelection(state): void` | Replace the full selection state ({ ranges, anchor }). |
| `selectAll(): void` | Select every visible cell as a single full-grid range. |
| `clearSelection(): void` | Collapse selection to the focused cell (or empty if no focus). |
Expand Down Expand Up @@ -219,7 +220,7 @@ interface PretableGridSnapshot<TRow> {
width: number;
};
sort: { columnId: string | null; direction: "asc" | "desc" | null };
filters: Record<string, string>;
filters: Record<string, ColumnFilter>;
selection: {
ranges: Array<{
startRowId: string;
Expand Down Expand Up @@ -266,7 +267,7 @@ interface UsePretableOptions<TRow> extends UsePretableOptions<TRow> {

interface PretableSurfaceState {
sort?: PretableSortState;
filters?: Record<string, string>;
filters?: Record<string, ColumnFilter>;
selection?: PretableSelectionState;
focus?: PretableFocusState;
columnWidths?: Record<string, number>;
Expand Down
4 changes: 2 additions & 2 deletions apps/website/content/docs/grid/custom-rendering.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const { grid, snapshot, renderSnapshot, telemetry } = usePretable({

What you get back:

- **`grid`** — the grid model. Methods: `setSort(columnId, direction)`, `setFilter(columnId, value)`, `clearFilters()`, `replaceFilters(map)`, `setSelection(state)`, `selectAll()`, `clearSelection()`, `addRange(range)`, `extendRangeFromAnchor(addr)`, `toggleRowSelection(id)`, `setSelectAllVisible(checked)`, `setFocus({rowId, columnId} | null)`, `moveFocus(direction, options?)`, `setViewport({scrollTop, scrollLeft, height, width})`, `applyTransaction({add, update, remove})`.
- **`grid`** — the grid model. Methods: `setSort(columnId, direction)`, `setColumnFilter(columnId, filter)`, `clearFilters()`, `replaceFilters(map)`, `distinctColumnValues(columnId)`, `setSelection(state)`, `selectAll()`, `clearSelection()`, `addRange(range)`, `extendRangeFromAnchor(addr)`, `toggleRowSelection(id)`, `setSelectAllVisible(checked)`, `setFocus({rowId, columnId} | null)`, `moveFocus(direction, options?)`, `setViewport({scrollTop, scrollLeft, height, width})`, `applyTransaction({add, update, remove})`.
- **`snapshot`** — current state. Shape: `{viewport, sort, filters, selection, focus, totalRowCount, visibleRows, visibleRange}`.
- **`renderSnapshot`** — what to render right now. Shape: `{columns: PlannedColumn[], rows: PretableRenderRow[], nodeCount, totalHeight, totalWidth}`. Each `PretableRenderRow` has `{id, row, rowIndex, top, height}`.
- **`telemetry`** — `{focusedRowId, rowModelRowCount, renderedRowCount, selectedRowId, totalRowCount, ...}`.
Expand Down Expand Up @@ -223,7 +223,7 @@ The minimal example above is a starting point. For production grids, you'll like
- **Keyboard navigation** — listen for `ArrowUp` / `ArrowDown` on the viewport and call `grid.moveFocus("up")` / `grid.moveFocus("down")`. The full keyboard contract (shift+arrow extend, Cmd/Ctrl+arrow jump, Tab wrap, Cmd+A, Esc) is wired by `<PretableSurface>` automatically.
- **Pinned columns sticky positioning** — apply `position: sticky; left: ${pinnedOffset}px` to pinned cells. The bench's adapter computes pinned offsets via the same algorithm `<PretableSurface>` uses internally; see `packages/react-surface/src/rendering.ts` (function `getPinnedLeftOffsets`) for the canonical implementation.
- **Per-row measured heights** — use `useLayoutEffect` to measure rendered row heights and pass `measuredHeights: Record<string, number>` to `usePretable` for content-aware sizing.
- **Filter inputs** — render a row of `<input>` elements above the body, debounce changes, and call `grid.setFilter(columnId, value)` per change.
- **Filter inputs** — render a row of `<input>` elements above the body, debounce changes, and call `grid.setColumnFilter(columnId, { operator: "contains", value })` per change (pass `null` to clear).
- **Telemetry instrumentation** — use the `telemetry` return value to track visible row counts, frame budget overruns, etc.

For the full reference implementation, see `packages/react-surface/src/pretable-surface.tsx` in the repository — that's what the bench's pretable adapter and the website's playground use internally. It's marked private (lives at `@pretable-internal/react-surface`), but reading its source is the canonical reference for the patterns above.
Expand Down
2 changes: 1 addition & 1 deletion apps/website/content/docs/grid/pretable-component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ The grid renders with Excel's surface tones, gridlines, accent. Toggle `data-den
`<Pretable>` is the simplest possible surface. It is intentionally thin. For:

- **Sort UI** — handle clicks on header buttons to call `grid.setSort(id, direction)`. Use [custom rendering](/docs/grid/custom-rendering).
- **Filter UI** — render input fields and call `grid.replaceFilters(...)` or `grid.setFilter(id, value)`.
- **Filter UI** — render input fields and call `grid.replaceFilters(...)` or `grid.setColumnFilter(id, { operator, value })`.
- **Selection state in your component tree** — wire `grid.toggleRowSelection(id)` and derive selected rows from `snapshot.selection.ranges` (use `deriveSelectedRows` for the three-state row helper).
- **Focus / keyboard navigation** — wire `grid.setFocus({ rowId, columnId })` and `grid.moveFocus("up" | "down" | "left" | "right", options?)` to keyboard handlers.
- **Custom cell components** — replace the default label/value layout. Custom rendering lets you render any React tree per cell.
Expand Down
67 changes: 60 additions & 7 deletions apps/website/content/docs/headless/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,13 @@ readonly options: PretableGridOptions<TRow>;

### Sort & filter

| Method | Signature |
| ---------------- | ---------------------------------------------------------------------- |
| `setSort` | `(columnId: string \| null, direction: PretableSortDirection) => void` |
| `setFilter` | `(columnId: string, value: string) => void` |
| `replaceFilters` | `(nextFilters: Record<string, string>) => void` |
| `clearFilters` | `() => void` |
| Method | Signature |
| ---------------------- | ---------------------------------------------------------------------- |
| `setSort` | `(columnId: string \| null, direction: PretableSortDirection) => void` |
| `setColumnFilter` | `(columnId: string, filter: ColumnFilter \| null) => void` |
| `replaceFilters` | `(nextFilters: Record<string, ColumnFilter>) => void` |
| `clearFilters` | `() => void` |
| `distinctColumnValues` | `(columnId: string) => string[]` |

### Selection & ranges

Expand Down Expand Up @@ -101,6 +102,8 @@ interface PretableColumn<TRow = PretableRow> {
format?: (input: PretableFormatInput<TRow>) => string;
sortable?: boolean;
filterable?: boolean;
filterType?: FilterType;
filterOptions?: FilterOption[];
reorderable?: boolean;
resizable?: boolean;
pinned?: "left";
Expand All @@ -111,6 +114,56 @@ interface PretableColumn<TRow = PretableRow> {
}
```

### Filter types

```ts
type FilterType = "text" | "number" | "date" | "enum";

type FilterOperator =
| "contains"
| "notContains"
| "equals"
| "notEquals"
| "startsWith"
| "endsWith"
| "gt"
| "gte"
| "lt"
| "lte"
| "between"
| "isAnyOf"
| "isNoneOf"
| "on"
| "before"
| "after"
| "dateBetween"
| "isEmpty"
| "isNotEmpty";

type FilterValue =
| string
| number
| readonly [number, number]
| readonly [string, string]
| readonly string[]
| null;

interface ColumnFilter {
operator: FilterOperator;
value?: FilterValue;
}

interface FilterOption {
value: string;
label?: string;
}
```

Each column's `filterType` (default `"text"`) selects which operators apply and how
values are compared. Columns are AND-combined. `filterOptions` supplies the choices for
an `enum` column; when omitted, call `distinctColumnValues(columnId)` to derive them from
the data.

### `PretableRow`

```ts
Expand All @@ -125,7 +178,7 @@ interface PretableGridSnapshot<TRow = PretableRow> {
visibleRange: PretableRowRange;
totalRowCount: number;
sort: PretableSortState;
filters: Record<string, string>;
filters: Record<string, ColumnFilter>;
selection: PretableSelectionState;
focus: PretableFocusState;
viewport: PretableViewportState;
Expand Down
2 changes: 1 addition & 1 deletion apps/website/content/docs/headless/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Call methods on the engine; the snapshot updates and React re-renders:

```ts
grid.setSort("latencyMs", "asc"); // sort ascending by a column
grid.setFilter("team", "payments"); // filter a column by substring
grid.setColumnFilter("team", { operator: "contains", value: "payments" }); // filter a column
grid.toggleRowSelection(rowId); // toggle a row in the selection
```

Expand Down
8 changes: 6 additions & 2 deletions apps/website/content/docs/headless/mutations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ Every change to the grid goes through a method on the engine handle. Each one up
grid.setSort("latencyMs", "asc"); // sort a column "asc" | "desc"
grid.setSort(null, null); // clear the sort

grid.setFilter("team", "payments"); // filter one column
grid.replaceFilters({ team: "search", status: "down" }); // replace all filters at once
grid.setColumnFilter("team", { operator: "contains", value: "payments" }); // filter one column
grid.setColumnFilter("team", null); // remove that column's filter
grid.replaceFilters({
team: { operator: "contains", value: "search" },
status: { operator: "isAnyOf", value: ["down"] },
}); // replace all filters at once
grid.clearFilters(); // remove every filter
```

Expand Down
Loading