Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/focus-region-chorded-tab.md
Original file line number Diff line number Diff line change
@@ -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.
@cixzhang @vjeux
22 changes: 11 additions & 11 deletions docs/specs/components/Dialog/Dialog.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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

Expand Down
Loading
Loading