Skip to content

Fix Tab selection in Combobox and TagPicker - #36795

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

Bernardo Sunderhus (bsunderhus) wants to merge 12 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.566 kB
45.546 kB
142.332 kB
46.176 kB
1.766 kB
630 B
react-combobox
Dropdown (including child components)
140.365 kB
45.323 kB
142.131 kB
45.924 kB
1.766 kB
601 B
react-components
react-components: all base hooks
218.304 kB
68.431 kB
220.582 kB
69.179 kB
2.278 kB
748 B
react-components
react-components: entire library
1.285 MB
323.173 kB
1.288 MB
324.071 kB
2.46 kB
898 B
react-headless-components-preview
react-headless-components-preview: entire library
242.401 kB
68.299 kB
244.764 kB
69.051 kB
2.363 kB
752 B
react-headless-components-preview
@fluentui/react-headless-components-preview/tag-picker
54.117 kB
17.778 kB
56.474 kB
18.492 kB
2.357 kB
714 B
react-tag-picker
@fluentui/react-tag-picker - package
176.027 kB
55.055 kB
178.485 kB
55.844 kB
2.458 kB
789 B
react-timepicker-compat
TimePicker
143.285 kB
46.94 kB
145.051 kB
47.521 kB
1.766 kB
581 B
Unchanged fixtures
Package & Exports Size (minified/GZIP)
react-components
react-components: Button, FluentProvider & webLightTheme
67.836 kB
19.629 kB
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
228.192 kB
68.813 kB
react-components
react-components: FluentProvider & webLightTheme
41.066 kB
13.698 kB
react-headless-components-preview
@fluentui/react-headless-components-preview/teaching-popover
36.155 kB
12.018 kB
react-portal-compat
PortalCompatProvider
5.341 kB
2.146 kB
🤖 This report was generated against 4ecbb6f138784edc78b5389891fa0bad8e8afcce

Comment thread packages/react-components/react-combobox/library/src/index.ts Outdated
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.
Move Tabster movefocus handling into a private hook module consumed by useTriggerSlot, remove the package-level export, and eliminate wrapper-specific ref wiring.
Drop the comment-only headless TagPicker change and its now-unneeded Beachball change file.
@dmytrokirpa
Dmytro Kirpa (dmytrokirpa) requested a balanced review from Copilot October 2, 2026 14:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

Review effort: Lite
Findings: 1 Medium severity · 1 Low severity

Open (2)
What changed in this PR

Fixes Tab/Shift+Tab selection behavior for combobox-style components (Dropdown/Combobox/TagPicker) when Tabster intercepts keydown events, and introduces a first-class single-select mode for TagPicker.

Changes:

  • Add Tabster tabster:movefocus integration to select the active option when tabbing away in single-select Combobox/Dropdown/TagPicker.
  • Introduce selectionMode="single" | "multiselect" for TagPicker, updating context/state semantics and stories.
  • Add supporting utilities (handledTabKeyEvents, createReactKeyboardEvent) and expand Cypress + unit test coverage.
File Description
packages/​react-components/​react-tag-picker/​stories/​src/​TagPicker/​TagPickerSingleSelect.stories.tsx Updates story to use new selectionMode="single" API and revised docs text.
packages/​react-components/​react-tag-picker/​library/​src/​index.ts Exports new TagPickerSelectionMode type.
packages/​react-components/​react-tag-picker/​library/​src/​contexts/​TagPickerContext.ts Adds selectionMode to TagPicker context and default value.
packages/​react-components/​react-tag-picker/​library/​src/​components/​TagPickerList/​useTagPickerList.ts Drives listbox multiselect semantics from TagPicker selectionMode.
packages/​react-components/​react-tag-picker/​library/​src/​components/​TagPickerInput/​useTagPickerInput.tsx Drives trigger behavior from selectionMode rather than forcing multiselect.
packages/​react-components/​react-tag-picker/​library/​src/​components/​TagPickerInput/​useTagPickerInput.test.tsx Updates test context defaults to include selectionMode.
packages/​react-components/​react-tag-picker/​library/​src/​components/​TagPickerGroup/​useTagPickerGroup.ts Updates comment around dismiss selection payload.
packages/​react-components/​react-tag-picker/​library/​src/​components/​TagPickerControl/​useTagPickerControl.test.tsx Updates test context defaults to include selectionMode.
packages/​react-components/​react-tag-picker/​library/​src/​components/​TagPickerButton/​useTagPickerButton.tsx Drives button-trigger behavior from selectionMode.
packages/​react-components/​react-tag-picker/​library/​src/​components/​TagPickerButton/​useTagPickerButton.test.tsx Updates test context defaults to include selectionMode.
packages/​react-components/​react-tag-picker/​library/​src/​components/​TagPicker/​useTagPickerContextValues.ts Plumbs selectionMode through context values and derives multiselect.
packages/​react-components/​react-tag-picker/​library/​src/​components/​TagPicker/​useTagPicker.ts Implements TagPicker single-select semantics (clear behavior, replacement, dismissal behavior).
packages/​react-components/​react-tag-picker/​library/​src/​components/​TagPicker/​useTagPicker.test.tsx Adds unit tests for selectionMode defaults/behavior and single-select dismissal.
packages/​react-components/​react-tag-picker/​library/​src/​components/​TagPicker/​index.ts Re-exports TagPickerSelectionMode.
packages/​react-components/​react-tag-picker/​library/​src/​components/​TagPicker/​TagPicker.types.ts Adds TagPickerSelectionMode type + selectionMode prop/state.
packages/​react-components/​react-tag-picker/​library/​src/​components/​TagPicker/​TagPicker.cy.tsx Adds Cypress coverage for Tabster-managed tabbing and TagPicker single-select behavior.
packages/​react-components/​react-tag-picker/​library/​src/​TagPicker.ts Re-exports TagPickerSelectionMode from package entry.
packages/​react-components/​react-tag-picker/​library/​etc/​react-tag-picker.api.md Updates API surface docs to include selectionMode and TagPickerSelectionMode.
packages/​react-components/​react-components/​src/​index.ts Re-exports TagPickerSelectionMode from umbrella package.
packages/​react-components/​react-components/​etc/​react-components.api.md Updates umbrella API docs to include TagPickerSelectionMode.
packages/​react-components/​react-combobox/​library/​src/​utils/​useTriggerSlot.ts Hooks Tabster movefocus and de-dupes Tab selection between native + Tabster paths.
packages/​react-components/​react-combobox/​library/​src/​utils/​useComboboxBaseState.ts Adds valueFromSelectedOptions to decouple input value from selection for TagPicker.
packages/​react-components/​react-combobox/​library/​src/​utils/​handledTabKeyEvents.ts Adds WeakSet-based tracking to ensure Tab selection runs once per native event.
packages/​react-components/​react-combobox/​library/​src/​utils/​createReactKeyboardEvent.ts Adds internal adapter to wrap native KeyboardEvent into React-compatible keyboard event shape.
packages/​react-components/​react-combobox/​library/​src/​utils/​createReactKeyboardEvent.test.ts Adds unit test validating adapter behavior and cleanup.
packages/​react-components/​react-combobox/​library/​src/​index.ts Minor formatting change near exports.
packages/​react-components/​react-combobox/​library/​src/​hooks/​useSelectOptionOnMoveFocus.ts Adds Tabster tabster:movefocus capture listener to select active option on focus move.
packages/​react-components/​react-combobox/​library/​src/​components/​Dropdown/​Dropdown.cy.tsx Adds Cypress coverage for Tab selection under Tabster and edge cases (preventDefault, multiselect, pointer).
packages/​react-components/​react-combobox/​library/​src/​components/​Combobox/​Combobox.cy.tsx Adds Cypress coverage for Combobox Tab selection under Tabster and multiselect/pointer cases.
packages/​react-components/​react-combobox/​library/​etc/​react-combobox.api.md Documents new valueFromSelectedOptions option for useComboboxBaseState.
change/​@fluentui-react-tag-picker-6d5e6b57-50b3-4fca-9af8-804906359dc5.json Beachball change entry for TagPicker single-select feature.
change/​@fluentui-react-components-8f68891e-b590-4c98-a2fd-9615d0b95767.json Beachball change entry for exposing TagPicker single-select via umbrella package.
change/​@fluentui-react-combobox-629eb6f3-4ef5-4a3b-9c2e-8a3a86012e25.json Beachball change entry for Tab selection fix in combobox package.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

multiselect:false enabled useInputTriggerSlot's Combobox-only clear-on-edit path, so typing an unmatched query or erasing it cleared the selected tag. The input slot now gets a no-op clearSelection; tag dismissal and the clear button are unaffected.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

The event adapter omits public keyboard fields, and single-select TagPicker interactions emit duplicate close callbacks.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Adapter omits React keyboard event keyCode, charCode, and which

packages/​react-components/​react-combobox/​library/​src/​utils/​createReactKeyboardEvent.ts:42

The adapter omits charCode, keyCode, and which, which are part of the existing React.KeyboardEvent contract. An onOptionSelect handler checking keyCode === 9 or which === 9 therefore receives undefined only on the Tabster path. Populate React-equivalent keydown values and add assertions for these fields to the adapter contract test.

disableAutoFocus,
editable: true,
multiselect: true,
multiselect,
setOpen,
setValue,
multiselect: true,
multiselect,

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: [accessibility] [tagpicker] - Missing (Shift+)TAB functionality to select option from the list

3 participants