Skip to content

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

Description

@Gnuk

What is missing

The library has no control that lets a user type into a field to filter a list of options and pick
one or several of them. @ippon-ui/styles@0.1.1 ships a floating panel of actions
(ippon-dropdown) but no floating panel of options: nothing in the distributed CSS or docs
carries role="listbox", role="option", aria-activedescendant or aria-autocomplete. Every
consumer that needs a picker therefore rebuilds one on top of input-text and dropdown, and each
rebuild invents its own keyboard handling and its own ARIA wiring — the two parts that are hardest
to get right and the two the design system should own.

What exists today and why it does not cover it

Component Why it falls short
atom/input-text The atom is the native input, so there is no room inside its box for a leading search icon or a trailing counter. No -icon-leading alternative is documented.
atom/icon Can sit next to an input, but has no documented way of sitting inside the input box.
molecule/field Documented as stateless (Label + control + Helper text, the caller wires for/id and aria-describedby). It anticipates more controls — "textarea or select tomorrow" — but no such control exists to slot in.
organism/dropdown A panel of actions: the trigger opens it through command="toggle-popover" / commandfor, and ippon-dropdown---buttons ionises buttons full width. Options are not buttons and must not carry button semantics. It has no active-option highlight, no selected state, and no busy / empty / failed content states.
molecule/table Static and presentational: no selectable row, no active row, no empty state.

The blocking point is the anchoring contract. dropdown is opened by an invoker button through
command / commandfor, and IpponDropdown hard-codes popover="auto". A combobox is anchored to
a text input, which is not an invoker, and has to open and close programmatically. That is the
one place where the existing organism cannot be reused as-is.

Design

https://www.figma.com/design/Tp97MEqQR8YTnjElPowa6k/Librairie_IPPON-2025?node-id=1410-155&t=hTsmxYga8DE27kYJ-0

The Figma node is a component set named Select Field with a single State property and four
values: Closed, Open & Non Active, Open & Active, With Value. Both open variants nest an
instance named Dropdown List — a bordered, shadowed panel of five option rows, each carrying a
checkbox, two of them checked in Open & Active. The field itself carries a magnifier and a visible
text caret, so it is typed into rather than being a button-styled select. It is therefore a
searchable multi-select combobox, not a search field and not a plain select.

Only a partial read of the file was possible (the Figma seat hit its tool-call limit before the
layer structure could be inspected), so the layout below is measured off the rendered frames rather
than read from auto-layout. The token bindings are read from the node.

Anatomy

  1. Label above the field, Body/Body Medium, Neutral/Text&Icon/Tertiary, Spacing/Margin/S
    (12) down to the field.
  2. Field box, 52 tall, surface Neutral/Surface/Primary, border Spacing/Border/Width/M (1) in
    Neutral/Border/Border, radius Spacing/Border/Radius/M (4), horizontal padding
    Spacing/Margin/M (16).
  3. Leading magnifier, 16x16, 16 from the left edge. Neutral/Text&Icon/Tertiary Inversed at
    rest, Neutral/Text&Icon/Primary once open or filled.
  4. Text zone: placeholder, or an empty typing area with a caret in
    Information 2/Text&Icon/Primary, or the selected labels joined by commas.
  5. Counter pill, 55x24, fully rounded, Brand/Surface/Primary, containing the selection count in
    Label/Label Small and a clear-all cross, both Brand/Text&Icon/On primary. Drawn only when at
    least one option is selected. It is one pill for the whole selection — the design has no
    removable per-value chips.
  6. Trailing chevron, a 10x6 solid triangle, 16 from the right edge, Neutral/Text&Icon/Tertiary.
    It is drawn identically in all four variants, so nothing says it rotates when open.
  7. Helper text below the field, Body/Body Small, Neutral/Text&Icon/Tertiary. Drawn on the
    closed variant only.
  8. Option panel, starting 10 below the field, same width, same surface, border and radius as the
    field, plus a soft drop shadow (no named effect token was readable), with Spacing/Margin/XXS (4)
    of inner padding top and bottom.
  9. Option row, 48 tall, full-bleed highlight, 16 of left padding to a 16x16 checkbox, then the
    label in Body/Body Medium, Neutral/Text&Icon/Primary.

Semantic tokens bound by the node, in the vocabulary the library already uses:

Role Token
Field and panel surface Neutral/Surface/Primary
Highlighted option row Neutral/Surface/Primary Hover
Field border, panel border, unchecked box Neutral/Border/Border
Typed text, selected values, option labels Neutral/Text&Icon/Primary
Label, helper text, chevron Neutral/Text&Icon/Tertiary
Placeholder, resting magnifier Neutral/Text&Icon/Tertiary Inversed
Focus ring Information 2/Surface/Primary
Text caret Information 2/Text&Icon/Primary
Counter pill fill, checked box fill Brand/Surface/Primary
Counter glyphs, check glyph Brand/Text&Icon/On primary

States to support

Drawn in the design:

  • closed / rest — placeholder and magnifier in the inversed tertiary colour, helper text below;
  • open, focused, no selection — a 2px focus ring drawn outside the border with a ~2px gap,
    caret visible, panel open, every checkbox unchecked;
  • open, focused, with selection — the same plus the counter pill and the checked rows;
  • closed with a value — no ring, selected labels joined by commas, counter pill kept, no helper
    text;
  • option row highlighted — a single full-bleed grey band. The design does not distinguish
    pointer hover from keyboard active option, so one visual state has to serve both;
  • checkbox checked / unchecked.

Not drawn, and needed before the component can ship — these need a design pass:

  • disabled and read-only, for the field and for an individual option;
  • error and success, to stay consistent with the -error / -success alternatives of input-text;
  • busy, while the options the consumer hands over are still being loaded;
  • empty, when the typed query matches nothing;
  • failed, when options could not be loaded;
  • what happens past five rows (max panel height, scrolling) and what happens when the joined labels
    overflow the field width (truncate or wrap);
  • single-select. The node draws checkboxes on every row, so it only documents the multi-select case.

Atomic level and composition

Three pieces, in this order.

Atom — a search-capable control box. input-text is a naked native input and cannot host the
leading magnifier or the trailing counter. Either a new input-search atom, or an
-icon-leading / -icon-trailing alternative on input-text plus the wrapper element that makes it
possible. Whichever is chosen, the contract must stay identical to input-text — named by a label
through for/id, described by a helper-text through aria-describedby — so that field accepts
it as a control without any change. This part could reasonably be split into its own issue; it is
listed here because the combobox cannot be built without it.

Molecule — the option row and the option list. An option row (checkbox or check mark, label,
optional icon, badge or secondary text) with selected and active alternatives, and the list that
stacks them together with its busy, empty and failed content. Same nature as field: a composition
of atoms with presentational alternatives and no orchestration of its own.

Organism — combobox. It wires the control to the floating list, owns the expanded state, the
active option, the selection and the ARIA relations between the two. That orchestration is what makes
it an organism rather than a molecule, and why it must not be pushed into field, which the library
documents as stateless.

Reuse rather than reinvent: icon for the magnifier and per-option glyphs, label and helper-text
for naming and describing, badge for the counter pill and for optional per-option metadata,
progress for the busy list, text for the empty message, error-area for the failed list (it is
already specified to fill a zone whose content could not be loaded and offers a retry action),
separator for grouping, field as the surrounding layout. The panel should share the floating
surface of dropdown — an options alternative alongside ippon-dropdown---buttons rather than a
second floating surface — with anchoring to a non-invoker trigger as the one thing to extend.

Following CAP, roughly:

<div class="ippon-combobox">
  <input class="ippon-input-text -icon-leading" role="combobox"
         aria-expanded="true" aria-controls="picker-listbox" aria-autocomplete="list"
         aria-activedescendant="picker-option-2" id="picker" />
  <span class="ippon-icon ippon-ion-search ippon-combobox--icon" role="presentation"></span>
  <span class="ippon-badge ippon-combobox--counter">2</span>
  <ul class="ippon-dropdown ippon-dropdown---options" role="listbox" id="picker-listbox">
    <li class="ippon-combobox--option -active" role="option" id="picker-option-2"
        aria-selected="true">Option 2</li>
  </ul>
</div>

Accessibility

The library already makes the caller wire for/id and aria-describedby in field; the combobox
should keep that contract and own everything the consumer cannot reasonably get right:

  • role="combobox" on the input, with aria-expanded, aria-controls and aria-autocomplete="list";
  • role="listbox" on the panel, role="option" with aria-selected and aria-disabled on each row,
    and aria-multiselectable when several options can be picked;
  • DOM focus never leaves the input: the active option is tracked with aria-activedescendant;
  • keyboard: ArrowDown / ArrowUp move the active option and open the panel when it is closed,
    Home / End jump to the first and last enabled option, Enter selects the active option,
    Escape closes and leaves focus in the input, Tab closes without selecting. Disabled options are
    skipped and the active option is scrolled into view;
  • pointer: hovering a row makes it active, clicking it selects it without stealing focus from the
    input;
  • the list announces its own state: aria-busy while loading, and a readable message for the empty
    and failed cases rather than an empty panel;
  • the selection counter must not be the only carrier of the information — the number of selected
    options needs a text alternative.

React wrapper

An IpponCombobox exported from @ippon-ui/react (plus IpponInputSearch if the control atom is
built separately), following the conventions already in the package: dataSelector through
DataSelectable, classes built with clsx() and the CAP helpers, alternatives as union types,
booleans as optional props. Like IpponDropdown it should require an id, and derive
${id}-listbox and ${id}-option-${key} internally so the consumer never wires ARIA by hand. Like
IpponImportFile it should hand back domain values rather than DOM events, and discriminate the
single and multiple cases through a union.

type IpponComboboxProps<T> = DataSelectable & {
  id: string;
  value: string;                            // the query, fully controlled
  onChange: (value: string) => void;
  items: readonly T[];
  itemKey: (item: T) => string;
  itemLabel: (item: T) => string;
  itemDescription?: (item: T) => string;
  itemDisabled?: (item: T) => boolean;
  renderItem?: (item: T, state: { active: boolean; selected: boolean }) => ReactNode;
  placeholder?: string;
  disabled?: boolean;
  required?: boolean;
  name?: string;
  variant?: 'error' | 'success';
  loading?: boolean;                        // renders progress, sets aria-busy
  emptyState?: ReactNode;
  error?: ReactNode;
  onRetry?: () => void;                     // feeds the error-area retry action
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  onKeyDown?: KeyboardEventHandler;
} & (
  | { multiple?: false; selection?: T; onSelect: (item: T) => void }
  | {
      multiple: true;
      selection: readonly T[];
      onSelect: (item: T) => void;
      onDeselect: (item: T) => void;
      onClear: () => void;                  // the cross on the counter pill
    }
);

Generic over T so consumers keep their own types. aria-describedby and aria-labelledby must be
passed through so the component can live inside an IpponField whose label and helper text the caller
wires.

Out of scope

No data layer. The component never fetches, never debounces, never caches and never filters: it
renders the options it is handed, and the consumer owns the query lifecycle. No per-value removable
chips either — the design expresses the whole selection with a single counter pill.

Context

Found on @ippon-ui/styles@0.1.1 while building a form where a user picks entries out of a list too
long to render in full, and has to type to narrow it down. The current workaround is an
IpponInputText placed inside an ippon-dropdown whose rows are IpponButtons: it looks close
enough, but the panel is a menu of actions rather than a listbox, keyboard users get button
semantics and tab-through instead of arrow-key traversal, and screen readers are never told that the
field controls a list of options. That is exactly the part a design system should not leave to each
consumer.

Happy to take this one if the shape above is agreed — the missing states listed under "States to
support" would need a design pass first.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions