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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/Avatar/Avatar.d.ts +4 -1
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +19 -23
  5. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  6. package/dist/AvatarGroup/AvatarGroupOverflow.js +7 -2
  7. package/dist/Button/Button.d.ts.map +1 -1
  8. package/dist/Button/Button.js +9 -7
  9. package/dist/Chat/ChatMessage.d.ts +7 -0
  10. package/dist/Chat/ChatMessage.d.ts.map +1 -1
  11. package/dist/Chat/ChatMessageBubble.d.ts +13 -1
  12. package/dist/Chat/ChatMessageBubble.d.ts.map +1 -1
  13. package/dist/Chat/ChatMessageBubble.js +19 -1
  14. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  15. package/dist/CheckboxInput/CheckboxInput.js +5 -0
  16. package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -1
  17. package/dist/CommandPalette/CommandPaletteFooter.js +5 -3
  18. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  19. package/dist/DateTimeInput/DateTimeInput.js +2 -2
  20. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  21. package/dist/DropdownMenu/DropdownMenuSubMenu.js +19 -15
  22. package/dist/HoverCard/HoverCard.d.ts +1 -1
  23. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  24. package/dist/HoverCard/HoverCard.js +5 -13
  25. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  26. package/dist/HoverCard/useHoverCard.js +6 -5
  27. package/dist/InputGroup/groupStyles.d.ts.map +1 -1
  28. package/dist/InputGroup/groupStyles.js +7 -2
  29. package/dist/Layer/layerHost.d.ts +24 -0
  30. package/dist/Layer/layerHost.d.ts.map +1 -0
  31. package/dist/Layer/layerHost.js +79 -0
  32. package/dist/Layer/useLayer.d.ts +14 -6
  33. package/dist/Layer/useLayer.d.ts.map +1 -1
  34. package/dist/Layer/useLayer.js +158 -30
  35. package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
  36. package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
  37. package/dist/NavItem/navItemStyles.stylex.js +11 -5
  38. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  39. package/dist/RadioList/RadioListItem.js +5 -0
  40. package/dist/SideNav/SideNav.d.ts +7 -9
  41. package/dist/SideNav/SideNav.d.ts.map +1 -1
  42. package/dist/SideNav/SideNav.js +32 -5
  43. package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
  44. package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
  45. package/dist/SideNav/SideNavCollapseButton.js +39 -15
  46. package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
  47. package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
  48. package/dist/SideNav/SideNavCollapseContext.js +16 -2
  49. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  50. package/dist/SideNav/SideNavHeading.js +89 -32
  51. package/dist/SideNav/SideNavItem.d.ts +7 -2
  52. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  53. package/dist/SideNav/SideNavItem.js +119 -75
  54. package/dist/SideNav/SideNavSection.d.ts.map +1 -1
  55. package/dist/SideNav/SideNavSection.js +7 -16
  56. package/dist/SideNav/index.d.ts +1 -1
  57. package/dist/SideNav/index.d.ts.map +1 -1
  58. package/dist/Slider/Slider.d.ts.map +1 -1
  59. package/dist/Slider/Slider.js +56 -15
  60. package/dist/Switch/Switch.d.ts.map +1 -1
  61. package/dist/Switch/Switch.js +5 -0
  62. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  63. package/dist/Thumbnail/Thumbnail.js +5 -0
  64. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  65. package/dist/TopNav/TopNavHeading.js +14 -6
  66. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  67. package/dist/TopNav/TopNavMegaMenu.js +25 -93
  68. package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
  69. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  70. package/dist/TopNav/TopNavMenu.js +10 -5
  71. package/dist/astryx.css +40 -8
  72. package/dist/astryx.umd.js +50 -50
  73. package/dist/astryx.umd.js.map +4 -4
  74. package/dist/hooks/containerReveal.stylex.d.ts +71 -4
  75. package/dist/hooks/containerReveal.stylex.d.ts.map +1 -1
  76. package/dist/hooks/containerReveal.stylex.js +57 -5
  77. package/dist/hooks/index.d.ts +2 -2
  78. package/dist/hooks/index.d.ts.map +1 -1
  79. package/dist/hooks/index.js +1 -1
  80. package/dist/hooks/useContainerReveal.d.ts +60 -3
  81. package/dist/hooks/useContainerReveal.d.ts.map +1 -1
  82. package/dist/hooks/useContainerReveal.js +27 -5
  83. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  84. package/dist/hooks/useFocusTrap.js +16 -2
  85. package/dist/hooks/useMenuHover.d.ts +50 -5
  86. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  87. package/dist/hooks/useMenuHover.js +171 -51
  88. package/dist/theme/defineTheme.d.ts +16 -5
  89. package/dist/theme/defineTheme.d.ts.map +1 -1
  90. package/dist/theme/defineTheme.js +44 -48
  91. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  92. package/dist/theme/derivedVarRegistry.js +7 -0
  93. package/dist/theme/expandColorScale.d.ts.map +1 -1
  94. package/dist/theme/expandColorScale.js +1 -0
  95. package/dist/theme/expandMotionScale.d.ts +1 -0
  96. package/dist/theme/expandMotionScale.d.ts.map +1 -1
  97. package/dist/theme/expandMotionScale.js +1 -0
  98. package/dist/theme/expandRadiusScale.d.ts +1 -0
  99. package/dist/theme/expandRadiusScale.d.ts.map +1 -1
  100. package/dist/theme/expandRadiusScale.js +1 -0
  101. package/dist/theme/expandTypeScale.d.ts +1 -0
  102. package/dist/theme/expandTypeScale.d.ts.map +1 -1
  103. package/dist/theme/expandTypeScale.js +1 -0
  104. package/dist/theme/mergeComponents.d.ts +20 -0
  105. package/dist/theme/mergeComponents.d.ts.map +1 -0
  106. package/dist/theme/mergeComponents.js +56 -0
  107. package/dist/theme/onMediaTokens.d.ts +6 -1
  108. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  109. package/dist/theme/onMediaTokens.js +11 -3
  110. package/dist/theme/tokens.stylex.d.ts +3 -1
  111. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  112. package/dist/theme/tokens.stylex.js +3 -1
  113. package/locales/en.json +84 -0
  114. package/locales/pseudo.json +63 -0
  115. package/package.json +3 -3
  116. package/src/Avatar/Avatar.doc.mjs +5 -2
  117. package/src/Avatar/Avatar.test.tsx +51 -0
  118. package/src/Avatar/Avatar.tsx +30 -20
  119. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  120. package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
  121. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
  122. package/src/Button/Button.tsx +5 -3
  123. package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
  124. package/src/Card/Card.doc.mjs +2 -0
  125. package/src/Chat/Chat.doc.mjs +4 -1
  126. package/src/Chat/ChatMessage.doc.mjs +3 -3
  127. package/src/Chat/ChatMessage.tsx +7 -0
  128. package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
  129. package/src/Chat/ChatMessageBubble.test.tsx +95 -6
  130. package/src/Chat/ChatMessageBubble.tsx +25 -0
  131. package/src/CheckboxInput/CheckboxInput.tsx +20 -0
  132. package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
  133. package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
  134. package/src/DateTimeInput/DateTimeInput.tsx +4 -2
  135. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
  136. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
  137. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  138. package/src/HoverCard/HoverCard.test.tsx +283 -55
  139. package/src/HoverCard/HoverCard.tsx +5 -13
  140. package/src/HoverCard/useHoverCard.tsx +8 -11
  141. package/src/InputGroup/groupStyles.ts +7 -8
  142. package/src/Item/Item.doc.mjs +4 -0
  143. package/src/Layer/layerHost.test.ts +99 -0
  144. package/src/Layer/layerHost.ts +141 -0
  145. package/src/Layer/useLayer.doc.mjs +14 -4
  146. package/src/Layer/useLayer.test.tsx +332 -7
  147. package/src/Layer/useLayer.tsx +235 -36
  148. package/src/MobileNav/MobileNav.doc.mjs +7 -0
  149. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  150. package/src/RadioList/RadioListItem.tsx +20 -0
  151. package/src/SideNav/SideNav.doc.mjs +13 -4
  152. package/src/SideNav/SideNav.test.tsx +858 -2
  153. package/src/SideNav/SideNav.tsx +37 -16
  154. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  155. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  156. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  157. package/src/SideNav/SideNavHeading.tsx +53 -25
  158. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  159. package/src/SideNav/SideNavItem.tsx +97 -84
  160. package/src/SideNav/SideNavSection.tsx +6 -20
  161. package/src/SideNav/index.ts +2 -0
  162. package/src/Slider/Slider.test.tsx +69 -33
  163. package/src/Slider/Slider.tsx +65 -13
  164. package/src/Switch/Switch.tsx +20 -0
  165. package/src/TabList/TabList.doc.mjs +3 -0
  166. package/src/Text/Text.doc.mjs +1 -1
  167. package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
  168. package/src/Thumbnail/Thumbnail.tsx +10 -0
  169. package/src/Timestamp/Timestamp.test.tsx +40 -22
  170. package/src/Toolbar/Toolbar.test.tsx +14 -14
  171. package/src/TopNav/TopNav.test.tsx +86 -1
  172. package/src/TopNav/TopNavHeading.tsx +21 -14
  173. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  174. package/src/TopNav/TopNavMegaMenu.tsx +29 -105
  175. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  176. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  177. package/src/TopNav/TopNavMenu.tsx +14 -4
  178. package/src/TreeList/TreeList.doc.mjs +1 -0
  179. package/src/hooks/containerReveal.stylex.ts +140 -9
  180. package/src/hooks/index.ts +6 -1
  181. package/src/hooks/useContainerReveal.doc.mjs +11 -5
  182. package/src/hooks/useContainerReveal.test.tsx +70 -3
  183. package/src/hooks/useContainerReveal.ts +86 -7
  184. package/src/hooks/useFocusTrap.test.tsx +16 -0
  185. package/src/hooks/useFocusTrap.ts +20 -2
  186. package/src/hooks/useMenuHover.test.tsx +364 -0
  187. package/src/hooks/useMenuHover.ts +243 -47
  188. package/src/theme/defineTheme.test.ts +127 -0
  189. package/src/theme/defineTheme.ts +57 -51
  190. package/src/theme/derivedVarRegistry.test.ts +78 -12
  191. package/src/theme/derivedVarRegistry.ts +4 -0
  192. package/src/theme/expandColorScale.ts +1 -0
  193. package/src/theme/expandMotionScale.ts +1 -0
  194. package/src/theme/expandRadiusScale.ts +1 -0
  195. package/src/theme/expandTypeScale.ts +1 -0
  196. package/src/theme/mergeComponents.ts +59 -0
  197. package/src/theme/onMediaTokens.ts +9 -2
  198. package/src/theme/themingTargets.test.ts +152 -26
  199. package/src/theme/tokens.stylex.ts +3 -1
  200. package/src/theme/tokens.test.ts +12 -0
  201. package/src/theme/useTheme.test.tsx +18 -0
@@ -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 content-option → style-block mapping, and the promise that a
8
- * large flat list mounts without dev warnings.
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: () => {className?: string; style?: CSSProperties};
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 {...mergeProps(getContainerProps(), stylex.props(styles.row))}>
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: () => stylex.props(styles.container),
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 {isRevealInverted = false, isLayoutPreserved = false} =
114
- contentOptions;
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(style);
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
+ });