@astryxdesign/core 0.4.2 → 0.4.3-canary.00bed29
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/CHANGELOG.md +45 -0
- package/README.md +79 -45
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +3 -19
- package/dist/Banner/Banner.d.ts +9 -2
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +84 -33
- package/dist/Banner/index.d.ts +14 -12
- package/dist/Banner/index.d.ts.map +1 -1
- package/dist/Banner/index.js +10 -8
- package/dist/BottomSheet/BottomSheet.d.ts +66 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheet.js +392 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetPanel.js +398 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
- package/dist/BottomSheet/index.d.ts +11 -0
- package/dist/BottomSheet/index.d.ts.map +1 -0
- package/dist/BottomSheet/index.js +12 -0
- package/dist/BottomSheet/snapOffsets.d.ts +72 -0
- package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
- package/dist/BottomSheet/snapOffsets.js +130 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
- package/dist/BottomSheet/useMobileKeyboard.js +373 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
- package/dist/BottomSheet/useSheetGestures.js +942 -0
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +3 -4
- package/dist/Chat/ChatTokenizedText.js +1 -1
- package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +98 -31
- package/dist/ComplexSelector/index.d.ts +2 -2
- package/dist/ComplexSelector/index.d.ts.map +1 -1
- package/dist/ComplexSelector/index.js +1 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +4 -2
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +9 -1
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +31 -1
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -1
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +9 -3
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +55 -12
- package/dist/MobileNav/MobileNav.d.ts +12 -0
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +102 -30
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +21 -7
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +9 -1
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearch.js +4 -1
- package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
- package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
- package/dist/PowerSearch/formatFilterValue.js +2 -4
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +14 -3
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +5 -1
- package/dist/Table/columnUtils.d.ts.map +1 -1
- package/dist/Table/columnUtils.js +4 -1
- package/dist/TextArea/TextArea.d.ts +6 -3
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +17 -6
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +8 -1
- package/dist/Tokenizer/Tokenizer.js +11 -5
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +4 -1
- package/dist/TreeList/TreeList.js +2 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +5 -3
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +10 -1
- package/dist/astryx.css +25 -2
- package/dist/hooks/index.d.ts +8 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +8 -1
- package/dist/hooks/useFocusTrap.d.ts +0 -11
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +1 -11
- package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
- package/dist/hooks/useInputStatusIcon.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/theme/defineTheme.d.ts +30 -7
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +36 -47
- package/dist/theme/expandColorScale.d.ts +17 -2
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +63 -33
- package/dist/theme/mergeComponents.d.ts +20 -0
- package/dist/theme/mergeComponents.d.ts.map +1 -0
- package/dist/theme/mergeComponents.js +56 -0
- package/dist/theme/onMediaTokens.d.ts +6 -1
- package/dist/theme/onMediaTokens.d.ts.map +1 -1
- package/dist/theme/onMediaTokens.js +11 -3
- package/dist/utils/characters.d.ts +27 -0
- package/dist/utils/characters.d.ts.map +1 -0
- package/dist/utils/characters.js +83 -0
- package/dist/utils/ime.d.ts +5 -0
- package/dist/utils/ime.d.ts.map +1 -0
- package/dist/utils/ime.js +63 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -1
- package/locales/en.json +48 -0
- package/locales/pseudo.json +36 -0
- package/package.json +9 -8
- package/src/Avatar/Avatar.test.tsx +6 -1
- package/src/Avatar/Avatar.tsx +3 -21
- package/src/Banner/Banner.doc.mjs +12 -10
- package/src/Banner/Banner.test.tsx +108 -0
- package/src/Banner/Banner.tsx +124 -38
- package/src/Banner/index.ts +15 -13
- package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
- package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
- package/src/BottomSheet/BottomSheet.tsx +545 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
- package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
- package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
- package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
- package/src/BottomSheet/index.ts +15 -0
- package/src/BottomSheet/snapOffsets.test.ts +187 -0
- package/src/BottomSheet/snapOffsets.ts +159 -0
- package/src/BottomSheet/useMobileKeyboard.ts +499 -0
- package/src/BottomSheet/useSheetGestures.test.ts +1014 -0
- package/src/BottomSheet/useSheetGestures.ts +1407 -0
- package/src/Chat/ChatComposerInput.tsx +3 -4
- package/src/Chat/ChatTokenizedText.tsx +1 -1
- package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
- package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
- package/src/ComplexSelector/ComplexSelector.tsx +156 -28
- package/src/ComplexSelector/index.ts +3 -1
- package/src/ContextMenu/ContextMenu.tsx +4 -2
- package/src/DateInput/DateInput.test.tsx +21 -0
- package/src/DateInput/DateInput.tsx +9 -1
- package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
- package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
- package/src/DateTimeInput/DateTimeInput.tsx +39 -2
- package/src/Dialog/Dialog.tsx +2 -1
- package/src/FileInput/FileInput.test.tsx +53 -9
- package/src/Lightbox/Lightbox.test.tsx +49 -28
- package/src/Lightbox/Lightbox.tsx +7 -3
- package/src/Markdown/parser.test.ts +53 -0
- package/src/Markdown/parser.ts +53 -12
- package/src/MobileNav/MobileNav.tsx +128 -37
- package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
- package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
- package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
- package/src/MultiSelector/MultiSelector.test.tsx +195 -51
- package/src/MultiSelector/MultiSelector.tsx +22 -8
- package/src/NumberInput/NumberInput.test.tsx +18 -0
- package/src/NumberInput/NumberInput.tsx +9 -1
- package/src/PowerSearch/PowerSearch.test.tsx +48 -3
- package/src/PowerSearch/PowerSearch.tsx +4 -1
- package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
- package/src/PowerSearch/formatFilterValue.test.ts +22 -0
- package/src/PowerSearch/formatFilterValue.ts +2 -4
- package/src/Selector/Selector.test.tsx +85 -26
- package/src/Selector/Selector.tsx +13 -4
- package/src/Switch/Switch.test.tsx +16 -0
- package/src/Switch/Switch.tsx +7 -0
- package/src/Table/Table.test.tsx +6 -0
- package/src/Table/columnUtils.ts +3 -1
- package/src/TextArea/TextArea.doc.mjs +1 -1
- package/src/TextArea/TextArea.test.tsx +72 -0
- package/src/TextArea/TextArea.tsx +26 -8
- package/src/TimeInput/TimeInput.test.tsx +23 -0
- package/src/TimeInput/TimeInput.tsx +8 -0
- package/src/Tokenizer/Tokenizer.test.tsx +50 -32
- package/src/Tokenizer/Tokenizer.tsx +5 -5
- package/src/Tooltip/useTooltip.tsx +4 -1
- package/src/TreeList/TreeList.doc.mjs +2 -2
- package/src/TreeList/TreeList.tsx +1 -1
- package/src/Typeahead/BaseTypeahead.tsx +10 -3
- package/src/Typeahead/Typeahead.test.tsx +116 -7
- package/src/Typeahead/Typeahead.tsx +10 -1
- package/src/__tests__/stubMatchMedia.test.ts +79 -0
- package/src/__tests__/stubMatchMedia.ts +95 -0
- package/src/hooks/index.ts +9 -5
- package/src/hooks/useFocusTrap.ts +1 -14
- package/src/hooks/useInputStatusIcon.test.tsx +19 -0
- package/src/hooks/useInputStatusIcon.tsx +7 -0
- package/src/index.ts +1 -0
- package/src/theme/defineTheme.test.ts +127 -0
- package/src/theme/defineTheme.ts +70 -55
- package/src/theme/expandColorScale.test.ts +138 -0
- package/src/theme/expandColorScale.ts +89 -44
- package/src/theme/extensibleAxes.test.ts +365 -0
- package/src/theme/mergeComponents.ts +59 -0
- package/src/theme/onMediaTokens.ts +9 -2
- package/src/utils/characters.test.ts +141 -0
- package/src/utils/characters.ts +88 -0
- package/src/utils/ime.test.ts +32 -0
- package/src/utils/ime.ts +69 -0
- package/src/utils/index.ts +4 -0
- package/dist/astryx.umd.js +0 -147
- package/dist/astryx.umd.js.map +0 -7
|
@@ -77,7 +77,19 @@ const styles = stylex.create({
|
|
|
77
77
|
outline: 'none',
|
|
78
78
|
// Native <dialog> uses display:none when closed.
|
|
79
79
|
// Open state applied via isOpen prop to avoid :where([open]) specificity issues.
|
|
80
|
+
// `display` participates in the transition with allow-discrete so it flips
|
|
81
|
+
// to none only after the slide-out finishes. That also keeps the dialog
|
|
82
|
+
// rendered until close() has actually run: an open modal dialog that isn't
|
|
83
|
+
// rendered still blocks the whole document, and a browser that fails to
|
|
84
|
+
// un-block it on close leaves the page inert with no error (#4290).
|
|
85
|
+
// Deliberately not shortened under reduced motion: `display` is discrete,
|
|
86
|
+
// so a long hold animates nothing — it is only the window the close has to
|
|
87
|
+
// land inside. The visible transitions (the drawer's transform and the
|
|
88
|
+
// backdrop's opacity) are the ones that respect the preference.
|
|
80
89
|
display: 'none',
|
|
90
|
+
transitionProperty: 'display',
|
|
91
|
+
transitionDuration: durationVars['--duration-medium'],
|
|
92
|
+
transitionBehavior: 'allow-discrete',
|
|
81
93
|
},
|
|
82
94
|
open: {
|
|
83
95
|
display: 'flex',
|
|
@@ -183,6 +195,75 @@ const dynamicStyles = stylex.create({
|
|
|
183
195
|
}),
|
|
184
196
|
});
|
|
185
197
|
|
|
198
|
+
// =============================================================================
|
|
199
|
+
// Close timing
|
|
200
|
+
// =============================================================================
|
|
201
|
+
|
|
202
|
+
/** Longest the drawer will wait before closing, however long the hold is. */
|
|
203
|
+
const MAX_CLOSE_DELAY_MS = 250;
|
|
204
|
+
/** Fraction of the hold to close at, so the close never lands on its boundary. */
|
|
205
|
+
const CLOSE_WITHIN_HOLD = 0.6;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Shortest duration in a `transition-duration` list, in ms; null if unreadable.
|
|
209
|
+
*
|
|
210
|
+
* Browsers serialise computed `<time>` values in seconds — an authored `410ms`
|
|
211
|
+
* reads back as `"0.41s"` and a list as `"0.41s, 0.12s"` — so the seconds branch
|
|
212
|
+
* is the one that runs outside tests. jsdom echoes an inline `250ms` back as-is
|
|
213
|
+
* and never resolves `var()`, so both units and the unreadable case are covered
|
|
214
|
+
* directly in MobileNavCloseTiming.test.ts rather than through the component.
|
|
215
|
+
*
|
|
216
|
+
* @internal Exported for unit tests.
|
|
217
|
+
*/
|
|
218
|
+
export function parseShortestDurationMs(value: string): number | null {
|
|
219
|
+
const durations = value
|
|
220
|
+
.split(',')
|
|
221
|
+
.map(part => {
|
|
222
|
+
const trimmed = part.trim();
|
|
223
|
+
const ms = Number.parseFloat(trimmed);
|
|
224
|
+
if (!Number.isFinite(ms)) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
return trimmed.endsWith('ms')
|
|
228
|
+
? ms
|
|
229
|
+
: trimmed.endsWith('s')
|
|
230
|
+
? ms * 1000
|
|
231
|
+
: null;
|
|
232
|
+
})
|
|
233
|
+
.filter((ms): ms is number => ms !== null);
|
|
234
|
+
|
|
235
|
+
return durations.length ? Math.min(...durations) : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* How long to wait before closing the native dialog.
|
|
240
|
+
*
|
|
241
|
+
* The drawer is only rendered for as long as its `display` transition runs, and
|
|
242
|
+
* closing an unrendered modal dialog is what leaves the page inert (#4290). So
|
|
243
|
+
* the close has to land inside that hold. The hold is `--duration-medium`,
|
|
244
|
+
* which themes rewrite — the shipped y2k theme sets it to exactly 250ms — so
|
|
245
|
+
* read the hold in effect rather than assuming it.
|
|
246
|
+
*/
|
|
247
|
+
function resolveCloseDelay(dialog: HTMLDialogElement): number {
|
|
248
|
+
// Reduced motion makes the close sooner; it must not make the hold shorter.
|
|
249
|
+
// Shrinking both leaves no slack — one slow frame between the commit and this
|
|
250
|
+
// macrotask and the drawer has already stopped being rendered.
|
|
251
|
+
const cap = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
252
|
+
? 0
|
|
253
|
+
: MAX_CLOSE_DELAY_MS;
|
|
254
|
+
|
|
255
|
+
const hold = parseShortestDurationMs(
|
|
256
|
+
window.getComputedStyle(dialog).transitionDuration,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// The hold is unreadable — an unresolved var() outside a real browser.
|
|
260
|
+
if (hold === null) {
|
|
261
|
+
return cap;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return hold <= 0 ? 0 : Math.min(cap, hold * CLOSE_WITHIN_HOLD);
|
|
265
|
+
}
|
|
266
|
+
|
|
186
267
|
// =============================================================================
|
|
187
268
|
// Types
|
|
188
269
|
// =============================================================================
|
|
@@ -320,6 +401,34 @@ export function MobileNav({
|
|
|
320
401
|
side === 'auto' ? 'end' : side,
|
|
321
402
|
);
|
|
322
403
|
|
|
404
|
+
// Resolve which edge the drawer slides from. Deliberately its own effect,
|
|
405
|
+
// declared before the open/close effect below so the trigger is still the
|
|
406
|
+
// active element when `side='auto'` reads it. Keeping it out of that effect
|
|
407
|
+
// is what stops a `side` change during a close from re-arming the delay: the
|
|
408
|
+
// CSS hold runs from the commit that started the slide-out and does not
|
|
409
|
+
// restart, so a fresh full delay could land after the drawer had already
|
|
410
|
+
// stopped being rendered — #4290 again.
|
|
411
|
+
useEffect(() => {
|
|
412
|
+
if (!isOpen) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (side === 'auto') {
|
|
417
|
+
const trigger = document.activeElement as HTMLElement | null;
|
|
418
|
+
if (trigger && trigger !== document.body) {
|
|
419
|
+
const rect = trigger.getBoundingClientRect();
|
|
420
|
+
const triggerCenter = rect.left + rect.width / 2;
|
|
421
|
+
// eslint-disable-next-line @eslint-react/set-state-in-effect -- side is resolved from trigger layout immediately before showModal()
|
|
422
|
+
setResolvedSide(
|
|
423
|
+
triggerCenter < window.innerWidth / 2 ? 'start' : 'end',
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
// eslint-disable-next-line @eslint-react/set-state-in-effect -- side prop changes must update the open dialog placement
|
|
428
|
+
setResolvedSide(side);
|
|
429
|
+
}
|
|
430
|
+
}, [isOpen, side]);
|
|
431
|
+
|
|
323
432
|
// Open/close the dialog via showModal()/close()
|
|
324
433
|
// close() is delayed so the slide-out transition can play.
|
|
325
434
|
useEffect(() => {
|
|
@@ -328,28 +437,7 @@ export function MobileNav({
|
|
|
328
437
|
return;
|
|
329
438
|
}
|
|
330
439
|
|
|
331
|
-
if (closeTimeoutRef.current) {
|
|
332
|
-
clearTimeout(closeTimeoutRef.current);
|
|
333
|
-
closeTimeoutRef.current = null;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
440
|
if (isOpen) {
|
|
337
|
-
// Determine drawer side from trigger position when auto
|
|
338
|
-
if (side === 'auto') {
|
|
339
|
-
const trigger = document.activeElement as HTMLElement | null;
|
|
340
|
-
if (trigger && trigger !== document.body) {
|
|
341
|
-
const rect = trigger.getBoundingClientRect();
|
|
342
|
-
const triggerCenter = rect.left + rect.width / 2;
|
|
343
|
-
// eslint-disable-next-line @eslint-react/set-state-in-effect -- side is resolved from trigger layout immediately before showModal()
|
|
344
|
-
setResolvedSide(
|
|
345
|
-
triggerCenter < window.innerWidth / 2 ? 'start' : 'end',
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
} else {
|
|
349
|
-
// eslint-disable-next-line @eslint-react/set-state-in-effect -- side prop changes must update the open dialog placement
|
|
350
|
-
setResolvedSide(side);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
441
|
if (!dialog.open) {
|
|
354
442
|
dialog.showModal();
|
|
355
443
|
}
|
|
@@ -360,13 +448,9 @@ export function MobileNav({
|
|
|
360
448
|
} else if (dialog.open) {
|
|
361
449
|
document.documentElement.style.overflow = '';
|
|
362
450
|
|
|
363
|
-
const duration = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
364
|
-
.matches
|
|
365
|
-
? 10
|
|
366
|
-
: 250;
|
|
367
451
|
closeTimeoutRef.current = setTimeout(() => {
|
|
368
452
|
dialog.close();
|
|
369
|
-
},
|
|
453
|
+
}, resolveCloseDelay(dialog));
|
|
370
454
|
}
|
|
371
455
|
|
|
372
456
|
return () => {
|
|
@@ -375,20 +459,27 @@ export function MobileNav({
|
|
|
375
459
|
closeTimeoutRef.current = null;
|
|
376
460
|
}
|
|
377
461
|
document.documentElement.style.overflow = '';
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
462
|
+
};
|
|
463
|
+
}, [isOpen]);
|
|
464
|
+
|
|
465
|
+
// Close the native dialog on unmount if it's still open. Inside AppShell the
|
|
466
|
+
// drawer is mounted in an <Activity> that switches to mode="hidden" when the
|
|
467
|
+
// drawer closes; React then runs effect cleanups (with a stale isOpen)
|
|
468
|
+
// instead of re-running the effect with isOpen=false, so the close branch
|
|
469
|
+
// above never fires. If we leave the <dialog> `open` here, showModal() is
|
|
470
|
+
// skipped on the next open (the dialog is already open in the hidden tree)
|
|
471
|
+
// and the drawer can never be re-opened — see MobileNavReopen.test.tsx.
|
|
472
|
+
// This must be a separate unmount-only effect: putting it in the open/close
|
|
473
|
+
// effect above would close the dialog on every isOpen flip and cut off the
|
|
474
|
+
// delayed slide-out close.
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
const dialog = dialogRef.current;
|
|
477
|
+
return () => {
|
|
478
|
+
if (dialog?.open) {
|
|
388
479
|
dialog.close();
|
|
389
480
|
}
|
|
390
481
|
};
|
|
391
|
-
}, [
|
|
482
|
+
}, []);
|
|
392
483
|
|
|
393
484
|
// Handle native cancel event (Escape key) — prevent default and route through onOpenChange
|
|
394
485
|
const handleCancel = useCallback(
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file MobileNavCloseEdgeCases.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, MobileNav + AppShell + SideNav
|
|
6
|
+
* @output Edge-case coverage for the deferred close introduced for #4290
|
|
7
|
+
* @position Testing; guards the close path of MobileNav.tsx
|
|
8
|
+
*
|
|
9
|
+
* #4290 moved the unmount close into its own effect so the deferred slide-out
|
|
10
|
+
* close is no longer cut off by its own cleanup. That split is the risky part:
|
|
11
|
+
* the close now depends on a timer surviving to fire, and on the unmount-only
|
|
12
|
+
* effect catching every teardown the timer misses. These tests cover the paths
|
|
13
|
+
* where the two could disagree and leave a modal dialog open — which blocks the
|
|
14
|
+
* whole document, whether or not the drawer is rendered.
|
|
15
|
+
*
|
|
16
|
+
* The invariant behind all of them: the drawer must never be left open. Being
|
|
17
|
+
* slow to close is a glitch; never closing is a wedged page.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
describe,
|
|
22
|
+
it,
|
|
23
|
+
expect,
|
|
24
|
+
vi,
|
|
25
|
+
beforeAll,
|
|
26
|
+
beforeEach,
|
|
27
|
+
afterEach,
|
|
28
|
+
} from 'vitest';
|
|
29
|
+
import {StrictMode} from 'react';
|
|
30
|
+
import {render, screen, fireEvent, act} from '@testing-library/react';
|
|
31
|
+
import {MobileNav} from './MobileNav';
|
|
32
|
+
import {AppShell} from '../AppShell/AppShell';
|
|
33
|
+
import {SideNav, SideNavItem, SideNavSection} from '../SideNav';
|
|
34
|
+
import {stubMatchMedia} from '../__tests__/stubMatchMedia';
|
|
35
|
+
|
|
36
|
+
// jsdom doesn't implement showModal/close on <dialog>, so we mock them
|
|
37
|
+
beforeAll(() => {
|
|
38
|
+
HTMLDialogElement.prototype.showModal =
|
|
39
|
+
HTMLDialogElement.prototype.showModal ||
|
|
40
|
+
function (this: HTMLDialogElement) {
|
|
41
|
+
this.setAttribute('open', '');
|
|
42
|
+
};
|
|
43
|
+
HTMLDialogElement.prototype.close =
|
|
44
|
+
HTMLDialogElement.prototype.close ||
|
|
45
|
+
function (this: HTMLDialogElement) {
|
|
46
|
+
this.removeAttribute('open');
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
class MockResizeObserver {
|
|
51
|
+
observe() {}
|
|
52
|
+
unobserve() {}
|
|
53
|
+
disconnect() {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// `matches` has to depend on the query: a blanket `matches: true` puts AppShell
|
|
57
|
+
// below its breakpoint, which these tests need, but it also matches
|
|
58
|
+
// `prefers-reduced-motion`, which caps the close delay at 0 — every test here
|
|
59
|
+
// would run against an immediate close while appearing to exercise the real
|
|
60
|
+
// delay. See the header of stubMatchMedia.ts.
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.stubGlobal('ResizeObserver', MockResizeObserver);
|
|
64
|
+
stubMatchMedia({reduceMotion: false});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
document.documentElement.style.overflow = '';
|
|
69
|
+
vi.restoreAllMocks();
|
|
70
|
+
vi.unstubAllGlobals();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function Drawer({
|
|
74
|
+
isOpen,
|
|
75
|
+
side,
|
|
76
|
+
}: {
|
|
77
|
+
isOpen: boolean;
|
|
78
|
+
side?: 'start' | 'end' | 'auto';
|
|
79
|
+
}) {
|
|
80
|
+
return (
|
|
81
|
+
<MobileNav
|
|
82
|
+
isOpen={isOpen}
|
|
83
|
+
onOpenChange={() => {}}
|
|
84
|
+
side={side}
|
|
85
|
+
data-testid="mobile-nav">
|
|
86
|
+
<span>Nav content</span>
|
|
87
|
+
</MobileNav>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function TestShell() {
|
|
92
|
+
return (
|
|
93
|
+
<AppShell
|
|
94
|
+
sideNav={
|
|
95
|
+
<SideNav>
|
|
96
|
+
<SideNavSection title="Test" isHeaderHidden>
|
|
97
|
+
<SideNavItem label="Home" />
|
|
98
|
+
</SideNavSection>
|
|
99
|
+
</SideNav>
|
|
100
|
+
}
|
|
101
|
+
mobileNav={{breakpoint: 'md'}}>
|
|
102
|
+
<div>Content</div>
|
|
103
|
+
</AppShell>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('MobileNav close path edge cases', () => {
|
|
108
|
+
it('survives StrictMode double-invoked effects', () => {
|
|
109
|
+
vi.useFakeTimers();
|
|
110
|
+
try {
|
|
111
|
+
const {rerender} = render(
|
|
112
|
+
<StrictMode>
|
|
113
|
+
<Drawer isOpen />
|
|
114
|
+
</StrictMode>,
|
|
115
|
+
);
|
|
116
|
+
const dialog = screen.getByTestId('mobile-nav');
|
|
117
|
+
|
|
118
|
+
// StrictMode mounts effects, tears them down, and mounts them again. The
|
|
119
|
+
// unmount-only effect closes the dialog during that teardown, so the
|
|
120
|
+
// open/close effect has to re-open it on the second mount.
|
|
121
|
+
expect(dialog).toHaveAttribute('open');
|
|
122
|
+
|
|
123
|
+
act(() => {
|
|
124
|
+
rerender(
|
|
125
|
+
<StrictMode>
|
|
126
|
+
<Drawer isOpen={false} />
|
|
127
|
+
</StrictMode>,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
act(() => {
|
|
131
|
+
vi.advanceTimersByTime(300);
|
|
132
|
+
});
|
|
133
|
+
expect(dialog).not.toHaveAttribute('open');
|
|
134
|
+
} finally {
|
|
135
|
+
vi.useRealTimers();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('closes the dialog when unmounted while fully open', () => {
|
|
140
|
+
const {unmount} = render(<Drawer isOpen />);
|
|
141
|
+
const dialog = screen.getByTestId('mobile-nav');
|
|
142
|
+
expect(dialog).toHaveAttribute('open');
|
|
143
|
+
|
|
144
|
+
unmount();
|
|
145
|
+
|
|
146
|
+
// Left open, the next showModal() is skipped and the drawer can never be
|
|
147
|
+
// re-opened (#3091) — and the document stays blocked meanwhile.
|
|
148
|
+
expect(dialog).not.toHaveAttribute('open');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('does not restart the pending close when the side prop changes', () => {
|
|
152
|
+
vi.useFakeTimers();
|
|
153
|
+
try {
|
|
154
|
+
const {rerender} = render(<Drawer isOpen side="start" />);
|
|
155
|
+
const dialog = screen.getByTestId('mobile-nav');
|
|
156
|
+
|
|
157
|
+
act(() => {
|
|
158
|
+
rerender(<Drawer isOpen={false} side="start" />);
|
|
159
|
+
});
|
|
160
|
+
expect(dialog).toHaveAttribute('open');
|
|
161
|
+
|
|
162
|
+
// Most of the way through the pending close...
|
|
163
|
+
act(() => {
|
|
164
|
+
vi.advanceTimersByTime(200);
|
|
165
|
+
});
|
|
166
|
+
expect(dialog).toHaveAttribute('open');
|
|
167
|
+
|
|
168
|
+
// ...then change the side. Side resolution lives in its own effect, so
|
|
169
|
+
// this has to leave the close alone. Were `side` a dep of the open/close
|
|
170
|
+
// effect, its cleanup would cancel the pending close and re-arm a fresh
|
|
171
|
+
// full delay — while the CSS hold keeps running from the commit that
|
|
172
|
+
// started the slide-out. The close would then land after the drawer had
|
|
173
|
+
// stopped being rendered, which is the #4290 state again.
|
|
174
|
+
//
|
|
175
|
+
// The split advance is the whole point: the total must clear one delay
|
|
176
|
+
// but not two.
|
|
177
|
+
act(() => {
|
|
178
|
+
rerender(<Drawer isOpen={false} side="end" />);
|
|
179
|
+
});
|
|
180
|
+
act(() => {
|
|
181
|
+
vi.advanceTimersByTime(100);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(dialog).not.toHaveAttribute('open');
|
|
185
|
+
} finally {
|
|
186
|
+
vi.useRealTimers();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('settles closed after a rapid open/close/open/close burst', () => {
|
|
191
|
+
vi.useFakeTimers();
|
|
192
|
+
try {
|
|
193
|
+
const {rerender} = render(<Drawer isOpen={false} />);
|
|
194
|
+
const dialog = screen.getByTestId('mobile-nav');
|
|
195
|
+
|
|
196
|
+
act(() => {
|
|
197
|
+
rerender(<Drawer isOpen />);
|
|
198
|
+
});
|
|
199
|
+
act(() => {
|
|
200
|
+
rerender(<Drawer isOpen={false} />);
|
|
201
|
+
});
|
|
202
|
+
act(() => {
|
|
203
|
+
rerender(<Drawer isOpen />);
|
|
204
|
+
});
|
|
205
|
+
act(() => {
|
|
206
|
+
rerender(<Drawer isOpen={false} />);
|
|
207
|
+
});
|
|
208
|
+
act(() => {
|
|
209
|
+
vi.advanceTimersByTime(300);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(dialog).not.toHaveAttribute('open');
|
|
213
|
+
} finally {
|
|
214
|
+
vi.useRealTimers();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('restores documentElement overflow once the drawer has closed', () => {
|
|
219
|
+
vi.useFakeTimers();
|
|
220
|
+
try {
|
|
221
|
+
const {rerender} = render(<Drawer isOpen />);
|
|
222
|
+
const dialog = screen.getByTestId('mobile-nav');
|
|
223
|
+
expect(document.documentElement.style.overflow).toBe('clip');
|
|
224
|
+
|
|
225
|
+
// Two acts on purpose: the close timer is armed by the effect that runs
|
|
226
|
+
// when the first act flushes, so advancing inside that same act would
|
|
227
|
+
// advance past nothing and the drawer would never actually close.
|
|
228
|
+
act(() => {
|
|
229
|
+
rerender(<Drawer isOpen={false} />);
|
|
230
|
+
});
|
|
231
|
+
act(() => {
|
|
232
|
+
vi.advanceTimersByTime(300);
|
|
233
|
+
});
|
|
234
|
+
// Pin the close itself. The effect cleanup resets overflow on every
|
|
235
|
+
// isOpen flip, so the overflow assertion alone passes even with the whole
|
|
236
|
+
// close path deleted — the test name would be the only thing left of it.
|
|
237
|
+
expect(dialog).not.toHaveAttribute('open');
|
|
238
|
+
// A drawer that closes but leaves the page scroll-locked is its own
|
|
239
|
+
// flavour of "the page is broken and nothing says why".
|
|
240
|
+
expect(document.documentElement.style.overflow).toBe('');
|
|
241
|
+
} finally {
|
|
242
|
+
vi.useRealTimers();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('restores documentElement overflow when unmounted while open', () => {
|
|
247
|
+
const {unmount} = render(<Drawer isOpen />);
|
|
248
|
+
expect(document.documentElement.style.overflow).toBe('clip');
|
|
249
|
+
|
|
250
|
+
unmount();
|
|
251
|
+
|
|
252
|
+
expect(document.documentElement.style.overflow).toBe('');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('leaves no pending close timer behind after unmount', () => {
|
|
256
|
+
vi.useFakeTimers();
|
|
257
|
+
try {
|
|
258
|
+
const {rerender, unmount} = render(<Drawer isOpen />);
|
|
259
|
+
act(() => {
|
|
260
|
+
rerender(<Drawer isOpen={false} />);
|
|
261
|
+
});
|
|
262
|
+
unmount();
|
|
263
|
+
|
|
264
|
+
// The queued close must be cancelled, not left to fire against a
|
|
265
|
+
// detached node.
|
|
266
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
267
|
+
} finally {
|
|
268
|
+
vi.useRealTimers();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('closes inside the display hold when a theme shortens it', () => {
|
|
273
|
+
// The hold is `--duration-medium`, which themes rewrite: the shipped y2k
|
|
274
|
+
// theme uses 250ms and the documented "Snappy" preset does too. A close
|
|
275
|
+
// scheduled on the hold's boundary lands on the frame the drawer stops
|
|
276
|
+
// being rendered — which is exactly the #4290 state. The delay has to be
|
|
277
|
+
// derived from the hold actually in effect, not assumed.
|
|
278
|
+
// Full motion (the file default) — under reduced motion the close is
|
|
279
|
+
// immediate and the boundary is never exercised.
|
|
280
|
+
vi.useFakeTimers();
|
|
281
|
+
try {
|
|
282
|
+
const hold = 250;
|
|
283
|
+
const {rerender} = render(
|
|
284
|
+
<MobileNav
|
|
285
|
+
isOpen
|
|
286
|
+
onOpenChange={() => {}}
|
|
287
|
+
style={{transitionDuration: `${hold}ms`}}
|
|
288
|
+
data-testid="mobile-nav">
|
|
289
|
+
<span>Nav content</span>
|
|
290
|
+
</MobileNav>,
|
|
291
|
+
);
|
|
292
|
+
const dialog = screen.getByTestId('mobile-nav');
|
|
293
|
+
expect(getComputedStyle(dialog).transitionDuration).toBe(`${hold}ms`);
|
|
294
|
+
|
|
295
|
+
act(() => {
|
|
296
|
+
rerender(
|
|
297
|
+
<MobileNav
|
|
298
|
+
isOpen={false}
|
|
299
|
+
onOpenChange={() => {}}
|
|
300
|
+
style={{transitionDuration: `${hold}ms`}}
|
|
301
|
+
data-testid="mobile-nav">
|
|
302
|
+
<span>Nav content</span>
|
|
303
|
+
</MobileNav>,
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
act(() => {
|
|
307
|
+
vi.advanceTimersByTime(hold - 1);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(dialog).not.toHaveAttribute('open');
|
|
311
|
+
} finally {
|
|
312
|
+
vi.useRealTimers();
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('closes on the next macrotask when the hold is zero', () => {
|
|
317
|
+
// A theme can set --duration-medium to 0 — nothing validates motion values
|
|
318
|
+
// — and then `display` flips on the next style recalc with no hold at all.
|
|
319
|
+
// No delay is safe at that point, so the close has to go out immediately
|
|
320
|
+
// rather than fall back to the cap and land a quarter-second after the
|
|
321
|
+
// drawer stopped being rendered, which is #4290 restored.
|
|
322
|
+
vi.useFakeTimers();
|
|
323
|
+
try {
|
|
324
|
+
const style = {transitionDuration: '0ms'};
|
|
325
|
+
const {rerender} = render(
|
|
326
|
+
<MobileNav
|
|
327
|
+
isOpen
|
|
328
|
+
onOpenChange={() => {}}
|
|
329
|
+
style={style}
|
|
330
|
+
data-testid="mobile-nav">
|
|
331
|
+
<span>Nav content</span>
|
|
332
|
+
</MobileNav>,
|
|
333
|
+
);
|
|
334
|
+
const dialog = screen.getByTestId('mobile-nav');
|
|
335
|
+
|
|
336
|
+
act(() => {
|
|
337
|
+
rerender(
|
|
338
|
+
<MobileNav
|
|
339
|
+
isOpen={false}
|
|
340
|
+
onOpenChange={() => {}}
|
|
341
|
+
style={style}
|
|
342
|
+
data-testid="mobile-nav">
|
|
343
|
+
<span>Nav content</span>
|
|
344
|
+
</MobileNav>,
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
act(() => {
|
|
348
|
+
vi.advanceTimersByTime(1);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(dialog).not.toHaveAttribute('open');
|
|
352
|
+
} finally {
|
|
353
|
+
vi.useRealTimers();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('closes on the next macrotask under reduced motion', () => {
|
|
358
|
+
// Reduced motion should make the close *sooner*, not the safety margin
|
|
359
|
+
// *smaller*. Shortening the hold to match leaves no slack: one slow frame
|
|
360
|
+
// between the commit and the close macrotask and the drawer has already
|
|
361
|
+
// stopped being rendered — #4290 again, on the accessibility setting.
|
|
362
|
+
//
|
|
363
|
+
// Only the delay is assertable here. That the hold itself stays long is a
|
|
364
|
+
// CSS fact — styles.dialog carries no reduced-motion override, unlike
|
|
365
|
+
// styles.drawer and ::backdrop — and jsdom does not evaluate media queries
|
|
366
|
+
// inside stylesheets, so there is nothing to read back.
|
|
367
|
+
stubMatchMedia({reduceMotion: true});
|
|
368
|
+
vi.useFakeTimers();
|
|
369
|
+
try {
|
|
370
|
+
const style = {transitionDuration: '410ms'};
|
|
371
|
+
const {rerender} = render(
|
|
372
|
+
<MobileNav
|
|
373
|
+
isOpen
|
|
374
|
+
onOpenChange={() => {}}
|
|
375
|
+
style={style}
|
|
376
|
+
data-testid="mobile-nav">
|
|
377
|
+
<span>Nav content</span>
|
|
378
|
+
</MobileNav>,
|
|
379
|
+
);
|
|
380
|
+
const dialog = screen.getByTestId('mobile-nav');
|
|
381
|
+
|
|
382
|
+
act(() => {
|
|
383
|
+
rerender(
|
|
384
|
+
<MobileNav
|
|
385
|
+
isOpen={false}
|
|
386
|
+
onOpenChange={() => {}}
|
|
387
|
+
style={style}
|
|
388
|
+
data-testid="mobile-nav">
|
|
389
|
+
<span>Nav content</span>
|
|
390
|
+
</MobileNav>,
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
act(() => {
|
|
394
|
+
vi.advanceTimersByTime(1);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(dialog).not.toHaveAttribute('open');
|
|
398
|
+
} finally {
|
|
399
|
+
vi.useRealTimers();
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('closes the AppShell drawer when dismissed with Escape', () => {
|
|
404
|
+
render(<TestShell />);
|
|
405
|
+
fireEvent.click(screen.getByRole('button', {name: /open navigation/i}));
|
|
406
|
+
const dialog = screen.getAllByRole('dialog', {hidden: true})[0];
|
|
407
|
+
expect(dialog).toHaveAttribute('open');
|
|
408
|
+
|
|
409
|
+
fireEvent(dialog, new Event('cancel', {cancelable: true, bubbles: false}));
|
|
410
|
+
|
|
411
|
+
expect(dialog).not.toHaveAttribute('open');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('closes the AppShell drawer when the backdrop is clicked', () => {
|
|
415
|
+
render(<TestShell />);
|
|
416
|
+
fireEvent.click(screen.getByRole('button', {name: /open navigation/i}));
|
|
417
|
+
const dialog = screen.getAllByRole('dialog', {hidden: true})[0];
|
|
418
|
+
expect(dialog).toHaveAttribute('open');
|
|
419
|
+
|
|
420
|
+
// A click landing on the dialog itself, not the drawer panel, is a
|
|
421
|
+
// backdrop dismiss.
|
|
422
|
+
fireEvent.click(dialog);
|
|
423
|
+
|
|
424
|
+
expect(dialog).not.toHaveAttribute('open');
|
|
425
|
+
});
|
|
426
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file MobileNavCloseTiming.test.ts
|
|
5
|
+
* @input Uses vitest, parseShortestDurationMs from MobileNav
|
|
6
|
+
* @output Unit coverage for the close-delay duration parsing (#4290)
|
|
7
|
+
* @position Testing; guards the timing helper behind MobileNav's deferred close
|
|
8
|
+
*
|
|
9
|
+
* Through the component this helper is only ever reachable via jsdom, which
|
|
10
|
+
* echoes an inline `250ms` back verbatim and never resolves `var()`. Browsers do
|
|
11
|
+
* neither: they serialise computed `<time>` values in seconds, so `"0.41s"` is
|
|
12
|
+
* the only shape the helper sees in production and `"250ms"` is a shape it never
|
|
13
|
+
* sees. Reading it directly is the only way the seconds branch, a list, and a
|
|
14
|
+
* zero hold get covered at all.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {describe, it, expect} from 'vitest';
|
|
18
|
+
import {parseShortestDurationMs} from './MobileNav';
|
|
19
|
+
|
|
20
|
+
describe('parseShortestDurationMs', () => {
|
|
21
|
+
it.each([
|
|
22
|
+
// How browsers actually serialise a computed transition-duration.
|
|
23
|
+
['0.41s', 410],
|
|
24
|
+
['0.25s', 250],
|
|
25
|
+
['1s', 1000],
|
|
26
|
+
// jsdom's shape: an inline declaration echoed back unchanged.
|
|
27
|
+
['250ms', 250],
|
|
28
|
+
// Lists. The dialog transitions one property today, but a style or xstyle
|
|
29
|
+
// prop can add more, and only the shortest entry is safe to close inside.
|
|
30
|
+
['0.41s, 0.12s', 120],
|
|
31
|
+
['120ms, 410ms', 120],
|
|
32
|
+
// A zero hold is the #4290 state itself. It has to read as 0 — meaning
|
|
33
|
+
// "close now" — rather than as unreadable, which would fall back to the cap
|
|
34
|
+
// and schedule the close long after the drawer stopped being rendered.
|
|
35
|
+
['0s', 0],
|
|
36
|
+
['0ms', 0],
|
|
37
|
+
])('reads %j as %ims', (value, expected) => {
|
|
38
|
+
expect(parseShortestDurationMs(value)).toBeCloseTo(expected, 6);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it.each([
|
|
42
|
+
// An unresolved var() — jsdom, or any style read outside a browser.
|
|
43
|
+
['var(--duration-medium)'],
|
|
44
|
+
// A CSS math function reads as unreadable too, so the caller falls back to
|
|
45
|
+
// the cap rather than deriving a delay from a number it did not compute.
|
|
46
|
+
['max(150ms, var(--duration-medium))'],
|
|
47
|
+
// A bare number is not a duration.
|
|
48
|
+
['250'],
|
|
49
|
+
[''],
|
|
50
|
+
[' '],
|
|
51
|
+
])('reports %j as unreadable', value => {
|
|
52
|
+
expect(parseShortestDurationMs(value)).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('skips unreadable entries rather than discarding the whole list', () => {
|
|
56
|
+
expect(parseShortestDurationMs('var(--x), 0.3s')).toBeCloseTo(300, 6);
|
|
57
|
+
});
|
|
58
|
+
});
|