Skip to content

UN-4301 [FEAT] Add the v1 selection inputs: Select, Combobox, MultiSelect and SegmentedControl (P1-T04) - #2466

Open
praveen-formido wants to merge 19 commits into
mainfrom
UN-4301-selection-inputs
Open

praveen-formido wants to merge 19 commits into
mainfrom
UN-4301-selection-inputs

Conversation

@praveen-formido

@praveen-formido praveen-formido commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

What

  • Frontend v1 selection inputs (P1-T04), in frontend/src/v1/components/ui/select/, on Radix and the P0-T04 tokens:
    • Select (Radix Select): 32-tall trigger, placeholder, leading icon beside the label (Radix drops the value's class, so a wrapper lays it out), popover list with a check on the current choice; arrows, type-ahead, Home/End, Enter, Escape; a value that matches no option shows the placeholder; an empty list shows a disabled "No options" row (the one shape axe accepts inside Radix's listbox). For lists under about 50 options.
    • Combobox: the ARIA combobox-with-listbox pattern (aria-activedescendant with ids stable per option, so the active option is re-announced while filtering) over a Radix Popover; a click or ArrowDown/Up opens, the selected label is text-selected on focus so typing replaces it, typing filters, Enter selects, Escape/blur/Tab close and restore the selected label; chevron and Clear buttons; loadOptions(query, signal) for server-loaded options with a 250 ms debounce, aborts, stale responses dropped, loading, empty and error rows; selectedOption names a saved value before the first load; lists above 100 options render only the rows in view (@tanstack/react-virtual).
    • MultiSelect: the same engine with chips (20 tall), a multiselectable list that stays open on toggle, Backspace removing the last chip when the input is empty, chip remove buttons named "Remove X" and kept out of the tab order, selectedOptions for preset values.
    • SegmentedControl (Radix ToggleGroup single): 32 tall, 2 to 5 options, exactly one selected, rendered as a radio group with manual activation (arrows move focus, Space/Enter commit); takes id, aria-describedby and invalid like the others.
    • Shared: the SelectOption model (label, description, leading logo or icon, disabled, keywords for the filter), OptionRow, Chip, trigger.ts (the P1-T03 FieldFrame recipe so a select and an input sit identically), useListbox (keyboard and active-option engine), useAsyncOptions, Listbox, and shared.tsx (ChevronButton, ListPopover, useLoadedOptions) so Combobox and MultiSelect share one popover, chevron and loaded-options path.
  • select.gallery.tsx lists sixteen static variants and states; markup.test.tsx snapshots each one to __snapshots__/markup.md and runs axe on each, as ui/code does. A folder README and a "Selection inputs" section in src/v1/README.md.

Why

  • P1-T04 (UN-4301, spec): choosing one or many options works the same everywhere. It unblocks P1-T06 (form system, lane B), P1-T12 (list toolbar and filters, lane F), P2-T06 (share access, lane F) and P7-T05 (LLMWhisperer playground, lane C).

How

Decision Why
Trigger is the P1-T03 FieldFrame recipe (32 tall, radius 8, input border, hover muted-foreground, invalid destructive, ring only while the control is focused), copied not imported #2416 is unmerged; a select and an input must sit identically in a form. The Select trigger alone adds ui:cursor-pointer (it is a button; the Combobox and MultiSelect frames hold a text input).
List surface is rounded-lg border bg-popover p-1.5 shadow-md, max-h-80, trigger-width, a flex column with hidden overflow and one scroll element inside Board § 6: dropdown menus are shadow-md. One scroll container keeps Radix's scroll-into-view working and lets both the surface cap and Radix's available-height cap apply (tailwind-merge collapses two max-h on one element).
Status rows ("Loading…", "No options"/"No matches", "Could not load options") are <output> elements beside the role="listbox" element A listbox may contain only options and groups. Radix Select's row sits inside its viewport, the one exception.
Select renders every option; long and server lists go to Combobox Radix Select cannot virtualise or search; documented limit of about 50.
Combobox Home/End jump in the list while it is open and edit the caret while closed The spec says Home and End jump; APG says they move the caret. The list wins only while it is open.
Type-ahead is Radix Select's own; Combobox and MultiSelect filter instead, and keywords feed that filter The filter is the searchable form of type-ahead; a separate type-ahead helper had no caller and was removed.
SegmentedControl takes the shadcn tab-list treatment (muted track, background pill with shadow-sm) and manual activation No design frame draws a segmented control; the spec says "Enter selects", so arrows move focus only. Both are stated in the README.
selectedOption / selectedOptions props A saved value in an edit form has no label until the first server load; the caller passes the option it already has.
Chips are not tab stops N chips would add N stops before the field's own input; Backspace and the open list are the keyboard path for removal.
Option descriptions use the caption role (12/16) One role for every v1 description; the frame draws 11/16.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • No. Everything is new under frontend/src/v1/components/ui/select/, plus a stubLayout test helper in src/v1/test/dom.ts and a section in src/v1/README.md. No v0 file, dependency or build file changes, and no v0 route renders v1.

Database Migrations

  • None.

Env Config

  • None.

Relevant Docs

Related Issues or PRs

Dependencies Versions

  • None. @tanstack/react-virtual 3.14 was already a dependency (JsonView).

Notes on Testing

  • 172 tests in the folder: behaviour and keyboard per component plus hook tests for the engine and the loader (out-of-order server responses, abort on unmount, Escape restore, blur close, disabled options never active, Backspace chip removal, aria-activedescendant changing while filtering, a 1,000-option list rendering fewer than 60 rows with aria-setsize/aria-posinset). Every behavioural guard was mutation-checked: removing it fails a test.
  • Markup snapshot of sixteen gallery entries with an axe scan each; open states are covered by the behaviour tests (Radix portals are not static entries).
  • bun run test:v1: 618 tests green, coverage about 98% lines / 95% branches; lint:v1, typecheck:v1 and biome ci src/v1 clean. The open-empty-Select axe test disables one rule, aria-hidden-focus, because Radix Select is modal and hides the page behind it.
  • Radix Select needed no DOM stubs under happy-dom 20; virtual lists use stubLayout from @v1/test.
  • Reviewed with a per-task review and a whole-branch review with one fix wave; the branch's rulings are in claudedocs/frontend-v1-lane-c/plans/2026-11-09-P1-T04-sdd-ledger.md (not committed).

🤖 Generated with Claude Code

praveen-formido and others added 16 commits November 9, 2026 16:40
…-ahead guards in tests

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
…n the form pass-throughs in Select

Co-Authored-By: Claude Fable 5.1 <[email protected]>
…ity, and close the engine's untested guards

Co-Authored-By: Claude Fable 5.1 <[email protected]>
…ed a preset Combobox value

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Lift the icon-button class, the blur containment check and the list
content props into shared.ts so the MultiSelect can reuse them; the
Combobox behaves as before.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Several choices from a long or server-loaded list: chips before the
input, Enter or a click toggles an option and keeps the list open with
the query cleared, Backspace in an empty input removes the last chip,
and a name submits one hidden input per value.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
…ab order, and pin the MultiSelect chevron

A toggle clears the query but leaves its option active, so the arrows
carry on from it. Chip gains removeFocusable; MultiSelect passes false,
so Tab reaches the input in one stop. The chevron's open/close branch and
its focus-keeping mousedown are now tested, and a controlled value with
duplicates is collapsed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
…, and open the Combobox on click

Select wraps Radix's value element (which drops className) in a flex
wrapper whose child variants truncate the label and keep the chevron in
the trigger. Option ids follow the option's value, so the active id
changes when the first match does. A click on the Combobox or
MultiSelect input opens the list, the shown label is selected so typing
replaces it, and a press on the MultiSelect frame focuses the input.
Enter is kept from the form while a list is open; closing drops loaded
options and opening reports loading at once. The listbox takes the
field's name, the chevron controls it, virtual rows carry setsize and
posinset, and onOpenChange(false) closes through close().

Co-Authored-By: Claude Fable 5.1 <[email protected]>
…k between Combobox and MultiSelect; give SegmentedControl the field props

shared.ts becomes shared.tsx with ChevronButton, ListPopover, the
ListFieldProps type and fieldInputProps, closeOnLeave, toggleList, and
useLoadedOptions, which owns the served mirror, the seen map and the
loader, and so creates the listbox itself. Both fields name a value from
the current options, then any option seen, then the preset option(s).
SegmentedControl takes id, aria-describedby and invalid on its group,
drawn as a destructive outline outside the track.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
typeAheadIndex had no caller (Radix Select has its own type-ahead, and
the Combobox and MultiSelect filter), so it and its tests go. The empty
Select's "No options" row becomes a disabled option outside Radix's
collection: axe rejects a status element inside the listbox even in a
group, and an empty listbox, and a page scan of the open empty Select is
now clean apart from Radix's modal aria-hidden-focus. The READMEs say
which components take SelectOption and which SegmentedOption, and the
Chip props are a table with className.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
@praveen-formido praveen-formido added the main-only Stays on main: not backported to a release line label Nov 10, 2026
@greptile-apps

greptile-apps Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerView in GreptileConfidence Score: 5/5

[Low impact] The PR appears safe to merge, with two non-blocking input edge cases to fix.

Fix All in Claude CodeFindings

  1. P2 Arrows interrupt text composition ▶
  2. P2 Unknown defaults leave nothing selected ▶
Fix with agent prompt
### Issue 1
frontend/src/v1/components/ui/select/useListbox.ts:183-187
While an IME is composing text, `useListbox` still captures ArrowUp and ArrowDown and prevents their normal action. These keys also move through IME candidates, so users entering Chinese or Japanese text can move through the picker instead of their text candidates. Return early for composing events before the key switch, not just inside the Enter branch.

### Issue 2
frontend/src/v1/components/ui/select/SegmentedControl.tsx:67-71
`SegmentedControl` accepts an unknown `defaultValue` without checking it against `options`. For example, `defaultValue="old"` with options `"list"` and `"grid"` leaves every item unchecked. This breaks the documented first-enabled fallback and the promise that one item stays selected. Check the supplied default before using it; fall back to the first enabled option when it does not match.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds reusable v1 selection inputs: Select, Combobox, MultiSelect, and SegmentedControl.

  • Select gives short lists a consistent keyboard-friendly dropdown.
  • Searchable pickers filter choices and load long lists as needed.
  • MultiSelect keeps chosen values as removable chips.
  • SegmentedControl shows a small set of choices without a dropdown.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Select[Select] --> Radix[Radix Select]
  Combobox[Combobox] --> Shared[useLoadedOptions]
  MultiSelect[MultiSelect] --> Shared
  Shared --> Keyboard[useListbox]
  Shared --> Async[useAsyncOptions]
  Keyboard --> List[Listbox]
  List --> Rows[OptionRow]
  List --> Virtual[Virtual rows above 100 options]
  Segmented[SegmentedControl] --> Toggle[Radix ToggleGroup]
Loading

Reviews (4) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." · Reviewed by Greptile

…sts at the free height and keep a saved value active while it loads

Answers the first review of the selection inputs. A query change now
resets useAsyncOptions to its loading state, so the previous query's
results are neither shown nor selectable while the next load waits out
the debounce (Enter during that wait selected a row the server had not
matched). ListPopover's Listbox scroll element takes the popover's
available-height cap beside the surface's 320px one, as Select's
Viewport already did, so a short viewport scrolls the list instead of
pushing the active option off-screen. Opening a server-loaded field with
a saved value before its options arrive keeps that value active, so the
first result no longer takes its place. The three axe-only tests assert
through the registered matcher, which SonarCloud reads as an assertion;
the helper call it could not see failed the quality gate.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
A saved value remembered while its server options were loading could
arrive disabled and take the active row, so Enter did nothing although
enabled options were there. The active index now skips a disabled match
and falls back to the first enabled option, which also holds for an
index set by hand.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

@athul-rs athul-rs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field and useFieldControl are in #2416 (P1-T03, not P1-T06 as the README says). Once it merges, could Select, Combobox and MultiSelect call useFieldControl(props) like Input and Textarea do? Then <Field label> and P1-T06's FormField label them with no wiring in each form, and they still work standalone because the hook passes the props through when there is no Field. SegmentedControl keeps aria-label/aria-labelledby as you note.

@github-actions

Copy link
Copy Markdown
Contributor

Frontend Lint Report (Biome)

✅ All checks passed! No linting or formatting issues found.

@sonarqubecloud

Copy link
Copy Markdown

Comment on lines +183 to +187
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
switch (event.key) {
case "ArrowDown":
case "ArrowUp":
event.preventDefault();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Arrows interrupt text composition

While an IME is composing text, useListbox still captures ArrowUp and ArrowDown and prevents their normal action. These keys also move through IME candidates, so users entering Chinese or Japanese text can move through the picker instead of their text candidates. Return early for composing events before the key switch, not just inside the Enter branch.

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/v1/components/ui/select/useListbox.ts
Line: 183-187

Comment:
**Arrows interrupt text composition**

While an IME is composing text, `useListbox` still captures ArrowUp and ArrowDown and prevents their normal action. These keys also move through IME candidates, so users entering Chinese or Japanese text can move through the picker instead of their text candidates. Return early for composing events before the key switch, not just inside the Enter branch.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

Comment on lines +67 to +71
const [uncontrolled, setUncontrolled] = useState(
() =>
defaultValue ?? options.find((option) => !option.disabled)?.value ?? "",
);
const current = value ?? uncontrolled;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unknown defaults leave nothing selected

SegmentedControl accepts an unknown defaultValue without checking it against options. For example, defaultValue="old" with options "list" and "grid" leaves every item unchecked. This breaks the documented first-enabled fallback and the promise that one item stays selected. Check the supplied default before using it; fall back to the first enabled option when it does not match.

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/v1/components/ui/select/SegmentedControl.tsx
Line: 67-71

Comment:
**Unknown defaults leave nothing selected**

`SegmentedControl` accepts an unknown `defaultValue` without checking it against `options`. For example, `defaultValue="old"` with options `"list"` and `"grid"` leaves every item unchecked. This breaks the documented first-enabled fallback and the promise that one item stays selected. Check the supplied default before using it; fall back to the first enabled option when it does not match.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
⚪ e2e-agent-kv e2e 0 0 0 1 0.1
✅ e2e-api-deployment e2e 3 0 0 0 12.8
✅ e2e-coowners e2e 1 0 0 0 1.8
✅ e2e-etl e2e 1 0 0 0 14.4
✅ e2e-login e2e 2 0 0 0 1.2
✅ e2e-prompt-studio e2e 1 0 0 0 6.8
✅ e2e-smoke e2e 2 0 0 0 1.0
✅ e2e-workflow e2e 1 0 0 0 22.6
❌ ui e2e 0 1 0 0 0.0
TOTAL 11 1 0 1 60.6

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
💤 Covered, but not exercised in this build
  • adapter-register-llm — Register and validate an LLM adapter. (covered by integration-backend; no result reported in this build)
  • workflow-author — Create a workflow; its source+destination endpoints materialise and are configurable. (covered by integration-backend; no result reported in this build)
  • api-deployment-provision — Deploying a workflow as an API mints a usable key and a resolvable endpoint. (covered by integration-backend; no result reported in this build)
  • api-deployment-auth — Unauthenticated or mis-scoped API-deployment calls are rejected before dispatch. (covered by integration-backend; no result reported in this build)
  • mcp-server-auth — Unauthenticated or mis-scoped hosted-MCP calls are rejected before any tool runs. (covered by integration-backend; no result reported in this build)
  • mcp-platform-auth — The org-scoped MCP endpoint stays behind the platform-API-key middleware; unauthenticated or mis-scoped calls reach no tool. (covered by integration-backend; no result reported in this build)
  • platform-key-whoami — A platform API key resolves its own organisation over the org-less whoami endpoint; the org comes from the key row, not the URL. (covered by integration-backend; no result reported in this build)
  • prompt-studio-author — Create a Prompt Studio project and add a prompt to it. (covered by integration-backend; no result reported in this build)
  • connector-register-test — Connector credentials are validated against the live system and stored encrypted. (covered by integration-backend; no result reported in this build)
  • usage-aggregate-read — Per-run token usage aggregates correctly and stays scoped to its organization. (covered by integration-backend; no result reported in this build)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • co-owner-manage — covered by e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-run — covered by e2e-api-deployment
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • pipeline-etl-execute — covered by e2e-etl
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

main-only Stays on main: not backported to a release line

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants