/** * React hooks for refs, events, ids, effects, and element metadata. * @module Hooks */ import { canUseDOM, addGlobalEventListener } from "@ariakit/utils"; import type { AnyFunction } from "@ariakit/utils"; import type { ComponentType, DependencyList, EffectCallback, MutableRefObject, MouseEvent as ReactMouseEvent, Ref, RefCallback, RefObject, SetStateAction, } from "react"; import * as React from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, } from "react"; import { setRef } from "./misc.ts"; import type { WrapElement } from "./types.ts"; interface ReactWithOptionalHooks { useId?: () => string; useDeferredValue?: (value: T) => T; useInsertionEffect?: ( effect: EffectCallback, dependencies?: DependencyList, ) => void; } // See https://github.com/webpack/webpack/issues/14814 const _React: ReactWithOptionalHooks = { ...React }; const useReactId = _React.useId; const useReactDeferredValue = _React.useDeferredValue; const useReactInsertionEffect = _React.useInsertionEffect; // Select once per loaded React version so every render uses the same hook. const useEventUpdate = useReactInsertionEffect ?? ((callback: () => void) => callback()); interface MergedRefEffect { ref: Ref; cleanup?: () => void; } /** * `React.useLayoutEffect` that fallbacks to `React.useEffect` on server side. */ export const useSafeLayoutEffect = canUseDOM ? useLayoutEffect : useEffect; /** * Returns a value that never changes even if the argument is updated. * @example * function Component({ prop }) { * const initialProp = useInitialValue(prop); * } */ export function useInitialValue(value: T | (() => T)) { const [initialValue] = useState(value); return initialValue; } /** * Creates a `React.RefObject` that is constantly updated with the incoming * value. * @example * function Component({ prop }) { * const propRef = useLiveRef(prop); * } */ export function useLiveRef(value: T) { const ref = useRef(value); useSafeLayoutEffect(() => { ref.current = value; }); return ref; } /** * Creates a stable callback function that has access to the latest state and * can be used within event handlers and effect callbacks. Throws when used in * the render phase. * @example * function Component(props) { * const onClick = useEvent(props.onClick); * React.useEffect(() => {}, [onClick]); * } */ export function useEvent(callback?: T) { const ref = useRef(() => { throw new Error("Cannot call an event handler while rendering."); }); useEventUpdate(() => { ref.current = callback; }); return useCallback((...args) => ref.current?.(...args), []) as T; } /** * Creates a React state that calls a callback function whenever the state * changes and rolls back to the previous state on cleanup. */ export function useTransactionState( callback?: ((state: SetStateAction) => void) | null, ) { const [state, setState] = useState(null); useSafeLayoutEffect(() => { if (state == null) return; if (!callback) return; let prevState: T | null = null; callback((prev) => { prevState = prev; return state; }); return () => { callback(prevState); }; }, [state, callback]); return [state, setState] as const; } /** * Merges React Refs into a single memoized function ref so you can pass it to * an element. * @example * const Component = React.forwardRef((props, ref) => { * const internalRef = React.useRef(); * return
; * }); */ export function useMergeRefs(...refs: Array | undefined>) { return useMemo(() => { if (!refs.some(Boolean)) return; return (value: unknown) => { const refEffects: MergedRefEffect[] = []; for (const ref of refs) { if (!ref) continue; const cleanup = setRef(ref, value); refEffects.push({ ref, cleanup: typeof cleanup === "function" ? cleanup : undefined, }); } if (!refEffects.some((effect) => effect.cleanup)) return; return () => { for (const { ref, cleanup } of refEffects) { if (cleanup) { cleanup(); } else { // React only sees the merged ref, so its cleanup replaces the // usual null call for all refs. Child refs that didn't return a // cleanup still need the null detach they would receive alone. setRef(ref, null); } } }; }; // Variadic refs prevent using an array literal for the dependencies. // oxlint-disable-next-line exhaustive-deps, react/use-memo -- variadic refs }, refs); } function useIdPolyfill(defaultId?: string): string | undefined { const [id, setId] = useState(defaultId); useSafeLayoutEffect(() => { if (defaultId || id) return; const random = Math.random().toString(36).slice(2, 8); setId(`id-${random}`); }, [defaultId, id]); return defaultId || id; } function useReactIdWithDefault(defaultId?: string): string | undefined { // This implementation is selected only when React provides useId. const id = useReactId!(); return defaultId || id; } // Select once per loaded React version so every render uses the same hook. const useCompatibleId = useReactId ? useReactIdWithDefault : useIdPolyfill; /** * Generates a unique ID. Uses React's useId if available. */ export function useId(defaultId?: string): string | undefined { return useCompatibleId(defaultId); } function useDeferredValuePolyfill(value: T): T { const [deferredValue, setDeferredValue] = useState(value); useEffect(() => { const raf = requestAnimationFrame(() => setDeferredValue(value)); return () => cancelAnimationFrame(raf); }, [value]); return deferredValue; } // Select once per loaded React version so every render uses the same hook. const useCompatibleDeferredValue = useReactDeferredValue ?? useDeferredValuePolyfill; /** * Uses React's useDeferredValue if available. */ export function useDeferredValue(value: T): T { return useCompatibleDeferredValue(value); } /** * Returns the tag name by parsing an element ref. * @example * function Component(props) { * const ref = React.useRef(); * const tagName = useTagName(ref, "button"); // div * return
; * } */ export function useTagName( refOrElement?: RefObject | HTMLElement | null, type?: string | ComponentType, ) { const stringOrUndefined = (type?: string | ComponentType) => { if (typeof type !== "string") return; return type; }; const [tagName, setTagName] = useState(() => stringOrUndefined(type)); useSafeLayoutEffect(() => { const element = refOrElement && "current" in refOrElement ? refOrElement.current : refOrElement; setTagName(element?.tagName.toLowerCase() || stringOrUndefined(type)); }, [refOrElement, type]); return tagName; } /** * Returns the attribute value of an element. * @example * function Component(props) { * const ref = React.useRef(); * const role = useAttribute(ref, "role", props.role); * return
; * } */ export function useAttribute( refOrElement: RefObject | HTMLElement | null, attributeName: string, defaultValue?: string, ) { const initialValue = useInitialValue(defaultValue); const [attribute, setAttribute] = useState(initialValue); useEffect(() => { const element = refOrElement && "current" in refOrElement ? refOrElement.current : refOrElement; if (!element) return; const callback = () => { const value = element.getAttribute(attributeName); setAttribute(value == null ? initialValue : value); }; const observer = new MutationObserver(callback); observer.observe(element, { attributeFilter: [attributeName] }); callback(); return () => observer.disconnect(); }, [refOrElement, attributeName, initialValue]); return attribute; } /** * A `React.useEffect` that will not run on the first render. */ export function useUpdateEffect(effect: EffectCallback, deps?: DependencyList) { const mounted = useRef(false); useEffect(() => { if (mounted.current) { return effect(); } mounted.current = true; // Caller-provided dependencies prevent using an array literal here. // oxlint-disable-next-line exhaustive-deps -- public deps API }, deps); useEffect( () => () => { mounted.current = false; }, [], ); } /** * A `React.useLayoutEffect` that will not run on the first render. */ export function useUpdateLayoutEffect( effect: EffectCallback, deps?: DependencyList, ) { const mounted = useRef(false); useSafeLayoutEffect(() => { if (mounted.current) { return effect(); } mounted.current = true; // Caller-provided dependencies prevent using an array literal here. // oxlint-disable-next-line exhaustive-deps -- public deps API }, deps); useSafeLayoutEffect( () => () => { mounted.current = false; }, [], ); } /** * A React hook similar to `useState` and `useReducer`, but with the only * purpose of re-rendering the component. */ export function useForceUpdate() { return useReducer(() => [], []); } /** * Returns an event callback similar to `useEvent`, but this also accepts a * boolean value, which will be turned into a function. */ export function useBooleanEvent( booleanOrCallback: boolean | ((...args: T) => boolean), ) { return useEvent( typeof booleanOrCallback === "function" ? booleanOrCallback : () => booleanOrCallback, ); } /** * Returns props with an additional `wrapElement` prop. */ export function useWrapElement

( props: P & { wrapElement?: WrapElement }, callback: WrapElement, deps: DependencyList = [], ): P & { wrapElement: WrapElement } { const wrapElement: WrapElement = useCallback( (element) => { if (props.wrapElement) { element = props.wrapElement(element); } return callback(element); }, // Caller-provided dependencies prevent using an array literal here. // oxlint-disable-next-line exhaustive-deps, react/use-memo -- public deps API [...deps, props.wrapElement], ); return { ...props, wrapElement }; } /** * Merges the portalRef prop and returns a `domReady` to be used in the * components that use Portal underneath. */ export function usePortalRef( portalProp = false, portalRefProp?: | RefCallback | MutableRefObject, ) { const [portalNode, setPortalNode] = useState(null); const portalRef = useMergeRefs(setPortalNode, portalRefProp); const domReady = !portalProp || portalNode; return { portalRef, portalNode, domReady }; } /** * A hook that passes metadata props around without leaking them to the DOM. */ export function useMetadataProps( props: { onLoadedMetadataCapture?: AnyFunction & { [key in K]?: T } }, key: K, value: T, ) { const parent = props.onLoadedMetadataCapture; const onLoadedMetadataCapture = useMemo(() => { return Object.assign( () => {}, parent, ...(value !== undefined ? [{ [key]: value }] : []), ); }, [parent, key, value]); return [parent?.[key], { onLoadedMetadataCapture }] as const; } let hasInstalledGlobalEventListeners = false; /** * Returns a function that checks whether the mouse is moving. */ export function useIsMouseMoving() { useEffect(() => { if (hasInstalledGlobalEventListeners) return; // We're not returning the event listener cleanup function here because we // may lose some events if this component is unmounted, but others are // still mounted. addGlobalEventListener("mousemove", setMouseMoving, true); // See https://github.com/ariakit/ariakit/issues/1137 addGlobalEventListener("mousedown", resetMouseMoving, true); addGlobalEventListener("mouseup", resetMouseMoving, true); addGlobalEventListener("keydown", resetMouseMoving, true); addGlobalEventListener("scroll", resetMouseMoving, true); hasInstalledGlobalEventListeners = true; }, []); const isMouseMoving = useEvent(() => mouseMoving); return isMouseMoving; } let mouseMoving = false; let previousScreenX = 0; let previousScreenY = 0; function hasMouseMovement(event: ReactMouseEvent | MouseEvent) { const movementX = event.movementX || event.screenX - previousScreenX; const movementY = event.movementY || event.screenY - previousScreenY; previousScreenX = event.screenX; previousScreenY = event.screenY; return movementX || movementY || process.env.NODE_ENV === "test"; } function setMouseMoving(event: MouseEvent) { if (!hasMouseMovement(event)) return; mouseMoving = true; } function resetMouseMoving() { mouseMoving = false; }