@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
@@ -10,29 +10,41 @@
10
10
  * update these tests to match the public behavior.
11
11
  */
12
12
 
13
- import {fireEvent, render, screen} from '@testing-library/react';
13
+ import {act, fireEvent, render, screen} from '@testing-library/react';
14
14
  import {
15
+ StrictMode,
15
16
  createRef,
17
+ useEffect,
18
+ useLayoutEffect,
16
19
  useState,
17
20
  type KeyboardEvent as ReactKeyboardEvent,
18
21
  } from 'react';
19
22
  import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
20
23
  import {hasActiveFocusTrapEscape, useFocusTrap} from '../hooks';
21
24
  import {useLayerDismissal} from '../Layer/useLayerDismissal';
25
+ import {Dialog} from '../Dialog';
26
+ import {hydrateRoot} from 'react-dom/client';
27
+ import {renderToString} from 'react-dom/server';
22
28
  import {BottomSheet} from './BottomSheet';
23
29
  import {BottomSheetSwitcher} from './BottomSheetSwitcher';
24
30
 
31
+ // jsdom implements no <dialog> presentation. The stubs record which open
32
+ // dialogs are modal, standing in for the `:modal` pseudo-class jsdom cannot
33
+ // match.
34
+ const modalDialogs = new WeakSet<HTMLDialogElement>();
25
35
  beforeEach(() => {
26
36
  HTMLDialogElement.prototype.showModal = vi.fn(function (
27
37
  this: HTMLDialogElement,
28
38
  ) {
29
39
  this.setAttribute('open', '');
40
+ modalDialogs.add(this);
30
41
  });
31
42
  HTMLDialogElement.prototype.show = vi.fn(function (this: HTMLDialogElement) {
32
43
  this.setAttribute('open', '');
33
44
  });
34
45
  HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
35
46
  this.removeAttribute('open');
47
+ modalDialogs.delete(this);
36
48
  });
37
49
  window.scrollTo = vi.fn();
38
50
  vi.stubGlobal(
@@ -224,10 +236,206 @@ function ModeSwitchFlow() {
224
236
  );
225
237
  }
226
238
 
227
- function getSharedDialog(): HTMLDialogElement {
239
+ function RequiredConfirmFlow() {
240
+ const [activeSheet, setActiveSheet] = useState<string | null>('details');
241
+ return (
242
+ <BottomSheetSwitcher
243
+ activeSheet={activeSheet}
244
+ onActiveSheetChange={setActiveSheet}>
245
+ <BottomSheet
246
+ sheetId="details"
247
+ label="Details"
248
+ data-testid="details-sheet">
249
+ <button type="button" onClick={() => setActiveSheet('confirm')}>
250
+ Continue
251
+ </button>
252
+ </BottomSheet>
253
+ <BottomSheet
254
+ sheetId="confirm"
255
+ label="Confirm"
256
+ purpose="required"
257
+ data-testid="confirm-sheet">
258
+ <button type="button" onClick={() => setActiveSheet(null)}>
259
+ Done
260
+ </button>
261
+ </BottomSheet>
262
+ </BottomSheetSwitcher>
263
+ );
264
+ }
265
+
266
+ /**
267
+ * A three-step flow that mounts closed, for the host-dialog contract: the
268
+ * switcher's ref, `id`, and `data-testid`. `onMountLayout` and
269
+ * `onMountPassive` run in the consumer's own mount effects, where a parent
270
+ * would first read the ref.
271
+ */
272
+ function HostFlow({
273
+ dialogRef,
274
+ dialogProps,
275
+ hasScrim = true,
276
+ initialSheet = null,
277
+ onMountLayout,
278
+ onMountPassive,
279
+ }: {
280
+ dialogRef?: React.Ref<HTMLDialogElement>;
281
+ dialogProps?: Record<string, string>;
282
+ hasScrim?: boolean;
283
+ initialSheet?: string | null;
284
+ onMountLayout?: () => void;
285
+ onMountPassive?: () => void;
286
+ }) {
287
+ const [activeSheet, setActiveSheet] = useState<string | null>(initialSheet);
288
+ useLayoutEffect(() => onMountLayout?.(), [onMountLayout]);
289
+ useEffect(() => onMountPassive?.(), [onMountPassive]);
290
+ return (
291
+ <>
292
+ <button type="button" onClick={() => setActiveSheet('details')}>
293
+ Start flow
294
+ </button>
295
+ <BottomSheetSwitcher
296
+ ref={dialogRef}
297
+ {...dialogProps}
298
+ activeSheet={activeSheet}
299
+ onActiveSheetChange={setActiveSheet}
300
+ hasScrim={hasScrim}>
301
+ <BottomSheet
302
+ sheetId="details"
303
+ label="Details"
304
+ data-testid="details-sheet">
305
+ <button type="button" onClick={() => setActiveSheet('confirm')}>
306
+ Continue
307
+ </button>
308
+ </BottomSheet>
309
+ <BottomSheet
310
+ sheetId="confirm"
311
+ label="Confirm"
312
+ data-testid="confirm-sheet">
313
+ <button type="button" onClick={() => setActiveSheet(null)}>
314
+ Done
315
+ </button>
316
+ </BottomSheet>
317
+ <BottomSheet sheetId="extra" label="Extra">
318
+ Content
319
+ </BottomSheet>
320
+ </BottomSheetSwitcher>
321
+ </>
322
+ );
323
+ }
324
+
325
+ /**
326
+ * The dialog hosting the active sheet. A retained sheet's dialog is inert and
327
+ * `aria-hidden`, so only the active sheet's dialog is exposed by role.
328
+ */
329
+ function getActiveDialog(): HTMLDialogElement {
228
330
  return screen.getByRole<HTMLDialogElement>('dialog');
229
331
  }
230
332
 
333
+ function getSheetDialog(testId: string): HTMLDialogElement {
334
+ const dialog = screen.getByTestId(testId).closest('dialog');
335
+ if (dialog == null) {
336
+ throw new Error('sheet dialog not found');
337
+ }
338
+ return dialog;
339
+ }
340
+
341
+ function openDialogs(): HTMLDialogElement[] {
342
+ return Array.from(
343
+ document.querySelectorAll<HTMLDialogElement>('dialog[open]'),
344
+ );
345
+ }
346
+
347
+ /**
348
+ * Whether the shared page-scroll lock (useScrollLock, as Dialog uses) is
349
+ * engaged: it pins <body>. The browser spec measures the lock's effect on
350
+ * scrolling; this reads its pin.
351
+ */
352
+ function isScrollLockEngaged(): boolean {
353
+ return document.body.style.position === 'fixed';
354
+ }
355
+
356
+ /** A two-step flow whose second step opens a Core Dialog above itself. */
357
+ function DialogInStepFlow() {
358
+ const [activeSheet, setActiveSheet] = useState<string | null>(null);
359
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
360
+ return (
361
+ <>
362
+ <button type="button" onClick={() => setActiveSheet('details')}>
363
+ Start flow
364
+ </button>
365
+ <BottomSheetSwitcher
366
+ activeSheet={activeSheet}
367
+ onActiveSheetChange={setActiveSheet}>
368
+ <BottomSheet
369
+ sheetId="details"
370
+ label="Details"
371
+ data-testid="details-sheet">
372
+ <button type="button" onClick={() => setActiveSheet('confirm')}>
373
+ Continue
374
+ </button>
375
+ </BottomSheet>
376
+ <BottomSheet
377
+ sheetId="confirm"
378
+ label="Confirm"
379
+ data-testid="confirm-sheet">
380
+ <button type="button" onClick={() => setIsDialogOpen(true)}>
381
+ Open dialog
382
+ </button>
383
+ <Dialog
384
+ isOpen={isDialogOpen}
385
+ onOpenChange={setIsDialogOpen}
386
+ aria-label="Are you sure">
387
+ <button type="button" onClick={() => setIsDialogOpen(false)}>
388
+ Close dialog
389
+ </button>
390
+ </Dialog>
391
+ <button type="button" onClick={() => setActiveSheet(null)}>
392
+ Done
393
+ </button>
394
+ </BottomSheet>
395
+ </BottomSheetSwitcher>
396
+ </>
397
+ );
398
+ }
399
+
400
+ /**
401
+ * Records every native dialog call with the dialog's accessible label, and
402
+ * returns focus the way Chromium and WebKit do: close() on a dialog presented
403
+ * with showModal() focuses the element that was focused when it was
404
+ * presented, wherever focus is by then (measured: both engines return it
405
+ * even when focus sits in another open dialog).
406
+ */
407
+ function recordDialogCalls(): string[] {
408
+ const log: string[] = [];
409
+ const focusedAtPresent = new WeakMap<HTMLDialogElement, Element | null>();
410
+ const record = (name: string) =>
411
+ vi.fn(function (this: HTMLDialogElement) {
412
+ log.push(`${name}:${this.getAttribute('aria-label') ?? '?'}`);
413
+ if (name === 'close') {
414
+ this.removeAttribute('open');
415
+ const wasModal = modalDialogs.delete(this);
416
+ const previouslyFocused = focusedAtPresent.get(this);
417
+ focusedAtPresent.delete(this);
418
+ if (
419
+ wasModal &&
420
+ previouslyFocused instanceof HTMLElement &&
421
+ previouslyFocused.isConnected
422
+ ) {
423
+ previouslyFocused.focus();
424
+ }
425
+ } else {
426
+ if (name === 'showModal') {
427
+ focusedAtPresent.set(this, document.activeElement);
428
+ modalDialogs.add(this);
429
+ }
430
+ this.setAttribute('open', '');
431
+ }
432
+ });
433
+ HTMLDialogElement.prototype.showModal = record('showModal');
434
+ HTMLDialogElement.prototype.show = record('show');
435
+ HTMLDialogElement.prototype.close = record('close');
436
+ return log;
437
+ }
438
+
231
439
  function finishSheetTransition(
232
440
  element: HTMLElement,
233
441
  propertyName: 'transform' | 'opacity',
@@ -288,11 +496,11 @@ describe('BottomSheetSwitcher', () => {
288
496
  expect(document.querySelectorAll('dialog[open]')).toHaveLength(1);
289
497
  expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1);
290
498
  expect(HTMLDialogElement.prototype.show).not.toHaveBeenCalled();
291
- expect(getSharedDialog()).toHaveAttribute('aria-modal', 'true');
292
- expect(getSharedDialog()).toHaveAccessibleName('Details');
499
+ expect(getActiveDialog()).toHaveAttribute('aria-modal', 'true');
500
+ expect(getActiveDialog()).toHaveAccessibleName('Details');
293
501
  });
294
502
 
295
- it('forwards sheet DOM props and refs to its panel in the shared dialog', () => {
503
+ it('forwards sheet DOM props and refs to its panel in its dialog', () => {
296
504
  const panelRef = createRef<HTMLDivElement>();
297
505
 
298
506
  render(
@@ -312,10 +520,10 @@ describe('BottomSheetSwitcher', () => {
312
520
  expect(panelRef.current).toBe(panel);
313
521
  expect(panel).toHaveClass('astryx-bottom-sheet');
314
522
  expect(panel).toHaveAttribute('data-sheet-owner', 'settings');
315
- expect(getSharedDialog()).toContainElement(panel);
523
+ expect(getActiveDialog()).toContainElement(panel);
316
524
  });
317
525
 
318
- it('forwards switcher DOM props, events, styles, and refs to the shared dialog', () => {
526
+ it("forwards switcher DOM props, events, styles, and refs to the active sheet's dialog", () => {
319
527
  const dialogRef = createRef<HTMLDialogElement>();
320
528
  const onClick = vi.fn();
321
529
  const onActiveSheetChange = vi.fn();
@@ -336,7 +544,7 @@ describe('BottomSheetSwitcher', () => {
336
544
  </BottomSheetSwitcher>,
337
545
  );
338
546
 
339
- const dialog = getSharedDialog();
547
+ const dialog = getActiveDialog();
340
548
  expect(dialogRef.current).toBe(dialog);
341
549
  expect(dialog).toHaveAccessibleName('Notification setup');
342
550
  expect(dialog).toHaveAttribute('data-flow-owner', 'settings');
@@ -366,14 +574,16 @@ describe('BottomSheetSwitcher', () => {
366
574
  finishSheetTransition(nestedDialog, 'transform');
367
575
 
368
576
  expect(nestedDialog).not.toHaveAttribute('open');
369
- expect(getSharedDialog()).toHaveAttribute('open');
577
+ expect(getActiveDialog()).toHaveAttribute('open');
370
578
  expect(opener).toHaveFocus();
371
579
  });
372
580
 
373
581
  it('keeps the previous sheet stationary until the new entrance finishes, then fades it', () => {
374
582
  render(<Flow />);
375
583
  fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
376
- const sharedDialog = getSharedDialog();
584
+ const detailsDialog = getSheetDialog('details-sheet');
585
+ const confirmDialog = getSheetDialog('confirm-sheet');
586
+ expect(getActiveDialog()).toBe(detailsDialog);
377
587
  const detailsSheet = getSheetLayer('details-sheet');
378
588
  const confirmSheet = getSheetLayer('confirm-sheet');
379
589
 
@@ -384,9 +594,11 @@ describe('BottomSheetSwitcher', () => {
384
594
  expect(detailsSheet).toHaveAttribute('aria-hidden', 'true');
385
595
  expect(confirmSheet).not.toHaveAttribute('hidden');
386
596
  expect(confirmSheet).not.toHaveAttribute('inert');
387
- expect(document.querySelectorAll('dialog[open]')).toHaveLength(1);
388
- expect(getSharedDialog()).toBe(sharedDialog);
389
- expect(sharedDialog).toHaveAccessibleName('Confirm');
597
+ // Both dialogs stay open through the handoff; the retained one is inert.
598
+ expect(openDialogs()).toEqual([detailsDialog, confirmDialog]);
599
+ expect(detailsDialog).toHaveAttribute('inert');
600
+ expect(getActiveDialog()).toBe(confirmDialog);
601
+ expect(confirmDialog).toHaveAccessibleName('Confirm');
390
602
 
391
603
  // The previous sheet is covered, not exiting: neither transform nor
392
604
  // opacity completion may release it before the new entrance completes.
@@ -401,8 +613,8 @@ describe('BottomSheetSwitcher', () => {
401
613
 
402
614
  expect(detailsSheet).toHaveAttribute('hidden');
403
615
  expect(confirmSheet).not.toHaveAttribute('hidden');
404
- expect(document.querySelectorAll('dialog[open]')).toHaveLength(1);
405
- expect(getSharedDialog()).toBe(sharedDialog);
616
+ expect(openDialogs()).toEqual([confirmDialog]);
617
+ expect(getActiveDialog()).toBe(confirmDialog);
406
618
 
407
619
  fireEvent.click(screen.getByRole('button', {name: 'Back'}));
408
620
 
@@ -415,8 +627,8 @@ describe('BottomSheetSwitcher', () => {
415
627
 
416
628
  expect(detailsSheet).not.toHaveAttribute('hidden');
417
629
  expect(confirmSheet).toHaveAttribute('hidden');
418
- expect(document.querySelectorAll('dialog[open]')).toHaveLength(1);
419
- expect(getSharedDialog()).toBe(sharedDialog);
630
+ expect(openDialogs()).toEqual([detailsDialog]);
631
+ expect(getActiveDialog()).toBe(detailsDialog);
420
632
  });
421
633
 
422
634
  it('moves a taller previous sheet down while the shorter new sheet enters, then waits for both', () => {
@@ -483,11 +695,69 @@ describe('BottomSheetSwitcher', () => {
483
695
  expect(detailsSheet).not.toHaveAttribute('inert');
484
696
  expect(confirmSheet).not.toHaveAttribute('hidden');
485
697
  expect(confirmSheet).toHaveAttribute('inert');
486
- expect(document.querySelectorAll('dialog[open]')).toHaveLength(1);
698
+ // The returning sheet re-presents above the one it replaces: a modal
699
+ // dialog beneath another modal dialog is inert in the top layer.
700
+ expect(openDialogs()).toHaveLength(2);
701
+ expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(3);
702
+ expect(getActiveDialog()).toBe(getSheetDialog('details-sheet'));
487
703
 
488
704
  finishSheetTransition(confirmSheet, 'opacity');
489
705
  expect(detailsSheet).not.toHaveAttribute('hidden');
490
706
  expect(confirmSheet).toHaveAttribute('hidden');
707
+ expect(openDialogs()).toEqual([getSheetDialog('details-sheet')]);
708
+ });
709
+
710
+ it('re-presents a returning sheet with its backdrop cleared instantly, so the handed-over scrim is never doubled', () => {
711
+ // Going back while the previous sheet is still fading re-presents its
712
+ // dialog beneath the new scrim owner. That dialog closed with its
713
+ // backdrop at 1; the clear must apply with the scrim transition pinned
714
+ // to 0s across showModal(), or the backdrop eases 1 -> 0 on top of the
715
+ // other dialog's and the page is dimmed twice for a dozen frames.
716
+ const atPresent: {label: string; duration: string; opacity: string}[] = [];
717
+ HTMLDialogElement.prototype.showModal = vi.fn(function (
718
+ this: HTMLDialogElement,
719
+ ) {
720
+ this.setAttribute('open', '');
721
+ modalDialogs.add(this);
722
+ atPresent.push({
723
+ label: this.getAttribute('aria-label') ?? '?',
724
+ duration: this.style.getPropertyValue('--_sheet-scrim-duration'),
725
+ opacity: this.style.getPropertyValue('--_sheet-scrim-opacity'),
726
+ });
727
+ });
728
+ render(<Flow />);
729
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
730
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
731
+ const detailsDialog = getSheetDialog('details-sheet');
732
+ const confirmDialog = getSheetDialog('confirm-sheet');
733
+ // Mid-handoff: Details retains the scrim, Confirm entered clear.
734
+ expect(detailsDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
735
+ expect(confirmDialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
736
+
737
+ fireEvent.click(screen.getByRole('button', {name: 'Back'}));
738
+
739
+ // Confirm, now retained, took the scrim over; Details re-presented clear
740
+ // with the transition pinned for the present, and the pin lifted after.
741
+ expect(atPresent.at(-1)).toEqual({
742
+ label: 'Details',
743
+ duration: '0s',
744
+ opacity: '0',
745
+ });
746
+ expect(detailsDialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
747
+ expect(
748
+ detailsDialog.style.getPropertyValue('--_sheet-scrim-duration'),
749
+ ).toBe('');
750
+ expect(confirmDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
751
+ expect(
752
+ confirmDialog.style.getPropertyValue('--_sheet-scrim-duration'),
753
+ ).toBe('');
754
+ // Exactly one dialog carries the scrim.
755
+ expect(
756
+ openDialogs().filter(
757
+ dialog =>
758
+ dialog.style.getPropertyValue('--_sheet-scrim-opacity') === '1',
759
+ ),
760
+ ).toEqual([confirmDialog]);
491
761
  });
492
762
 
493
763
  it('ignores late scrim updates from an outgoing sheet gesture', () => {
@@ -513,19 +783,24 @@ describe('BottomSheetSwitcher', () => {
513
783
  clientY: 600,
514
784
  timeStamp: 16,
515
785
  });
786
+ const detailsDialog = getSheetDialog('details-sheet');
516
787
  expect(
517
- getSharedDialog().style.getPropertyValue('--_sheet-scrim-opacity'),
788
+ detailsDialog.style.getPropertyValue('--_sheet-scrim-opacity'),
518
789
  ).not.toBe('1');
519
790
 
791
+ // The retained dialog owns the flow's scrim through the handoff; the
792
+ // entering dialog's backdrop stays clear above it.
520
793
  fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
521
- expect(getSharedDialog()).toHaveStyle({'--_sheet-scrim-opacity': '1'});
794
+ expect(detailsDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
795
+ expect(getActiveDialog()).toHaveStyle({'--_sheet-scrim-opacity': '0'});
522
796
 
523
797
  fireEvent.pointerMove(handle, {
524
798
  pointerId: 1,
525
799
  clientY: 680,
526
800
  timeStamp: 32,
527
801
  });
528
- expect(getSharedDialog()).toHaveStyle({'--_sheet-scrim-opacity': '1'});
802
+ expect(detailsDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
803
+ expect(getActiveDialog()).toHaveStyle({'--_sheet-scrim-opacity': '0'});
529
804
  });
530
805
 
531
806
  it('keeps an active handoff when a consumer callback ref changes', () => {
@@ -547,41 +822,42 @@ describe('BottomSheetSwitcher', () => {
547
822
  expect(confirmSheet).not.toHaveAttribute('hidden');
548
823
  });
549
824
 
550
- it('dismisses the flow from the one shared scrim', () => {
825
+ it("dismisses the flow from the active sheet's scrim", () => {
551
826
  render(<Flow />);
552
827
  fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
553
- const sharedDialog = getSharedDialog();
828
+ const activeDialog = getActiveDialog();
554
829
 
555
- fireEvent.click(sharedDialog);
830
+ fireEvent.click(activeDialog);
556
831
 
557
832
  const outgoingSheet = getSheetLayer('details-sheet');
558
833
  expect(outgoingSheet).not.toHaveAttribute('hidden');
559
834
  expect(outgoingSheet).toHaveAttribute('inert');
560
- expect(sharedDialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
561
- expect(document.body.style.position).toBe('fixed');
835
+ expect(activeDialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
836
+ // Scroll stays locked through the exit.
837
+ expect(isScrollLockEngaged()).toBe(true);
562
838
 
563
839
  finishSheetTransition(outgoingSheet, 'transform');
564
840
 
565
- expect(sharedDialog).not.toHaveAttribute('open');
566
- expect(document.body.style.position).not.toBe('fixed');
841
+ expect(activeDialog).not.toHaveAttribute('open');
842
+ expect(isScrollLockEngaged()).toBe(false);
567
843
  });
568
844
 
569
- it('releases the shared modal layer when the closing sheet unmounts immediately', () => {
845
+ it('releases the modal layer when the closing sheet unmounts immediately', () => {
570
846
  render(<ConditionalFlow />);
571
847
  fireEvent.click(
572
848
  screen.getByRole('button', {name: 'Start conditional flow'}),
573
849
  );
574
- const sharedDialog = screen.getByRole('dialog', {
850
+ const dialog = screen.getByRole('dialog', {
575
851
  name: 'Conditional details',
576
852
  });
577
853
 
578
- fireEvent.keyDown(sharedDialog, {key: 'Escape'});
854
+ fireEvent.keyDown(dialog, {key: 'Escape'});
579
855
 
580
- expect(sharedDialog).not.toHaveAttribute('open');
581
- expect(document.body.style.position).not.toBe('fixed');
856
+ expect(dialog).not.toHaveAttribute('open');
857
+ expect(isScrollLockEngaged()).toBe(false);
582
858
  });
583
859
 
584
- it('keeps the shared dialog inline and opens it modally', () => {
860
+ it('keeps a sheet dialog inline and opens it modally', () => {
585
861
  render(
586
862
  <div
587
863
  data-testid="clipping-ancestor"
@@ -603,7 +879,7 @@ describe('BottomSheetSwitcher', () => {
603
879
  expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1);
604
880
  });
605
881
 
606
- it('keeps focus in a modal sheet that has no tabbable controls', () => {
882
+ it('focuses the panel of a modal sheet with no tabbable controls and leaves Tab to the native modal dialog', () => {
607
883
  render(
608
884
  <>
609
885
  <button type="button">Background action</button>
@@ -617,14 +893,18 @@ describe('BottomSheetSwitcher', () => {
617
893
  </>,
618
894
  );
619
895
 
620
- const dialog = screen.getByRole('dialog', {name: 'Read-only details'});
896
+ const dialog = screen.getByRole<HTMLDialogElement>('dialog', {
897
+ name: 'Read-only details',
898
+ });
621
899
  const panel = getSheetPanel(dialog);
622
900
  expect(panel).toHaveFocus();
623
- expect(fireEvent.keyDown(panel, {key: 'Tab'})).toBe(false);
901
+ // Containment is the browser's: the dialog was presented with showModal(),
902
+ // which makes everything outside it inert, so no script intercepts Tab.
903
+ // BottomSheetNativeModal.a11y.browser.spec.ts proves the key in engines.
904
+ expect(modalDialogs.has(dialog)).toBe(true);
905
+ expect(dialog).toHaveAttribute('aria-modal', 'true');
906
+ expect(fireEvent.keyDown(panel, {key: 'Tab'})).toBe(true);
624
907
  expect(panel).toHaveFocus();
625
- expect(
626
- screen.getByRole('button', {name: 'Background action'}),
627
- ).not.toHaveFocus();
628
908
  });
629
909
 
630
910
  it('can coordinate a non-modal flow without rendering a scrim', () => {
@@ -639,12 +919,12 @@ describe('BottomSheetSwitcher', () => {
639
919
  </BottomSheetSwitcher>,
640
920
  );
641
921
 
642
- const dialog = getSharedDialog();
922
+ const dialog = getActiveDialog();
643
923
  expect(dialog).not.toHaveAttribute('aria-modal');
644
924
  expect(dialog).toHaveAttribute('open');
645
925
  expect(HTMLDialogElement.prototype.show).toHaveBeenCalledTimes(1);
646
926
  expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled();
647
- expect(document.body.style.position).not.toBe('fixed');
927
+ expect(isScrollLockEngaged()).toBe(false);
648
928
  });
649
929
 
650
930
  it('requests activeSheet=null when the active sheet dismisses', () => {
@@ -680,7 +960,7 @@ describe('BottomSheetSwitcher', () => {
680
960
  </BottomSheet>
681
961
  </BottomSheetSwitcher>,
682
962
  );
683
- const dialog = getSharedDialog();
963
+ const dialog = getActiveDialog();
684
964
  const panel = getSheetPanel(dialog);
685
965
  const handle = panel.firstElementChild;
686
966
  if (!(handle instanceof HTMLElement)) {
@@ -710,7 +990,7 @@ describe('BottomSheetSwitcher', () => {
710
990
 
711
991
  it('keeps the flow open when a file picker inside a step is dismissed, and still closes on its own close request', () => {
712
992
  // A file input fires a bubbling `cancel` at itself when its picker is
713
- // dismissed. A step renders no dialog, so it reaches the switcher's.
993
+ // dismissed. It bubbles to the step's own dialog, which leaves it alone.
714
994
  const onActiveSheetChange = vi.fn();
715
995
  render(
716
996
  <BottomSheetSwitcher
@@ -728,7 +1008,7 @@ describe('BottomSheetSwitcher', () => {
728
1008
  expect(onActiveSheetChange).not.toHaveBeenCalled();
729
1009
  expect(fromPicker.defaultPrevented).toBe(false);
730
1010
 
731
- fireEvent(getSharedDialog(), new Event('cancel', {cancelable: true}));
1011
+ fireEvent(getActiveDialog(), new Event('cancel', {cancelable: true}));
732
1012
 
733
1013
  expect(onActiveSheetChange).toHaveBeenCalledTimes(1);
734
1014
  expect(onActiveSheetChange).toHaveBeenCalledWith(null);
@@ -782,7 +1062,7 @@ describe('BottomSheetSwitcher', () => {
782
1062
  </BottomSheet>
783
1063
  </BottomSheetSwitcher>,
784
1064
  );
785
- const dialog = getSharedDialog();
1065
+ const dialog = getActiveDialog();
786
1066
  const panel = getSheetPanel(dialog);
787
1067
  const handle = panel.firstElementChild;
788
1068
  if (!(handle instanceof HTMLElement)) {
@@ -878,7 +1158,7 @@ describe('BottomSheetSwitcher', () => {
878
1158
  </BottomSheetSwitcher>,
879
1159
  );
880
1160
 
881
- fireEvent.keyDown(getSharedDialog(), {key: 'Escape'});
1161
+ fireEvent.keyDown(getActiveDialog(), {key: 'Escape'});
882
1162
 
883
1163
  expect(onKeyDown).toHaveBeenCalledTimes(1);
884
1164
  expect(onActiveSheetChange).toHaveBeenCalledWith(null);
@@ -901,7 +1181,7 @@ describe('BottomSheetSwitcher', () => {
901
1181
  </BottomSheetSwitcher>,
902
1182
  );
903
1183
 
904
- fireEvent.keyDown(getSharedDialog(), {key: 'Escape'});
1184
+ fireEvent.keyDown(getActiveDialog(), {key: 'Escape'});
905
1185
 
906
1186
  expect(onKeyDown).toHaveBeenCalledTimes(1);
907
1187
  expect(onActiveSheetChange).not.toHaveBeenCalled();
@@ -949,7 +1229,7 @@ describe('BottomSheetSwitcher', () => {
949
1229
  );
950
1230
 
951
1231
  const event = new Event('cancel', {cancelable: true});
952
- getSharedDialog().dispatchEvent(event);
1232
+ getActiveDialog().dispatchEvent(event);
953
1233
 
954
1234
  expect(event.defaultPrevented).toBe(true);
955
1235
  expect(onNestedDismiss).not.toHaveBeenCalled();
@@ -1005,4 +1285,726 @@ describe('BottomSheetSwitcher', () => {
1005
1285
 
1006
1286
  expect(backButton).toHaveFocus();
1007
1287
  });
1288
+
1289
+ describe('one native dialog per sheet', () => {
1290
+ it('renders a closed dialog for every sheet and presents only the selected one', () => {
1291
+ render(<Flow />);
1292
+ const detailsDialog = getSheetDialog('details-sheet');
1293
+ const confirmDialog = getSheetDialog('confirm-sheet');
1294
+ expect(detailsDialog).not.toBe(confirmDialog);
1295
+ expect(openDialogs()).toEqual([]);
1296
+
1297
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1298
+
1299
+ expect(openDialogs()).toEqual([detailsDialog]);
1300
+ expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1);
1301
+ expect(confirmDialog).not.toHaveAttribute('open');
1302
+ expect(confirmDialog).not.toHaveAttribute('aria-modal');
1303
+ expect(detailsDialog).toHaveAttribute('aria-modal', 'true');
1304
+ expect(detailsDialog).toContainElement(
1305
+ screen.getByTestId('details-sheet'),
1306
+ );
1307
+ expect(confirmDialog).toContainElement(
1308
+ screen.getByTestId('confirm-sheet'),
1309
+ );
1310
+ });
1311
+
1312
+ it('presents the entering dialog once and closes the retained dialog only after it fades', () => {
1313
+ const log = recordDialogCalls();
1314
+ render(<Flow />);
1315
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1316
+ expect(log).toEqual(['showModal:Details']);
1317
+
1318
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1319
+ expect(log).toEqual(['showModal:Details', 'showModal:Confirm']);
1320
+
1321
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1322
+ expect(log).toEqual(['showModal:Details', 'showModal:Confirm']);
1323
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1324
+ expect(log).toEqual([
1325
+ 'showModal:Details',
1326
+ 'showModal:Confirm',
1327
+ 'close:Details',
1328
+ ]);
1329
+
1330
+ fireEvent.keyDown(getActiveDialog(), {key: 'Escape'});
1331
+ expect(log).toHaveLength(3);
1332
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1333
+ expect(log.at(-1)).toBe('close:Confirm');
1334
+ expect(openDialogs()).toEqual([]);
1335
+ });
1336
+
1337
+ it('holds one page scroll lock for the whole flow: engaged continuously through a handoff, released once after the exit', () => {
1338
+ // The shared lock restores the saved offset with window.scrollTo on its
1339
+ // last release, so the call count is the release count: a flow that
1340
+ // released and re-acquired between steps would scroll the page back
1341
+ // mid-handoff.
1342
+ vi.spyOn(window, 'scrollY', 'get').mockReturnValue(300);
1343
+ render(<Flow />);
1344
+ expect(isScrollLockEngaged()).toBe(false);
1345
+
1346
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1347
+ expect(isScrollLockEngaged()).toBe(true);
1348
+ expect(document.body.style.top).toBe('-300px');
1349
+ expect(window.scrollTo).not.toHaveBeenCalled();
1350
+
1351
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1352
+ // Two modal sheet dialogs are open: still the one lock.
1353
+ expect(openDialogs()).toHaveLength(2);
1354
+ expect(isScrollLockEngaged()).toBe(true);
1355
+ expect(window.scrollTo).not.toHaveBeenCalled();
1356
+
1357
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1358
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1359
+ expect(openDialogs()).toHaveLength(1);
1360
+ expect(isScrollLockEngaged()).toBe(true);
1361
+ expect(window.scrollTo).not.toHaveBeenCalled();
1362
+
1363
+ fireEvent.click(screen.getByRole('button', {name: 'Back'}));
1364
+ finishSheetTransition(getSheetLayer('details-sheet'), 'transform');
1365
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'opacity');
1366
+ expect(isScrollLockEngaged()).toBe(true);
1367
+ expect(window.scrollTo).not.toHaveBeenCalled();
1368
+
1369
+ fireEvent.keyDown(getActiveDialog(), {key: 'Escape'});
1370
+ // Still held through the exit motion.
1371
+ expect(isScrollLockEngaged()).toBe(true);
1372
+ expect(window.scrollTo).not.toHaveBeenCalled();
1373
+ finishSheetTransition(getSheetLayer('details-sheet'), 'transform');
1374
+ expect(isScrollLockEngaged()).toBe(false);
1375
+ expect(document.body.style.top).toBe('');
1376
+ expect(window.scrollTo).toHaveBeenCalledTimes(1);
1377
+ expect(window.scrollTo).toHaveBeenCalledWith(0, 300);
1378
+ });
1379
+
1380
+ it('keeps the lock through a StrictMode replay of a flow mounted open, and releases it when the flow unmounts while presented', () => {
1381
+ vi.spyOn(window, 'scrollY', 'get').mockReturnValue(120);
1382
+ const mounted = render(
1383
+ <StrictMode>
1384
+ <HostFlow initialSheet="details" />
1385
+ </StrictMode>,
1386
+ );
1387
+ // StrictMode runs the lock's effect, its cleanup, and the effect again
1388
+ // in one commit: the pin must stand afterwards, and the one real
1389
+ // release has not happened.
1390
+ expect(isScrollLockEngaged()).toBe(true);
1391
+ expect(document.body.style.top).toBe('-120px');
1392
+ expect(window.scrollTo).toHaveBeenCalledTimes(1);
1393
+ vi.mocked(window.scrollTo).mockClear();
1394
+
1395
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1396
+ expect(openDialogs()).toHaveLength(2);
1397
+ expect(isScrollLockEngaged()).toBe(true);
1398
+ expect(window.scrollTo).not.toHaveBeenCalled();
1399
+
1400
+ // Unmounted while presented, two dialogs open: one release.
1401
+ mounted.unmount();
1402
+ expect(isScrollLockEngaged()).toBe(false);
1403
+ expect(document.body.style.top).toBe('');
1404
+ expect(window.scrollTo).toHaveBeenCalledTimes(1);
1405
+ expect(window.scrollTo).toHaveBeenCalledWith(0, 120);
1406
+ });
1407
+
1408
+ it('keeps the lock engaged under a Core Dialog opened from a step, and the Dialog paints above the step', () => {
1409
+ render(<DialogInStepFlow />);
1410
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1411
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1412
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1413
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1414
+ const confirmDialog = getSheetDialog('confirm-sheet');
1415
+
1416
+ fireEvent.click(screen.getByRole('button', {name: 'Open dialog'}));
1417
+ const dialog = screen.getByRole('dialog', {name: 'Are you sure'});
1418
+ expect(dialog).toHaveAttribute('open');
1419
+ // The Dialog was presented after the step's dialog, so the top layer
1420
+ // paints it above; the Dialog and the flow share the lock's counter,
1421
+ // so the step beneath keeps the pin when the Dialog gives its hold back.
1422
+ expect(HTMLDialogElement.prototype.showModal).toHaveBeenLastCalledWith();
1423
+ expect(
1424
+ vi.mocked(HTMLDialogElement.prototype.showModal).mock.contexts.at(-1),
1425
+ ).toBe(dialog);
1426
+ expect(confirmDialog).toHaveAttribute('open');
1427
+ expect(isScrollLockEngaged()).toBe(true);
1428
+
1429
+ expect(window.scrollTo).not.toHaveBeenCalled();
1430
+
1431
+ // Escape reaches the Dialog first, not the step beneath it.
1432
+ fireEvent.keyDown(dialog, {key: 'Escape'});
1433
+ expect(dialog).not.toHaveAttribute('open');
1434
+ expect(confirmDialog).toHaveAttribute('open');
1435
+ expect(isScrollLockEngaged()).toBe(true);
1436
+ expect(window.scrollTo).not.toHaveBeenCalled();
1437
+
1438
+ fireEvent.click(screen.getByRole('button', {name: 'Done'}));
1439
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1440
+ expect(isScrollLockEngaged()).toBe(false);
1441
+ expect(window.scrollTo).toHaveBeenCalledTimes(1);
1442
+ });
1443
+
1444
+ it("resolves the switcher ref to the host sheet's dialog and never to null while a sheet is mounted, from the first commit on", () => {
1445
+ const dialogRef = createRef<HTMLDialogElement>();
1446
+ const seenAtMount: {layout?: Element | null; passive?: Element | null} =
1447
+ {};
1448
+ const {unmount} = render(
1449
+ <HostFlow
1450
+ dialogRef={dialogRef}
1451
+ onMountLayout={() => {
1452
+ seenAtMount.layout = dialogRef.current;
1453
+ }}
1454
+ onMountPassive={() => {
1455
+ seenAtMount.passive = dialogRef.current;
1456
+ }}
1457
+ />,
1458
+ );
1459
+ // Closed, never opened: the first sheet's closed dialog, as the shared
1460
+ // dialog once was a closed element rather than null -- already in the
1461
+ // consumer's own mount effects, layout and passive alike.
1462
+ const detailsDialog = getSheetDialog('details-sheet');
1463
+ expect(seenAtMount.layout).toBe(detailsDialog);
1464
+ expect(seenAtMount.passive).toBe(detailsDialog);
1465
+ expect(dialogRef.current).toBe(detailsDialog);
1466
+ expect(dialogRef.current).not.toHaveAttribute('open');
1467
+
1468
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1469
+ expect(dialogRef.current).toBe(detailsDialog);
1470
+ expect(dialogRef.current).toHaveAttribute('open');
1471
+
1472
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1473
+ expect(dialogRef.current).toBe(getSheetDialog('confirm-sheet'));
1474
+
1475
+ fireEvent.click(screen.getByRole('button', {name: 'Done'}));
1476
+ // The exiting sheet's dialog hosts the flow through its exit motion and
1477
+ // stays the host once the flow is closed.
1478
+ expect(dialogRef.current).toBe(getSheetDialog('confirm-sheet'));
1479
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1480
+ expect(dialogRef.current).toBe(getSheetDialog('confirm-sheet'));
1481
+ expect(dialogRef.current).not.toHaveAttribute('open');
1482
+
1483
+ unmount();
1484
+ expect(dialogRef.current).toBeNull();
1485
+ });
1486
+
1487
+ it('runs a callback ref cleanup exactly once per detach and never passes it null', () => {
1488
+ const received: (HTMLDialogElement | null)[] = [];
1489
+ const cleanups: HTMLDialogElement[] = [];
1490
+ const callbackRef = (dialog: HTMLDialogElement | null) => {
1491
+ received.push(dialog);
1492
+ return () => {
1493
+ if (dialog != null) {
1494
+ cleanups.push(dialog);
1495
+ }
1496
+ };
1497
+ };
1498
+ const {unmount} = render(<HostFlow dialogRef={callbackRef} />);
1499
+ const detailsDialog = getSheetDialog('details-sheet');
1500
+ const confirmDialog = getSheetDialog('confirm-sheet');
1501
+ expect(received).toEqual([detailsDialog]);
1502
+ expect(cleanups).toEqual([]);
1503
+
1504
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1505
+ // Same host: the ref is not detached and re-attached.
1506
+ expect(received).toEqual([detailsDialog]);
1507
+
1508
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1509
+ // Host moved: the Details attachment is cleaned up once, then Confirm
1510
+ // is attached; no `null` call stands in for the cleanup.
1511
+ expect(cleanups).toEqual([detailsDialog]);
1512
+ expect(received).toEqual([detailsDialog, confirmDialog]);
1513
+
1514
+ fireEvent.click(screen.getByRole('button', {name: 'Done'}));
1515
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1516
+ expect(cleanups).toEqual([detailsDialog]);
1517
+ expect(received).toEqual([detailsDialog, confirmDialog]);
1518
+
1519
+ unmount();
1520
+ expect(cleanups).toEqual([detailsDialog, confirmDialog]);
1521
+ expect(received).toEqual([detailsDialog, confirmDialog]);
1522
+ expect(received).not.toContain(null);
1523
+ });
1524
+
1525
+ it('renders the switcher id and test id once, on the host dialog, and moves them with it', () => {
1526
+ render(
1527
+ <HostFlow
1528
+ dialogProps={{
1529
+ id: 'checkout',
1530
+ 'data-testid': 'checkout-flow',
1531
+ 'data-flow-owner': 'settings',
1532
+ }}
1533
+ />,
1534
+ );
1535
+ const dialogs = Array.from(document.querySelectorAll('dialog'));
1536
+ expect(dialogs).toHaveLength(3);
1537
+ const withId = () =>
1538
+ Array.from(document.querySelectorAll('[id="checkout"]'));
1539
+ // Closed: exactly one element carries the id, the first sheet's dialog.
1540
+ expect(withId()).toEqual([dialogs[0]]);
1541
+ expect(screen.getByTestId('checkout-flow')).toBe(dialogs[0]);
1542
+ // The remaining dialog props still reach every sheet dialog.
1543
+ for (const dialog of dialogs) {
1544
+ expect(dialog).toHaveAttribute('data-flow-owner', 'settings');
1545
+ }
1546
+
1547
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1548
+ expect(withId()).toEqual([dialogs[0]]);
1549
+ expect(document.getElementById('checkout')).toHaveAttribute('open');
1550
+
1551
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1552
+ // Mid-handoff two dialogs are open; the id names the active one only.
1553
+ expect(openDialogs()).toHaveLength(2);
1554
+ expect(withId()).toEqual([dialogs[1]]);
1555
+ expect(screen.getByTestId('checkout-flow')).toBe(dialogs[1]);
1556
+ expect(document.getElementById('checkout')).toHaveAccessibleName(
1557
+ 'Confirm',
1558
+ );
1559
+
1560
+ fireEvent.click(screen.getByRole('button', {name: 'Done'}));
1561
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1562
+ expect(withId()).toEqual([dialogs[1]]);
1563
+ });
1564
+
1565
+ it("renders the switcher id and data-testid once, on the active sheet's dialog, in the server markup of a flow that mounts open", () => {
1566
+ // The active sheet is known from props, so the host needs no commit:
1567
+ // server markup carries both attributes on that sheet's dialog, once.
1568
+ const markup = renderToString(
1569
+ <BottomSheetSwitcher
1570
+ id="checkout"
1571
+ data-testid="checkout-flow"
1572
+ activeSheet="confirm"
1573
+ onActiveSheetChange={() => {}}>
1574
+ <BottomSheet sheetId="details" label="Details">
1575
+ Content
1576
+ </BottomSheet>
1577
+ <BottomSheet sheetId="confirm" label="Confirm">
1578
+ Content
1579
+ </BottomSheet>
1580
+ </BottomSheetSwitcher>,
1581
+ );
1582
+ const dialogs = markup.match(/<dialog\b[^>]*>/g) ?? [];
1583
+ expect(dialogs).toHaveLength(2);
1584
+ expect(markup.match(/id="checkout"/g)).toHaveLength(1);
1585
+ expect(markup.match(/data-testid="checkout-flow"/g)).toHaveLength(1);
1586
+ const host = dialogs.find(tag => tag.includes('id="checkout"'));
1587
+ expect(host).toContain('aria-label="Confirm"');
1588
+ expect(host).toContain('data-testid="checkout-flow"');
1589
+ expect(
1590
+ dialogs.find(tag => tag.includes('aria-label="Details"')),
1591
+ ).not.toContain('id=');
1592
+ });
1593
+
1594
+ it('renders every sheet dialog closed in server markup, with the switcher id on no element until the first commit', () => {
1595
+ // Which sheet mounts first is known once its dialog registers during
1596
+ // the first commit; server rendering has no commit, so the id waits for
1597
+ // the client. What server markup must still get right: one closed
1598
+ // dialog per sheet, none `open`, and the id never duplicated.
1599
+ const markup = renderToString(
1600
+ <BottomSheetSwitcher
1601
+ id="checkout"
1602
+ data-testid="checkout-flow"
1603
+ activeSheet={null}
1604
+ onActiveSheetChange={() => {}}>
1605
+ <BottomSheet sheetId="details" label="Details">
1606
+ Content
1607
+ </BottomSheet>
1608
+ <BottomSheet sheetId="confirm" label="Confirm">
1609
+ Content
1610
+ </BottomSheet>
1611
+ </BottomSheetSwitcher>,
1612
+ );
1613
+ expect(markup.match(/<dialog\b/g)).toHaveLength(2);
1614
+ expect(markup).not.toMatch(/<dialog[^>]*\sopen/);
1615
+ expect(markup.match(/id="checkout"/g) ?? []).toHaveLength(0);
1616
+
1617
+ // Hydrating the same tree on the client: the first commit's synchronous
1618
+ // re-render puts the id on the first sheet's dialog, exactly once.
1619
+ const container = document.createElement('div');
1620
+ container.innerHTML = markup;
1621
+ document.body.appendChild(container);
1622
+ act(() => {
1623
+ hydrateRoot(
1624
+ container,
1625
+ <BottomSheetSwitcher
1626
+ id="checkout"
1627
+ data-testid="checkout-flow"
1628
+ activeSheet={null}
1629
+ onActiveSheetChange={() => {}}>
1630
+ <BottomSheet sheetId="details" label="Details">
1631
+ Content
1632
+ </BottomSheet>
1633
+ <BottomSheet sheetId="confirm" label="Confirm">
1634
+ Content
1635
+ </BottomSheet>
1636
+ </BottomSheetSwitcher>,
1637
+ );
1638
+ });
1639
+ const withId = container.querySelectorAll('[id="checkout"]');
1640
+ expect(withId).toHaveLength(1);
1641
+ expect(withId[0]).toBe(container.querySelector('dialog'));
1642
+ expect(withId[0]).toHaveAttribute('data-testid', 'checkout-flow');
1643
+ expect(withId[0]).toHaveAttribute('aria-label', 'Details');
1644
+ container.remove();
1645
+ });
1646
+
1647
+ it('orders a non-modal handoff inside the switcher-owned isolation surface, with no page-wide z-index on a sheet dialog', () => {
1648
+ render(<HostFlow hasScrim={false} initialSheet="details" />);
1649
+ const detailsDialog = getSheetDialog('details-sheet');
1650
+ const confirmDialog = getSheetDialog('confirm-sheet');
1651
+ const flow = detailsDialog.parentElement;
1652
+ if (flow == null) {
1653
+ throw new Error('flow wrapper not found');
1654
+ }
1655
+ expect(flow).toBe(confirmDialog.parentElement);
1656
+ // The wrapper is the isolated surface for the flow (`spec:AST-027`
1657
+ // FR2) and carries the one overlay value the shared shell carried.
1658
+ expect(getComputedStyle(flow).isolation).toBe('isolate');
1659
+ expect(getComputedStyle(flow).zIndex).toBe('1000');
1660
+
1661
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1662
+ expect(openDialogs()).toEqual([detailsDialog, confirmDialog]);
1663
+ // Inside it the dialogs use local values only: the interactive sheet
1664
+ // on top at 1, the retained sheet at auto. Neither is in a page band.
1665
+ expect(getComputedStyle(confirmDialog).zIndex).toBe('1');
1666
+ // jsdom reports an unset z-index as '' where a browser says 'auto'.
1667
+ expect(['', 'auto']).toContain(getComputedStyle(detailsDialog).zIndex);
1668
+ for (const dialog of [detailsDialog, confirmDialog]) {
1669
+ expect(Number(getComputedStyle(dialog).zIndex) >= 1000).toBe(false);
1670
+ }
1671
+ });
1672
+
1673
+ it('applies the switcher dialog props to each sheet dialog, with a consumer role kept unless a required step forces alertdialog', () => {
1674
+ const onToggle = vi.fn();
1675
+ render(
1676
+ <BottomSheetSwitcher
1677
+ activeSheet="details"
1678
+ onActiveSheetChange={() => {}}
1679
+ className="consumer-switcher"
1680
+ style={{insetInlineStart: 12}}
1681
+ role="alertdialog"
1682
+ aria-describedby="flow-help"
1683
+ data-flow-owner="settings"
1684
+ onToggle={onToggle}>
1685
+ <BottomSheet sheetId="details" label="Details" data-testid="a">
1686
+ Content
1687
+ </BottomSheet>
1688
+ <BottomSheet sheetId="confirm" label="Confirm" data-testid="b">
1689
+ Content
1690
+ </BottomSheet>
1691
+ <BottomSheet
1692
+ sheetId="danger"
1693
+ label="Danger"
1694
+ purpose="required"
1695
+ data-testid="c">
1696
+ Content
1697
+ </BottomSheet>
1698
+ </BottomSheetSwitcher>,
1699
+ );
1700
+ // Every switcher prop the shared dialog honoured reaches every sheet
1701
+ // dialog, explicit attributes included: a consumer `role` is kept
1702
+ // (the shared dialog let it through) and a `required` step still
1703
+ // forces `alertdialog`.
1704
+ for (const testId of ['a', 'b', 'c']) {
1705
+ const dialog = getSheetDialog(testId);
1706
+ expect(dialog).toHaveClass('consumer-switcher');
1707
+ expect(dialog).toHaveStyle({insetInlineStart: '12px'});
1708
+ expect(dialog).toHaveAttribute('data-flow-owner', 'settings');
1709
+ expect(dialog).toHaveAttribute('aria-describedby', 'flow-help');
1710
+ expect(dialog).toHaveAttribute('role', 'alertdialog');
1711
+ fireEvent(dialog, new Event('toggle'));
1712
+ }
1713
+ expect(onToggle).toHaveBeenCalledTimes(3);
1714
+ expect(getSheetDialog('a')).toHaveAttribute('aria-label', 'Details');
1715
+ expect(getSheetDialog('b')).toHaveAttribute('aria-label', 'Confirm');
1716
+ });
1717
+
1718
+ it('forces alertdialog on a required step and leaves the other sheet dialogs with no role when the switcher sets none', () => {
1719
+ render(
1720
+ <BottomSheetSwitcher
1721
+ activeSheet="details"
1722
+ onActiveSheetChange={() => {}}>
1723
+ <BottomSheet sheetId="details" label="Details" data-testid="a">
1724
+ Content
1725
+ </BottomSheet>
1726
+ <BottomSheet
1727
+ sheetId="danger"
1728
+ label="Danger"
1729
+ purpose="required"
1730
+ data-testid="c">
1731
+ Content
1732
+ </BottomSheet>
1733
+ </BottomSheetSwitcher>,
1734
+ );
1735
+ expect(getSheetDialog('a')).not.toHaveAttribute('role');
1736
+ expect(getSheetDialog('c')).toHaveAttribute('role', 'alertdialog');
1737
+ });
1738
+
1739
+ it('keeps the scrim takeover pin under prefers-reduced-motion: the reduced duration reads the pinned variable', () => {
1740
+ render(<HostFlow initialSheet="details" />);
1741
+ // jsdom evaluates neither @media nor ::backdrop; read the compiled
1742
+ // rules the dialog's scrim class produced. The reduced-motion rule for
1743
+ // the backdrop's transition-duration must defer to the takeover pin
1744
+ // (`--_sheet-scrim-duration: 0s` for one style flush); a literal 0.01s
1745
+ // there would win the cascade and paint a frame with no scrim.
1746
+ const rules: string[] = [];
1747
+ for (const sheet of Array.from(document.styleSheets)) {
1748
+ for (const rule of Array.from(sheet.cssRules)) {
1749
+ rules.push(rule.cssText);
1750
+ }
1751
+ }
1752
+ const reducedBackdropDurations = rules.filter(
1753
+ text =>
1754
+ text.includes('prefers-reduced-motion: reduce') &&
1755
+ text.includes('::backdrop') &&
1756
+ text.includes('transition-duration'),
1757
+ );
1758
+ expect(reducedBackdropDurations.length).toBeGreaterThan(0);
1759
+ for (const text of reducedBackdropDurations) {
1760
+ expect(text).toMatch(
1761
+ /transition-duration:\s*var\(--_sheet-scrim-duration,\s*0?\.01s\)/,
1762
+ );
1763
+ expect(text).not.toMatch(/transition-duration:\s*0?\.01s/);
1764
+ }
1765
+ });
1766
+
1767
+ it('paints one scrim through a handoff and hands it over without a fade', () => {
1768
+ render(<Flow />);
1769
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1770
+ const detailsDialog = getSheetDialog('details-sheet');
1771
+ const confirmDialog = getSheetDialog('confirm-sheet');
1772
+ expect(detailsDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
1773
+
1774
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1775
+ // The retained dialog's backdrop is the scrim; the entering dialog's
1776
+ // backdrop, painted above the retained panel, stays clear.
1777
+ expect(detailsDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
1778
+ expect(confirmDialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
1779
+
1780
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1781
+ expect(detailsDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
1782
+ expect(confirmDialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
1783
+
1784
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1785
+ expect(detailsDialog).not.toHaveAttribute('open');
1786
+ expect(confirmDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
1787
+ // The swap pins the backdrop transition for one style flush only.
1788
+ expect(
1789
+ confirmDialog.style.getPropertyValue('--_sheet-scrim-duration'),
1790
+ ).toBe('');
1791
+
1792
+ fireEvent.keyDown(confirmDialog, {key: 'Escape'});
1793
+ expect(confirmDialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
1794
+ });
1795
+
1796
+ it('moves focus into the entering sheet while the retained dialog is inert', () => {
1797
+ render(<Flow />);
1798
+ const opener = screen.getByRole('button', {name: 'Start flow'});
1799
+ opener.focus();
1800
+ fireEvent.click(opener);
1801
+ expect(getSheetPanel(getSheetDialog('details-sheet'))).toHaveFocus();
1802
+
1803
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1804
+
1805
+ expect(getSheetPanel(getSheetDialog('confirm-sheet'))).toHaveFocus();
1806
+ expect(getSheetDialog('details-sheet')).toHaveAttribute('inert');
1807
+ expect(getSheetDialog('details-sheet')).toHaveAttribute(
1808
+ 'aria-hidden',
1809
+ 'true',
1810
+ );
1811
+ expect(getSheetDialog('confirm-sheet')).not.toHaveAttribute('inert');
1812
+
1813
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1814
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1815
+ fireEvent.keyDown(getSheetDialog('confirm-sheet'), {key: 'Escape'});
1816
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1817
+
1818
+ expect(opener).toHaveFocus();
1819
+ });
1820
+
1821
+ it('moves focus only when a step is entered or the flow ends: a retained dialog closing after its fade fires no focus event', () => {
1822
+ // close() on a modal dialog returns focus to the element focused when
1823
+ // it was presented -- the Continue or Back that advanced the flow, or
1824
+ // the opener -- and the retained dialog closes a fade after the user
1825
+ // is in the next step. Each step presents with nothing recorded, so
1826
+ // the close moves nothing: no focus/blur/focusin/focusout anywhere
1827
+ // while a retained dialog closes, after Continue, after Back, and
1828
+ // after a reversal mid-handoff.
1829
+ recordDialogCalls();
1830
+ render(<Flow />);
1831
+ const events: string[] = [];
1832
+ const record = (event: Event) => {
1833
+ const target = event.target as HTMLElement;
1834
+ events.push(
1835
+ `${event.type}:${target.getAttribute('aria-label') ?? target.textContent?.trim() ?? target.tagName}`,
1836
+ );
1837
+ };
1838
+ for (const type of ['focus', 'blur', 'focusin', 'focusout']) {
1839
+ document.addEventListener(type, record, true);
1840
+ }
1841
+ const take = () => events.splice(0, events.length);
1842
+
1843
+ const opener = screen.getByRole('button', {name: 'Start flow'});
1844
+ opener.focus();
1845
+ fireEvent.click(opener);
1846
+ const detailsPanel = getSheetPanel(getSheetDialog('details-sheet'));
1847
+ const confirmPanel = getSheetPanel(getSheetDialog('confirm-sheet'));
1848
+ expect(detailsPanel).toHaveFocus();
1849
+ take();
1850
+
1851
+ // Continue: focus moves once, from the control into the entering step.
1852
+ const continueButton = screen.getByRole('button', {name: 'Continue'});
1853
+ continueButton.focus();
1854
+ fireEvent.click(continueButton);
1855
+ expect(confirmPanel).toHaveFocus();
1856
+ take();
1857
+ // The retained Details dialog closes after its fade: nothing moves.
1858
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1859
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1860
+ expect(getSheetDialog('details-sheet')).not.toHaveAttribute('open');
1861
+ expect(take()).toEqual([]);
1862
+ expect(confirmPanel).toHaveFocus();
1863
+
1864
+ // Back: the same, with the retained Confirm dialog closing.
1865
+ const backButton = screen.getByRole('button', {name: 'Back'});
1866
+ backButton.focus();
1867
+ fireEvent.click(backButton);
1868
+ expect(detailsPanel).toHaveFocus();
1869
+ take();
1870
+ finishSheetTransition(getSheetLayer('details-sheet'), 'transform');
1871
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'opacity');
1872
+ expect(getSheetDialog('confirm-sheet')).not.toHaveAttribute('open');
1873
+ expect(take()).toEqual([]);
1874
+ expect(detailsPanel).toHaveFocus();
1875
+
1876
+ // Reversal mid-handoff: Continue, then Back before Confirm settles.
1877
+ continueButton.focus();
1878
+ fireEvent.click(continueButton);
1879
+ expect(confirmPanel).toHaveFocus();
1880
+ backButton.focus();
1881
+ fireEvent.click(backButton);
1882
+ expect(detailsPanel).toHaveFocus();
1883
+ take();
1884
+ finishSheetTransition(getSheetLayer('details-sheet'), 'transform');
1885
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'opacity');
1886
+ expect(getSheetDialog('confirm-sheet')).not.toHaveAttribute('open');
1887
+ expect(take()).toEqual([]);
1888
+ expect(detailsPanel).toHaveFocus();
1889
+
1890
+ // Ending the flow: the switcher returns focus to the opener itself.
1891
+ fireEvent.keyDown(getSheetDialog('details-sheet'), {key: 'Escape'});
1892
+ finishSheetTransition(getSheetLayer('details-sheet'), 'transform');
1893
+ expect(opener).toHaveFocus();
1894
+ for (const type of ['focus', 'blur', 'focusin', 'focusout']) {
1895
+ document.removeEventListener(type, record, true);
1896
+ }
1897
+ });
1898
+
1899
+ it("answers a native cancel by the active sheet's purpose after a handoff", () => {
1900
+ const log = recordDialogCalls();
1901
+ render(<RequiredConfirmFlow />);
1902
+ expect(screen.queryByRole('alertdialog')).toBeNull();
1903
+
1904
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1905
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1906
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1907
+
1908
+ const confirmDialog = getSheetDialog('confirm-sheet');
1909
+ expect(confirmDialog).toHaveAttribute('role', 'alertdialog');
1910
+ expect(getSheetDialog('details-sheet')).not.toHaveAttribute('role');
1911
+
1912
+ fireEvent.keyDown(confirmDialog, {key: 'Escape'});
1913
+ const cancel = new Event('cancel', {cancelable: true});
1914
+ confirmDialog.dispatchEvent(cancel);
1915
+ fireEvent.click(confirmDialog);
1916
+
1917
+ // Always prevented: the browser never closes a controlled dialog.
1918
+ expect(cancel.defaultPrevented).toBe(true);
1919
+ expect(confirmDialog).toHaveAttribute('open');
1920
+ expect(log.filter(entry => entry.startsWith('close:'))).toEqual([
1921
+ 'close:Details',
1922
+ ]);
1923
+
1924
+ fireEvent.click(screen.getByRole('button', {name: 'Done'}));
1925
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1926
+ expect(openDialogs()).toEqual([]);
1927
+ });
1928
+
1929
+ it("dismisses an info flow from a cancel on any sheet dialog, and from a backdrop click on the active sheet's dialog only", () => {
1930
+ const onActiveSheetChange = vi.fn();
1931
+ render(
1932
+ <BottomSheetSwitcher
1933
+ activeSheet="confirm"
1934
+ onActiveSheetChange={onActiveSheetChange}>
1935
+ <BottomSheet sheetId="details" label="Details" data-testid="a">
1936
+ Content
1937
+ </BottomSheet>
1938
+ <BottomSheet sheetId="confirm" label="Confirm" data-testid="b">
1939
+ <button type="button">Inside</button>
1940
+ </BottomSheet>
1941
+ </BottomSheetSwitcher>,
1942
+ );
1943
+ const activeDialog = getSheetDialog('b');
1944
+
1945
+ const cancel = new Event('cancel', {cancelable: true});
1946
+ activeDialog.dispatchEvent(cancel);
1947
+ expect(cancel.defaultPrevented).toBe(true);
1948
+ expect(onActiveSheetChange).toHaveBeenNthCalledWith(1, null);
1949
+
1950
+ // A `::backdrop` click targets the dialog element itself.
1951
+ fireEvent.click(activeDialog);
1952
+ expect(onActiveSheetChange).toHaveBeenNthCalledWith(2, null);
1953
+
1954
+ // A click that is not on a backdrop, or on a dialog other than the
1955
+ // host's, dismisses nothing.
1956
+ fireEvent.click(screen.getByRole('button', {name: 'Inside'}));
1957
+ fireEvent.click(getSheetDialog('a'));
1958
+ expect(onActiveSheetChange).toHaveBeenCalledTimes(2);
1959
+
1960
+ // The composed cancel handler is on every sheet dialog, not the host
1961
+ // only: the browser raises `cancel` on the dialog it presents, so in
1962
+ // engines this is the active sheet's, but the switcher does not guard
1963
+ // it by host.
1964
+ const cancelElsewhere = new Event('cancel', {cancelable: true});
1965
+ getSheetDialog('a').dispatchEvent(cancelElsewhere);
1966
+ expect(cancelElsewhere.defaultPrevented).toBe(true);
1967
+ expect(onActiveSheetChange).toHaveBeenNthCalledWith(3, null);
1968
+ });
1969
+
1970
+ it('presents a non-modal flow with show() on each sheet dialog', () => {
1971
+ const log = recordDialogCalls();
1972
+ render(<HostFlow hasScrim={false} initialSheet="details" />);
1973
+ expect(log).toEqual(['show:Details']);
1974
+
1975
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1976
+
1977
+ expect(log).toEqual(['show:Details', 'show:Confirm']);
1978
+ expect(openDialogs()).toHaveLength(2);
1979
+ expect(getSheetDialog('confirm-sheet')).not.toHaveAttribute('aria-modal');
1980
+ expect(isScrollLockEngaged()).toBe(false);
1981
+
1982
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1983
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1984
+ expect(log).toEqual(['show:Details', 'show:Confirm', 'close:Details']);
1985
+ });
1986
+
1987
+ it('re-presents a sheet that is still fading when the flow returns to it', () => {
1988
+ const log = recordDialogCalls();
1989
+ render(<Flow />);
1990
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1991
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1992
+ fireEvent.click(screen.getByRole('button', {name: 'Back'}));
1993
+
1994
+ // Details was still open beneath Confirm in the top layer; it closes and
1995
+ // presents again so the interactive sheet is the topmost modal layer.
1996
+ expect(log).toEqual([
1997
+ 'showModal:Details',
1998
+ 'showModal:Confirm',
1999
+ 'close:Details',
2000
+ 'showModal:Details',
2001
+ ]);
2002
+ expect(getActiveDialog()).toBe(getSheetDialog('details-sheet'));
2003
+ expect(getSheetDialog('confirm-sheet')).toHaveAttribute('inert');
2004
+
2005
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'opacity');
2006
+ expect(log.at(-1)).toBe('close:Confirm');
2007
+ expect(openDialogs()).toEqual([getSheetDialog('details-sheet')]);
2008
+ });
2009
+ });
1008
2010
  });