/** * Stage 4.2 port of Base UI React's `useClick` hook. * * React source: * `base-ui/packages/react/src/floating-ui-react/hooks/useClick.ts` * * Opens or closes the floating element when the reference element is * clicked. Supports `event: 'click' | 'mousedown' | 'mousedown-only'`, * `toggle`, `ignoreMouse`, `stickIfOpen`, `touchOpenDelay`, and a custom * `reason`. * * ### React → Solid deviations * * 1. React `useRef` for `pointerTypeRef` → a plain closure `let`. Solid * component bodies don't re-run, so mutables live as variables. * * 2. React memoises `reference` via `useMemo` keyed on all option deps. * Solid reads the options directly at event time off the captured * props, so the returned object is a plain literal. Callers that * want option reactivity can pass accessors (see `touchOpenDelay` * resolution below) — but the React source also treated these as * simple values, so the default Solid shape is 1:1. * * 3. `useAnimationFrame` and `useTimeout` are already ported (shared * internals). They provide the same `request()` / `cancel()` and * `start()` / `clear()` handles. * * 4. The reason default (`REASONS.triggerPress`) stays identical. */ import { untrack } from "solid-js"; import type { BaseUIChangeEventDetails } from "../../change-event-details"; import { createChangeEventDetails, REASONS } from "../../change-event-details"; import { type MaybeAccessor, readOption } from "../../maybe-accessor"; import { useAnimationFrame } from "../../use-animation-frame"; import { useTimeout } from "../../use-timeout"; import type { ElementProps, FloatingContext, FloatingRootContext, } from "../types"; import { getTarget, isTypeableElement } from "../utils/element"; import { isClickLikeEvent, isMouseLikePointerType } from "../utils/event"; export interface UseClickProps { /** * Whether the Hook is enabled, including all internal Effects and event * handlers. Accepts an accessor so reactive flips land without * re-mounting the consumer (solidify.md P3). * @default true */ enabled?: MaybeAccessor | undefined; /** * The type of event to use to determine a "click" with mouse input. * Keyboard clicks work as normal. * @default 'click' */ event?: "click" | "mousedown" | "mousedown-only" | undefined; /** * Whether to toggle the open state with repeated clicks. Accepts an * accessor for live updates (solidify.md P3). * @default true */ toggle?: MaybeAccessor | undefined; /** * Whether to ignore the logic for mouse input (for example, if * `useHover()` is also being used). * @default false */ ignoreMouse?: boolean | undefined; /** * If already open from another event such as the `useHover()` Hook, * determines whether to keep the floating element open when clicking * the reference element for the first time. * @default true */ stickIfOpen?: boolean | undefined; /** * Touch-only delay (ms) before opening. Useful to allow mobile * viewport/keyboard to settle. * @default 0 */ touchOpenDelay?: number | undefined; /** * The reason for the click. * @default REASONS.triggerPress */ reason?: typeof REASONS.triggerPress | typeof REASONS.inputPress | undefined; } const EMPTY_OBJECT = Object.freeze({}) as Record; /** * Opens or closes the floating element when clicking the reference element. * @see https://floating-ui.com/docs/useClick */ export function useClick( context: FloatingRootContext | FloatingContext, props: UseClickProps = {}, ): ElementProps { const store = "rootStore" in context ? context.rootStore : context; const dataRef = store.context.dataRef; // Untrack the body-time destructure (see use-dismiss for the full // reasoning). These fields are not `MaybeAccessor` shapes. const { event: eventOption = "click", ignoreMouse = false, stickIfOpen = true, touchOpenDelay = 0, reason = REASONS.triggerPress, } = untrack(() => ({ event: props.event, ignoreMouse: props.ignoreMouse, stickIfOpen: props.stickIfOpen, touchOpenDelay: props.touchOpenDelay, reason: props.reason, })); // `enabled` + `toggle` are read lazily so reactive accessor callers // observe live changes without re-mounting (solidify.md P3). const getEnabled = (): boolean => props.enabled === undefined ? true : readOption(props.enabled); const getToggle = (): boolean => props.toggle === undefined ? true : readOption(props.toggle); let pointerType: "mouse" | "pen" | "touch" | undefined; const frame = useAnimationFrame(); const touchOpenTimeout = useTimeout(); const reference: ElementProps["reference"] = { onPointerDown(event: PointerEvent) { if (!getEnabled()) return; pointerType = event.pointerType as "mouse" | "pen" | "touch"; }, onMouseDown(event: MouseEvent) { if (!getEnabled()) return; const nativeEvent = event; const open = store.state.open; // Ignore all buttons except for the "main" button. // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button if ( event.button !== 0 || eventOption === "click" || (isMouseLikePointerType(pointerType, true) && ignoreMouse) ) { return; } const openEvent = dataRef.current.openEvent; const openEventType = openEvent?.type; const hasClickedOnInactiveTrigger = store.state.domReferenceElement !== event.currentTarget; const toggle = getToggle(); const nextOpen = (open && hasClickedOnInactiveTrigger) || !( open && toggle && (openEvent && stickIfOpen ? openEventType === "click" || openEventType === "mousedown" : true) ); // Animations sometimes won't run on a typeable element if using // a rAF. Focus is always set on these elements. For touch, we // may delay opening. const target = getTarget(nativeEvent); if (isTypeableElement(target)) { const details = createChangeEventDetails( reason, nativeEvent, target as HTMLElement, ) as BaseUIChangeEventDetails; if (nextOpen && pointerType === "touch" && touchOpenDelay > 0) { touchOpenTimeout.start(touchOpenDelay, () => { store.setOpen(true, details); }); } else { store.setOpen(nextOpen, details); } return; } // Capture the currentTarget before the rAF. React sets it to // null after the event handler completes; happy-dom / Solid do // the same, so keep the snapshot. const eventCurrentTarget = event.currentTarget as HTMLElement; // Wait until focus is set on the element. This is an // alternative to `event.preventDefault()` to avoid // `:focus-visible` from appearing when using a pointer. frame.request(() => { const details = createChangeEventDetails( reason, nativeEvent, eventCurrentTarget, ) as BaseUIChangeEventDetails; if (nextOpen && pointerType === "touch" && touchOpenDelay > 0) { touchOpenTimeout.start(touchOpenDelay, () => { store.setOpen(true, details); }); } else { store.setOpen(nextOpen, details); } }); }, onClick(event: MouseEvent) { if (!getEnabled()) return; if (eventOption === "mousedown-only") { return; } if (eventOption === "mousedown" && pointerType) { pointerType = undefined; return; } if (isMouseLikePointerType(pointerType, true) && ignoreMouse) { return; } const open = store.state.open; const openEvent = dataRef.current.openEvent; const hasClickedOnInactiveTrigger = store.state.domReferenceElement !== event.currentTarget; const toggle = getToggle(); const nextOpen = (open && hasClickedOnInactiveTrigger) || !( open && toggle && (openEvent && stickIfOpen ? isClickLikeEvent(openEvent) : true) ); const details = createChangeEventDetails( reason, event, event.currentTarget as HTMLElement, ) as BaseUIChangeEventDetails; if (nextOpen && pointerType === "touch" && touchOpenDelay > 0) { touchOpenTimeout.start(touchOpenDelay, () => { store.setOpen(true, details); }); } else { store.setOpen(nextOpen, details); } }, onKeyDown() { if (!getEnabled()) return; pointerType = undefined; }, }; // When `enabled` is an accessor, always return the bag so attached // handlers can reactively short-circuit. When it's a literal `false`, // preserve the React-compatible empty-bag shape. The body-time // `props.enabled` access is wrapped in `untrack` so the strict-read // scope established by `devComponent(...)` does not warn — the // accessor branch goes via `getEnabled()` at event time inside // already-tracked handlers. const isStaticallyDisabled = untrack( () => typeof props.enabled !== "function" && !getEnabled(), ); if (isStaticallyDisabled) { return EMPTY_OBJECT; } return { reference }; }