Skip to content

Fix Tab selection in Combobox and TagPicker - #36795

Open
Bernardo Sunderhus (bsunderhus) wants to merge 8 commits into
microsoft:masterfrom
bsunderhus:fix/combobox-tab-selection
Open

Bernardo Sunderhus (bsunderhus) wants to merge 8 commits into
microsoft:masterfrom
bsunderhus:fix/combobox-tab-selection

Conversation

@bsunderhus

@bsunderhus Bernardo Sunderhus (bsunderhus) commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Combobox-style components keep focus on the trigger and represent the highlighted option with aria-activedescendant. In single-select mode, Tab or Shift+Tab should select that option before moving focus. In multiselect mode, Tab should only close the popup.

The existing React onKeyDown path works during ordinary browser navigation but can be skipped when Tabster manages focus. Tabster handles keydown during window capture and may stop propagation before React receives it. TagPicker also had no real single-select mode: controlled state could contain one item, but its internal trigger, listbox, option, and dismissal semantics remained multiselect.

Fixes #31365.

Approaches considered

  • React keydown only: does not run when Tabster intercepts the event.
  • Tabster ignoreKeydown: restores React keydown by disabling Tabster focus handling, breaking focus-navigation edge cases.
  • Selection on blur: cannot distinguish Tab from pointer or programmatic focus changes and produced false selections.
  • Capture listeners: React capture is still later than Tabster window capture; native listeners introduce ordering and active-option lifetime problems.
  • Selection inside setOpen: incorrectly couples every close operation with selection.
  • Casting the native Tabster event: violates the existing React event callback contract.
  • Adding onOptionSelect2: type-safe but introduces a versioned public callback, deprecation, dual plumbing, and future cleanup for a private integration issue.

Solution

The implementation uses two complementary paths:

  1. Ordinary Tab navigation continues through React onKeyDown.
  2. Tabster-managed navigation is observed through tabster:movefocus. Its original native KeyboardEvent is wrapped by a private React-compatible adapter and passed through the existing selection path. Tabster still performs focus movement.

The adapter preserves the callback behavior consumers can observe: nativeEvent, callback-scoped currentTarget, default prevention, propagation state, and persist(). It remains private and contract-tested. Combobox and TagPicker keep separate private implementations so no package-internal helper is exported.

This preserves the existing onOptionSelect API while covering both managed and ordinary focus topologies.

TagPicker

TagPicker now supports:

<TagPicker selectionMode="single">...</TagPicker>

selectionMode is optional and defaults to multiselect. It drives selection state, input/button triggers, listbox and option semantics, and tag dismissal. Single mode replaces the current option, keeps input text independent from the selected tag, and clears selection when the tag is dismissed.

selectionMode also remains optional on public state/context types to avoid breaking custom state or context objects; internal context construction defaults it to multiselect.

Validation

  • Combobox unit tests: 258 passing
  • TagPicker unit tests: 181 passing
  • Combobox/Dropdown Cypress: 16 passing
  • TagPicker Cypress: 41 passing
  • lint, type-check, aggregate type-check, API Extractor, and beachball validation
@github-actions

Copy link
Copy Markdown

Pull request demo site: URL

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-combobox
Combobox (including child components)
140.461 kB
45.528 kB
142.083 kB
46.161 kB
1.622 kB
633 B
react-combobox
Dropdown (including child components)
140.26 kB
45.31 kB
141.883 kB
45.906 kB
1.623 kB
596 B
react-components
react-components: all base hooks
218.171 kB
68.411 kB
218.877 kB
68.62 kB
706 B
209 B
react-components
react-components: entire library
1.285 MB
323.133 kB
1.288 MB
324.055 kB
2.899 kB
922 B
react-headless-components-preview
react-headless-components-preview: entire library
242.149 kB
68.258 kB
242.936 kB
68.497 kB
787 B
239 B
react-headless-components-preview
@fluentui/react-headless-components-preview/tag-picker
54.012 kB
17.756 kB
54.798 kB
17.971 kB
786 B
215 B
react-tag-picker
@fluentui/react-tag-picker - package
175.922 kB
55.024 kB
178.762 kB
55.856 kB
2.84 kB
832 B
react-timepicker-compat
TimePicker
143.18 kB
46.922 kB
144.802 kB
47.511 kB
1.622 kB
589 B
Unchanged fixtures
Package & Exports Size (minified/GZIP)
react-components
react-components: Button, FluentProvider & webLightTheme
67.731 kB
19.588 kB
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
228.087 kB
68.773 kB
react-components
react-components: FluentProvider & webLightTheme
40.961 kB
13.674 kB
react-headless-components-preview
@fluentui/react-headless-components-preview/teaching-popover
36.05 kB
11.996 kB
react-portal-compat
PortalCompatProvider
5.341 kB
2.146 kB
🤖 This report was generated against 45af037915bae19733ffb8cb2123d8e6e216e881
Comment thread packages/react-components/react-combobox/library/src/index.ts
Handle duplicate Tabster movefocus events, support shadow DOM targets, limit listeners to relevant states, and cover the real root-exit focus path.
Keep multiselect clear behavior unchanged and centralize tag dismissal through the existing selectOption context action for styled and headless TagPicker groups.
Add the missing Beachball metadata for the headless TagPicker dismissal fix.

This branch has not been deployed

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

2 participants