@astryxdesign/core 0.4.0 → 0.4.1-canary.0ad35c3
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 +39 -0
- package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
- package/dist/AspectRatio/AspectRatio.js +1 -2
- package/dist/AspectRatio/index.d.ts.map +1 -1
- package/dist/AspectRatio/index.js +1 -2
- package/dist/Badge/Badge.d.ts.map +1 -1
- package/dist/Badge/Badge.js +1 -2
- package/dist/Badge/index.d.ts.map +1 -1
- package/dist/Badge/index.js +1 -2
- package/dist/Banner/Banner.d.ts +3 -0
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +17 -3
- package/dist/Blockquote/Blockquote.d.ts.map +1 -1
- package/dist/Blockquote/Blockquote.js +1 -2
- package/dist/Blockquote/index.d.ts.map +1 -1
- package/dist/Blockquote/index.js +1 -2
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +11 -9
- package/dist/Card/Card.d.ts.map +1 -1
- package/dist/Card/Card.js +1 -2
- package/dist/Card/index.d.ts.map +1 -1
- package/dist/Card/index.js +1 -2
- package/dist/Center/Center.d.ts.map +1 -1
- package/dist/Center/Center.js +1 -2
- package/dist/Center/index.d.ts.map +1 -1
- package/dist/Center/index.js +1 -2
- 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/Code/Code.d.ts.map +1 -1
- package/dist/Code/Code.js +1 -2
- package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPalette.js +5 -0
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +7 -8
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +23 -10
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +44 -16
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +32 -13
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioGroup.js +10 -3
- package/dist/Field/PanelSearchInput.d.ts +81 -0
- package/dist/Field/PanelSearchInput.d.ts.map +1 -0
- package/dist/Field/PanelSearchInput.js +151 -0
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +21 -10
- package/dist/Grid/Grid.d.ts.map +1 -1
- package/dist/Grid/Grid.js +1 -2
- package/dist/Grid/index.d.ts.map +1 -1
- package/dist/Grid/index.js +1 -2
- 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/Icon/globalIconRegistry.d.ts +5 -4
- package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
- package/dist/Icon/globalIconRegistry.js +5 -4
- package/dist/Indicator/index.d.ts +1 -1
- package/dist/Indicator/index.d.ts.map +1 -1
- package/dist/Indicator/types.d.ts +10 -0
- package/dist/Indicator/types.d.ts.map +1 -1
- 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 +155 -29
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +24 -50
- package/dist/MultiSelector/MultiSelector.d.ts +8 -1
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +134 -102
- 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/Popover/usePopover.d.ts +15 -0
- package/dist/Popover/usePopover.d.ts.map +1 -1
- package/dist/Popover/usePopover.js +14 -2
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
- package/dist/ProgressBar/ProgressBar.js +2 -3
- package/dist/Section/Section.d.ts.map +1 -1
- package/dist/Section/Section.js +1 -2
- package/dist/Section/index.d.ts.map +1 -1
- package/dist/Section/index.js +1 -2
- package/dist/Selector/Selector.d.ts +10 -0
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +135 -95
- 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 +70 -24
- package/dist/SideNav/SideNavItem.d.ts +7 -2
- package/dist/SideNav/SideNavItem.d.ts.map +1 -1
- package/dist/SideNav/SideNavItem.js +114 -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/Skeleton/Skeleton.d.ts.map +1 -1
- package/dist/Skeleton/Skeleton.js +1 -2
- package/dist/Skeleton/index.d.ts.map +1 -1
- package/dist/Skeleton/index.js +1 -2
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +2 -6
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +3 -3
- package/dist/Table/TableRow.d.ts +1 -1
- package/dist/Table/TableRow.d.ts.map +1 -1
- package/dist/Table/TableRow.js +4 -2
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +7 -2
- package/dist/Timestamp/Timestamp.d.ts.map +1 -1
- package/dist/Timestamp/Timestamp.js +14 -3
- package/dist/Timestamp/formatInstant.d.ts +11 -1
- package/dist/Timestamp/formatInstant.d.ts.map +1 -1
- package/dist/Timestamp/formatInstant.js +3 -1
- package/dist/TopNav/TopNavMegaMenu.js +1 -1
- package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
- package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMenu.js +2 -2
- package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
- package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
- package/dist/VisuallyHidden/index.d.ts.map +1 -1
- package/dist/VisuallyHidden/index.js +1 -2
- package/dist/astryx.css +23 -24
- package/dist/astryx.umd.js +47 -47
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +16 -2
- package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
- package/dist/hooks/useInputStatusIcon.js +17 -4
- package/dist/theme/defineTheme.d.ts +2 -2
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +2 -1
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/tokens.stylex.d.ts +12 -0
- package/dist/theme/tokens.stylex.d.ts.map +1 -1
- package/dist/theme/tokens.stylex.js +23 -0
- package/dist/utils/focusOutline.stylex.d.ts +34 -19
- package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
- package/dist/utils/focusOutline.stylex.js +46 -33
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +5 -1
- package/dist/utils/interactionModality.d.ts +34 -0
- package/dist/utils/interactionModality.d.ts.map +1 -0
- package/dist/utils/interactionModality.js +70 -0
- package/locales/en.json +44 -0
- package/locales/pseudo.json +33 -0
- package/package.json +3 -3
- package/src/AppShell/AppShell.doc.mjs +3 -0
- package/src/AspectRatio/AspectRatio.tsx +0 -2
- package/src/AspectRatio/index.ts +0 -2
- package/src/Badge/Badge.tsx +0 -2
- package/src/Badge/index.ts +0 -2
- package/src/Banner/Banner.test.tsx +58 -1
- package/src/Banner/Banner.tsx +24 -5
- package/src/Blockquote/Blockquote.tsx +0 -2
- package/src/Blockquote/index.ts +0 -2
- package/src/Button/Button.doc.mjs +2 -2
- package/src/Button/Button.tsx +10 -7
- package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
- package/src/Card/Card.tsx +0 -2
- package/src/Card/index.ts +0 -2
- package/src/Center/Center.tsx +0 -2
- package/src/Center/index.ts +0 -2
- 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.test.tsx +8 -3
- package/src/Code/Code.tsx +0 -2
- package/src/CommandPalette/CommandPalette.test.tsx +54 -0
- package/src/CommandPalette/CommandPalette.tsx +5 -0
- package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
- package/src/ComplexSelector/ComplexSelector.tsx +6 -10
- package/src/DateInput/DateInput.test.tsx +19 -0
- package/src/DateInput/DateInput.tsx +3 -6
- package/src/DateRangeInput/DateRangeInput.tsx +3 -9
- package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
- package/src/DateTimeInput/DateTimeInput.tsx +23 -9
- package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
- package/src/Field/PanelSearchInput.tsx +271 -0
- package/src/FileInput/FileInput.tsx +21 -8
- package/src/Grid/Grid.tsx +0 -2
- package/src/Grid/index.ts +0 -2
- 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/Icon/globalIconRegistry.tsx +5 -4
- package/src/Indicator/Indicator.doc.mjs +1 -1
- package/src/Indicator/index.ts +1 -0
- package/src/Indicator/types.ts +11 -0
- package/src/InputGroup/groupStyles.ts +7 -8
- 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 +318 -7
- package/src/Layer/useLayer.tsx +229 -35
- package/src/Lightbox/Lightbox.tsx +2 -11
- package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
- package/src/MultiSelector/MultiSelector.test.tsx +135 -5
- package/src/MultiSelector/MultiSelector.tsx +170 -127
- package/src/NavItem/navItemStyles.stylex.ts +31 -5
- package/src/Popover/Popover.doc.mjs +2 -0
- package/src/Popover/usePopover.tsx +37 -4
- package/src/ProgressBar/ProgressBar.tsx +2 -8
- package/src/Section/Section.tsx +0 -2
- package/src/Section/index.ts +0 -2
- package/src/Selector/Selector.doc.mjs +10 -0
- package/src/Selector/Selector.test.tsx +386 -2
- package/src/Selector/Selector.tsx +182 -122
- package/src/SideNav/SideNav.doc.mjs +13 -4
- package/src/SideNav/SideNav.test.tsx +771 -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 +28 -10
- package/src/SideNav/SideNavItem.doc.mjs +13 -0
- package/src/SideNav/SideNavItem.tsx +92 -84
- package/src/SideNav/SideNavSection.tsx +6 -20
- package/src/SideNav/index.ts +2 -0
- package/src/Skeleton/Skeleton.tsx +0 -2
- package/src/Skeleton/index.ts +0 -2
- package/src/Slider/Slider.tsx +2 -11
- package/src/Switch/Switch.tsx +10 -2
- package/src/Table/Table.test.tsx +31 -0
- package/src/Table/TableRow.tsx +6 -0
- package/src/Text/Text.doc.mjs +1 -1
- package/src/TimeInput/TimeInput.test.tsx +54 -0
- package/src/TimeInput/TimeInput.tsx +17 -2
- package/src/Timestamp/Timestamp.test.tsx +125 -30
- package/src/Timestamp/Timestamp.tsx +14 -2
- package/src/Timestamp/formatInstant.ts +16 -1
- package/src/Toolbar/Toolbar.test.tsx +14 -14
- package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
- package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
- package/src/TopNav/TopNavMegaMenu.tsx +4 -1
- package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
- package/src/TopNav/TopNavMenu.test.tsx +41 -0
- package/src/TopNav/TopNavMenu.tsx +8 -2
- package/src/TreeList/TreeList.test.tsx +3 -2
- package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
- package/src/VisuallyHidden/index.ts +0 -2
- package/src/hooks/index.ts +5 -1
- package/src/hooks/useFocusTrap.test.tsx +16 -0
- package/src/hooks/useFocusTrap.ts +20 -2
- package/src/hooks/useInputStatusIcon.tsx +8 -8
- package/src/serverSafeComponents.test.ts +665 -0
- package/src/theme/defineTheme.ts +3 -0
- package/src/theme/generateThemeRules.test.ts +17 -0
- package/src/theme/index.ts +2 -0
- package/src/theme/themingTargets.test.ts +152 -26
- package/src/theme/tokens.stylex.ts +18 -0
- package/src/theme/tokens.test.ts +37 -0
- package/src/utils/focusOutline.stylex.ts +37 -23
- package/src/utils/index.ts +4 -0
- package/src/utils/interactionModality.ts +74 -0
|
@@ -16,6 +16,24 @@ import {formatTooltipLines} from './tooltipEntries';
|
|
|
16
16
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
17
17
|
import {InternationalizationProvider} from '../i18n';
|
|
18
18
|
|
|
19
|
+
async function openTimestampHoverCard(): Promise<HTMLElement> {
|
|
20
|
+
const timestamp = document.querySelector('time');
|
|
21
|
+
if (timestamp == null) {
|
|
22
|
+
throw new Error('Expected Timestamp to render a <time> element');
|
|
23
|
+
}
|
|
24
|
+
const trigger = timestamp.parentElement;
|
|
25
|
+
if (trigger == null) {
|
|
26
|
+
throw new Error('Expected Timestamp to render a hover-card trigger');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fireEvent.mouseEnter(trigger);
|
|
30
|
+
await waitFor(() => {
|
|
31
|
+
expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return screen.getByRole('dialog', {hidden: true});
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
describe('Timestamp', () => {
|
|
20
38
|
// The hover card is loaded lazily (React.lazy + Suspense), so its chunk
|
|
21
39
|
// resolves asynchronously the first time a card renders. Warm the module
|
|
@@ -408,6 +426,50 @@ describe('Timestamp', () => {
|
|
|
408
426
|
expect(el.getAttribute('aria-label')).toBeNull();
|
|
409
427
|
});
|
|
410
428
|
|
|
429
|
+
// Timezone abbreviations like "PST" or "GMT+2" are unexpanded abbreviations
|
|
430
|
+
// to a screen-reader user (WCAG 3.1.4) — the AT-facing aria-label must spell
|
|
431
|
+
// the timezone out in full, while visible text keeps the compact short form.
|
|
432
|
+
it('uses the long timezone name in the aria-label (WCAG 3.1.4)', () => {
|
|
433
|
+
const oneHourAgo = new Date(Date.now() - 3600 * 1000);
|
|
434
|
+
render(
|
|
435
|
+
<Timestamp
|
|
436
|
+
value={oneHourAgo.getTime() / 1000}
|
|
437
|
+
format="relative"
|
|
438
|
+
hasTooltip={false}
|
|
439
|
+
data-testid="ts"
|
|
440
|
+
/>,
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const longTz =
|
|
444
|
+
new Intl.DateTimeFormat(undefined, {timeZoneName: 'long'})
|
|
445
|
+
.formatToParts(oneHourAgo)
|
|
446
|
+
.find(p => p.type === 'timeZoneName')?.value ?? '';
|
|
447
|
+
expect(longTz).not.toBe('');
|
|
448
|
+
expect(screen.getByTestId('ts').getAttribute('aria-label')).toContain(
|
|
449
|
+
longTz,
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('keeps the short timezone form in visible text when isTimezoneShown', () => {
|
|
454
|
+
const date = new Date('2026-03-25T10:00:00Z');
|
|
455
|
+
render(
|
|
456
|
+
<Timestamp
|
|
457
|
+
value="2026-03-25T10:00:00Z"
|
|
458
|
+
format="time"
|
|
459
|
+
isTimezoneShown
|
|
460
|
+
data-testid="ts"
|
|
461
|
+
/>,
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
const tzPart = (form: 'short' | 'long') =>
|
|
465
|
+
new Intl.DateTimeFormat(undefined, {timeZoneName: form})
|
|
466
|
+
.formatToParts(date)
|
|
467
|
+
.find(p => p.type === 'timeZoneName')?.value ?? '';
|
|
468
|
+
const text = screen.getByTestId('ts').textContent ?? '';
|
|
469
|
+
expect(text).toContain(tzPart('short'));
|
|
470
|
+
expect(text).not.toContain(tzPart('long'));
|
|
471
|
+
});
|
|
472
|
+
|
|
411
473
|
// --- Input handling ---
|
|
412
474
|
|
|
413
475
|
it('accepts Unix timestamp in seconds', () => {
|
|
@@ -602,22 +664,32 @@ describe('Timestamp', () => {
|
|
|
602
664
|
);
|
|
603
665
|
const el = screen.getByTestId('ts');
|
|
604
666
|
|
|
605
|
-
// The card
|
|
606
|
-
//
|
|
667
|
+
// The card carries the full absolute time in its visible form (short
|
|
668
|
+
// timezone abbreviation) as its single default copyable row. The
|
|
669
|
+
// aria-label spells the timezone out in full (WCAG 3.1.4), so the two
|
|
670
|
+
// strings intentionally differ — compare the card against an
|
|
671
|
+
// independently formatted short-form string after opening it.
|
|
607
672
|
// Compare with normalized whitespace: Intl output can contain narrow
|
|
608
673
|
// no-break spaces that jest-dom's matcher normalization would break on.
|
|
609
|
-
const card = await screen.findByRole('dialog', {hidden: true});
|
|
610
674
|
const normalize = (s: string) => s.replace(/\s+/g, ' ');
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
675
|
+
const datetime = new Date(el.getAttribute('datetime') ?? '');
|
|
676
|
+
const expected = new Intl.DateTimeFormat(undefined, {
|
|
677
|
+
year: 'numeric',
|
|
678
|
+
month: 'long',
|
|
679
|
+
day: 'numeric',
|
|
680
|
+
hour: 'numeric',
|
|
681
|
+
minute: '2-digit',
|
|
682
|
+
second: '2-digit',
|
|
683
|
+
timeZoneName: 'short',
|
|
684
|
+
}).format(datetime);
|
|
615
685
|
// Tab onto the timestamp — the only tab stop in the document.
|
|
616
686
|
await user.tab();
|
|
617
687
|
expect(el).toHaveFocus();
|
|
618
688
|
await waitFor(() => {
|
|
619
689
|
expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
|
|
620
690
|
});
|
|
691
|
+
const card = screen.getByRole('dialog', {hidden: true});
|
|
692
|
+
expect(normalize(card.textContent ?? '')).toContain(normalize(expected));
|
|
621
693
|
});
|
|
622
694
|
|
|
623
695
|
it('does not add a tab stop when the hover card is disabled', () => {
|
|
@@ -701,24 +773,37 @@ describe('Timestamp', () => {
|
|
|
701
773
|
.queryAllByRole('tooltip', {hidden: true})
|
|
702
774
|
.filter(el => !/^(Copy|Copied)$/.test(el.textContent?.trim() ?? '')),
|
|
703
775
|
).toHaveLength(0);
|
|
704
|
-
const card = await
|
|
776
|
+
const card = await openTimestampHoverCard();
|
|
705
777
|
// The default card is the named details card, exactly as the configured
|
|
706
778
|
// one is.
|
|
707
779
|
expect(card).toHaveAttribute('aria-label', 'Timestamp details');
|
|
708
|
-
// Exactly one row, carrying the full absolute time
|
|
709
|
-
//
|
|
780
|
+
// Exactly one row, carrying the full absolute time in its visible form
|
|
781
|
+
// (short timezone abbreviation) with its own copy button. The
|
|
782
|
+
// aria-label spells the timezone out in full (WCAG 3.1.4), so the two
|
|
783
|
+
// strings intentionally differ — compare the row against an
|
|
784
|
+
// independently formatted short-form string.
|
|
710
785
|
expect(card.querySelectorAll('dd')).toHaveLength(1);
|
|
711
786
|
expect(card.querySelectorAll('button')).toHaveLength(1);
|
|
712
787
|
|
|
713
788
|
const normalize = (s: string) => s.replace(/\s+/g, ' ');
|
|
714
|
-
|
|
715
|
-
|
|
789
|
+
const datetime = new Date(
|
|
790
|
+
screen.getByTestId('ts').getAttribute('datetime') ?? '',
|
|
716
791
|
);
|
|
792
|
+
const expected = new Intl.DateTimeFormat(undefined, {
|
|
793
|
+
year: 'numeric',
|
|
794
|
+
month: 'long',
|
|
795
|
+
day: 'numeric',
|
|
796
|
+
hour: 'numeric',
|
|
797
|
+
minute: '2-digit',
|
|
798
|
+
second: '2-digit',
|
|
799
|
+
timeZoneName: 'short',
|
|
800
|
+
}).format(datetime);
|
|
801
|
+
expect(normalize(card.textContent ?? '')).toContain(normalize(expected));
|
|
717
802
|
});
|
|
718
803
|
|
|
719
804
|
it("copies the default absolute row's value", async () => {
|
|
720
805
|
render(<Timestamp value={Date.now() / 1000 - 3600} format="relative" />);
|
|
721
|
-
const card = await
|
|
806
|
+
const card = await openTimestampHoverCard();
|
|
722
807
|
const rowValue = card.querySelector('dd')?.textContent ?? '';
|
|
723
808
|
expect(rowValue).toBeTruthy();
|
|
724
809
|
fireEvent.click(card.querySelector('button')!);
|
|
@@ -734,10 +819,11 @@ describe('Timestamp', () => {
|
|
|
734
819
|
|
|
735
820
|
it("shows a 'Copy' tooltip on the copy button, flipping to 'Copied' after a copy", async () => {
|
|
736
821
|
render(<Timestamp value={Date.now() / 1000 - 3600} format="relative" />);
|
|
737
|
-
const card = await
|
|
822
|
+
const card = await openTimestampHoverCard();
|
|
738
823
|
const button = card.querySelector('button')!;
|
|
739
824
|
// The visible tooltip content defaults to the short imperative 'Copy'
|
|
740
825
|
// (the full "Copy <value>" string stays the button's aria-label).
|
|
826
|
+
fireEvent.mouseEnter(button);
|
|
741
827
|
await waitFor(() =>
|
|
742
828
|
expect(
|
|
743
829
|
screen.getByText('Copy', {
|
|
@@ -804,7 +890,7 @@ describe('Timestamp', () => {
|
|
|
804
890
|
.queryAllByRole('tooltip', {hidden: true})
|
|
805
891
|
.filter(el => !/^(Copy|Copied)$/.test(el.textContent?.trim() ?? '')),
|
|
806
892
|
).toHaveLength(0);
|
|
807
|
-
const card = await
|
|
893
|
+
const card = await openTimestampHoverCard();
|
|
808
894
|
expect(card).toHaveAttribute('aria-label', 'Timestamp details');
|
|
809
895
|
});
|
|
810
896
|
|
|
@@ -820,7 +906,7 @@ describe('Timestamp', () => {
|
|
|
820
906
|
]}
|
|
821
907
|
/>,
|
|
822
908
|
);
|
|
823
|
-
const card = await
|
|
909
|
+
const card = await openTimestampHoverCard();
|
|
824
910
|
expect(card.querySelectorAll('dd')).toHaveLength(3);
|
|
825
911
|
// Entries are read-only unless they opt in, so no copy buttons and no
|
|
826
912
|
// trailing action column are rendered.
|
|
@@ -847,7 +933,7 @@ describe('Timestamp', () => {
|
|
|
847
933
|
]}
|
|
848
934
|
/>,
|
|
849
935
|
);
|
|
850
|
-
const card = await
|
|
936
|
+
const card = await openTimestampHoverCard();
|
|
851
937
|
// Three rows, but only the opted-in row carries a copy button.
|
|
852
938
|
expect(card.querySelectorAll('dd')).toHaveLength(3);
|
|
853
939
|
expect(card.querySelectorAll('button')).toHaveLength(1);
|
|
@@ -868,7 +954,7 @@ describe('Timestamp', () => {
|
|
|
868
954
|
]}
|
|
869
955
|
/>,
|
|
870
956
|
);
|
|
871
|
-
const card = await
|
|
957
|
+
const card = await openTimestampHoverCard();
|
|
872
958
|
const values = Array.from(card.querySelectorAll('dd')).map(
|
|
873
959
|
el => el.textContent,
|
|
874
960
|
);
|
|
@@ -892,7 +978,7 @@ describe('Timestamp', () => {
|
|
|
892
978
|
]}
|
|
893
979
|
/>,
|
|
894
980
|
);
|
|
895
|
-
const card = await
|
|
981
|
+
const card = await openTimestampHoverCard();
|
|
896
982
|
const [rowValue] = Array.from(card.querySelectorAll('dd')).map(
|
|
897
983
|
el => el.textContent ?? '',
|
|
898
984
|
);
|
|
@@ -923,7 +1009,7 @@ describe('Timestamp', () => {
|
|
|
923
1009
|
/>
|
|
924
1010
|
</InternationalizationProvider>,
|
|
925
1011
|
);
|
|
926
|
-
const card = await
|
|
1012
|
+
const card = await openTimestampHoverCard();
|
|
927
1013
|
fireEvent.click(card.querySelector('button')!);
|
|
928
1014
|
await waitFor(() => {
|
|
929
1015
|
expect(
|
|
@@ -943,7 +1029,7 @@ describe('Timestamp', () => {
|
|
|
943
1029
|
);
|
|
944
1030
|
// Without entries an absolute format has no hover surface at all;
|
|
945
1031
|
// configuring entries must not be silently ignored.
|
|
946
|
-
const card = await
|
|
1032
|
+
const card = await openTimestampHoverCard();
|
|
947
1033
|
expect(card.textContent).toContain('UTC');
|
|
948
1034
|
// ...and the anchor becomes keyboard-reachable, as it is for relative.
|
|
949
1035
|
expect(screen.getByTestId('ts')).toHaveAttribute('tabindex', '0');
|
|
@@ -1052,7 +1138,7 @@ describe('Timestamp', () => {
|
|
|
1052
1138
|
/>,
|
|
1053
1139
|
);
|
|
1054
1140
|
// autoThreshold={0} forces the absolute branch regardless of the clock.
|
|
1055
|
-
const card = await
|
|
1141
|
+
const card = await openTimestampHoverCard();
|
|
1056
1142
|
expect(card.textContent).toContain('2026-02-19 17:00:00');
|
|
1057
1143
|
expect(screen.getByTestId('ts')).toHaveAttribute('tabindex', '0');
|
|
1058
1144
|
});
|
|
@@ -1067,7 +1153,7 @@ describe('Timestamp', () => {
|
|
|
1067
1153
|
]}
|
|
1068
1154
|
/>,
|
|
1069
1155
|
);
|
|
1070
|
-
const card = await
|
|
1156
|
+
const card = await openTimestampHoverCard();
|
|
1071
1157
|
expect(card.textContent).toContain('2026-02-19 17:00:00');
|
|
1072
1158
|
});
|
|
1073
1159
|
|
|
@@ -1083,7 +1169,7 @@ describe('Timestamp', () => {
|
|
|
1083
1169
|
]}
|
|
1084
1170
|
/>,
|
|
1085
1171
|
);
|
|
1086
|
-
const card = await
|
|
1172
|
+
const card = await openTimestampHoverCard();
|
|
1087
1173
|
// An unlabeled entry still emits its label cell, so the grid stays
|
|
1088
1174
|
// aligned and the <dl> stays valid markup.
|
|
1089
1175
|
expect(card.querySelectorAll('dt')).toHaveLength(3);
|
|
@@ -1121,7 +1207,7 @@ describe('Timestamp', () => {
|
|
|
1121
1207
|
]}
|
|
1122
1208
|
/>,
|
|
1123
1209
|
);
|
|
1124
|
-
const card = await
|
|
1210
|
+
const card = await openTimestampHoverCard();
|
|
1125
1211
|
const values = Array.from(card.querySelectorAll('dd')).map(
|
|
1126
1212
|
el => el.textContent,
|
|
1127
1213
|
);
|
|
@@ -1171,10 +1257,14 @@ describe('Timestamp', () => {
|
|
|
1171
1257
|
},
|
|
1172
1258
|
);
|
|
1173
1259
|
|
|
1174
|
-
it('renders the full style
|
|
1260
|
+
it('renders the full style as the aria-label and as a line, differing only in zone-name spelling', () => {
|
|
1175
1261
|
// 'full' is the one member with no visible-text counterpart — it backs
|
|
1176
1262
|
// the accessible name of a relative timestamp and the tooltip's default
|
|
1177
|
-
// line.
|
|
1263
|
+
// line. The two must not drift apart on anything but the zone name: the
|
|
1264
|
+
// aria-label spells the zone out in full for assistive tech (WCAG 3.1.4),
|
|
1265
|
+
// the visible line keeps the abbreviation. Comparing them after swapping
|
|
1266
|
+
// the abbreviation for the spelled-out form keeps this a direct
|
|
1267
|
+
// surface-to-surface check, so re-forking either path still fails here.
|
|
1178
1268
|
render(
|
|
1179
1269
|
<Timestamp
|
|
1180
1270
|
value={VALUE}
|
|
@@ -1183,9 +1273,14 @@ describe('Timestamp', () => {
|
|
|
1183
1273
|
data-testid="ts"
|
|
1184
1274
|
/>,
|
|
1185
1275
|
);
|
|
1186
|
-
const
|
|
1187
|
-
|
|
1188
|
-
|
|
1276
|
+
const date = new Date(VALUE);
|
|
1277
|
+
const tzPart = (form: 'short' | 'long') =>
|
|
1278
|
+
new Intl.DateTimeFormat(undefined, {timeZoneName: form})
|
|
1279
|
+
.formatToParts(date)
|
|
1280
|
+
.find(p => p.type === 'timeZoneName')?.value ?? '';
|
|
1281
|
+
const [line] = formatTooltipLines(date, [{format: 'full'}]);
|
|
1282
|
+
expect(screen.getByTestId('ts').getAttribute('aria-label')).toBe(
|
|
1283
|
+
line.value.replace(tzPart('short'), tzPart('long')),
|
|
1189
1284
|
);
|
|
1190
1285
|
});
|
|
1191
1286
|
});
|
|
@@ -451,8 +451,14 @@ export function Timestamp({
|
|
|
451
451
|
? formatInstant(date, effectiveFormat, {isTimezoneShown})
|
|
452
452
|
: '';
|
|
453
453
|
|
|
454
|
-
// Full absolute text for tooltip
|
|
454
|
+
// Full absolute text for the tooltip (visible — keeps the compact timezone
|
|
455
|
+
// abbreviation) and for the AT-facing aria-label, which spells the timezone
|
|
456
|
+
// out in full: abbreviations like "PST" or "GMT+2" are unexpanded
|
|
457
|
+
// abbreviations to a screen-reader user (WCAG 3.1.4).
|
|
455
458
|
const fullAbsoluteText = isValidDate ? formatInstant(date, 'full') : '';
|
|
459
|
+
const ariaLabelText = isValidDate
|
|
460
|
+
? formatInstant(date, 'full', {timeZoneNameStyle: 'long'})
|
|
461
|
+
: '';
|
|
456
462
|
|
|
457
463
|
// Live updates
|
|
458
464
|
useEffect(() => {
|
|
@@ -518,8 +524,14 @@ export function Timestamp({
|
|
|
518
524
|
<time
|
|
519
525
|
ref={mergeRefs(ref, timeRef)}
|
|
520
526
|
dateTime={isoString}
|
|
527
|
+
// `ariaLabelText` is '' only for an invalid date, which bails out
|
|
528
|
+
// before rendering — but keep the guard local: an empty aria-label
|
|
529
|
+
// must be omitted entirely (not rendered as aria-label="") so AT
|
|
530
|
+
// falls back to reading the visible <time> content.
|
|
521
531
|
aria-label={
|
|
522
|
-
isRelativeFormat(effectiveFormat)
|
|
532
|
+
isRelativeFormat(effectiveFormat) && ariaLabelText !== ''
|
|
533
|
+
? ariaLabelText
|
|
534
|
+
: undefined
|
|
523
535
|
}
|
|
524
536
|
// The hover card is anchored here with focusTrigger="always", which
|
|
525
537
|
// attaches focus listeners but does not itself make the anchor
|
|
@@ -69,6 +69,16 @@ export interface FormatInstantOptions {
|
|
|
69
69
|
* @default false
|
|
70
70
|
*/
|
|
71
71
|
isTimezoneShown?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* How the zone name is spelled when a format carries one. `'short'` is the
|
|
74
|
+
* abbreviation ("PST"); `'long'` spells it out ("Pacific Standard Time").
|
|
75
|
+
* Only `'full'` honours it — it is the one format that always names its zone.
|
|
76
|
+
* The spelled-out form backs a relative timestamp's accessible name, where an
|
|
77
|
+
* abbreviation reads as an unexpanded initialism to a screen reader
|
|
78
|
+
* (WCAG 3.1.4); every visible surface keeps the abbreviation.
|
|
79
|
+
* @default 'short'
|
|
80
|
+
*/
|
|
81
|
+
timeZoneNameStyle?: 'short' | 'long';
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
// =============================================================================
|
|
@@ -155,7 +165,11 @@ function getWallClock(date: Date, timeZone: string | undefined): WallClock {
|
|
|
155
165
|
export function formatInstant(
|
|
156
166
|
date: Date,
|
|
157
167
|
format: InstantFormat,
|
|
158
|
-
{
|
|
168
|
+
{
|
|
169
|
+
timeZone,
|
|
170
|
+
isTimezoneShown = false,
|
|
171
|
+
timeZoneNameStyle = 'short',
|
|
172
|
+
}: FormatInstantOptions = {},
|
|
159
173
|
): string {
|
|
160
174
|
const zone = timeZone === undefined ? {} : {timeZone};
|
|
161
175
|
const zoneName = isTimezoneShown ? {timeZoneName: 'short' as const} : {};
|
|
@@ -164,6 +178,7 @@ export function formatInstant(
|
|
|
164
178
|
case 'full':
|
|
165
179
|
return new Intl.DateTimeFormat(undefined, {
|
|
166
180
|
...FULL_OPTIONS,
|
|
181
|
+
timeZoneName: timeZoneNameStyle,
|
|
167
182
|
...zone,
|
|
168
183
|
}).format(date);
|
|
169
184
|
|
|
@@ -72,11 +72,11 @@ describe('Toolbar', () => {
|
|
|
72
72
|
expect(screen.getByTestId('end')).toBeInTheDocument();
|
|
73
73
|
|
|
74
74
|
// Three-slot layout produces 3 child divs (plus the aria-hidden
|
|
75
|
-
// keyboard-hint
|
|
75
|
+
// keyboard-hint layer infrastructure, excluded as an implementation detail)
|
|
76
76
|
const toolbar = screen.getByRole('toolbar');
|
|
77
|
-
expect(
|
|
78
|
-
|
|
79
|
-
);
|
|
77
|
+
expect(
|
|
78
|
+
toolbar.querySelectorAll(':scope > :not([popover]):not(template)'),
|
|
79
|
+
).toHaveLength(3);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
it('renders two-slot layout without centerContent', () => {
|
|
@@ -91,11 +91,11 @@ describe('Toolbar', () => {
|
|
|
91
91
|
expect(screen.getByTestId('end')).toBeInTheDocument();
|
|
92
92
|
|
|
93
93
|
// Two-slot layout produces 2 child divs (plus the aria-hidden
|
|
94
|
-
// keyboard-hint
|
|
94
|
+
// keyboard-hint layer infrastructure, excluded here)
|
|
95
95
|
const toolbar = screen.getByRole('toolbar');
|
|
96
|
-
expect(
|
|
97
|
-
|
|
98
|
-
);
|
|
96
|
+
expect(
|
|
97
|
+
toolbar.querySelectorAll(':scope > :not([popover]):not(template)'),
|
|
98
|
+
).toHaveLength(2);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
it('renders start-only layout', () => {
|
|
@@ -107,9 +107,9 @@ describe('Toolbar', () => {
|
|
|
107
107
|
);
|
|
108
108
|
expect(screen.getByTestId('start')).toBeInTheDocument();
|
|
109
109
|
const toolbar = screen.getByRole('toolbar');
|
|
110
|
-
expect(
|
|
111
|
-
|
|
112
|
-
);
|
|
110
|
+
expect(
|
|
111
|
+
toolbar.querySelectorAll(':scope > :not([popover]):not(template)'),
|
|
112
|
+
).toHaveLength(1);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
115
|
it('renders end-only layout', () => {
|
|
@@ -121,9 +121,9 @@ describe('Toolbar', () => {
|
|
|
121
121
|
);
|
|
122
122
|
expect(screen.getByTestId('end')).toBeInTheDocument();
|
|
123
123
|
const toolbar = screen.getByRole('toolbar');
|
|
124
|
-
expect(
|
|
125
|
-
|
|
126
|
-
);
|
|
124
|
+
expect(
|
|
125
|
+
toolbar.querySelectorAll(':scope > :not([popover]):not(template)'),
|
|
126
|
+
).toHaveLength(1);
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
it('sets aria-orientation to horizontal by default', () => {
|
|
@@ -7,6 +7,11 @@ export const docs = {
|
|
|
7
7
|
subComponentOf: 'TopNav',
|
|
8
8
|
displayName: 'Top Nav Mega Menu',
|
|
9
9
|
description: 'Navigation item that displays a full-width mega menu panel on hover. Uses a slots API with items and featured props. TopNavMegaMenuItem renders itself in both desktop and mobile drawer modes. Supports inline collapsible drawer via render mode context.',
|
|
10
|
+
// The panel anchors to the nearest <nav>; wrap the preview in a TopNav so
|
|
11
|
+
// it opens below the bar instead of pinning to the viewport corner (#4905).
|
|
12
|
+
playground: {
|
|
13
|
+
wrapper: {component: 'TopNav'},
|
|
14
|
+
},
|
|
10
15
|
props: [
|
|
11
16
|
{
|
|
12
17
|
name: 'label',
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* SYNC: When TopNavMegaMenu changes, update tests to match new behavior
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import * as stylex from '@stylexjs/stylex';
|
|
13
|
+
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
|
|
12
14
|
import {describe, it, expect, vi, beforeAll, afterAll, afterEach} from 'vitest';
|
|
13
15
|
import {render, screen, act, fireEvent} from '@testing-library/react';
|
|
14
16
|
import userEvent from '@testing-library/user-event';
|
|
@@ -817,3 +819,44 @@ describe('TopNavMegaMenuItem', () => {
|
|
|
817
819
|
expect(screen.getByRole('link', {name: /Analytics/})).toBeInTheDocument();
|
|
818
820
|
});
|
|
819
821
|
});
|
|
822
|
+
|
|
823
|
+
// =============================================================================
|
|
824
|
+
// The shared focus ring (#4654) — see the note in SideNav.test.tsx: jsdom will
|
|
825
|
+
// not derive `:focus-visible` here, so what is pinned is that the focusable
|
|
826
|
+
// element composes the shared utility's classes rather than falling back to
|
|
827
|
+
// the browser's own outline.
|
|
828
|
+
// =============================================================================
|
|
829
|
+
|
|
830
|
+
const sharedFocusRingClasses = stylex
|
|
831
|
+
.props(focusOutlineStyles.focusVisible)
|
|
832
|
+
.className!.split(' ');
|
|
833
|
+
|
|
834
|
+
function expectSharedFocusRing(el: Element) {
|
|
835
|
+
const classes = el.className.split(' ');
|
|
836
|
+
for (const c of sharedFocusRingClasses) {
|
|
837
|
+
expect(classes).toContain(c);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
describe('TopNavMegaMenu — drawer focus ring', () => {
|
|
842
|
+
it('draws the shared ring on the drawer section header', () => {
|
|
843
|
+
render(
|
|
844
|
+
<TopNavRenderContext value="drawer">
|
|
845
|
+
<TopNavMegaMenu
|
|
846
|
+
label="Products"
|
|
847
|
+
items={<TopNavMegaMenuItem title="Analytics" href="/analytics" />}
|
|
848
|
+
/>
|
|
849
|
+
</TopNavRenderContext>,
|
|
850
|
+
);
|
|
851
|
+
expectSharedFocusRing(screen.getByRole('button', {name: 'Products'}));
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it('draws the shared ring on a drawer item', () => {
|
|
855
|
+
render(
|
|
856
|
+
<TopNavRenderContext value="drawer">
|
|
857
|
+
<TopNavMegaMenuItem title="Analytics" href="/analytics" />
|
|
858
|
+
</TopNavRenderContext>,
|
|
859
|
+
);
|
|
860
|
+
expectSharedFocusRing(screen.getByRole('link', {name: /Analytics/}));
|
|
861
|
+
});
|
|
862
|
+
});
|
|
@@ -602,7 +602,10 @@ function DrawerMegaMenu({
|
|
|
602
602
|
aria-controls={`${menuId}-items`}
|
|
603
603
|
{...mergeProps(
|
|
604
604
|
themeProps('top-nav-mega-menu', {mode: 'drawer'}),
|
|
605
|
-
|
|
605
|
+
focusOutlineProps.focusVisible(
|
|
606
|
+
navItemStyles.item,
|
|
607
|
+
styles.drawerHeader,
|
|
608
|
+
),
|
|
606
609
|
)}>
|
|
607
610
|
{label}
|
|
608
611
|
<Icon
|
|
@@ -215,7 +215,7 @@ export function TopNavMegaMenuItem({
|
|
|
215
215
|
{...elementProps}
|
|
216
216
|
{...mergeProps(
|
|
217
217
|
themeProps('top-nav-mega-menu-item', {mode: 'drawer'}),
|
|
218
|
-
|
|
218
|
+
focusOutlineProps.focusVisible(navItemStyles.item, styles.drawerItem),
|
|
219
219
|
)}>
|
|
220
220
|
{icon && <div {...stylex.props(styles.drawerItemIcon)}>{icon}</div>}
|
|
221
221
|
<div {...stylex.props(styles.drawerItemContent)}>
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
import {describe, it, expect, vi} from 'vitest';
|
|
13
13
|
import {render, screen, fireEvent} from '@testing-library/react';
|
|
14
14
|
import userEvent from '@testing-library/user-event';
|
|
15
|
+
import * as stylex from '@stylexjs/stylex';
|
|
16
|
+
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
|
|
17
|
+
import {TopNavRenderContext} from './TopNavRenderContext';
|
|
15
18
|
import {TopNavMenu} from './TopNavMenu';
|
|
16
19
|
|
|
17
20
|
const mockItems = [
|
|
@@ -171,3 +174,41 @@ describe('keyboard navigation (APG menu pattern)', () => {
|
|
|
171
174
|
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
172
175
|
});
|
|
173
176
|
});
|
|
177
|
+
|
|
178
|
+
// =============================================================================
|
|
179
|
+
// The shared focus ring (#4654) — see the note in SideNav.test.tsx: jsdom will
|
|
180
|
+
// not derive `:focus-visible` here, so what is pinned is that the focusable
|
|
181
|
+
// element composes the shared utility's classes rather than falling back to
|
|
182
|
+
// the browser's own outline.
|
|
183
|
+
// =============================================================================
|
|
184
|
+
|
|
185
|
+
const sharedFocusRingClasses = stylex
|
|
186
|
+
.props(focusOutlineStyles.focusVisible)
|
|
187
|
+
.className!.split(' ');
|
|
188
|
+
|
|
189
|
+
function expectSharedFocusRing(el: Element) {
|
|
190
|
+
const classes = el.className.split(' ');
|
|
191
|
+
for (const c of sharedFocusRingClasses) {
|
|
192
|
+
expect(classes).toContain(c);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
describe('TopNavMenu — drawer focus ring', () => {
|
|
197
|
+
it('draws the shared ring on the drawer section header', () => {
|
|
198
|
+
render(
|
|
199
|
+
<TopNavRenderContext value="drawer">
|
|
200
|
+
<TopNavMenu label="Products" items={mockItems} />
|
|
201
|
+
</TopNavRenderContext>,
|
|
202
|
+
);
|
|
203
|
+
expectSharedFocusRing(screen.getByRole('button', {name: /Products/}));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('draws the shared ring on a drawer item', () => {
|
|
207
|
+
render(
|
|
208
|
+
<TopNavRenderContext value="drawer">
|
|
209
|
+
<TopNavMenu label="Products" items={mockItems} />
|
|
210
|
+
</TopNavRenderContext>,
|
|
211
|
+
);
|
|
212
|
+
expectSharedFocusRing(screen.getByRole('link', {name: /Analytics/}));
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -432,7 +432,10 @@ export function TopNavMenu({
|
|
|
432
432
|
onClick={() => setDrawerExpanded(v => !v)}
|
|
433
433
|
aria-expanded={drawerExpanded}
|
|
434
434
|
aria-controls={`${menuId}-items`}
|
|
435
|
-
{...
|
|
435
|
+
{...focusOutlineProps.focusVisible(
|
|
436
|
+
navItemStyles.item,
|
|
437
|
+
drawerStyles.header,
|
|
438
|
+
)}>
|
|
436
439
|
{label}
|
|
437
440
|
<Icon
|
|
438
441
|
icon="chevronDown"
|
|
@@ -459,7 +462,10 @@ export function TopNavMenu({
|
|
|
459
462
|
item.onClick?.();
|
|
460
463
|
closeMobileNav();
|
|
461
464
|
}}
|
|
462
|
-
{...
|
|
465
|
+
{...focusOutlineProps.focusVisible(
|
|
466
|
+
navItemStyles.item,
|
|
467
|
+
drawerStyles.item,
|
|
468
|
+
)}>
|
|
463
469
|
{item.icon && (
|
|
464
470
|
<span {...stylex.props(drawerStyles.itemIcon)}>
|
|
465
471
|
{item.icon}
|
|
@@ -16,6 +16,7 @@ import {TreeList} from './TreeList';
|
|
|
16
16
|
import type {TreeListItemData} from './TreeListTypes';
|
|
17
17
|
import {defineTheme} from '../theme/defineTheme';
|
|
18
18
|
import {generateThemeCSS} from '../theme/generateThemeRules';
|
|
19
|
+
import {FOCUS_OUTLINE} from '../utils/focusOutline.stylex';
|
|
19
20
|
|
|
20
21
|
function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
|
|
21
22
|
const {prose, component} = generateThemeCSS(theme);
|
|
@@ -299,8 +300,8 @@ describe('TreeList', () => {
|
|
|
299
300
|
expect(root).toHaveFocus();
|
|
300
301
|
|
|
301
302
|
expect(
|
|
302
|
-
getComputedStyle(root).getPropertyValue('--_focus-outline'),
|
|
303
|
-
).
|
|
303
|
+
getComputedStyle(root).getPropertyValue('--_focus-outline').trim(),
|
|
304
|
+
).toBe(FOCUS_OUTLINE);
|
|
304
305
|
// Mid and Leaf are DOM descendants of Root's <li> (nested <ul role="group">
|
|
305
306
|
// subtrees) — their own outline var must stay unset, not inherit Root's.
|
|
306
307
|
expect(getComputedStyle(mid).getPropertyValue('--_focus-outline')).toBe(
|
package/src/hooks/index.ts
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
* SYNC: When modified, update this header
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
export {
|
|
14
|
+
export {
|
|
15
|
+
hasActiveFocusTrapEscape,
|
|
16
|
+
isImeKeyEvent,
|
|
17
|
+
useFocusTrap,
|
|
18
|
+
} from './useFocusTrap';
|
|
15
19
|
export type {UseFocusTrapOptions, UseFocusTrapReturn} from './useFocusTrap';
|
|
16
20
|
|
|
17
21
|
export {useAnnounce} from './useAnnounce';
|