@astryxdesign/core 0.4.3 → 0.4.4-canary.0f4d45f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +79 -45
  3. package/dist/Banner/Banner.d.ts +3 -0
  4. package/dist/Banner/Banner.d.ts.map +1 -1
  5. package/dist/Banner/Banner.js +13 -3
  6. package/dist/BottomSheet/BottomSheet.d.ts +66 -0
  7. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  8. package/dist/BottomSheet/BottomSheet.js +392 -0
  9. package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
  10. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  11. package/dist/BottomSheet/BottomSheetPanel.js +398 -0
  12. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  13. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  14. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  15. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  16. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  17. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  18. package/dist/BottomSheet/index.d.ts +11 -0
  19. package/dist/BottomSheet/index.d.ts.map +1 -0
  20. package/dist/BottomSheet/index.js +12 -0
  21. package/dist/BottomSheet/snapOffsets.d.ts +72 -0
  22. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  23. package/dist/BottomSheet/snapOffsets.js +130 -0
  24. package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
  25. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  26. package/dist/BottomSheet/useMobileKeyboard.js +373 -0
  27. package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
  28. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  29. package/dist/BottomSheet/useSheetGestures.js +989 -0
  30. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  31. package/dist/Chat/ChatComposerInput.js +3 -4
  32. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  33. package/dist/ComplexSelector/ComplexSelector.js +12 -6
  34. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  35. package/dist/ContextMenu/ContextMenu.js +4 -2
  36. package/dist/DateInput/DateInput.d.ts.map +1 -1
  37. package/dist/DateInput/DateInput.js +9 -1
  38. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  39. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  40. package/dist/Dialog/Dialog.d.ts.map +1 -1
  41. package/dist/Dialog/Dialog.js +2 -1
  42. package/dist/Field/InputClearButton.d.ts +2 -1
  43. package/dist/Field/InputClearButton.d.ts.map +1 -1
  44. package/dist/Field/InputClearButton.js +6 -1
  45. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  46. package/dist/Lightbox/Lightbox.js +9 -3
  47. package/dist/MobileNav/MobileNav.d.ts +12 -0
  48. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  49. package/dist/MobileNav/MobileNav.js +102 -30
  50. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  51. package/dist/MultiSelector/MultiSelector.js +21 -7
  52. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  53. package/dist/NumberInput/NumberInput.js +9 -1
  54. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  55. package/dist/Selector/Selector.d.ts.map +1 -1
  56. package/dist/Selector/Selector.js +14 -3
  57. package/dist/StatusDot/StatusDot.d.ts +43 -3
  58. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  59. package/dist/StatusDot/StatusDot.js +42 -5
  60. package/dist/Switch/Switch.d.ts.map +1 -1
  61. package/dist/Switch/Switch.js +5 -1
  62. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  63. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  64. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  65. package/dist/TimeInput/TimeInput.js +8 -1
  66. package/dist/Tokenizer/Tokenizer.js +11 -5
  67. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  68. package/dist/Tooltip/useTooltip.js +4 -1
  69. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  70. package/dist/Typeahead/BaseTypeahead.js +5 -3
  71. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  72. package/dist/Typeahead/Typeahead.js +10 -1
  73. package/dist/astryx.css +21 -0
  74. package/dist/hooks/index.d.ts +8 -1
  75. package/dist/hooks/index.d.ts.map +1 -1
  76. package/dist/hooks/index.js +8 -1
  77. package/dist/hooks/useFocusTrap.d.ts +0 -11
  78. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  79. package/dist/hooks/useFocusTrap.js +1 -11
  80. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  81. package/dist/hooks/useInputStatusIcon.js +1 -0
  82. package/dist/index.d.ts +1 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +1 -0
  85. package/dist/theme/defineTheme.d.ts +22 -4
  86. package/dist/theme/defineTheme.d.ts.map +1 -1
  87. package/dist/theme/expandColorScale.d.ts +17 -2
  88. package/dist/theme/expandColorScale.d.ts.map +1 -1
  89. package/dist/theme/expandColorScale.js +63 -33
  90. package/dist/utils/ime.d.ts +5 -0
  91. package/dist/utils/ime.d.ts.map +1 -0
  92. package/dist/utils/ime.js +63 -0
  93. package/dist/utils/index.d.ts +1 -0
  94. package/dist/utils/index.d.ts.map +1 -1
  95. package/dist/utils/index.js +2 -1
  96. package/locales/af-ZA.json +958 -0
  97. package/locales/ar-SA.json +998 -0
  98. package/locales/ca-ES.json +974 -0
  99. package/locales/cs-CZ.json +990 -0
  100. package/locales/da-DK.json +966 -0
  101. package/locales/de-DE.json +966 -0
  102. package/locales/el-GR.json +990 -0
  103. package/locales/en.json +48 -0
  104. package/locales/es-ES.json +978 -0
  105. package/locales/fi-FI.json +990 -0
  106. package/locales/fr-FR.json +928 -0
  107. package/locales/he-IL.json +990 -0
  108. package/locales/hu-HU.json +990 -0
  109. package/locales/it-IT.json +978 -0
  110. package/locales/ja-JP.json +998 -0
  111. package/locales/ko-KR.json +990 -0
  112. package/locales/nl-NL.json +954 -0
  113. package/locales/no-NO.json +978 -0
  114. package/locales/pl-PL.json +982 -0
  115. package/locales/pseudo.json +36 -0
  116. package/locales/pt-BR.json +982 -0
  117. package/locales/pt-PT.json +982 -0
  118. package/locales/ro-RO.json +982 -0
  119. package/locales/ru-RU.json +990 -0
  120. package/locales/sr-SP.json +990 -0
  121. package/locales/sv-SE.json +986 -0
  122. package/locales/tr-TR.json +990 -0
  123. package/locales/uk-UA.json +990 -0
  124. package/locales/vi-VN.json +986 -0
  125. package/locales/zh-CN.json +998 -0
  126. package/locales/zh-TW.json +998 -0
  127. package/package.json +9 -8
  128. package/src/Banner/Banner.doc.mjs +3 -3
  129. package/src/Banner/Banner.test.tsx +40 -0
  130. package/src/Banner/Banner.tsx +27 -2
  131. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  132. package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
  133. package/src/BottomSheet/BottomSheet.tsx +545 -0
  134. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  135. package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
  136. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  137. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  138. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  139. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  140. package/src/BottomSheet/index.ts +15 -0
  141. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  142. package/src/BottomSheet/snapOffsets.ts +159 -0
  143. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  144. package/src/BottomSheet/useSheetGestures.test.ts +1059 -0
  145. package/src/BottomSheet/useSheetGestures.ts +1459 -0
  146. package/src/Chat/ChatComposerInput.tsx +3 -4
  147. package/src/ComplexSelector/ComplexSelector.tsx +12 -5
  148. package/src/ContextMenu/ContextMenu.tsx +4 -2
  149. package/src/DateInput/DateInput.test.tsx +21 -0
  150. package/src/DateInput/DateInput.tsx +9 -1
  151. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  152. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  153. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  154. package/src/Dialog/Dialog.tsx +2 -1
  155. package/src/Field/Field.doc.mjs +1 -0
  156. package/src/Field/InputClearButton.test.tsx +25 -0
  157. package/src/Field/InputClearButton.tsx +4 -1
  158. package/src/FileInput/FileInput.test.tsx +53 -9
  159. package/src/Lightbox/Lightbox.test.tsx +49 -28
  160. package/src/Lightbox/Lightbox.tsx +7 -3
  161. package/src/MobileNav/MobileNav.tsx +128 -37
  162. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  163. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  164. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  165. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  166. package/src/MultiSelector/MultiSelector.tsx +22 -8
  167. package/src/NumberInput/NumberInput.test.tsx +18 -0
  168. package/src/NumberInput/NumberInput.tsx +9 -1
  169. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  170. package/src/Selector/Selector.test.tsx +85 -26
  171. package/src/Selector/Selector.tsx +13 -4
  172. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  173. package/src/StatusDot/StatusDot.test.tsx +115 -8
  174. package/src/StatusDot/StatusDot.tsx +74 -9
  175. package/src/Switch/Switch.test.tsx +16 -0
  176. package/src/Switch/Switch.tsx +7 -0
  177. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  178. package/src/TimeInput/TimeInput.test.tsx +23 -0
  179. package/src/TimeInput/TimeInput.tsx +8 -0
  180. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  181. package/src/Tokenizer/Tokenizer.tsx +5 -5
  182. package/src/Tooltip/useTooltip.tsx +4 -1
  183. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  184. package/src/Typeahead/Typeahead.test.tsx +116 -7
  185. package/src/Typeahead/Typeahead.tsx +10 -1
  186. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  187. package/src/__tests__/stubMatchMedia.ts +95 -0
  188. package/src/hooks/index.ts +9 -5
  189. package/src/hooks/useFocusTrap.ts +1 -14
  190. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  191. package/src/hooks/useInputStatusIcon.tsx +7 -0
  192. package/src/index.ts +1 -0
  193. package/src/theme/defineTheme.ts +22 -4
  194. package/src/theme/expandColorScale.test.ts +138 -0
  195. package/src/theme/expandColorScale.ts +89 -44
  196. package/src/utils/ime.test.ts +32 -0
  197. package/src/utils/ime.ts +69 -0
  198. package/src/utils/index.ts +2 -0
  199. package/dist/astryx.umd.js +0 -147
  200. package/dist/astryx.umd.js.map +0 -7
@@ -4,6 +4,7 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
4
4
  import {render, screen, fireEvent, waitFor} from '@testing-library/react';
5
5
  import {Lightbox} from './Lightbox';
6
6
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
7
+ import {InternationalizationProvider} from '../i18n';
7
8
 
8
9
  // Mock showModal/close for jsdom
9
10
  beforeEach(() => {
@@ -27,6 +28,17 @@ function politeRegion(): HTMLElement | null {
27
28
  return document.querySelector('[data-astryx-live-region="polite"]');
28
29
  }
29
30
 
31
+ // Both position messages, supplied by the test. Overriding the pair means an
32
+ // assertion also proves which of the two keys the component reached for.
33
+ const POSITION_MESSAGES = {
34
+ fr: {
35
+ '@astryx.lightbox.mediaPosition':
36
+ '{alt}, vue {index, number} sur {total, number}',
37
+ '@astryx.lightbox.imagePosition':
38
+ 'Photo {index, number} sur {total, number}',
39
+ },
40
+ };
41
+
30
42
  describe('Lightbox', () => {
31
43
  it('renders as a dialog element', () => {
32
44
  render(
@@ -320,47 +332,53 @@ describe('Lightbox', () => {
320
332
 
321
333
  it('announces the new image and position when navigating next via button', async () => {
322
334
  render(
323
- <Lightbox
324
- isOpen={true}
325
- onOpenChange={() => {}}
326
- media={media}
327
- defaultIndex={0}
328
- />,
335
+ <InternationalizationProvider locale="fr" overrides={POSITION_MESSAGES}>
336
+ <Lightbox
337
+ isOpen={true}
338
+ onOpenChange={() => {}}
339
+ media={media}
340
+ defaultIndex={0}
341
+ />
342
+ </InternationalizationProvider>,
329
343
  );
330
344
  fireEvent.click(screen.getByLabelText('Next'));
331
345
  await waitFor(() => {
332
- expect(politeRegion()).toHaveTextContent('Image B, 2 of 3');
346
+ expect(politeRegion()?.textContent).toBe('Image B, vue 2 sur 3');
333
347
  });
334
348
  });
335
349
 
336
350
  it('announces the new image and position when navigating via arrow keys', async () => {
337
351
  render(
338
- <Lightbox
339
- isOpen={true}
340
- onOpenChange={() => {}}
341
- media={media}
342
- defaultIndex={1}
343
- />,
352
+ <InternationalizationProvider locale="fr" overrides={POSITION_MESSAGES}>
353
+ <Lightbox
354
+ isOpen={true}
355
+ onOpenChange={() => {}}
356
+ media={media}
357
+ defaultIndex={1}
358
+ />
359
+ </InternationalizationProvider>,
344
360
  );
345
361
  const dialog = document.querySelector('dialog')!;
346
362
  fireEvent.keyDown(dialog, {key: 'ArrowRight'});
347
363
  await waitFor(() => {
348
- expect(politeRegion()).toHaveTextContent('Image C, 3 of 3');
364
+ expect(politeRegion()?.textContent).toBe('Image C, vue 3 sur 3');
349
365
  });
350
366
  });
351
367
 
352
368
  it('announces the new image and position when navigating prev', async () => {
353
369
  render(
354
- <Lightbox
355
- isOpen={true}
356
- onOpenChange={() => {}}
357
- media={media}
358
- defaultIndex={2}
359
- />,
370
+ <InternationalizationProvider locale="fr" overrides={POSITION_MESSAGES}>
371
+ <Lightbox
372
+ isOpen={true}
373
+ onOpenChange={() => {}}
374
+ media={media}
375
+ defaultIndex={2}
376
+ />
377
+ </InternationalizationProvider>,
360
378
  );
361
379
  fireEvent.click(screen.getByLabelText('Previous'));
362
380
  await waitFor(() => {
363
- expect(politeRegion()).toHaveTextContent('Image B, 2 of 3');
381
+ expect(politeRegion()?.textContent).toBe('Image B, vue 2 sur 3');
364
382
  });
365
383
  });
366
384
 
@@ -370,16 +388,19 @@ describe('Lightbox', () => {
370
388
  {src: '/b.jpg', alt: ''},
371
389
  ];
372
390
  render(
373
- <Lightbox
374
- isOpen={true}
375
- onOpenChange={() => {}}
376
- media={unlabeled}
377
- defaultIndex={0}
378
- />,
391
+ <InternationalizationProvider locale="fr" overrides={POSITION_MESSAGES}>
392
+ <Lightbox
393
+ isOpen={true}
394
+ onOpenChange={() => {}}
395
+ media={unlabeled}
396
+ defaultIndex={0}
397
+ />
398
+ </InternationalizationProvider>,
379
399
  );
380
400
  fireEvent.click(screen.getByLabelText('Next'));
381
401
  await waitFor(() => {
382
- expect(politeRegion()).toHaveTextContent('Image 2 of 2');
402
+ // imagePosition, not a mediaPosition with an empty {alt}.
403
+ expect(politeRegion()?.textContent).toBe('Photo 2 sur 2');
383
404
  });
384
405
  });
385
406
 
@@ -378,9 +378,13 @@ export function Lightbox({
378
378
  return;
379
379
  }
380
380
  const item = mediaArray[Math.min(index, mediaArray.length - 1)];
381
- const position = `${index + 1} of ${mediaArray.length}`;
382
- announce(item?.alt ? `${item.alt}, ${position}` : `Image ${position}`);
383
- }, [index, isOpen, announce, mediaArray]);
381
+ const position = {index: index + 1, total: mediaArray.length};
382
+ announce(
383
+ item?.alt
384
+ ? t('@astryx.lightbox.mediaPosition', {alt: item.alt, ...position})
385
+ : t('@astryx.lightbox.imagePosition', position),
386
+ );
387
+ }, [index, isOpen, announce, mediaArray, t]);
384
388
 
385
389
  // Open/close dialog
386
390
  useIsomorphicLayoutEffect(() => {
@@ -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
- }, duration);
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
- // Close the native dialog on teardown if it's still open. Inside AppShell
379
- // the drawer is mounted in an <Activity> that switches to mode="hidden"
380
- // when the drawer closes; React then runs this cleanup (with a stale
381
- // isOpen) instead of re-running the effect with isOpen=false, so the
382
- // close branch above never fires. If we leave the <dialog> `open` here,
383
- // showModal() is skipped on the next open (the dialog is already open in
384
- // the hidden tree) and the drawer can never be re-opened. Closing it
385
- // unconditionally on teardown keeps the native dialog state in sync so a
386
- // subsequent open cleanly calls showModal() again.
387
- if (dialog.open) {
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
- }, [isOpen, side]);
482
+ }, []);
392
483
 
393
484
  // Handle native cancel event (Escape key) — prevent default and route through onOpenChange
394
485
  const handleCancel = useCallback(