From 3c6746039fbbb37f124bdc15b602e74aa0e8a08b Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 20:01:02 -0800 Subject: [PATCH 1/3] test(focus region): a chorded Tab at a modal's ends stays with the page --- packages/core/src/hooks/useFocusTrap.test.tsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/core/src/hooks/useFocusTrap.test.tsx b/packages/core/src/hooks/useFocusTrap.test.tsx index 27f12516a208c..1770977c58725 100644 --- a/packages/core/src/hooks/useFocusTrap.test.tsx +++ b/packages/core/src/hooks/useFocusTrap.test.tsx @@ -547,6 +547,51 @@ describe('useFocusTrap tabbable model (infra-8)', () => { }); }); +describe('useFocusTrap chorded Tab', () => { + function renderEnds() { + render( + + + + , + ); + return { + first: screen.getByTestId('first'), + last: screen.getByTestId('last'), + }; + } + + it('leaves Ctrl+Tab and Meta+Tab at either end to the browser', () => { + const {first, last} = renderEnds(); + + last.focus(); + expect(fireEvent.keyDown(last, {key: 'Tab', ctrlKey: true})).toBe(true); + expect(fireEvent.keyDown(last, {key: 'Tab', metaKey: true})).toBe(true); + expect(last).toHaveFocus(); + + first.focus(); + expect( + fireEvent.keyDown(first, {key: 'Tab', ctrlKey: true, shiftKey: true}), + ).toBe(true); + expect( + fireEvent.keyDown(first, {key: 'Tab', metaKey: true, shiftKey: true}), + ).toBe(true); + expect(first).toHaveFocus(); + }); + + it('still wraps Alt+Tab, which moves focus in Safari', () => { + const {first, last} = renderEnds(); + last.focus(); + + expect(fireEvent.keyDown(last, {key: 'Tab', altKey: true})).toBe(false); + expect(first).toHaveFocus(); + }); +}); + describe('FOCUSABLE_SELECTOR href matching', () => { // Only real links (/) are focusable via href. A bare // [href] term also matched non-focusable elements carrying href (e.g. a From a4f1414aa3ec100a87308195cf4444a4789bad81 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 20:01:02 -0800 Subject: [PATCH 2/3] fix(focus region): leave Ctrl+Tab and Meta+Tab to the page --- .changeset/focus-region-chorded-tab.md | 6 ++++++ packages/core/src/hooks/useFocusTrap.ts | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 .changeset/focus-region-chorded-tab.md diff --git a/.changeset/focus-region-chorded-tab.md b/.changeset/focus-region-chorded-tab.md new file mode 100644 index 0000000000000..d19ccfe98eccc --- /dev/null +++ b/.changeset/focus-region-chorded-tab.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Dialog, CommandPalette, BottomSheet, Lightbox, Drawer, MobileNav, Popover: a Tab pressed with Ctrl or Meta on the first or last control of a modal no longer moves focus to the other end, and the modal no longer cancels the press. A page or a host app that handles Ctrl+Tab or Meta+Tab itself, for example to switch its own tabs, now gets the press on every control, as it already did between the first and the last. Alt+Tab still wraps, because Safari moves focus to each item on Option+Tab. +@vjeux diff --git a/packages/core/src/hooks/useFocusTrap.ts b/packages/core/src/hooks/useFocusTrap.ts index 0627ec6d2f783..7566a0dda8f43 100644 --- a/packages/core/src/hooks/useFocusTrap.ts +++ b/packages/core/src/hooks/useFocusTrap.ts @@ -399,6 +399,12 @@ export function useFocusRegionContainment( } if (event.key === 'Tab') { + if (event.ctrlKey || event.metaKey) { + // Ctrl+Tab and Meta+Tab switch tabs or apps. They do not move + // focus, so the region leaves them to the browser and the page. + // Alt+Tab stays: Safari moves focus to each item on Option+Tab. + return; + } if (event.defaultPrevented) { // A control that handles Tab itself (an editor that indents) keeps // focus where it is. From 0bd5bf475eae8acf618265de746a857c522be47a Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Wed, 11 Nov 2026 06:20:29 -0800 Subject: [PATCH 3/3] test(focus region): Alt+Shift+Tab and plain Tab still wrap; records state the chorded Tab rule Dialog FR7 and the Popover Tab row state that Tab and Shift+Tab wrap with or without Alt (Option), and that Tab chorded with Ctrl or Meta passes through to the browser. The tests add Alt+Shift+Tab and plain Tab and Shift+Tab at either end. The changeset credits both authors. --- .changeset/focus-region-chorded-tab.md | 2 +- docs/specs/components/Dialog/Dialog.spec.md | 22 +++++++-------- docs/specs/components/Popover/Popover.spec.md | 28 +++++++++---------- packages/core/src/hooks/useFocusTrap.test.tsx | 16 ++++++++++- 4 files changed, 41 insertions(+), 27 deletions(-) diff --git a/.changeset/focus-region-chorded-tab.md b/.changeset/focus-region-chorded-tab.md index d19ccfe98eccc..8e14756439bb6 100644 --- a/.changeset/focus-region-chorded-tab.md +++ b/.changeset/focus-region-chorded-tab.md @@ -3,4 +3,4 @@ --- [fix] Dialog, CommandPalette, BottomSheet, Lightbox, Drawer, MobileNav, Popover: a Tab pressed with Ctrl or Meta on the first or last control of a modal no longer moves focus to the other end, and the modal no longer cancels the press. A page or a host app that handles Ctrl+Tab or Meta+Tab itself, for example to switch its own tabs, now gets the press on every control, as it already did between the first and the last. Alt+Tab still wraps, because Safari moves focus to each item on Option+Tab. -@vjeux +@cixzhang @vjeux diff --git a/docs/specs/components/Dialog/Dialog.spec.md b/docs/specs/components/Dialog/Dialog.spec.md index 3d53b08bdf9ca..ee193bbfd07e0 100644 --- a/docs/specs/components/Dialog/Dialog.spec.md +++ b/docs/specs/components/Dialog/Dialog.spec.md @@ -91,7 +91,7 @@ current family and architecture owners. | FR4 | Closing a native Dialog MUST return focus to the still-connected external element that invoked the modal when that element can receive focus. Descendant mount focus MUST NOT replace that return owner. | shipped trigger-return intent and regression history | settled | | FR5 | Inline rendering MUST NOT open a modal, apply modal initial focus, trap focus, or return focus as if a modal had closed. | documented `isInline` preview behavior | settled | | FR6 | Initial-focus selection MUST follow the rendered eligible descendants, not a private component type or source-only assumption. | composable descendant contract | settled | -| FR7 | A native modal Dialog MUST be a focus region through Core's shared focus-region module: Tab from its last tabbable control moves focus to its first, and Shift+Tab from its first moves focus to its last, so focus never leaves the modal for the browser's own interface. When a Dialog opens from a Dialog, the innermost one holding focus answers Tab. | owner decision DEC-2 | settled | +| FR7 | A native modal Dialog MUST be a focus region through Core's shared focus-region module: Tab from its last tabbable control moves focus to its first, and Shift+Tab from its first moves focus to its last, so focus never leaves the modal for the browser's own interface. Tab and Shift+Tab wrap with or without Alt (Option), since Safari moves focus on Option+Tab; Tab chorded with Ctrl or Meta passes through to the browser. When a Dialog opens from a Dialog, the innermost one holding focus answers Tab. | owner decision DEC-2 | settled | | FR8 | A modal Dialog open on the server renders closed until hydration (no open styles, no `aria-modal`, the markup of a closed Dialog) and opens in the render after hydration, the first that can make it modal; it never claims a modality it does not have (`spec:AST-020` FR1, WCAG 2.2 SC 4.1.2). An inline Dialog is not modal and renders as it is. The server and hydrating renders produce the same markup (`spec:AST-013` FR9), and the open after hydration is settled, with no entry motion; later opens and closes animate as usual (`spec:AST-013` FR13). | `spec:AST-020` FR1 | settled | | FR9 | On every pointer, the modal backdrop is the theme's `--color-overlay` dim with no blur (no `backdrop-filter`); under forced colors the dialog keeps a visible edge (a system-colour border) so it stays apart from the page. A full-screen Dialog (`variant="fullscreen"`) covers the page and draws none. | Owner decision, 2026-10-25: the overlay stays theme-controlled and unblurred on every pointer. | settled | @@ -147,16 +147,16 @@ theming owners. FR9 sets the backdrop's appearance. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | -| FR1–FR3, AR1 | shared modal-dialog binding in `Dialog.a11y.test.tsx` and `Dialog.a11y.chromium.spec.ts`; local DialogHeader focus tests | header default, explicit descendant, no request | the shared contract fails when focus is applied before native modality, misses the declared target, or leaves the visible modal on native fallback | audit:Dialog/accessibility | -| FR4, AR2 | shared modal-dialog browser binding plus the local unavailable-invoker close test | external trigger, descendant mount focus, removed trigger | the shared contract records an exact current failure when descendant focus replaces the return owner; the local test keeps close safe after invoker removal | audit:Dialog/behavior | -| AR2 | `Dialog.test.tsx` "unmounting while open" cases | dialog rendered only while open and unmounted by its own action; removed with its view; focus moved elsewhere first; StrictMode | focus left on the page body after an open dialog unmounts, or focus pulled back from where the user moved it, fails | audit:Dialog/behavior | -| FR5 | inline Dialog tests | DialogHeader and focusable children | inline preview steals focus or invokes native modal methods | audit:Dialog/behavior | -| FR6, AR3 | shared modal-dialog binding plus the local composed-action focus fixture | DialogHeader heading, TextInput, action button | focus depends on a private React child type or strips semantics from the target | audit:Dialog/public-api | -| FR7 | `Dialog.test.tsx`, `AlertDialog.test.tsx` and `CommandPalette.test.tsx` focus-region suites | last and first tabbable control, Dialog in Dialog, inline | Tab from the last control leaves the modal, or an outer Dialog answers an inner one's Tab | audit:Dialog/accessibility | -| FR8 | `Dialog.ssr.test.tsx` | Open on the server; hydration; a closed Dialog's markup | An open server render with `aria-modal`, open styles, or markup unlike a closed one, a hydration error, no `showModal()` after hydration, an entry motion on the open right after hydration, or none on a later open fails. | audit:Dialog/accessibility | -| FR9 | `modalBackdrop.test.tsx`; `ModalBackdrop.a11y.browser.spec.ts` in Chromium and WebKit | A finger at phone width; a mouse at desktop width; forced colors under either pointer; a full-screen Dialog in forced colors | A `backdrop-filter` on the backdrop at either width, no visible edge in forced colors, or an edge on a full-screen Dialog, fails. | audit:Dialog/accessibility | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | +| FR1–FR3, AR1 | shared modal-dialog binding in `Dialog.a11y.test.tsx` and `Dialog.a11y.chromium.spec.ts`; local DialogHeader focus tests | header default, explicit descendant, no request | the shared contract fails when focus is applied before native modality, misses the declared target, or leaves the visible modal on native fallback | audit:Dialog/accessibility | +| FR4, AR2 | shared modal-dialog browser binding plus the local unavailable-invoker close test | external trigger, descendant mount focus, removed trigger | the shared contract records an exact current failure when descendant focus replaces the return owner; the local test keeps close safe after invoker removal | audit:Dialog/behavior | +| AR2 | `Dialog.test.tsx` "unmounting while open" cases | dialog rendered only while open and unmounted by its own action; removed with its view; focus moved elsewhere first; StrictMode | focus left on the page body after an open dialog unmounts, or focus pulled back from where the user moved it, fails | audit:Dialog/behavior | +| FR5 | inline Dialog tests | DialogHeader and focusable children | inline preview steals focus or invokes native modal methods | audit:Dialog/behavior | +| FR6, AR3 | shared modal-dialog binding plus the local composed-action focus fixture | DialogHeader heading, TextInput, action button | focus depends on a private React child type or strips semantics from the target | audit:Dialog/public-api | +| FR7 | `Dialog.test.tsx`, `AlertDialog.test.tsx` and `CommandPalette.test.tsx` focus-region suites, and `useFocusTrap.test.tsx` "useFocusTrap chorded Tab" | last and first tabbable control, Dialog in Dialog, inline | Tab from the last control leaves the modal, or an outer Dialog answers an inner one's Tab | audit:Dialog/accessibility | +| FR8 | `Dialog.ssr.test.tsx` | Open on the server; hydration; a closed Dialog's markup | An open server render with `aria-modal`, open styles, or markup unlike a closed one, a hydration error, no `showModal()` after hydration, an entry motion on the open right after hydration, or none on a later open fails. | audit:Dialog/accessibility | +| FR9 | `modalBackdrop.test.tsx`; `ModalBackdrop.a11y.browser.spec.ts` in Chromium and WebKit | A finger at phone width; a mouse at desktop width; forced colors under either pointer; a full-screen Dialog in forced colors | A `backdrop-filter` on the backdrop at either width, no visible edge in forced colors, or an edge on a full-screen Dialog, fails. | audit:Dialog/accessibility | ## Decision log diff --git a/docs/specs/components/Popover/Popover.spec.md b/docs/specs/components/Popover/Popover.spec.md index f8d234ec28d95..150b935364c5d 100644 --- a/docs/specs/components/Popover/Popover.spec.md +++ b/docs/specs/components/Popover/Popover.spec.md @@ -220,20 +220,20 @@ table owns their semantic effect. ### Representative focus and dismissal states -| State | Required invariant | Allowed variation | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| Pointer or touch activation of dialog Popover | Initial focus enters the first genuine content control, with dialog-surface fallback when none exists. Shared modality-aware indication suppresses keyboard-only rings after pointer input. | The trigger and content may vary. | -| Keyboard or AT-style activation of dialog Popover | Initial focus enters the first genuine content control, with dialog-surface fallback when none exists. The eligible destination paints the shared keyboard focus indication. | Activation with absent or zero event detail follows this path. Named AT announcement remains unverified. | -| Read-only dialog | With no genuine caller control, the dialog surface receives initial focus. The injected fallback close control remains hidden and reachable by Tab but is never selected initially. | Caller content may contain no tabbable control. | -| Neutral-role popup | The neutral wrapper is not selected as a dialog focus target; automatic entry prefers genuine content focus, or callers may disable autofocus for trigger-retained patterns. | Child menu, listbox, or other semantics own their focus model. | -| Automatic focus disabled | Opening does not move focus. | May be persistent configuration or a one-opening show preference. | -| Controlled open | External open state invokes the no-activation opening path: first genuine content control, then dialog-container fallback. | The caller remains the visibility source of truth. | -| Tab or Shift+Tab inside the open popup | Focus moves to the next or previous tabbable descendant and wraps at either end, so it stays inside the popup, modal or not. | A role-neutral menu closes on Tab and a listbox keeps focus in its input, as their own records state. | -| Tab or Shift+Tab from outside the open popup | The popup does not capture it: focus moves to the next or previous control in the page's own order, as if the popup were not there. | The browser's order, which places an anchored popup after its trigger, may still lead into it. | -| Tab from a focused container | Focus enters the first tabbable descendant. | If none exists, focus remains inside. | -| Shift+Tab from a focused container | Focus enters the last tabbable descendant. | If none exists, focus remains inside. | -| Escape | The topmost eligible Popover closes according to shared dismissal policy. | Explicit-dismiss surfaces may disable both light and Escape dismissal. | -| Close after focus entered the popup | Focus returns to the trigger when removal would otherwise strand focus. | A caller may additionally own focus return through its hide notification. | +| State | Required invariant | Allowed variation | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Pointer or touch activation of dialog Popover | Initial focus enters the first genuine content control, with dialog-surface fallback when none exists. Shared modality-aware indication suppresses keyboard-only rings after pointer input. | The trigger and content may vary. | +| Keyboard or AT-style activation of dialog Popover | Initial focus enters the first genuine content control, with dialog-surface fallback when none exists. The eligible destination paints the shared keyboard focus indication. | Activation with absent or zero event detail follows this path. Named AT announcement remains unverified. | +| Read-only dialog | With no genuine caller control, the dialog surface receives initial focus. The injected fallback close control remains hidden and reachable by Tab but is never selected initially. | Caller content may contain no tabbable control. | +| Neutral-role popup | The neutral wrapper is not selected as a dialog focus target; automatic entry prefers genuine content focus, or callers may disable autofocus for trigger-retained patterns. | Child menu, listbox, or other semantics own their focus model. | +| Automatic focus disabled | Opening does not move focus. | May be persistent configuration or a one-opening show preference. | +| Controlled open | External open state invokes the no-activation opening path: first genuine content control, then dialog-container fallback. | The caller remains the visibility source of truth. | +| Tab or Shift+Tab inside the open popup | Focus moves to the next or previous tabbable descendant and wraps at either end, so it stays inside the popup, modal or not. It wraps with or without Alt (Option); Tab chorded with Ctrl or Meta passes through to the browser. | A role-neutral menu closes on Tab and a listbox keeps focus in its input, as their own records state. | +| Tab or Shift+Tab from outside the open popup | The popup does not capture it: focus moves to the next or previous control in the page's own order, as if the popup were not there. | The browser's order, which places an anchored popup after its trigger, may still lead into it. | +| Tab from a focused container | Focus enters the first tabbable descendant. | If none exists, focus remains inside. | +| Shift+Tab from a focused container | Focus enters the last tabbable descendant. | If none exists, focus remains inside. | +| Escape | The topmost eligible Popover closes according to shared dismissal policy. | Explicit-dismiss surfaces may disable both light and Escape dismissal. | +| Close after focus entered the popup | Focus returns to the trigger when removal would otherwise strand focus. | A caller may additionally own focus return through its hide notification. | ### Transformation and precedence order diff --git a/packages/core/src/hooks/useFocusTrap.test.tsx b/packages/core/src/hooks/useFocusTrap.test.tsx index 1770977c58725..27afb5d2fad18 100644 --- a/packages/core/src/hooks/useFocusTrap.test.tsx +++ b/packages/core/src/hooks/useFocusTrap.test.tsx @@ -583,12 +583,26 @@ describe('useFocusTrap chorded Tab', () => { expect(first).toHaveFocus(); }); - it('still wraps Alt+Tab, which moves focus in Safari', () => { + it('still wraps Alt+Tab and Alt+Shift+Tab, which move focus in Safari', () => { const {first, last} = renderEnds(); last.focus(); expect(fireEvent.keyDown(last, {key: 'Tab', altKey: true})).toBe(false); expect(first).toHaveFocus(); + expect( + fireEvent.keyDown(first, {key: 'Tab', altKey: true, shiftKey: true}), + ).toBe(false); + expect(last).toHaveFocus(); + }); + + it('still wraps a plain Tab and Shift+Tab at either end', () => { + const {first, last} = renderEnds(); + last.focus(); + + expect(fireEvent.keyDown(last, {key: 'Tab'})).toBe(false); + expect(first).toHaveFocus(); + expect(fireEvent.keyDown(first, {key: 'Tab', shiftKey: true})).toBe(false); + expect(last).toHaveFocus(); }); });