@astryxdesign/core 0.4.1 → 0.4.2-canary.464a445

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 (182) 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 +13 -7
  72. package/dist/astryx.umd.js +50 -50
  73. package/dist/astryx.umd.js.map +4 -4
  74. package/dist/hooks/index.d.ts +1 -1
  75. package/dist/hooks/index.d.ts.map +1 -1
  76. package/dist/hooks/index.js +1 -1
  77. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  78. package/dist/hooks/useFocusTrap.js +16 -2
  79. package/dist/hooks/useMenuHover.d.ts +50 -5
  80. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  81. package/dist/hooks/useMenuHover.js +171 -51
  82. package/dist/theme/defineTheme.d.ts +8 -2
  83. package/dist/theme/defineTheme.d.ts.map +1 -1
  84. package/dist/theme/defineTheme.js +8 -1
  85. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  86. package/dist/theme/derivedVarRegistry.js +7 -0
  87. package/dist/theme/expandColorScale.d.ts.map +1 -1
  88. package/dist/theme/expandColorScale.js +1 -0
  89. package/dist/theme/expandMotionScale.d.ts +1 -0
  90. package/dist/theme/expandMotionScale.d.ts.map +1 -1
  91. package/dist/theme/expandMotionScale.js +1 -0
  92. package/dist/theme/expandRadiusScale.d.ts +1 -0
  93. package/dist/theme/expandRadiusScale.d.ts.map +1 -1
  94. package/dist/theme/expandRadiusScale.js +1 -0
  95. package/dist/theme/expandTypeScale.d.ts +1 -0
  96. package/dist/theme/expandTypeScale.d.ts.map +1 -1
  97. package/dist/theme/expandTypeScale.js +1 -0
  98. package/dist/theme/tokens.stylex.d.ts +3 -1
  99. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  100. package/dist/theme/tokens.stylex.js +3 -1
  101. package/locales/en.json +84 -0
  102. package/locales/pseudo.json +63 -0
  103. package/package.json +3 -3
  104. package/src/Avatar/Avatar.doc.mjs +5 -2
  105. package/src/Avatar/Avatar.test.tsx +51 -0
  106. package/src/Avatar/Avatar.tsx +30 -20
  107. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  108. package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
  109. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
  110. package/src/Button/Button.tsx +5 -3
  111. package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
  112. package/src/Card/Card.doc.mjs +2 -0
  113. package/src/Chat/Chat.doc.mjs +4 -1
  114. package/src/Chat/ChatMessage.doc.mjs +3 -3
  115. package/src/Chat/ChatMessage.tsx +7 -0
  116. package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
  117. package/src/Chat/ChatMessageBubble.test.tsx +95 -6
  118. package/src/Chat/ChatMessageBubble.tsx +25 -0
  119. package/src/CheckboxInput/CheckboxInput.tsx +20 -0
  120. package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
  121. package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
  122. package/src/DateTimeInput/DateTimeInput.tsx +4 -2
  123. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
  124. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
  125. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  126. package/src/HoverCard/HoverCard.test.tsx +283 -55
  127. package/src/HoverCard/HoverCard.tsx +5 -13
  128. package/src/HoverCard/useHoverCard.tsx +8 -11
  129. package/src/InputGroup/groupStyles.ts +7 -8
  130. package/src/Item/Item.doc.mjs +4 -0
  131. package/src/Layer/layerHost.test.ts +99 -0
  132. package/src/Layer/layerHost.ts +141 -0
  133. package/src/Layer/useLayer.doc.mjs +14 -4
  134. package/src/Layer/useLayer.test.tsx +332 -7
  135. package/src/Layer/useLayer.tsx +235 -36
  136. package/src/MobileNav/MobileNav.doc.mjs +7 -0
  137. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  138. package/src/RadioList/RadioListItem.tsx +20 -0
  139. package/src/SideNav/SideNav.doc.mjs +13 -4
  140. package/src/SideNav/SideNav.test.tsx +858 -2
  141. package/src/SideNav/SideNav.tsx +37 -16
  142. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  143. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  144. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  145. package/src/SideNav/SideNavHeading.tsx +53 -25
  146. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  147. package/src/SideNav/SideNavItem.tsx +97 -84
  148. package/src/SideNav/SideNavSection.tsx +6 -20
  149. package/src/SideNav/index.ts +2 -0
  150. package/src/Slider/Slider.test.tsx +69 -33
  151. package/src/Slider/Slider.tsx +65 -13
  152. package/src/Switch/Switch.tsx +20 -0
  153. package/src/TabList/TabList.doc.mjs +3 -0
  154. package/src/Text/Text.doc.mjs +1 -1
  155. package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
  156. package/src/Thumbnail/Thumbnail.tsx +10 -0
  157. package/src/Timestamp/Timestamp.test.tsx +40 -22
  158. package/src/Toolbar/Toolbar.test.tsx +14 -14
  159. package/src/TopNav/TopNav.test.tsx +86 -1
  160. package/src/TopNav/TopNavHeading.tsx +21 -14
  161. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  162. package/src/TopNav/TopNavMegaMenu.tsx +29 -105
  163. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  164. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  165. package/src/TopNav/TopNavMenu.tsx +14 -4
  166. package/src/TreeList/TreeList.doc.mjs +1 -0
  167. package/src/hooks/index.ts +5 -1
  168. package/src/hooks/useFocusTrap.test.tsx +16 -0
  169. package/src/hooks/useFocusTrap.ts +20 -2
  170. package/src/hooks/useMenuHover.test.tsx +364 -0
  171. package/src/hooks/useMenuHover.ts +243 -47
  172. package/src/theme/defineTheme.ts +9 -0
  173. package/src/theme/derivedVarRegistry.test.ts +78 -12
  174. package/src/theme/derivedVarRegistry.ts +4 -0
  175. package/src/theme/expandColorScale.ts +1 -0
  176. package/src/theme/expandMotionScale.ts +1 -0
  177. package/src/theme/expandRadiusScale.ts +1 -0
  178. package/src/theme/expandTypeScale.ts +1 -0
  179. package/src/theme/themingTargets.test.ts +152 -26
  180. package/src/theme/tokens.stylex.ts +3 -1
  181. package/src/theme/tokens.test.ts +12 -0
  182. 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
  }
@@ -28,6 +28,12 @@
28
28
  * <App />
29
29
  * </Theme>
30
30
  * ```
31
+ *
32
+ * SYNC: `DefineThemeInput` is the theme surface. Adding, removing, or renaming
33
+ * a field means updating:
34
+ * - /packages/cli/assets/theme.template.ts (documents every field; the
35
+ * drift guard is scripts/check-theme-template.test.mjs)
36
+ * - /packages/cli/assets/docs/theme.doc.mjs (`astryx docs theme`)
31
37
  */
32
38
 
33
39
  import type {IconRegistry} from '../Icon/globalIconRegistry';
@@ -42,6 +48,7 @@ import {
42
48
  colorDefaults,
43
49
  spacingDefaults,
44
50
  sizeDefaults,
51
+ borderDefaults,
45
52
  focusDefaults,
46
53
  radiusDefaults,
47
54
  shadowDefaults,
@@ -75,6 +82,7 @@ export type CoreTokenName =
75
82
  | keyof typeof colorDefaults
76
83
  | keyof typeof spacingDefaults
77
84
  | keyof typeof sizeDefaults
85
+ | keyof typeof borderDefaults
78
86
  | keyof typeof focusDefaults
79
87
  | keyof typeof radiusDefaults
80
88
  | keyof typeof shadowDefaults
@@ -379,6 +387,7 @@ export const tokenDefaults: Record<string, string> = {
379
387
  ...colorDefaults,
380
388
  ...spacingDefaults,
381
389
  ...sizeDefaults,
390
+ ...borderDefaults,
382
391
  ...focusDefaults,
383
392
  ...radiusDefaults,
384
393
  ...shadowDefaults,
@@ -10,6 +10,21 @@
10
10
  * 2. Doc check: verifies each var is documented in the doc file's vars[]
11
11
  * 3. Registry check: verifies themeable vars have derived[] entries that
12
12
  * match the registry
13
+ *
14
+ * Layers 1 and 3 each used to carry a hole that let real vars through:
15
+ *
16
+ * - The source scan skipped every `--_*` name outright, on the theory that a
17
+ * private var is "internal, not themeable". It is internal, but it is still
18
+ * documented — `theming.vars[]` takes `private: true` for exactly this
19
+ * (`--_card-radius`, `--_dropdown-menu-padding`), and the derived-var
20
+ * pipeline is what theme authors reach it through. Skipping the prefix meant
21
+ * 10 private vars across 12 (component, var) sites were declared in source
22
+ * and documented nowhere.
23
+ * - Layer 3 narrowed its verdict to vars matching `/radius|padding/`, so any
24
+ * var whose name did not happen to contain those two words was exempt from
25
+ * ever needing a derived[] mapping. That is now an explicit allowlist
26
+ * (VARS_WITHOUT_DERIVED_MAPPING) instead of a name heuristic: a new var must
27
+ * be given a derived[] entry or be added to the list on purpose.
13
28
  */
14
29
 
15
30
  import {describe, it, expect} from 'vitest';
@@ -75,7 +90,13 @@ const STRUCTURAL_VARS = new Set([
75
90
  /**
76
91
  * Extract component-specific CSS custom property names from a source file.
77
92
  * Matches patterns like '--_card-radius': or '--_chat-composer-padding':
78
- * Excludes structural/runtime vars and standard token vars (--color-*, --spacing-*, etc.).
93
+ * Excludes structural/runtime vars and standard token vars (--color-*,
94
+ * --spacing-*, etc.).
95
+ *
96
+ * Private (`--_*`) vars are INCLUDED. They are internal in the sense that a
97
+ * theme author does not set them directly, but they are still part of the
98
+ * documented theming surface (`theming.vars[]` with `private: true`) and are
99
+ * how derived[] entries connect a standard CSS property to a component.
79
100
  */
80
101
  function extractComponentVars(filePath: string): string[] {
81
102
  const content = readFileSync(filePath, 'utf-8');
@@ -101,10 +122,6 @@ function extractComponentVars(filePath: string): string[] {
101
122
  if (/^--(container-|layout-|edge-|component-)/.test(varName)) {
102
123
  continue;
103
124
  }
104
- // Skip private vars (--_ prefix = internal, not themeable)
105
- if (varName.startsWith('--_')) {
106
- continue;
107
- }
108
125
  vars.add(varName);
109
126
  }
110
127
  return [...vars];
@@ -190,6 +207,7 @@ const DIR_TO_REGISTRY_KEY: Record<string, string> = {
190
207
  Button: 'button',
191
208
  Card: 'card',
192
209
  Chat: 'chat',
210
+ ContextMenu: 'context-menu',
193
211
  Dialog: 'dialog',
194
212
  DropdownMenu: 'dropdown-menu',
195
213
  Field: 'field',
@@ -213,8 +231,55 @@ const CROSS_COMPONENT_VARS: Record<string, string[]> = {
213
231
  Carousel: ['--_button-radius'],
214
232
  Thumbnail: ['--_button-radius'],
215
233
  Chat: ['--_button-radius'],
234
+ // AvatarGroupOverflow sets the overlap for the Avatars it lays out; Avatar
235
+ // owns and documents it (and sets it itself when it is the group root).
236
+ AvatarGroup: ['--_avatar-group-overlap'],
237
+ // BreadcrumbItem tunes the DropdownMenu it opens.
238
+ Breadcrumbs: ['--_dropdown-menu-radius', '--_dropdown-menu-padding'],
239
+ // SelectableCard draws its selection ring through the Card shadow slot.
240
+ SelectableCard: ['--_card-ring'],
241
+ // Toolbar offsets the TabList indicator it hosts.
242
+ Toolbar: ['--_tab-indicator-bottom'],
243
+ // The destructive item variant recolors the Item it renders; Item owns,
244
+ // documents and reads both slots.
245
+ DropdownMenu: ['--_item-label-color', '--_item-description-color'],
216
246
  };
217
247
 
248
+ /**
249
+ * Documented vars that intentionally have NO derived[] entry — a theme author
250
+ * cannot reach them by writing a standard CSS property, only by targeting the
251
+ * component's own theming surface.
252
+ *
253
+ * This replaces a `/radius|padding/` name test that exempted every var whose
254
+ * name did not contain those words. Each entry is a deliberate classification,
255
+ * so a NEW var has to be argued into the list rather than slipping past on its
256
+ * name. The list should shrink over time, not grow.
257
+ */
258
+ const VARS_WITHOUT_DERIVED_MAPPING = new Set([
259
+ // No standard CSS property maps onto these — they are component behaviors.
260
+ '--button-focus-offset',
261
+ '--button-icon-only-aspect',
262
+ '--_avatar-group-overlap',
263
+ '--_codeblock-gutter-width',
264
+ '--_tab-indicator-bottom',
265
+ // Hit-area outset on a ::after overlay — `inset` on a pseudo-element is not
266
+ // a property a theme author sets on the component.
267
+ '--_thumbnail-hit-inset',
268
+ // Indentation and row-spacing metrics: --tree-list-indent is the authorable
269
+ // step, --_tree-indent the per-row distance TreeListItem computes from it.
270
+ // --tree-list-row-gap is applied as half a padding-block on each row wrapper,
271
+ // not as gap on the list, so no standard property on the tree-list target
272
+ // maps onto it; a theme sets the var directly.
273
+ '--tree-list-indent',
274
+ '--_tree-indent',
275
+ '--tree-list-row-gap',
276
+ // Composed into a single box-shadow list on the card, so neither maps 1:1
277
+ // onto boxShadow — setting one through a derived entry would clobber the
278
+ // other.
279
+ '--_card-elevation',
280
+ '--_card-ring',
281
+ ]);
282
+
218
283
  // ---------------------------------------------------------------------------
219
284
  // Tests
220
285
  // ---------------------------------------------------------------------------
@@ -262,19 +327,20 @@ describe('component CSS vars are documented and themeable', () => {
262
327
  return true;
263
328
  });
264
329
 
265
- // Filter to only vars that look like they map to CSS properties
266
- // (radius → borderRadius, padding → padding). Vars like
267
- // --button-press-scale or --button-disabled-opacity are
268
- // component-specific behaviors, not standard CSS property mappings.
269
- const themeableVars = missingDerived.filter(v =>
270
- /radius|padding/.test(v),
330
+ // Everything that is not explicitly classified as unmappable must have
331
+ // a derived[] entry. (This was a `/radius|padding/` name test, which
332
+ // exempted any var whose name lacked those words.)
333
+ const themeableVars = missingDerived.filter(
334
+ v => !VARS_WITHOUT_DERIVED_MAPPING.has(v),
271
335
  );
272
336
 
273
337
  expect(
274
338
  themeableVars,
275
339
  `${dir} has vars that should be themeable via derived[]: ${themeableVars.join(', ')}. ` +
276
340
  `Add derived[] entries in ${dir}.doc.mjs mapping standard CSS ` +
277
- `properties (borderRadius, padding) to these internal vars.`,
341
+ `properties (borderRadius, padding) to these internal vars — or, if ` +
342
+ `no standard property maps onto them, add them to ` +
343
+ `VARS_WITHOUT_DERIVED_MAPPING with the reason.`,
278
344
  ).toEqual([]);
279
345
  });
280
346
  }
@@ -57,6 +57,10 @@ export const derivedVarRegistry: Record<string, DerivedVarEntry[]> = {
57
57
  {property: 'borderRadius', vars: ['--_dialog-radius']},
58
58
  {property: 'padding', expand: 'container'},
59
59
  ],
60
+ 'context-menu': [
61
+ {property: 'borderRadius', vars: ['--_dropdown-menu-radius']},
62
+ {property: 'padding', vars: ['--_dropdown-menu-padding']},
63
+ ],
60
64
  'dropdown-menu': [
61
65
  {property: 'borderRadius', vars: ['--_dropdown-menu-radius']},
62
66
  {property: 'padding', vars: ['--_dropdown-menu-padding']},
@@ -28,6 +28,7 @@
28
28
  *
29
29
  * SYNC: When modified, update:
30
30
  * - /packages/core/src/theme/defineTheme.ts
31
+ * - /packages/cli/assets/theme.template.ts (the annotated field reference)
31
32
  */
32
33
 
33
34
  import {contrastRatio} from './contrast';
@@ -18,6 +18,7 @@
18
18
  * SYNC: When modified, update:
19
19
  * - /packages/core/src/theme/expandMotionScale.test.ts
20
20
  * - /packages/core/src/theme/defineTheme.ts
21
+ * - /packages/cli/assets/theme.template.ts (the annotated field reference)
21
22
  */
22
23
 
23
24
  // =============================================================================
@@ -22,6 +22,7 @@
22
22
  * SYNC: When modified, update:
23
23
  * - /packages/core/src/theme/expandRadiusScale.test.ts
24
24
  * - /packages/core/src/theme/defineTheme.ts
25
+ * - /packages/cli/assets/theme.template.ts (the annotated field reference)
25
26
  */
26
27
 
27
28
  // =============================================================================
@@ -44,6 +44,7 @@
44
44
  * SYNC: When modified, update:
45
45
  * - /packages/core/src/theme/expandTypeScale.test.ts
46
46
  * - /packages/core/src/theme/defineTheme.ts
47
+ * - /packages/cli/assets/theme.template.ts (the annotated field reference)
47
48
  */
48
49
 
49
50
  // =============================================================================