@astryxdesign/core 0.7.3 → 0.7.4-canary.b25f95e

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 (100) 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/useTriggerMenu.d.ts.map +1 -1
  13. package/dist/Chat/useTriggerMenu.js +46 -7
  14. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  15. package/dist/ComplexSelector/ComplexSelector.js +6 -1
  16. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  17. package/dist/ContextMenu/ContextMenu.js +12 -0
  18. package/dist/DateInput/DateInput.d.ts.map +1 -1
  19. package/dist/DateInput/DateInput.js +6 -1
  20. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  21. package/dist/DateInput/NativeDateField.js +6 -1
  22. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  23. package/dist/DateInput/TouchDateField.js +9 -1
  24. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  25. package/dist/DateRangeInput/DateRangeInput.js +6 -1
  26. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  27. package/dist/MultiSelector/MultiSelector.js +6 -1
  28. package/dist/Selector/Selector.d.ts.map +1 -1
  29. package/dist/Selector/Selector.js +6 -1
  30. package/dist/TextArea/TextArea.d.ts.map +1 -1
  31. package/dist/TextArea/TextArea.js +6 -1
  32. package/dist/TextInput/TextInput.d.ts.map +1 -1
  33. package/dist/TextInput/TextInput.js +6 -1
  34. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  35. package/dist/Tokenizer/Tokenizer.js +9 -1
  36. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  37. package/dist/Typeahead/BaseTypeahead.js +7 -1
  38. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  39. package/dist/Typeahead/Typeahead.js +9 -1
  40. package/dist/astryx.css +2 -0
  41. package/dist/hooks/useBusyAnnouncement.d.ts +22 -0
  42. package/dist/hooks/useBusyAnnouncement.d.ts.map +1 -0
  43. package/dist/hooks/useBusyAnnouncement.js +87 -0
  44. package/dist/hooks/useMenuPress.d.ts +2 -2
  45. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  46. package/dist/hooks/useMenuPress.js +5 -1
  47. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  48. package/dist/i18n/generated-locales/en.generated.js +1 -0
  49. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  50. package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
  51. package/locales/en.json +6 -2
  52. package/locales/pseudo.json +3 -0
  53. package/package.json +4 -4
  54. package/src/BottomSheet/BottomSheet.doc.mjs +2 -2
  55. package/src/BottomSheet/BottomSheet.spec.md +2 -5
  56. package/src/BottomSheet/BottomSheet.tsx +260 -47
  57. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -2
  58. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +11 -10
  59. package/src/BottomSheet/BottomSheetSwitcher.spec.md +28 -35
  60. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +830 -48
  61. package/src/BottomSheet/BottomSheetSwitcher.tsx +396 -215
  62. package/src/BottomSheet/BottomSheetSwitcherContext.ts +57 -2
  63. package/src/Carousel/Carousel.test.tsx +8 -12
  64. package/src/Carousel/Carousel.tsx +6 -13
  65. package/src/Chat/ChatComposerInput.test.tsx +167 -1
  66. package/src/Chat/useTriggerMenu.tsx +55 -6
  67. package/src/ComplexSelector/ComplexSelector.tsx +5 -1
  68. package/src/ContextMenu/ContextMenu.test.tsx +34 -0
  69. package/src/ContextMenu/ContextMenu.tsx +13 -1
  70. package/src/DateInput/DateInput.tsx +5 -1
  71. package/src/DateInput/DateInputTouch.test.tsx +45 -0
  72. package/src/DateInput/NativeDateField.test.tsx +10 -2
  73. package/src/DateInput/NativeDateField.tsx +5 -1
  74. package/src/DateInput/TouchDateField.tsx +14 -2
  75. package/src/DateRangeInput/DateRangeInput.tsx +5 -1
  76. package/src/MultiSelector/MultiSelector.test.tsx +18 -4
  77. package/src/MultiSelector/MultiSelector.tsx +5 -1
  78. package/src/ProgressBar/ProgressBar.test.tsx +4 -21
  79. package/src/ProgressBar/__tests__/ProgressBar.a11y.chromium.spec.ts +114 -0
  80. package/src/ProgressBar/__tests__/ProgressBar.a11y.known-failures.ts +12 -0
  81. package/src/ProgressBar/__tests__/ProgressBar.a11y.renders.tsx +38 -0
  82. package/src/ProgressBar/__tests__/ProgressBar.a11y.states.ts +123 -0
  83. package/src/ProgressBar/__tests__/ProgressBar.a11y.test.tsx +107 -0
  84. package/src/Selector/Selector.test.tsx +22 -5
  85. package/src/Selector/Selector.tsx +5 -1
  86. package/src/TextArea/TextArea.test.tsx +9 -1
  87. package/src/TextArea/TextArea.tsx +5 -1
  88. package/src/TextInput/TextInput.tsx +5 -1
  89. package/src/Tokenizer/Tokenizer.test.tsx +17 -12
  90. package/src/Tokenizer/Tokenizer.tsx +10 -1
  91. package/src/Typeahead/BaseTypeahead.tsx +14 -1
  92. package/src/Typeahead/Typeahead.test.tsx +32 -41
  93. package/src/Typeahead/Typeahead.tsx +10 -1
  94. package/src/__tests__/BusyOwnerAnnouncement.test.tsx +369 -0
  95. package/src/hooks/__tests__/MenuPressShadowRoot.a11y.chromium.spec.ts +50 -0
  96. package/src/hooks/useBusyAnnouncement.ts +100 -0
  97. package/src/hooks/useMenuPress.test.tsx +27 -1
  98. package/src/hooks/useMenuPress.ts +12 -3
  99. package/src/i18n/generated-locales/en.generated.ts +1 -0
  100. package/src/i18n/generated-locales/pseudo.generated.ts +1 -0
@@ -12,7 +12,10 @@
12
12
 
13
13
  import {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';
@@ -22,17 +25,23 @@ import {useLayerDismissal} from '../Layer/useLayerDismissal';
22
25
  import {BottomSheet} from './BottomSheet';
23
26
  import {BottomSheetSwitcher} from './BottomSheetSwitcher';
24
27
 
28
+ // jsdom implements no <dialog> presentation. The stubs record which open
29
+ // dialogs are modal, standing in for the `:modal` pseudo-class jsdom cannot
30
+ // match.
31
+ const modalDialogs = new WeakSet<HTMLDialogElement>();
25
32
  beforeEach(() => {
26
33
  HTMLDialogElement.prototype.showModal = vi.fn(function (
27
34
  this: HTMLDialogElement,
28
35
  ) {
29
36
  this.setAttribute('open', '');
37
+ modalDialogs.add(this);
30
38
  });
31
39
  HTMLDialogElement.prototype.show = vi.fn(function (this: HTMLDialogElement) {
32
40
  this.setAttribute('open', '');
33
41
  });
34
42
  HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
35
43
  this.removeAttribute('open');
44
+ modalDialogs.delete(this);
36
45
  });
37
46
  window.scrollTo = vi.fn();
38
47
  vi.stubGlobal(
@@ -224,10 +233,156 @@ function ModeSwitchFlow() {
224
233
  );
225
234
  }
226
235
 
227
- function getSharedDialog(): HTMLDialogElement {
236
+ function RequiredConfirmFlow() {
237
+ const [activeSheet, setActiveSheet] = useState<string | null>('details');
238
+ return (
239
+ <BottomSheetSwitcher
240
+ activeSheet={activeSheet}
241
+ onActiveSheetChange={setActiveSheet}>
242
+ <BottomSheet
243
+ sheetId="details"
244
+ label="Details"
245
+ data-testid="details-sheet">
246
+ <button type="button" onClick={() => setActiveSheet('confirm')}>
247
+ Continue
248
+ </button>
249
+ </BottomSheet>
250
+ <BottomSheet
251
+ sheetId="confirm"
252
+ label="Confirm"
253
+ purpose="required"
254
+ data-testid="confirm-sheet">
255
+ <button type="button" onClick={() => setActiveSheet(null)}>
256
+ Done
257
+ </button>
258
+ </BottomSheet>
259
+ </BottomSheetSwitcher>
260
+ );
261
+ }
262
+
263
+ function HostFlow({
264
+ dialogRef,
265
+ dialogProps,
266
+ hasScrim = true,
267
+ initialSheet = null,
268
+ onMountLayout,
269
+ onMountPassive,
270
+ }: {
271
+ dialogRef?: React.Ref<HTMLDialogElement>;
272
+ dialogProps?: Record<string, string>;
273
+ hasScrim?: boolean;
274
+ initialSheet?: string | null;
275
+ onMountLayout?: () => void;
276
+ onMountPassive?: () => void;
277
+ }) {
278
+ const [activeSheet, setActiveSheet] = useState<string | null>(initialSheet);
279
+ useLayoutEffect(() => onMountLayout?.(), [onMountLayout]);
280
+ useEffect(() => onMountPassive?.(), [onMountPassive]);
281
+ return (
282
+ <>
283
+ <button type="button" onClick={() => setActiveSheet('details')}>
284
+ Start flow
285
+ </button>
286
+ <BottomSheetSwitcher
287
+ ref={dialogRef}
288
+ {...dialogProps}
289
+ activeSheet={activeSheet}
290
+ onActiveSheetChange={setActiveSheet}
291
+ hasScrim={hasScrim}>
292
+ <BottomSheet
293
+ sheetId="details"
294
+ label="Details"
295
+ data-testid="details-sheet">
296
+ <button type="button" onClick={() => setActiveSheet('confirm')}>
297
+ Continue
298
+ </button>
299
+ </BottomSheet>
300
+ <BottomSheet
301
+ sheetId="confirm"
302
+ label="Confirm"
303
+ data-testid="confirm-sheet">
304
+ <button type="button" onClick={() => setActiveSheet(null)}>
305
+ Done
306
+ </button>
307
+ </BottomSheet>
308
+ <BottomSheet sheetId="extra" label="Extra">
309
+ Content
310
+ </BottomSheet>
311
+ </BottomSheetSwitcher>
312
+ </>
313
+ );
314
+ }
315
+
316
+ /**
317
+ * The dialog hosting the active sheet. A retained sheet's dialog is inert and
318
+ * `aria-hidden`, so only the active sheet's dialog is exposed by role.
319
+ */
320
+ function getActiveDialog(): HTMLDialogElement {
228
321
  return screen.getByRole<HTMLDialogElement>('dialog');
229
322
  }
230
323
 
324
+ function getSheetDialog(testId: string): HTMLDialogElement {
325
+ const dialog = screen.getByTestId(testId).closest('dialog');
326
+ if (dialog == null) {
327
+ throw new Error('sheet dialog not found');
328
+ }
329
+ return dialog;
330
+ }
331
+
332
+ function openDialogs(): HTMLDialogElement[] {
333
+ return Array.from(
334
+ document.querySelectorAll<HTMLDialogElement>('dialog[open]'),
335
+ );
336
+ }
337
+
338
+ /**
339
+ * Whether the shared page-scroll lock (useScrollLock, as Dialog uses) is
340
+ * engaged: it pins <body>. The browser spec measures the lock's effect on
341
+ * scrolling; this reads its pin.
342
+ */
343
+ function isScrollLockEngaged(): boolean {
344
+ return document.body.style.position === 'fixed';
345
+ }
346
+
347
+ /**
348
+ * Records every native dialog call with the dialog's accessible label, and
349
+ * returns focus the way Chromium and WebKit do: close() on a dialog presented
350
+ * with showModal() focuses the element that was focused when it was
351
+ * presented, wherever focus is by then (measured: both engines return it
352
+ * even when focus sits in another open dialog).
353
+ */
354
+ function recordDialogCalls(): string[] {
355
+ const log: string[] = [];
356
+ const focusedAtPresent = new WeakMap<HTMLDialogElement, Element | null>();
357
+ const record = (name: string) =>
358
+ vi.fn(function (this: HTMLDialogElement) {
359
+ log.push(`${name}:${this.getAttribute('aria-label') ?? '?'}`);
360
+ if (name === 'close') {
361
+ this.removeAttribute('open');
362
+ const wasModal = modalDialogs.delete(this);
363
+ const previouslyFocused = focusedAtPresent.get(this);
364
+ focusedAtPresent.delete(this);
365
+ if (
366
+ wasModal &&
367
+ previouslyFocused instanceof HTMLElement &&
368
+ previouslyFocused.isConnected
369
+ ) {
370
+ previouslyFocused.focus();
371
+ }
372
+ } else {
373
+ if (name === 'showModal') {
374
+ focusedAtPresent.set(this, document.activeElement);
375
+ modalDialogs.add(this);
376
+ }
377
+ this.setAttribute('open', '');
378
+ }
379
+ });
380
+ HTMLDialogElement.prototype.showModal = record('showModal');
381
+ HTMLDialogElement.prototype.show = record('show');
382
+ HTMLDialogElement.prototype.close = record('close');
383
+ return log;
384
+ }
385
+
231
386
  function finishSheetTransition(
232
387
  element: HTMLElement,
233
388
  propertyName: 'transform' | 'opacity',
@@ -288,11 +443,11 @@ describe('BottomSheetSwitcher', () => {
288
443
  expect(document.querySelectorAll('dialog[open]')).toHaveLength(1);
289
444
  expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1);
290
445
  expect(HTMLDialogElement.prototype.show).not.toHaveBeenCalled();
291
- expect(getSharedDialog()).toHaveAttribute('aria-modal', 'true');
292
- expect(getSharedDialog()).toHaveAccessibleName('Details');
446
+ expect(getActiveDialog()).toHaveAttribute('aria-modal', 'true');
447
+ expect(getActiveDialog()).toHaveAccessibleName('Details');
293
448
  });
294
449
 
295
- it('forwards sheet DOM props and refs to its panel in the shared dialog', () => {
450
+ it('forwards sheet DOM props and refs to its panel in its dialog', () => {
296
451
  const panelRef = createRef<HTMLDivElement>();
297
452
 
298
453
  render(
@@ -312,10 +467,10 @@ describe('BottomSheetSwitcher', () => {
312
467
  expect(panelRef.current).toBe(panel);
313
468
  expect(panel).toHaveClass('astryx-bottom-sheet');
314
469
  expect(panel).toHaveAttribute('data-sheet-owner', 'settings');
315
- expect(getSharedDialog()).toContainElement(panel);
470
+ expect(getActiveDialog()).toContainElement(panel);
316
471
  });
317
472
 
318
- it('forwards switcher DOM props, events, styles, and refs to the shared dialog', () => {
473
+ it("forwards switcher DOM props, events, styles, and refs to the active sheet's dialog", () => {
319
474
  const dialogRef = createRef<HTMLDialogElement>();
320
475
  const onClick = vi.fn();
321
476
  const onActiveSheetChange = vi.fn();
@@ -336,7 +491,7 @@ describe('BottomSheetSwitcher', () => {
336
491
  </BottomSheetSwitcher>,
337
492
  );
338
493
 
339
- const dialog = getSharedDialog();
494
+ const dialog = getActiveDialog();
340
495
  expect(dialogRef.current).toBe(dialog);
341
496
  expect(dialog).toHaveAccessibleName('Notification setup');
342
497
  expect(dialog).toHaveAttribute('data-flow-owner', 'settings');
@@ -366,14 +521,16 @@ describe('BottomSheetSwitcher', () => {
366
521
  finishSheetTransition(nestedDialog, 'transform');
367
522
 
368
523
  expect(nestedDialog).not.toHaveAttribute('open');
369
- expect(getSharedDialog()).toHaveAttribute('open');
524
+ expect(getActiveDialog()).toHaveAttribute('open');
370
525
  expect(opener).toHaveFocus();
371
526
  });
372
527
 
373
528
  it('keeps the previous sheet stationary until the new entrance finishes, then fades it', () => {
374
529
  render(<Flow />);
375
530
  fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
376
- const sharedDialog = getSharedDialog();
531
+ const detailsDialog = getSheetDialog('details-sheet');
532
+ const confirmDialog = getSheetDialog('confirm-sheet');
533
+ expect(getActiveDialog()).toBe(detailsDialog);
377
534
  const detailsSheet = getSheetLayer('details-sheet');
378
535
  const confirmSheet = getSheetLayer('confirm-sheet');
379
536
 
@@ -384,9 +541,11 @@ describe('BottomSheetSwitcher', () => {
384
541
  expect(detailsSheet).toHaveAttribute('aria-hidden', 'true');
385
542
  expect(confirmSheet).not.toHaveAttribute('hidden');
386
543
  expect(confirmSheet).not.toHaveAttribute('inert');
387
- expect(document.querySelectorAll('dialog[open]')).toHaveLength(1);
388
- expect(getSharedDialog()).toBe(sharedDialog);
389
- expect(sharedDialog).toHaveAccessibleName('Confirm');
544
+ // Both dialogs stay open through the handoff; the retained one is inert.
545
+ expect(openDialogs()).toEqual([detailsDialog, confirmDialog]);
546
+ expect(detailsDialog).toHaveAttribute('inert');
547
+ expect(getActiveDialog()).toBe(confirmDialog);
548
+ expect(confirmDialog).toHaveAccessibleName('Confirm');
390
549
 
391
550
  // The previous sheet is covered, not exiting: neither transform nor
392
551
  // opacity completion may release it before the new entrance completes.
@@ -401,8 +560,8 @@ describe('BottomSheetSwitcher', () => {
401
560
 
402
561
  expect(detailsSheet).toHaveAttribute('hidden');
403
562
  expect(confirmSheet).not.toHaveAttribute('hidden');
404
- expect(document.querySelectorAll('dialog[open]')).toHaveLength(1);
405
- expect(getSharedDialog()).toBe(sharedDialog);
563
+ expect(openDialogs()).toEqual([confirmDialog]);
564
+ expect(getActiveDialog()).toBe(confirmDialog);
406
565
 
407
566
  fireEvent.click(screen.getByRole('button', {name: 'Back'}));
408
567
 
@@ -415,8 +574,8 @@ describe('BottomSheetSwitcher', () => {
415
574
 
416
575
  expect(detailsSheet).not.toHaveAttribute('hidden');
417
576
  expect(confirmSheet).toHaveAttribute('hidden');
418
- expect(document.querySelectorAll('dialog[open]')).toHaveLength(1);
419
- expect(getSharedDialog()).toBe(sharedDialog);
577
+ expect(openDialogs()).toEqual([detailsDialog]);
578
+ expect(getActiveDialog()).toBe(detailsDialog);
420
579
  });
421
580
 
422
581
  it('moves a taller previous sheet down while the shorter new sheet enters, then waits for both', () => {
@@ -483,11 +642,69 @@ describe('BottomSheetSwitcher', () => {
483
642
  expect(detailsSheet).not.toHaveAttribute('inert');
484
643
  expect(confirmSheet).not.toHaveAttribute('hidden');
485
644
  expect(confirmSheet).toHaveAttribute('inert');
486
- expect(document.querySelectorAll('dialog[open]')).toHaveLength(1);
645
+ // The returning sheet re-presents above the one it replaces: a modal
646
+ // dialog beneath another modal dialog is inert in the top layer.
647
+ expect(openDialogs()).toHaveLength(2);
648
+ expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(3);
649
+ expect(getActiveDialog()).toBe(getSheetDialog('details-sheet'));
487
650
 
488
651
  finishSheetTransition(confirmSheet, 'opacity');
489
652
  expect(detailsSheet).not.toHaveAttribute('hidden');
490
653
  expect(confirmSheet).toHaveAttribute('hidden');
654
+ expect(openDialogs()).toEqual([getSheetDialog('details-sheet')]);
655
+ });
656
+
657
+ it('re-presents a returning sheet with its backdrop cleared instantly, so the handed-over scrim is never doubled', () => {
658
+ // Going back while the previous sheet is still fading re-presents its
659
+ // dialog beneath the new scrim owner. That dialog closed with its
660
+ // backdrop at 1; the clear must apply with the scrim transition pinned
661
+ // to 0s across showModal(), or the backdrop eases 1 -> 0 on top of the
662
+ // other dialog's and the page is dimmed twice for a dozen frames.
663
+ const atPresent: {label: string; duration: string; opacity: string}[] = [];
664
+ HTMLDialogElement.prototype.showModal = vi.fn(function (
665
+ this: HTMLDialogElement,
666
+ ) {
667
+ this.setAttribute('open', '');
668
+ modalDialogs.add(this);
669
+ atPresent.push({
670
+ label: this.getAttribute('aria-label') ?? '?',
671
+ duration: this.style.getPropertyValue('--_sheet-scrim-duration'),
672
+ opacity: this.style.getPropertyValue('--_sheet-scrim-opacity'),
673
+ });
674
+ });
675
+ render(<Flow />);
676
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
677
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
678
+ const detailsDialog = getSheetDialog('details-sheet');
679
+ const confirmDialog = getSheetDialog('confirm-sheet');
680
+ // Mid-handoff: Details retains the scrim, Confirm entered clear.
681
+ expect(detailsDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
682
+ expect(confirmDialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
683
+
684
+ fireEvent.click(screen.getByRole('button', {name: 'Back'}));
685
+
686
+ // Confirm, now retained, took the scrim over; Details re-presented clear
687
+ // with the transition pinned for the present, and the pin lifted after.
688
+ expect(atPresent.at(-1)).toEqual({
689
+ label: 'Details',
690
+ duration: '0s',
691
+ opacity: '0',
692
+ });
693
+ expect(detailsDialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
694
+ expect(
695
+ detailsDialog.style.getPropertyValue('--_sheet-scrim-duration'),
696
+ ).toBe('');
697
+ expect(confirmDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
698
+ expect(
699
+ confirmDialog.style.getPropertyValue('--_sheet-scrim-duration'),
700
+ ).toBe('');
701
+ // Exactly one dialog carries the scrim.
702
+ expect(
703
+ openDialogs().filter(
704
+ dialog =>
705
+ dialog.style.getPropertyValue('--_sheet-scrim-opacity') === '1',
706
+ ),
707
+ ).toEqual([confirmDialog]);
491
708
  });
492
709
 
493
710
  it('ignores late scrim updates from an outgoing sheet gesture', () => {
@@ -513,19 +730,24 @@ describe('BottomSheetSwitcher', () => {
513
730
  clientY: 600,
514
731
  timeStamp: 16,
515
732
  });
733
+ const detailsDialog = getSheetDialog('details-sheet');
516
734
  expect(
517
- getSharedDialog().style.getPropertyValue('--_sheet-scrim-opacity'),
735
+ detailsDialog.style.getPropertyValue('--_sheet-scrim-opacity'),
518
736
  ).not.toBe('1');
519
737
 
738
+ // The retained dialog owns the flow's scrim through the handoff; the
739
+ // entering dialog's backdrop stays clear above it.
520
740
  fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
521
- expect(getSharedDialog()).toHaveStyle({'--_sheet-scrim-opacity': '1'});
741
+ expect(detailsDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
742
+ expect(getActiveDialog()).toHaveStyle({'--_sheet-scrim-opacity': '0'});
522
743
 
523
744
  fireEvent.pointerMove(handle, {
524
745
  pointerId: 1,
525
746
  clientY: 680,
526
747
  timeStamp: 32,
527
748
  });
528
- expect(getSharedDialog()).toHaveStyle({'--_sheet-scrim-opacity': '1'});
749
+ expect(detailsDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
750
+ expect(getActiveDialog()).toHaveStyle({'--_sheet-scrim-opacity': '0'});
529
751
  });
530
752
 
531
753
  it('keeps an active handoff when a consumer callback ref changes', () => {
@@ -547,41 +769,42 @@ describe('BottomSheetSwitcher', () => {
547
769
  expect(confirmSheet).not.toHaveAttribute('hidden');
548
770
  });
549
771
 
550
- it('dismisses the flow from the one shared scrim', () => {
772
+ it("dismisses the flow from the active sheet's scrim", () => {
551
773
  render(<Flow />);
552
774
  fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
553
- const sharedDialog = getSharedDialog();
775
+ const activeDialog = getActiveDialog();
554
776
 
555
- fireEvent.click(sharedDialog);
777
+ fireEvent.click(activeDialog);
556
778
 
557
779
  const outgoingSheet = getSheetLayer('details-sheet');
558
780
  expect(outgoingSheet).not.toHaveAttribute('hidden');
559
781
  expect(outgoingSheet).toHaveAttribute('inert');
560
- expect(sharedDialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
561
- expect(document.body.style.position).toBe('fixed');
782
+ expect(activeDialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
783
+ // Scroll stays locked through the exit.
784
+ expect(isScrollLockEngaged()).toBe(true);
562
785
 
563
786
  finishSheetTransition(outgoingSheet, 'transform');
564
787
 
565
- expect(sharedDialog).not.toHaveAttribute('open');
566
- expect(document.body.style.position).not.toBe('fixed');
788
+ expect(activeDialog).not.toHaveAttribute('open');
789
+ expect(isScrollLockEngaged()).toBe(false);
567
790
  });
568
791
 
569
- it('releases the shared modal layer when the closing sheet unmounts immediately', () => {
792
+ it('releases the modal layer when the closing sheet unmounts immediately', () => {
570
793
  render(<ConditionalFlow />);
571
794
  fireEvent.click(
572
795
  screen.getByRole('button', {name: 'Start conditional flow'}),
573
796
  );
574
- const sharedDialog = screen.getByRole('dialog', {
797
+ const dialog = screen.getByRole('dialog', {
575
798
  name: 'Conditional details',
576
799
  });
577
800
 
578
- fireEvent.keyDown(sharedDialog, {key: 'Escape'});
801
+ fireEvent.keyDown(dialog, {key: 'Escape'});
579
802
 
580
- expect(sharedDialog).not.toHaveAttribute('open');
581
- expect(document.body.style.position).not.toBe('fixed');
803
+ expect(dialog).not.toHaveAttribute('open');
804
+ expect(isScrollLockEngaged()).toBe(false);
582
805
  });
583
806
 
584
- it('keeps the shared dialog inline and opens it modally', () => {
807
+ it('keeps a sheet dialog inline and opens it modally', () => {
585
808
  render(
586
809
  <div
587
810
  data-testid="clipping-ancestor"
@@ -603,7 +826,7 @@ describe('BottomSheetSwitcher', () => {
603
826
  expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1);
604
827
  });
605
828
 
606
- it('keeps focus in a modal sheet that has no tabbable controls', () => {
829
+ it('focuses the panel of a modal sheet with no tabbable controls and leaves Tab to the native modal dialog', () => {
607
830
  render(
608
831
  <>
609
832
  <button type="button">Background action</button>
@@ -617,14 +840,18 @@ describe('BottomSheetSwitcher', () => {
617
840
  </>,
618
841
  );
619
842
 
620
- const dialog = screen.getByRole('dialog', {name: 'Read-only details'});
843
+ const dialog = screen.getByRole<HTMLDialogElement>('dialog', {
844
+ name: 'Read-only details',
845
+ });
621
846
  const panel = getSheetPanel(dialog);
622
847
  expect(panel).toHaveFocus();
623
- expect(fireEvent.keyDown(panel, {key: 'Tab'})).toBe(false);
848
+ // Containment is the browser's: the dialog was presented with showModal(),
849
+ // which makes everything outside it inert, so no script intercepts Tab.
850
+ // BottomSheetNativeModal.a11y.browser.spec.ts proves the key in engines.
851
+ expect(modalDialogs.has(dialog)).toBe(true);
852
+ expect(dialog).toHaveAttribute('aria-modal', 'true');
853
+ expect(fireEvent.keyDown(panel, {key: 'Tab'})).toBe(true);
624
854
  expect(panel).toHaveFocus();
625
- expect(
626
- screen.getByRole('button', {name: 'Background action'}),
627
- ).not.toHaveFocus();
628
855
  });
629
856
 
630
857
  it('can coordinate a non-modal flow without rendering a scrim', () => {
@@ -639,12 +866,12 @@ describe('BottomSheetSwitcher', () => {
639
866
  </BottomSheetSwitcher>,
640
867
  );
641
868
 
642
- const dialog = getSharedDialog();
869
+ const dialog = getActiveDialog();
643
870
  expect(dialog).not.toHaveAttribute('aria-modal');
644
871
  expect(dialog).toHaveAttribute('open');
645
872
  expect(HTMLDialogElement.prototype.show).toHaveBeenCalledTimes(1);
646
873
  expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled();
647
- expect(document.body.style.position).not.toBe('fixed');
874
+ expect(isScrollLockEngaged()).toBe(false);
648
875
  });
649
876
 
650
877
  it('requests activeSheet=null when the active sheet dismisses', () => {
@@ -680,7 +907,7 @@ describe('BottomSheetSwitcher', () => {
680
907
  </BottomSheet>
681
908
  </BottomSheetSwitcher>,
682
909
  );
683
- const dialog = getSharedDialog();
910
+ const dialog = getActiveDialog();
684
911
  const panel = getSheetPanel(dialog);
685
912
  const handle = panel.firstElementChild;
686
913
  if (!(handle instanceof HTMLElement)) {
@@ -710,7 +937,7 @@ describe('BottomSheetSwitcher', () => {
710
937
 
711
938
  it('keeps the flow open when a file picker inside a step is dismissed, and still closes on its own close request', () => {
712
939
  // 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.
940
+ // dismissed. It bubbles to the step's own dialog, which leaves it alone.
714
941
  const onActiveSheetChange = vi.fn();
715
942
  render(
716
943
  <BottomSheetSwitcher
@@ -728,7 +955,7 @@ describe('BottomSheetSwitcher', () => {
728
955
  expect(onActiveSheetChange).not.toHaveBeenCalled();
729
956
  expect(fromPicker.defaultPrevented).toBe(false);
730
957
 
731
- fireEvent(getSharedDialog(), new Event('cancel', {cancelable: true}));
958
+ fireEvent(getActiveDialog(), new Event('cancel', {cancelable: true}));
732
959
 
733
960
  expect(onActiveSheetChange).toHaveBeenCalledTimes(1);
734
961
  expect(onActiveSheetChange).toHaveBeenCalledWith(null);
@@ -782,7 +1009,7 @@ describe('BottomSheetSwitcher', () => {
782
1009
  </BottomSheet>
783
1010
  </BottomSheetSwitcher>,
784
1011
  );
785
- const dialog = getSharedDialog();
1012
+ const dialog = getActiveDialog();
786
1013
  const panel = getSheetPanel(dialog);
787
1014
  const handle = panel.firstElementChild;
788
1015
  if (!(handle instanceof HTMLElement)) {
@@ -878,7 +1105,7 @@ describe('BottomSheetSwitcher', () => {
878
1105
  </BottomSheetSwitcher>,
879
1106
  );
880
1107
 
881
- fireEvent.keyDown(getSharedDialog(), {key: 'Escape'});
1108
+ fireEvent.keyDown(getActiveDialog(), {key: 'Escape'});
882
1109
 
883
1110
  expect(onKeyDown).toHaveBeenCalledTimes(1);
884
1111
  expect(onActiveSheetChange).toHaveBeenCalledWith(null);
@@ -901,7 +1128,7 @@ describe('BottomSheetSwitcher', () => {
901
1128
  </BottomSheetSwitcher>,
902
1129
  );
903
1130
 
904
- fireEvent.keyDown(getSharedDialog(), {key: 'Escape'});
1131
+ fireEvent.keyDown(getActiveDialog(), {key: 'Escape'});
905
1132
 
906
1133
  expect(onKeyDown).toHaveBeenCalledTimes(1);
907
1134
  expect(onActiveSheetChange).not.toHaveBeenCalled();
@@ -949,7 +1176,7 @@ describe('BottomSheetSwitcher', () => {
949
1176
  );
950
1177
 
951
1178
  const event = new Event('cancel', {cancelable: true});
952
- getSharedDialog().dispatchEvent(event);
1179
+ getActiveDialog().dispatchEvent(event);
953
1180
 
954
1181
  expect(event.defaultPrevented).toBe(true);
955
1182
  expect(onNestedDismiss).not.toHaveBeenCalled();
@@ -1005,4 +1232,559 @@ describe('BottomSheetSwitcher', () => {
1005
1232
 
1006
1233
  expect(backButton).toHaveFocus();
1007
1234
  });
1235
+
1236
+ describe('one native dialog per sheet', () => {
1237
+ it('renders a closed dialog for every sheet and presents only the selected one', () => {
1238
+ render(<Flow />);
1239
+ const detailsDialog = getSheetDialog('details-sheet');
1240
+ const confirmDialog = getSheetDialog('confirm-sheet');
1241
+ expect(detailsDialog).not.toBe(confirmDialog);
1242
+ expect(openDialogs()).toEqual([]);
1243
+
1244
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1245
+
1246
+ expect(openDialogs()).toEqual([detailsDialog]);
1247
+ expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1);
1248
+ expect(confirmDialog).not.toHaveAttribute('open');
1249
+ expect(confirmDialog).not.toHaveAttribute('aria-modal');
1250
+ expect(detailsDialog).toHaveAttribute('aria-modal', 'true');
1251
+ expect(detailsDialog).toContainElement(
1252
+ screen.getByTestId('details-sheet'),
1253
+ );
1254
+ expect(confirmDialog).toContainElement(
1255
+ screen.getByTestId('confirm-sheet'),
1256
+ );
1257
+ });
1258
+
1259
+ it('presents the entering dialog once and closes the retained dialog only after it fades', () => {
1260
+ const log = recordDialogCalls();
1261
+ render(<Flow />);
1262
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1263
+ expect(log).toEqual(['showModal:Details']);
1264
+
1265
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1266
+ expect(log).toEqual(['showModal:Details', 'showModal:Confirm']);
1267
+
1268
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1269
+ expect(log).toEqual(['showModal:Details', 'showModal:Confirm']);
1270
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1271
+ expect(log).toEqual([
1272
+ 'showModal:Details',
1273
+ 'showModal:Confirm',
1274
+ 'close:Details',
1275
+ ]);
1276
+
1277
+ fireEvent.keyDown(getActiveDialog(), {key: 'Escape'});
1278
+ expect(log).toHaveLength(3);
1279
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1280
+ expect(log.at(-1)).toBe('close:Confirm');
1281
+ expect(openDialogs()).toEqual([]);
1282
+ });
1283
+
1284
+ it('holds one page scroll lock for the whole flow: engaged continuously through a handoff, released once after the exit', () => {
1285
+ // The shared lock restores the saved offset with window.scrollTo on its
1286
+ // last release, so the call count is the release count: a flow that
1287
+ // released and re-acquired between steps would scroll the page back
1288
+ // mid-handoff.
1289
+ vi.spyOn(window, 'scrollY', 'get').mockReturnValue(300);
1290
+ render(<Flow />);
1291
+ expect(isScrollLockEngaged()).toBe(false);
1292
+
1293
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1294
+ expect(isScrollLockEngaged()).toBe(true);
1295
+ expect(document.body.style.top).toBe('-300px');
1296
+ expect(window.scrollTo).not.toHaveBeenCalled();
1297
+
1298
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1299
+ // Two modal sheet dialogs are open: still the one lock.
1300
+ expect(openDialogs()).toHaveLength(2);
1301
+ expect(isScrollLockEngaged()).toBe(true);
1302
+ expect(window.scrollTo).not.toHaveBeenCalled();
1303
+
1304
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1305
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1306
+ expect(openDialogs()).toHaveLength(1);
1307
+ expect(isScrollLockEngaged()).toBe(true);
1308
+ expect(window.scrollTo).not.toHaveBeenCalled();
1309
+
1310
+ fireEvent.click(screen.getByRole('button', {name: 'Back'}));
1311
+ finishSheetTransition(getSheetLayer('details-sheet'), 'transform');
1312
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'opacity');
1313
+ expect(isScrollLockEngaged()).toBe(true);
1314
+ expect(window.scrollTo).not.toHaveBeenCalled();
1315
+
1316
+ fireEvent.keyDown(getActiveDialog(), {key: 'Escape'});
1317
+ // Still held through the exit motion.
1318
+ expect(isScrollLockEngaged()).toBe(true);
1319
+ expect(window.scrollTo).not.toHaveBeenCalled();
1320
+ finishSheetTransition(getSheetLayer('details-sheet'), 'transform');
1321
+ expect(isScrollLockEngaged()).toBe(false);
1322
+ expect(document.body.style.top).toBe('');
1323
+ expect(window.scrollTo).toHaveBeenCalledTimes(1);
1324
+ expect(window.scrollTo).toHaveBeenCalledWith(0, 300);
1325
+ });
1326
+
1327
+ it('keeps the lock through a StrictMode replay of a flow mounted open, and releases it when the flow unmounts while presented', () => {
1328
+ vi.spyOn(window, 'scrollY', 'get').mockReturnValue(120);
1329
+ const mounted = render(
1330
+ <StrictMode>
1331
+ <HostFlow initialSheet="details" />
1332
+ </StrictMode>,
1333
+ );
1334
+ // StrictMode runs the lock's effect, its cleanup, and the effect again
1335
+ // in one commit: the pin must stand afterwards, and the one real
1336
+ // release has not happened.
1337
+ expect(isScrollLockEngaged()).toBe(true);
1338
+ expect(document.body.style.top).toBe('-120px');
1339
+ expect(window.scrollTo).toHaveBeenCalledTimes(1);
1340
+ vi.mocked(window.scrollTo).mockClear();
1341
+
1342
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1343
+ expect(openDialogs()).toHaveLength(2);
1344
+ expect(isScrollLockEngaged()).toBe(true);
1345
+ expect(window.scrollTo).not.toHaveBeenCalled();
1346
+
1347
+ // Unmounted while presented, two dialogs open: one release.
1348
+ mounted.unmount();
1349
+ expect(isScrollLockEngaged()).toBe(false);
1350
+ expect(document.body.style.top).toBe('');
1351
+ expect(window.scrollTo).toHaveBeenCalledTimes(1);
1352
+ expect(window.scrollTo).toHaveBeenCalledWith(0, 120);
1353
+ });
1354
+
1355
+ 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", () => {
1356
+ const dialogRef = createRef<HTMLDialogElement>();
1357
+ const seenAtMount: {layout?: Element | null; passive?: Element | null} =
1358
+ {};
1359
+ const {unmount} = render(
1360
+ <HostFlow
1361
+ dialogRef={dialogRef}
1362
+ onMountLayout={() => {
1363
+ seenAtMount.layout = dialogRef.current;
1364
+ }}
1365
+ onMountPassive={() => {
1366
+ seenAtMount.passive = dialogRef.current;
1367
+ }}
1368
+ />,
1369
+ );
1370
+ // Closed, never opened: the first sheet's closed dialog, as the shared
1371
+ // dialog once was a closed element rather than null -- already in the
1372
+ // consumer's own mount effects, layout and passive alike.
1373
+ const detailsDialog = getSheetDialog('details-sheet');
1374
+ expect(seenAtMount.layout).toBe(detailsDialog);
1375
+ expect(seenAtMount.passive).toBe(detailsDialog);
1376
+ expect(dialogRef.current).toBe(detailsDialog);
1377
+ expect(dialogRef.current).not.toHaveAttribute('open');
1378
+
1379
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1380
+ expect(dialogRef.current).toBe(detailsDialog);
1381
+ expect(dialogRef.current).toHaveAttribute('open');
1382
+
1383
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1384
+ expect(dialogRef.current).toBe(getSheetDialog('confirm-sheet'));
1385
+
1386
+ fireEvent.click(screen.getByRole('button', {name: 'Done'}));
1387
+ // The exiting sheet's dialog hosts the flow through its exit motion and
1388
+ // stays the host once the flow is closed.
1389
+ expect(dialogRef.current).toBe(getSheetDialog('confirm-sheet'));
1390
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1391
+ expect(dialogRef.current).toBe(getSheetDialog('confirm-sheet'));
1392
+ expect(dialogRef.current).not.toHaveAttribute('open');
1393
+
1394
+ unmount();
1395
+ expect(dialogRef.current).toBeNull();
1396
+ });
1397
+
1398
+ it('runs a callback ref cleanup exactly once per detach and never passes it null', () => {
1399
+ const received: (HTMLDialogElement | null)[] = [];
1400
+ const cleanups: HTMLDialogElement[] = [];
1401
+ const callbackRef = (dialog: HTMLDialogElement | null) => {
1402
+ received.push(dialog);
1403
+ return () => {
1404
+ if (dialog != null) {
1405
+ cleanups.push(dialog);
1406
+ }
1407
+ };
1408
+ };
1409
+ const {unmount} = render(<HostFlow dialogRef={callbackRef} />);
1410
+ const detailsDialog = getSheetDialog('details-sheet');
1411
+ const confirmDialog = getSheetDialog('confirm-sheet');
1412
+ expect(received).toEqual([detailsDialog]);
1413
+ expect(cleanups).toEqual([]);
1414
+
1415
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1416
+ // Same host: the ref is not detached and re-attached.
1417
+ expect(received).toEqual([detailsDialog]);
1418
+
1419
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1420
+ // Host moved: the Details attachment is cleaned up once, then Confirm
1421
+ // is attached; no `null` call stands in for the cleanup.
1422
+ expect(cleanups).toEqual([detailsDialog]);
1423
+ expect(received).toEqual([detailsDialog, confirmDialog]);
1424
+
1425
+ fireEvent.click(screen.getByRole('button', {name: 'Done'}));
1426
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1427
+ expect(cleanups).toEqual([detailsDialog]);
1428
+ expect(received).toEqual([detailsDialog, confirmDialog]);
1429
+
1430
+ unmount();
1431
+ expect(cleanups).toEqual([detailsDialog, confirmDialog]);
1432
+ expect(received).toEqual([detailsDialog, confirmDialog]);
1433
+ expect(received).not.toContain(null);
1434
+ });
1435
+
1436
+ it('renders the switcher id and test id once, on the host dialog, and moves them with it', () => {
1437
+ render(
1438
+ <HostFlow
1439
+ dialogProps={{
1440
+ id: 'checkout',
1441
+ 'data-testid': 'checkout-flow',
1442
+ 'data-flow-owner': 'settings',
1443
+ }}
1444
+ />,
1445
+ );
1446
+ const dialogs = Array.from(document.querySelectorAll('dialog'));
1447
+ expect(dialogs).toHaveLength(3);
1448
+ const withId = () =>
1449
+ Array.from(document.querySelectorAll('[id="checkout"]'));
1450
+ // Closed: exactly one element carries the id, the first sheet's dialog.
1451
+ expect(withId()).toEqual([dialogs[0]]);
1452
+ expect(screen.getByTestId('checkout-flow')).toBe(dialogs[0]);
1453
+ // The remaining dialog props still reach every sheet dialog.
1454
+ for (const dialog of dialogs) {
1455
+ expect(dialog).toHaveAttribute('data-flow-owner', 'settings');
1456
+ }
1457
+
1458
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1459
+ expect(withId()).toEqual([dialogs[0]]);
1460
+ expect(document.getElementById('checkout')).toHaveAttribute('open');
1461
+
1462
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1463
+ // Mid-handoff two dialogs are open; the id names the active one only.
1464
+ expect(openDialogs()).toHaveLength(2);
1465
+ expect(withId()).toEqual([dialogs[1]]);
1466
+ expect(screen.getByTestId('checkout-flow')).toBe(dialogs[1]);
1467
+ expect(document.getElementById('checkout')).toHaveAccessibleName(
1468
+ 'Confirm',
1469
+ );
1470
+
1471
+ fireEvent.click(screen.getByRole('button', {name: 'Done'}));
1472
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1473
+ expect(withId()).toEqual([dialogs[1]]);
1474
+ });
1475
+
1476
+ it('orders a non-modal handoff inside the switcher-owned isolation surface, with no page-wide z-index on a sheet dialog', () => {
1477
+ render(<HostFlow hasScrim={false} initialSheet="details" />);
1478
+ const detailsDialog = getSheetDialog('details-sheet');
1479
+ const confirmDialog = getSheetDialog('confirm-sheet');
1480
+ const flow = detailsDialog.parentElement;
1481
+ if (flow == null) {
1482
+ throw new Error('flow wrapper not found');
1483
+ }
1484
+ expect(flow).toBe(confirmDialog.parentElement);
1485
+ // The wrapper is the isolated surface for the flow (`spec:AST-027`
1486
+ // FR2) and carries the one overlay value the shared shell carried.
1487
+ expect(getComputedStyle(flow).isolation).toBe('isolate');
1488
+ expect(getComputedStyle(flow).zIndex).toBe('1000');
1489
+
1490
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1491
+ expect(openDialogs()).toEqual([detailsDialog, confirmDialog]);
1492
+ // Inside it the dialogs use local values only: the interactive sheet
1493
+ // on top at 1, the retained sheet at auto. Neither is in a page band.
1494
+ expect(getComputedStyle(confirmDialog).zIndex).toBe('1');
1495
+ // jsdom reports an unset z-index as '' where a browser says 'auto'.
1496
+ expect(['', 'auto']).toContain(getComputedStyle(detailsDialog).zIndex);
1497
+ for (const dialog of [detailsDialog, confirmDialog]) {
1498
+ expect(Number(getComputedStyle(dialog).zIndex) >= 1000).toBe(false);
1499
+ }
1500
+ });
1501
+
1502
+ it('applies the switcher dialog props to each sheet dialog, with a consumer role kept unless a required step forces alertdialog', () => {
1503
+ const onToggle = vi.fn();
1504
+ render(
1505
+ <BottomSheetSwitcher
1506
+ activeSheet="details"
1507
+ onActiveSheetChange={() => {}}
1508
+ className="consumer-switcher"
1509
+ style={{insetInlineStart: 12}}
1510
+ role="alertdialog"
1511
+ aria-describedby="flow-help"
1512
+ data-flow-owner="settings"
1513
+ onToggle={onToggle}>
1514
+ <BottomSheet sheetId="details" label="Details" data-testid="a">
1515
+ Content
1516
+ </BottomSheet>
1517
+ <BottomSheet sheetId="confirm" label="Confirm" data-testid="b">
1518
+ Content
1519
+ </BottomSheet>
1520
+ <BottomSheet
1521
+ sheetId="danger"
1522
+ label="Danger"
1523
+ purpose="required"
1524
+ data-testid="c">
1525
+ Content
1526
+ </BottomSheet>
1527
+ </BottomSheetSwitcher>,
1528
+ );
1529
+ // Every switcher prop the shared dialog honoured reaches every sheet
1530
+ // dialog, explicit attributes included: a consumer `role` is kept
1531
+ // (the shared dialog let it through) and a `required` step still
1532
+ // forces `alertdialog`.
1533
+ for (const testId of ['a', 'b', 'c']) {
1534
+ const dialog = getSheetDialog(testId);
1535
+ expect(dialog).toHaveClass('consumer-switcher');
1536
+ expect(dialog).toHaveStyle({insetInlineStart: '12px'});
1537
+ expect(dialog).toHaveAttribute('data-flow-owner', 'settings');
1538
+ expect(dialog).toHaveAttribute('aria-describedby', 'flow-help');
1539
+ expect(dialog).toHaveAttribute('role', 'alertdialog');
1540
+ fireEvent(dialog, new Event('toggle'));
1541
+ }
1542
+ expect(onToggle).toHaveBeenCalledTimes(3);
1543
+ expect(getSheetDialog('a')).toHaveAttribute('aria-label', 'Details');
1544
+ expect(getSheetDialog('b')).toHaveAttribute('aria-label', 'Confirm');
1545
+ });
1546
+
1547
+ it('paints one scrim through a handoff and hands it over without a fade', () => {
1548
+ render(<Flow />);
1549
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1550
+ const detailsDialog = getSheetDialog('details-sheet');
1551
+ const confirmDialog = getSheetDialog('confirm-sheet');
1552
+ expect(detailsDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
1553
+
1554
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1555
+ // The retained dialog's backdrop is the scrim; the entering dialog's
1556
+ // backdrop, painted above the retained panel, stays clear.
1557
+ expect(detailsDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
1558
+ expect(confirmDialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
1559
+
1560
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1561
+ expect(detailsDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
1562
+ expect(confirmDialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
1563
+
1564
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1565
+ expect(detailsDialog).not.toHaveAttribute('open');
1566
+ expect(confirmDialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
1567
+ // The swap pins the backdrop transition for one style flush only.
1568
+ expect(
1569
+ confirmDialog.style.getPropertyValue('--_sheet-scrim-duration'),
1570
+ ).toBe('');
1571
+
1572
+ fireEvent.keyDown(confirmDialog, {key: 'Escape'});
1573
+ expect(confirmDialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
1574
+ });
1575
+
1576
+ it('moves focus into the entering sheet while the retained dialog is inert', () => {
1577
+ render(<Flow />);
1578
+ const opener = screen.getByRole('button', {name: 'Start flow'});
1579
+ opener.focus();
1580
+ fireEvent.click(opener);
1581
+ expect(getSheetPanel(getSheetDialog('details-sheet'))).toHaveFocus();
1582
+
1583
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1584
+
1585
+ expect(getSheetPanel(getSheetDialog('confirm-sheet'))).toHaveFocus();
1586
+ expect(getSheetDialog('details-sheet')).toHaveAttribute('inert');
1587
+ expect(getSheetDialog('details-sheet')).toHaveAttribute(
1588
+ 'aria-hidden',
1589
+ 'true',
1590
+ );
1591
+ expect(getSheetDialog('confirm-sheet')).not.toHaveAttribute('inert');
1592
+
1593
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1594
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1595
+ fireEvent.keyDown(getSheetDialog('confirm-sheet'), {key: 'Escape'});
1596
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1597
+
1598
+ expect(opener).toHaveFocus();
1599
+ });
1600
+
1601
+ it('moves focus only when a step is entered or the flow ends: a retained dialog closing after its fade fires no focus event', () => {
1602
+ // close() on a modal dialog returns focus to the element focused when
1603
+ // it was presented -- the Continue or Back that advanced the flow, or
1604
+ // the opener -- and the retained dialog closes a fade after the user
1605
+ // is in the next step. Each step presents with nothing recorded, so
1606
+ // the close moves nothing: no focus/blur/focusin/focusout anywhere
1607
+ // while a retained dialog closes, after Continue, after Back, and
1608
+ // after a reversal mid-handoff.
1609
+ recordDialogCalls();
1610
+ render(<Flow />);
1611
+ const events: string[] = [];
1612
+ const record = (event: Event) => {
1613
+ const target = event.target as HTMLElement;
1614
+ events.push(
1615
+ `${event.type}:${target.getAttribute('aria-label') ?? target.textContent?.trim() ?? target.tagName}`,
1616
+ );
1617
+ };
1618
+ for (const type of ['focus', 'blur', 'focusin', 'focusout']) {
1619
+ document.addEventListener(type, record, true);
1620
+ }
1621
+ const take = () => events.splice(0, events.length);
1622
+
1623
+ const opener = screen.getByRole('button', {name: 'Start flow'});
1624
+ opener.focus();
1625
+ fireEvent.click(opener);
1626
+ const detailsPanel = getSheetPanel(getSheetDialog('details-sheet'));
1627
+ const confirmPanel = getSheetPanel(getSheetDialog('confirm-sheet'));
1628
+ expect(detailsPanel).toHaveFocus();
1629
+ take();
1630
+
1631
+ // Continue: focus moves once, from the control into the entering step.
1632
+ const continueButton = screen.getByRole('button', {name: 'Continue'});
1633
+ continueButton.focus();
1634
+ fireEvent.click(continueButton);
1635
+ expect(confirmPanel).toHaveFocus();
1636
+ take();
1637
+ // The retained Details dialog closes after its fade: nothing moves.
1638
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1639
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1640
+ expect(getSheetDialog('details-sheet')).not.toHaveAttribute('open');
1641
+ expect(take()).toEqual([]);
1642
+ expect(confirmPanel).toHaveFocus();
1643
+
1644
+ // Back: the same, with the retained Confirm dialog closing.
1645
+ const backButton = screen.getByRole('button', {name: 'Back'});
1646
+ backButton.focus();
1647
+ fireEvent.click(backButton);
1648
+ expect(detailsPanel).toHaveFocus();
1649
+ take();
1650
+ finishSheetTransition(getSheetLayer('details-sheet'), 'transform');
1651
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'opacity');
1652
+ expect(getSheetDialog('confirm-sheet')).not.toHaveAttribute('open');
1653
+ expect(take()).toEqual([]);
1654
+ expect(detailsPanel).toHaveFocus();
1655
+
1656
+ // Reversal mid-handoff: Continue, then Back before Confirm settles.
1657
+ continueButton.focus();
1658
+ fireEvent.click(continueButton);
1659
+ expect(confirmPanel).toHaveFocus();
1660
+ backButton.focus();
1661
+ fireEvent.click(backButton);
1662
+ expect(detailsPanel).toHaveFocus();
1663
+ take();
1664
+ finishSheetTransition(getSheetLayer('details-sheet'), 'transform');
1665
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'opacity');
1666
+ expect(getSheetDialog('confirm-sheet')).not.toHaveAttribute('open');
1667
+ expect(take()).toEqual([]);
1668
+ expect(detailsPanel).toHaveFocus();
1669
+
1670
+ // Ending the flow: the switcher returns focus to the opener itself.
1671
+ fireEvent.keyDown(getSheetDialog('details-sheet'), {key: 'Escape'});
1672
+ finishSheetTransition(getSheetLayer('details-sheet'), 'transform');
1673
+ expect(opener).toHaveFocus();
1674
+ for (const type of ['focus', 'blur', 'focusin', 'focusout']) {
1675
+ document.removeEventListener(type, record, true);
1676
+ }
1677
+ });
1678
+
1679
+ it("answers a native cancel by the active sheet's purpose after a handoff", () => {
1680
+ const log = recordDialogCalls();
1681
+ render(<RequiredConfirmFlow />);
1682
+ expect(screen.queryByRole('alertdialog')).toBeNull();
1683
+
1684
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1685
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1686
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1687
+
1688
+ const confirmDialog = getSheetDialog('confirm-sheet');
1689
+ expect(confirmDialog).toHaveAttribute('role', 'alertdialog');
1690
+ expect(getSheetDialog('details-sheet')).not.toHaveAttribute('role');
1691
+
1692
+ fireEvent.keyDown(confirmDialog, {key: 'Escape'});
1693
+ const cancel = new Event('cancel', {cancelable: true});
1694
+ confirmDialog.dispatchEvent(cancel);
1695
+ fireEvent.click(confirmDialog);
1696
+
1697
+ // Always prevented: the browser never closes a controlled dialog.
1698
+ expect(cancel.defaultPrevented).toBe(true);
1699
+ expect(confirmDialog).toHaveAttribute('open');
1700
+ expect(log.filter(entry => entry.startsWith('close:'))).toEqual([
1701
+ 'close:Details',
1702
+ ]);
1703
+
1704
+ fireEvent.click(screen.getByRole('button', {name: 'Done'}));
1705
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1706
+ expect(openDialogs()).toEqual([]);
1707
+ });
1708
+
1709
+ it("dismisses an info flow from a cancel on any sheet dialog, and from a backdrop click on the active sheet's dialog only", () => {
1710
+ const onActiveSheetChange = vi.fn();
1711
+ render(
1712
+ <BottomSheetSwitcher
1713
+ activeSheet="confirm"
1714
+ onActiveSheetChange={onActiveSheetChange}>
1715
+ <BottomSheet sheetId="details" label="Details" data-testid="a">
1716
+ Content
1717
+ </BottomSheet>
1718
+ <BottomSheet sheetId="confirm" label="Confirm" data-testid="b">
1719
+ <button type="button">Inside</button>
1720
+ </BottomSheet>
1721
+ </BottomSheetSwitcher>,
1722
+ );
1723
+ const activeDialog = getSheetDialog('b');
1724
+
1725
+ const cancel = new Event('cancel', {cancelable: true});
1726
+ activeDialog.dispatchEvent(cancel);
1727
+ expect(cancel.defaultPrevented).toBe(true);
1728
+ expect(onActiveSheetChange).toHaveBeenNthCalledWith(1, null);
1729
+
1730
+ // A `::backdrop` click targets the dialog element itself.
1731
+ fireEvent.click(activeDialog);
1732
+ expect(onActiveSheetChange).toHaveBeenNthCalledWith(2, null);
1733
+
1734
+ // A click that is not on a backdrop, or on a dialog other than the
1735
+ // host's, dismisses nothing.
1736
+ fireEvent.click(screen.getByRole('button', {name: 'Inside'}));
1737
+ fireEvent.click(getSheetDialog('a'));
1738
+ expect(onActiveSheetChange).toHaveBeenCalledTimes(2);
1739
+
1740
+ // The composed cancel handler is on every sheet dialog, not the host
1741
+ // only: the browser raises `cancel` on the dialog it presents, so in
1742
+ // engines this is the active sheet's, but the switcher does not guard
1743
+ // it by host.
1744
+ const cancelElsewhere = new Event('cancel', {cancelable: true});
1745
+ getSheetDialog('a').dispatchEvent(cancelElsewhere);
1746
+ expect(cancelElsewhere.defaultPrevented).toBe(true);
1747
+ expect(onActiveSheetChange).toHaveBeenNthCalledWith(3, null);
1748
+ });
1749
+
1750
+ it('presents a non-modal flow with show() on each sheet dialog', () => {
1751
+ const log = recordDialogCalls();
1752
+ render(<HostFlow hasScrim={false} initialSheet="details" />);
1753
+ expect(log).toEqual(['show:Details']);
1754
+
1755
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1756
+
1757
+ expect(log).toEqual(['show:Details', 'show:Confirm']);
1758
+ expect(openDialogs()).toHaveLength(2);
1759
+ expect(getSheetDialog('confirm-sheet')).not.toHaveAttribute('aria-modal');
1760
+ expect(isScrollLockEngaged()).toBe(false);
1761
+
1762
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform');
1763
+ finishSheetTransition(getSheetLayer('details-sheet'), 'opacity');
1764
+ expect(log).toEqual(['show:Details', 'show:Confirm', 'close:Details']);
1765
+ });
1766
+
1767
+ it('re-presents a sheet that is still fading when the flow returns to it', () => {
1768
+ const log = recordDialogCalls();
1769
+ render(<Flow />);
1770
+ fireEvent.click(screen.getByRole('button', {name: 'Start flow'}));
1771
+ fireEvent.click(screen.getByRole('button', {name: 'Continue'}));
1772
+ fireEvent.click(screen.getByRole('button', {name: 'Back'}));
1773
+
1774
+ // Details was still open beneath Confirm in the top layer; it closes and
1775
+ // presents again so the interactive sheet is the topmost modal layer.
1776
+ expect(log).toEqual([
1777
+ 'showModal:Details',
1778
+ 'showModal:Confirm',
1779
+ 'close:Details',
1780
+ 'showModal:Details',
1781
+ ]);
1782
+ expect(getActiveDialog()).toBe(getSheetDialog('details-sheet'));
1783
+ expect(getSheetDialog('confirm-sheet')).toHaveAttribute('inert');
1784
+
1785
+ finishSheetTransition(getSheetLayer('confirm-sheet'), 'opacity');
1786
+ expect(log.at(-1)).toBe('close:Confirm');
1787
+ expect(openDialogs()).toEqual([getSheetDialog('details-sheet')]);
1788
+ });
1789
+ });
1008
1790
  });