Skip to content

feat(react,ui): PretableSelect — the four pickers on the kit Listbox (components SP2) - #582

Merged
blove merged 27 commits into
mainfrom
blove/components-sp2-select
Sep 6, 2026
Merged

feat(react,ui): PretableSelect — the four pickers on the kit Listbox (components SP2)#582
blove merged 27 commits into
mainfrom
blove/components-sp2-select

Conversation

@blove

@blove blove commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Components SP2. The kit gains a picker, and the four <select> elements left in the grid are rebuilt on it.

What ships

  • Listbox + useListboxKeys, extracted from the enum cell editor. The editor's own suite passed unchanged across the extraction — the primitive is the behaviour that was already there, given a name and a second consumer.
  • PretableSelect, a select-only combobox: a button[role="combobox"] carrying data-pretable-value, opening a portalled [data-pretable-listbox] in document.body.
  • The Select slot on PretableComponents (components={{ Select }}), alongside the Button and IconButton slots from SP1, with PretableSelectProps, PretableSelectOption and PretableSelectComponent exported.
  • Four sites migrated — the filter dialog's operator, the tool panel's filter column and filter operator, and the grouping aggregate picker — adding four PretableBuiltInButtonSite names: filter-operator, filter-row-column, filter-row-operator, aggregate.
  • CSS on the kit contract, with the site guard's own table extended to the four new sites rather than a new table written beside it.
  • Docs: the Select on the Components page, and the prose that described native selects corrected.

Measurement, stated honestly

Three of the four pickers hold their box; the two builder pickers deliberately shrink.

site before after
filter-operator 218×28 218×28
aggregate 178×24 177×24
filter-row-column 191×24 90×24
filter-row-operator 239×24 97×24

The two tool-panel pickers now size to their label, not to their longest option — that is the intended change, and it is why the filter row wraps less. Heights, padding, border, colours and fonts are unchanged on all four.

The focus ring was verified under real keyboard focus as rgb(37, 84, 207) solid 2px at offset -2px on the operator and aggregate pickers. The raw after-JSON shows none because a programmatic el.focus() does not match :focus-visible on a button — the probe, not the ring, is what was missing.

Guards, mutation-checked

  • the site guard's own table, extended (not a parallel one)
  • the kit select guard, including the label and caret rules
  • the bracket anchor pinned to the base rule rather than the caret
  • a press inside the listbox stays inside
  • disabling the trigger mid-open closes the list
  • an empty option list never opens
  • an Equal pin on the PretableSelectOption shape
  • a new jsx-runtime-imports guard (below)

Found in the browser, not by 1844 unit tests

PretableSelect used <>…</> without importing Fragment. The classic-JSX build emitted an undefined identifier, and the home-page grid rendered nothing. Every unit test passed. Fixed in 119d5b80, and guarded by jsx-runtime-imports, which is itself tested against commented and aliased imports.

Consumer notes

The four pickers are buttons, not <select> elements: a test keyed on select, .value, selectOption() or toHaveValue() must read data-pretable-value and click the [data-pretable-option][data-value]. The enum editor's data-pretable-enum-listbox / data-pretable-enum-option are replaced by data-pretable-listbox / data-pretable-option. The trigger is an explicit tab stop in every browser — the native control was not one in WebKit. Full detail in the changeset.

Review provenance

Every task ran spec review and quality review with fix rounds; the fix rounds are visible as their own commits (93c8371f listbox, 987c72ff enum editor, 9f272e05 select, c313a023 guard). The final whole-branch review's blocking items are addressed: the Tab-stop gap in WebKit and the empty-list open (5e906a17), the JSX-import guard's false positives on commented and aliased imports (c313a023), the add-group menu's own keyboard and the site count (06699b23), and the funnel dialog surviving a picked operator (1b8183c2).

Verification

format, lint, typecheck, build, api, api:check, typecheck:public — all clean, pnpm api left the tree unchanged. pnpm test: every workspace green first run (react 1851/1851, 134 files). pnpm bench:e2e: 29 passed. Website e2e against a local production build (smoke components tool-panel grid-header, chromium + webkit, --workers=1): 170 passed.

🤖 Generated with Claude Code

blove and others added 27 commits September 5, 2026 20:34
A select-only combobox on the SP1 contract, built on a Listbox primitive
extracted from the enum cell editor so the grid has one list implementation;
the four native selects migrate, and their test drivers with them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The kit's one list — the portalled role=listbox markup in one place — and
the keyboard both triggers need: arrows with wrap and disabled-skip,
Home/End, 500ms typeahead, Enter/Space, Escape/Tab, and open-on-key for a
closed trigger. Mutation-checked: ignoring disabled in the step fails the
skip test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…fect

The set-state-in-effect rule was right: an effect commits a frame of stale
highlight. Adjust during render instead, and pin the re-seed with a test.
Mutation-checked: deleting the re-seed fails it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lose, ids

ArrowUp from no highlight lands on the last enabled option, as the native
control does; an empty list still closes on an outside press; option ids
come from one helper; the reveal effect watches the options too. Escape's
stopPropagation and the toggling-anchor contract are now stated, not
implied. An all-disabled list is pinned: no highlight, nothing commits.
One hook test now drives a real DOM keydown instead of a hand-rolled
event, and the scrollIntoView patch is restored.

Placement assertion pins menuPopoverStyle (mutation-checked by swapping in
popoverStyle); ArrowUp fix mutation-checked by reverting the start
normalisation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Its list and its arrow keys come from the primitive; its filtering input,
its direction-aware commit and its strict blur stay its own; its list keeps
the dialog width through Listbox's new `width` prop, and aria-selected
keeps following the highlight, the editable-combobox pattern. The enum
editor suite passes unchanged, which is the proof. The list rules move to
[data-pretable-listbox] / [data-pretable-option] in the kit section; the
editor passes its own listbox attribute through. Mutation-checked: an
ignored width prop fails its test; a deleted line-height fails the trio
guard.

References found outside EnumCellEditor.tsx and grid.css, all in
packages/ui/src/__tests__/css-cascade.test.ts:
- the "styles the enum combobox listbox" guard: renamed to the kit
  attributes ([data-pretable-listbox], [data-pretable-option]).
- the "portaled popovers declare the whole inherited trio" guard: its
  block regex is now [data-pretable-listbox], and the listbox joins the
  blocks that must declare line-height.
No e2e locator, docs page or other test named either attribute.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ows only

The listbox's enum attribute vouched for a consumer that did not exist;
it and the listProps passthrough go. Home/End stay on the text caret, the
editable-combobox pattern — only the arrows reach the kit keyboard
(test-pinned; mutation-checked by re-forwarding Home). The seed and the
render share one "what is shown" rule; the index clamp guards both ends;
the unreachable :empty rule and its guard go together; the editor's
dialog width is pinned (mutation-checked by dropping the prop).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A button[role=combobox] trigger carrying data-pretable-select / -site /
-value and the kit list beneath it; opens on click and on the navigation
keys, commits on option click or Enter/Space, closes on Escape (focus
returned), Tab and outside press; honours the toggling-anchor contract
(mutation-checked: dropping the pointerdown stopPropagation fails the
close-not-reopen test). A value absent from the options renders as its
own label — the pruned-operator case (mutation-checked). jsdom helpers
replace the native-select drivers every later test used.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…closes, named list

A press inside the portalled list no longer reaches a host popover's
outside-press listener (the mirror of the trigger's own stopPropagation;
mutation-checked). Disabling an open select closes it — a disabled button
gets no keydown, so Escape would have died with it (mutation-checked).
The list takes the trigger's name. A merged callback ref replaces the
imperative handle; children are a compile error; an absent value seeds the
highlight on the first enabled option (mutation-checked); re-selecting the
current value fires no onChange (mutation-checked); the read-only helper
no longer moves focus.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Name required, string value in and out, no type/children leak, the four
select sites in the built-in vocabulary (mutation-checked by removing one),
ref and native attributes through.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comparison, defaults, literal and deps all gain it — the four-part edit —
and the changed-slot test now covers it; mutation-checked by dropping the
dep.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Trigger box, label ellipsis, caret, active option, ring and disabled in
the state section, Highlight/HighlightText under forced colours. The
site guard is red by design until the pickers' rules collapse in the
migration. Kit guard mutation-checked by deleting cursor: pointer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Filter dialog operator (ref kept — the dialog focuses it on open), the
builder's column and operator, the aggregate picker (custom entry now a
disabled option, never written). Every site keeps its attribute; the
aggregate picker gains one. Site rules that selected by element type are
rewritten onto the attributes and keep only size and flex participation.
Test drivers move to chooseOption / readOptions / selectValue; no claim
changed. Site guard mutation-checked (a re-added border fails it); the
kit guard now covers the label and caret (a deleted label rule fails it);
the bracket guard covers the select sites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he caret

The anchor was a prefix match that the caret rule also satisfied, so the
ordering guard could not fail (mutation-checked by moving the base rule
below the builder pickers). Comments that still described native-select
substitution now describe what the kit picker does; the pane-width floor's
rationale says what it was measured against; the label helper lives in one
place; the override test checks the option count it records.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t a select

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`select.tsx` returns a `<>…</>` fragment, but the package builds JSX with
the classic pragma (`pragma: createElement`, `pragmaFrag: Fragment`), so the
missing import made every rendered PretableSelect throw
"Fragment is not defined" and take the whole grid down with it. The vitest
suite could not see this: it transforms JSX with the automatic runtime, which
needs no import.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the classic-JSX imports

The five undocumented members of PretableSelectOption/Props now carry
their docs in the API report. PretableSelectOption is pinned to the
internal ListboxOption by TYPE IDENTITY, not two-way assignability — an
optional field added to either side satisfies both assignments and would
have slipped through (mutation-checked with `readonly extra?: string` on
ListboxOption: typecheck fails). A new test walks every shipped .tsx and
demands the createElement / Fragment imports the classic JSX build needs —
the trap that shipped an undefined Fragment in PretableSelect while 1844
unit tests stayed green (mutation-checked by removing the import; the test
fails naming components/select.tsx). No other offenders: every other
shipped .tsx already imports what it calls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…select

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…oseOption

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…xteen

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The guard's reactImportedNames read the unstripped source (so a mention
of createElement/Fragment inside a comment counted as a real import)
and matched `X as Y` by X (so `createElement as h` satisfied the guard
even though the classic pragma needs the bare name in scope). Both were
false negatives: a shipped file could pass the guard and still throw
"Fragment is not defined" at runtime.

Fixed by scanning a comments-only-stripped source (stripping strings too
would blank the "react" specifier itself) and binding each import by its
post-alias name. Added unit tests for the helper covering a commented-out
import, an aliased import, and a multi-line import; updated the header
comment to describe the two-stripper split and alias-binding rule.

Also corrected an over-claiming comment on the option-shape pin in
select.tsx: a `false` there is a compile error at that line, not
something that names the drifted field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…shared; search combobox scoped

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t never opens

The four pickers this component replaced were native <select>s — a stop in
every browser's sequential focus order. A plain <button> is not one in
WebKit unless macOS's "Tab moves between all controls" is on, so the
trigger carries an explicit `tabIndex={0}`, placed before the prop spread
so a consumer running its own roving tabindex still wins. The tool panel's
rail tab carries one for the same reason.

And `openList` returns early on an empty option set: opening there left the
trigger claiming `aria-expanded="true"` with `aria-controls` pointing at a
list that renders nothing. The keyboard path opens through the same
`onOpen`, so one guard covers both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…se stops describing native selects

The list is portalled to <body>, so the option a pointer picks is outside the
funnel dialog's subtree and its outside-press listener sees that press. A
browser test on `/fixtures/grouping` now picks an operator and asserts the
dialog is still standing with the new value, then that Escape unwinds one
layer per press: the list first, the dialog second.

The tool panel's two keyboard walks move the pickers back out of the
conditional sets. They carry an explicit tabindex again, so their exact
position is asserted in every browser — verified against WebKit, which
offered all of them.

Prose only, no declarations: the two skins' `color-scheme` comments named an
operator select and its popup list that are no longer native, and the token
reference said `--pretable-bg-selected` is read in two cell editors when it
is the kit's shared selected-option rule plus the date picker's day. The
components page's styling recipe named `--pretable-surface-hover`, which does
not exist; it is `--pretable-bg-hover`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
pretable Ignored Ignored Sep 6, 2026 6:57am UTC

Request Review

@blove
blove enabled auto-merge (squash) September 6, 2026 07:01
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-92dfx6hm7-cacheplane.vercel.app
Commit: 02347f93c90dd5af251984b5c2772693db14d9e3

Updated automatically by the deploy-preview job.

@blove
blove merged commit 2f4c6fc into main Sep 6, 2026
21 checks passed
@blove
blove deleted the blove/components-sp2-select branch September 6, 2026 07:15
@blove blove mentioned this pull request Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant