@slithy/base-ui 0.1.0 → 0.2.0
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 +18 -0
- package/README.md +103 -128
- package/dist/index.d.ts +118 -35
- package/dist/index.js +911 -424
- package/package.json +3 -2
- package/src/Dropdown/Dropdown.test.tsx +361 -186
- package/src/Dropdown/Dropdown.tsx +353 -349
- package/src/Dropdown/DropdownRenderer.tsx +118 -0
- package/src/Dropdown/DropdownStore.ts +147 -0
- package/src/Dropdown/index.ts +1 -0
- package/src/Tooltip/Tooltip.test.tsx +221 -212
- package/src/Tooltip/Tooltip.tsx +274 -201
- package/src/Tooltip/TooltipRenderer.tsx +137 -0
- package/src/Tooltip/TooltipStore.ts +142 -0
- package/src/Tooltip/index.ts +2 -1
- package/src/index.ts +2 -2
- package/src/useCloseCleanup.ts +60 -0
- package/src/useSafePolygon.ts +144 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Menu } from "@base-ui/react/menu";
|
|
2
1
|
import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
|
|
2
|
+
import { Menu } from "@base-ui/react/menu";
|
|
3
3
|
import {
|
|
4
4
|
cloneElement,
|
|
5
5
|
createContext,
|
|
@@ -8,410 +8,414 @@ import {
|
|
|
8
8
|
useContext,
|
|
9
9
|
useEffect,
|
|
10
10
|
useRef,
|
|
11
|
-
useState,
|
|
12
11
|
type ComponentPropsWithoutRef,
|
|
13
12
|
type ReactElement,
|
|
14
13
|
type ReactNode,
|
|
15
|
-
type RefObject,
|
|
16
14
|
} from "react";
|
|
17
|
-
import "./
|
|
15
|
+
import { useDropdownStore, type DropdownCallbacks, type DropdownMenuConfig, type DropdownPositionConfig } from "./DropdownStore";
|
|
16
|
+
import { useTooltipStore } from "../Tooltip/TooltipStore";
|
|
18
17
|
|
|
19
18
|
/* ------------------------------------------------------------------ */
|
|
20
|
-
/*
|
|
21
|
-
/* until the user actually interacts with the trigger. */
|
|
19
|
+
/* Context — connects Trigger to Portal content */
|
|
22
20
|
/* ------------------------------------------------------------------ */
|
|
23
21
|
|
|
24
|
-
interface
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
interface DropdownContext {
|
|
23
|
+
contentRef: React.RefObject<ReactNode | null>;
|
|
24
|
+
triggerRef: React.RefObject<HTMLElement | null>;
|
|
25
|
+
disabled: boolean;
|
|
26
|
+
openOn: "click" | "pointerdown";
|
|
27
|
+
callbacks: DropdownCallbacks;
|
|
28
|
+
menuConfig: DropdownMenuConfig;
|
|
29
|
+
positionConfig: DropdownPositionConfig;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
const
|
|
32
|
+
const DropdownCtx = createContext<DropdownContext | null>(null);
|
|
33
33
|
|
|
34
34
|
/* ------------------------------------------------------------------ */
|
|
35
|
-
/* Root —
|
|
35
|
+
/* Root — context provider */
|
|
36
36
|
/* ------------------------------------------------------------------ */
|
|
37
37
|
|
|
38
|
-
type RootProps =
|
|
38
|
+
type RootProps = {
|
|
39
39
|
children?: ReactNode;
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
/** Controlled open state. */
|
|
41
|
+
open?: boolean;
|
|
42
|
+
/** Open on first render (uncontrolled). */
|
|
43
|
+
defaultOpen?: boolean;
|
|
43
44
|
disabled?: boolean;
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
/** Whether the menu is modal (locks scroll, inerts page). @default true */
|
|
46
|
+
modal?: boolean;
|
|
47
|
+
/** Fire the open/close toggle on `"click"` (mouseup) or `"pointerdown"` (mousedown). @default "click" */
|
|
48
|
+
openOn?: "click" | "pointerdown";
|
|
49
|
+
/** Wrap keyboard focus from last item back to first (and vice versa). @default true */
|
|
50
|
+
loopFocus?: boolean;
|
|
51
|
+
/** Highlight items on pointer hover (`data-highlighted`). @default true */
|
|
52
|
+
highlightItemOnHover?: boolean;
|
|
53
|
+
/** Arrow key direction: `"vertical"` (up/down) or `"horizontal"` (left/right). @default "vertical" */
|
|
54
|
+
orientation?: "vertical" | "horizontal";
|
|
55
|
+
/** Which side of the trigger to place the popup. @default "bottom" */
|
|
56
|
+
side?: DropdownPositionConfig["side"];
|
|
57
|
+
/** Distance between trigger and popup in pixels. @default 4 */
|
|
58
|
+
sideOffset?: number;
|
|
59
|
+
/** Alignment relative to the trigger. @default "center" */
|
|
60
|
+
align?: DropdownPositionConfig["align"];
|
|
61
|
+
/** Offset along the alignment axis in pixels. @default 0 */
|
|
62
|
+
alignOffset?: number;
|
|
63
|
+
/** Padding from viewport edges for collision detection. @default 5 */
|
|
64
|
+
collisionPadding?: DropdownPositionConfig["collisionPadding"];
|
|
65
|
+
onOpenChange?: (open: boolean) => void;
|
|
66
|
+
onOpenChangeComplete?: (open: boolean) => void;
|
|
47
67
|
};
|
|
48
68
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
onDeactivate: () => void;
|
|
57
|
-
onMenuOpenChange: (open: boolean) => void;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
function InnerRoot({
|
|
61
|
-
children,
|
|
62
|
-
defaultOpen,
|
|
63
|
-
shouldOpenRef,
|
|
64
|
-
unmountOnClose,
|
|
65
|
-
onDeactivate,
|
|
66
|
-
onMenuOpenChange,
|
|
67
|
-
onOpenChange,
|
|
68
|
-
onOpenChangeComplete,
|
|
69
|
-
...props
|
|
70
|
-
}: InnerRootProps) {
|
|
71
|
-
// When activated by click/keyboard (shouldOpenRef), we need the menu to open
|
|
72
|
-
// with a CSS transition. Base UI's useTransitionStatus skips `data-starting-style`
|
|
73
|
-
// when `defaultOpen` is true (open is already true on first render). So instead
|
|
74
|
-
// we use controlled `open`: mount closed, then open after one frame so
|
|
75
|
-
// `data-starting-style` applies and the enter transition plays.
|
|
76
|
-
const shouldOpen = useRef(false);
|
|
77
|
-
const [deferredOpen, setDeferredOpen] = useState(false);
|
|
78
|
-
|
|
79
|
-
if (!shouldOpen.current && shouldOpenRef.current) {
|
|
80
|
-
shouldOpenRef.current = false;
|
|
81
|
-
shouldOpen.current = true;
|
|
82
|
-
}
|
|
69
|
+
function Root({ children, open, defaultOpen, disabled = false, modal, openOn = "click", loopFocus, highlightItemOnHover, orientation, side, sideOffset, align, alignOffset, collisionPadding, onOpenChange, onOpenChangeComplete }: RootProps) {
|
|
70
|
+
const contentRef = useRef<ReactNode | null>(null);
|
|
71
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
72
|
+
const callbacks: DropdownCallbacks = { onOpenChange, onOpenChangeComplete };
|
|
73
|
+
const menuConfig: DropdownMenuConfig = { modal, loopFocus, highlightItemOnHover, orientation };
|
|
74
|
+
const positionConfig: DropdownPositionConfig = { side, sideOffset, align, alignOffset, collisionPadding };
|
|
75
|
+
const initializedRef = useRef(false);
|
|
83
76
|
|
|
77
|
+
// Controlled open: sync store with prop
|
|
84
78
|
useEffect(() => {
|
|
85
|
-
if (
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// When controlled externally, pass through directly.
|
|
96
|
-
// When triggered by shouldOpenRef, use the deferred controlled open.
|
|
97
|
-
// Otherwise, use defaultOpen (uncontrolled).
|
|
98
|
-
const menuProps: Record<string, unknown> = {};
|
|
99
|
-
if (externalOpen !== undefined) {
|
|
100
|
-
menuProps.open = externalOpen;
|
|
101
|
-
} else if (shouldOpen.current) {
|
|
102
|
-
menuProps.open = deferredOpen;
|
|
103
|
-
} else if (defaultOpen !== undefined) {
|
|
104
|
-
menuProps.defaultOpen = defaultOpen;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return (
|
|
108
|
-
<Menu.Root
|
|
109
|
-
{...restProps}
|
|
110
|
-
{...menuProps}
|
|
111
|
-
onOpenChange={(isOpen, event) => {
|
|
112
|
-
onOpenChange?.(isOpen, event);
|
|
113
|
-
onMenuOpenChange(isOpen);
|
|
114
|
-
if (shouldOpen.current) setDeferredOpen(isOpen);
|
|
115
|
-
}}
|
|
116
|
-
onOpenChangeComplete={(isOpen) => {
|
|
117
|
-
onOpenChangeComplete?.(isOpen);
|
|
118
|
-
if (!isOpen && unmountOnClose) onDeactivate();
|
|
119
|
-
}}
|
|
120
|
-
>
|
|
121
|
-
{children}
|
|
122
|
-
</Menu.Root>
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function Root({
|
|
127
|
-
children,
|
|
128
|
-
open,
|
|
129
|
-
defaultOpen,
|
|
130
|
-
disabled,
|
|
131
|
-
unmountOnClose = false,
|
|
132
|
-
onOpenChangeComplete,
|
|
133
|
-
...props
|
|
134
|
-
}: RootProps) {
|
|
135
|
-
const [wasActivated, setWasActivated] = useState(false);
|
|
136
|
-
const [menuOpen, setMenuOpen] = useState(false);
|
|
137
|
-
const shouldRefocusRef = useRef(false);
|
|
138
|
-
const shouldOpenRef = useRef(false);
|
|
139
|
-
|
|
140
|
-
// One-way latch: once true, stays true so leave animations can play.
|
|
141
|
-
// Controlled `open` or `defaultOpen` bypass the latch entirely.
|
|
142
|
-
// When unmountOnClose is true, the latch resets after close completes.
|
|
143
|
-
const activated = wasActivated || !!open || !!defaultOpen;
|
|
144
|
-
|
|
145
|
-
const activate = useCallback(() => {
|
|
146
|
-
if (!disabled) {
|
|
147
|
-
// If activation is from a click/keyboard (shouldOpenRef), the menu will
|
|
148
|
-
// open immediately — preemptively mark menuOpen so the tooltip is
|
|
149
|
-
// suppressed from the first render (no flicker).
|
|
150
|
-
if (shouldOpenRef.current) setMenuOpen(true);
|
|
151
|
-
setWasActivated(true);
|
|
79
|
+
if (open === undefined) return;
|
|
80
|
+
const store = useDropdownStore.getState();
|
|
81
|
+
if (open && !store.open) {
|
|
82
|
+
const content = contentRef.current;
|
|
83
|
+
const anchor = triggerRef.current;
|
|
84
|
+
if (content && anchor) {
|
|
85
|
+
store.openDropdown(content, anchor, { callbacks, menuConfig, positionConfig });
|
|
86
|
+
}
|
|
87
|
+
} else if (!open && store.open && store.anchor === triggerRef.current) {
|
|
88
|
+
store.closeDropdown();
|
|
152
89
|
}
|
|
153
|
-
}, [
|
|
90
|
+
}, [open]);
|
|
154
91
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
92
|
+
// defaultOpen: open on first render
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (initializedRef.current || defaultOpen === undefined || !defaultOpen) return;
|
|
95
|
+
initializedRef.current = true;
|
|
96
|
+
// Defer to next frame so Portal has registered content and Trigger has mounted
|
|
97
|
+
requestAnimationFrame(() => {
|
|
98
|
+
const content = contentRef.current;
|
|
99
|
+
const anchor = triggerRef.current;
|
|
100
|
+
if (content && anchor) {
|
|
101
|
+
useDropdownStore.getState().openDropdown(content, anchor, { callbacks, menuConfig, positionConfig });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}, [defaultOpen]);
|
|
105
|
+
|
|
106
|
+
const ctx: DropdownContext = {
|
|
107
|
+
contentRef,
|
|
108
|
+
triggerRef,
|
|
109
|
+
disabled,
|
|
110
|
+
openOn,
|
|
111
|
+
callbacks,
|
|
112
|
+
menuConfig,
|
|
113
|
+
positionConfig,
|
|
166
114
|
};
|
|
167
115
|
|
|
168
|
-
if (!activated) {
|
|
169
|
-
return (
|
|
170
|
-
<DeferredContext.Provider value={ctx}>
|
|
171
|
-
{children}
|
|
172
|
-
</DeferredContext.Provider>
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
116
|
return (
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
defaultOpen={defaultOpen}
|
|
181
|
-
disabled={disabled}
|
|
182
|
-
shouldOpenRef={shouldOpenRef}
|
|
183
|
-
unmountOnClose={unmountOnClose}
|
|
184
|
-
onDeactivate={deactivate}
|
|
185
|
-
onMenuOpenChange={setMenuOpen}
|
|
186
|
-
onOpenChangeComplete={onOpenChangeComplete}
|
|
187
|
-
{...props}
|
|
188
|
-
>
|
|
189
|
-
{children}
|
|
190
|
-
</InnerRoot>
|
|
191
|
-
</DeferredContext.Provider>
|
|
117
|
+
<DropdownCtx.Provider value={ctx}>
|
|
118
|
+
{children}
|
|
119
|
+
</DropdownCtx.Provider>
|
|
192
120
|
);
|
|
193
121
|
}
|
|
194
122
|
|
|
195
123
|
/* ------------------------------------------------------------------ */
|
|
196
|
-
/* Trigger —
|
|
124
|
+
/* Trigger — plain <button>, zero Base UI overhead */
|
|
197
125
|
/* ------------------------------------------------------------------ */
|
|
198
126
|
|
|
199
|
-
type TriggerProps =
|
|
200
|
-
|
|
127
|
+
type TriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
128
|
+
children?: ReactNode;
|
|
129
|
+
/** Replace the default `<button>` with a custom element. */
|
|
130
|
+
render?: ReactElement | ((props: Record<string, unknown>) => ReactElement);
|
|
131
|
+
/** Tooltip content shown on hover/focus. Dismissed when the dropdown opens. */
|
|
201
132
|
tooltip?: ReactNode;
|
|
133
|
+
/** Delay before opening the tooltip in ms. @default 600 */
|
|
134
|
+
tooltipDelay?: number;
|
|
202
135
|
};
|
|
203
136
|
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
"
|
|
208
|
-
"delay",
|
|
209
|
-
"closeDelay",
|
|
210
|
-
"openOnHover",
|
|
211
|
-
"render",
|
|
212
|
-
"tooltip",
|
|
213
|
-
]);
|
|
214
|
-
|
|
215
|
-
function pickHtmlProps(props: Record<string, unknown>) {
|
|
216
|
-
const html: Record<string, unknown> = {};
|
|
217
|
-
for (const key in props) {
|
|
218
|
-
if (!BASE_UI_KEYS.has(key)) html[key] = props[key];
|
|
219
|
-
}
|
|
220
|
-
return html;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function Trigger({
|
|
224
|
-
children,
|
|
225
|
-
className,
|
|
226
|
-
style,
|
|
227
|
-
disabled,
|
|
228
|
-
tooltip,
|
|
229
|
-
...rest
|
|
230
|
-
}: TriggerProps) {
|
|
231
|
-
const deferred = useContext(DeferredContext);
|
|
232
|
-
const triggerElRef = useRef<HTMLButtonElement | null>(null);
|
|
233
|
-
|
|
234
|
-
// --- Tooltip latch (independent of dropdown latch) ---
|
|
235
|
-
const [tooltipActivated, setTooltipActivated] = useState(false);
|
|
236
|
-
const isHoveringRef = useRef(false);
|
|
237
|
-
const pendingRef = useRef(0);
|
|
238
|
-
const lastPointerTypeRef = useRef("");
|
|
137
|
+
function Trigger({ children, onClick, onPointerDown, onPointerEnter, onPointerLeave, onFocus, onBlur, onKeyDown, render, tooltip, tooltipDelay = 600, ...props }: TriggerProps) {
|
|
138
|
+
const ctx = useContext(DropdownCtx);
|
|
139
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
140
|
+
const openOn = ctx?.openOn ?? "click";
|
|
239
141
|
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
const shouldRefocusRef = deferred?.shouldRefocusRef;
|
|
243
|
-
const triggerRef = useCallback(
|
|
142
|
+
// Register trigger element with Root for controlled/defaultOpen
|
|
143
|
+
const setTriggerRef = useCallback(
|
|
244
144
|
(el: HTMLButtonElement | null) => {
|
|
245
|
-
|
|
246
|
-
if (
|
|
247
|
-
shouldRefocusRef.current = false;
|
|
248
|
-
el.focus();
|
|
249
|
-
}
|
|
145
|
+
triggerRef.current = el;
|
|
146
|
+
if (ctx) ctx.triggerRef.current = el;
|
|
250
147
|
},
|
|
251
|
-
[
|
|
148
|
+
[ctx],
|
|
252
149
|
);
|
|
150
|
+
const storeState = useDropdownStore((s) => ({
|
|
151
|
+
open: s.open,
|
|
152
|
+
anchor: s.anchor,
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
// Is THIS trigger the one that opened the current dropdown?
|
|
156
|
+
const isActive = storeState.open && storeState.anchor === triggerRef.current;
|
|
157
|
+
|
|
158
|
+
/* ---- Tooltip integration ---- */
|
|
253
159
|
|
|
254
|
-
|
|
255
|
-
|
|
160
|
+
const tooltipOpenTimerRef = useRef(0);
|
|
161
|
+
const tooltipCloseTimerRef = useRef(0);
|
|
162
|
+
const lastPointerTypeRef = useRef("");
|
|
163
|
+
|
|
164
|
+
// Subscribe to tooltip store for aria-describedby
|
|
165
|
+
const tooltipState = useTooltipStore((s) => ({
|
|
166
|
+
open: s.open,
|
|
167
|
+
anchor: s.anchor,
|
|
168
|
+
popupId: s.popupId,
|
|
169
|
+
}));
|
|
170
|
+
const isTooltipActive = tooltip != null && tooltipState.open && tooltipState.anchor === triggerRef.current;
|
|
171
|
+
|
|
172
|
+
// Skip focus-triggered tooltip after dropdown close.
|
|
173
|
+
// DropdownRenderer restores focus via RAF; by that time the menu DOM is unmounted
|
|
174
|
+
// so relatedTarget is null/orphaned — we can't rely on DOM checks.
|
|
175
|
+
const wasActiveRef = useRef(false);
|
|
176
|
+
const skipFocusTooltipRef = useRef(false);
|
|
256
177
|
useEffect(() => {
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const tooltipHoverHandlers = tooltip
|
|
276
|
-
? {
|
|
277
|
-
onMouseEnter: () => {
|
|
278
|
-
if (lastPointerTypeRef.current === "touch") return;
|
|
279
|
-
isHoveringRef.current = true;
|
|
280
|
-
pendingRef.current = requestAnimationFrame(() => {
|
|
281
|
-
if (isHoveringRef.current) setTooltipActivated(true);
|
|
282
|
-
});
|
|
283
|
-
},
|
|
284
|
-
onMouseLeave: () => {
|
|
285
|
-
isHoveringRef.current = false;
|
|
286
|
-
cancelAnimationFrame(pendingRef.current);
|
|
287
|
-
},
|
|
288
|
-
}
|
|
289
|
-
: {};
|
|
290
|
-
|
|
291
|
-
// Force tooltip closed when the dropdown menu is open
|
|
292
|
-
const menuOpen = deferred?.menuOpen ?? false;
|
|
293
|
-
|
|
294
|
-
// Tooltip portal content (shared across states 2 and 4)
|
|
295
|
-
const tooltipPortal = !!tooltip ? (
|
|
296
|
-
<BaseTooltip.Portal>
|
|
297
|
-
<BaseTooltip.Positioner sideOffset={8}>
|
|
298
|
-
<BaseTooltip.Popup className="slithy-tooltip-popup">
|
|
299
|
-
{tooltip}
|
|
300
|
-
</BaseTooltip.Popup>
|
|
301
|
-
</BaseTooltip.Positioner>
|
|
302
|
-
</BaseTooltip.Portal>
|
|
303
|
-
) : null;
|
|
304
|
-
|
|
305
|
-
// --- Pre-activation (dropdown not yet activated) ---
|
|
306
|
-
if (deferred && !deferred.activated) {
|
|
307
|
-
const { render: renderProp } = rest;
|
|
308
|
-
const htmlProps = pickHtmlProps(rest);
|
|
309
|
-
const callHandler = (handler: unknown, event: unknown) => {
|
|
310
|
-
if (typeof handler === "function") handler(event);
|
|
311
|
-
};
|
|
178
|
+
if (wasActiveRef.current && !isActive) {
|
|
179
|
+
skipFocusTooltipRef.current = true;
|
|
180
|
+
const id = requestAnimationFrame(() => {
|
|
181
|
+
skipFocusTooltipRef.current = false;
|
|
182
|
+
});
|
|
183
|
+
return () => cancelAnimationFrame(id);
|
|
184
|
+
}
|
|
185
|
+
wasActiveRef.current = isActive;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const dismissTooltip = useCallback(() => {
|
|
189
|
+
clearTimeout(tooltipOpenTimerRef.current);
|
|
190
|
+
clearTimeout(tooltipCloseTimerRef.current);
|
|
191
|
+
const ts = useTooltipStore.getState();
|
|
192
|
+
if (ts.open && ts.anchor === triggerRef.current) {
|
|
193
|
+
ts.closeTooltip({ skipWarmUp: true });
|
|
194
|
+
}
|
|
195
|
+
}, []);
|
|
312
196
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
callHandler(htmlProps.onClick, e);
|
|
336
|
-
deferred.shouldOpenRef.current = true;
|
|
337
|
-
deferred.shouldRefocusRef.current = true;
|
|
338
|
-
deferred.activate();
|
|
339
|
-
},
|
|
340
|
-
onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
341
|
-
callHandler(htmlProps.onKeyDown, e);
|
|
342
|
-
if (
|
|
343
|
-
e.key === "Enter" ||
|
|
344
|
-
e.key === " " ||
|
|
345
|
-
e.key === "ArrowDown" ||
|
|
346
|
-
e.key === "ArrowUp"
|
|
347
|
-
) {
|
|
348
|
-
deferred.shouldOpenRef.current = true;
|
|
349
|
-
deferred.shouldRefocusRef.current = true;
|
|
350
|
-
deferred.activate();
|
|
351
|
-
}
|
|
352
|
-
},
|
|
353
|
-
};
|
|
197
|
+
const scheduleTooltip = useCallback(() => {
|
|
198
|
+
if (!tooltip || !triggerRef.current) return;
|
|
199
|
+
|
|
200
|
+
// Don't show tooltip while any dropdown is open
|
|
201
|
+
const ds = useDropdownStore.getState();
|
|
202
|
+
if (ds.open) return;
|
|
203
|
+
|
|
204
|
+
clearTimeout(tooltipOpenTimerRef.current);
|
|
205
|
+
clearTimeout(tooltipCloseTimerRef.current);
|
|
206
|
+
|
|
207
|
+
const ts = useTooltipStore.getState();
|
|
208
|
+
|
|
209
|
+
// Already showing this tooltip
|
|
210
|
+
if (ts.open && ts.anchor === triggerRef.current) return;
|
|
211
|
+
|
|
212
|
+
const tooltipContent = <BaseTooltip.Popup className="slithy-tooltip-popup">{tooltip}</BaseTooltip.Popup>;
|
|
213
|
+
|
|
214
|
+
// Instant switch or warm-up
|
|
215
|
+
const isSwitch = ts.open && ts.anchor !== triggerRef.current;
|
|
216
|
+
const elapsed = Date.now() - ts.lastCloseTime;
|
|
217
|
+
const isWarmUp = !isSwitch && elapsed < 300;
|
|
218
|
+
const effectiveDelay = isSwitch || isWarmUp ? 0 : tooltipDelay;
|
|
354
219
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
button = cloneElement(renderProp, preProps, children) as ReactElement;
|
|
358
|
-
} else if (typeof renderProp === "function") {
|
|
359
|
-
button = renderProp({ ...preProps, children } as Parameters<typeof renderProp>[0], {} as Parameters<typeof renderProp>[1]);
|
|
220
|
+
if (effectiveDelay === 0) {
|
|
221
|
+
ts.openTooltip(tooltipContent, triggerRef.current!);
|
|
360
222
|
} else {
|
|
361
|
-
|
|
223
|
+
tooltipOpenTimerRef.current = window.setTimeout(() => {
|
|
224
|
+
// Re-check: a dropdown may have opened during the delay
|
|
225
|
+
const dds = useDropdownStore.getState();
|
|
226
|
+
if (dds.open) return;
|
|
227
|
+
if (triggerRef.current) {
|
|
228
|
+
const content = <BaseTooltip.Popup className="slithy-tooltip-popup">{tooltip}</BaseTooltip.Popup>;
|
|
229
|
+
useTooltipStore.getState().openTooltip(content, triggerRef.current);
|
|
230
|
+
}
|
|
231
|
+
}, effectiveDelay);
|
|
362
232
|
}
|
|
233
|
+
}, [tooltip, tooltipDelay]);
|
|
234
|
+
|
|
235
|
+
const scheduleTooltipClose = useCallback(() => {
|
|
236
|
+
clearTimeout(tooltipOpenTimerRef.current);
|
|
237
|
+
const ts = useTooltipStore.getState();
|
|
238
|
+
if (!ts.open || ts.anchor !== triggerRef.current) return;
|
|
239
|
+
tooltipCloseTimerRef.current = window.setTimeout(() => {
|
|
240
|
+
const s = useTooltipStore.getState();
|
|
241
|
+
if (s.open && s.anchor === triggerRef.current) {
|
|
242
|
+
s.closeTooltip();
|
|
243
|
+
}
|
|
244
|
+
}, 300);
|
|
245
|
+
}, []);
|
|
363
246
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
<BaseTooltip.Trigger render={button} />
|
|
372
|
-
{!menuOpen && tooltipPortal}
|
|
373
|
-
</BaseTooltip.Root>
|
|
374
|
-
</BaseTooltip.Provider>
|
|
375
|
-
);
|
|
376
|
-
}
|
|
247
|
+
// Clear timers on unmount
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
return () => {
|
|
250
|
+
clearTimeout(tooltipOpenTimerRef.current);
|
|
251
|
+
clearTimeout(tooltipCloseTimerRef.current);
|
|
252
|
+
};
|
|
253
|
+
}, []);
|
|
377
254
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
255
|
+
/* ---- Dropdown logic ---- */
|
|
256
|
+
|
|
257
|
+
const toggleDropdown = useCallback(
|
|
258
|
+
(options?: { keyboard?: boolean }) => {
|
|
259
|
+
if (ctx?.disabled) return;
|
|
260
|
+
|
|
261
|
+
// Dismiss tooltip when opening the dropdown
|
|
262
|
+
dismissTooltip();
|
|
263
|
+
|
|
264
|
+
const store = useDropdownStore.getState();
|
|
265
|
+
|
|
266
|
+
// Toggle: if this trigger's dropdown is open, close it
|
|
267
|
+
if (store.open && store.anchor === triggerRef.current) {
|
|
268
|
+
store.closeDropdown();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Open with registered content
|
|
273
|
+
const content = ctx?.contentRef.current;
|
|
274
|
+
if (content && triggerRef.current) {
|
|
275
|
+
store.openDropdown(content, triggerRef.current, {
|
|
276
|
+
keyboard: options?.keyboard,
|
|
277
|
+
callbacks: ctx?.callbacks,
|
|
278
|
+
menuConfig: ctx?.menuConfig,
|
|
279
|
+
positionConfig: ctx?.positionConfig,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
[ctx, dismissTooltip],
|
|
389
284
|
);
|
|
390
285
|
|
|
391
|
-
|
|
392
|
-
|
|
286
|
+
const handlePointerDown = useCallback(
|
|
287
|
+
(e: React.PointerEvent<HTMLButtonElement>) => {
|
|
288
|
+
lastPointerTypeRef.current = e.pointerType;
|
|
289
|
+
onPointerDown?.(e);
|
|
290
|
+
if (e.defaultPrevented || openOn !== "pointerdown") return;
|
|
291
|
+
toggleDropdown();
|
|
292
|
+
},
|
|
293
|
+
[onPointerDown, openOn, toggleDropdown],
|
|
294
|
+
);
|
|
393
295
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
296
|
+
const handleClick = useCallback(
|
|
297
|
+
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
298
|
+
onClick?.(e);
|
|
299
|
+
if (e.defaultPrevented) return;
|
|
300
|
+
if (openOn === "click") {
|
|
301
|
+
toggleDropdown();
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
[onClick, openOn, toggleDropdown],
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const handlePointerEnter = useCallback(
|
|
308
|
+
(e: React.PointerEvent<HTMLButtonElement>) => {
|
|
309
|
+
onPointerEnter?.(e);
|
|
310
|
+
if (e.defaultPrevented) return;
|
|
311
|
+
if (lastPointerTypeRef.current === "touch") return;
|
|
312
|
+
scheduleTooltip();
|
|
313
|
+
},
|
|
314
|
+
[onPointerEnter, scheduleTooltip],
|
|
402
315
|
);
|
|
316
|
+
|
|
317
|
+
const handlePointerLeave = useCallback(
|
|
318
|
+
(e: React.PointerEvent<HTMLButtonElement>) => {
|
|
319
|
+
onPointerLeave?.(e);
|
|
320
|
+
if (e.defaultPrevented) return;
|
|
321
|
+
scheduleTooltipClose();
|
|
322
|
+
},
|
|
323
|
+
[onPointerLeave, scheduleTooltipClose],
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const handleFocus = useCallback(
|
|
327
|
+
(e: React.FocusEvent<HTMLButtonElement>) => {
|
|
328
|
+
onFocus?.(e);
|
|
329
|
+
if (e.defaultPrevented) return;
|
|
330
|
+
// Skip tooltip when focus returns from closing dropdown menu
|
|
331
|
+
if (skipFocusTooltipRef.current) {
|
|
332
|
+
skipFocusTooltipRef.current = false;
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (lastPointerTypeRef.current === "touch") return;
|
|
336
|
+
scheduleTooltip();
|
|
337
|
+
},
|
|
338
|
+
[onFocus, scheduleTooltip],
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const handleBlur = useCallback(
|
|
342
|
+
(e: React.FocusEvent<HTMLButtonElement>) => {
|
|
343
|
+
onBlur?.(e);
|
|
344
|
+
if (e.defaultPrevented) return;
|
|
345
|
+
dismissTooltip();
|
|
346
|
+
},
|
|
347
|
+
[onBlur, dismissTooltip],
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const handleKeyDown = useCallback(
|
|
351
|
+
(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
352
|
+
onKeyDown?.(e);
|
|
353
|
+
if (ctx?.disabled || e.defaultPrevented) return;
|
|
354
|
+
|
|
355
|
+
if (
|
|
356
|
+
e.key === "Enter" ||
|
|
357
|
+
e.key === " " ||
|
|
358
|
+
e.key === "ArrowDown" ||
|
|
359
|
+
e.key === "ArrowUp"
|
|
360
|
+
) {
|
|
361
|
+
e.preventDefault();
|
|
362
|
+
toggleDropdown({ keyboard: true });
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
[ctx, onKeyDown, toggleDropdown],
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const triggerProps = {
|
|
369
|
+
ref: setTriggerRef,
|
|
370
|
+
type: "button" as const,
|
|
371
|
+
"aria-haspopup": "menu" as const,
|
|
372
|
+
"aria-expanded": isActive,
|
|
373
|
+
"aria-describedby": isTooltipActive && tooltipState.popupId ? tooltipState.popupId : undefined,
|
|
374
|
+
onPointerDown: handlePointerDown,
|
|
375
|
+
onClick: handleClick,
|
|
376
|
+
onPointerEnter: handlePointerEnter,
|
|
377
|
+
onPointerLeave: handlePointerLeave,
|
|
378
|
+
onFocus: handleFocus,
|
|
379
|
+
onBlur: handleBlur,
|
|
380
|
+
onKeyDown: handleKeyDown,
|
|
381
|
+
...props,
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
if (isValidElement(render)) {
|
|
385
|
+
return cloneElement(render, triggerProps, children) as ReactElement;
|
|
386
|
+
}
|
|
387
|
+
if (typeof render === "function") {
|
|
388
|
+
return render({ ...triggerProps, children });
|
|
389
|
+
}
|
|
390
|
+
return <button {...triggerProps}>{children}</button>;
|
|
403
391
|
}
|
|
404
392
|
|
|
405
393
|
/* ------------------------------------------------------------------ */
|
|
406
|
-
/* Portal —
|
|
394
|
+
/* Portal — captures children, does not render them */
|
|
407
395
|
/* ------------------------------------------------------------------ */
|
|
408
396
|
|
|
409
|
-
type PortalProps =
|
|
397
|
+
type PortalProps = {
|
|
398
|
+
children?: ReactNode;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
function Portal({ children }: PortalProps) {
|
|
402
|
+
const ctx = useContext(DropdownCtx);
|
|
403
|
+
const storeAnchor = useDropdownStore((s) => s.anchor);
|
|
404
|
+
|
|
405
|
+
if (ctx) {
|
|
406
|
+
ctx.contentRef.current = children;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// If this dropdown is currently active, push content updates to the store
|
|
410
|
+
// so checkbox/radio state changes reflect immediately in the popup.
|
|
411
|
+
const isActive = !!(ctx && storeAnchor && storeAnchor === ctx.triggerRef.current);
|
|
412
|
+
useEffect(() => {
|
|
413
|
+
if (isActive) {
|
|
414
|
+
useDropdownStore.getState().updateContent(children);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
410
417
|
|
|
411
|
-
|
|
412
|
-
const deferred = useContext(DeferredContext);
|
|
413
|
-
if (deferred && !deferred.activated) return null;
|
|
414
|
-
return <Menu.Portal {...props} />;
|
|
418
|
+
return null;
|
|
415
419
|
}
|
|
416
420
|
|
|
417
421
|
/* ------------------------------------------------------------------ */
|