@astryxdesign/core 0.7.3 → 0.7.4-canary.28b1cd4
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.
- package/dist/BottomSheet/BottomSheet.d.ts +18 -6
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +268 -58
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts +41 -12
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetSwitcher.js +294 -165
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +51 -1
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -1
- package/dist/Carousel/Carousel.d.ts.map +1 -1
- package/dist/Carousel/Carousel.js +7 -11
- package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
- package/dist/Chat/useTriggerMenu.js +46 -7
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +6 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +12 -0
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +6 -1
- package/dist/DateInput/NativeDateField.d.ts.map +1 -1
- package/dist/DateInput/NativeDateField.js +6 -1
- package/dist/DateInput/TouchDateField.d.ts.map +1 -1
- package/dist/DateInput/TouchDateField.js +9 -1
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +6 -1
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +6 -1
- package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
- package/dist/DateTimeInput/TouchDateTimeField.js +9 -1
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +6 -1
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +6 -1
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +6 -1
- package/dist/TextInput/TextInput.d.ts.map +1 -1
- package/dist/TextInput/TextInput.js +6 -1
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +6 -1
- package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
- package/dist/TimeInput/TouchTimeField.js +9 -1
- package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
- package/dist/Tokenizer/Tokenizer.js +9 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +7 -1
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +9 -1
- package/dist/astryx.css +2 -0
- package/dist/hooks/useBusyAnnouncement.d.ts +22 -0
- package/dist/hooks/useBusyAnnouncement.d.ts.map +1 -0
- package/dist/hooks/useBusyAnnouncement.js +87 -0
- package/dist/hooks/useMenuPress.d.ts +2 -2
- package/dist/hooks/useMenuPress.d.ts.map +1 -1
- package/dist/hooks/useMenuPress.js +5 -1
- package/dist/hooks/useTypeahead.d.ts.map +1 -1
- package/dist/hooks/useTypeahead.js +7 -2
- package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/en.generated.js +1 -0
- package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
- package/locales/en.json +6 -2
- package/locales/pseudo.json +3 -0
- package/package.json +4 -4
- package/src/BottomSheet/BottomSheet.doc.mjs +2 -2
- package/src/BottomSheet/BottomSheet.spec.md +2 -5
- package/src/BottomSheet/BottomSheet.tsx +260 -47
- package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -2
- package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +11 -10
- package/src/BottomSheet/BottomSheetSwitcher.spec.md +28 -35
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +830 -48
- package/src/BottomSheet/BottomSheetSwitcher.tsx +396 -215
- package/src/BottomSheet/BottomSheetSwitcherContext.ts +57 -2
- package/src/Carousel/Carousel.test.tsx +8 -12
- package/src/Carousel/Carousel.tsx +6 -13
- package/src/Chat/ChatComposerInput.test.tsx +167 -1
- package/src/Chat/useTriggerMenu.tsx +55 -6
- package/src/ComplexSelector/ComplexSelector.tsx +5 -1
- package/src/ContextMenu/ContextMenu.test.tsx +34 -0
- package/src/ContextMenu/ContextMenu.tsx +13 -1
- package/src/DateInput/DateInput.tsx +5 -1
- package/src/DateInput/DateInputTouch.test.tsx +45 -0
- package/src/DateInput/NativeDateField.test.tsx +10 -2
- package/src/DateInput/NativeDateField.tsx +5 -1
- package/src/DateInput/TouchDateField.tsx +14 -2
- package/src/DateRangeInput/DateRangeInput.tsx +5 -1
- package/src/DateTimeInput/DateTimeInput.tsx +5 -1
- package/src/DateTimeInput/TouchDateTimeField.tsx +14 -2
- package/src/MultiSelector/MultiSelector.test.tsx +18 -4
- package/src/MultiSelector/MultiSelector.tsx +5 -1
- package/src/ProgressBar/ProgressBar.test.tsx +4 -21
- package/src/ProgressBar/__tests__/ProgressBar.a11y.chromium.spec.ts +114 -0
- package/src/ProgressBar/__tests__/ProgressBar.a11y.known-failures.ts +12 -0
- package/src/ProgressBar/__tests__/ProgressBar.a11y.renders.tsx +38 -0
- package/src/ProgressBar/__tests__/ProgressBar.a11y.states.ts +123 -0
- package/src/ProgressBar/__tests__/ProgressBar.a11y.test.tsx +107 -0
- package/src/Selector/Selector.test.tsx +22 -5
- package/src/Selector/Selector.tsx +5 -1
- package/src/TextArea/TextArea.test.tsx +9 -1
- package/src/TextArea/TextArea.tsx +5 -1
- package/src/TextInput/TextInput.tsx +5 -1
- package/src/TimeInput/TimeInput.tsx +5 -1
- package/src/TimeInput/TouchTimeField.tsx +12 -2
- package/src/Tokenizer/Tokenizer.test.tsx +17 -12
- package/src/Tokenizer/Tokenizer.tsx +10 -1
- package/src/Typeahead/BaseTypeahead.tsx +14 -1
- package/src/Typeahead/Typeahead.test.tsx +32 -41
- package/src/Typeahead/Typeahead.tsx +10 -1
- package/src/__tests__/BusyOwnerAnnouncement.test.tsx +470 -0
- package/src/hooks/__tests__/MenuPressShadowRoot.a11y.chromium.spec.ts +50 -0
- package/src/hooks/useBusyAnnouncement.ts +100 -0
- package/src/hooks/useMenuPress.test.tsx +27 -1
- package/src/hooks/useMenuPress.ts +12 -3
- package/src/hooks/useTypeahead.test.tsx +42 -1
- package/src/hooks/useTypeahead.ts +7 -2
- package/src/i18n/generated-locales/en.generated.ts +1 -0
- 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
|
|
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(
|
|
292
|
-
expect(
|
|
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
|
|
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(
|
|
470
|
+
expect(getActiveDialog()).toContainElement(panel);
|
|
316
471
|
});
|
|
317
472
|
|
|
318
|
-
it(
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
-
|
|
388
|
-
expect(
|
|
389
|
-
expect(
|
|
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(
|
|
405
|
-
expect(
|
|
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(
|
|
419
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
775
|
+
const activeDialog = getActiveDialog();
|
|
554
776
|
|
|
555
|
-
fireEvent.click(
|
|
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(
|
|
561
|
-
|
|
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(
|
|
566
|
-
expect(
|
|
788
|
+
expect(activeDialog).not.toHaveAttribute('open');
|
|
789
|
+
expect(isScrollLockEngaged()).toBe(false);
|
|
567
790
|
});
|
|
568
791
|
|
|
569
|
-
it('releases the
|
|
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
|
|
797
|
+
const dialog = screen.getByRole('dialog', {
|
|
575
798
|
name: 'Conditional details',
|
|
576
799
|
});
|
|
577
800
|
|
|
578
|
-
fireEvent.keyDown(
|
|
801
|
+
fireEvent.keyDown(dialog, {key: 'Escape'});
|
|
579
802
|
|
|
580
|
-
expect(
|
|
581
|
-
expect(
|
|
803
|
+
expect(dialog).not.toHaveAttribute('open');
|
|
804
|
+
expect(isScrollLockEngaged()).toBe(false);
|
|
582
805
|
});
|
|
583
806
|
|
|
584
|
-
it('keeps
|
|
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('
|
|
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', {
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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.
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
});
|