Skip to content

feat(styles,react): add combobox, option list, input search and checkbox - #52

Draft
Gnuk wants to merge 1 commit into
mainfrom
51-combobox
Draft

feat(styles,react): add combobox, option list, input search and checkbox#52
Gnuk wants to merge 1 commit into
mainfrom
51-combobox

Conversation

@Gnuk

@Gnuk Gnuk commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Closes #51

What this brings

@ippon-ui/styles had a floating panel of actions (ippon-dropdown) but no floating panel of options. Every consumer needing a picker rebuilt one on top of input-text and dropdown, inventing its own keyboard handling and its own ARIA wiring — the two parts hardest to get right and the two the design system should own.

Five new components, bottom up:

Level Component Role
quark control-box, checkbox, visually-hidden shared field container, shared check box, screen-reader-only text
atom input-search a field with room inside its box for a leading icon and a trailing slot
atom checkbox a native checkbox rendered as the box the design asks for
molecule option one row: check box, label, optional secondary text, optional trailing slot
molecule option-list the listbox, its footer, and its loading, empty and failed content
organism combobox the orchestration: expanded state, active option, selection, ARIA relations

Plus IpponInputSearch, IpponCheckbox, IpponOption, IpponOptionList and IpponCombobox in @ippon-ui/react.

The anchoring point

dropdown could not be reused as is. It is opened by an invoker button carrying command / commandfor, and the invoker anchors the panel implicitly. A combobox is anchored to a text field, which is not an invoker and has to open and close programmatically.

  • the panel is popover="manual", not auto: with auto, the click landing in the field would light-dismiss the panel that click just opened;
  • the anchoring is explicit — anchor-name on the field, position-anchor and anchor-size(width) on the panel — with anchor-scope on the wrapper so two combobox on a page do not both anchor to the first field.

IpponDropdown gains alternative and popover props, both defaulting to the current behaviour.

Design fidelity

The measurements in the issue were read off the rendered frames rather than the auto-layout (the Figma seat hit its tool-call limit). Everything is snapped to the existing token scale instead, per @Gnuk's arbitration: the 52 field height becomes --ippon-size-48input-text's own height, 52 being it plus the 2×2 focus ring caught in the measurement — and the 10 panel gap becomes the --ippon-size-8 dropdown already uses.

One open design question. The panel reuses dropdown's floating surface rather than introducing a second one, as the issue asked, so it keeps --ippon-radius-l and its shadow-without-border where the Figma binds radius M and a border. The field and the panel therefore have two different corner radii while the design draws them identical, and the two shapes nearly touch, so it is visible. Library coherence against design fidelity — worth a call before merge; it is one line either way.

States

The nominal multi-select is what the Figma draws. The rest was undrawn and is derived from conventions already in the library, on @Gnuk's call to settle them here rather than leave each consumer to improvise:

  • error / success — same mechanics as input-text's -error / -success;
  • disabled / read-only — the field dims or greys, the panel never opens;
  • loading / empty / failed — three ReactNode slots on option-list, filled in the stories by IpponProgress, IpponText and IpponErrorArea with its retry action. The state renders beside the rows, never in their place, so a search still in flight keeps the previous results on screen instead of making the panel flicker at every keystroke;
  • overflow — the panel scrolls past five rows (calc(5 * var(--ippon-size-48) + 2 * var(--ippon-size-4))), and joined labels truncate with an ellipsis;
  • single select — the -single alternative swaps the box for a bare check glyph, shows no counter and closes on pick.

Pagination

option-list takes a footer, rendered after the rows, inside the scrolling area and outside the listbox. A "load more" button, a "20 of 137" counter or the sentinel of an infinite scroll goes there. Being inside the scroll is what makes a sentinel work: pinned under the list it would always be in view and would never stop asking for more. IpponCombobox passes it straight through — no data enters the component.

Accessibility

role="combobox" with aria-expanded, aria-controls and aria-autocomplete="list"; role="listbox" with aria-multiselectable and aria-busy; role="option" with aria-selected and aria-disabled. DOM focus never leaves the input — the active option is tracked with aria-activedescendant, which is why the option row draws a check box rather than containing one.

Keyboard: arrows, Home / End, Enter, Escape, disabled options skipped, active option scrolled into view.

Dismissal took three passes to get right, and the browser corrected me twice. It now rests on two mechanisms: a pointer landing outside the component, and focus moving to a named element outside it — so Tab closes the panel without selecting, while Tab onto something the panel itself holds, such as a footer button, keeps it open, which is the only way that button is reachable without a pointer.

The third case is the one a footer makes possible: a "load more" button that hides itself once the last page is in removes the focused element from the document. Chromium fires no focus event at all there, so nothing closed the panel — but nothing could close it afterwards either, since no part of the component held focus any more. It stayed open through a click on a button entirely outside it. The outside-pointer listener is what fixes that, and Escape is listened for at the document level for the same reason, so an orphaned focus never leaves a panel only a pointer could dismiss.

That document-level Escape then needed its own default action cancelled, so it dismisses the panel alone instead of also reaching whatever else on the page answers that key — a native dialog around the field, for instance, which would otherwise close and take the form with it. The path through the field already cancelled it and hid the gap. A closed combobox claims nothing. Engines that do report such a removal are told apart from a genuine focus exit by relatedTarget === null && !target.isConnected; that branch is defensive and untestable from jsdom, which is stated plainly rather than implied by a green suite.

The selection count is also stated in words for assistive technology, so the counter badge is not its only carrier. IpponIon gains a label prop (and the Pug mixin a label option) setting aria-label: a clickable icon carrying no text — the counter's clear cross — had no accessible name.

Out of scope

No data layer: the component never fetches, never debounces, never caches and never filters. It renders the options it is handed; the consumer owns the query. The stories wire the filtering and the pagination to show what that looks like.

Known gap, deliberately not invented here: the checkbox atom has no indeterminate state. It is in neither the design nor the list of states to settle, and a "select all" will want it one day.

Checks

mise format-ci, mise lint-ci, mise test-unit-ci (369 React tests, 32 files) and mise build all pass. The input-text SCSS refactor onto the shared quark was verified to produce byte-identical CSS.

The organism was driven in a real browser through Storybook, not only in jsdom: focus opens the panel, the anchoring positions it, arrows move the active option, Enter selects, typing filters, Escape closes and leaves focus in the field, Tab reaches the footer button with the panel open and the next Tab closes it, "load more" grows the list into its scroll, a footer that removes itself leaves the panel open on its full list, and an outside click — or Escape — then closes it.

🤖 Generated with Claude Code

@Gnuk
Gnuk force-pushed the 51-combobox branch 9 times, most recently from c55807a to d390433 Compare August 18, 2026 14:15
The library had a floating panel of actions but no floating panel of
options, so every consumer needing a picker rebuilt one on top of
input-text and dropdown, inventing its own keyboard handling and its own
ARIA wiring.

dropdown could not be reused as is: it is opened by an invoker button
carrying command/commandfor, which anchors the panel implicitly, while a
combobox is anchored to a text field that is not an invoker and has to
open programmatically. The panel is therefore a manual popover — with
auto, the click landing in the field would light-dismiss the panel that
click just opened — anchored explicitly through anchor-name and
position-anchor, with anchor-scope confining the name to each instance.

Two quarks keep the new atoms from drifting: one holds the field
container input-text and input-search now share, the other the check box
the checkbox atom and the option row share. The input-text refactor
leaves the generated CSS unchanged byte for byte.

Closes #51
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.

combobox: no component lets a user type to filter a list of options and pick them

1 participant