@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.
- package/CHANGELOG.md +55 -0
- package/dist/Avatar/Avatar.d.ts +4 -1
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +19 -23
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +7 -2
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +9 -7
- package/dist/Chat/ChatMessage.d.ts +7 -0
- package/dist/Chat/ChatMessage.d.ts.map +1 -1
- package/dist/Chat/ChatMessageBubble.d.ts +13 -1
- package/dist/Chat/ChatMessageBubble.d.ts.map +1 -1
- package/dist/Chat/ChatMessageBubble.js +19 -1
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +5 -0
- package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteFooter.js +5 -3
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +2 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +19 -15
- package/dist/HoverCard/HoverCard.d.ts +1 -1
- package/dist/HoverCard/HoverCard.d.ts.map +1 -1
- package/dist/HoverCard/HoverCard.js +5 -13
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +6 -5
- package/dist/InputGroup/groupStyles.d.ts.map +1 -1
- package/dist/InputGroup/groupStyles.js +7 -2
- package/dist/Layer/layerHost.d.ts +24 -0
- package/dist/Layer/layerHost.d.ts.map +1 -0
- package/dist/Layer/layerHost.js +79 -0
- package/dist/Layer/useLayer.d.ts +14 -6
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +158 -30
- package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
- package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
- package/dist/NavItem/navItemStyles.stylex.js +11 -5
- package/dist/RadioList/RadioListItem.d.ts.map +1 -1
- package/dist/RadioList/RadioListItem.js +5 -0
- package/dist/SideNav/SideNav.d.ts +7 -9
- package/dist/SideNav/SideNav.d.ts.map +1 -1
- package/dist/SideNav/SideNav.js +32 -5
- package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
- package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
- package/dist/SideNav/SideNavCollapseButton.js +39 -15
- package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
- package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
- package/dist/SideNav/SideNavCollapseContext.js +16 -2
- package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
- package/dist/SideNav/SideNavHeading.js +89 -32
- package/dist/SideNav/SideNavItem.d.ts +7 -2
- package/dist/SideNav/SideNavItem.d.ts.map +1 -1
- package/dist/SideNav/SideNavItem.js +119 -75
- package/dist/SideNav/SideNavSection.d.ts.map +1 -1
- package/dist/SideNav/SideNavSection.js +7 -16
- package/dist/SideNav/index.d.ts +1 -1
- package/dist/SideNav/index.d.ts.map +1 -1
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +56 -15
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +5 -0
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +5 -0
- package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
- package/dist/TopNav/TopNavHeading.js +14 -6
- package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMegaMenu.js +25 -93
- package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
- package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMenu.js +10 -5
- package/dist/astryx.css +40 -8
- package/dist/astryx.umd.js +50 -50
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/containerReveal.stylex.d.ts +71 -4
- package/dist/hooks/containerReveal.stylex.d.ts.map +1 -1
- package/dist/hooks/containerReveal.stylex.js +57 -5
- package/dist/hooks/index.d.ts +2 -2
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/useContainerReveal.d.ts +60 -3
- package/dist/hooks/useContainerReveal.d.ts.map +1 -1
- package/dist/hooks/useContainerReveal.js +27 -5
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +16 -2
- package/dist/hooks/useMenuHover.d.ts +50 -5
- package/dist/hooks/useMenuHover.d.ts.map +1 -1
- package/dist/hooks/useMenuHover.js +171 -51
- package/dist/theme/defineTheme.d.ts +16 -5
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +44 -48
- package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
- package/dist/theme/derivedVarRegistry.js +7 -0
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +1 -0
- package/dist/theme/expandMotionScale.d.ts +1 -0
- package/dist/theme/expandMotionScale.d.ts.map +1 -1
- package/dist/theme/expandMotionScale.js +1 -0
- package/dist/theme/expandRadiusScale.d.ts +1 -0
- package/dist/theme/expandRadiusScale.d.ts.map +1 -1
- package/dist/theme/expandRadiusScale.js +1 -0
- package/dist/theme/expandTypeScale.d.ts +1 -0
- package/dist/theme/expandTypeScale.d.ts.map +1 -1
- package/dist/theme/expandTypeScale.js +1 -0
- package/dist/theme/mergeComponents.d.ts +20 -0
- package/dist/theme/mergeComponents.d.ts.map +1 -0
- package/dist/theme/mergeComponents.js +56 -0
- package/dist/theme/onMediaTokens.d.ts +6 -1
- package/dist/theme/onMediaTokens.d.ts.map +1 -1
- package/dist/theme/onMediaTokens.js +11 -3
- package/dist/theme/tokens.stylex.d.ts +3 -1
- package/dist/theme/tokens.stylex.d.ts.map +1 -1
- package/dist/theme/tokens.stylex.js +3 -1
- package/locales/en.json +84 -0
- package/locales/pseudo.json +63 -0
- package/package.json +3 -3
- package/src/Avatar/Avatar.doc.mjs +5 -2
- package/src/Avatar/Avatar.test.tsx +51 -0
- package/src/Avatar/Avatar.tsx +30 -20
- package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
- package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
- package/src/Button/Button.tsx +5 -3
- package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
- package/src/Card/Card.doc.mjs +2 -0
- package/src/Chat/Chat.doc.mjs +4 -1
- package/src/Chat/ChatMessage.doc.mjs +3 -3
- package/src/Chat/ChatMessage.tsx +7 -0
- package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
- package/src/Chat/ChatMessageBubble.test.tsx +95 -6
- package/src/Chat/ChatMessageBubble.tsx +25 -0
- package/src/CheckboxInput/CheckboxInput.tsx +20 -0
- package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
- package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
- package/src/DateTimeInput/DateTimeInput.tsx +4 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
- package/src/HoverCard/HoverCard.doc.mjs +3 -3
- package/src/HoverCard/HoverCard.test.tsx +283 -55
- package/src/HoverCard/HoverCard.tsx +5 -13
- package/src/HoverCard/useHoverCard.tsx +8 -11
- package/src/InputGroup/groupStyles.ts +7 -8
- package/src/Item/Item.doc.mjs +4 -0
- package/src/Layer/layerHost.test.ts +99 -0
- package/src/Layer/layerHost.ts +141 -0
- package/src/Layer/useLayer.doc.mjs +14 -4
- package/src/Layer/useLayer.test.tsx +332 -7
- package/src/Layer/useLayer.tsx +235 -36
- package/src/MobileNav/MobileNav.doc.mjs +7 -0
- package/src/NavItem/navItemStyles.stylex.ts +31 -5
- package/src/RadioList/RadioListItem.tsx +20 -0
- package/src/SideNav/SideNav.doc.mjs +13 -4
- package/src/SideNav/SideNav.test.tsx +858 -2
- package/src/SideNav/SideNav.tsx +37 -16
- package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
- package/src/SideNav/SideNavCollapseButton.tsx +67 -15
- package/src/SideNav/SideNavCollapseContext.ts +29 -9
- package/src/SideNav/SideNavHeading.tsx +53 -25
- package/src/SideNav/SideNavItem.doc.mjs +13 -0
- package/src/SideNav/SideNavItem.tsx +97 -84
- package/src/SideNav/SideNavSection.tsx +6 -20
- package/src/SideNav/index.ts +2 -0
- package/src/Slider/Slider.test.tsx +69 -33
- package/src/Slider/Slider.tsx +65 -13
- package/src/Switch/Switch.tsx +20 -0
- package/src/TabList/TabList.doc.mjs +3 -0
- package/src/Text/Text.doc.mjs +1 -1
- package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
- package/src/Thumbnail/Thumbnail.tsx +10 -0
- package/src/Timestamp/Timestamp.test.tsx +40 -22
- package/src/Toolbar/Toolbar.test.tsx +14 -14
- package/src/TopNav/TopNav.test.tsx +86 -1
- package/src/TopNav/TopNavHeading.tsx +21 -14
- package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
- package/src/TopNav/TopNavMegaMenu.tsx +29 -105
- package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
- package/src/TopNav/TopNavMenu.test.tsx +41 -0
- package/src/TopNav/TopNavMenu.tsx +14 -4
- package/src/TreeList/TreeList.doc.mjs +1 -0
- package/src/hooks/containerReveal.stylex.ts +140 -9
- package/src/hooks/index.ts +6 -1
- package/src/hooks/useContainerReveal.doc.mjs +11 -5
- package/src/hooks/useContainerReveal.test.tsx +70 -3
- package/src/hooks/useContainerReveal.ts +86 -7
- package/src/hooks/useFocusTrap.test.tsx +16 -0
- package/src/hooks/useFocusTrap.ts +20 -2
- package/src/hooks/useMenuHover.test.tsx +364 -0
- package/src/hooks/useMenuHover.ts +243 -47
- package/src/theme/defineTheme.test.ts +127 -0
- package/src/theme/defineTheme.ts +57 -51
- package/src/theme/derivedVarRegistry.test.ts +78 -12
- package/src/theme/derivedVarRegistry.ts +4 -0
- package/src/theme/expandColorScale.ts +1 -0
- package/src/theme/expandMotionScale.ts +1 -0
- package/src/theme/expandRadiusScale.ts +1 -0
- package/src/theme/expandTypeScale.ts +1 -0
- package/src/theme/mergeComponents.ts +59 -0
- package/src/theme/onMediaTokens.ts +9 -2
- package/src/theme/themingTargets.test.ts +152 -26
- package/src/theme/tokens.stylex.ts +3 -1
- package/src/theme/tokens.test.ts +12 -0
- 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
|
+
});
|