Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3ae6802
docs: spec for components SP2 — Listbox, and PretableSelect
blove Sep 6, 2026
3cabff5
docs: components SP2 implementation plan
blove Sep 6, 2026
c31a5ed
feat(react): the Listbox primitive and its trigger keyboard
blove Sep 6, 2026
4cc62fc
fix(react): re-seed the listbox highlight during render, not in an ef…
blove Sep 6, 2026
93c8371
fix(react): listbox review round — ArrowUp from nothing, empty-list c…
blove Sep 6, 2026
2f47cd4
refactor(react,ui): the enum editor renders the kit Listbox
blove Sep 6, 2026
987c72f
refactor(react,ui): enum editor review round — no dead attribute, arr…
blove Sep 6, 2026
bcf7429
docs(react): the NOOP comment claims only what holds
blove Sep 6, 2026
4a28084
feat(react): PretableSelect, a select-only combobox on the kit Listbox
blove Sep 6, 2026
9f272e0
fix(react): select review round — list presses stay inside, disabled …
blove Sep 6, 2026
66d629d
test(react): pin the PretableSelect type contract
blove Sep 6, 2026
f10cbe0
feat(react): the Select slot
blove Sep 6, 2026
f76bf9e
feat(ui): the kit select rules; the site guard learns the four pickers
blove Sep 6, 2026
9c19ca1
refactor(react,ui): the four pickers render the kit Select
blove Sep 6, 2026
11afd4b
fix(ui,react): the select bracket anchor matches the base rule, not t…
blove Sep 6, 2026
157d885
docs(react): the pruned-operator comments describe the kit picker, no…
blove Sep 6, 2026
3368b28
feat(react): export PretableSelect and the Select slot type
blove Sep 6, 2026
119d5b8
fix(react): import Fragment in PretableSelect so it renders at all
blove Sep 6, 2026
cc996b4
fix(react): document the select members; pin the option shape; guard …
blove Sep 6, 2026
339a241
docs: the Select on the Components page; three sentences stop saying …
blove Sep 6, 2026
927c175
test(website): the pickers in a real browser; e2e drivers move to cho…
blove Sep 6, 2026
06699b2
docs: the add-group menu keeps its own keyboard; the site count is si…
blove Sep 6, 2026
c313a02
test(react): the JSX-import guard ignores commented and aliased imports
blove Sep 6, 2026
8381db0
test(website): grouping walk pins the full roster; pane-open helpers …
blove Sep 6, 2026
5e906a1
fix(react): the select trigger is a Tab stop everywhere; an empty lis…
blove Sep 6, 2026
1b8183c
test(website),docs: the funnel dialog survives a picked operator; pro…
blove Sep 6, 2026
02347f9
chore: changeset for components SP2
blove Sep 6, 2026
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
44 changes: 44 additions & 0 deletions .changeset/components-sp2-select.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@pretable/react": minor
"@pretable/ui": patch
---

The kit's picker, and the four `<select>` elements in the grid rebuilt on it.

`PretableSelect` is a `button[role="combobox"]` that opens a portalled listbox,
with `PretableSelectProps`, `PretableSelectOption` and `PretableSelectComponent`
alongside it and a `Select` slot on `PretableComponents` —
`components={{ Select }}` on `<PretableSurface>` and `<Pretable>` replaces every
picker in the grid, the same way `Button` and `IconButton` already do.
`PretableBuiltInButtonSite` gains four names: `filter-operator`,
`filter-row-column`, `filter-row-operator` and `aggregate`.

**The four pickers are no longer `<select>` elements.** The filter dialog's
operator, the tool panel's filter column and filter operator, and the grouping
aggregate picker are now `button[role="combobox"]` triggers carrying
`data-pretable-value`, which open a `[data-pretable-listbox]` of
`[data-pretable-option][data-value]` in `document.body`. A test keyed on
`select`, on `.value`, on Playwright's `selectOption()` or on `toHaveValue()`
must read the attribute and click the option instead. Every site keeps the
`data-pretable-*` attribute it already had, and the aggregate picker gains
`data-pretable-aggregate`.

**Keyboard.** Arrows, Enter and Space open a closed picker; arrows wrap and skip
disabled options; Home and End jump to the ends; typing a prefix jumps to the
first match (the buffer clears after 500ms); Enter or Space commits; Escape
closes back to the trigger; Tab closes and moves on. `onChange` fires only when
the value actually changes. The trigger is an explicit tab stop in every
browser, which the native control was not in WebKit.

A disabled option is shown, skipped and inert rather than hidden — that is the
aggregate picker's `Custom` entry, a consumer-written aggregator the grid
displays but never writes back.

**Styling.** The enum cell editor's list is now the kit list, so
`data-pretable-enum-listbox` and `data-pretable-enum-option` are gone, replaced
by `data-pretable-listbox` and `data-pretable-option` — plus `[data-active]` for
the roving highlight and `[aria-selected="true"]` for the current value. Tokens
are unchanged.

**Layout.** The tool panel's filter rows wrap less: a picker now sizes to its
own label, with an ellipsis, instead of to its longest option.
41 changes: 40 additions & 1 deletion apps/website/app/fixtures/components/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type PretableButtonComponent,
type PretableColumn,
type PretableIconButtonComponent,
type PretableSelectComponent,
} from "@pretable/react";
import { forwardRef } from "react";

Expand Down Expand Up @@ -68,13 +69,51 @@ const FixtureIconButton: PretableIconButtonComponent = forwardRef(
},
);

/**
* A picker replacement that is a plain button, not a combobox — deliberately
* nothing like the kit's own. It records what the grid handed it (`site`, the
* committed value, how many options) and, on click, commits the first other
* enabled option, so a test can prove the grid's `onChange` reaches the model
* through a replacement that shares none of the kit's internals.
*/
const FixtureSelect: PretableSelectComponent = forwardRef(
function FixtureSelect(
{ site, options, value, onChange, "aria-label": label, ...props },
ref,
) {
return (
<button
{...props}
ref={ref}
type="button"
aria-label={label}
data-fixture-select={site ?? ""}
data-fixture-value={value}
data-fixture-option-count={options.length}
onClick={() =>
onChange(
options.find((o) => o.value !== value && !o.disabled)?.value ??
value,
)
}
>
{value}
</button>
);
},
);

export default function ComponentsFixturePage() {
return (
<main style={{ padding: 24 }}>
<PretableSurface
ariaLabel="components-fixture"
columns={COLUMNS}
components={{ Button: FixtureButton, IconButton: FixtureIconButton }}
components={{
Button: FixtureButton,
IconButton: FixtureIconButton,
Select: FixtureSelect,
}}
getRowId={(row) => row.id}
rows={ROWS}
toolPanel={{ defaultActiveSection: "columns" }}
Expand Down
104 changes: 87 additions & 17 deletions apps/website/content/docs/grid/components.mdx
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
---
title: Components
description: "The kit components the grid renders its own chrome from — Button and IconButton — how to style them, and how to replace either with your own."
description: "The kit components the grid renders its own chrome from — Button, IconButton and Select — how to style them, and how to replace any of them with your own."
nav: Grid
---

`@pretable/react` renders its own controls from a small component kit, and
ships those components for you to use and to replace. This page covers the
first two: `PretableButton` and `PretableIconButton`. Here is a grid whose
every labelled button is the app's own:
three that ship today: `PretableButton`, `PretableIconButton` and
`PretableSelect`. Here is a grid whose every labelled button is the app's
own:

<Example id="components-button" />

## The components

Both render `<button type="button">`, always — every grid button is one, and a
stray submit inside your `<form>` is a real bug class, so `type` is not a prop.
`className` and `style` pass straight through — the components set neither,
so yours is the whole value. Both forward their `ref` to the button node.
All three render `<button type="button">`, always — every grid control is
one, and a stray submit inside your `<form>` is a real bug class, so `type` is
not a prop. `className` and `style` pass straight through — the components set
neither, so yours is the whole value. All three forward their `ref` to the
button node.

### Button

Expand All @@ -38,14 +40,71 @@ accessibility failure discovered later. An empty one warns in development.
| `aria-label` | `string` | Required. The accessible name. |
| `site` | `PretableButtonSite` | Where in the grid the button is; lands as `data-pretable-site`. |

### Select

A select-only combobox: the kit's answer to the native `<select>`, whose open
list no theme can reach. The trigger is a `<button role="combobox">`; the list
is a portalled `role="listbox"` placed against it. `aria-label` is
**required**, for the same reason it is on `IconButton` — a picker with no
accessible name reads as an unnamed button — and an empty one warns in
development. Four grid pickers are this component: the funnel menu's
[operator](/docs/grid/filtering#the-built-in-menu), the tool panel's
[filter-builder column and operator](/docs/grid/tool-panel#dom-hooks-and-strings),
and the grouping section's
[aggregate picker](/docs/grid/tool-panel#aggregates).

The keyboard is the listbox one: on a closed trigger, `↓`, `↑`, `Enter` or
`Space` opens the list; open, `↓` / `↑` move through it, wrapping at the ends
and skipping disabled options; `Home` / `End` jump to the first and last
enabled option; typing jumps by label prefix, the prefix resetting after
500ms of silence; `Enter` or `Space` commits the active option; `Escape`
closes the list and returns focus to the trigger; `Tab` closes it and moves
on. `onChange` fires only when the chosen value differs from `value`.

A `disabled` option is shown, skipped by the keyboard and inert to click —
that is how the aggregate picker shows a consumer-written custom aggregate it
must never write back. And a `value` that is absent from `options` renders as
its own label rather than as an empty trigger, so a picker never silently
disagrees with the state behind it.

```tsx
import { PretableSelect } from "@pretable/react";

<PretableSelect
aria-label="Filter operator"
options={[
{ value: "contains", label: "Contains" },
{ value: "equals", label: "Equals" },
{ value: "regex", label: "Matches regex", disabled: true },
]}
value={operator}
onChange={setOperator}
/>;
```

| Prop | Type | Notes |
| ------------ | --------------------------------- | ----------------------------------------------------------------------------- |
| `options` | `readonly PretableSelectOption[]` | The choices, in display order. Each is `{ value, label, disabled? }`. |
| `value` | `string` | The committed value. Absent from `options`, it renders as its own label. |
| `onChange` | `(value: string) => void` | Called with the chosen value, and only when it differs from `value`. |
| `aria-label` | `string` | Required. The accessible name — name what it chooses, e.g. `Filter operator`. |
| `site` | `PretableButtonSite` | Where in the grid the picker is; lands as `data-pretable-site`. |

## Styling

The styling channel is the one the whole grid uses: attributes and tokens.
Every button carries `data-pretable-button` or `data-pretable-icon-button`; a
labelled button also carries `data-pretable-variant`, and either carries
`data-pretable-site` when the grid gave it one. The grid's own sites also
keep their original attribute (`data-pretable-filter-clear`,
`data-pretable-tool-reset`, …), so a selector you already wrote keeps matching.
Every push-button carries `data-pretable-button` or
`data-pretable-icon-button`; a labelled button also carries
`data-pretable-variant`, and any of them carries `data-pretable-site` when
the grid gave it one. A select's trigger carries `data-pretable-select` and
writes its committed value to `data-pretable-value` — a `<button>` has no
`.value`, so that attribute is the truth a test reads and a selector
matches. Its label span is `data-pretable-select-label`; the open list is
`data-pretable-listbox` with a `data-pretable-option` per entry, the
keyboard-active one carrying `data-active` and the chosen one
`aria-selected="true"`. The grid's own sites also keep their original
attribute (`data-pretable-filter-clear`, `data-pretable-tool-reset`, …), so a
selector you already wrote keeps matching.

```css
[data-pretable-button][data-pretable-variant="ghost"] {
Expand All @@ -54,24 +113,35 @@ keep their original attribute (`data-pretable-filter-clear`,
[data-pretable-site="tool-reset"] {
color: var(--pretable-text-error);
}
[data-pretable-select][data-pretable-value="between"] {
font-variant-numeric: tabular-nums;
}
[data-pretable-option][data-active],
[data-pretable-option][aria-selected="true"] {
background: var(--pretable-bg-hover);
}
```

## Replacing a component

Pass `components` to `<PretableSurface>` (or `<Pretable>`) with one entry per
component type you want to replace. The replacement is used everywhere that
type appears — the tool panel and the portalled filter dialog for `Button`;
the header, the tool panel and the group panel for `IconButton` — and
the header, the tool panel and the group panel for `IconButton`; the filter
dialog, the filter builder and the grouping section for `Select` — and
receives exactly the props the built-in does, `site` included, so it can
branch on where it is.

| Prop | Type | Notes |
| ------------ | ----------------------------- | ------------------------------------------------------------------------------ |
| `Button` | `PretableButtonComponent` | Receives `PretableButtonProps`; must forward its `ref` to the button node. |
| `IconButton` | `PretableIconButtonComponent` | Receives `PretableIconButtonProps`; must forward its `ref` to the button node. |
| `Select` | `PretableSelectComponent` | Receives `PretableSelectProps`; must forward its `ref` to the button node. |

Forwarding the `ref` is the one obligation on a replacement: the grid anchors
menus on the node and returns keyboard focus to it. Under React 18 that means
`forwardRef`; under React 19 a plain `ref` prop is enough. The set of built-in
`site` names is the `PretableBuiltInButtonSite` type and grows additively — a
new grid button may introduce a new site without a major bump.
menus on the node and returns keyboard focus to it — a replaced `Select`
owes it too, because the filter dialog focuses its operator picker when it
opens. Under React 18 that means `forwardRef`; under React 19 a plain `ref`
prop is enough. The set of built-in `site` names is the
`PretableBuiltInButtonSite` type and grows additively — a new grid button
may introduce a new site without a major bump.
4 changes: 2 additions & 2 deletions apps/website/content/docs/grid/filtering.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: "Operator-based column filters: the built-in header menu, per-colum
nav: Grid
---

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](#the-funnel-on-touch) 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.
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 picker and a typed value control. On a touch device there is no hover, so the funnel is [drawn all the time](#the-funnel-on-touch) 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.

Expand Down Expand Up @@ -71,7 +71,7 @@ Semantics worth knowing:

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](#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:
Clicking the funnel opens a popover (`role="dialog"`, labeled `Filter {header}`) anchored under the button, with the operator picker focused. From there:

- **Filters apply live.** Free-text and number typing is debounced (~200&nbsp;ms); operator changes, date picks, and enum checkboxes apply immediately. Closing the menu flushes any pending keystrokes.
- **Ranges wait for both bounds.** `between` / `dateBetween` only 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.
Expand Down
4 changes: 2 additions & 2 deletions apps/website/content/docs/grid/tool-panel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ The block renders **in rows mode only**. In explicit-model mode the caller owns

### The grouping section's DOM hooks and strings

The section's container is `data-pretable-tool-grouping`. A group-by row is `data-pretable-tool-group-row` (with `data-pretable-column-id`), its grip `data-pretable-tool-row-grip` and its remove button `data-pretable-tool-group-remove`; the add button is `data-pretable-add-group` and its menu `data-pretable-add-group-menu`. The expansion pair is `data-pretable-expand-all` / `data-pretable-collapse-all`, the switch's input `data-pretable-hide-grouped`, and an aggregate row `data-pretable-aggregate-row` (with `data-pretable-column-id`).
The section's container is `data-pretable-tool-grouping`. A group-by row is `data-pretable-tool-group-row` (with `data-pretable-column-id`), its grip `data-pretable-tool-row-grip` and its remove button `data-pretable-tool-group-remove`; the add button is `data-pretable-add-group` and its menu `data-pretable-add-group-menu`. The expansion pair is `data-pretable-expand-all` / `data-pretable-collapse-all`, the switch's input `data-pretable-hide-grouped`, and an aggregate row `data-pretable-aggregate-row` (with `data-pretable-column-id`), whose picker is `data-pretable-aggregate`.

Every string is a message: `toolPanelGroupByLabel`, `toolPanelAddRowGroupLabel`, `toolPanelRemoveGroupLabel`, `toolPanelReorderGroupLabel`, and `toolPanelNoGroupsMessage` for the group-by block; `toolPanelExpandAllLabel` and `toolPanelCollapseAllLabel`; `toolPanelHideGroupedColumnsLabel`; and for the pickers `toolPanelAggregatesLabel`, `toolPanelAggregateColumnLabel`, `toolPanelAggregateDefaultOption`, `toolPanelAggregateNoneOption`, `toolPanelAggregateCustomLabel`, and the five builtin names (`toolPanelAggregateSumLabel` through `toolPanelAggregateCountLabel`). The reorder grip deliberately does **not** reuse the columns section's "Reorder {column}": the two grips coexist in one panel, and identical accessible names would leave a screen-reader user unable to tell reordering a column from reordering a grouping level.

Expand Down Expand Up @@ -274,7 +274,7 @@ The rail is one Tab stop, however many sections it grows.

Each row's grip is its own Tab stop, so the chord is reachable without a pointer. The two panes' grips carry different accessible names — "Reorder {column}" in the columns section, "Reorder grouping by {column}" in the grouping section — because both can be in the panel at once. Pinning into an empty pinned group is the one operation the chord cannot express — that is the columns row's ⋮ menu.

The grouping pane's remaining controls are ordinary form controls — the expansion buttons, the switch, one `<select>` per aggregate row — and its **+ Add group** menu is the same menu keyboard the filters pane's pickers use: arrows move, `Enter` selects, `Escape` closes back to the button.
The grouping pane's remaining controls are ordinary form controls — the expansion buttons and the switch. One aggregate picker per row is a [select-only combobox](/docs/grid/components#select), not a native `<select>`, and it keeps that component's own keyboard: arrows open and move, `Home` / `End` jump to the ends, typing jumps by prefix, `Enter` / `Space` commits, `Escape` closes it. Its **+ Add group** menu is a plainer, separate control — a focus-roving menu, not a combobox: `↑` / `↓` move between items, `Enter` activates the focused one (native button activation, not a keyboard handler), and `Escape` closes it back to the button. There is no `Home` / `End` and no typeahead in this menu.

The filters pane needs no chords of its own: its controls are ordinary form controls, walked in the order the tree reads — column, operator, value, remove, then the run's add pair, and the same again inside each group. Two things are not stops there. The **join** is focusable only where it can actually change anything: on the second and later rows of a nested run it is a button, and everywhere else — every run's first row, and every row of the root run, whose implicit AND has nothing to flip — it is plain text. And the plain buttons may be skipped by the browser: WebKit leaves a `<button>` out of the Tab order unless macOS's "Tab moves between all controls" is on, which is a platform preference rather than something a grid can set.

Expand Down
Loading