Repository navigation
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## refactor/move-shared-listbox-parts #5180 +/- ##
======================================================================
+ Coverage 97.77% 97.78% +0.01%
======================================================================
Files 1004 1009 +5
Lines 32566 32786 +220
Branches 12100 12156 +56
======================================================================
+ Hits 31841 32060 +219
+ Misses 719 683 -36
- Partials 6 43 +37 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
9f3d361 to
290cc68
Compare
290cc68 to
5601acf
Compare
4c74889 to
5aaaf47
Compare
1dfd1e0 to
10601c8
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved keyboard navigation, focus, scrolling, accessibility, and renderer-contract defects affect the new component’s behavior.
9 open issues
Scroll on repeated keyboard navigation requests · New Preserve parent group state for child renderers · New Avoid dangling aria-describedby for custom disabled content · New Reconcile highlight index when options change · New Wrap navigation based on highlightable options · New Initialize keyboard focus despite stale hover highlights · New Exclude nonmatching group headers from type-ahead targets · New Ignore handled events before updating type-ahead search · New Define an OptionList-specific renderer contract · New
What changed in this PR
Adds OptionList as an inline single- or multi-selection component, building on #5179’s shared selection models. It is the first caller of the new listbox engine, while Select and Multiselect retain their existing behavior.
Changes:
- Adds the public component, grouped selection, custom rendering, and localized announcements.
- Introduces listbox keyboard, focus, scrolling, and item-rendering support.
- Adds test-utils, unit and integration tests, demo pages, and generated API snapshots.
| File | Description |
|---|---|
src/test-utils/dom/option-list/index.ts |
Adds option and group wrappers. |
src/option-list/test-classes/styles.scss |
Defines stable test selectors. |
src/option-list/styles.scss |
Adds component root styling. |
src/option-list/internal.tsx |
Connects public options to the listbox engine. |
src/option-list/interfaces.ts |
Defines the public component API. |
src/option-list/index.tsx |
Adds the public entry point. |
src/option-list/__tests__/option-list.test.tsx |
Tests component behavior and accessibility. |
src/option-list/__integ__/option-list.test.ts |
Tests browser selection and focus. |
src/option-list/__integ__/option-list-scroll.test.ts |
Tests keyboard scrolling across containers. |
src/internal/components/selectable-item/styles.scss |
Supports hiding the first divider. |
src/internal/components/selectable-item/interfaces.ts |
Adds the divider-control prop. |
src/internal/components/selectable-item/index.tsx |
Applies the divider-control class. |
src/internal/components/options-list/utils/use-keyboard.ts |
Makes Escape handling optional. |
src/internal/components/options-list/index.tsx |
Supports mouse-leave and default status. |
src/internal/components/listbox/use-type-ahead.ts |
Adds buffered keyboard matching. |
src/internal/components/listbox/use-listbox.ts |
Implements the listbox interaction controller. |
src/internal/components/listbox/parts/styles.scss |
Styles selection indicators and items. |
src/internal/components/listbox/parts/scroll-to-index.ts |
Adds ancestor-aware option scrolling. |
src/internal/components/listbox/parts/render-options.tsx |
Supports the new listbox item renderer. |
src/internal/components/listbox/parts/list.tsx |
Renders and measures inline lists. |
src/internal/components/listbox/parts/item.tsx |
Renders selection states and disabled reasons. |
src/internal/components/listbox/index.tsx |
Connects rendering and highlight scrolling. |
src/internal/components/listbox/__tests__/use-type-ahead.test.ts |
Tests type-ahead handling. |
src/i18n/messages/all.zh-TW.json |
Adds Traditional Chinese selection text. |
src/i18n/messages/all.zh-CN.json |
Adds Simplified Chinese selection text. |
src/i18n/messages/all.tr.json |
Adds Turkish selection text. |
src/i18n/messages/all.th.json |
Adds Thai selection text. |
src/i18n/messages/all.pt-BR.json |
Adds Brazilian Portuguese selection text. |
src/i18n/messages/all.ko.json |
Adds Korean selection text. |
src/i18n/messages/all.ja.json |
Adds Japanese selection text. |
src/i18n/messages/all.it.json |
Adds Italian selection text. |
src/i18n/messages/all.id.json |
Adds Indonesian selection text. |
src/i18n/messages/all.fr.json |
Adds French selection text. |
src/i18n/messages/all.es.json |
Adds Spanish selection text. |
src/i18n/messages/all.en.json |
Adds English selection text. |
src/i18n/messages/all.en-GB.json |
Adds British English selection text. |
src/i18n/messages/all.de.json |
Adds German selection text. |
src/i18n/messages/all.ar.json |
Adds Arabic selection text. |
src/i18n/messages-types.ts |
Registers the localized message type. |
src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap |
Records generated wrapper APIs. |
src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap |
Records component test selectors. |
src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap |
Records component and wrapper documentation. |
src/__tests__/required-props-for-components.ts |
Supplies shared-test defaults. |
pages/option-list/simple.page.tsx |
Demonstrates single, multi, and grouped selection. |
pages/option-list/scroll.scss |
Defines demo scroll containers. |
pages/option-list/scroll.page.tsx |
Demonstrates configurable scrolling and dropdown placement. |
pages/option-list/permutations.page.tsx |
Adds visual permutations. |
eslint.config.mjs |
Restricts direct runtime imports from Select and Multiselect. |
build-tools/utils/pluralize.js |
Registers OptionList’s generated plural name. |
Review effort: Balanced
Scrolling the OptionList root through className exercises the same path as a wrapper div: the highlight scrolls into its nearest scrolling ancestor either way. Styling a component's root is also not a supported pattern, so keep only the wrapper case.
OptionList's renderOption now uses its own item types with the icon slot and multi-selection group states. Replaces Select's renderer type. No runtime change.
Type-ahead filtered the list as a whole, which also returned the group headers of matching children (and the children of matching groups). With interactive groups those headers became type-ahead targets. Match each option on its own content in the shared use-type-ahead. Select and Multiselect get the same fix, since they share use-type-ahead. Tests cover the hook, OptionList, Select and Multiselect.
Type-ahead also ran for key events that were already handled elsewhere, for example Space selecting the highlighted option, so the space started a search and the next letter didn't move the highlight. The shared use-type-ahead now skips events with defaultPrevented. Select and Multiselect get the same fix, since they share use-type-ahead. Tests cover the hook, OptionList, Select and Multiselect.
The parent group item took the disabled state of the option instead of its group, so an enabled group with one disabled option reported different parent states to its options. The shared listbox item now passes the group's own disabled state in both selection modes. Also corrects the OptionList OptionGroupItem.disabled doc and regenerates the documenter snapshot, which also listed renderOption item fields that the source doesn't document. Select and Multiselect get the same fix, since they share the listbox item. Tests cover OptionList, Select and Multiselect.
With renderOption, a disabled option kept aria-describedby pointing to its disabled reason, but the hidden description element wasn't rendered, so the reference was dangling. The shared listbox item now keeps the hidden description mounted for custom content so screen readers still announce the reason. The tooltip stays limited to the default content. Select and Multiselect get the same fix, since they share the listbox item. Tests cover OptionList, Select and Multiselect.
…ptions In single selection, group headers can't be highlighted, and an empty group leaves a header row at the start or end of the list. ArrowUp and ArrowDown checked the raw first and last rows, so they didn't wrap and the highlight got stuck. Check against the first and last highlightable options instead. Also note that keyboard navigation must use the scrolling setters.
…over If an option was hovered while the list wasn't focused, keyboard focus entry (Tab or ref.focus()) kept that mouse highlight instead of highlighting the selected option, or the first option. The keyboard style and the scroll into view were skipped. Only keep the current highlight for focus caused by a click.
Options are matched by value, so options without one couldn't be told apart: with two options missing a value, selecting the second also marked the first as selected. Make value required on OptionListProps.Option, so this is caught at compile time. Group values stay optional, because selection never uses them. A group header doesn't appear in selectedOptions, and choosing it in multi selection toggles its options by their own values. The group value only locates the header in test utils. Map the options the list returns back to the consumer's options through typed lookups instead of falling back to internal options.
Custom renderOption content can show text the option's own fields don't contain, so the default highlight announcement no longer matches what users see. Select already exposes renderHighlightedAriaLive for this. Add the same prop to OptionList. It receives the consumer's original option and group objects and its return value replaces the default announcement for the highlighted option.
The scroll effect runs on mount with highlighted index -1. Single-selection group headers are not interactive and also render with data-mouse-target="-1", so the lookup found a header and called scrollIntoView on it. That could scroll the page or a surrounding container when an unfocused grouped list mounted. Return early from scrollOptionIntoView for negative indexes.
Split the single option-list.test.tsx into feature files that follow the Select and Multiselect __tests__ layout, with shared fixtures and helpers (Stateful, renderOptionList, option fixtures, key/click helpers) in common.tsx: - option-list.test.tsx: rendering, selection, ref, root attributes - disabled.test.tsx: disabled options and disabled reasons (Select: disabled) - keyboard.test.tsx: arrow/Home/End, Enter/Space, Escape, type-ahead, blur - highlight.test.tsx: focus entry, hover, scrolling, highlight reconciliation when options change; the only file that mocks scrollIntoView - group-state.test.tsx: groups, group checkbox state, group navigation and group type-ahead (Select/Multiselect: group-state) - render-option.test.tsx: renderOption item shapes (Select/Multiselect) - option-list-a11y.test.tsx: axe checks and renderHighlightedAriaLive (Select: select-a11y) - i18n.test.tsx: selectedAriaLabel via props and the i18n provider Merged tests (assertions kept, test removed): - "blur clears the highlight" and "has no aria-activedescendant without a highlighted option" ran the same render/focus/blur steps; merged into "blur clears the highlight and aria-activedescendant". - "multi: group header items include the checkbox state" was covered by the icon/group-state renderOption test except the group option check, which moved into that test.
- keyboard: removed "type-ahead highlights the matching option"; the same 't' -> 'c' highlight is asserted by "type-ahead after Space selects moves the highlight to the matching option". - keyboard: folded "Home and End move the highlight without the browser scrolling the page" into "focus highlights the first option, arrows move with wrap-around, Home and End jump", which now checks defaultPrevented on the End and Home keydown events. - i18n: removed "announces the highlighted option as selected with i18nStrings.selectedAriaLabel"; "i18nStrings.selectedAriaLabel takes precedence over the provider message" asserts the prop string is rendered. - highlight: folded "repeating a keyboard move that keeps the highlight scrolls it into view again" into "keyboard moves scroll the highlighted option into view" (sequence down, end, end, home, up; 5 scrolls). - disabled: folded "shows the disabled reason tooltip when a disabled option is highlighted with the keyboard" into "Escape closes the disabled reason tooltip and keeps the highlight and selection", which now uses a ReactNode disabledReason and asserts the tooltip content before pressing Escape.
- option-list.test.tsx > selection > "single: uses only the first selected option": covered by listbox single-selection.test.ts > "only the first selected option is kept". - option-list.test.tsx > rendering > "hides the top divider of the first option only": asserted a CSS class only. - highlight.test.tsx > "does not show a highlight before the user interacts with the list": covered by keyboard > "blur clears the highlight and aria-activedescendant" and highlight > "focus from a pointer adds no highlight and does not scroll". - group-state.test.tsx > "announces the group of the highlighted option": covered by listbox use-announcement.test.ts group tests and option-list-a11y > renderHighlightedAriaLive group test.
OptionList passes the consumer's icon node to the shared option renderer as __customIcon. The shared option renders a custom icon as is; only its built-in icon (iconName/iconUrl/iconSvg, used by Select and Multiselect) is wrapped in the .icon span that adds padding-inline-end: space-xs. Other custom icon callers (Token, chart filter) bring their own wrapper, but OptionList did not, so its icon sat directly against the label. Wrap the OptionList icon in its own .icon span with the same styles. Select, Multiselect, Token and the chart filter render unchanged. The gap was already missing before the OptionList rebuild (4925c40).
…havior Reverts four shared listbox changes so this PR no longer changes Select and Multiselect behavior. OptionList loses them too. They will come back for all three components in a follow-up fix PR. - A (b5f875a): use-type-ahead matches against the whole filtered list again (group header for a matching child, children of a matching group). - B (ad1132a): use-type-ahead no longer skips keys with defaultPrevented. - C (e0bed93): renderOption item.parent.disabled mirrors props.disabled again. - D (eb9030d): the hidden disabled-reason description is not rendered when renderOption returns custom content. The hideTopDivider, scrollOptionIntoView, options-list statusType default and onMouseLeave, and optional closeDropdown changes stay. Removed tests: - src/select/__tests__/native-search.test.tsx and src/multiselect/__tests__/native-search.test.tsx (new in this PR). - select and multiselect render-option: "parent reflects the disabled state of the group, not of the option", "keeps the disabled reason description for screen readers with custom content". - listbox use-type-ahead: "ignores keys that were already handled by another handler" and the "with groups" describe (5 tests). - OptionList group-state type-ahead describe (3 tests), keyboard "type-ahead after Space selects moves the highlight to the matching option", render-option "%s: parent reflects the group state, not the disabled state of the option". - OptionList disabled "describes the disabled option with its disabled reason when renderOption is used (%s)" keeps only the no-tooltip assertion and is renamed "does not show the disabled reason tooltip when renderOption is used". The multiselect integ test is back to its base expectations.
OptionList's Listbox now renders the shared PlainList instead of its own List component. PlainList gains one optional prop, `scrollStrategy?: 'menu' | 'ancestors'`, defaulting to 'menu'. 'menu' runs the existing scrollToIndex (scrolls only the menu, skipped for menus 15px tall or less). 'ancestors' calls scrollOptionIntoView, which scrolls through all scrollable ancestors and skips index -1, matching what List did. Listbox passes scrollStrategy="ancestors", filteringValue="" and isMultiSelect from the list props. src/internal/components/listbox/parts/list.tsx is removed. scrollOptionIntoView stays in scroll-to-index.ts. Select and Multiselect don't pass scrollStrategy, so they take the default path unchanged.
The rule kept OptionList and the listbox engine from importing Select and Multiselect at runtime. Since the shared item, type-ahead and PlainList moved to internal/components/listbox, it no longer protects much: it still lists the deleted parts/list.tsx and skips the shared item and plain list, which import the Select styles.
useListbox uses isHighlightable, getListboxOptionProps, isAnnouncedSelected, getSelectableOption and getListMouseHandlers from listbox/utils/option-props.ts instead of its own copies. No behavior change.
Replace useListbox's own arrow key handlers with the shared getListboxNavigation helper, wrapped with withScroll so keyboard moves still request a scroll. Drop OptionList's own Home/End preventDefault and pass useMenuKeyboard's handler directly, as Select does. No behavior change for Select or Multiselect. OptionList now uses Select's wrap and Home/End behavior. The wrap fix (wrap at the first and last highlightable options) and Home/End native scroll prevention come in the keyboard fix PR together with their tests. Removed here: - group-state.test.tsx: "arrows wrap at the first and last options when the list starts and ends with an empty group" - keyboard.test.tsx: the Home/End defaultPrevented assertions in "focus highlights the first option, arrows move with wrap-around, Home and End jump" (the rest of the test is kept)
The permutations page could exceed the page size limit as more cases are added. Shared fixtures and the page wrapper move to permutations-common.tsx.
Drop "inline" from the options description, the sentence about choosing group headers in multi selection, and the implementation details from the group value description.
| // stale index is never committed. It keeps the highlight type and doesn't request a scroll. | ||
| const [previousOptions, setPreviousOptions] = useState(options); | ||
| if (previousOptions !== options) { | ||
| setPreviousOptions(options); |
There was a problem hiding this comment.
Do we need previous options use React state? Can we use the usePrevious util instead?
| goHome, | ||
| goEnd, | ||
| }), | ||
| selectOption: () => selectOption(), |
There was a problem hiding this comment.
why do we create a new function here?
| embedded: true, | ||
| isMultiSelect: selectionType === 'multi' || undefined, | ||
| ...getListMouseHandlers({ options, selectOption, setHighlightedIndexWithMouse }), | ||
| onKeyDown: listKeyDownHandler, |

Description
Stacked on #5179.
What: Adds OptionList, an inline list with single or multi selection, plus its test-utils and dev pages. It
runs on the new internal list engine, with OptionList as its only caller. Select and Multiselect behave the same.
Related links, issue #, if available: Docs: CUJAl2wq1b6Z and 5evHnSwN7iHe
How has this been tested?
option-listdev pages.The following items are to be evaluated by the author(s) and the reviewer(s).
Correctness
CONTRIBUTING.md.CONTRIBUTING.md.Security
checkSafeUrlfunction.Testing
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.