@astryxdesign/core 0.7.3 → 0.7.4-canary.1a552f2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +18 -6
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +268 -58
  4. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +41 -12
  5. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetSwitcher.js +294 -165
  7. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +51 -1
  8. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -1
  9. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -1
  10. package/dist/Carousel/Carousel.d.ts.map +1 -1
  11. package/dist/Carousel/Carousel.js +7 -11
  12. package/dist/Chat/ChatMessageList.d.ts.map +1 -1
  13. package/dist/Chat/ChatMessageList.js +8 -1
  14. package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
  15. package/dist/Chat/useTriggerMenu.js +46 -7
  16. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  17. package/dist/CheckboxInput/CheckboxInput.js +6 -0
  18. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  19. package/dist/CommandPalette/CommandPalette.js +6 -11
  20. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  21. package/dist/CommandPalette/CommandPaletteInput.js +7 -1
  22. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  23. package/dist/ComplexSelector/ComplexSelector.js +6 -1
  24. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  25. package/dist/ContextMenu/ContextMenu.js +12 -0
  26. package/dist/DateInput/DateInput.d.ts.map +1 -1
  27. package/dist/DateInput/DateInput.js +6 -1
  28. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  29. package/dist/DateInput/NativeDateField.js +6 -1
  30. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  31. package/dist/DateInput/TouchDateField.js +9 -1
  32. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  33. package/dist/DateRangeInput/DateRangeInput.js +6 -1
  34. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  35. package/dist/DateTimeInput/DateTimeInput.js +6 -1
  36. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  37. package/dist/DateTimeInput/TouchDateTimeField.js +9 -1
  38. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  39. package/dist/DropdownMenu/DropdownMenuSubMenu.js +7 -1
  40. package/dist/FileInput/FileInput.d.ts.map +1 -1
  41. package/dist/FileInput/FileInput.js +16 -2
  42. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  43. package/dist/MultiSelector/MultiSelector.js +6 -1
  44. package/dist/Selector/Selector.d.ts.map +1 -1
  45. package/dist/Selector/Selector.js +6 -1
  46. package/dist/TextArea/TextArea.d.ts.map +1 -1
  47. package/dist/TextArea/TextArea.js +6 -1
  48. package/dist/TextInput/TextInput.d.ts.map +1 -1
  49. package/dist/TextInput/TextInput.js +6 -1
  50. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  51. package/dist/Thumbnail/Thumbnail.js +9 -2
  52. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  53. package/dist/TimeInput/TimeInput.js +6 -1
  54. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  55. package/dist/TimeInput/TouchTimeField.js +9 -1
  56. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  57. package/dist/Tokenizer/Tokenizer.js +9 -1
  58. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  59. package/dist/Typeahead/BaseTypeahead.js +7 -1
  60. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  61. package/dist/Typeahead/Typeahead.js +9 -1
  62. package/dist/astryx.css +2 -0
  63. package/dist/hooks/useBusyAnnouncement.d.ts +22 -0
  64. package/dist/hooks/useBusyAnnouncement.d.ts.map +1 -0
  65. package/dist/hooks/useBusyAnnouncement.js +87 -0
  66. package/dist/hooks/useMenuPress.d.ts +2 -2
  67. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  68. package/dist/hooks/useMenuPress.js +5 -1
  69. package/dist/hooks/useTypeahead.d.ts.map +1 -1
  70. package/dist/hooks/useTypeahead.js +7 -2
  71. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  72. package/dist/i18n/generated-locales/en.generated.js +1 -0
  73. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  74. package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
  75. package/locales/en.json +6 -2
  76. package/locales/pseudo.json +3 -0
  77. package/package.json +4 -4
  78. package/src/BottomSheet/BottomSheet.doc.mjs +2 -2
  79. package/src/BottomSheet/BottomSheet.spec.md +3 -5
  80. package/src/BottomSheet/BottomSheet.tsx +260 -47
  81. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +29 -4
  82. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +11 -10
  83. package/src/BottomSheet/BottomSheetSwitcher.spec.md +35 -41
  84. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +1051 -49
  85. package/src/BottomSheet/BottomSheetSwitcher.tsx +396 -215
  86. package/src/BottomSheet/BottomSheetSwitcherContext.ts +57 -2
  87. package/src/BottomSheet/__tests__/BottomSheetNativeModal.a11y.browser.spec.ts +461 -0
  88. package/src/Carousel/Carousel.test.tsx +8 -12
  89. package/src/Carousel/Carousel.tsx +6 -13
  90. package/src/Chat/ChatComposerInput.test.tsx +167 -1
  91. package/src/Chat/ChatMessageList.test.tsx +6 -2
  92. package/src/Chat/ChatMessageList.tsx +7 -1
  93. package/src/Chat/__tests__/ChatMessageList.a11y.chromium.spec.ts +20 -12
  94. package/src/Chat/useTriggerMenu.tsx +55 -6
  95. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  96. package/src/CommandPalette/CommandPalette.test.tsx +4 -2
  97. package/src/CommandPalette/CommandPalette.tsx +6 -11
  98. package/src/CommandPalette/CommandPaletteInput.test.tsx +3 -1
  99. package/src/CommandPalette/CommandPaletteInput.tsx +10 -1
  100. package/src/ComplexSelector/ComplexSelector.tsx +5 -1
  101. package/src/ContextMenu/ContextMenu.test.tsx +34 -0
  102. package/src/ContextMenu/ContextMenu.tsx +13 -1
  103. package/src/DateInput/DateInput.tsx +5 -1
  104. package/src/DateInput/DateInputTouch.test.tsx +45 -0
  105. package/src/DateInput/NativeDateField.test.tsx +10 -2
  106. package/src/DateInput/NativeDateField.tsx +5 -1
  107. package/src/DateInput/TouchDateField.tsx +14 -2
  108. package/src/DateRangeInput/DateRangeInput.tsx +5 -1
  109. package/src/DateTimeInput/DateTimeInput.tsx +5 -1
  110. package/src/DateTimeInput/TouchDateTimeField.tsx +14 -2
  111. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +10 -1
  112. package/src/FileInput/FileInput.tsx +18 -2
  113. package/src/MultiSelector/MultiSelector.test.tsx +18 -4
  114. package/src/MultiSelector/MultiSelector.tsx +5 -1
  115. package/src/ProgressBar/ProgressBar.test.tsx +4 -21
  116. package/src/ProgressBar/__tests__/ProgressBar.a11y.chromium.spec.ts +114 -0
  117. package/src/ProgressBar/__tests__/ProgressBar.a11y.known-failures.ts +12 -0
  118. package/src/ProgressBar/__tests__/ProgressBar.a11y.renders.tsx +38 -0
  119. package/src/ProgressBar/__tests__/ProgressBar.a11y.states.ts +123 -0
  120. package/src/ProgressBar/__tests__/ProgressBar.a11y.test.tsx +107 -0
  121. package/src/Selector/Selector.test.tsx +22 -5
  122. package/src/Selector/Selector.tsx +5 -1
  123. package/src/TextArea/TextArea.test.tsx +9 -1
  124. package/src/TextArea/TextArea.tsx +5 -1
  125. package/src/TextInput/TextInput.tsx +5 -1
  126. package/src/Thumbnail/Thumbnail.test.tsx +6 -1
  127. package/src/Thumbnail/Thumbnail.tsx +12 -3
  128. package/src/TimeInput/TimeInput.tsx +5 -1
  129. package/src/TimeInput/TouchTimeField.tsx +12 -2
  130. package/src/Tokenizer/Tokenizer.test.tsx +17 -12
  131. package/src/Tokenizer/Tokenizer.tsx +10 -1
  132. package/src/Typeahead/BaseTypeahead.tsx +14 -1
  133. package/src/Typeahead/Typeahead.test.tsx +32 -41
  134. package/src/Typeahead/Typeahead.tsx +10 -1
  135. package/src/__tests__/BusyOwnerAnnouncement.test.tsx +792 -0
  136. package/src/hooks/__tests__/MenuPressShadowRoot.a11y.chromium.spec.ts +50 -0
  137. package/src/hooks/useBusyAnnouncement.ts +100 -0
  138. package/src/hooks/useMenuPress.test.tsx +27 -1
  139. package/src/hooks/useMenuPress.ts +12 -3
  140. package/src/hooks/useTypeahead.test.tsx +42 -1
  141. package/src/hooks/useTypeahead.ts +7 -2
  142. package/src/i18n/generated-locales/en.generated.ts +1 -0
  143. package/src/i18n/generated-locales/pseudo.generated.ts +1 -0
@@ -5,11 +5,22 @@
5
5
  /**
6
6
  * @file BottomSheetSwitcherContext.ts
7
7
  * @input Uses React context
8
- * @output Internal phase and lifecycle context shared by BottomSheetSwitcher and BottomSheet
8
+ * @output Internal phase, lifecycle, and host-dialog context shared by BottomSheetSwitcher and BottomSheet
9
9
  * @position Private coordination layer for mutually exclusive BottomSheets
10
+ *
11
+ * Every child sheet renders its own native `<dialog>`; the switcher's only
12
+ * element is a flow wrapper. This context carries the phase machine, the
13
+ * host-dialog selection, and the switcher's dialog-level props and handlers
14
+ * down to those sheet hosts.
10
15
  */
11
16
 
17
+ import type {
18
+ KeyboardEvent as ReactKeyboardEvent,
19
+ MouseEvent as ReactMouseEvent,
20
+ SyntheticEvent,
21
+ } from 'react';
12
22
  import {createContext} from 'react';
23
+ import type {BaseProps} from '../BaseProps';
13
24
  import type {DialogPurpose} from '../Dialog';
14
25
 
15
26
  export type BottomSheetSwitcherPhase =
@@ -26,23 +37,67 @@ export interface BottomSheetSwitcherTransitionEvent {
26
37
  phase: 'entering' | 'aligning' | 'fading' | 'exiting';
27
38
  }
28
39
 
40
+ /**
41
+ * The switcher's own dialog surface, applied by every sheet host. `ref` is not
42
+ * part of it: the switcher resolves its ref to the host sheet's dialog, and
43
+ * the single-instance attributes (`id`, `data-testid`) render there only.
44
+ */
45
+ export type BottomSheetSwitcherDialogProps = Omit<
46
+ BaseProps<HTMLDialogElement>,
47
+ 'onClick' | 'onKeyDown'
48
+ >;
49
+
29
50
  export interface BottomSheetSwitcherContextValue {
30
51
  activeSheet: string | null;
31
52
  hasScrim: boolean;
32
53
  onActiveSheetChange: (sheetId: string | null) => void;
33
54
  getSheetPhase: (sheetId: string) => BottomSheetSwitcherPhase;
34
55
  getSheetAlignmentOffset: (sheetId: string) => number;
56
+ /**
57
+ * The sheet whose dialog hosts the flow: the active sheet, the exiting
58
+ * sheet through its exit, and the last host (or the first mounted sheet)
59
+ * while the flow is closed. The switcher's ref and its single-instance
60
+ * attributes resolve to this sheet's dialog.
61
+ */
62
+ hostSheet: string | null;
63
+ /**
64
+ * Whether this sheet's dialog paints the flow's one scrim right now. During
65
+ * a handoff the retained sheet keeps it, so the entering dialog's backdrop
66
+ * stays transparent above the still-visible previous panel.
67
+ */
68
+ isScrimOwner: (sheetId: string) => boolean;
69
+ /** The flow is closing: the owning backdrop fades out on the exit curve. */
70
+ isFlowClosing: boolean;
35
71
  registerSheetElement: (sheetId: string, element: HTMLElement | null) => void;
36
- registerSheetLabel: (sheetId: string, label: string | null) => void;
72
+ registerSheetDialog: (
73
+ sheetId: string,
74
+ dialog: HTMLDialogElement | null,
75
+ ) => void;
37
76
  registerSheetPurpose: (
38
77
  sheetId: string,
39
78
  purpose: DialogPurpose | null,
40
79
  ) => void;
80
+ /**
81
+ * Called by a sheet host just before it presents its dialog modally, so the
82
+ * switcher can remember the page element that opened the flow.
83
+ */
84
+ onBeforeSheetPresent: (sheetId: string) => void;
85
+ /**
86
+ * Whether a modal sheet dialog that is already open sits beneath another
87
+ * open sheet dialog in the top layer, so it must re-present to become the
88
+ * topmost layer again.
89
+ */
90
+ isCoveredInTopLayer: (sheetId: string) => boolean;
41
91
  onSheetEnterStart: (sheetId: string) => void;
42
92
  onSheetTransitionComplete: (
43
93
  event: BottomSheetSwitcherTransitionEvent,
44
94
  ) => void;
45
95
  onSheetScrimOpacityChange: (sheetId: string, opacity: number) => void;
96
+ /** Consumer dialog props the switcher forwards to every sheet dialog. */
97
+ dialogProps: BottomSheetSwitcherDialogProps;
98
+ onDialogCancel: (event: SyntheticEvent<HTMLDialogElement>) => void;
99
+ onDialogClick: (event: ReactMouseEvent<HTMLDialogElement>) => void;
100
+ onDialogKeyDown: (event: ReactKeyboardEvent<HTMLDialogElement>) => void;
46
101
  }
47
102
 
48
103
  export const BottomSheetSwitcherContext =
@@ -0,0 +1,461 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file BottomSheetNativeModal.a11y.browser.spec.ts
5
+ * @input The `Core/BottomSheetSwitcher` stories in a built Storybook, and
6
+ * real Chromium/WebKit wheel, keyboard, and pointer input.
7
+ * @output Browser evidence for a flow whose every sheet owns a native modal
8
+ * dialog: the flow's one page scroll lock (the shared hook, as Dialog)
9
+ * holding while two sheet dialogs are open through a handoff and under a
10
+ * Core Dialog opened from a step, never released between steps nor when
11
+ * that Dialog closes, and the page's offset restored after the exit; Tab landing inside the entering sheet's dialog
12
+ * as soon as both dialogs are open and on Tab stops for as long as the
13
+ * retained dialog is open beneath it (its fade included), never the page
14
+ * once the handoff is done, by the browser's own containment, with the
15
+ * retained dialog carrying `inert` and `aria-hidden` on every step it is
16
+ * open; Escape and
17
+ * the backdrop dismissing by purpose with focus returned to the opener;
18
+ * and one steady scrim through a handoff.
19
+ * @position Cross-browser proof; jsdom presents no dialog and contains no
20
+ * focus, so BottomSheetSwitcher.test.tsx can only assert the calls. WebKit
21
+ * here is desktop WebKit under Playwright, not iOS Safari.
22
+ */
23
+
24
+ import {expect, test, type Page} from '@playwright/test';
25
+ import {
26
+ DEFAULT_STORYBOOK_DIR,
27
+ serveStorybook,
28
+ type StaticServer,
29
+ } from '@astryxdesign/a11y-spec/storybook';
30
+
31
+ let storybook: StaticServer;
32
+ test.beforeAll(async () => {
33
+ storybook = await serveStorybook(
34
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
35
+ );
36
+ });
37
+ test.afterAll(async () => {
38
+ await storybook?.close();
39
+ });
40
+
41
+ test.use({viewport: {width: 390, height: 844}, hasTouch: true});
42
+
43
+ const OPENER = 'Set up notifications';
44
+
45
+ /**
46
+ * Open a switcher story with the flow closed and the page made scrollable:
47
+ * a tall spacer below the story, scrolled part way down, so a lock that
48
+ * fails would show as a change in `scrollY`.
49
+ */
50
+ async function mountScrollable(page: Page, story = 'hug-content') {
51
+ await page.goto(
52
+ `${storybook.origin}/iframe.html?id=core-bottomsheetswitcher--${story}&viewMode=story`,
53
+ );
54
+ // The story's play function opens the flow; start from closed.
55
+ await expect(page.locator('dialog[open]')).toHaveCount(1);
56
+ await page.keyboard.press('Escape');
57
+ await expect(page.locator('dialog[open]')).toHaveCount(0);
58
+ await page.evaluate(() => {
59
+ const spacer = document.createElement('div');
60
+ spacer.id = 'spacer';
61
+ spacer.style.height = '3000px';
62
+ document.body.appendChild(spacer);
63
+ const counter = window as {__scrollEvents?: number};
64
+ counter.__scrollEvents = 0;
65
+ window.addEventListener('scroll', () => {
66
+ counter.__scrollEvents = (counter.__scrollEvents ?? 0) + 1;
67
+ });
68
+ window.scrollTo(0, 300);
69
+ });
70
+ expect(await page.evaluate(() => window.scrollY)).toBe(300);
71
+ }
72
+
73
+ /**
74
+ * The shared lock's pin on <body>, and the page's offset as the lock sees
75
+ * it: while pinned, `scrollY` reads 0 and the saved offset is `top`; after
76
+ * release, `scrollY` is the restored offset. `scrollEvents` counts window
77
+ * scroll events since `resetScrollCounter`: a lock released and re-acquired
78
+ * between steps restores the offset and pins again, which fires one.
79
+ */
80
+ async function lockState(page: Page): Promise<{
81
+ position: string;
82
+ top: string;
83
+ scrollY: number;
84
+ scrollEvents: number;
85
+ }> {
86
+ return page.evaluate(() => ({
87
+ position: document.body.style.position,
88
+ top: document.body.style.top,
89
+ scrollY: window.scrollY,
90
+ scrollEvents: (window as {__scrollEvents?: number}).__scrollEvents ?? 0,
91
+ }));
92
+ }
93
+
94
+ /**
95
+ * Zero the counter once the events already owed have arrived: the spacer
96
+ * scroll in `mountScrollable` and the lock's own pin (300 → 0) each fire one
97
+ * window `scroll` event, and WebKit delivers them late on a cold run.
98
+ */
99
+ async function resetScrollCounter(page: Page) {
100
+ await page
101
+ .waitForFunction(
102
+ () => ((window as {__scrollEvents?: number}).__scrollEvents ?? 0) >= 2,
103
+ undefined,
104
+ {timeout: 3000},
105
+ )
106
+ .catch(() => {});
107
+ await page.evaluate(() => {
108
+ (window as {__scrollEvents?: number}).__scrollEvents = 0;
109
+ });
110
+ }
111
+
112
+ async function wheelDown(page: Page) {
113
+ // Over the top of the viewport: the backdrop when a modal sheet is open,
114
+ // the page otherwise.
115
+ await page.mouse.move(195, 80);
116
+ await page.mouse.wheel(0, 400);
117
+ await page.waitForTimeout(250);
118
+ }
119
+
120
+ /** A finger drag over the backdrop; Chromium only (CDP), WebKit has no API. */
121
+ async function touchScrollDown(page: Page, browserName: string) {
122
+ if (browserName !== 'chromium') {
123
+ return;
124
+ }
125
+ const session = await page.context().newCDPSession(page);
126
+ await session.send('Input.synthesizeScrollGesture', {
127
+ x: 195,
128
+ y: 120,
129
+ yDistance: -300,
130
+ gestureSourceType: 'touch',
131
+ speed: 800,
132
+ });
133
+ await session.detach();
134
+ await page.waitForTimeout(250);
135
+ }
136
+
137
+ /**
138
+ * Open the flow from its opener without scrolling the page: a pointer click
139
+ * would first scroll the opener into view, and the lock tests need the page
140
+ * to stay where it was scrolled. The opener is focused first, as a real
141
+ * activation would leave it, so focus return has somewhere to go.
142
+ */
143
+ async function openFlow(page: Page) {
144
+ await page.evaluate(name => {
145
+ const opener = Array.from(document.querySelectorAll('button')).find(
146
+ button => button.textContent?.trim() === name,
147
+ );
148
+ opener?.focus({preventScroll: true});
149
+ opener?.click();
150
+ }, OPENER);
151
+ await expect(page.getByRole('dialog', {name: OPENER})).toBeVisible();
152
+ await page.waitForTimeout(500);
153
+ }
154
+
155
+ test('the flow holds one page scroll lock through a handoff and under a Core Dialog opened from a step, never released between steps, and restores the offset after the exit', async ({
156
+ page,
157
+ browserName,
158
+ }) => {
159
+ await mountScrollable(page, 'dialog-from-step');
160
+ await openFlow(page);
161
+ let state = await lockState(page);
162
+ expect(state.position).toBe('fixed');
163
+ expect(state.top).toBe('-300px');
164
+ await resetScrollCounter(page);
165
+
166
+ await page.getByRole('button', {name: 'Continue', exact: true}).click();
167
+ await expect(page.locator('dialog[open]')).toHaveCount(2);
168
+ state = await lockState(page);
169
+ expect(state.position).toBe('fixed');
170
+ expect(state.top).toBe('-300px');
171
+ // Wheel and finger over the backdrop with both dialogs open: the pinned
172
+ // page does not move.
173
+ await wheelDown(page);
174
+ await touchScrollDown(page, browserName);
175
+ state = await lockState(page);
176
+ expect(state.position).toBe('fixed');
177
+ expect(state.top).toBe('-300px');
178
+ expect(state.scrollY).toBe(0);
179
+ await expect(page.locator('dialog[open]')).toHaveCount(1, {timeout: 3000});
180
+ // One lock for the flow: the handoff released nothing, so the page never
181
+ // scrolled back and forth between steps.
182
+ state = await lockState(page);
183
+ expect(state.position).toBe('fixed');
184
+ expect(state.scrollEvents).toBe(0);
185
+
186
+ // A Core Dialog opened from the step, as a builder would, takes the same
187
+ // shared lock a second time; closing it must give back only its own hold,
188
+ // leaving the flow's pin in place with the saved offset untouched.
189
+ await page.getByRole('button', {name: 'Reset', exact: true}).click();
190
+ const resetDialog = page.getByRole('dialog', {name: 'Reset frequency?'});
191
+ await expect(resetDialog).toBeVisible();
192
+ await wheelDown(page);
193
+ state = await lockState(page);
194
+ expect(state.position).toBe('fixed');
195
+ expect(state.top).toBe('-300px');
196
+ expect(state.scrollY).toBe(0);
197
+ await page.getByRole('button', {name: 'Keep', exact: true}).click();
198
+ await expect(resetDialog).toBeHidden();
199
+ await page.waitForTimeout(400);
200
+ state = await lockState(page);
201
+ expect(state.position).toBe('fixed');
202
+ expect(state.top).toBe('-300px');
203
+ expect(state.scrollY).toBe(0);
204
+ expect(state.scrollEvents).toBe(0);
205
+ await wheelDown(page);
206
+ expect(await page.evaluate(() => window.scrollY)).toBe(0);
207
+ // The Dialog returned focus into the step, so Escape closes the flow.
208
+ await expect(page.locator('dialog[open]')).toHaveCount(1);
209
+
210
+ await page.keyboard.press('Escape');
211
+ await expect(page.locator('dialog[open]')).toHaveCount(0, {timeout: 3000});
212
+ state = await lockState(page);
213
+ expect(state.position).toBe('');
214
+ expect(state.top).toBe('');
215
+ expect(state.scrollY).toBe(300);
216
+ // The page scrolls again.
217
+ await wheelDown(page);
218
+ expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(300);
219
+ });
220
+
221
+ /**
222
+ * One synchronous read per step: how many sheet dialogs are open, where focus
223
+ * is (and whether it moved since the last read and sits outside any inert
224
+ * subtree), and whether the retained dialog (by accessible name) still
225
+ * carries `inert` and `aria-hidden`. Read together so a close between two
226
+ * reads cannot make a stale attribute look like a regression, or hide one.
227
+ */
228
+ async function handoffStep(
229
+ page: Page,
230
+ retainedName: string,
231
+ ): Promise<{
232
+ openCount: number;
233
+ /** Page-clock ms since the second dialog opened; -1 before that. */
234
+ sinceBothOpen: number;
235
+ where: string;
236
+ moved: boolean;
237
+ insideInert: boolean;
238
+ retained: {open: boolean; inert: boolean; ariaHidden: string | null} | null;
239
+ }> {
240
+ return page.evaluate(name => {
241
+ const openDialogs = Array.from(document.querySelectorAll('dialog[open]'));
242
+ const clock = window as {__bothOpenAt?: number};
243
+ const sinceBothOpen =
244
+ clock.__bothOpenAt == null
245
+ ? -1
246
+ : Math.round(performance.now() - clock.__bothOpenAt);
247
+ const active = document.activeElement;
248
+ const tracker = window as {__lastActive?: Element | null};
249
+ const moved = active !== tracker.__lastActive;
250
+ tracker.__lastActive = active;
251
+ const insideInert = active?.closest('[inert]') != null;
252
+ const activeDialog = active?.closest('dialog') ?? null;
253
+ const where =
254
+ active == null || active === document.body
255
+ ? 'body'
256
+ : activeDialog == null
257
+ ? 'page'
258
+ : (activeDialog.getAttribute('aria-label') ?? '?');
259
+ const retainedDialog = Array.from(document.querySelectorAll('dialog')).find(
260
+ dialog => dialog.getAttribute('aria-label') === name,
261
+ );
262
+ return {
263
+ openCount: openDialogs.length,
264
+ sinceBothOpen,
265
+ where,
266
+ moved,
267
+ insideInert,
268
+ retained:
269
+ retainedDialog == null
270
+ ? null
271
+ : {
272
+ open: retainedDialog.hasAttribute('open'),
273
+ inert: retainedDialog.hasAttribute('inert'),
274
+ ariaHidden: retainedDialog.getAttribute('aria-hidden'),
275
+ },
276
+ };
277
+ }, retainedName);
278
+ }
279
+
280
+ test('focus is inside the entering sheet as soon as both dialogs are open, and Tab reaches stops inside it for as long as the retained dialog is open, which stays inert and hidden through its fade, and inside the flow after the handoff', async ({
281
+ page,
282
+ }) => {
283
+ await mountScrollable(page);
284
+ await openFlow(page);
285
+ // Stamp the page clock the moment the second dialog opens, so the late
286
+ // window below is measured from the handoff itself, not from when the
287
+ // test runner got around to reading it on a slow run.
288
+ await page.evaluate(() => {
289
+ const clock = window as {__bothOpenAt?: number};
290
+ clock.__bothOpenAt = undefined;
291
+ const observer = new MutationObserver(() => {
292
+ if (
293
+ clock.__bothOpenAt == null &&
294
+ document.querySelectorAll('dialog[open]').length >= 2
295
+ ) {
296
+ clock.__bothOpenAt = performance.now();
297
+ observer.disconnect();
298
+ }
299
+ });
300
+ observer.observe(document.body, {
301
+ subtree: true,
302
+ attributes: true,
303
+ attributeFilter: ['open'],
304
+ });
305
+ });
306
+ await page.getByRole('button', {name: 'Continue', exact: true}).click();
307
+ // Both dialogs are open for the handoff's ~650ms: the retained one holds
308
+ // still, then fades from ~345ms, then closes. Step with Tab until it has
309
+ // closed, reading open count, focus, and the retained dialog's attributes
310
+ // in one evaluate per step: focus must land in the entering sheet's dialog
311
+ // ("Notification frequency"), never in the retained one ("Set up
312
+ // notifications") or the page, and the retained dialog must carry `inert`
313
+ // and `aria-hidden` on every step it is still open, the fade included.
314
+ await expect(page.locator('dialog[open]')).toHaveCount(2);
315
+ // The entering sheet takes focus with its presentation, before any Tab:
316
+ // the moment both dialogs are open, focus is inside "Notification
317
+ // frequency" and outside every inert subtree. An entering sheet made
318
+ // inert while it enters would leave focus on <body> for its first ~300ms.
319
+ const atOpen = await handoffStep(page, OPENER);
320
+ expect(atOpen.openCount).toBe(2);
321
+ expect(atOpen.where, 'focus as soon as both dialogs are open').toBe(
322
+ 'Notification frequency',
323
+ );
324
+ expect(atOpen.insideInert, 'focus inside an inert subtree at open').toBe(
325
+ false,
326
+ );
327
+ expect(atOpen.sinceBothOpen).toBeGreaterThanOrEqual(0);
328
+ let midHandoffPresses = 0;
329
+ let lateHandoffPresses = 0;
330
+ let midHandoffStopsInside = 0;
331
+ let lateHandoffStopsInside = 0;
332
+ for (let index = 0; index < 40; index += 1) {
333
+ await page.keyboard.press('Tab');
334
+ const step = await handoffStep(page, OPENER);
335
+ if (step.openCount < 2 || step.retained?.open !== true) {
336
+ break;
337
+ }
338
+ midHandoffPresses += 1;
339
+ // "Late" is inside the retained dialog's fade (from ~345ms after both
340
+ // opened), on the page's own clock.
341
+ const isLate = step.sinceBothOpen > 400;
342
+ if (isLate) {
343
+ lateHandoffPresses += 1;
344
+ }
345
+ const label = `mid-handoff, Tab ${index + 1} at ${step.sinceBothOpen}ms`;
346
+ expect(step.where, label).not.toBe('page');
347
+ expect(step.where, label).not.toBe(OPENER);
348
+ expect(['Notification frequency', 'body'], label).toContain(step.where);
349
+ // A stop inside the entering sheet is a Tab that moved focus onto a
350
+ // focusable element there, outside any inert subtree; focus merely left
351
+ // where the presentation put it, inside a dialog that went inert, is not.
352
+ expect(step.insideInert, `${label}: focus inside an inert subtree`).toBe(
353
+ false,
354
+ );
355
+ if (step.where === 'Notification frequency' && step.moved) {
356
+ midHandoffStopsInside += 1;
357
+ if (isLate) {
358
+ lateHandoffStopsInside += 1;
359
+ }
360
+ }
361
+ expect(step.retained.inert, `${label}: retained dialog inert`).toBe(true);
362
+ expect(step.retained.ariaHidden, `${label}: retained aria-hidden`).toBe(
363
+ 'true',
364
+ );
365
+ await page.waitForTimeout(40);
366
+ }
367
+ // Presses landed both before and during the retained dialog's fade, else
368
+ // the test proved nothing about the handoff; and at least one of them
369
+ // stopped inside the entering sheet, else an entering sheet that can never
370
+ // take focus (every stop on the browser-UI `body` stop) would pass.
371
+ expect(midHandoffPresses).toBeGreaterThan(2);
372
+ expect(lateHandoffPresses).toBeGreaterThan(0);
373
+ expect(midHandoffStopsInside).toBeGreaterThan(0);
374
+ // ...and at least one of those stops landed inside the retained dialog's
375
+ // fade (from ~345ms), so an entering sheet that went inert only while the
376
+ // retained one fades would not pass.
377
+ expect(lateHandoffStopsInside).toBeGreaterThan(0);
378
+
379
+ await expect(page.locator('dialog[open]')).toHaveCount(1, {timeout: 3000});
380
+ let stopsInside = 0;
381
+ for (let index = 0; index < 8; index += 1) {
382
+ await page.keyboard.press('Tab');
383
+ const step = await handoffStep(page, OPENER);
384
+ expect(step.where, `after the handoff, Tab ${index + 1}`).not.toBe('page');
385
+ expect(['Notification frequency', 'body']).toContain(step.where);
386
+ expect(step.insideInert, `after the handoff, Tab ${index + 1}`).toBe(false);
387
+ if (step.where === 'Notification frequency' && step.moved) {
388
+ stopsInside += 1;
389
+ }
390
+ }
391
+ expect(stopsInside).toBeGreaterThan(0);
392
+ });
393
+
394
+ test('Escape and a backdrop tap dismiss an info flow and return focus to the opener', async ({
395
+ page,
396
+ }) => {
397
+ await mountScrollable(page);
398
+ const opener = page.getByRole('button', {name: OPENER});
399
+ await openFlow(page);
400
+ await page.getByRole('button', {name: 'Continue', exact: true}).click();
401
+ await page.waitForTimeout(900);
402
+
403
+ await page.keyboard.press('Escape');
404
+ await expect(page.locator('dialog[open]')).toHaveCount(0, {timeout: 3000});
405
+ await expect(opener).toBeFocused();
406
+
407
+ await openFlow(page);
408
+ await page.mouse.click(195, 60);
409
+ await expect(page.locator('dialog[open]')).toHaveCount(0, {timeout: 3000});
410
+ await expect(opener).toBeFocused();
411
+ });
412
+
413
+ test('one steady scrim through a handoff: no dip and no double dim', async ({
414
+ page,
415
+ }) => {
416
+ await mountScrollable(page);
417
+ await openFlow(page);
418
+ const samples = await page.evaluate(
419
+ async () =>
420
+ new Promise<number[]>(resolve => {
421
+ const dims: number[] = [];
422
+ const start = performance.now();
423
+ const tick = () => {
424
+ let combined = 1;
425
+ for (const dialog of document.querySelectorAll('dialog[open]')) {
426
+ if (!dialog.matches(':modal')) {
427
+ continue;
428
+ }
429
+ const style = getComputedStyle(dialog, '::backdrop');
430
+ const match = /rgba?\(([^)]+)\)/.exec(style.backgroundColor);
431
+ const parts = match
432
+ ? match[1].split(/[,/]\s*|\s+/).filter(Boolean)
433
+ : [];
434
+ const alpha = parts.length === 4 ? Number(parts[3]) : 1;
435
+ combined *= 1 - alpha * Number(style.opacity);
436
+ }
437
+ dims.push(Number((1 - combined).toFixed(3)));
438
+ if (performance.now() - start < 1100) {
439
+ requestAnimationFrame(tick);
440
+ } else {
441
+ resolve(dims);
442
+ }
443
+ };
444
+ Array.from(
445
+ document.querySelectorAll<HTMLButtonElement>(
446
+ 'dialog[open]:not([inert]) button',
447
+ ),
448
+ )
449
+ .find(button => button.textContent?.trim() === 'Continue')
450
+ ?.click();
451
+ requestAnimationFrame(tick);
452
+ }),
453
+ );
454
+ expect(samples.length).toBeGreaterThan(20);
455
+ const steady = samples[0];
456
+ expect(steady).toBeGreaterThan(0.3);
457
+ for (const dim of samples) {
458
+ expect(dim).toBeGreaterThanOrEqual(steady * 0.9);
459
+ expect(dim).toBeLessThanOrEqual(steady * 1.05);
460
+ }
461
+ });
@@ -519,7 +519,7 @@ describe('Carousel', () => {
519
519
  fireEvent.scroll(el);
520
520
  }
521
521
 
522
- it('hands focus to the opposite button when the edge disables the one in use', async () => {
522
+ it('hands focus to the scroll area when the edge disables the one in use', async () => {
523
523
  const user = userEvent.setup();
524
524
  render(
525
525
  <Carousel aria-label="Gallery">
@@ -536,7 +536,7 @@ describe('Carousel', () => {
536
536
  await user.click(next);
537
537
 
538
538
  expect(next).toBeDisabled();
539
- expect(document.activeElement).toBe(screen.getByLabelText('Scroll left'));
539
+ expect(document.activeElement).toBe(getScroller());
540
540
  expect(document.activeElement).not.toBe(document.body);
541
541
  });
542
542
 
@@ -559,9 +559,7 @@ describe('Carousel', () => {
559
559
  await user.click(previous);
560
560
 
561
561
  expect(previous).toBeDisabled();
562
- expect(document.activeElement).toBe(
563
- screen.getByLabelText('Scroll right'),
564
- );
562
+ expect(document.activeElement).toBe(getScroller());
565
563
  });
566
564
 
567
565
  it('hands off under RTL, where the scroll axis is inverted', async () => {
@@ -605,7 +603,7 @@ describe('Carousel', () => {
605
603
  await user.click(next);
606
604
 
607
605
  expect(next).toBeDisabled();
608
- expect(document.activeElement).toBe(screen.getByLabelText('Scroll left'));
606
+ expect(document.activeElement).toBe(getScroller());
609
607
  });
610
608
 
611
609
  it('hands off when scroll-snap carries the container past what the press asked for', async () => {
@@ -626,7 +624,7 @@ describe('Carousel', () => {
626
624
  await user.click(next);
627
625
 
628
626
  expect(next).toBeDisabled();
629
- expect(document.activeElement).toBe(screen.getByLabelText('Scroll left'));
627
+ expect(document.activeElement).toBe(getScroller());
630
628
  });
631
629
 
632
630
  it('falls back to the scroll container when the carousel stops overflowing', async () => {
@@ -701,7 +699,7 @@ describe('Carousel', () => {
701
699
  // rather than loosening the guard -- is caught.
702
700
  await user.click(screen.getByLabelText('Scroll right'));
703
701
 
704
- expect(document.activeElement).toBe(screen.getByLabelText('Scroll left'));
702
+ expect(document.activeElement).toBe(getScroller());
705
703
  });
706
704
 
707
705
  it('does not move focus on a press that leaves the direction available', async () => {
@@ -748,8 +746,7 @@ describe('Carousel', () => {
748
746
  </Carousel>,
749
747
  );
750
748
  makeScrollable(getScroller(), 400);
751
- const previous = screen.getByLabelText('Scroll left');
752
- const focus = vi.spyOn(previous, 'focus');
749
+ const focus = vi.spyOn(getScroller(), 'focus');
753
750
 
754
751
  await user.click(screen.getByLabelText('Scroll right'));
755
752
 
@@ -789,8 +786,7 @@ describe('Carousel', () => {
789
786
  makeScrollable(getScroller(), 400);
790
787
  await user.click(screen.getByLabelText('Scroll right'));
791
788
 
792
- const previous = screen.getByLabelText('Scroll left');
793
- expect(document.activeElement).toBe(previous);
789
+ expect(document.activeElement).toBe(getScroller());
794
790
  // Park focus outside and re-render: the transition is spent, so nothing
795
791
  // may pull focus back in.
796
792
  const outside = document.createElement('button');
@@ -504,9 +504,7 @@ export function Carousel({
504
504
  // the scroll, the position before it plus the step, and the settled position
505
505
  // reported by `scrollend` -- were each wrong somewhere, under reduced motion,
506
506
  // under mandatory scroll-snap, and on a browser that never fires the event.
507
- // Running after the commit also makes the opposite button a valid receiver,
508
- // which it is not during the press: at that moment it is still disabled and
509
- // focus() on a disabled control does nothing. Ruled acceptable by the
507
+ // Ruled acceptable by the
510
508
  // maintainer, 2026-08-27, on the conditions encoded below: it acts only on
511
509
  // the transition that newly disables the focused button, it moves focus and
512
510
  // nothing else, and it cannot fire twice for one transition.
@@ -543,21 +541,16 @@ export function Carousel({
543
541
  return;
544
542
  }
545
543
 
546
- const opposite =
547
- side === 'start' ? endButtonRef.current : startButtonRef.current;
548
- // The opposite button is the receiver. It is disabled only when the
549
- // carousel stopped overflowing entirely, which happens when its content
550
- // shrinks; the scroll container is then the nearest tab stop still inside
551
- // the labelled region.
552
- const receiver =
553
- opposite != null && !opposite.disabled ? opposite : scrollElRef.current;
544
+ // The scroll area is the receiver (component:Carousel FR2). It takes focus
545
+ // programmatically even when it is out of the tab order.
546
+ const receiver = scrollElRef.current;
554
547
  if (receiver == null) {
555
548
  return;
556
549
  }
557
550
 
558
551
  // One move per transition: clearing the tracker first means a re-render or
559
552
  // a StrictMode double-invoke re-runs this and returns at the `side == null`
560
- // guard above. Focusing the receiver sets it again, to the other side.
553
+ // guard above.
561
554
  focusedNavRef.current = null;
562
555
  // preventScroll: a plain focus() scrolls its element into view, which on
563
556
  // the scroll container cancels the scroll the press just started.
@@ -661,7 +654,7 @@ export function Carousel({
661
654
  // button mounted (stable layout) but removes it from the tab
662
655
  // order and a11y tree while it's visually hidden, so keyboard
663
656
  // users don't land on an invisible control. The Effect above
664
- // hands focus to the other button when the state behind this
657
+ // hands focus to the scroll area when the state behind this
665
658
  // one flips it disabled.
666
659
  isDisabled={!canScrollStart}
667
660
  onFocus={() => {