import { ownerDocument } from "./dom/owner-document"; import type { PropsLike } from "./merge-props-n"; /** * Accessor-or-value. Consumers may pass plain values or Solid accessors for * any reactive gate; readers resolve at call time so the returned handlers * see the latest state without recreating the primitive. */ type MaybeAccessor = T | (() => T); function read(value: MaybeAccessor): T { return typeof value === "function" ? (value as () => T)() : value; } /** * Minimal event shape — both Solid JSX events and raw DOM events satisfy it. * `baseUIHandlerPrevented` / `preventBaseUIHandler` are layered on by * `mergePropsN` during the event chain (see `merge-props-n.ts`). */ interface BaseUIEventLike { baseUIHandlerPrevented?: boolean; preventBaseUIHandler?: () => void; currentTarget: EventTarget | null; } export interface UseMixedToggleClickHandlerParameters { /** * Whether the mixed toggle click handler is enabled. * @default true */ enabled?: MaybeAccessor | undefined; /** * Which action the `mousedown` event performs on the popup — `"open"` means * the popup opens on mousedown (so the follow-up click must not close it), * `"close"` means the popup closes on mousedown (so the follow-up click * must not re-open it). */ mouseDownAction: MaybeAccessor<"open" | "close">; /** * Current open state of the popup. Read at event-time — pass a signal * accessor to track updates. */ open: MaybeAccessor; } /** * Port of Base UI React's `useMixedToggleClickHandler` * (`packages/react/src/utils/useMixedToggleClickHandler.ts`). * * Produces a pair of `onMouseDown` / `onClick` handlers that fix the * "mousedown opens, click closes" (or "mousedown closes, click opens") * race that occurs when a popup trigger is toggled by different events: * the mousedown flips the popup state, and the ensuing click — fired by * the same user gesture — would otherwise immediately flip it back. * * The fix is to record, on mousedown, that a subsequent click should be * ignored; the one-shot `click` listener installed on the owner document * clears the latch at the end of the event loop so unrelated future * clicks still work. * * Solid 2 notes: * * - Unlike React's `useMemo` gate on `enabled`, the Solid port reads * `enabled` at event time. That keeps the primitive's return value * identity stable (`mergePropsN` doesn't care about identity, but * callers that capture the handlers once don't need to rewire on * toggle) and avoids a stale-closure hazard during a transition. * - `mouseDownAction` and `open` are likewise read at event time so the * primitive can be called once during setup rather than inside an * effect. * - The returned shape is `PropsLike` so consumers feed it directly into * `mergePropsN`. The right-most handler can call `preventBaseUIHandler()` * to short-circuit chained consumer handlers — matching the React * source, which uses `event.preventBaseUIHandler()` for the same * purpose. */ export function useMixedToggleClickHandler( params: UseMixedToggleClickHandlerParameters, ): PropsLike { // Closure-captured latch. Set on mousedown when the follow-up click would // unwind the popup toggle; cleared either by the one-shot document listener // (end-of-event-loop cleanup) or by the `onClick` handler itself (so a // direct same-gesture click path still preventBaseUIHandler's). let ignoreClick = false; function onMouseDown(event: MouseEvent & BaseUIEventLike): void { if (read(params.enabled) === false) return; const action = read(params.mouseDownAction); const open = read(params.open); // Only engage when the mousedown is actually going to flip the popup. // If `mouseDownAction === "open"` and the popup is already open, // mousedown is a no-op and the follow-up click is fine; same for the // `close` side. if ((action === "open" && !open) || (action === "close" && open)) { ignoreClick = true; const target = event.currentTarget as Element | null; // Install a one-shot `click` listener on the owner document so the // latch is cleared even if the click bubbles out of our element // (e.g. consumer stopPropagation elsewhere in the tree). `once: // true` means we don't need to manage removal. ownerDocument(target).addEventListener( "click", () => { ignoreClick = false; }, { once: true }, ); } } function onClick(event: MouseEvent & BaseUIEventLike): void { if (read(params.enabled) === false) return; if (ignoreClick) { ignoreClick = false; // Tell downstream merged handlers not to act on this click — the // mousedown already toggled the popup, so the click would undo it. event.preventBaseUIHandler?.(); } } return { onClick, onMouseDown } as PropsLike; }