import { type Accessor, createSignal, untrack } from "solid-js"; import { isIOS } from "../floating-ui/detect-browser"; import type { PropsLike } from "../merge-props-n"; import { createStableCallback } from "../stable-callback"; import { type InteractionType, useEnhancedClickHandler, } from "../use-enhanced-click-handler"; import { useValueChanged } from "../use-value-changed"; export type { InteractionType }; export interface UseOpenInteractionTypeReturn { /** * The interaction type that opened the component, or `null` when the * component is not currently open. Reactive accessor — mirrors React's * `openMethod` state value. */ openMethod: Accessor; /** * Trigger props to spread onto the opening element. Shape matches Base UI * React's return: `{ onClick, onPointerDown }`, ready for `mergePropsN` * composition. */ triggerProps: PropsLike; } /** * Determines the interaction type (keyboard, mouse, touch, etc.) that * opened the component. * * Port of Base UI React's * `packages/react/src/utils/useOpenInteractionType.ts`. The semantic * contract matches 1:1: * * - While the component is closed, the next `pointerdown` / `click` on * the trigger captures the interaction type and stores it on open. * - While the component is open, additional clicks do NOT overwrite the * captured type — the React source gates `setOpenMethod` on `!open`. * - When `open` transitions `true → false`, the captured type is reset * to `null`. * - On iOS Safari the "hitslop" around touch targets can fire * `mousedown` without a matching `pointerdown`, producing an empty * `interactionType`. In that case we fall back to `"touch"`. * * Solid 2 notes: * * - `open` is an accessor — there is no render cycle, so consumers must * thread reactive state through signals. * - `useValueChanged` handles the `true → false` reset idiomatically. * - `handleTriggerClick` is wrapped with `createStableCallback` to * match React's `useStableCallback` contract; it's also what lets the * closure read the latest `open()` value at invocation time without * re-creating the inner callback. */ export function useOpenInteractionType( open: Accessor, ): UseOpenInteractionTypeReturn { const [openMethod, setOpenMethod] = createSignal( null, ); // `createStableCallback` returns a trampoline with a stable identity whose // body reads from the provided getter each time it fires. Our getter // closes over `open` and `setOpenMethod` — both are stable references // captured once at setup. Reading `open()` inside the handler (untracked, // since we're not inside an effect) gives us the current open state at // click time, matching React's read-through-closure semantics. const handleTriggerClick = createStableCallback< (event: MouseEvent | PointerEvent, interactionType: InteractionType) => void >(() => (_event, interactionType) => { // Wrap in `untrack` so that — if this handler is ever invoked from // inside an effect apply — we don't subscribe to `open()` there. const isOpen = untrack(open); if (!isOpen) { setOpenMethod( interactionType || // iOS Safari hitslop: `mousedown` fires without a preceding // `pointerdown`, leaving `interactionType === ""`. Treat it // as a touch on iOS so downstream gating (focus rings, etc.) // behaves consistently. (isIOS ? "touch" : ""), ); } }); // Reset the captured type on every `true → false` transition. React's // original reads `previousOpen && !open`; `useValueChanged` fires with // `(next, previous)` on every change, so we just compare them directly. useValueChanged(open, (nextOpen, previousOpen) => { if (previousOpen && !nextOpen) { setOpenMethod(null); } }); const { onClick, onPointerDown } = useEnhancedClickHandler( handleTriggerClick, ) as { onClick: (event: MouseEvent | PointerEvent) => void; onPointerDown: (event: PointerEvent) => void; }; // React's original memoizes the returned object; Solid components don't // re-run, so the object is allocated exactly once per consumer and its // identity is naturally stable. return { openMethod, triggerProps: { onClick, onPointerDown } as PropsLike, }; }