@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
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
* @file useContainerReveal.test.tsx
|
|
5
5
|
* @input Uses vitest, @testing-library/react, useContainerReveal
|
|
6
6
|
* @output Unit tests for the enabled/disabled contract, the dynamic isEnabled
|
|
7
|
-
* prop, the
|
|
8
|
-
*
|
|
7
|
+
* prop, the container options (hoverDelay, forceState), the per-element
|
|
8
|
+
* option → style-block mapping, and the promise that a large flat list
|
|
9
|
+
* mounts without dev warnings.
|
|
9
10
|
* @position Testing; validates useContainerReveal.ts.
|
|
10
11
|
*
|
|
11
12
|
* Nesting isolation is a cascade behavior jsdom does not implement, so it is
|
|
12
13
|
* verified in a real browser (Storybook's NestedIsolation story) rather than
|
|
13
|
-
* asserted here.
|
|
14
|
+
* asserted here. The same goes for what the dwell and the forced states
|
|
15
|
+
* actually paint: these tests assert the wiring, the browser proves the pixels
|
|
16
|
+
* (HoverIntentDelay, ForcedVisibility).
|
|
14
17
|
*
|
|
15
18
|
* SYNC: When useContainerReveal.ts changes, update these tests.
|
|
16
19
|
*/
|
|
@@ -74,6 +77,70 @@ describe('useContainerReveal', () => {
|
|
|
74
77
|
expect(clipped).not.toBe(preserved);
|
|
75
78
|
});
|
|
76
79
|
|
|
80
|
+
it('forceState pins each end of the container to its own style block', () => {
|
|
81
|
+
const {result} = renderHook(() => useContainerReveal());
|
|
82
|
+
const auto = result.current.getContainerProps().className;
|
|
83
|
+
const inactive = result.current.getContainerProps({
|
|
84
|
+
forceState: 'inactive',
|
|
85
|
+
}).className;
|
|
86
|
+
const active = result.current.getContainerProps({
|
|
87
|
+
forceState: 'active',
|
|
88
|
+
}).className;
|
|
89
|
+
expect(new Set([auto, inactive, active]).size).toBe(3);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('forceVisibility pins one element, independent of its reveal mode', () => {
|
|
93
|
+
const {result} = renderHook(() => useContainerReveal());
|
|
94
|
+
const auto = result.current.getContentRevealProps().className;
|
|
95
|
+
const shown = result.current.getContentRevealProps({
|
|
96
|
+
forceVisibility: 'shown',
|
|
97
|
+
}).className;
|
|
98
|
+
const hidden = result.current.getContentRevealProps({
|
|
99
|
+
forceVisibility: 'hidden',
|
|
100
|
+
}).className;
|
|
101
|
+
expect(new Set([auto, shown, hidden]).size).toBe(3);
|
|
102
|
+
|
|
103
|
+
// The layout-preserved variant has no position to flip, so hidden maps to
|
|
104
|
+
// its own opacity-only block.
|
|
105
|
+
expect(
|
|
106
|
+
result.current.getContentRevealProps({
|
|
107
|
+
forceVisibility: 'hidden',
|
|
108
|
+
isLayoutPreserved: true,
|
|
109
|
+
}).className,
|
|
110
|
+
).not.toBe(hidden);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('hoverDelay publishes the dwell as an inline custom property', () => {
|
|
114
|
+
const {result} = renderHook(() => useContainerReveal());
|
|
115
|
+
const {style} = result.current.getContainerProps({hoverDelay: 120});
|
|
116
|
+
expect(Object.values(style ?? {})).toContain('120ms');
|
|
117
|
+
expect(result.current.getContainerProps({hoverDelay: 0}).style).toEqual(
|
|
118
|
+
result.current.getContainerProps().style,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('hoverDelay and forceState compose on one container', () => {
|
|
123
|
+
const {result} = renderHook(() => useContainerReveal());
|
|
124
|
+
const props = result.current.getContainerProps({
|
|
125
|
+
hoverDelay: 120,
|
|
126
|
+
forceState: 'inactive',
|
|
127
|
+
});
|
|
128
|
+
expect(Object.values(props.style ?? {})).toContain('120ms');
|
|
129
|
+
expect(props.className).not.toBe(
|
|
130
|
+
result.current.getContainerProps({hoverDelay: 120}).className,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('ignores container options while disabled', () => {
|
|
135
|
+
const {result} = renderHook(() => useContainerReveal({isEnabled: false}));
|
|
136
|
+
expect(
|
|
137
|
+
result.current.getContainerProps({
|
|
138
|
+
hoverDelay: 120,
|
|
139
|
+
forceState: 'inactive',
|
|
140
|
+
}),
|
|
141
|
+
).toEqual({});
|
|
142
|
+
});
|
|
143
|
+
|
|
77
144
|
it('mounts a large flat list without a dev warning', () => {
|
|
78
145
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
79
146
|
function Row() {
|
|
@@ -20,10 +20,21 @@
|
|
|
20
20
|
* container shadows its ancestor's state for its own subtree. See
|
|
21
21
|
* containerReveal.stylex.ts.
|
|
22
22
|
*
|
|
23
|
+
* Two levers sit on top of the pointer, both still CSS-only. On the container:
|
|
24
|
+
* `hoverDelay` (dwell before the reveal starts — the Tooltip / HoverCard
|
|
25
|
+
* intent gate applied to a reveal) and `forceState` (pin the trigger state a
|
|
26
|
+
* caller owns: a motion gate, a scroll, an open menu). On a single piece of
|
|
27
|
+
* content: `forceVisibility`, which pins how THAT element looks. State belongs
|
|
28
|
+
* to the container because one container feeds children whose looks are
|
|
29
|
+
* opposite; appearance belongs to the element, where it is unambiguous.
|
|
30
|
+
* Neither lever can hide content from a keyboard user — see ACCESSIBILITY.
|
|
31
|
+
*
|
|
23
32
|
* ACCESSIBILITY (WCAG 2.2 by construction):
|
|
24
33
|
* - Revealed content is visually hidden at rest via position + opacity, so it
|
|
25
34
|
* stays in the accessibility tree and tab order — never display:none.
|
|
26
|
-
* - Keyboard: revealed on :focus-within, so tabbing in shows it
|
|
35
|
+
* - Keyboard: revealed on :focus-within, so tabbing in shows it — with no dwell
|
|
36
|
+
* to wait through, and neither an inactive container nor a forced-hidden
|
|
37
|
+
* element can keep it dark.
|
|
27
38
|
* - Touch: always visible on coarse pointers; never gated behind hover.
|
|
28
39
|
* - Concealed (inverted) content is a mouse-only visual swap: it ignores
|
|
29
40
|
* :focus-within (a keyboard user must never watch content vanish) and stays
|
|
@@ -50,6 +61,36 @@ export interface UseContainerRevealOptions {
|
|
|
50
61
|
isEnabled?: boolean;
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
export interface ContainerRevealOptions {
|
|
65
|
+
/**
|
|
66
|
+
* Pin the container's trigger state instead of letting the pointer drive it.
|
|
67
|
+
* `'active'` reads as pointed-at and `'inactive'` as at rest; omit it — the
|
|
68
|
+
* default — to leave the container on hover and focus.
|
|
69
|
+
*
|
|
70
|
+
* State, not appearance: what each child then looks like is the child's own
|
|
71
|
+
* business (revealed content fades in on `'active'`, inverted content fades
|
|
72
|
+
* out). This is the lever for state a caller owns — a motion gate over a
|
|
73
|
+
* list, a scroll in progress, a row whose menu is open and must stay lit.
|
|
74
|
+
*
|
|
75
|
+
* `'inactive'` never overrides keyboard focus or a coarse pointer: the
|
|
76
|
+
* container still reveals on :focus-within and stays revealed on touch, so
|
|
77
|
+
* it cannot hide content from a keyboard or touch user.
|
|
78
|
+
*/
|
|
79
|
+
forceState?: 'active' | 'inactive';
|
|
80
|
+
/**
|
|
81
|
+
* Hover-intent gate, in milliseconds: how long the pointer must rest on the
|
|
82
|
+
* container before the reveal starts. A pointer that passes through leaves
|
|
83
|
+
* nothing painted behind it, which is what keeps a list of rows quiet while
|
|
84
|
+
* the cursor sweeps across it.
|
|
85
|
+
*
|
|
86
|
+
* Mouse-only, like Tooltip's and HoverCard's `delay`: keyboard focus and
|
|
87
|
+
* touch reveal immediately. It survives `prefers-reduced-motion` — an intent
|
|
88
|
+
* gate is timing, not motion.
|
|
89
|
+
* @default 0
|
|
90
|
+
*/
|
|
91
|
+
hoverDelay?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
53
94
|
export interface ContentRevealOptions {
|
|
54
95
|
/**
|
|
55
96
|
* Conceal-on-hover instead of reveal-on-hover: content is visible at rest
|
|
@@ -64,11 +105,27 @@ export interface ContentRevealOptions {
|
|
|
64
105
|
* @default false
|
|
65
106
|
*/
|
|
66
107
|
isLayoutPreserved?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Pin THIS element's appearance, whatever the container's state: `'shown'`
|
|
110
|
+
* keeps it visible, `'hidden'` keeps it out. Omit it — the default — to
|
|
111
|
+
* follow the container.
|
|
112
|
+
*
|
|
113
|
+
* Appearance, not state: it says how one element looks, so it is unambiguous
|
|
114
|
+
* where the container's `forceState` cannot be (a container feeds revealed
|
|
115
|
+
* and inverted children at once).
|
|
116
|
+
*
|
|
117
|
+
* `'hidden'` yields to focus — a forced-hidden element is still mounted and
|
|
118
|
+
* tabbable, so it reappears when focus lands inside it.
|
|
119
|
+
*/
|
|
120
|
+
forceVisibility?: 'shown' | 'hidden';
|
|
67
121
|
}
|
|
68
122
|
|
|
69
123
|
export interface UseContainerRevealReturn {
|
|
70
124
|
/** Spread onto the container whose hover/focus-within drives the reveal. */
|
|
71
|
-
getContainerProps: () => {
|
|
125
|
+
getContainerProps: (options?: ContainerRevealOptions) => {
|
|
126
|
+
className?: string;
|
|
127
|
+
style?: CSSProperties;
|
|
128
|
+
};
|
|
72
129
|
/** Spread onto each revealed / concealed child. */
|
|
73
130
|
getContentRevealProps: (options?: ContentRevealOptions) => {
|
|
74
131
|
className?: string;
|
|
@@ -87,7 +144,11 @@ const EMPTY = Object.freeze({});
|
|
|
87
144
|
* isEnabled: revealOn === 'hover',
|
|
88
145
|
* });
|
|
89
146
|
*
|
|
90
|
-
* <div
|
|
147
|
+
* <div
|
|
148
|
+
* {...mergeProps(
|
|
149
|
+
* getContainerProps({hoverDelay: 120, forceState: gateState}),
|
|
150
|
+
* stylex.props(styles.row),
|
|
151
|
+
* )}>
|
|
91
152
|
* {label}
|
|
92
153
|
* <span {...mergeProps(getContentRevealProps(), stylex.props(styles.actions))}>
|
|
93
154
|
* {actions}
|
|
@@ -108,10 +169,21 @@ export function useContainerReveal(
|
|
|
108
169
|
};
|
|
109
170
|
}
|
|
110
171
|
return {
|
|
111
|
-
getContainerProps: () =>
|
|
172
|
+
getContainerProps: (containerOptions: ContainerRevealOptions = {}) => {
|
|
173
|
+
const {forceState, hoverDelay = 0} = containerOptions;
|
|
174
|
+
return stylex.props(
|
|
175
|
+
styles.container,
|
|
176
|
+
hoverDelay > 0 && styles.hoverDelay(`${hoverDelay}ms`),
|
|
177
|
+
forceState === 'inactive' && styles.stateInactive,
|
|
178
|
+
forceState === 'active' && styles.stateActive,
|
|
179
|
+
);
|
|
180
|
+
},
|
|
112
181
|
getContentRevealProps: (contentOptions: ContentRevealOptions = {}) => {
|
|
113
|
-
const {
|
|
114
|
-
|
|
182
|
+
const {
|
|
183
|
+
isRevealInverted = false,
|
|
184
|
+
isLayoutPreserved = false,
|
|
185
|
+
forceVisibility,
|
|
186
|
+
} = contentOptions;
|
|
115
187
|
const style = isRevealInverted
|
|
116
188
|
? isLayoutPreserved
|
|
117
189
|
? styles.concealLayoutPreserved
|
|
@@ -119,7 +191,14 @@ export function useContainerReveal(
|
|
|
119
191
|
: isLayoutPreserved
|
|
120
192
|
? styles.revealLayoutPreserved
|
|
121
193
|
: styles.reveal;
|
|
122
|
-
return stylex.props(
|
|
194
|
+
return stylex.props(
|
|
195
|
+
style,
|
|
196
|
+
forceVisibility === 'shown' && styles.contentShown,
|
|
197
|
+
forceVisibility === 'hidden' &&
|
|
198
|
+
(isLayoutPreserved
|
|
199
|
+
? styles.contentHiddenLayoutPreserved
|
|
200
|
+
: styles.contentHidden),
|
|
201
|
+
);
|
|
123
202
|
},
|
|
124
203
|
};
|
|
125
204
|
}, [isEnabled]);
|
|
@@ -192,6 +192,22 @@ describe('useFocusTrap tabbable model (infra-8)', () => {
|
|
|
192
192
|
fireEvent.keyDown(document, {key: 'Tab'});
|
|
193
193
|
expect(screen.getByTestId('first')).toHaveFocus();
|
|
194
194
|
});
|
|
195
|
+
|
|
196
|
+
it('keeps a programmatic focus target when the trap has no tabbable controls', () => {
|
|
197
|
+
render(
|
|
198
|
+
<Trap>
|
|
199
|
+
<div tabIndex={-1} data-testid="programmatic-target">
|
|
200
|
+
Read-only dialog content
|
|
201
|
+
</div>
|
|
202
|
+
</Trap>,
|
|
203
|
+
);
|
|
204
|
+
const target = screen.getByTestId('programmatic-target');
|
|
205
|
+
target.focus();
|
|
206
|
+
|
|
207
|
+
expect(fireEvent.keyDown(target, {key: 'Tab'})).toBe(false);
|
|
208
|
+
expect(target).toHaveFocus();
|
|
209
|
+
expect(screen.getByTestId('outside')).not.toHaveFocus();
|
|
210
|
+
});
|
|
195
211
|
});
|
|
196
212
|
|
|
197
213
|
describe('FOCUSABLE_SELECTOR href matching', () => {
|
|
@@ -332,12 +332,21 @@ export function useFocusTrap<T extends HTMLElement = HTMLElement>(
|
|
|
332
332
|
lastFocusRef.current = target as Element;
|
|
333
333
|
} else if (isKeyboardNavigationRef.current) {
|
|
334
334
|
// Focus escaped via keyboard - redirect it back
|
|
335
|
-
focusFirstDescendant(container);
|
|
335
|
+
const focusedFirst = focusFirstDescendant(container);
|
|
336
336
|
|
|
337
337
|
// If we're back at the same element (Shift+Tab from first element),
|
|
338
338
|
// try focusing the last element instead
|
|
339
|
-
if (lastFocusRef.current === document.activeElement) {
|
|
339
|
+
if (focusedFirst && lastFocusRef.current === document.activeElement) {
|
|
340
340
|
focusLastDescendant(container);
|
|
341
|
+
} else if (
|
|
342
|
+
!focusedFirst &&
|
|
343
|
+
lastFocusRef.current instanceof HTMLElement &&
|
|
344
|
+
container.contains(lastFocusRef.current)
|
|
345
|
+
) {
|
|
346
|
+
// A modal surface may intentionally have no tabbable controls and
|
|
347
|
+
// place initial focus on a tabIndex={-1} heading or panel. Preserve
|
|
348
|
+
// that programmatic focus target instead of letting Tab escape.
|
|
349
|
+
attemptFocus(lastFocusRef.current);
|
|
341
350
|
}
|
|
342
351
|
|
|
343
352
|
lastFocusRef.current = document.activeElement;
|
|
@@ -410,6 +419,15 @@ export function useFocusTrap<T extends HTMLElement = HTMLElement>(
|
|
|
410
419
|
|
|
411
420
|
const focusable = getFocusableElements(container);
|
|
412
421
|
if (focusable.length === 0) {
|
|
422
|
+
// There is nowhere to advance to. Keep focus on the current
|
|
423
|
+
// programmatic target (for example a dialog panel with tabIndex=-1)
|
|
424
|
+
// rather than allowing the browser to move into background content.
|
|
425
|
+
event.preventDefault();
|
|
426
|
+
const active = document.activeElement;
|
|
427
|
+
if (active instanceof HTMLElement && container.contains(active)) {
|
|
428
|
+
lastFocusRef.current = active;
|
|
429
|
+
}
|
|
430
|
+
isKeyboardNavigationRef.current = false;
|
|
413
431
|
return;
|
|
414
432
|
}
|
|
415
433
|
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file useMenuHover.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, TopNavMenu (a real consumer)
|
|
6
|
+
* @output Unit tests for the shared hover-menu interaction contract (#3121)
|
|
7
|
+
* @position Testing; exercised through TopNavMenu rather than a synthetic
|
|
8
|
+
* harness, so the hook cannot drift from how consumers wire it
|
|
9
|
+
*
|
|
10
|
+
* SYNC: When useMenuHover changes, update tests to match new behavior
|
|
11
|
+
*/
|
|
12
|
+
import {describe, it, expect, vi, afterEach} from 'vitest';
|
|
13
|
+
import {render, screen, act} from '@testing-library/react';
|
|
14
|
+
import userEvent from '@testing-library/user-event';
|
|
15
|
+
import {TopNavMenu} from '../TopNav/TopNavMenu';
|
|
16
|
+
|
|
17
|
+
const items = [
|
|
18
|
+
{title: 'Analytics', description: 'Track user behavior', href: '/analytics'},
|
|
19
|
+
{title: 'Messaging', description: 'Real-time comms', href: '/messaging'},
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** A touchscreen: no hover-capable pointer. */
|
|
23
|
+
function mockPointerlessDevice() {
|
|
24
|
+
vi.spyOn(window, 'matchMedia').mockImplementation(
|
|
25
|
+
(query: string) =>
|
|
26
|
+
({
|
|
27
|
+
matches: false,
|
|
28
|
+
media: query,
|
|
29
|
+
onchange: null,
|
|
30
|
+
addListener: () => {},
|
|
31
|
+
removeListener: () => {},
|
|
32
|
+
addEventListener: () => {},
|
|
33
|
+
removeEventListener: () => {},
|
|
34
|
+
dispatchEvent: () => false,
|
|
35
|
+
}) as MediaQueryList,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderMenu() {
|
|
40
|
+
render(<TopNavMenu label="Products" items={items} />);
|
|
41
|
+
return screen.getByRole('button', {name: 'Products'});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function firstMenuItem() {
|
|
45
|
+
return document.querySelector<HTMLElement>('[role="menuitem"]');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
vi.useRealTimers();
|
|
50
|
+
vi.restoreAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('useMenuHover — hover/click guard', () => {
|
|
54
|
+
it('opens on hover after the show delay', async () => {
|
|
55
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
56
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
57
|
+
const trigger = renderMenu();
|
|
58
|
+
|
|
59
|
+
await user.hover(trigger);
|
|
60
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
61
|
+
|
|
62
|
+
act(() => {
|
|
63
|
+
vi.advanceTimersByTime(300);
|
|
64
|
+
});
|
|
65
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps the menu open when a hover-open is immediately clicked', async () => {
|
|
69
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
70
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
71
|
+
const trigger = renderMenu();
|
|
72
|
+
|
|
73
|
+
await user.hover(trigger);
|
|
74
|
+
act(() => {
|
|
75
|
+
vi.advanceTimersByTime(300);
|
|
76
|
+
});
|
|
77
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
78
|
+
|
|
79
|
+
await user.click(trigger);
|
|
80
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('pins a confirmed menu so leaving the trigger no longer closes it', async () => {
|
|
84
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
85
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
86
|
+
const trigger = renderMenu();
|
|
87
|
+
|
|
88
|
+
await user.hover(trigger);
|
|
89
|
+
act(() => {
|
|
90
|
+
vi.advanceTimersByTime(300);
|
|
91
|
+
});
|
|
92
|
+
await user.click(trigger);
|
|
93
|
+
|
|
94
|
+
await user.unhover(trigger);
|
|
95
|
+
act(() => {
|
|
96
|
+
vi.advanceTimersByTime(500);
|
|
97
|
+
});
|
|
98
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('closes on a click that lands well after the hover-open', async () => {
|
|
102
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
103
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
104
|
+
const trigger = renderMenu();
|
|
105
|
+
|
|
106
|
+
await user.hover(trigger);
|
|
107
|
+
act(() => {
|
|
108
|
+
vi.advanceTimersByTime(300);
|
|
109
|
+
});
|
|
110
|
+
// Past the guard: a deliberate dismissal, not a follow-on.
|
|
111
|
+
act(() => {
|
|
112
|
+
vi.advanceTimersByTime(1200);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await user.click(trigger);
|
|
116
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('closes a transient (hover-opened) menu when the pointer leaves', async () => {
|
|
120
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
121
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
122
|
+
const trigger = renderMenu();
|
|
123
|
+
|
|
124
|
+
await user.hover(trigger);
|
|
125
|
+
act(() => {
|
|
126
|
+
vi.advanceTimersByTime(300);
|
|
127
|
+
});
|
|
128
|
+
await user.unhover(trigger);
|
|
129
|
+
act(() => {
|
|
130
|
+
vi.advanceTimersByTime(400);
|
|
131
|
+
});
|
|
132
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('re-entering the trigger of an open menu does not re-arm the guard', async () => {
|
|
136
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
137
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
138
|
+
const trigger = renderMenu();
|
|
139
|
+
|
|
140
|
+
// Click-open pins the menu.
|
|
141
|
+
await user.click(trigger);
|
|
142
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
143
|
+
|
|
144
|
+
// Re-entering must not un-pin it, nor make the next click a "confirm".
|
|
145
|
+
await user.unhover(trigger);
|
|
146
|
+
await user.hover(trigger);
|
|
147
|
+
act(() => {
|
|
148
|
+
vi.advanceTimersByTime(300);
|
|
149
|
+
});
|
|
150
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
151
|
+
|
|
152
|
+
await user.click(trigger);
|
|
153
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('stays closed when the panel vanishing puts the trigger back under the pointer', async () => {
|
|
157
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
158
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
159
|
+
const trigger = renderMenu();
|
|
160
|
+
|
|
161
|
+
await user.click(trigger);
|
|
162
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
163
|
+
|
|
164
|
+
// Without suppression, the mouseenter fired when the panel stops covering
|
|
165
|
+
// the trigger reopens the menu — which made Escape look inert.
|
|
166
|
+
await user.click(trigger);
|
|
167
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
168
|
+
await user.hover(trigger);
|
|
169
|
+
act(() => {
|
|
170
|
+
vi.advanceTimersByTime(200);
|
|
171
|
+
});
|
|
172
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('reopens on a deliberate re-hover once the suppression window passes', async () => {
|
|
176
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
177
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
178
|
+
const trigger = renderMenu();
|
|
179
|
+
|
|
180
|
+
await user.click(trigger);
|
|
181
|
+
await user.click(trigger);
|
|
182
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
183
|
+
|
|
184
|
+
await user.unhover(trigger);
|
|
185
|
+
act(() => {
|
|
186
|
+
vi.advanceTimersByTime(600);
|
|
187
|
+
});
|
|
188
|
+
await user.hover(trigger);
|
|
189
|
+
act(() => {
|
|
190
|
+
vi.advanceTimersByTime(300);
|
|
191
|
+
});
|
|
192
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('toggles cleanly for click-only interaction', async () => {
|
|
196
|
+
const user = userEvent.setup();
|
|
197
|
+
const trigger = renderMenu();
|
|
198
|
+
|
|
199
|
+
await user.click(trigger);
|
|
200
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
201
|
+
await user.click(trigger);
|
|
202
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('useMenuHover — focus management', () => {
|
|
207
|
+
it('leaves focus on the trigger for a hover-open', async () => {
|
|
208
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
209
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
210
|
+
const trigger = renderMenu();
|
|
211
|
+
|
|
212
|
+
await user.hover(trigger);
|
|
213
|
+
act(() => {
|
|
214
|
+
vi.advanceTimersByTime(300);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(firstMenuItem()).not.toHaveFocus();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('moves focus to the first item on a click-open, synchronously', async () => {
|
|
221
|
+
const user = userEvent.setup();
|
|
222
|
+
const trigger = renderMenu();
|
|
223
|
+
|
|
224
|
+
await user.click(trigger);
|
|
225
|
+
|
|
226
|
+
// No waitFor and no timer flush: a deferred (rAF) focus fails here, which
|
|
227
|
+
// is the point of the assertion.
|
|
228
|
+
expect(firstMenuItem()).toHaveFocus();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('moves focus into the menu when a hover-open is confirmed by click', async () => {
|
|
232
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
233
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
234
|
+
const trigger = renderMenu();
|
|
235
|
+
|
|
236
|
+
await user.hover(trigger);
|
|
237
|
+
act(() => {
|
|
238
|
+
vi.advanceTimersByTime(300);
|
|
239
|
+
});
|
|
240
|
+
expect(firstMenuItem()).not.toHaveFocus();
|
|
241
|
+
|
|
242
|
+
await user.click(trigger);
|
|
243
|
+
expect(firstMenuItem()).toHaveFocus();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('returns focus to the trigger when a click closes the menu', async () => {
|
|
247
|
+
const user = userEvent.setup();
|
|
248
|
+
const trigger = renderMenu();
|
|
249
|
+
|
|
250
|
+
await user.click(trigger);
|
|
251
|
+
expect(firstMenuItem()).toHaveFocus();
|
|
252
|
+
|
|
253
|
+
await user.click(trigger);
|
|
254
|
+
expect(trigger).toHaveFocus();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('returns focus to the trigger on Escape', async () => {
|
|
258
|
+
const user = userEvent.setup();
|
|
259
|
+
const trigger = renderMenu();
|
|
260
|
+
|
|
261
|
+
await user.click(trigger);
|
|
262
|
+
expect(firstMenuItem()).toHaveFocus();
|
|
263
|
+
|
|
264
|
+
await user.keyboard('{Escape}');
|
|
265
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
266
|
+
expect(trigger).toHaveFocus();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('useMenuHover — keyboard activation', () => {
|
|
271
|
+
it('opens on Enter and moves focus into the menu', async () => {
|
|
272
|
+
const user = userEvent.setup();
|
|
273
|
+
const trigger = renderMenu();
|
|
274
|
+
trigger.focus();
|
|
275
|
+
|
|
276
|
+
await user.keyboard('{Enter}');
|
|
277
|
+
|
|
278
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
279
|
+
expect(firstMenuItem()).toHaveFocus();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('opens on Space', async () => {
|
|
283
|
+
const user = userEvent.setup();
|
|
284
|
+
const trigger = renderMenu();
|
|
285
|
+
trigger.focus();
|
|
286
|
+
|
|
287
|
+
await user.keyboard(' ');
|
|
288
|
+
|
|
289
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('never toggles an open menu closed — it moves focus in instead', async () => {
|
|
293
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
294
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
295
|
+
const trigger = renderMenu();
|
|
296
|
+
|
|
297
|
+
// A hover-open is the only state where a keyboard user can activate the
|
|
298
|
+
// trigger of an open menu; closing on Enter would strand them.
|
|
299
|
+
await user.hover(trigger);
|
|
300
|
+
act(() => {
|
|
301
|
+
vi.advanceTimersByTime(300);
|
|
302
|
+
});
|
|
303
|
+
trigger.focus();
|
|
304
|
+
|
|
305
|
+
await user.keyboard('{Enter}');
|
|
306
|
+
|
|
307
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
308
|
+
expect(firstMenuItem()).toHaveFocus();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('arrow keys walk the menu items', async () => {
|
|
312
|
+
const user = userEvent.setup();
|
|
313
|
+
const trigger = renderMenu();
|
|
314
|
+
|
|
315
|
+
await user.click(trigger);
|
|
316
|
+
const menuItems =
|
|
317
|
+
document.querySelectorAll<HTMLElement>('[role="menuitem"]');
|
|
318
|
+
expect(menuItems[0]).toHaveFocus();
|
|
319
|
+
|
|
320
|
+
await user.keyboard('{ArrowDown}');
|
|
321
|
+
expect(menuItems[1]).toHaveFocus();
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe('useMenuHover — devices without hover', () => {
|
|
326
|
+
it('does not open on a synthetic mouseenter from a tap', async () => {
|
|
327
|
+
mockPointerlessDevice();
|
|
328
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
329
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
330
|
+
const trigger = renderMenu();
|
|
331
|
+
|
|
332
|
+
// Taps emit a compatibility mouseenter; opening on it leaves a menu
|
|
333
|
+
// hanging open behind the tap.
|
|
334
|
+
await user.hover(trigger);
|
|
335
|
+
act(() => {
|
|
336
|
+
vi.advanceTimersByTime(500);
|
|
337
|
+
});
|
|
338
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('still opens and closes on click', async () => {
|
|
342
|
+
mockPointerlessDevice();
|
|
343
|
+
const user = userEvent.setup();
|
|
344
|
+
const trigger = renderMenu();
|
|
345
|
+
|
|
346
|
+
await user.click(trigger);
|
|
347
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
348
|
+
await user.click(trigger);
|
|
349
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe('useMenuHover — native invoker wiring', () => {
|
|
354
|
+
it('registers the trigger as the panel it controls', () => {
|
|
355
|
+
const trigger = renderMenu();
|
|
356
|
+
|
|
357
|
+
// jsdom implements neither light dismiss nor invokers, so this asserts the
|
|
358
|
+
// wiring; the behavior itself is browser-verified.
|
|
359
|
+
expect(trigger).toHaveAttribute(
|
|
360
|
+
'popovertarget',
|
|
361
|
+
trigger.getAttribute('aria-controls'),
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
});
|