@astryxdesign/core 0.4.1 → 0.4.2-canary.356d2f9

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 (201) 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 +40 -8
  72. package/dist/astryx.umd.js +50 -50
  73. package/dist/astryx.umd.js.map +4 -4
  74. package/dist/hooks/containerReveal.stylex.d.ts +71 -4
  75. package/dist/hooks/containerReveal.stylex.d.ts.map +1 -1
  76. package/dist/hooks/containerReveal.stylex.js +57 -5
  77. package/dist/hooks/index.d.ts +2 -2
  78. package/dist/hooks/index.d.ts.map +1 -1
  79. package/dist/hooks/index.js +1 -1
  80. package/dist/hooks/useContainerReveal.d.ts +60 -3
  81. package/dist/hooks/useContainerReveal.d.ts.map +1 -1
  82. package/dist/hooks/useContainerReveal.js +27 -5
  83. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  84. package/dist/hooks/useFocusTrap.js +16 -2
  85. package/dist/hooks/useMenuHover.d.ts +50 -5
  86. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  87. package/dist/hooks/useMenuHover.js +171 -51
  88. package/dist/theme/defineTheme.d.ts +16 -5
  89. package/dist/theme/defineTheme.d.ts.map +1 -1
  90. package/dist/theme/defineTheme.js +44 -48
  91. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  92. package/dist/theme/derivedVarRegistry.js +7 -0
  93. package/dist/theme/expandColorScale.d.ts.map +1 -1
  94. package/dist/theme/expandColorScale.js +1 -0
  95. package/dist/theme/expandMotionScale.d.ts +1 -0
  96. package/dist/theme/expandMotionScale.d.ts.map +1 -1
  97. package/dist/theme/expandMotionScale.js +1 -0
  98. package/dist/theme/expandRadiusScale.d.ts +1 -0
  99. package/dist/theme/expandRadiusScale.d.ts.map +1 -1
  100. package/dist/theme/expandRadiusScale.js +1 -0
  101. package/dist/theme/expandTypeScale.d.ts +1 -0
  102. package/dist/theme/expandTypeScale.d.ts.map +1 -1
  103. package/dist/theme/expandTypeScale.js +1 -0
  104. package/dist/theme/mergeComponents.d.ts +20 -0
  105. package/dist/theme/mergeComponents.d.ts.map +1 -0
  106. package/dist/theme/mergeComponents.js +56 -0
  107. package/dist/theme/onMediaTokens.d.ts +6 -1
  108. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  109. package/dist/theme/onMediaTokens.js +11 -3
  110. package/dist/theme/tokens.stylex.d.ts +3 -1
  111. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  112. package/dist/theme/tokens.stylex.js +3 -1
  113. package/locales/en.json +84 -0
  114. package/locales/pseudo.json +63 -0
  115. package/package.json +3 -3
  116. package/src/Avatar/Avatar.doc.mjs +5 -2
  117. package/src/Avatar/Avatar.test.tsx +51 -0
  118. package/src/Avatar/Avatar.tsx +30 -20
  119. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  120. package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
  121. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
  122. package/src/Button/Button.tsx +5 -3
  123. package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
  124. package/src/Card/Card.doc.mjs +2 -0
  125. package/src/Chat/Chat.doc.mjs +4 -1
  126. package/src/Chat/ChatMessage.doc.mjs +3 -3
  127. package/src/Chat/ChatMessage.tsx +7 -0
  128. package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
  129. package/src/Chat/ChatMessageBubble.test.tsx +95 -6
  130. package/src/Chat/ChatMessageBubble.tsx +25 -0
  131. package/src/CheckboxInput/CheckboxInput.tsx +20 -0
  132. package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
  133. package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
  134. package/src/DateTimeInput/DateTimeInput.tsx +4 -2
  135. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
  136. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
  137. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  138. package/src/HoverCard/HoverCard.test.tsx +283 -55
  139. package/src/HoverCard/HoverCard.tsx +5 -13
  140. package/src/HoverCard/useHoverCard.tsx +8 -11
  141. package/src/InputGroup/groupStyles.ts +7 -8
  142. package/src/Item/Item.doc.mjs +4 -0
  143. package/src/Layer/layerHost.test.ts +99 -0
  144. package/src/Layer/layerHost.ts +141 -0
  145. package/src/Layer/useLayer.doc.mjs +14 -4
  146. package/src/Layer/useLayer.test.tsx +332 -7
  147. package/src/Layer/useLayer.tsx +235 -36
  148. package/src/MobileNav/MobileNav.doc.mjs +7 -0
  149. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  150. package/src/RadioList/RadioListItem.tsx +20 -0
  151. package/src/SideNav/SideNav.doc.mjs +13 -4
  152. package/src/SideNav/SideNav.test.tsx +858 -2
  153. package/src/SideNav/SideNav.tsx +37 -16
  154. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  155. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  156. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  157. package/src/SideNav/SideNavHeading.tsx +53 -25
  158. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  159. package/src/SideNav/SideNavItem.tsx +97 -84
  160. package/src/SideNav/SideNavSection.tsx +6 -20
  161. package/src/SideNav/index.ts +2 -0
  162. package/src/Slider/Slider.test.tsx +69 -33
  163. package/src/Slider/Slider.tsx +65 -13
  164. package/src/Switch/Switch.tsx +20 -0
  165. package/src/TabList/TabList.doc.mjs +3 -0
  166. package/src/Text/Text.doc.mjs +1 -1
  167. package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
  168. package/src/Thumbnail/Thumbnail.tsx +10 -0
  169. package/src/Timestamp/Timestamp.test.tsx +40 -22
  170. package/src/Toolbar/Toolbar.test.tsx +14 -14
  171. package/src/TopNav/TopNav.test.tsx +86 -1
  172. package/src/TopNav/TopNavHeading.tsx +21 -14
  173. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  174. package/src/TopNav/TopNavMegaMenu.tsx +29 -105
  175. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  176. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  177. package/src/TopNav/TopNavMenu.tsx +14 -4
  178. package/src/TreeList/TreeList.doc.mjs +1 -0
  179. package/src/hooks/containerReveal.stylex.ts +140 -9
  180. package/src/hooks/index.ts +6 -1
  181. package/src/hooks/useContainerReveal.doc.mjs +11 -5
  182. package/src/hooks/useContainerReveal.test.tsx +70 -3
  183. package/src/hooks/useContainerReveal.ts +86 -7
  184. package/src/hooks/useFocusTrap.test.tsx +16 -0
  185. package/src/hooks/useFocusTrap.ts +20 -2
  186. package/src/hooks/useMenuHover.test.tsx +364 -0
  187. package/src/hooks/useMenuHover.ts +243 -47
  188. package/src/theme/defineTheme.test.ts +127 -0
  189. package/src/theme/defineTheme.ts +57 -51
  190. package/src/theme/derivedVarRegistry.test.ts +78 -12
  191. package/src/theme/derivedVarRegistry.ts +4 -0
  192. package/src/theme/expandColorScale.ts +1 -0
  193. package/src/theme/expandMotionScale.ts +1 -0
  194. package/src/theme/expandRadiusScale.ts +1 -0
  195. package/src/theme/expandTypeScale.ts +1 -0
  196. package/src/theme/mergeComponents.ts +59 -0
  197. package/src/theme/onMediaTokens.ts +9 -2
  198. package/src/theme/themingTargets.test.ts +152 -26
  199. package/src/theme/tokens.stylex.ts +3 -1
  200. package/src/theme/tokens.test.ts +12 -0
  201. package/src/theme/useTheme.test.tsx +18 -0
@@ -10,16 +10,22 @@
10
10
  */
11
11
 
12
12
  import React from 'react';
13
- import {describe, it, expect, vi} from 'vitest';
14
- import {render, screen, act, fireEvent} from '@testing-library/react';
13
+ import {describe, it, expect, vi, afterEach} from 'vitest';
14
+ import {render, screen, act, fireEvent, waitFor} from '@testing-library/react';
15
15
  import userEvent from '@testing-library/user-event';
16
16
  import {useRef, useState, type ReactNode} from 'react';
17
+ import * as stylex from '@stylexjs/stylex';
18
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
19
+ import {getForcedColorsRules} from '../__tests__/forcedColors';
20
+ import {Button} from '../Button';
17
21
  import {SideNav} from './SideNav';
18
22
  import {SideNavCollapseButton} from './SideNavCollapseButton';
19
23
  import {SideNavHeading} from './SideNavHeading';
20
24
  import {SideNavItem} from './SideNavItem';
21
25
  import {SideNavSection} from './SideNavSection';
22
26
  import {LinkProvider} from '../Link/LinkProvider';
27
+ import {InternationalizationProvider} from '../i18n/InternationalizationProvider';
28
+ import pseudoCatalog from '../../locales/pseudo.json';
23
29
  import {
24
30
  SideNavCollapseContext,
25
31
  type SideNavImperativeCollapseHandle,
@@ -1174,6 +1180,233 @@ describe('SideNavItem (collapsed)', () => {
1174
1180
  });
1175
1181
  });
1176
1182
 
1183
+ // =============================================================================
1184
+ // SideNavItem — collapsed submenu hover intent (useMenuHover adoption, C12)
1185
+ // =============================================================================
1186
+
1187
+ /**
1188
+ * Pin the ambient pointer modality: the global `matchMedia` stub never
1189
+ * matches, which reads as a coarse pointer, so hover assertions must opt in.
1190
+ */
1191
+ function stubPointerModality(modality: 'fine' | 'coarse') {
1192
+ vi.stubGlobal('matchMedia', (query: string) => ({
1193
+ matches: modality === 'fine' && query.includes('hover: hover'),
1194
+ media: query,
1195
+ onchange: null,
1196
+ addListener: () => {},
1197
+ removeListener: () => {},
1198
+ addEventListener: () => {},
1199
+ removeEventListener: () => {},
1200
+ dispatchEvent: () => false,
1201
+ }));
1202
+ }
1203
+
1204
+ /** The hook's hover-intent delays: 150 ms to show, 200 ms to hide. */
1205
+ const SHOW_DELAY = 150;
1206
+ const HIDE_DELAY = 200;
1207
+
1208
+ describe('SideNavItem — collapsed submenu hover intent', () => {
1209
+ afterEach(() => {
1210
+ vi.useRealTimers();
1211
+ vi.unstubAllGlobals();
1212
+ });
1213
+
1214
+ function renderFlyout() {
1215
+ renderCollapsed(
1216
+ <SideNavItem label="Settings" icon={StubIcon} data-testid="parent">
1217
+ <SideNavItem label="General" href="/settings/general" />
1218
+ <SideNavItem label="Security" href="/settings/security" />
1219
+ </SideNavItem>,
1220
+ );
1221
+ return screen.getByTestId('parent');
1222
+ }
1223
+
1224
+ const advance = (ms: number) =>
1225
+ act(() => {
1226
+ vi.advanceTimersByTime(ms);
1227
+ });
1228
+
1229
+ it('opens the flyout only after the hover-intent delay', () => {
1230
+ stubPointerModality('fine');
1231
+ vi.useFakeTimers();
1232
+ const trigger = renderFlyout();
1233
+
1234
+ fireEvent.mouseEnter(trigger);
1235
+ // A pointer merely passing over the icon must not open anything.
1236
+ advance(SHOW_DELAY - 1);
1237
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
1238
+
1239
+ advance(1);
1240
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
1241
+ });
1242
+
1243
+ it('closes a hover-opened flyout when the pointer leaves', () => {
1244
+ stubPointerModality('fine');
1245
+ vi.useFakeTimers();
1246
+ const trigger = renderFlyout();
1247
+
1248
+ fireEvent.mouseEnter(trigger);
1249
+ advance(SHOW_DELAY);
1250
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
1251
+
1252
+ fireEvent.mouseLeave(trigger);
1253
+ advance(HIDE_DELAY);
1254
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
1255
+ });
1256
+
1257
+ it('keeps a click-opened flyout open when the pointer leaves', () => {
1258
+ stubPointerModality('fine');
1259
+ vi.useFakeTimers();
1260
+ const trigger = renderFlyout();
1261
+
1262
+ // `detail: 1` marks a pointer click: the hook reads a detail-0 click as
1263
+ // Enter/Space, which always opens rather than toggling.
1264
+ fireEvent.click(trigger, {detail: 1});
1265
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
1266
+
1267
+ // Clicking is a commitment: the flyout outlives the pointer.
1268
+ fireEvent.mouseLeave(trigger);
1269
+ advance(HIDE_DELAY * 2);
1270
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
1271
+ });
1272
+
1273
+ it('does not spring back open when a click dismisses it under a stationary pointer', () => {
1274
+ stubPointerModality('fine');
1275
+ vi.useFakeTimers();
1276
+ const trigger = renderFlyout();
1277
+
1278
+ fireEvent.mouseEnter(trigger);
1279
+ advance(SHOW_DELAY);
1280
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
1281
+
1282
+ // The pointer never left, so dismissing fires a fresh mouseenter as the
1283
+ // flyout unmounts — which used to reopen what the user just closed.
1284
+ fireEvent.click(trigger, {detail: 1});
1285
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
1286
+
1287
+ fireEvent.mouseEnter(trigger);
1288
+ advance(SHOW_DELAY);
1289
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
1290
+
1291
+ // One-shot, not a latch: a deliberate leave-and-return opens it again.
1292
+ fireEvent.mouseLeave(trigger);
1293
+ fireEvent.mouseEnter(trigger);
1294
+ advance(SHOW_DELAY);
1295
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
1296
+ });
1297
+
1298
+ it('schedules no hover timers on a coarse pointer', () => {
1299
+ stubPointerModality('coarse');
1300
+ vi.useFakeTimers();
1301
+ const trigger = renderFlyout();
1302
+
1303
+ // A touch tap synthesises mouseenter. On a device that cannot hover there
1304
+ // is no hover intent to read, so nothing may be scheduled from it.
1305
+ fireEvent.mouseEnter(trigger);
1306
+ expect(vi.getTimerCount()).toBe(0);
1307
+
1308
+ advance(SHOW_DELAY * 2);
1309
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
1310
+
1311
+ // Tapping still opens it — the touch path goes through click, untouched.
1312
+ fireEvent.click(trigger, {detail: 1});
1313
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
1314
+ });
1315
+
1316
+ it('does not open the hover popover after the item unmounts', () => {
1317
+ stubPointerModality('fine');
1318
+ vi.useFakeTimers();
1319
+ const {unmount} = render(
1320
+ <SideNavCollapseContext
1321
+ value={{isCollapsed: true, toggle: () => {}, isCollapsible: true}}>
1322
+ <SideNavItem label="Settings" icon={StubIcon} data-testid="parent">
1323
+ <SideNavItem label="General" href="/settings/general" />
1324
+ </SideNavItem>
1325
+ </SideNavCollapseContext>,
1326
+ );
1327
+
1328
+ fireEvent.mouseEnter(screen.getByTestId('parent'));
1329
+ expect(vi.getTimerCount()).toBeGreaterThan(0);
1330
+
1331
+ unmount();
1332
+
1333
+ // The hook owns the timer cleanup now; it still has to hold.
1334
+ expect(vi.getTimerCount()).toBe(0);
1335
+ });
1336
+
1337
+ it('leaves arrow keys in the flyout to the browser', async () => {
1338
+ stubPointerModality('fine');
1339
+ const user = userEvent.setup();
1340
+ renderCollapsed(
1341
+ <SideNavItem label="Settings" icon={StubIcon} data-testid="parent">
1342
+ <SideNavItem label="General" href="/settings/general" />
1343
+ <SideNavItem label="Security" href="/settings/security" />
1344
+ </SideNavItem>,
1345
+ );
1346
+
1347
+ const trigger = screen.getByTestId('parent');
1348
+ await user.click(trigger);
1349
+
1350
+ // The flyout renders as a native popover, which jsdom leaves
1351
+ // `display: none`, so its contents are outside the default a11y-tree
1352
+ // queries.
1353
+ const first = screen.getByRole('link', {name: 'General', hidden: true});
1354
+ first.focus();
1355
+
1356
+ // A dialog of links, not a menu of menuitems: wiring the hook's
1357
+ // list-focus half would preventDefault every arrow key and find no
1358
+ // `[role="menuitem"]` to move to.
1359
+ const notPrevented = fireEvent.keyDown(first, {key: 'ArrowDown'});
1360
+ expect(notPrevented).toBe(true);
1361
+ expect(first).toHaveFocus();
1362
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
1363
+ });
1364
+
1365
+ it('opens from the keyboard, moves focus into the flyout, and restores it on Escape', async () => {
1366
+ stubPointerModality('fine');
1367
+ const user = userEvent.setup();
1368
+ renderCollapsed(
1369
+ <SideNavItem label="Settings" icon={StubIcon} data-testid="parent">
1370
+ <SideNavItem label="General" href="/settings/general" />
1371
+ <SideNavItem label="Security" href="/settings/security" />
1372
+ </SideNavItem>,
1373
+ );
1374
+
1375
+ const trigger = screen.getByTestId('parent');
1376
+ trigger.focus();
1377
+ await user.keyboard('{Enter}');
1378
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
1379
+
1380
+ // Keyboard opens move focus in; hover opens leave it on the trigger.
1381
+ await waitFor(() =>
1382
+ expect(
1383
+ screen.getByRole('link', {name: 'General', hidden: true}),
1384
+ ).toHaveFocus(),
1385
+ );
1386
+
1387
+ await user.keyboard('{Escape}');
1388
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
1389
+ await waitFor(() => expect(trigger).toHaveFocus());
1390
+ });
1391
+
1392
+ it('leaves focus on the trigger when hover opens the flyout', () => {
1393
+ stubPointerModality('fine');
1394
+ vi.useFakeTimers();
1395
+ const trigger = renderFlyout();
1396
+ trigger.focus();
1397
+
1398
+ fireEvent.mouseEnter(trigger);
1399
+ advance(SHOW_DELAY);
1400
+ act(() => {
1401
+ vi.advanceTimersToNextFrame();
1402
+ });
1403
+
1404
+ // `skipAutoFocus`: a mid-hover pointer user keeps their caret.
1405
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
1406
+ expect(trigger).toHaveFocus();
1407
+ });
1408
+ });
1409
+
1177
1410
  // =============================================================================
1178
1411
  // Mobile nav close-on-activate
1179
1412
  // =============================================================================
@@ -1393,3 +1626,626 @@ describe('SideNavItem — mobile drawer close-on-activate', () => {
1393
1626
  expect(closeMobileNav).not.toHaveBeenCalled();
1394
1627
  });
1395
1628
  });
1629
+
1630
+ // =============================================================================
1631
+ // Keyboard operation (audit §6 V6)
1632
+ // =============================================================================
1633
+
1634
+ describe('SideNav keyboard operation', () => {
1635
+ it('reaches every nav item with Tab, in document order', async () => {
1636
+ const user = userEvent.setup();
1637
+ render(
1638
+ <SideNav>
1639
+ <SideNavSection title="Main">
1640
+ <SideNavItem label="Dashboard" href="/dashboard" />
1641
+ <SideNavItem label="Projects" href="/projects" />
1642
+ <SideNavItem label="Settings" onClick={() => {}} />
1643
+ </SideNavSection>
1644
+ </SideNav>,
1645
+ );
1646
+
1647
+ await user.tab();
1648
+ expect(screen.getByRole('link', {name: 'Dashboard'})).toHaveFocus();
1649
+ await user.tab();
1650
+ expect(screen.getByRole('link', {name: 'Projects'})).toHaveFocus();
1651
+ await user.tab();
1652
+ expect(screen.getByRole('button', {name: 'Settings'})).toHaveFocus();
1653
+ });
1654
+
1655
+ it('skips a disabled item in the tab order', async () => {
1656
+ const user = userEvent.setup();
1657
+ render(
1658
+ <SideNav>
1659
+ <SideNavItem label="Dashboard" onClick={() => {}} />
1660
+ <SideNavItem label="Archived" onClick={() => {}} isDisabled />
1661
+ <SideNavItem label="Settings" onClick={() => {}} />
1662
+ </SideNav>,
1663
+ );
1664
+
1665
+ await user.tab();
1666
+ expect(screen.getByRole('button', {name: 'Dashboard'})).toHaveFocus();
1667
+ await user.tab();
1668
+ expect(screen.getByRole('button', {name: 'Settings'})).toHaveFocus();
1669
+ });
1670
+
1671
+ it('toggles a collapsible group with Enter and with Space', async () => {
1672
+ const user = userEvent.setup();
1673
+ render(
1674
+ <SideNavItem label="Settings" onClick={() => {}} collapsible>
1675
+ <SideNavItem label="General" href="/settings/general" />
1676
+ </SideNavItem>,
1677
+ );
1678
+
1679
+ const toggle = screen.getByRole('button', {name: 'Collapse Settings'});
1680
+ toggle.focus();
1681
+
1682
+ await user.keyboard('{Enter}');
1683
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
1684
+
1685
+ await user.keyboard(' ');
1686
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
1687
+ });
1688
+
1689
+ it('activates a button item with Enter', async () => {
1690
+ const user = userEvent.setup();
1691
+ const onClick = vi.fn();
1692
+ render(<SideNavItem label="Log out" onClick={onClick} />);
1693
+
1694
+ await user.tab();
1695
+ await user.keyboard('{Enter}');
1696
+ expect(onClick).toHaveBeenCalledTimes(1);
1697
+ });
1698
+
1699
+ it('opens the collapsed submenu from the keyboard and closes it with Escape', async () => {
1700
+ const user = userEvent.setup();
1701
+ renderCollapsed(
1702
+ <SideNavItem label="Settings" icon={StubIcon} data-testid="parent">
1703
+ <SideNavItem label="General" href="/settings/general" />
1704
+ </SideNavItem>,
1705
+ );
1706
+
1707
+ const trigger = screen.getByTestId('parent');
1708
+ trigger.focus();
1709
+ await user.keyboard('{Enter}');
1710
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
1711
+
1712
+ await user.keyboard('{Escape}');
1713
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
1714
+ });
1715
+ });
1716
+
1717
+ // =============================================================================
1718
+ // Focus management (audit §6 V7)
1719
+ // =============================================================================
1720
+
1721
+ describe('SideNav focus management', () => {
1722
+ it('keeps focus on the toggle when a group collapses', async () => {
1723
+ const user = userEvent.setup();
1724
+ render(
1725
+ <SideNavItem label="Settings" href="/settings" collapsible>
1726
+ <SideNavItem label="General" href="/settings/general" />
1727
+ </SideNavItem>,
1728
+ );
1729
+
1730
+ const toggle = screen.getByRole('button', {name: 'Collapse Settings'});
1731
+ await user.click(toggle);
1732
+
1733
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
1734
+ expect(toggle).toHaveFocus();
1735
+ });
1736
+
1737
+ it('does not leave focus on a child that the group just hid', async () => {
1738
+ const user = userEvent.setup();
1739
+ render(
1740
+ <SideNavItem label="Settings" href="/settings" collapsible>
1741
+ <SideNavItem label="General" href="/settings/general" />
1742
+ </SideNavItem>,
1743
+ );
1744
+
1745
+ const child = screen.getByRole('link', {name: 'General'});
1746
+ child.focus();
1747
+
1748
+ const toggle = screen.getByRole('button', {name: 'Collapse Settings'});
1749
+ await user.click(toggle);
1750
+
1751
+ // The collapsed group is `inert`: focus may land neither inside it nor
1752
+ // on <body>.
1753
+ expect(document.activeElement).not.toBe(child);
1754
+ expect(document.activeElement).not.toBe(document.body);
1755
+ expect(toggle).toHaveFocus();
1756
+ });
1757
+
1758
+ it('returns focus to the trigger when the collapsed submenu closes', async () => {
1759
+ const user = userEvent.setup();
1760
+ renderCollapsed(
1761
+ <SideNavItem label="Settings" icon={StubIcon} data-testid="parent">
1762
+ <SideNavItem label="General" href="/settings/general" />
1763
+ </SideNavItem>,
1764
+ );
1765
+
1766
+ const trigger = screen.getByTestId('parent');
1767
+ await user.click(trigger);
1768
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
1769
+
1770
+ await user.keyboard('{Escape}');
1771
+ // The layer restores focus asynchronously after it closes.
1772
+ await waitFor(() => expect(trigger).toHaveFocus());
1773
+ });
1774
+ });
1775
+
1776
+ // =============================================================================
1777
+ // Audit regressions — the defects this pass fixed
1778
+ // =============================================================================
1779
+
1780
+ describe('SideNav audit regressions', () => {
1781
+ it('names the collapsed submenu dialog from the catalog, not a concatenation', async () => {
1782
+ const user = userEvent.setup();
1783
+ renderCollapsed(
1784
+ <SideNavItem label="Settings" icon={StubIcon} data-testid="parent">
1785
+ <SideNavItem label="General" href="/settings/general" />
1786
+ </SideNavItem>,
1787
+ );
1788
+
1789
+ await user.click(screen.getByTestId('parent'));
1790
+ const dialog = document.querySelector('[role="dialog"]');
1791
+ expect(dialog).not.toBeNull();
1792
+ expect(dialog).toHaveAttribute('aria-label', 'Settings submenu');
1793
+ });
1794
+
1795
+ it('translates the collapsed submenu dialog name', async () => {
1796
+ const user = userEvent.setup();
1797
+ render(
1798
+ <InternationalizationProvider
1799
+ locale="pseudo"
1800
+ messages={{pseudo: pseudoCatalog}}>
1801
+ <SideNavCollapseContext
1802
+ value={{isCollapsed: true, toggle: () => {}, isCollapsible: true}}>
1803
+ <SideNavItem label="Settings" icon={StubIcon} data-testid="parent">
1804
+ <SideNavItem label="General" href="/settings/general" />
1805
+ </SideNavItem>
1806
+ </SideNavCollapseContext>
1807
+ </InternationalizationProvider>,
1808
+ );
1809
+
1810
+ await user.click(screen.getByTestId('parent'));
1811
+ const label = document
1812
+ .querySelector('[role="dialog"]')
1813
+ ?.getAttribute('aria-label');
1814
+
1815
+ // A concatenated `${label} submenu` would stay plain English here.
1816
+ expect(label).toContain('Settings');
1817
+ expect(label).not.toBe('Settings submenu');
1818
+ });
1819
+
1820
+ it('forwards unrecognised props on SideNavItem through to the control', () => {
1821
+ render(
1822
+ <SideNavItem
1823
+ label="Dashboard"
1824
+ href="/dashboard"
1825
+ data-analytics="nav-dashboard"
1826
+ aria-describedby="hint"
1827
+ />,
1828
+ );
1829
+
1830
+ const link = screen.getByRole('link', {name: 'Dashboard'});
1831
+ expect(link).toHaveAttribute('data-analytics', 'nav-dashboard');
1832
+ expect(link).toHaveAttribute('aria-describedby', 'hint');
1833
+ });
1834
+
1835
+ it('forwards unrecognised props on a collapsed SideNavItem', () => {
1836
+ renderCollapsed(
1837
+ <SideNavItem
1838
+ label="Dashboard"
1839
+ icon={StubIcon}
1840
+ href="/dashboard"
1841
+ data-analytics="nav-dashboard"
1842
+ />,
1843
+ );
1844
+
1845
+ expect(screen.getByRole('link')).toHaveAttribute(
1846
+ 'data-analytics',
1847
+ 'nav-dashboard',
1848
+ );
1849
+ });
1850
+
1851
+ it('exposes a disabled item as a theming state', () => {
1852
+ render(<SideNavItem label="Archived" onClick={() => {}} isDisabled />);
1853
+
1854
+ expect(screen.getByRole('button', {name: 'Archived'})).toHaveAttribute(
1855
+ 'data-disabled',
1856
+ 'disabled',
1857
+ );
1858
+ });
1859
+
1860
+ it('keeps a hidden section header readable by assistive technology', () => {
1861
+ render(
1862
+ <SideNavSection title="Main" isHeaderHidden>
1863
+ <SideNavItem label="Dashboard" href="/dashboard" />
1864
+ </SideNavSection>,
1865
+ );
1866
+
1867
+ expect(screen.getByRole('group')).toHaveAccessibleName('Main');
1868
+ expect(screen.getByText('Main')).toBeInTheDocument();
1869
+ });
1870
+
1871
+ it('keeps a SideNavCollapseButton outside the sidenav in step with it', async () => {
1872
+ const user = userEvent.setup();
1873
+
1874
+ function App() {
1875
+ const [isCollapsed, setIsCollapsed] = useState(false);
1876
+ const collapsible = {isCollapsed, onCollapsedChange: setIsCollapsed};
1877
+ return (
1878
+ <>
1879
+ <SideNavCollapseButton collapsible={collapsible} />
1880
+ <SideNav collapsible={{...collapsible, hasButton: false}}>
1881
+ <SideNavItem label="Dashboard" href="/dashboard" />
1882
+ </SideNav>
1883
+ </>
1884
+ );
1885
+ }
1886
+
1887
+ render(<App />);
1888
+
1889
+ await user.click(screen.getByRole('button', {name: 'Collapse sidebar'}));
1890
+ const expand = await screen.findByRole('button', {name: 'Expand sidebar'});
1891
+
1892
+ await user.click(expand);
1893
+ expect(
1894
+ await screen.findByRole('button', {name: 'Collapse sidebar'}),
1895
+ ).toBeInTheDocument();
1896
+ });
1897
+ });
1898
+
1899
+ // =============================================================================
1900
+ // A15 — the shared focus ring
1901
+ //
1902
+ // jsdom does not derive `:focus-visible` from `.focus()` or from Tab, so the
1903
+ // painted ring cannot be read back here; the geometry is measured in a real
1904
+ // browser instead. These pin the composition — that the focusable element
1905
+ // carries the classes `focusOutlineStyles.focusVisible` compiles to.
1906
+ // =============================================================================
1907
+
1908
+ const sharedFocusRingClasses = stylex
1909
+ .props(focusOutlineStyles.focusVisible)
1910
+ .className!.split(' ');
1911
+
1912
+ function expectSharedFocusRing(el: Element) {
1913
+ const classes = el.className.split(' ');
1914
+ for (const c of sharedFocusRingClasses) {
1915
+ expect(classes).toContain(c);
1916
+ }
1917
+ }
1918
+
1919
+ function expectNoSharedFocusRing(el: Element) {
1920
+ const classes = el.className.split(' ');
1921
+ for (const c of sharedFocusRingClasses) {
1922
+ expect(classes).not.toContain(c);
1923
+ }
1924
+ }
1925
+
1926
+ describe('SideNav focus ring (A15)', () => {
1927
+ it('draws the shared ring on a link item', () => {
1928
+ render(<SideNavItem label="Dashboard" href="/dashboard" />);
1929
+ expectSharedFocusRing(screen.getByRole('link', {name: 'Dashboard'}));
1930
+ });
1931
+
1932
+ it('draws the shared ring on a button item', () => {
1933
+ render(<SideNavItem label="Dashboard" onClick={() => {}} />);
1934
+ expectSharedFocusRing(screen.getByRole('button', {name: 'Dashboard'}));
1935
+ });
1936
+
1937
+ it('draws the shared ring on a collapsed icon-only item', () => {
1938
+ renderCollapsed(
1939
+ <SideNavItem label="Dashboard" icon={StubIcon} href="/dashboard" />,
1940
+ );
1941
+ expectSharedFocusRing(screen.getByRole('link', {name: 'Dashboard'}));
1942
+ });
1943
+
1944
+ it('draws the shared ring on a collapsed submenu trigger', () => {
1945
+ renderCollapsed(
1946
+ <SideNavItem label="Settings" icon={StubIcon}>
1947
+ <SideNavItem label="General" href="/settings/general" />
1948
+ </SideNavItem>,
1949
+ );
1950
+ expectSharedFocusRing(screen.getByRole('button', {name: 'Settings'}));
1951
+ });
1952
+
1953
+ it('rings each focusable of a split-action row, and not the row itself', () => {
1954
+ render(
1955
+ <SideNavItem label="Settings" href="/settings" collapsible>
1956
+ <SideNavItem label="General" href="/settings/general" />
1957
+ </SideNavItem>,
1958
+ );
1959
+
1960
+ const link = screen.getByRole('link', {name: 'Settings'});
1961
+ const toggle = screen.getByRole('button', {name: 'Collapse Settings'});
1962
+ expectSharedFocusRing(link);
1963
+ expectSharedFocusRing(toggle);
1964
+
1965
+ // A presentational <div> holding two independent tab stops; ringing it
1966
+ // too would paint a second outline around the whole row.
1967
+ const row = link.parentElement!;
1968
+ expect(row.tagName).toBe('DIV');
1969
+ expect(row).toContainElement(toggle);
1970
+ expectNoSharedFocusRing(row);
1971
+ });
1972
+
1973
+ it('draws the shared ring on a heading rendered as one link', () => {
1974
+ render(<SideNavHeading heading="My App" headingHref="/" />);
1975
+ expectSharedFocusRing(screen.getByRole('link', {name: /My App/}));
1976
+ });
1977
+
1978
+ it('draws the shared ring on a collapsed heading link', () => {
1979
+ renderCollapsed(
1980
+ <SideNavHeading heading="My App" headingHref="/" icon={<span>i</span>} />,
1981
+ );
1982
+ expectSharedFocusRing(screen.getByRole('link', {name: 'My App'}));
1983
+ });
1984
+
1985
+ it('draws the shared ring on the heading menu trigger', () => {
1986
+ render(<SideNavHeading heading="My App" menu={<div>menu</div>} />);
1987
+ expectSharedFocusRing(screen.getByRole('button', {name: 'Open menu'}));
1988
+ });
1989
+ });
1990
+
1991
+ // jsdom cannot emulate forced-colors rendering, so these assert that the
1992
+ // compiled output carries the rules the selected row relies on. The rendered
1993
+ // behaviour was measured in Chromium under `forced-colors: active` — see the
1994
+ // evidence on #4880.
1995
+ describe('forced colors (WCAG 1.4.11)', () => {
1996
+ it('compiles forced-colors overrides so the current page survives Windows High Contrast', () => {
1997
+ render(
1998
+ <SideNav>
1999
+ <SideNavSection title="Main">
2000
+ <SideNavItem label="Dashboard" href="/dashboard" isSelected />
2001
+ <SideNavItem label="Projects" href="/projects" />
2002
+ </SideNavSection>
2003
+ </SideNav>,
2004
+ );
2005
+ const css = getForcedColorsRules();
2006
+ // `--color-neutral` is a background: forced colors flatten it to Canvas
2007
+ // and the selected row loses every trace of being selected. Highlight /
2008
+ // HighlightText is the platform convention that survives.
2009
+ expect(css).toContain('background-color: highlight;');
2010
+ expect(css).toContain('color: highlighttext;');
2011
+ // The hover fill has to hold the Highlight too, or hovering the current
2012
+ // page repaints it with the ordinary hover overlay and erases the state.
2013
+ // Nesting it inside the hover media query is what wins that fight: the
2014
+ // rule compiles to a triple-class selector, which outranks `item`'s
2015
+ // single-class hover overlay no matter the source order.
2016
+ expect(css).toMatch(
2017
+ /@media \(hover: hover\)[\s\S]*?forced-colors: active[\s\S]*?:hover[\s\S]*?background-color: highlight/,
2018
+ );
2019
+ });
2020
+
2021
+ it('does not opt nav rows out of UA colour remapping', () => {
2022
+ render(
2023
+ <SideNav>
2024
+ <SideNavSection title="Main">
2025
+ <SideNavItem label="Dashboard" href="/dashboard" isSelected />
2026
+ </SideNavSection>
2027
+ </SideNav>,
2028
+ );
2029
+ // A nav row is not a native form control — it resets `appearance` and
2030
+ // paints its own background, so the system keywords land without
2031
+ // `forced-color-adjust: none`. Setting it would inherit into
2032
+ // `endContent`, keeping a Badge's authored fill instead of remapping it.
2033
+ const row = screen.getByRole('link', {name: 'Dashboard'});
2034
+ expect(getComputedStyle(row).forcedColorAdjust).not.toBe('none');
2035
+ });
2036
+ });
2037
+
2038
+ // =============================================================================
2039
+ // Footer icon row — one size for the whole row
2040
+ // =============================================================================
2041
+
2042
+ describe('SideNav footer icon row sizing', () => {
2043
+ /**
2044
+ * The class(es) a `size="sm"` Button carries that a `size="md"` one does
2045
+ * not — i.e. whatever StyleX compiled the sm height into. Comparing class
2046
+ * sets keeps the assertion off the generated hashes.
2047
+ */
2048
+ function sizeOnlyClasses(size: 'sm' | 'lg'): string[] {
2049
+ const {unmount} = render(
2050
+ <>
2051
+ <Button
2052
+ label="sized reference"
2053
+ size={size}
2054
+ isIconOnly
2055
+ icon={<span />}
2056
+ data-testid="sized-ref"
2057
+ />
2058
+ <Button
2059
+ label="md reference"
2060
+ size="md"
2061
+ isIconOnly
2062
+ icon={<span />}
2063
+ data-testid="md-ref"
2064
+ />
2065
+ </>,
2066
+ );
2067
+ const sized = new Set(screen.getByTestId('sized-ref').classList);
2068
+ const md = new Set(screen.getByTestId('md-ref').classList);
2069
+ const only = [...sized].filter(c => !md.has(c));
2070
+ unmount();
2071
+ // Guard against a vacuous pass: if the two sizes compiled to the same
2072
+ // classes there is nothing to assert and every check would trivially hold.
2073
+ expect(only.length).toBeGreaterThan(0);
2074
+ return only;
2075
+ }
2076
+
2077
+ it('renders the built-in collapse button at the row size, not the Button default', () => {
2078
+ const smClasses = sizeOnlyClasses('sm');
2079
+ render(
2080
+ <SideNav collapsible>
2081
+ <SideNavSection title="Main">
2082
+ <SideNavItem label="Dashboard" href="/dashboard" />
2083
+ </SideNavSection>
2084
+ </SideNav>,
2085
+ );
2086
+ const collapseButton = screen.getByRole('button', {
2087
+ name: /collapse sidebar/i,
2088
+ });
2089
+ for (const className of smClasses) {
2090
+ expect(collapseButton).toHaveClass(className);
2091
+ }
2092
+ });
2093
+
2094
+ it('cascades the same size to footerIcons the consumer did not size', () => {
2095
+ const smClasses = sizeOnlyClasses('sm');
2096
+ render(
2097
+ <SideNav
2098
+ collapsible
2099
+ footerIcons={
2100
+ <Button label="Help" isIconOnly icon={<span />} data-testid="help" />
2101
+ }>
2102
+ <SideNavSection title="Main">
2103
+ <SideNavItem label="Dashboard" href="/dashboard" />
2104
+ </SideNavSection>
2105
+ </SideNav>,
2106
+ );
2107
+ const help = screen.getByTestId('help');
2108
+ for (const className of smClasses) {
2109
+ expect(help).toHaveClass(className);
2110
+ }
2111
+ });
2112
+
2113
+ it('lets an explicit size on a footer icon win over the cascade', () => {
2114
+ const lgClasses = sizeOnlyClasses('lg');
2115
+ render(
2116
+ <SideNav
2117
+ collapsible
2118
+ footerIcons={
2119
+ <Button
2120
+ label="Help"
2121
+ size="lg"
2122
+ isIconOnly
2123
+ icon={<span />}
2124
+ data-testid="help"
2125
+ />
2126
+ }>
2127
+ <SideNavSection title="Main">
2128
+ <SideNavItem label="Dashboard" href="/dashboard" />
2129
+ </SideNavSection>
2130
+ </SideNav>,
2131
+ );
2132
+ const help = screen.getByTestId('help');
2133
+ for (const className of lgClasses) {
2134
+ expect(help).toHaveClass(className);
2135
+ }
2136
+ });
2137
+
2138
+ it('honours an explicit size on SideNavCollapseButton outside the nav', () => {
2139
+ const lgClasses = sizeOnlyClasses('lg');
2140
+ render(
2141
+ <SideNavCollapseContext
2142
+ value={{isCollapsed: false, toggle: () => {}, isCollapsible: true}}>
2143
+ <SideNavCollapseButton size="lg" data-testid="collapse" />
2144
+ </SideNavCollapseContext>,
2145
+ );
2146
+ const collapse = screen.getByTestId('collapse');
2147
+ for (const className of lgClasses) {
2148
+ expect(collapse).toHaveClass(className);
2149
+ }
2150
+ });
2151
+
2152
+ it('centres the chevron instead of seating it on a text baseline', () => {
2153
+ renderExpanded(<SideNavCollapseButton data-testid="collapse" />);
2154
+ // Icon renders its own `astryx-icon` span around the svg; the RTL-mirror
2155
+ // wrapper this component adds is that span's parent.
2156
+ const iconSpan = screen
2157
+ .getByTestId('collapse')
2158
+ .querySelector('span.astryx-icon');
2159
+ const mirror = iconSpan?.parentElement;
2160
+ // The RTL-mirror wrapper is a flex item of Button's icon slot, so it
2161
+ // blockifies; as a block it gets a line box and the glyph sits on the
2162
+ // baseline, 2.42px above centre (measured in Chromium). A flex container
2163
+ // has no line box.
2164
+ expect(mirror && getComputedStyle(mirror).display).toBe('flex');
2165
+ });
2166
+ });
2167
+
2168
+ describe('SideNavHeading hover/click guard', () => {
2169
+ // tabIndex={-1} matches real menu items; a bare role=menuitem div is not
2170
+ // focusable.
2171
+ const menuItems = (
2172
+ <>
2173
+ <div role="menuitem" tabIndex={-1}>
2174
+ Alpha
2175
+ </div>
2176
+ <div role="menuitem" tabIndex={-1}>
2177
+ Beta
2178
+ </div>
2179
+ </>
2180
+ );
2181
+
2182
+ it('keeps the menu open when a hover-open is immediately clicked', async () => {
2183
+ vi.useFakeTimers({shouldAdvanceTime: true});
2184
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
2185
+ render(<SideNavHeading heading="My App" menu={menuItems} />);
2186
+ const trigger = screen.getByRole('button', {name: 'Open menu'});
2187
+
2188
+ await user.hover(trigger);
2189
+ act(() => {
2190
+ vi.advanceTimersByTime(50);
2191
+ });
2192
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
2193
+
2194
+ await user.click(trigger);
2195
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
2196
+
2197
+ vi.useRealTimers();
2198
+ });
2199
+
2200
+ it('closes on a click that lands well after the hover-open', async () => {
2201
+ vi.useFakeTimers({shouldAdvanceTime: true});
2202
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
2203
+ render(<SideNavHeading heading="My App" menu={menuItems} />);
2204
+ const trigger = screen.getByRole('button', {name: 'Open menu'});
2205
+
2206
+ await user.hover(trigger);
2207
+ act(() => {
2208
+ vi.advanceTimersByTime(50);
2209
+ });
2210
+ act(() => {
2211
+ vi.advanceTimersByTime(1200);
2212
+ });
2213
+
2214
+ await user.click(trigger);
2215
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
2216
+
2217
+ vi.useRealTimers();
2218
+ });
2219
+
2220
+ it('leaves focus on the trigger for a hover-open, and moves it in on click', async () => {
2221
+ vi.useFakeTimers({shouldAdvanceTime: true});
2222
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
2223
+ render(<SideNavHeading heading="My App" menu={menuItems} />);
2224
+ const trigger = screen.getByRole('button', {name: 'Open menu'});
2225
+
2226
+ await user.hover(trigger);
2227
+ act(() => {
2228
+ vi.advanceTimersByTime(50);
2229
+ });
2230
+ const firstItem = screen.getAllByRole('menuitem', {hidden: true})[0];
2231
+ expect(firstItem).not.toHaveFocus();
2232
+
2233
+ await user.click(trigger);
2234
+ expect(firstItem).toHaveFocus();
2235
+
2236
+ vi.useRealTimers();
2237
+ });
2238
+
2239
+ it('returns focus to the trigger on Escape', async () => {
2240
+ const user = userEvent.setup();
2241
+ render(<SideNavHeading heading="My App" menu={menuItems} />);
2242
+ const trigger = screen.getByRole('button', {name: 'Open menu'});
2243
+
2244
+ await user.click(trigger);
2245
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
2246
+
2247
+ await user.keyboard('{Escape}');
2248
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
2249
+ expect(trigger).toHaveFocus();
2250
+ });
2251
+ });