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