@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,42 +4,98 @@
4
4
 
5
5
  /**
6
6
  * @file useMenuHover.ts
7
- * @input Uses React hooks, useListFocus
7
+ * @input Uses React hooks, useListFocus, useMediaQuery
8
8
  * @output Exports useMenuHover hook
9
- * @position Internal hook; used by nav heading components and TopNavMenu
9
+ * @position Internal hook; used by nav heading components, TopNavMenu,
10
+ * TopNavMegaMenu and DropdownMenuSubMenu
10
11
  *
11
- * Hover as a progressive enhancement on top of standard popover behavior:
12
+ * Hover as a progressive enhancement over standard popover behavior: click
13
+ * toggles, Escape and outside-click close, arrows navigate; on top of that,
14
+ * mouseenter opens after a delay and mouseleave closes one that hover opened.
12
15
  *
13
- * 1. Default popover: click toggles, Escape closes, outside-click closes,
14
- * arrow keys navigate menu items.
16
+ * Three behaviors here are not obvious and are load-bearing:
15
17
  *
16
- * 2. Hover add-on:
17
- * - mouseenter activates "hover mode" and opens after delay
18
- * - While in hover mode, mouseleave closes after delay
19
- * - Any close (click, Escape, outside-click) resets hover mode
20
- * - Click-to-close additionally skips the next mouseenter
18
+ * 1. Hover→click guard (#3121). A hover-opened menu is already open under the
19
+ * cursor when the pointer arrives, so the click that naturally follows would
20
+ * toggle it shut. Within `clickGuardMs` a click instead confirms it: the menu
21
+ * pins and behaves like a click-open from then on.
21
22
  *
22
- * Only uses mouseenter/mouseleave (not mouseover).
23
+ * 2. Focus moves synchronously. `useLayer.show()` calls `showPopover()` in the
24
+ * same tick and the layer's children are always mounted, so items are
25
+ * focusable the moment `show()` returns — a `requestAnimationFrame` here
26
+ * lands after paint and races anything else moving focus. Hover-opens are the
27
+ * exception: the pointer is driving, so focus stays on the trigger. The hook
28
+ * always passes `skipAutoFocus` and owns focus itself, because the popover's
29
+ * auto-focus targets the first *tabbable* node while a menu wants its first
30
+ * item — under roving tabindex those are different elements.
31
+ *
32
+ * 3. Native light dismiss. A `popover="auto"` is dismissed by the browser on
33
+ * pointer interaction outside the panel, and a trigger outside the panel
34
+ * counts — before React's click handler runs. `popoverId` makes the trigger
35
+ * the panel's invoker, which exempts it. jsdom implements neither light
36
+ * dismiss nor invokers, so that wiring is only verifiable in a browser.
23
37
  */
24
38
 
25
39
  import {useCallback, useEffect, useRef} from 'react';
40
+ import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect';
26
41
  import {useListFocus} from './useListFocus';
27
42
  import {useMediaQuery} from './useMediaQuery';
28
43
 
29
- interface UseMenuHoverOptions {
44
+ const DEFAULT_CLICK_GUARD_MS = 500;
45
+
46
+ /**
47
+ * How long after a close a mouseenter on the trigger is ignored.
48
+ *
49
+ * A panel positioned over its own trigger puts that trigger back under a
50
+ * stationary pointer when it closes, and the browser fires a fresh mouseenter —
51
+ * which reopened the menu the user had just dismissed. Time-bounded rather than
52
+ * a one-shot flag, so a deliberate re-hover seconds later still opens; a real
53
+ * mouseleave clears it early.
54
+ */
55
+ const REOPEN_SUPPRESS_MS = 300;
56
+
57
+ export interface UseMenuHoverOptions {
30
58
  show: (options?: {skipAutoFocus?: boolean}) => void;
31
59
  hide: () => void;
32
60
  isOpen: boolean;
33
61
  isEnabled: boolean;
62
+ /** Delay before a hover opens the menu (ms). @default 150 */
34
63
  showDelay?: number;
64
+ /** Delay before leaving the trigger or menu closes it (ms). @default 200 */
35
65
  hideDelay?: number;
66
+ /**
67
+ * Window after a hover-open in which a click confirms the menu instead of
68
+ * closing it. 0 opts out, making every click toggle. @default 500
69
+ */
70
+ clickGuardMs?: number;
71
+ /**
72
+ * Selector for the menu's focusable items, forwarded to `useListFocus`. A
73
+ * panel of links (a mega menu) needs an override; `role="menuitem"` rows do
74
+ * not. @default '[role="menuitem"]'
75
+ */
76
+ itemSelector?: string;
77
+ /**
78
+ * The popup's DOM id (`usePopover().id`). Supply it when the popup is a
79
+ * native `popover="auto"` and the trigger sits outside it; omit it for
80
+ * `popover="manual"` popups and triggers inside the panel.
81
+ */
82
+ popoverId?: string;
83
+ /**
84
+ * Whether the hook moves focus on a click or keyboard open. False for
85
+ * consumers that wire the pointer half only and leave focus to the popover's
86
+ * own trap — a panel that is a dialog rather than a menu (`SideNavItem`'s
87
+ * collapsed flyout) has no items for the hook to focus. @default true
88
+ */
89
+ ownsFocus?: boolean;
36
90
  }
37
91
 
38
- interface UseMenuHoverReturn<T extends HTMLElement = HTMLElement> {
92
+ export interface UseMenuHoverReturn<T extends HTMLElement = HTMLElement> {
39
93
  triggerProps: {
40
- onClick: () => void;
94
+ onClick: (event?: React.MouseEvent) => void;
41
95
  onMouseEnter: () => void;
42
96
  onMouseLeave: () => void;
97
+ /** Present only when `popoverId` is supplied. */
98
+ popoverTarget?: string;
43
99
  };
44
100
  contentProps: {
45
101
  onMouseEnter: () => void;
@@ -47,7 +103,25 @@ interface UseMenuHoverReturn<T extends HTMLElement = HTMLElement> {
47
103
  onKeyDown: (e: React.KeyboardEvent) => void;
48
104
  };
49
105
  menuRef: React.RefObject<T | null>;
50
- focusFirst: () => void;
106
+ /** Focus the first enabled item. Returns false when there was none. */
107
+ focusFirst: () => boolean;
108
+ /**
109
+ * Focus the first enabled item, falling back to the menu container when the
110
+ * menu is empty or still loading.
111
+ */
112
+ focusMenu: () => void;
113
+ /**
114
+ * For consumers with their own click handler: call this in the open branch.
115
+ * Returns `true` when the click was the one following a hover-open — the menu
116
+ * is now pinned and the click must NOT be treated as a dismissal.
117
+ */
118
+ confirmHoverOpen: () => boolean;
119
+ /**
120
+ * Close, restoring focus to the trigger. For dismiss affordances rendered
121
+ * inside the popup, which are close buttons rather than the trigger and so
122
+ * must not carry its toggle and keyboard-activation semantics.
123
+ */
124
+ close: () => void;
51
125
  setTriggerEl: (el: HTMLElement | null) => void;
52
126
  }
53
127
 
@@ -61,6 +135,10 @@ export function useMenuHover<T extends HTMLElement = HTMLElement>(
61
135
  isEnabled,
62
136
  showDelay = 150,
63
137
  hideDelay = 200,
138
+ clickGuardMs = DEFAULT_CLICK_GUARD_MS,
139
+ itemSelector,
140
+ popoverId,
141
+ ownsFocus = true,
64
142
  } = options;
65
143
 
66
144
  const hasHover = useMediaQuery('(hover: hover)');
@@ -68,17 +146,25 @@ export function useMenuHover<T extends HTMLElement = HTMLElement>(
68
146
  const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
69
147
  const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
70
148
  const triggerElRef = useRef<HTMLElement | null>(null);
71
- // Whether the menu was opened/interacted via hover (enables mouseleave-to-close)
149
+ /** Menu was hover-opened, so mouseleave may close it. */
72
150
  const hoverModeRef = useRef(false);
73
- // One-shot: skip the next mouseenter after click-to-close
74
- const skipNextEnterRef = useRef(false);
151
+ const closedAtRef = useRef(0);
152
+ /** When the current open began as a hover-open; 0 once confirmed or closed. */
153
+ const hoverOpenedAtRef = useRef(0);
75
154
  const prevIsOpenRef = useRef(isOpen);
76
155
 
77
- // When popover closes (any reason), reset hover mode
78
- if (prevIsOpenRef.current && !isOpen) {
79
- hoverModeRef.current = false;
80
- }
81
- prevIsOpenRef.current = isOpen;
156
+ // Catches every close, whatever caused it. A layout effect, not render: it
157
+ // must land before the browser hit-tests the vanished panel and fires the
158
+ // mouseenter REOPEN_SUPPRESS_MS exists to swallow.
159
+ useIsomorphicLayoutEffect(() => {
160
+ const wasOpen = prevIsOpenRef.current;
161
+ prevIsOpenRef.current = isOpen;
162
+ if (wasOpen && !isOpen) {
163
+ hoverModeRef.current = false;
164
+ hoverOpenedAtRef.current = 0;
165
+ closedAtRef.current = Date.now();
166
+ }
167
+ }, [isOpen]);
82
168
 
83
169
  const clearTimeouts = useCallback(() => {
84
170
  if (showTimerRef.current) {
@@ -91,56 +177,160 @@ export function useMenuHover<T extends HTMLElement = HTMLElement>(
91
177
  }
92
178
  }, []);
93
179
 
180
+ // useListFocus needs an onEscape that is defined in terms of its own listRef;
181
+ // the indirection avoids the use-before-declare.
182
+ const escapeHandlerRef = useRef<() => void>(() => {});
183
+
94
184
  const {
95
185
  listRef: menuRef,
96
186
  handleKeyDown: handleListKeyDown,
97
187
  focusFirst,
98
188
  } = useListFocus<T>({
99
- onEscape: () => {
100
- clearTimeouts();
101
- hide();
102
- },
189
+ itemSelector,
190
+ onEscape: () => escapeHandlerRef.current(),
103
191
  });
104
192
 
193
+ const hideAndRestoreFocus = useCallback(() => {
194
+ // Read before hiding: closing the layer moves focus itself.
195
+ const menuHadFocus =
196
+ menuRef.current?.contains(document.activeElement) ?? false;
197
+ hide();
198
+ if (menuHadFocus) {
199
+ triggerElRef.current?.focus();
200
+ }
201
+ }, [hide, menuRef]);
202
+
203
+ useEffect(() => {
204
+ escapeHandlerRef.current = () => {
205
+ clearTimeouts();
206
+ hideAndRestoreFocus();
207
+ };
208
+ }, [clearTimeouts, hideAndRestoreFocus]);
209
+
105
210
  useEffect(() => {
106
211
  return () => clearTimeouts();
107
212
  }, [clearTimeouts]);
108
213
 
109
- // Click: always toggle
110
- const handleClick = useCallback(() => {
111
- clearTimeouts();
112
- if (isOpen) {
113
- skipNextEnterRef.current = true;
114
- hide();
115
- } else {
116
- skipNextEnterRef.current = false;
214
+ const focusMenu = useCallback(() => {
215
+ // An empty or still-loading menu has no focusable item; focus the container
216
+ // so keyboard ownership still transfers off the trigger's list.
217
+ if (!focusFirst()) {
218
+ menuRef.current?.focus();
219
+ }
220
+ }, [focusFirst, menuRef]);
221
+
222
+ const openAndFocus = useCallback(() => {
223
+ if (!ownsFocus) {
117
224
  show();
118
- requestAnimationFrame(() => focusFirst());
225
+ return;
226
+ }
227
+ show({skipAutoFocus: true});
228
+ focusMenu();
229
+ }, [ownsFocus, show, focusMenu]);
230
+
231
+ const confirmHoverOpen = useCallback((): boolean => {
232
+ const isConfirming =
233
+ clickGuardMs > 0 &&
234
+ hoverOpenedAtRef.current > 0 &&
235
+ Date.now() - hoverOpenedAtRef.current < clickGuardMs;
236
+ if (!isConfirming) {
237
+ return false;
119
238
  }
120
- }, [isOpen, clearTimeouts, show, hide, focusFirst]);
239
+ hoverModeRef.current = false;
240
+ hoverOpenedAtRef.current = 0;
241
+ return true;
242
+ }, [clickGuardMs]);
243
+
244
+ const handleClick = useCallback(
245
+ (event?: React.MouseEvent) => {
246
+ // Cancel the invoker's default toggle so this handler stays the single
247
+ // source of truth; popoverTarget still exempts the trigger from dismissal.
248
+ if (popoverId) {
249
+ event?.preventDefault();
250
+ }
251
+ clearTimeouts();
252
+
253
+ // Enter/Space arrive as a click with detail 0, and always open. A keyboard
254
+ // user reaches the trigger of an open menu only because a hover-open left
255
+ // focus behind; closing there would stranded them outside a visible menu.
256
+ const isKeyboardActivation = event != null && event.detail === 0;
257
+ if (isKeyboardActivation) {
258
+ closedAtRef.current = 0;
259
+ hoverModeRef.current = false;
260
+ hoverOpenedAtRef.current = 0;
261
+ if (isOpen) {
262
+ if (ownsFocus) {
263
+ focusMenu();
264
+ }
265
+ } else {
266
+ openAndFocus();
267
+ }
268
+ return;
269
+ }
270
+
271
+ if (!isOpen) {
272
+ closedAtRef.current = 0;
273
+ hoverModeRef.current = false;
274
+ hoverOpenedAtRef.current = 0;
275
+ openAndFocus();
276
+ return;
277
+ }
278
+
279
+ if (confirmHoverOpen()) {
280
+ if (ownsFocus) {
281
+ focusMenu();
282
+ }
283
+ return;
284
+ }
285
+
286
+ hideAndRestoreFocus();
287
+ },
288
+ [
289
+ popoverId,
290
+ clearTimeouts,
291
+ isOpen,
292
+ ownsFocus,
293
+ confirmHoverOpen,
294
+ openAndFocus,
295
+ focusMenu,
296
+ hideAndRestoreFocus,
297
+ ],
298
+ );
121
299
 
122
- // Hover: mouseenter activates hover mode and opens
123
300
  const handleMouseEnter = useCallback(() => {
124
301
  if (!hasHover) {
125
302
  return;
126
303
  }
127
- if (skipNextEnterRef.current) {
128
- skipNextEnterRef.current = false;
304
+ if (
305
+ closedAtRef.current > 0 &&
306
+ Date.now() - closedAtRef.current < REOPEN_SUPPRESS_MS
307
+ ) {
308
+ return;
309
+ }
310
+ // Re-entering an open menu's trigger must not un-pin it or re-arm the
311
+ // guard, which would make the next deliberate click another "confirm" and
312
+ // leave the menu undismissable.
313
+ if (isOpen) {
314
+ clearTimeouts();
129
315
  return;
130
316
  }
131
317
  hoverModeRef.current = true;
132
318
  clearTimeouts();
319
+ const openByHover = () => {
320
+ hoverOpenedAtRef.current = Date.now();
321
+ show({skipAutoFocus: true});
322
+ };
133
323
  if (showDelay > 0) {
134
- showTimerRef.current = setTimeout(() => {
135
- show({skipAutoFocus: true});
136
- }, showDelay);
324
+ showTimerRef.current = setTimeout(openByHover, showDelay);
137
325
  } else {
138
- show({skipAutoFocus: true});
326
+ openByHover();
139
327
  }
140
- }, [hasHover, clearTimeouts, show, showDelay]);
328
+ }, [hasHover, isOpen, clearTimeouts, show, showDelay]);
141
329
 
142
- // Hover: mouseleave only closes if in hover mode
143
330
  const handleMouseLeave = useCallback(() => {
331
+ // A real leave ends the stationary-pointer case the suppression window
332
+ // exists for, so the next enter is deliberate whenever it arrives.
333
+ closedAtRef.current = 0;
144
334
  if (!hoverModeRef.current) {
145
335
  return;
146
336
  }
@@ -150,7 +340,6 @@ export function useMenuHover<T extends HTMLElement = HTMLElement>(
150
340
  }, hideDelay);
151
341
  }, [clearTimeouts, hide, hideDelay]);
152
342
 
153
- // Content: mouseenter cancels pending hide
154
343
  const handleContentMouseEnter = useCallback(() => {
155
344
  clearTimeouts();
156
345
  }, [clearTimeouts]);
@@ -173,6 +362,9 @@ export function useMenuHover<T extends HTMLElement = HTMLElement>(
173
362
  },
174
363
  menuRef,
175
364
  focusFirst,
365
+ focusMenu,
366
+ confirmHoverOpen,
367
+ close: hideAndRestoreFocus,
176
368
  setTriggerEl: noopRef,
177
369
  };
178
370
  }
@@ -182,6 +374,7 @@ export function useMenuHover<T extends HTMLElement = HTMLElement>(
182
374
  onClick: handleClick,
183
375
  onMouseEnter: handleMouseEnter,
184
376
  onMouseLeave: handleMouseLeave,
377
+ ...(popoverId ? {popoverTarget: popoverId} : null),
185
378
  },
186
379
  contentProps: {
187
380
  onMouseEnter: handleContentMouseEnter,
@@ -190,6 +383,9 @@ export function useMenuHover<T extends HTMLElement = HTMLElement>(
190
383
  },
191
384
  menuRef,
192
385
  focusFirst,
386
+ focusMenu,
387
+ confirmHoverOpen,
388
+ close: hideAndRestoreFocus,
193
389
  setTriggerEl: setTriggerRef,
194
390
  };
195
391
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {describe, it, expect, vi} from 'vitest';
4
4
  import type {IconRegistry} from '../Icon/globalIconRegistry';
5
+ import type {DefinedTheme} from './defineTheme';
5
6
  import {defineTheme, generateThemeCSS, isDefinedTheme} from './defineTheme';
6
7
 
7
8
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
@@ -1126,4 +1127,130 @@ describe('defineTheme extends', () => {
1126
1127
  base.tokens['--font-size-base'],
1127
1128
  );
1128
1129
  });
1130
+
1131
+ it('inherits indicators when the child has none', () => {
1132
+ const indicator = (() => null) as unknown as NonNullable<
1133
+ DefinedTheme['indicators']
1134
+ >['check'];
1135
+ const base = defineTheme({name: 'base', indicators: {check: indicator}});
1136
+ const child = defineTheme({name: 'child', extends: base});
1137
+ expect(child.indicators?.check).toBe(indicator);
1138
+ });
1139
+
1140
+ it('inherits onDark token overrides from base theme', () => {
1141
+ const base = defineTheme({
1142
+ name: 'base',
1143
+ onDark: {tokens: {'--color-border': '#ffffff'}},
1144
+ });
1145
+ const child = defineTheme({name: 'child', extends: base});
1146
+ expect(child.__onDark?.tokens['--color-border']).toBe('#ffffff');
1147
+ });
1148
+
1149
+ it('inherits onLight token overrides from base theme', () => {
1150
+ const base = defineTheme({
1151
+ name: 'base',
1152
+ onLight: {tokens: {'--color-border': '#000000'}},
1153
+ });
1154
+ const child = defineTheme({name: 'child', extends: base});
1155
+ expect(child.__onLight?.tokens['--color-border']).toBe('#000000');
1156
+ });
1157
+
1158
+ it('lets the child override an inherited onDark token', () => {
1159
+ const base = defineTheme({
1160
+ name: 'base',
1161
+ onDark: {tokens: {'--color-border': '#ffffff', '--color-track': '#eee'}},
1162
+ });
1163
+ const child = defineTheme({
1164
+ name: 'child',
1165
+ extends: base,
1166
+ onDark: {tokens: {'--color-border': '#cccccc'}},
1167
+ });
1168
+ expect(child.__onDark?.tokens['--color-border']).toBe('#cccccc');
1169
+ expect(child.__onDark?.tokens['--color-track']).toBe('#eee');
1170
+ });
1171
+
1172
+ it('inherits and deep-merges onDark component overrides', () => {
1173
+ const base = defineTheme({
1174
+ name: 'base',
1175
+ onDark: {
1176
+ components: {
1177
+ card: {base: {borderColor: '#fff', backgroundColor: '#111'}},
1178
+ badge: {base: {color: '#fff'}},
1179
+ },
1180
+ },
1181
+ });
1182
+ const child = defineTheme({
1183
+ name: 'child',
1184
+ extends: base,
1185
+ onDark: {components: {card: {base: {borderColor: '#ccc'}}}},
1186
+ });
1187
+ expect(child.__onDark?.components?.card?.base).toEqual({
1188
+ borderColor: '#ccc',
1189
+ backgroundColor: '#111',
1190
+ });
1191
+ expect(child.__onDark?.components?.badge?.base).toEqual({color: '#fff'});
1192
+ });
1193
+
1194
+ it('inherits __inputTokens so inherited [light, dark] tuples survive', () => {
1195
+ const base = defineTheme({
1196
+ name: 'base',
1197
+ tokens: {'--color-accent': ['#111111', '#eeeeee']},
1198
+ });
1199
+ const child = defineTheme({
1200
+ name: 'child',
1201
+ extends: base,
1202
+ tokens: {'--color-border': '#cccccc'},
1203
+ });
1204
+ expect(child.__inputTokens?.['--color-accent']).toEqual([
1205
+ '#111111',
1206
+ '#eeeeee',
1207
+ ]);
1208
+ expect(child.__inputTokens?.['--color-border']).toBe('#cccccc');
1209
+ });
1210
+
1211
+ it('__inputTokens from the child win over the base', () => {
1212
+ const base = defineTheme({
1213
+ name: 'base',
1214
+ tokens: {'--color-accent': ['#111111', '#eeeeee']},
1215
+ });
1216
+ const child = defineTheme({
1217
+ name: 'child',
1218
+ extends: base,
1219
+ tokens: {'--color-accent': '#ff0000'},
1220
+ });
1221
+ expect(child.__inputTokens?.['--color-accent']).toBe('#ff0000');
1222
+ });
1223
+
1224
+ // The failure that motivated these: `extends` resolving to `undefined` (a
1225
+ // named import that silently missed — see the theme-build resolution tests)
1226
+ // used to inherit nothing and build a stylesheet that looked fine.
1227
+ it('throws when extends is present but undefined', () => {
1228
+ expect(() =>
1229
+ defineTheme({
1230
+ name: 'child',
1231
+ extends: undefined,
1232
+ tokens: {'--color-accent': '#ff0000'},
1233
+ }),
1234
+ ).toThrow(/extends/);
1235
+ });
1236
+
1237
+ it('throws when extends is not a theme', () => {
1238
+ expect(() =>
1239
+ defineTheme({
1240
+ name: 'child',
1241
+ // A module namespace object is the shape a bad import hands over.
1242
+ extends: {foo: 'bar'} as unknown as DefinedTheme,
1243
+ }),
1244
+ ).toThrow(/extends/);
1245
+ });
1246
+
1247
+ it('accepts a pre-built theme module as a base', () => {
1248
+ const built = {
1249
+ name: 'built-base',
1250
+ __built: true,
1251
+ tokens: {'--color-accent': '#111111'},
1252
+ } as DefinedTheme;
1253
+ const child = defineTheme({name: 'child', extends: built});
1254
+ expect(child.tokens['--color-accent']).toBe('#111111');
1255
+ });
1129
1256
  });