@astryxdesign/core 0.5.2-canary.c9c8564 → 0.5.2-canary.e4f8e4e

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 (146) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +3 -1
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +4 -3
  4. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +2 -2
  5. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetEdgeTint.js +7 -10
  7. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  8. package/dist/BottomSheet/BottomSheetSwitcher.js +1 -1
  9. package/dist/Button/Button.d.ts +2 -1
  10. package/dist/Button/Button.d.ts.map +1 -1
  11. package/dist/Button/Button.js +11 -4
  12. package/dist/Carousel/Carousel.d.ts.map +1 -1
  13. package/dist/Carousel/Carousel.js +2 -2
  14. package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
  15. package/dist/Chat/ChatToolCalls.js +34 -17
  16. package/dist/ContextMenu/ContextMenu.d.ts +14 -3
  17. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  18. package/dist/ContextMenu/ContextMenu.js +149 -26
  19. package/dist/ContextMenu/index.d.ts +1 -0
  20. package/dist/ContextMenu/index.d.ts.map +1 -1
  21. package/dist/DropdownMenu/DropdownMenu.d.ts +24 -5
  22. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  23. package/dist/DropdownMenu/DropdownMenu.js +340 -26
  24. package/dist/DropdownMenu/DropdownMenuContext.d.ts +1 -1
  25. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  26. package/dist/DropdownMenu/DropdownMenuItem.d.ts.map +1 -1
  27. package/dist/DropdownMenu/DropdownMenuItem.js +1 -1
  28. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +6 -2
  29. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  30. package/dist/DropdownMenu/DropdownMenuSubMenu.js +43 -14
  31. package/dist/DropdownMenu/MenuBottomSheet.d.ts +20 -0
  32. package/dist/DropdownMenu/MenuBottomSheet.d.ts.map +1 -0
  33. package/dist/DropdownMenu/MenuBottomSheet.js +36 -0
  34. package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts +10 -0
  35. package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts.map +1 -0
  36. package/dist/DropdownMenu/MenuBottomSheetActionList.js +111 -0
  37. package/dist/DropdownMenu/index.d.ts +2 -1
  38. package/dist/DropdownMenu/index.d.ts.map +1 -1
  39. package/dist/DropdownMenu/menuWidth.d.ts +14 -0
  40. package/dist/DropdownMenu/menuWidth.d.ts.map +1 -0
  41. package/dist/DropdownMenu/menuWidth.js +35 -0
  42. package/dist/DropdownMenu/useMenuOverflow.d.ts +10 -0
  43. package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -0
  44. package/dist/DropdownMenu/useMenuOverflow.js +60 -0
  45. package/dist/MoreMenu/MoreMenu.d.ts +8 -2
  46. package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
  47. package/dist/MoreMenu/MoreMenu.js +2 -0
  48. package/dist/MultiSelector/MultiSelector.d.ts +11 -1
  49. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  50. package/dist/MultiSelector/MultiSelector.js +97 -68
  51. package/dist/MultiSelector/index.d.ts +1 -1
  52. package/dist/MultiSelector/index.d.ts.map +1 -1
  53. package/dist/Popover/Popover.d.ts +10 -6
  54. package/dist/Popover/Popover.d.ts.map +1 -1
  55. package/dist/Popover/Popover.js +141 -16
  56. package/dist/Popover/usePopover.d.ts +4 -0
  57. package/dist/Popover/usePopover.d.ts.map +1 -1
  58. package/dist/Popover/usePopover.js +52 -5
  59. package/dist/Selector/Selector.d.ts +10 -0
  60. package/dist/Selector/Selector.d.ts.map +1 -1
  61. package/dist/Selector/Selector.js +106 -80
  62. package/dist/Selector/SelectorBottomSheet.d.ts +21 -0
  63. package/dist/Selector/SelectorBottomSheet.d.ts.map +1 -0
  64. package/dist/Selector/SelectorBottomSheet.js +83 -0
  65. package/dist/Selector/index.d.ts +1 -1
  66. package/dist/Selector/index.d.ts.map +1 -1
  67. package/dist/Selector/selectorPresentation.stylex.d.ts +16 -0
  68. package/dist/Selector/selectorPresentation.stylex.d.ts.map +1 -0
  69. package/dist/Selector/selectorPresentation.stylex.js +21 -0
  70. package/dist/Selector/useSelectorPresentation.d.ts +32 -0
  71. package/dist/Selector/useSelectorPresentation.d.ts.map +1 -0
  72. package/dist/Selector/useSelectorPresentation.js +90 -0
  73. package/dist/Toast/useToastGesture.d.ts.map +1 -1
  74. package/dist/Toast/useToastGesture.js +46 -9
  75. package/dist/astryx.css +19 -1
  76. package/dist/hooks/useAdaptivePresentation.d.ts +5 -0
  77. package/dist/hooks/useAdaptivePresentation.d.ts.map +1 -0
  78. package/dist/hooks/useAdaptivePresentation.js +19 -0
  79. package/dist/hooks/useFocusReturnVisibility.d.ts +7 -0
  80. package/dist/hooks/useFocusReturnVisibility.d.ts.map +1 -0
  81. package/dist/hooks/useFocusReturnVisibility.js +35 -0
  82. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  83. package/dist/hooks/useFocusTrap.js +12 -0
  84. package/locales/en.json +20 -0
  85. package/locales/pseudo.json +15 -0
  86. package/package.json +2 -2
  87. package/src/BottomSheet/BottomSheet.doc.mjs +6 -0
  88. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  89. package/src/BottomSheet/BottomSheet.tsx +7 -4
  90. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -6
  91. package/src/BottomSheet/BottomSheetEdgeTint.tsx +7 -10
  92. package/src/BottomSheet/BottomSheetSwitcher.tsx +1 -2
  93. package/src/Button/Button.doc.mjs +56 -0
  94. package/src/Button/Button.test.tsx +10 -0
  95. package/src/Button/Button.tsx +13 -5
  96. package/src/ButtonGroup/ButtonGroup.doc.mjs +47 -0
  97. package/src/Carousel/Carousel.test.tsx +83 -0
  98. package/src/Carousel/Carousel.tsx +8 -2
  99. package/src/Chat/ChatToolCalls.test.tsx +44 -0
  100. package/src/Chat/ChatToolCalls.tsx +36 -15
  101. package/src/ContextMenu/ContextMenu.doc.mjs +13 -1
  102. package/src/ContextMenu/ContextMenu.test.tsx +131 -1
  103. package/src/ContextMenu/ContextMenu.tsx +199 -32
  104. package/src/ContextMenu/index.ts +1 -0
  105. package/src/DropdownMenu/DropdownMenu.doc.mjs +201 -40
  106. package/src/DropdownMenu/DropdownMenu.test.tsx +518 -7
  107. package/src/DropdownMenu/DropdownMenu.tsx +459 -28
  108. package/src/DropdownMenu/DropdownMenuContext.tsx +1 -1
  109. package/src/DropdownMenu/DropdownMenuItem.tsx +2 -0
  110. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +4 -2
  111. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +124 -0
  112. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +72 -17
  113. package/src/DropdownMenu/MenuBottomSheet.tsx +46 -0
  114. package/src/DropdownMenu/MenuBottomSheetActionList.tsx +141 -0
  115. package/src/DropdownMenu/index.ts +2 -0
  116. package/src/DropdownMenu/menuWidth.ts +51 -0
  117. package/src/DropdownMenu/useMenuOverflow.ts +77 -0
  118. package/src/IconButton/IconButton.doc.mjs +38 -0
  119. package/src/MoreMenu/MoreMenu.doc.mjs +17 -0
  120. package/src/MoreMenu/MoreMenu.test.tsx +47 -0
  121. package/src/MoreMenu/MoreMenu.tsx +13 -1
  122. package/src/MultiSelector/MultiSelector.doc.mjs +16 -0
  123. package/src/MultiSelector/MultiSelector.test.tsx +127 -0
  124. package/src/MultiSelector/MultiSelector.tsx +121 -73
  125. package/src/MultiSelector/index.ts +1 -0
  126. package/src/Popover/Popover.doc.mjs +11 -11
  127. package/src/Popover/Popover.test.tsx +397 -2
  128. package/src/Popover/Popover.tsx +228 -21
  129. package/src/Popover/usePopover.tsx +92 -15
  130. package/src/ProgressBar/ProgressBar.spec.md +208 -0
  131. package/src/SegmentedControl/SegmentedControl.doc.mjs +56 -0
  132. package/src/Selector/Selector.doc.mjs +12 -0
  133. package/src/Selector/Selector.test.tsx +135 -0
  134. package/src/Selector/Selector.tsx +141 -82
  135. package/src/Selector/SelectorBottomSheet.tsx +97 -0
  136. package/src/Selector/index.ts +1 -0
  137. package/src/Selector/selectorPresentation.stylex.ts +20 -0
  138. package/src/Selector/useSelectorPresentation.ts +147 -0
  139. package/src/Toast/ToastViewport.test.tsx +118 -4
  140. package/src/Toast/useToastGesture.ts +61 -8
  141. package/src/ToggleButton/ToggleButton.doc.mjs +56 -0
  142. package/src/hooks/useAdaptivePresentation.ts +35 -0
  143. package/src/hooks/useFocusReturnVisibility.ts +45 -0
  144. package/src/hooks/useFocusTrap.test.tsx +40 -0
  145. package/src/hooks/useFocusTrap.ts +13 -0
  146. package/src/theme/themingTargets.test.ts +91 -25
@@ -10,16 +10,43 @@
10
10
  */
11
11
 
12
12
  import {describe, it, expect, vi, beforeEach} from 'vitest';
13
+ import {readFileSync} from 'node:fs';
13
14
  import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
14
15
  import userEvent from '@testing-library/user-event';
15
16
  import {useState} from 'react';
17
+ import * as stylex from '@stylexjs/stylex';
16
18
  import {DropdownMenu} from './DropdownMenu';
17
19
  import {DropdownMenuItem} from './DropdownMenuItem';
18
20
  import {DropdownMenuDivider} from './DropdownMenuDivider';
19
21
  import {Divider} from '../Divider';
22
+ import {rtlStyles} from '../utils';
23
+ import {__resetInteractionModalityForTest} from '../utils/interactionModality';
24
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
20
25
 
21
26
  // Mock showPopover and hidePopover methods since they're not implemented in jsdom
22
27
  beforeEach(() => {
28
+ __resetInteractionModalityForTest();
29
+ HTMLDialogElement.prototype.showModal = vi.fn(function (
30
+ this: HTMLDialogElement,
31
+ ) {
32
+ this.setAttribute('open', '');
33
+ });
34
+ HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
35
+ this.removeAttribute('open');
36
+ });
37
+ vi.stubGlobal(
38
+ 'matchMedia',
39
+ vi.fn().mockImplementation((query: string) => ({
40
+ matches: false,
41
+ media: query,
42
+ onchange: null,
43
+ addEventListener: vi.fn(),
44
+ removeEventListener: vi.fn(),
45
+ addListener: vi.fn(),
46
+ removeListener: vi.fn(),
47
+ dispatchEvent: vi.fn(),
48
+ })),
49
+ );
23
50
  HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
24
51
  this.setAttribute('popover-open', '');
25
52
  const event = new Event('toggle', {bubbles: false});
@@ -83,6 +110,334 @@ describe('DropdownMenu', () => {
83
110
  ).not.toBeInTheDocument();
84
111
  });
85
112
 
113
+ it('renders data-driven actions in a bottom sheet when requested', async () => {
114
+ const user = userEvent.setup();
115
+ const onEdit = vi.fn();
116
+
117
+ render(
118
+ <DropdownMenu
119
+ button={{label: 'Project actions'}}
120
+ presentation="bottom-sheet"
121
+ items={[{label: 'Edit project', onClick: onEdit}]}
122
+ />,
123
+ );
124
+
125
+ const trigger = screen.getByRole('button', {name: /Project actions/});
126
+ expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
127
+ expect(screen.queryByRole('menu', {hidden: true})).not.toBeInTheDocument();
128
+
129
+ await user.click(trigger);
130
+
131
+ expect(
132
+ screen.getByRole('dialog', {name: 'Project actions'}),
133
+ ).toBeInTheDocument();
134
+ const editAction = screen.getByRole('button', {name: 'Edit project'});
135
+ await waitFor(() =>
136
+ expect(
137
+ screen.getByRole('dialog', {name: 'Project actions'}),
138
+ ).toContainElement(document.activeElement as HTMLElement),
139
+ );
140
+ expect(editAction).not.toHaveFocus();
141
+ await user.click(editAction);
142
+
143
+ expect(onEdit).toHaveBeenCalledTimes(1);
144
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
145
+ });
146
+
147
+ it('aligns the bottom-sheet heading with the item content edge and keeps item padding', async () => {
148
+ const user = userEvent.setup();
149
+
150
+ render(
151
+ <DropdownMenu
152
+ button={{label: 'Project actions'}}
153
+ presentation="bottom-sheet"
154
+ items={[{label: 'Edit project', icon: <span aria-hidden="true" />}]}
155
+ />,
156
+ );
157
+
158
+ await user.click(screen.getByRole('button', {name: /Project actions/}));
159
+
160
+ const heading = screen.getByRole('heading', {name: 'Project actions'});
161
+ expect(heading).toHaveStyle({marginInlineStart: 'var(--spacing-3)'});
162
+ expect(heading.closest('.astryx-section')).toHaveStyle({
163
+ paddingInlineStart: 'var(--spacing-1)',
164
+ paddingInlineEnd: 'var(--spacing-1)',
165
+ });
166
+ expect(
167
+ screen.getByRole('button', {name: 'Edit project'}).closest('li'),
168
+ ).toHaveStyle({paddingInline: 'var(--spacing-3)'});
169
+ });
170
+
171
+ it('drills into nested data items in bottom-sheet presentation', async () => {
172
+ const user = userEvent.setup();
173
+
174
+ render(
175
+ <DropdownMenu
176
+ button={{label: 'Project actions'}}
177
+ presentation="bottom-sheet"
178
+ items={[
179
+ {
180
+ label: 'Move to project',
181
+ items: [{label: 'Apollo launch'}],
182
+ },
183
+ ]}
184
+ />,
185
+ );
186
+
187
+ await user.click(screen.getByRole('button', {name: /Project actions/}));
188
+ await user.click(screen.getByRole('button', {name: 'Move to project'}));
189
+
190
+ const submenuHeading = screen.getByRole('heading', {
191
+ name: 'Move to project',
192
+ });
193
+ expect(submenuHeading).toBeInTheDocument();
194
+ await waitFor(() => expect(submenuHeading).toHaveFocus());
195
+ expect(
196
+ screen.getByRole('button', {name: 'Apollo launch'}),
197
+ ).toBeInTheDocument();
198
+
199
+ const backButton = screen.getByRole('button', {name: 'Back'});
200
+ expect(backButton).toHaveAttribute('aria-label', 'Back');
201
+ expect(backButton).not.toHaveTextContent('Back');
202
+ await user.click(backButton);
203
+ const rootHeading = screen.getByRole('heading', {
204
+ name: 'Project actions',
205
+ });
206
+ expect(rootHeading).toBeInTheDocument();
207
+ await waitFor(() => expect(rootHeading).toHaveFocus());
208
+ });
209
+
210
+ it('returns to the root after a controlled bottom sheet closes externally', async () => {
211
+ const user = userEvent.setup();
212
+
213
+ function ControlledDropdownMenu() {
214
+ const [isOpen, setIsOpen] = useState(false);
215
+
216
+ return (
217
+ <>
218
+ <button type="button" onClick={() => setIsOpen(false)}>
219
+ Close externally
220
+ </button>
221
+ <DropdownMenu
222
+ button={{label: 'Project actions'}}
223
+ presentation="bottom-sheet"
224
+ isMenuOpen={isOpen}
225
+ onOpenChange={setIsOpen}
226
+ items={[
227
+ {
228
+ label: 'Move to project',
229
+ items: [{label: 'Apollo launch'}],
230
+ },
231
+ ]}
232
+ />
233
+ </>
234
+ );
235
+ }
236
+
237
+ render(<ControlledDropdownMenu />);
238
+
239
+ await user.click(screen.getByRole('button', {name: /Project actions/}));
240
+ await user.click(screen.getByRole('button', {name: 'Move to project'}));
241
+ expect(
242
+ screen.getByRole('heading', {name: 'Move to project'}),
243
+ ).toBeInTheDocument();
244
+
245
+ await user.click(screen.getByRole('button', {name: 'Close externally'}));
246
+ await user.click(screen.getByRole('button', {name: /Project actions/}));
247
+
248
+ expect(
249
+ screen.getByRole('heading', {name: 'Project actions'}),
250
+ ).toBeInTheDocument();
251
+ expect(
252
+ screen.queryByRole('button', {name: 'Apollo launch'}),
253
+ ).not.toBeInTheDocument();
254
+ });
255
+
256
+ it('mirrors bottom-sheet drill-in affordances under RTL', async () => {
257
+ const user = userEvent.setup();
258
+ const {className: mirrorClassName} = stylex.props(rtlStyles.mirror);
259
+ const mirrorClasses = mirrorClassName?.split(' ') ?? [];
260
+ expect(mirrorClasses.length).toBeGreaterThan(0);
261
+
262
+ render(
263
+ <div dir="rtl">
264
+ <DropdownMenu
265
+ button={{label: 'Project actions'}}
266
+ presentation="bottom-sheet"
267
+ items={[
268
+ {
269
+ label: 'Move to project',
270
+ items: [{label: 'Apollo launch'}],
271
+ },
272
+ ]}
273
+ />
274
+ </div>,
275
+ );
276
+
277
+ await user.click(screen.getByRole('button', {name: /Project actions/}));
278
+ const submenuButton = screen.getByRole('button', {
279
+ name: 'Move to project',
280
+ });
281
+ const forwardIcon = submenuButton
282
+ .closest('li')
283
+ ?.querySelector('.astryx-icon');
284
+ expect(forwardIcon).not.toBeNull();
285
+ for (const className of mirrorClasses) {
286
+ expect(forwardIcon).toHaveClass(className);
287
+ }
288
+
289
+ await user.click(submenuButton);
290
+ const backButton = screen.getByRole('button', {name: 'Back'});
291
+ const backIcon = backButton.querySelector('.astryx-icon');
292
+ expect(backIcon).not.toBeNull();
293
+ for (const className of mirrorClasses) {
294
+ expect(backIcon).toHaveClass(className);
295
+ }
296
+ });
297
+
298
+ it('uses the BottomSheet for adaptive presentation on compact touch', async () => {
299
+ vi.stubGlobal(
300
+ 'matchMedia',
301
+ vi.fn().mockImplementation((query: string) => ({
302
+ matches: query === '(max-width: 768px) and (pointer: coarse)',
303
+ media: query,
304
+ onchange: null,
305
+ addEventListener: vi.fn(),
306
+ removeEventListener: vi.fn(),
307
+ addListener: vi.fn(),
308
+ removeListener: vi.fn(),
309
+ dispatchEvent: vi.fn(),
310
+ })),
311
+ );
312
+ const user = userEvent.setup();
313
+ render(
314
+ <DropdownMenu
315
+ button={{label: 'Actions'}}
316
+ presentation="adaptive"
317
+ items={[{label: 'Edit'}]}
318
+ />,
319
+ );
320
+
321
+ await user.click(screen.getByRole('button', {name: /Actions/}));
322
+ expect(screen.getByRole('dialog', {name: 'Actions'})).toBeInTheDocument();
323
+ expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled();
324
+ });
325
+
326
+ it('uses the shared menu BottomSheet frame', () => {
327
+ const source = readFileSync(
328
+ 'packages/core/src/DropdownMenu/DropdownMenu.tsx',
329
+ 'utf8',
330
+ );
331
+
332
+ expect(source).toContain('<MenuBottomSheet');
333
+ expect(source).not.toContain("import {BottomSheet} from '../BottomSheet'");
334
+ expect(source).not.toContain("import {Section} from '../Section'");
335
+ });
336
+
337
+ it('keeps adaptive presentation anchored without compact touch', async () => {
338
+ const user = userEvent.setup();
339
+ render(
340
+ <DropdownMenu
341
+ button={{label: 'Actions'}}
342
+ presentation="adaptive"
343
+ items={[{label: 'Edit'}]}
344
+ />,
345
+ );
346
+
347
+ await user.click(screen.getByRole('button', {name: /Actions/}));
348
+ expect(HTMLElement.prototype.showPopover).toHaveBeenCalledOnce();
349
+ expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled();
350
+ });
351
+
352
+ it('preserves uncontrolled open state when adaptive presentation changes', async () => {
353
+ let matches = false;
354
+ const listeners = new Set<() => void>();
355
+ vi.stubGlobal(
356
+ 'matchMedia',
357
+ vi.fn().mockImplementation((query: string) => ({
358
+ get matches() {
359
+ return matches;
360
+ },
361
+ media: query,
362
+ onchange: null,
363
+ addEventListener: (_type: string, listener: () => void) =>
364
+ listeners.add(listener),
365
+ removeEventListener: (_type: string, listener: () => void) =>
366
+ listeners.delete(listener),
367
+ addListener: vi.fn(),
368
+ removeListener: vi.fn(),
369
+ dispatchEvent: vi.fn(),
370
+ })),
371
+ );
372
+ const user = userEvent.setup();
373
+ render(
374
+ <DropdownMenu
375
+ button={{label: 'Actions'}}
376
+ presentation="adaptive"
377
+ items={[{label: 'Edit'}]}
378
+ />,
379
+ );
380
+
381
+ await user.click(screen.getByRole('button', {name: /Actions/}));
382
+ expect(HTMLElement.prototype.showPopover).toHaveBeenCalledOnce();
383
+
384
+ matches = true;
385
+ act(() => listeners.forEach(listener => listener()));
386
+
387
+ expect(screen.getByRole('dialog', {name: 'Actions'})).toBeInTheDocument();
388
+ expect(screen.getByRole('button', {name: /Actions/})).toHaveAttribute(
389
+ 'aria-expanded',
390
+ 'true',
391
+ );
392
+ });
393
+
394
+ it('focuses the first action when a bottom sheet opens from the keyboard', async () => {
395
+ const user = userEvent.setup();
396
+ render(
397
+ <DropdownMenu
398
+ button={{label: 'Actions'}}
399
+ presentation="bottom-sheet"
400
+ items={[{label: 'Edit'}, {label: 'Delete'}]}
401
+ />,
402
+ );
403
+
404
+ const trigger = screen.getByRole('button', {name: /Actions/});
405
+ trigger.focus();
406
+ await user.keyboard('{Enter}');
407
+
408
+ await waitFor(() =>
409
+ expect(screen.getByRole('button', {name: 'Edit'})).toHaveFocus(),
410
+ );
411
+ });
412
+
413
+ it('restores touch focus without painting a trigger focus ring', async () => {
414
+ render(
415
+ <DropdownMenu
416
+ button={{label: 'Actions'}}
417
+ presentation="bottom-sheet"
418
+ items={[{label: 'Edit'}]}
419
+ />,
420
+ );
421
+
422
+ const trigger = screen.getByRole('button', {name: /Actions/});
423
+ fireEvent.pointerDown(trigger, {pointerType: 'touch'});
424
+ fireEvent.click(trigger, {detail: 1});
425
+
426
+ const action = screen.getByRole('button', {name: 'Edit'});
427
+ action.focus();
428
+ fireEvent.pointerDown(action, {pointerType: 'touch'});
429
+ fireEvent.click(action, {detail: 1});
430
+
431
+ expect(action).not.toHaveFocus();
432
+ trigger.focus();
433
+ expect(trigger).toHaveFocus();
434
+ await waitFor(() =>
435
+ expect(trigger).toHaveClass(
436
+ stylex.props(focusOutlineStyles.suppressed).className!,
437
+ ),
438
+ );
439
+ });
440
+
86
441
  it('defaults menu placement below', () => {
87
442
  render(
88
443
  <DropdownMenu button={{label: 'Actions'}} items={[{label: 'Item 1'}]} />,
@@ -127,6 +482,26 @@ describe('DropdownMenu', () => {
127
482
  );
128
483
  });
129
484
 
485
+ it('uses block-axis viewport gutters for side placement', () => {
486
+ render(
487
+ <DropdownMenu
488
+ button={{label: 'Actions'}}
489
+ placement="end"
490
+ alignment="start"
491
+ items={[{label: 'Item 1'}]}
492
+ />,
493
+ );
494
+ const popover = screen
495
+ .getByRole('menu', {hidden: true})
496
+ .closest('[popover]');
497
+ expect(popover?.className).toContain(
498
+ 'DropdownMenu__styles.popoverViewportBlockStart',
499
+ );
500
+ expect(popover?.className).not.toContain(
501
+ 'DropdownMenu__styles.popoverViewportStart',
502
+ );
503
+ });
504
+
130
505
  it('emits the direction-independent logical mapping under an RTL ancestor (#3389)', async () => {
131
506
  // The self-* position-area keywords resolve against the popover's own
132
507
  // inherited direction in the browser, so RTL emits the same string as
@@ -147,6 +522,104 @@ describe('DropdownMenu', () => {
147
522
  );
148
523
  });
149
524
 
525
+ it('caps default and custom widths to the available viewport', () => {
526
+ const {unmount} = render(
527
+ <DropdownMenu button={{label: 'Actions'}} items={[{label: 'Item 1'}]} />,
528
+ );
529
+ let menu = screen.getByRole('menu', {hidden: true});
530
+ let popover = menu.closest('[popover]');
531
+ expect(popover?.className).toContain(
532
+ 'DropdownMenu__styles.popoverViewport',
533
+ );
534
+ expect(popover?.className).toContain('DropdownMenu__styles.popoverAligned');
535
+ expect(popover).toHaveStyle(
536
+ 'min-width: min(anchor-size(width),calc(100% - max(var(--spacing-4),env(safe-area-inset-left,0px),env(safe-area-inset-right,0px))))',
537
+ );
538
+
539
+ unmount();
540
+ render(
541
+ <DropdownMenu
542
+ button={{label: 'Wide actions'}}
543
+ menuWidth={640}
544
+ items={[{label: 'Item 1'}]}
545
+ />,
546
+ );
547
+ menu = screen.getByRole('menu', {hidden: true});
548
+ popover = menu.closest('[popover]');
549
+ expect(popover?.className).toContain(
550
+ 'DropdownMenu__styles.popoverViewport',
551
+ );
552
+ expect(popover).toHaveStyle({minWidth: 'var(--x-minWidth)'});
553
+ expect(popover?.getAttribute('style')).toContain('min(640px, calc(100%');
554
+ });
555
+
556
+ it.each(['max-content', 'fit-content', 'auto'])(
557
+ 'preserves the valid %s menuWidth while retaining the viewport cap',
558
+ menuWidth => {
559
+ render(
560
+ <DropdownMenu
561
+ button={{label: 'Actions'}}
562
+ menuWidth={menuWidth}
563
+ items={[{label: 'Item 1'}]}
564
+ />,
565
+ );
566
+
567
+ const popover = screen
568
+ .getByRole('menu', {hidden: true})
569
+ .closest('[popover]');
570
+ expect(popover?.className).toContain(
571
+ 'DropdownMenu__styles.popoverCustomIntrinsicWidth',
572
+ );
573
+ expect(popover?.getAttribute('style')).toContain(menuWidth);
574
+ expect(popover?.className).toContain(
575
+ 'DropdownMenu__styles.popoverViewportAligned',
576
+ );
577
+ expect(popover?.getAttribute('style')).not.toContain(`min(${menuWidth},`);
578
+ },
579
+ );
580
+
581
+ it('caps menu height and only scrolls when content overflows', async () => {
582
+ const clientHeightSpy = vi
583
+ .spyOn(HTMLElement.prototype, 'clientHeight', 'get')
584
+ .mockReturnValue(300);
585
+ const scrollHeightSpy = vi
586
+ .spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
587
+ .mockReturnValue(300);
588
+
589
+ try {
590
+ render(
591
+ <DropdownMenu
592
+ button={{label: 'Actions'}}
593
+ items={[{label: 'Item 1'}]}
594
+ />,
595
+ );
596
+ const menu = screen.getByRole('menu', {hidden: true});
597
+ fireEvent.click(screen.getByRole('button', {name: /Actions/}));
598
+ await waitFor(() => {
599
+ expect(screen.getByRole('button', {name: /Actions/})).toHaveAttribute(
600
+ 'aria-expanded',
601
+ 'true',
602
+ );
603
+ });
604
+ expect(menu).toHaveStyle(
605
+ 'max-height: min(300px,calc(100dvb - max(var(--spacing-4),env(safe-area-inset-top,0px)) - max(var(--spacing-4),env(safe-area-inset-bottom,0px))))',
606
+ );
607
+ expect(menu).not.toHaveStyle({overflowY: 'auto'});
608
+ expect(menu).toHaveAttribute('tabindex', '-1');
609
+
610
+ scrollHeightSpy.mockReturnValue(480);
611
+ fireEvent(window, new Event('resize'));
612
+
613
+ await waitFor(() => {
614
+ expect(menu).toHaveStyle({overflowY: 'auto'});
615
+ expect(menu).toHaveStyle({overflowX: 'hidden'});
616
+ });
617
+ } finally {
618
+ clientHeightSpy.mockRestore();
619
+ scrollHeightSpy.mockRestore();
620
+ }
621
+ });
622
+
150
623
  it('has aria-haspopup and aria-expanded attributes', () => {
151
624
  render(
152
625
  <DropdownMenu button={{label: 'Actions'}} items={[{label: 'Item 1'}]} />,
@@ -196,7 +669,7 @@ describe('DropdownMenu', () => {
196
669
  );
197
670
  });
198
671
 
199
- it('restores focus to the trigger after native light dismiss', async () => {
672
+ it('restores focus to the trigger after keyboard dismissal', async () => {
200
673
  const raf = vi
201
674
  .spyOn(window, 'requestAnimationFrame')
202
675
  .mockImplementation(callback => {
@@ -215,17 +688,16 @@ describe('DropdownMenu', () => {
215
688
 
216
689
  const trigger = screen.getByRole('button', {name: /Actions/});
217
690
  trigger.focus();
218
- await user.click(trigger);
219
- // Pointer opens focus the menu container, not the first item (#4477).
220
- expect(screen.getByRole('menu', {hidden: true})).toHaveFocus();
691
+ await user.keyboard('{Enter}');
692
+ expect(
693
+ screen.getByRole('menuitem', {name: 'Edit', hidden: true}),
694
+ ).toHaveFocus();
221
695
 
222
696
  const popoverEl = screen
223
697
  .getByRole('menu', {hidden: true})
224
698
  .closest('[popover]');
225
699
  expect(popoverEl).not.toBeNull();
226
- popoverEl?.addEventListener('toggle', () => {
227
- trigger.blur();
228
- });
700
+ trigger.blur();
229
701
  const toggleEvent = new Event('toggle');
230
702
  Object.defineProperty(toggleEvent, 'newState', {value: 'closed'});
231
703
  fireEvent(popoverEl as HTMLElement, toggleEvent);
@@ -236,6 +708,44 @@ describe('DropdownMenu', () => {
236
708
  }
237
709
  });
238
710
 
711
+ it('does not leave focus on the trigger after pointer dismissal', async () => {
712
+ const raf = vi
713
+ .spyOn(window, 'requestAnimationFrame')
714
+ .mockImplementation(callback => {
715
+ callback(0);
716
+ return 0;
717
+ });
718
+
719
+ try {
720
+ render(
721
+ <DropdownMenu
722
+ button={{label: 'Actions'}}
723
+ items={[{label: 'Edit'}, {label: 'Delete'}]}
724
+ />,
725
+ );
726
+
727
+ const trigger = screen.getByRole('button', {name: /Actions/});
728
+ fireEvent.pointerDown(trigger, {pointerType: 'touch'});
729
+ fireEvent.click(trigger, {detail: 1});
730
+ expect(screen.getByRole('menu', {hidden: true})).toHaveFocus();
731
+
732
+ const popoverEl = screen
733
+ .getByRole('menu', {hidden: true})
734
+ .closest('[popover]');
735
+ expect(popoverEl).not.toBeNull();
736
+ // Simulate native popover focus restoration occurring before React's
737
+ // toggle handler; pointer dismissal should remove that focus again.
738
+ trigger.focus();
739
+ const toggleEvent = new Event('toggle');
740
+ Object.defineProperty(toggleEvent, 'newState', {value: 'closed'});
741
+ fireEvent(popoverEl as HTMLElement, toggleEvent);
742
+
743
+ expect(trigger).not.toHaveFocus();
744
+ } finally {
745
+ raf.mockRestore();
746
+ }
747
+ });
748
+
239
749
  it('closes the menu when Tab is pressed inside it (APG menu-button)', async () => {
240
750
  const user = userEvent.setup();
241
751
  render(
@@ -1267,6 +1777,7 @@ describe('DropdownMenu open focus follows input modality (#4477)', () => {
1267
1777
 
1268
1778
  const menu = screen.getByRole('menu', {hidden: true});
1269
1779
  await waitFor(() => expect(menu).toHaveFocus());
1780
+ expect(menu).toHaveStyle({outline: 'none'});
1270
1781
  expect(
1271
1782
  screen.getByRole('menuitem', {name: 'Edit', hidden: true}),
1272
1783
  ).not.toHaveFocus();