@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.
@@ -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 "./Dropdown.css";
15
+ import { useDropdownStore, type DropdownCallbacks, type DropdownMenuConfig, type DropdownPositionConfig } from "./DropdownStore";
16
+ import { useTooltipStore } from "../Tooltip/TooltipStore";
18
17
 
19
18
  /* ------------------------------------------------------------------ */
20
- /* Deferred rendering avoids mounting Base UI hooks/positioning */
21
- /* until the user actually interacts with the trigger. */
19
+ /* Contextconnects Trigger to Portal content */
22
20
  /* ------------------------------------------------------------------ */
23
21
 
24
- interface DeferredState {
25
- activated: boolean;
26
- activate: () => void;
27
- shouldRefocusRef: RefObject<boolean>;
28
- shouldOpenRef: RefObject<boolean>;
29
- menuOpen: boolean;
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 DeferredContext = createContext<DeferredState | null>(null);
32
+ const DropdownCtx = createContext<DropdownContext | null>(null);
33
33
 
34
34
  /* ------------------------------------------------------------------ */
35
- /* Root — owns the activation latch */
35
+ /* Root — context provider */
36
36
  /* ------------------------------------------------------------------ */
37
37
 
38
- type RootProps = ComponentPropsWithoutRef<typeof Menu.Root> & {
38
+ type RootProps = {
39
39
  children?: ReactNode;
40
- /** When `true`, the dropdown will not open. The trigger button remains
41
- * interactive so consumers can attach alternate behavior (e.g. opening
42
- * a modal on mobile) via event handlers on `Dropdown.Trigger`. */
40
+ /** Controlled open state. */
41
+ open?: boolean;
42
+ /** Open on first render (uncontrolled). */
43
+ defaultOpen?: boolean;
43
44
  disabled?: boolean;
44
- /** Unmount Base UI after close animation completes, returning to the
45
- * lightweight pre-activation state. Defaults to `false`. */
46
- unmountOnClose?: boolean;
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
- // InnerRoot mounts fresh when activation flips. Its useState initializer
50
- // runs exactly once, so we can safely read shouldOpenRef to decide the
51
- // initial open state without side-effects during render.
52
- type InnerRootProps = ComponentPropsWithoutRef<typeof Menu.Root> & {
53
- children?: ReactNode;
54
- shouldOpenRef: RefObject<boolean>;
55
- unmountOnClose: boolean;
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 (!shouldOpen.current) return;
86
- const frame = requestAnimationFrame(() => {
87
- setDeferredOpen(true);
88
- });
89
- return () => cancelAnimationFrame(frame);
90
- }, []);
91
-
92
- // Separate `open` from rest of props to avoid key conflicts on Menu.Root.
93
- const { open: externalOpen, ...restProps } = props;
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
- }, [disabled]);
90
+ }, [open]);
154
91
 
155
- const deactivate = useCallback(() => {
156
- setMenuOpen(false);
157
- setWasActivated(false);
158
- }, []);
159
-
160
- const ctx: DeferredState = {
161
- activated,
162
- activate,
163
- shouldRefocusRef,
164
- shouldOpenRef,
165
- menuOpen,
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
- <DeferredContext.Provider value={ctx}>
178
- <InnerRoot
179
- open={open}
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 — lightweight pre-activation, then Base UI */
124
+ /* Trigger — plain <button>, zero Base UI overhead */
197
125
  /* ------------------------------------------------------------------ */
198
126
 
199
- type TriggerProps = ComponentPropsWithoutRef<typeof Menu.Trigger> & {
200
- /** Show a tooltip on hover. Accepts any ReactNode content. */
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
- // Base UI Trigger props that must not leak onto a raw <button>
205
- const BASE_UI_KEYS = new Set([
206
- "handle",
207
- "payload",
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
- // Ref callback: stores the element AND restores focus when the DOM
241
- // swaps from the pre-activation button to Base UI's Trigger.
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
- triggerElRef.current = el;
246
- if (shouldRefocusRef?.current && el) {
247
- shouldRefocusRef.current = false;
248
- el.focus();
249
- }
145
+ triggerRef.current = el;
146
+ if (ctx) ctx.triggerRef.current = el;
250
147
  },
251
- [shouldRefocusRef],
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
- // After tooltip activation, dispatch synthetic events to kick-start
255
- // Base UI's hover detection (same technique as Tooltip.tsx).
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 (!tooltipActivated || !isHoveringRef.current) return;
258
- const el = triggerElRef.current;
259
- if (!el) return;
260
- const frame = requestAnimationFrame(() => {
261
- if (!isHoveringRef.current) return;
262
- el.dispatchEvent(
263
- new PointerEvent("pointerenter", {
264
- bubbles: false,
265
- pointerType: "mouse",
266
- }),
267
- );
268
- el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false }));
269
- el.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }));
270
- });
271
- return () => cancelAnimationFrame(frame);
272
- }, [tooltipActivated]);
273
-
274
- // Tooltip hover handlers for pre-activation
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
- const preProps = {
314
- ...htmlProps,
315
- ref: triggerRef,
316
- className: typeof className === "function" ? undefined : className,
317
- style: typeof style === "function" ? undefined : style,
318
- disabled,
319
- ...tooltipHoverHandlers,
320
- // Activation strategy: mouse/pen uses onPointerDown for snappy
321
- // desktop UX, matching Base UI's Menu.Trigger (which opens on
322
- // mousedown). Touch skips onPointerDown and falls through to
323
- // onClick instead, because Base UI's Menu.Trigger opens on
324
- // touch-up timing on iOS — activating earlier would make the
325
- // pre-activation and post-activation triggers feel inconsistent.
326
- onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => {
327
- callHandler(htmlProps.onPointerDown, e);
328
- lastPointerTypeRef.current = e.pointerType;
329
- if (e.button !== 0 || e.pointerType === "touch") return;
330
- deferred.shouldOpenRef.current = true;
331
- deferred.shouldRefocusRef.current = true;
332
- deferred.activate();
333
- },
334
- onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
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
- let button: ReactElement;
356
- if (isValidElement(renderProp)) {
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
- button = <button {...preProps}>{children}</button>;
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
- // State 1: neither activated — plain button (or custom render)
365
- if (!tooltip || !tooltipActivated) return button;
366
-
367
- // State 2: tooltip activated, dropdown deferred
368
- return (
369
- <BaseTooltip.Provider>
370
- <BaseTooltip.Root>
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
- // --- Post-activation (dropdown activated) ---
379
- const menuTrigger = (
380
- <Menu.Trigger
381
- ref={triggerRef}
382
- className={className}
383
- style={style}
384
- disabled={disabled}
385
- {...rest}
386
- >
387
- {children}
388
- </Menu.Trigger>
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
- // State 3: dropdown activated, no tooltip
392
- if (!tooltip) return menuTrigger;
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
- // State 4: dropdown activated + tooltip — full composition
395
- return (
396
- <BaseTooltip.Provider>
397
- <BaseTooltip.Root>
398
- <BaseTooltip.Trigger render={menuTrigger} />
399
- {!menuOpen && tooltipPortal}
400
- </BaseTooltip.Root>
401
- </BaseTooltip.Provider>
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 — returns null pre-activation */
394
+ /* Portal — captures children, does not render them */
407
395
  /* ------------------------------------------------------------------ */
408
396
 
409
- type PortalProps = ComponentPropsWithoutRef<typeof Menu.Portal>;
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
- function Portal(props: PortalProps) {
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
  /* ------------------------------------------------------------------ */