@astryxdesign/core 0.4.1 → 0.4.2-canary.464a445

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 (182) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/Avatar/Avatar.d.ts +4 -1
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +19 -23
  5. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  6. package/dist/AvatarGroup/AvatarGroupOverflow.js +7 -2
  7. package/dist/Button/Button.d.ts.map +1 -1
  8. package/dist/Button/Button.js +9 -7
  9. package/dist/Chat/ChatMessage.d.ts +7 -0
  10. package/dist/Chat/ChatMessage.d.ts.map +1 -1
  11. package/dist/Chat/ChatMessageBubble.d.ts +13 -1
  12. package/dist/Chat/ChatMessageBubble.d.ts.map +1 -1
  13. package/dist/Chat/ChatMessageBubble.js +19 -1
  14. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  15. package/dist/CheckboxInput/CheckboxInput.js +5 -0
  16. package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -1
  17. package/dist/CommandPalette/CommandPaletteFooter.js +5 -3
  18. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  19. package/dist/DateTimeInput/DateTimeInput.js +2 -2
  20. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  21. package/dist/DropdownMenu/DropdownMenuSubMenu.js +19 -15
  22. package/dist/HoverCard/HoverCard.d.ts +1 -1
  23. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  24. package/dist/HoverCard/HoverCard.js +5 -13
  25. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  26. package/dist/HoverCard/useHoverCard.js +6 -5
  27. package/dist/InputGroup/groupStyles.d.ts.map +1 -1
  28. package/dist/InputGroup/groupStyles.js +7 -2
  29. package/dist/Layer/layerHost.d.ts +24 -0
  30. package/dist/Layer/layerHost.d.ts.map +1 -0
  31. package/dist/Layer/layerHost.js +79 -0
  32. package/dist/Layer/useLayer.d.ts +14 -6
  33. package/dist/Layer/useLayer.d.ts.map +1 -1
  34. package/dist/Layer/useLayer.js +158 -30
  35. package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
  36. package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
  37. package/dist/NavItem/navItemStyles.stylex.js +11 -5
  38. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  39. package/dist/RadioList/RadioListItem.js +5 -0
  40. package/dist/SideNav/SideNav.d.ts +7 -9
  41. package/dist/SideNav/SideNav.d.ts.map +1 -1
  42. package/dist/SideNav/SideNav.js +32 -5
  43. package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
  44. package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
  45. package/dist/SideNav/SideNavCollapseButton.js +39 -15
  46. package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
  47. package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
  48. package/dist/SideNav/SideNavCollapseContext.js +16 -2
  49. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  50. package/dist/SideNav/SideNavHeading.js +89 -32
  51. package/dist/SideNav/SideNavItem.d.ts +7 -2
  52. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  53. package/dist/SideNav/SideNavItem.js +119 -75
  54. package/dist/SideNav/SideNavSection.d.ts.map +1 -1
  55. package/dist/SideNav/SideNavSection.js +7 -16
  56. package/dist/SideNav/index.d.ts +1 -1
  57. package/dist/SideNav/index.d.ts.map +1 -1
  58. package/dist/Slider/Slider.d.ts.map +1 -1
  59. package/dist/Slider/Slider.js +56 -15
  60. package/dist/Switch/Switch.d.ts.map +1 -1
  61. package/dist/Switch/Switch.js +5 -0
  62. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  63. package/dist/Thumbnail/Thumbnail.js +5 -0
  64. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  65. package/dist/TopNav/TopNavHeading.js +14 -6
  66. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  67. package/dist/TopNav/TopNavMegaMenu.js +25 -93
  68. package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
  69. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  70. package/dist/TopNav/TopNavMenu.js +10 -5
  71. package/dist/astryx.css +13 -7
  72. package/dist/astryx.umd.js +50 -50
  73. package/dist/astryx.umd.js.map +4 -4
  74. package/dist/hooks/index.d.ts +1 -1
  75. package/dist/hooks/index.d.ts.map +1 -1
  76. package/dist/hooks/index.js +1 -1
  77. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  78. package/dist/hooks/useFocusTrap.js +16 -2
  79. package/dist/hooks/useMenuHover.d.ts +50 -5
  80. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  81. package/dist/hooks/useMenuHover.js +171 -51
  82. package/dist/theme/defineTheme.d.ts +8 -2
  83. package/dist/theme/defineTheme.d.ts.map +1 -1
  84. package/dist/theme/defineTheme.js +8 -1
  85. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  86. package/dist/theme/derivedVarRegistry.js +7 -0
  87. package/dist/theme/expandColorScale.d.ts.map +1 -1
  88. package/dist/theme/expandColorScale.js +1 -0
  89. package/dist/theme/expandMotionScale.d.ts +1 -0
  90. package/dist/theme/expandMotionScale.d.ts.map +1 -1
  91. package/dist/theme/expandMotionScale.js +1 -0
  92. package/dist/theme/expandRadiusScale.d.ts +1 -0
  93. package/dist/theme/expandRadiusScale.d.ts.map +1 -1
  94. package/dist/theme/expandRadiusScale.js +1 -0
  95. package/dist/theme/expandTypeScale.d.ts +1 -0
  96. package/dist/theme/expandTypeScale.d.ts.map +1 -1
  97. package/dist/theme/expandTypeScale.js +1 -0
  98. package/dist/theme/tokens.stylex.d.ts +3 -1
  99. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  100. package/dist/theme/tokens.stylex.js +3 -1
  101. package/locales/en.json +84 -0
  102. package/locales/pseudo.json +63 -0
  103. package/package.json +3 -3
  104. package/src/Avatar/Avatar.doc.mjs +5 -2
  105. package/src/Avatar/Avatar.test.tsx +51 -0
  106. package/src/Avatar/Avatar.tsx +30 -20
  107. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  108. package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
  109. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
  110. package/src/Button/Button.tsx +5 -3
  111. package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
  112. package/src/Card/Card.doc.mjs +2 -0
  113. package/src/Chat/Chat.doc.mjs +4 -1
  114. package/src/Chat/ChatMessage.doc.mjs +3 -3
  115. package/src/Chat/ChatMessage.tsx +7 -0
  116. package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
  117. package/src/Chat/ChatMessageBubble.test.tsx +95 -6
  118. package/src/Chat/ChatMessageBubble.tsx +25 -0
  119. package/src/CheckboxInput/CheckboxInput.tsx +20 -0
  120. package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
  121. package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
  122. package/src/DateTimeInput/DateTimeInput.tsx +4 -2
  123. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
  124. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
  125. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  126. package/src/HoverCard/HoverCard.test.tsx +283 -55
  127. package/src/HoverCard/HoverCard.tsx +5 -13
  128. package/src/HoverCard/useHoverCard.tsx +8 -11
  129. package/src/InputGroup/groupStyles.ts +7 -8
  130. package/src/Item/Item.doc.mjs +4 -0
  131. package/src/Layer/layerHost.test.ts +99 -0
  132. package/src/Layer/layerHost.ts +141 -0
  133. package/src/Layer/useLayer.doc.mjs +14 -4
  134. package/src/Layer/useLayer.test.tsx +332 -7
  135. package/src/Layer/useLayer.tsx +235 -36
  136. package/src/MobileNav/MobileNav.doc.mjs +7 -0
  137. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  138. package/src/RadioList/RadioListItem.tsx +20 -0
  139. package/src/SideNav/SideNav.doc.mjs +13 -4
  140. package/src/SideNav/SideNav.test.tsx +858 -2
  141. package/src/SideNav/SideNav.tsx +37 -16
  142. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  143. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  144. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  145. package/src/SideNav/SideNavHeading.tsx +53 -25
  146. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  147. package/src/SideNav/SideNavItem.tsx +97 -84
  148. package/src/SideNav/SideNavSection.tsx +6 -20
  149. package/src/SideNav/index.ts +2 -0
  150. package/src/Slider/Slider.test.tsx +69 -33
  151. package/src/Slider/Slider.tsx +65 -13
  152. package/src/Switch/Switch.tsx +20 -0
  153. package/src/TabList/TabList.doc.mjs +3 -0
  154. package/src/Text/Text.doc.mjs +1 -1
  155. package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
  156. package/src/Thumbnail/Thumbnail.tsx +10 -0
  157. package/src/Timestamp/Timestamp.test.tsx +40 -22
  158. package/src/Toolbar/Toolbar.test.tsx +14 -14
  159. package/src/TopNav/TopNav.test.tsx +86 -1
  160. package/src/TopNav/TopNavHeading.tsx +21 -14
  161. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  162. package/src/TopNav/TopNavMegaMenu.tsx +29 -105
  163. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  164. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  165. package/src/TopNav/TopNavMenu.tsx +14 -4
  166. package/src/TreeList/TreeList.doc.mjs +1 -0
  167. package/src/hooks/index.ts +5 -1
  168. package/src/hooks/useFocusTrap.test.tsx +16 -0
  169. package/src/hooks/useFocusTrap.ts +20 -2
  170. package/src/hooks/useMenuHover.test.tsx +364 -0
  171. package/src/hooks/useMenuHover.ts +243 -47
  172. package/src/theme/defineTheme.ts +9 -0
  173. package/src/theme/derivedVarRegistry.test.ts +78 -12
  174. package/src/theme/derivedVarRegistry.ts +4 -0
  175. package/src/theme/expandColorScale.ts +1 -0
  176. package/src/theme/expandMotionScale.ts +1 -0
  177. package/src/theme/expandRadiusScale.ts +1 -0
  178. package/src/theme/expandTypeScale.ts +1 -0
  179. package/src/theme/themingTargets.test.ts +152 -26
  180. package/src/theme/tokens.stylex.ts +3 -1
  181. package/src/theme/tokens.test.ts +12 -0
  182. package/src/theme/useTheme.test.tsx +18 -0
@@ -0,0 +1,364 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useMenuHover.test.tsx
5
+ * @input Uses vitest, @testing-library/react, TopNavMenu (a real consumer)
6
+ * @output Unit tests for the shared hover-menu interaction contract (#3121)
7
+ * @position Testing; exercised through TopNavMenu rather than a synthetic
8
+ * harness, so the hook cannot drift from how consumers wire it
9
+ *
10
+ * SYNC: When useMenuHover changes, update tests to match new behavior
11
+ */
12
+ import {describe, it, expect, vi, afterEach} from 'vitest';
13
+ import {render, screen, act} from '@testing-library/react';
14
+ import userEvent from '@testing-library/user-event';
15
+ import {TopNavMenu} from '../TopNav/TopNavMenu';
16
+
17
+ const items = [
18
+ {title: 'Analytics', description: 'Track user behavior', href: '/analytics'},
19
+ {title: 'Messaging', description: 'Real-time comms', href: '/messaging'},
20
+ ];
21
+
22
+ /** A touchscreen: no hover-capable pointer. */
23
+ function mockPointerlessDevice() {
24
+ vi.spyOn(window, 'matchMedia').mockImplementation(
25
+ (query: string) =>
26
+ ({
27
+ matches: false,
28
+ media: query,
29
+ onchange: null,
30
+ addListener: () => {},
31
+ removeListener: () => {},
32
+ addEventListener: () => {},
33
+ removeEventListener: () => {},
34
+ dispatchEvent: () => false,
35
+ }) as MediaQueryList,
36
+ );
37
+ }
38
+
39
+ function renderMenu() {
40
+ render(<TopNavMenu label="Products" items={items} />);
41
+ return screen.getByRole('button', {name: 'Products'});
42
+ }
43
+
44
+ function firstMenuItem() {
45
+ return document.querySelector<HTMLElement>('[role="menuitem"]');
46
+ }
47
+
48
+ afterEach(() => {
49
+ vi.useRealTimers();
50
+ vi.restoreAllMocks();
51
+ });
52
+
53
+ describe('useMenuHover — hover/click guard', () => {
54
+ it('opens on hover after the show delay', async () => {
55
+ vi.useFakeTimers({shouldAdvanceTime: true});
56
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
57
+ const trigger = renderMenu();
58
+
59
+ await user.hover(trigger);
60
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
61
+
62
+ act(() => {
63
+ vi.advanceTimersByTime(300);
64
+ });
65
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
66
+ });
67
+
68
+ it('keeps the menu open when a hover-open is immediately clicked', async () => {
69
+ vi.useFakeTimers({shouldAdvanceTime: true});
70
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
71
+ const trigger = renderMenu();
72
+
73
+ await user.hover(trigger);
74
+ act(() => {
75
+ vi.advanceTimersByTime(300);
76
+ });
77
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
78
+
79
+ await user.click(trigger);
80
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
81
+ });
82
+
83
+ it('pins a confirmed menu so leaving the trigger no longer closes it', async () => {
84
+ vi.useFakeTimers({shouldAdvanceTime: true});
85
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
86
+ const trigger = renderMenu();
87
+
88
+ await user.hover(trigger);
89
+ act(() => {
90
+ vi.advanceTimersByTime(300);
91
+ });
92
+ await user.click(trigger);
93
+
94
+ await user.unhover(trigger);
95
+ act(() => {
96
+ vi.advanceTimersByTime(500);
97
+ });
98
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
99
+ });
100
+
101
+ it('closes on a click that lands well after the hover-open', async () => {
102
+ vi.useFakeTimers({shouldAdvanceTime: true});
103
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
104
+ const trigger = renderMenu();
105
+
106
+ await user.hover(trigger);
107
+ act(() => {
108
+ vi.advanceTimersByTime(300);
109
+ });
110
+ // Past the guard: a deliberate dismissal, not a follow-on.
111
+ act(() => {
112
+ vi.advanceTimersByTime(1200);
113
+ });
114
+
115
+ await user.click(trigger);
116
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
117
+ });
118
+
119
+ it('closes a transient (hover-opened) menu when the pointer leaves', async () => {
120
+ vi.useFakeTimers({shouldAdvanceTime: true});
121
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
122
+ const trigger = renderMenu();
123
+
124
+ await user.hover(trigger);
125
+ act(() => {
126
+ vi.advanceTimersByTime(300);
127
+ });
128
+ await user.unhover(trigger);
129
+ act(() => {
130
+ vi.advanceTimersByTime(400);
131
+ });
132
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
133
+ });
134
+
135
+ it('re-entering the trigger of an open menu does not re-arm the guard', async () => {
136
+ vi.useFakeTimers({shouldAdvanceTime: true});
137
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
138
+ const trigger = renderMenu();
139
+
140
+ // Click-open pins the menu.
141
+ await user.click(trigger);
142
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
143
+
144
+ // Re-entering must not un-pin it, nor make the next click a "confirm".
145
+ await user.unhover(trigger);
146
+ await user.hover(trigger);
147
+ act(() => {
148
+ vi.advanceTimersByTime(300);
149
+ });
150
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
151
+
152
+ await user.click(trigger);
153
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
154
+ });
155
+
156
+ it('stays closed when the panel vanishing puts the trigger back under the pointer', async () => {
157
+ vi.useFakeTimers({shouldAdvanceTime: true});
158
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
159
+ const trigger = renderMenu();
160
+
161
+ await user.click(trigger);
162
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
163
+
164
+ // Without suppression, the mouseenter fired when the panel stops covering
165
+ // the trigger reopens the menu — which made Escape look inert.
166
+ await user.click(trigger);
167
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
168
+ await user.hover(trigger);
169
+ act(() => {
170
+ vi.advanceTimersByTime(200);
171
+ });
172
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
173
+ });
174
+
175
+ it('reopens on a deliberate re-hover once the suppression window passes', async () => {
176
+ vi.useFakeTimers({shouldAdvanceTime: true});
177
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
178
+ const trigger = renderMenu();
179
+
180
+ await user.click(trigger);
181
+ await user.click(trigger);
182
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
183
+
184
+ await user.unhover(trigger);
185
+ act(() => {
186
+ vi.advanceTimersByTime(600);
187
+ });
188
+ await user.hover(trigger);
189
+ act(() => {
190
+ vi.advanceTimersByTime(300);
191
+ });
192
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
193
+ });
194
+
195
+ it('toggles cleanly for click-only interaction', async () => {
196
+ const user = userEvent.setup();
197
+ const trigger = renderMenu();
198
+
199
+ await user.click(trigger);
200
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
201
+ await user.click(trigger);
202
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
203
+ });
204
+ });
205
+
206
+ describe('useMenuHover — focus management', () => {
207
+ it('leaves focus on the trigger for a hover-open', async () => {
208
+ vi.useFakeTimers({shouldAdvanceTime: true});
209
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
210
+ const trigger = renderMenu();
211
+
212
+ await user.hover(trigger);
213
+ act(() => {
214
+ vi.advanceTimersByTime(300);
215
+ });
216
+
217
+ expect(firstMenuItem()).not.toHaveFocus();
218
+ });
219
+
220
+ it('moves focus to the first item on a click-open, synchronously', async () => {
221
+ const user = userEvent.setup();
222
+ const trigger = renderMenu();
223
+
224
+ await user.click(trigger);
225
+
226
+ // No waitFor and no timer flush: a deferred (rAF) focus fails here, which
227
+ // is the point of the assertion.
228
+ expect(firstMenuItem()).toHaveFocus();
229
+ });
230
+
231
+ it('moves focus into the menu when a hover-open is confirmed by click', async () => {
232
+ vi.useFakeTimers({shouldAdvanceTime: true});
233
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
234
+ const trigger = renderMenu();
235
+
236
+ await user.hover(trigger);
237
+ act(() => {
238
+ vi.advanceTimersByTime(300);
239
+ });
240
+ expect(firstMenuItem()).not.toHaveFocus();
241
+
242
+ await user.click(trigger);
243
+ expect(firstMenuItem()).toHaveFocus();
244
+ });
245
+
246
+ it('returns focus to the trigger when a click closes the menu', async () => {
247
+ const user = userEvent.setup();
248
+ const trigger = renderMenu();
249
+
250
+ await user.click(trigger);
251
+ expect(firstMenuItem()).toHaveFocus();
252
+
253
+ await user.click(trigger);
254
+ expect(trigger).toHaveFocus();
255
+ });
256
+
257
+ it('returns focus to the trigger on Escape', async () => {
258
+ const user = userEvent.setup();
259
+ const trigger = renderMenu();
260
+
261
+ await user.click(trigger);
262
+ expect(firstMenuItem()).toHaveFocus();
263
+
264
+ await user.keyboard('{Escape}');
265
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
266
+ expect(trigger).toHaveFocus();
267
+ });
268
+ });
269
+
270
+ describe('useMenuHover — keyboard activation', () => {
271
+ it('opens on Enter and moves focus into the menu', async () => {
272
+ const user = userEvent.setup();
273
+ const trigger = renderMenu();
274
+ trigger.focus();
275
+
276
+ await user.keyboard('{Enter}');
277
+
278
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
279
+ expect(firstMenuItem()).toHaveFocus();
280
+ });
281
+
282
+ it('opens on Space', async () => {
283
+ const user = userEvent.setup();
284
+ const trigger = renderMenu();
285
+ trigger.focus();
286
+
287
+ await user.keyboard(' ');
288
+
289
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
290
+ });
291
+
292
+ it('never toggles an open menu closed — it moves focus in instead', async () => {
293
+ vi.useFakeTimers({shouldAdvanceTime: true});
294
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
295
+ const trigger = renderMenu();
296
+
297
+ // A hover-open is the only state where a keyboard user can activate the
298
+ // trigger of an open menu; closing on Enter would strand them.
299
+ await user.hover(trigger);
300
+ act(() => {
301
+ vi.advanceTimersByTime(300);
302
+ });
303
+ trigger.focus();
304
+
305
+ await user.keyboard('{Enter}');
306
+
307
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
308
+ expect(firstMenuItem()).toHaveFocus();
309
+ });
310
+
311
+ it('arrow keys walk the menu items', async () => {
312
+ const user = userEvent.setup();
313
+ const trigger = renderMenu();
314
+
315
+ await user.click(trigger);
316
+ const menuItems =
317
+ document.querySelectorAll<HTMLElement>('[role="menuitem"]');
318
+ expect(menuItems[0]).toHaveFocus();
319
+
320
+ await user.keyboard('{ArrowDown}');
321
+ expect(menuItems[1]).toHaveFocus();
322
+ });
323
+ });
324
+
325
+ describe('useMenuHover — devices without hover', () => {
326
+ it('does not open on a synthetic mouseenter from a tap', async () => {
327
+ mockPointerlessDevice();
328
+ vi.useFakeTimers({shouldAdvanceTime: true});
329
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
330
+ const trigger = renderMenu();
331
+
332
+ // Taps emit a compatibility mouseenter; opening on it leaves a menu
333
+ // hanging open behind the tap.
334
+ await user.hover(trigger);
335
+ act(() => {
336
+ vi.advanceTimersByTime(500);
337
+ });
338
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
339
+ });
340
+
341
+ it('still opens and closes on click', async () => {
342
+ mockPointerlessDevice();
343
+ const user = userEvent.setup();
344
+ const trigger = renderMenu();
345
+
346
+ await user.click(trigger);
347
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
348
+ await user.click(trigger);
349
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
350
+ });
351
+ });
352
+
353
+ describe('useMenuHover — native invoker wiring', () => {
354
+ it('registers the trigger as the panel it controls', () => {
355
+ const trigger = renderMenu();
356
+
357
+ // jsdom implements neither light dismiss nor invokers, so this asserts the
358
+ // wiring; the behavior itself is browser-verified.
359
+ expect(trigger).toHaveAttribute(
360
+ 'popovertarget',
361
+ trigger.getAttribute('aria-controls'),
362
+ );
363
+ });
364
+ });