@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.
- package/CHANGELOG.md +55 -0
- package/dist/Avatar/Avatar.d.ts +4 -1
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +19 -23
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +7 -2
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +9 -7
- package/dist/Chat/ChatMessage.d.ts +7 -0
- package/dist/Chat/ChatMessage.d.ts.map +1 -1
- package/dist/Chat/ChatMessageBubble.d.ts +13 -1
- package/dist/Chat/ChatMessageBubble.d.ts.map +1 -1
- package/dist/Chat/ChatMessageBubble.js +19 -1
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +5 -0
- package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteFooter.js +5 -3
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +2 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +19 -15
- package/dist/HoverCard/HoverCard.d.ts +1 -1
- package/dist/HoverCard/HoverCard.d.ts.map +1 -1
- package/dist/HoverCard/HoverCard.js +5 -13
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +6 -5
- package/dist/InputGroup/groupStyles.d.ts.map +1 -1
- package/dist/InputGroup/groupStyles.js +7 -2
- package/dist/Layer/layerHost.d.ts +24 -0
- package/dist/Layer/layerHost.d.ts.map +1 -0
- package/dist/Layer/layerHost.js +79 -0
- package/dist/Layer/useLayer.d.ts +14 -6
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +158 -30
- package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
- package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
- package/dist/NavItem/navItemStyles.stylex.js +11 -5
- package/dist/RadioList/RadioListItem.d.ts.map +1 -1
- package/dist/RadioList/RadioListItem.js +5 -0
- package/dist/SideNav/SideNav.d.ts +7 -9
- package/dist/SideNav/SideNav.d.ts.map +1 -1
- package/dist/SideNav/SideNav.js +32 -5
- package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
- package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
- package/dist/SideNav/SideNavCollapseButton.js +39 -15
- package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
- package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
- package/dist/SideNav/SideNavCollapseContext.js +16 -2
- package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
- package/dist/SideNav/SideNavHeading.js +89 -32
- package/dist/SideNav/SideNavItem.d.ts +7 -2
- package/dist/SideNav/SideNavItem.d.ts.map +1 -1
- package/dist/SideNav/SideNavItem.js +119 -75
- package/dist/SideNav/SideNavSection.d.ts.map +1 -1
- package/dist/SideNav/SideNavSection.js +7 -16
- package/dist/SideNav/index.d.ts +1 -1
- package/dist/SideNav/index.d.ts.map +1 -1
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +56 -15
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +5 -0
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +5 -0
- package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
- package/dist/TopNav/TopNavHeading.js +14 -6
- package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMegaMenu.js +25 -93
- package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
- package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMenu.js +10 -5
- package/dist/astryx.css +13 -7
- package/dist/astryx.umd.js +50 -50
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +16 -2
- package/dist/hooks/useMenuHover.d.ts +50 -5
- package/dist/hooks/useMenuHover.d.ts.map +1 -1
- package/dist/hooks/useMenuHover.js +171 -51
- package/dist/theme/defineTheme.d.ts +8 -2
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +8 -1
- package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
- package/dist/theme/derivedVarRegistry.js +7 -0
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +1 -0
- package/dist/theme/expandMotionScale.d.ts +1 -0
- package/dist/theme/expandMotionScale.d.ts.map +1 -1
- package/dist/theme/expandMotionScale.js +1 -0
- package/dist/theme/expandRadiusScale.d.ts +1 -0
- package/dist/theme/expandRadiusScale.d.ts.map +1 -1
- package/dist/theme/expandRadiusScale.js +1 -0
- package/dist/theme/expandTypeScale.d.ts +1 -0
- package/dist/theme/expandTypeScale.d.ts.map +1 -1
- package/dist/theme/expandTypeScale.js +1 -0
- package/dist/theme/tokens.stylex.d.ts +3 -1
- package/dist/theme/tokens.stylex.d.ts.map +1 -1
- package/dist/theme/tokens.stylex.js +3 -1
- package/locales/en.json +84 -0
- package/locales/pseudo.json +63 -0
- package/package.json +3 -3
- package/src/Avatar/Avatar.doc.mjs +5 -2
- package/src/Avatar/Avatar.test.tsx +51 -0
- package/src/Avatar/Avatar.tsx +30 -20
- package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
- package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
- package/src/Button/Button.tsx +5 -3
- package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
- package/src/Card/Card.doc.mjs +2 -0
- package/src/Chat/Chat.doc.mjs +4 -1
- package/src/Chat/ChatMessage.doc.mjs +3 -3
- package/src/Chat/ChatMessage.tsx +7 -0
- package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
- package/src/Chat/ChatMessageBubble.test.tsx +95 -6
- package/src/Chat/ChatMessageBubble.tsx +25 -0
- package/src/CheckboxInput/CheckboxInput.tsx +20 -0
- package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
- package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
- package/src/DateTimeInput/DateTimeInput.tsx +4 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
- package/src/HoverCard/HoverCard.doc.mjs +3 -3
- package/src/HoverCard/HoverCard.test.tsx +283 -55
- package/src/HoverCard/HoverCard.tsx +5 -13
- package/src/HoverCard/useHoverCard.tsx +8 -11
- package/src/InputGroup/groupStyles.ts +7 -8
- package/src/Item/Item.doc.mjs +4 -0
- package/src/Layer/layerHost.test.ts +99 -0
- package/src/Layer/layerHost.ts +141 -0
- package/src/Layer/useLayer.doc.mjs +14 -4
- package/src/Layer/useLayer.test.tsx +332 -7
- package/src/Layer/useLayer.tsx +235 -36
- package/src/MobileNav/MobileNav.doc.mjs +7 -0
- package/src/NavItem/navItemStyles.stylex.ts +31 -5
- package/src/RadioList/RadioListItem.tsx +20 -0
- package/src/SideNav/SideNav.doc.mjs +13 -4
- package/src/SideNav/SideNav.test.tsx +858 -2
- package/src/SideNav/SideNav.tsx +37 -16
- package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
- package/src/SideNav/SideNavCollapseButton.tsx +67 -15
- package/src/SideNav/SideNavCollapseContext.ts +29 -9
- package/src/SideNav/SideNavHeading.tsx +53 -25
- package/src/SideNav/SideNavItem.doc.mjs +13 -0
- package/src/SideNav/SideNavItem.tsx +97 -84
- package/src/SideNav/SideNavSection.tsx +6 -20
- package/src/SideNav/index.ts +2 -0
- package/src/Slider/Slider.test.tsx +69 -33
- package/src/Slider/Slider.tsx +65 -13
- package/src/Switch/Switch.tsx +20 -0
- package/src/TabList/TabList.doc.mjs +3 -0
- package/src/Text/Text.doc.mjs +1 -1
- package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
- package/src/Thumbnail/Thumbnail.tsx +10 -0
- package/src/Timestamp/Timestamp.test.tsx +40 -22
- package/src/Toolbar/Toolbar.test.tsx +14 -14
- package/src/TopNav/TopNav.test.tsx +86 -1
- package/src/TopNav/TopNavHeading.tsx +21 -14
- package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
- package/src/TopNav/TopNavMegaMenu.tsx +29 -105
- package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
- package/src/TopNav/TopNavMenu.test.tsx +41 -0
- package/src/TopNav/TopNavMenu.tsx +14 -4
- package/src/TreeList/TreeList.doc.mjs +1 -0
- package/src/hooks/index.ts +5 -1
- package/src/hooks/useFocusTrap.test.tsx +16 -0
- package/src/hooks/useFocusTrap.ts +20 -2
- package/src/hooks/useMenuHover.test.tsx +364 -0
- package/src/hooks/useMenuHover.ts +243 -47
- package/src/theme/defineTheme.ts +9 -0
- package/src/theme/derivedVarRegistry.test.ts +78 -12
- package/src/theme/derivedVarRegistry.ts +4 -0
- package/src/theme/expandColorScale.ts +1 -0
- package/src/theme/expandMotionScale.ts +1 -0
- package/src/theme/expandRadiusScale.ts +1 -0
- package/src/theme/expandTypeScale.ts +1 -0
- package/src/theme/themingTargets.test.ts +152 -26
- package/src/theme/tokens.stylex.ts +3 -1
- package/src/theme/tokens.test.ts +12 -0
- package/src/theme/useTheme.test.tsx +18 -0
|
@@ -4,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
|
|
9
|
+
* @position Internal hook; used by nav heading components, TopNavMenu,
|
|
10
|
+
* TopNavMegaMenu and DropdownMenuSubMenu
|
|
10
11
|
*
|
|
11
|
-
* Hover as a progressive enhancement
|
|
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
|
-
*
|
|
14
|
-
* arrow keys navigate menu items.
|
|
16
|
+
* Three behaviors here are not obvious and are load-bearing:
|
|
15
17
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
+
/** Menu was hover-opened, so mouseleave may close it. */
|
|
72
150
|
const hoverModeRef = useRef(false);
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
128
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/theme/defineTheme.ts
CHANGED
|
@@ -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-*,
|
|
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
|
-
//
|
|
266
|
-
// (
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
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']},
|
|
@@ -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
|
// =============================================================================
|