@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
@@ -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
+ });
@@ -0,0 +1,172 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file MobileNavCloseVisibility.test.tsx
5
+ * @input Uses vitest, @testing-library/react, MobileNav
6
+ * @output Regression tests keeping the drawer rendered until the dialog closes
7
+ * @position Testing; guards the close path of MobileNav.tsx
8
+ *
9
+ * Repro for #4290 (page unresponsive after closing the drawer in Safari 26.1).
10
+ *
11
+ * A `<dialog>` opened with `showModal()` blocks the rest of the document for as
12
+ * long as it stays in the top layer — whether or not it is rendered. The drawer
13
+ * hid itself the moment `isOpen` flipped, while the native `close()` only ran
14
+ * afterwards from an effect, so `close()` was always called on a dialog that was
15
+ * already `display: none`. A browser that fails to un-block the document when
16
+ * such a dialog closes leaves the page inert with no JavaScript error.
17
+ *
18
+ * The fix keeps `display` in the transition with `allow-discrete`, so the drawer
19
+ * stays rendered until the slide-out finishes, and moves the unmount close into
20
+ * its own effect so the deferred close is no longer cut off by its own cleanup.
21
+ *
22
+ * Note on scope: jsdom has no top layer and no CSS transitions, so the inertness
23
+ * itself is not observable here. These tests pin the two mechanisms that prevent
24
+ * it — the declared transition, and the close actually being deferred.
25
+ */
26
+
27
+ import {describe, it, expect, vi, beforeAll} from 'vitest';
28
+ import {render, screen, act} from '@testing-library/react';
29
+ import {MobileNav} from './MobileNav';
30
+ import {stubMatchMedia} from '../__tests__/stubMatchMedia';
31
+
32
+ // jsdom doesn't implement showModal/close on <dialog>, so we mock them
33
+ beforeAll(() => {
34
+ HTMLDialogElement.prototype.showModal =
35
+ HTMLDialogElement.prototype.showModal ||
36
+ function (this: HTMLDialogElement) {
37
+ this.setAttribute('open', '');
38
+ };
39
+ HTMLDialogElement.prototype.close =
40
+ HTMLDialogElement.prototype.close ||
41
+ function (this: HTMLDialogElement) {
42
+ this.removeAttribute('open');
43
+ };
44
+ });
45
+
46
+ function Drawer({isOpen}: {isOpen: boolean}) {
47
+ return (
48
+ <MobileNav isOpen={isOpen} onOpenChange={() => {}} data-testid="mobile-nav">
49
+ <span>Nav content</span>
50
+ </MobileNav>
51
+ );
52
+ }
53
+
54
+ describe('MobileNav stays rendered while it closes', () => {
55
+ it('transitions display with allow-discrete so it hides only once closed', () => {
56
+ render(<Drawer isOpen />);
57
+ const style = getComputedStyle(screen.getByTestId('mobile-nav'));
58
+
59
+ // Without these two the drawer blanks out the instant `isOpen` flips,
60
+ // leaving an open modal dialog that nothing renders — and nothing can
61
+ // click past. See the file header.
62
+ expect(style.transitionProperty).toContain('display');
63
+ expect(style.transitionBehavior).toBe('allow-discrete');
64
+ // A zero hold is the bug wearing the fix's clothes: the drawer stops being
65
+ // rendered on the next style recalc, before any effect can close it.
66
+ expect(style.transitionDuration.trim()).not.toBe('');
67
+ // The hold must stay tied to the motion token rather than a literal: the
68
+ // close delay is derived by reading this value back, so a hardcoded hold
69
+ // and a themed slide-out would drift apart again. jsdom leaves var()
70
+ // unresolved, which is exactly why this reads as a declaration.
71
+ expect(style.transitionDuration).toContain('var(');
72
+ });
73
+
74
+ it('keeps the native dialog open until the slide-out has run', () => {
75
+ vi.useFakeTimers();
76
+ try {
77
+ const {rerender} = render(<Drawer isOpen />);
78
+ const dialog = screen.getByTestId('mobile-nav');
79
+ expect(dialog).toHaveAttribute('open');
80
+
81
+ act(() => {
82
+ rerender(<Drawer isOpen={false} />);
83
+ });
84
+
85
+ // The drawer is on its way out but the dialog is still open, so it is
86
+ // still rendered and still the thing holding the top layer.
87
+ expect(dialog).toHaveAttribute('open');
88
+
89
+ act(() => {
90
+ vi.advanceTimersByTime(300);
91
+ });
92
+ expect(dialog).not.toHaveAttribute('open');
93
+ } finally {
94
+ vi.useRealTimers();
95
+ }
96
+ });
97
+
98
+ it('still closes the dialog when unmounted mid-slide-out', () => {
99
+ vi.useFakeTimers();
100
+ try {
101
+ const {rerender, unmount} = render(<Drawer isOpen />);
102
+ const dialog = screen.getByTestId('mobile-nav');
103
+
104
+ act(() => {
105
+ rerender(<Drawer isOpen={false} />);
106
+ });
107
+ expect(dialog).toHaveAttribute('open');
108
+
109
+ // Teardown before the timer fires — the unmount-only effect has to close
110
+ // the dialog, or the next showModal() is skipped and the drawer can never
111
+ // be re-opened (#3091).
112
+ unmount();
113
+ expect(dialog).not.toHaveAttribute('open');
114
+ } finally {
115
+ vi.useRealTimers();
116
+ }
117
+ });
118
+
119
+ it('still defers the close under prefers-reduced-motion', () => {
120
+ // Query-aware on purpose. A blanket `matches: true` is the stub shape that
121
+ // once left 9 of 11 tests in the sibling edge-case file silently running
122
+ // against a 0ms close delay — see the header of stubMatchMedia.ts. It is
123
+ // inert for this file today (MobileNav renders standalone here, so
124
+ // prefers-reduced-motion is the only query in play), but the shape is the
125
+ // hazard, not the blast radius.
126
+ stubMatchMedia({reduceMotion: true});
127
+ vi.useFakeTimers();
128
+ try {
129
+ const {rerender} = render(<Drawer isOpen />);
130
+ const dialog = screen.getByTestId('mobile-nav');
131
+
132
+ act(() => {
133
+ rerender(<Drawer isOpen={false} />);
134
+ });
135
+ // Reduced motion caps the delay at 0 — but 0 is still a macrotask. What
136
+ // must not happen is the close landing synchronously in the commit, while
137
+ // the dialog is mid-flip. This pins deferral, not duration.
138
+ expect(dialog).toHaveAttribute('open');
139
+
140
+ act(() => {
141
+ vi.advanceTimersByTime(20);
142
+ });
143
+ expect(dialog).not.toHaveAttribute('open');
144
+ } finally {
145
+ vi.useRealTimers();
146
+ vi.unstubAllGlobals();
147
+ }
148
+ });
149
+
150
+ it('re-opening mid-slide-out cancels the pending close', () => {
151
+ vi.useFakeTimers();
152
+ try {
153
+ const {rerender} = render(<Drawer isOpen />);
154
+ const dialog = screen.getByTestId('mobile-nav');
155
+
156
+ act(() => {
157
+ rerender(<Drawer isOpen={false} />);
158
+ });
159
+ act(() => {
160
+ rerender(<Drawer isOpen />);
161
+ });
162
+ act(() => {
163
+ vi.advanceTimersByTime(300);
164
+ });
165
+
166
+ // The queued close must not fire after the drawer has been re-opened.
167
+ expect(dialog).toHaveAttribute('open');
168
+ } finally {
169
+ vi.useRealTimers();
170
+ }
171
+ });
172
+ });