@astryxdesign/core 0.4.1 → 0.4.2-canary.356d2f9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/dist/Avatar/Avatar.d.ts +4 -1
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +19 -23
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +7 -2
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +9 -7
- package/dist/Chat/ChatMessage.d.ts +7 -0
- package/dist/Chat/ChatMessage.d.ts.map +1 -1
- package/dist/Chat/ChatMessageBubble.d.ts +13 -1
- package/dist/Chat/ChatMessageBubble.d.ts.map +1 -1
- package/dist/Chat/ChatMessageBubble.js +19 -1
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +5 -0
- package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteFooter.js +5 -3
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +2 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +19 -15
- package/dist/HoverCard/HoverCard.d.ts +1 -1
- package/dist/HoverCard/HoverCard.d.ts.map +1 -1
- package/dist/HoverCard/HoverCard.js +5 -13
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +6 -5
- package/dist/InputGroup/groupStyles.d.ts.map +1 -1
- package/dist/InputGroup/groupStyles.js +7 -2
- package/dist/Layer/layerHost.d.ts +24 -0
- package/dist/Layer/layerHost.d.ts.map +1 -0
- package/dist/Layer/layerHost.js +79 -0
- package/dist/Layer/useLayer.d.ts +14 -6
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +158 -30
- package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
- package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
- package/dist/NavItem/navItemStyles.stylex.js +11 -5
- package/dist/RadioList/RadioListItem.d.ts.map +1 -1
- package/dist/RadioList/RadioListItem.js +5 -0
- package/dist/SideNav/SideNav.d.ts +7 -9
- package/dist/SideNav/SideNav.d.ts.map +1 -1
- package/dist/SideNav/SideNav.js +32 -5
- package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
- package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
- package/dist/SideNav/SideNavCollapseButton.js +39 -15
- package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
- package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
- package/dist/SideNav/SideNavCollapseContext.js +16 -2
- package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
- package/dist/SideNav/SideNavHeading.js +89 -32
- package/dist/SideNav/SideNavItem.d.ts +7 -2
- package/dist/SideNav/SideNavItem.d.ts.map +1 -1
- package/dist/SideNav/SideNavItem.js +119 -75
- package/dist/SideNav/SideNavSection.d.ts.map +1 -1
- package/dist/SideNav/SideNavSection.js +7 -16
- package/dist/SideNav/index.d.ts +1 -1
- package/dist/SideNav/index.d.ts.map +1 -1
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +56 -15
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +5 -0
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +5 -0
- package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
- package/dist/TopNav/TopNavHeading.js +14 -6
- package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMegaMenu.js +25 -93
- package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
- package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMenu.js +10 -5
- package/dist/astryx.css +40 -8
- package/dist/astryx.umd.js +50 -50
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/containerReveal.stylex.d.ts +71 -4
- package/dist/hooks/containerReveal.stylex.d.ts.map +1 -1
- package/dist/hooks/containerReveal.stylex.js +57 -5
- package/dist/hooks/index.d.ts +2 -2
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/useContainerReveal.d.ts +60 -3
- package/dist/hooks/useContainerReveal.d.ts.map +1 -1
- package/dist/hooks/useContainerReveal.js +27 -5
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +16 -2
- package/dist/hooks/useMenuHover.d.ts +50 -5
- package/dist/hooks/useMenuHover.d.ts.map +1 -1
- package/dist/hooks/useMenuHover.js +171 -51
- package/dist/theme/defineTheme.d.ts +16 -5
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +44 -48
- package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
- package/dist/theme/derivedVarRegistry.js +7 -0
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +1 -0
- package/dist/theme/expandMotionScale.d.ts +1 -0
- package/dist/theme/expandMotionScale.d.ts.map +1 -1
- package/dist/theme/expandMotionScale.js +1 -0
- package/dist/theme/expandRadiusScale.d.ts +1 -0
- package/dist/theme/expandRadiusScale.d.ts.map +1 -1
- package/dist/theme/expandRadiusScale.js +1 -0
- package/dist/theme/expandTypeScale.d.ts +1 -0
- package/dist/theme/expandTypeScale.d.ts.map +1 -1
- package/dist/theme/expandTypeScale.js +1 -0
- package/dist/theme/mergeComponents.d.ts +20 -0
- package/dist/theme/mergeComponents.d.ts.map +1 -0
- package/dist/theme/mergeComponents.js +56 -0
- package/dist/theme/onMediaTokens.d.ts +6 -1
- package/dist/theme/onMediaTokens.d.ts.map +1 -1
- package/dist/theme/onMediaTokens.js +11 -3
- package/dist/theme/tokens.stylex.d.ts +3 -1
- package/dist/theme/tokens.stylex.d.ts.map +1 -1
- package/dist/theme/tokens.stylex.js +3 -1
- package/locales/en.json +84 -0
- package/locales/pseudo.json +63 -0
- package/package.json +3 -3
- package/src/Avatar/Avatar.doc.mjs +5 -2
- package/src/Avatar/Avatar.test.tsx +51 -0
- package/src/Avatar/Avatar.tsx +30 -20
- package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
- package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
- package/src/Button/Button.tsx +5 -3
- package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
- package/src/Card/Card.doc.mjs +2 -0
- package/src/Chat/Chat.doc.mjs +4 -1
- package/src/Chat/ChatMessage.doc.mjs +3 -3
- package/src/Chat/ChatMessage.tsx +7 -0
- package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
- package/src/Chat/ChatMessageBubble.test.tsx +95 -6
- package/src/Chat/ChatMessageBubble.tsx +25 -0
- package/src/CheckboxInput/CheckboxInput.tsx +20 -0
- package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
- package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
- package/src/DateTimeInput/DateTimeInput.tsx +4 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
- package/src/HoverCard/HoverCard.doc.mjs +3 -3
- package/src/HoverCard/HoverCard.test.tsx +283 -55
- package/src/HoverCard/HoverCard.tsx +5 -13
- package/src/HoverCard/useHoverCard.tsx +8 -11
- package/src/InputGroup/groupStyles.ts +7 -8
- package/src/Item/Item.doc.mjs +4 -0
- package/src/Layer/layerHost.test.ts +99 -0
- package/src/Layer/layerHost.ts +141 -0
- package/src/Layer/useLayer.doc.mjs +14 -4
- package/src/Layer/useLayer.test.tsx +332 -7
- package/src/Layer/useLayer.tsx +235 -36
- package/src/MobileNav/MobileNav.doc.mjs +7 -0
- package/src/NavItem/navItemStyles.stylex.ts +31 -5
- package/src/RadioList/RadioListItem.tsx +20 -0
- package/src/SideNav/SideNav.doc.mjs +13 -4
- package/src/SideNav/SideNav.test.tsx +858 -2
- package/src/SideNav/SideNav.tsx +37 -16
- package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
- package/src/SideNav/SideNavCollapseButton.tsx +67 -15
- package/src/SideNav/SideNavCollapseContext.ts +29 -9
- package/src/SideNav/SideNavHeading.tsx +53 -25
- package/src/SideNav/SideNavItem.doc.mjs +13 -0
- package/src/SideNav/SideNavItem.tsx +97 -84
- package/src/SideNav/SideNavSection.tsx +6 -20
- package/src/SideNav/index.ts +2 -0
- package/src/Slider/Slider.test.tsx +69 -33
- package/src/Slider/Slider.tsx +65 -13
- package/src/Switch/Switch.tsx +20 -0
- package/src/TabList/TabList.doc.mjs +3 -0
- package/src/Text/Text.doc.mjs +1 -1
- package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
- package/src/Thumbnail/Thumbnail.tsx +10 -0
- package/src/Timestamp/Timestamp.test.tsx +40 -22
- package/src/Toolbar/Toolbar.test.tsx +14 -14
- package/src/TopNav/TopNav.test.tsx +86 -1
- package/src/TopNav/TopNavHeading.tsx +21 -14
- package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
- package/src/TopNav/TopNavMegaMenu.tsx +29 -105
- package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
- package/src/TopNav/TopNavMenu.test.tsx +41 -0
- package/src/TopNav/TopNavMenu.tsx +14 -4
- package/src/TreeList/TreeList.doc.mjs +1 -0
- package/src/hooks/containerReveal.stylex.ts +140 -9
- package/src/hooks/index.ts +6 -1
- package/src/hooks/useContainerReveal.doc.mjs +11 -5
- package/src/hooks/useContainerReveal.test.tsx +70 -3
- package/src/hooks/useContainerReveal.ts +86 -7
- package/src/hooks/useFocusTrap.test.tsx +16 -0
- package/src/hooks/useFocusTrap.ts +20 -2
- package/src/hooks/useMenuHover.test.tsx +364 -0
- package/src/hooks/useMenuHover.ts +243 -47
- package/src/theme/defineTheme.test.ts +127 -0
- package/src/theme/defineTheme.ts +57 -51
- package/src/theme/derivedVarRegistry.test.ts +78 -12
- package/src/theme/derivedVarRegistry.ts +4 -0
- package/src/theme/expandColorScale.ts +1 -0
- package/src/theme/expandMotionScale.ts +1 -0
- package/src/theme/expandRadiusScale.ts +1 -0
- package/src/theme/expandTypeScale.ts +1 -0
- package/src/theme/mergeComponents.ts +59 -0
- package/src/theme/onMediaTokens.ts +9 -2
- package/src/theme/themingTargets.test.ts +152 -26
- package/src/theme/tokens.stylex.ts +3 -1
- package/src/theme/tokens.test.ts +12 -0
- package/src/theme/useTheme.test.tsx +18 -0
|
@@ -4,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
|
}
|
|
@@ -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
|
});
|