Skip to content

fix(react-headless-components-preview): make Combobox expandIcon toggle the listbox - #36574

Open
Dmytro Kirpa (dmytrokirpa) wants to merge 4 commits into
microsoft:masterfrom
dmytrokirpa:fix/headless-combobox-expand-icon
Open

fix(react-headless-components-preview): make Combobox expandIcon toggle the listbox#36574
Dmytro Kirpa (dmytrokirpa) wants to merge 4 commits into
microsoft:masterfrom
dmytrokirpa:fix/headless-combobox-expand-icon

Conversation

@dmytrokirpa

Copy link
Copy Markdown
Contributor

Description

The headless Combobox renders its expandIcon as a bare <span> — no button semantics, no accessible name, and no way to open the listbox. Clicking the chevron does nothing.

This happens because the headless useCombobox composes useListboxPopupState (the shared core it splits with Dropdown) rather than useComboboxBase_unstable from @fluentui/react-combobox. That shared core deliberately owns only positioning, active-descendant wiring, and the listbox/root slots — trigger and icon slots are layered on by each consumer. The expandIcon behavior that base Combobox provides was simply never re-applied. Only the type was shared (ComboboxState = BaseComboboxState & {...}), which made it look like the behavior came along too.

Dropdown is unaffected: its expandIcon renders inside the <button> trigger, so it is decorative by design — role="button" there would be invalid nested ARIA, and aria-expanded already lives on the button.

Changes

useCombobox, matching useComboboxBase_unstable:

  • role="button", aria-expanded, aria-disabled on the expandIcon slot
  • onMouseDown / onClick handlers that toggle the listbox and return focus to the input
  • Default accessible name fallback — "Open", "Open [aria-label]", or an aria-labelledby chain

On the toggle implementation

The listbox is a native popover="auto". Light-dismiss fires on pointerup, before click, and cannot be suppressed with preventDefault(). Two consequences:

  • Toggling on mousedown opens the listbox and the same pointer interaction immediately dismisses it.
  • Toggling on click using the current open state can open but never close — light-dismiss has already set open to false by the time click runs, so !open reopens it.

So mousedown records the pre-interaction state and click toggles from that. Both failure modes are covered by the new test; the click-only variant fails it with Expected <div#fluent-listbox> not to exist in the DOM, but it was continuously found.

Testing

New Cypress case toggles on expand icon click covers open → focus stays on the input → close.

  • Combobox Cypress: 29/29
  • Dropdown Cypress: 27/27
  • Unit tests: 28/28
  • Lint and type-check clean

Note for reviewers

This branch also adds role="button" to the clearIcon slot defaults, alongside the existing aria-hidden="true". Those two are in tension — an element with a button role that is hidden from assistive tech. Happy to drop it or split it out if it was not intended to ship here.

…le the listbox

The headless Combobox composes useListboxPopupState rather than the base
useComboboxBase_unstable hook, so the expandIcon slot was rendered as a bare
<span> with no button semantics and no way to open the listbox.

Add role=button, aria-expanded and aria-disabled, the default 'Open' accessible
name fallback, and toggle handlers. The listbox is a native popover, so its
light-dismiss closes it on pointerup before click runs -- toggling therefore uses
the open state captured on mousedown, otherwise clicking the icon to close would
immediately reopen it.
@dmytrokirpa
Dmytro Kirpa (dmytrokirpa) requested a review from a team as a code owner August 14, 2026 07:04
@github-actions

Copy link
Copy Markdown

Pull request demo site: URL

@Hotell

Copy link
Copy Markdown
Contributor

is there any way we can re-use from v9 to not duplicate the logic ?

@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-combobox
Combobox (including child components)
139.308 kB
45.062 kB
139.402 kB
45.114 kB
94 B
52 B
react-components
react-components: entire library
1.283 MB
322.103 kB
1.283 MB
322.13 kB
101 B
27 B
react-headless-components-preview
react-headless-components-preview: entire library
238.443 kB
67.246 kB
239.315 kB
67.488 kB
872 B
242 B
react-timepicker-compat
TimePicker
142.027 kB
46.435 kB
142.127 kB
46.48 kB
100 B
45 B
Unchanged fixtures
Package & Exports Size (minified/GZIP)
react-combobox
Dropdown (including child components)
139.073 kB
44.814 kB
react-components
react-components: Button, FluentProvider & webLightTheme
67.461 kB
19.461 kB
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
227.126 kB
68.407 kB
react-components
react-components: FluentProvider & webLightTheme
40.684 kB
13.549 kB
react-headless-components-preview
@fluentui/react-headless-components-preview/tag-picker
54.047 kB
17.736 kB
react-headless-components-preview
@fluentui/react-headless-components-preview/teaching-popover
36.016 kB
11.985 kB
react-portal-compat
PortalCompatProvider
5.341 kB
2.146 kB
react-tag-picker
@fluentui/react-tag-picker - package
174.769 kB
54.559 kB
🤖 This report was generated against 1964e7ce05f25771f4a476ec6b2fdbfb3d5e4bf7

…ExpandIconSlot

The expandIcon slot defaults (role="button", aria-expanded, aria-disabled) and
the accessible-name fallback chain were duplicated between the v9
useComboboxBase_unstable and the headless useCombobox. Extract them into
useComboboxExpandIconSlot and consume it from both.

Toggle handlers stay at the call sites: v9 toggles on mousedown alongside
useOnClickOutside, while the headless listbox is a native popover whose
light-dismiss fires on pointerup, so it needs the mousedown/click pair.
@dmytrokirpa
Dmytro Kirpa (dmytrokirpa) requested a review from a team as a code owner August 16, 2026 14:18
@dmytrokirpa

Dmytro Kirpa (dmytrokirpa) commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Yes — done in 6bc0dd3.

Extracted the shared part into useComboboxExpandIconSlot in @fluentui/react-combobox, now consumed by both useComboboxBase_unstable and the headless useCombobox. That covers the slot defaults (role="button", aria-expanded, aria-disabled) and the accessible-name fallback chain, which were the actual duplication. It follows the same pattern the headless package already uses to share useInputTriggerSlot, useButtonTriggerSlot, useComboboxBaseState and useListboxSlot.

@@ -0,0 +1,7 @@
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Menu Converged - submenuIndicator slotted content 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default.submenus open.chromium.png 413 Changed
vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default - RTL.submenus open.chromium.png 404 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.chromium.png 728 Changed
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 130 Changed
vr-tests-react-components/ProgressBar converged 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 91 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png 51 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png 26 Changed
vr-tests-react-components/TagPicker 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - Dark Mode.chromium.png 658 Changed
vr-tests-react-components/TagPicker.disabled - RTL.disabled input hover.chromium.png 635 Changed

There were 2 duplicate changes discarded. Check the build logs for more information.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants