feat: rebuild `Select` as a select-only combobox

Merged
DO
2d ago
+960-398

Proposed changes (including videos or screenshots)

Fuselage's Select no longer goes through React Aria's useSelect. Its trigger always carried an aria-labelledby, which overrode a label pointing at the select's id. So <FieldLabel htmlFor={id}> + <Select id={id}> never named the select (screen readers heard only the selected value), and React Aria logged "If you do not provide a visible label, you must specify an aria-label or aria-labelledby attribute" on every render.

The select is now a hand-built select-only combobox:

  • The trigger is a button with role="combobox". A label pointing at its id names it, like any other input, and aria-describedby reaches it for hints and errors. error sets aria-invalid.
  • The focus stays on the trigger while the popup is open. aria-activedescendant marks the active option.
  • Keyboard: arrows, Home/End, PageUp/PageDown, Enter/Space to select, Tab selects and moves on, Escape closes, Alt+Up, and multi-character typeahead (a space typed mid-search extends the search).
  • The popup is Fuselage's PositionAnimated, placed below the select at its width, and the page doesn't scroll while it is open. The list is named after the select.
  • The props are unchanged, so no call site changes.

The PR's changes, by concern:

  1. fix: positioned popups no longer crash when two of them are removed in the same render. They share one portal container, and the second cleanup tried to remove it again.
  2. refactor: move useActiveDescendant from the client to @rocket.chat/fuselage-hooks, so the client's comboboxes (composer popup, navbar search) and Fuselage share it.
  3. feat: add Listbox and ListboxOption, the popup list for a combobox that keeps the focus. The other legacy selects (MultiSelect, PaginatedSelect, …) can move onto them later. Option gains focusVisible.
  4. feat: rebuild Select on them, and remove SelectAria, SelectTrigger and the React Aria listbox. useTypeahead stays private to Select for now.
  5. test: client tests wait for a select's options to appear (findByRole), since the popup stays hidden until it is positioned, as the other positioned lists already do. Tests that found the select by label text now query the combobox, because its list carries the same name.

Visual comparison with develop

I compared Storybook pixel by pixel against develop in 13 states. All closed states, hover, keyboard focus, and the popup opened by keyboard are identical. Two differences, both intended:

  • The option under the mouse gets the hover surface, not the focus ring. On develop, React Aria's real focus made the ring follow the mouse. The ring now shows only while the keyboard drives the list, as it already does in Menu (focusVisible={isFocused && isFocusVisible}).
  • The States story now shows its states. It fakes hover, active and focus through className, which React Aria dropped. No call site passes className to Select.

Issue(s)

https://rocketchat.atlassian.net/browse/ARCH-2589

Steps to test or reproduce

  1. Open any settings page that uses a select field, such as Administration → Settings → Retention Policy → Timer precision. The select is named by its label ("Timer precision"), and the console no longer shows the React Aria warning.
  2. With the keyboard, Tab to a select and press ArrowDown. The list opens on the selected option. Arrows move, Enter selects, Escape closes without changing. Type the first letters of an option to jump to it.
  3. With the mouse, open a select, pick an option, and click outside. The list closes, and the focus stays on the select (also in Safari).
  4. In Storybook, open Inputs/Select.

Further comments

  • Checked in Chromium, Firefox and WebKit through Playwright: name from <label for>, keyboard, pointer, outside click, Space without the button's own click toggling the list, popup position and width. A manual screen reader pass (VoiceOver + Safari) is still worth doing.
  • Some selects still have no label at all, such as the Threads, Files and Members filters. A follow-up adapts the call sites, mostly back to plain htmlFor.
  • react-aria remains a dependency of Fuselage for Menu, Popover and Slider.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added reusable listbox and listbox option components, with support for active, selected, disabled, and focus-visible states.
    • Select menus now support keyboard navigation, type-ahead search, controlled or default values, and form submission.
  • Improvements
    • Select menus are now select-only comboboxes with accessible labels. Use disabled, value, and onChange to control them; the previous React Aria selection props are no longer supported.