import type { JSX } from "@solidjs/web"; /** * Stage 5.3 port of Base UI React's `FloatingDelayGroup` * (`packages/react/src/floating-ui-react/components/FloatingDelayGroup.tsx`). * * Coordinates hover-open/close delays across sibling floating surfaces so * that rapidly moving between grouped tooltips skips the normal open delay: * the next member opens immediately while any group member's close window * (or the explicit `timeoutMs`) is still open. * * ### React → Solid deviations * * - `React.useRef` → plain `MutableRef` objects. The ref plumbing mirrors * the React source exactly: `delayRef`, `initialDelayRef`, `currentIdRef`, * and `currentContextRef` are shared by identity with consumer components * via context so that sibling `useDelayGroup` calls all observe the same * mutable state. * - `React.useState(false)` for the instant-phase flag is a `createSignal` * pair (`isInstantPhase` / `setIsInstantPhase`) so the returned bag can * track reactively. * - `React.useMemo` over the context value is dropped — Solid component * bodies run once, and the contained refs already have stable identity, * so the object literal is created once per `FloatingDelayGroup` mount. * - `useIsoLayoutEffect(cb, [...deps])` pairs become the Solid split * compute/apply form. The dependency set that "opens a new member" is * replicated by reading `store.useState('floatingId')` + * `options.open` in the compute phase; the apply phase mirrors the * React effect body line-for-line. * - Timeout management uses `useTimeout()` for automatic `onCleanup` — * matches the React source's `useTimeout()` helper 1:1. * * ### Reason strings * * Pre-empted closes (when switching members) emit `REASONS.none`, matching * the React source literal (`createChangeEventDetails(REASONS.none)`). */ import { createContext, createSignal, onCleanup, untrack, useContext, } from "solid-js"; import { type BaseUIChangeEventDetails, createChangeEventDetails, REASONS, } from "../../change-event-details"; import { createStableCallback } from "../../stable-callback"; import { useIsoLayoutEffect } from "../../use-iso-layout-effect"; import { Timeout, useTimeout } from "../../use-timeout"; import { getDelay } from "../hooks/use-hover-shared"; import type { Delay, FloatingContext, FloatingRootContext, MutableRef, } from "../types"; // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- type GroupCurrentContext = { onOpenChange: ( open: boolean, eventDetails: BaseUIChangeEventDetails, ) => void; setIsInstantPhase: (value: boolean) => void; } | null; interface FloatingDelayGroupContextValue { hasProvider: boolean; timeoutMs: number; delayRef: MutableRef; initialDelayRef: MutableRef; timeout: Timeout; currentIdRef: MutableRef; currentContextRef: MutableRef; } const FloatingDelayGroupContext = createContext( { hasProvider: false, timeoutMs: 0, delayRef: { current: 0 }, initialDelayRef: { current: 0 }, timeout: new Timeout(), currentIdRef: { current: null }, currentContextRef: { current: null }, }, ); // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export interface FloatingDelayGroupProps { children?: JSX.Element; /** * The delay to use for the group when it's not in the instant phase. */ delay: Delay; /** * An optional explicit timeout to use for the group, which represents * when grouping logic will no longer be active after the close delay * completes. Useful when grouping should "last" longer than the close * delay, e.g. when there is no close delay at all. */ timeoutMs?: number | undefined; } /** * Provides context for a group of floating elements that should share a * `delay`. Sibling members consume the group via `useDelayGroup(context)` * and will skip their open delay if another group member has opened * recently (within the close delay or `timeoutMs` window). * * @see https://floating-ui.com/docs/FloatingDelayGroup * @internal */ export function FloatingDelayGroup( props: FloatingDelayGroupProps, ): JSX.Element { const timeoutMs = () => props.timeoutMs ?? 0; // Body-time snapshots — `delayRef.current` is mutated reactively by the // `delay` setter on the returned bag, and `initialDelayRef` is the // stable initial value (matches React's `useRef(props.delay)` semantics). const delayRef: MutableRef = { current: untrack(() => props.delay) }; const initialDelayRef: MutableRef = { current: untrack(() => props.delay), }; const currentIdRef: MutableRef = { current: null }; const currentContextRef: MutableRef = { current: null }; const timeout = useTimeout(); const value: FloatingDelayGroupContextValue = { hasProvider: true, delayRef, initialDelayRef, currentIdRef, currentContextRef, timeout, get timeoutMs() { return timeoutMs(); }, }; return ( {props.children} ); } // --------------------------------------------------------------------------- // useDelayGroup — sibling registration hook // --------------------------------------------------------------------------- export interface UseDelayGroupOptions { /** * Whether the trigger this hook is used in has opened the tooltip. */ open: boolean; } export interface UseDelayGroupReturn { /** * The delay reference object. Shared across all group members so that * hovering the next member picks up the instant-phase `open: 0` * without waiting for prop propagation. */ delayRef: MutableRef; /** * Whether animations should be removed because the group is in the * instant switch-between-members phase. */ isInstantPhase: boolean; /** * Whether a `` provider is present above the caller. */ hasProvider: boolean; } /** * Registers the caller into the nearest ``. Returns * the shared `delayRef`, a flag marking the instant (inter-member) * phase, and whether a provider is present. Call inside a component that * participates in hover-delay coordination (e.g. Tooltip.Root). * * Accepts either a `FloatingRootContext` (the store itself) or a * `FloatingContext` (with `.rootStore`). The React source uses the same * dual-input shape so consumers pass whichever they have on hand. * * @see https://floating-ui.com/docs/FloatingDelayGroup * @internal */ export function useDelayGroup( context: FloatingRootContext | FloatingContext, options: UseDelayGroupOptions = { open: false }, ): UseDelayGroupReturn { const store = "rootStore" in context ? context.rootStore : context; const groupContext = useContext(FloatingDelayGroupContext); const { currentIdRef, delayRef, initialDelayRef, currentContextRef, hasProvider, timeout, } = groupContext; const [isInstantPhase, setIsInstantPhaseSignal] = createSignal(false, { ownedWrite: true, }); // Stabilize so previous member can synchronously flip our phase when // control passes to a new member mid-effect (React relies on a stable // state setter; Solid's setter is already stable but we wrap for the // identity-parity the shared `currentContextRef` requires). const setIsInstantPhase = createStableCallback<(value: boolean) => void>( () => (value: boolean) => setIsInstantPhaseSignal(value), ); // Effect #1: when this member is currently the group owner and its `open` // flag flips to false, schedule (or immediately perform) the unset — if // `timeoutMs` is configured, the group stays "warm" for the extra window. useIsoLayoutEffect( () => ({ floatingId: store.useState("floatingId"), open: options.open, timeoutMs: groupContext.timeoutMs, }), (snapshot) => { const { floatingId, open, timeoutMs } = snapshot; function unset() { setIsInstantPhaseSignal(false); currentContextRef.current?.setIsInstantPhase(false); currentIdRef.current = null; currentContextRef.current = null; delayRef.current = initialDelayRef.current; } if (!currentIdRef.current) { return undefined; } if (!open && currentIdRef.current === floatingId) { setIsInstantPhaseSignal(false); if (timeoutMs) { const closingId = floatingId; timeout.start(timeoutMs, () => { // If another member has taken over the group, skip resetting. if ( store.state.open || (currentIdRef.current && currentIdRef.current !== closingId) ) { return; } unset(); }); // Matches React's `return () => timeout.clear()` — if the // effect re-runs before the timeout fires (or the scope is // disposed), cancel the pending reset. return () => { timeout.clear(); }; } unset(); } return undefined; }, ); // Effect #2: when this member opens, claim the group. If a different // member was the previous owner, flip both into instant phase and force // the old owner to close synchronously with `REASONS.none`. useIsoLayoutEffect( () => ({ floatingId: store.useState("floatingId"), open: options.open, }), (snapshot) => { const { floatingId, open } = snapshot; if (!open) { return; } const prevContext = currentContextRef.current; const prevId = currentIdRef.current; // A new tooltip is opening, so cancel any pending timeout that // would reset the group's delay back to the initial value. timeout.clear(); currentContextRef.current = { onOpenChange: store.setOpen, setIsInstantPhase, }; currentIdRef.current = floatingId ?? null; delayRef.current = { open: 0, close: getDelay(initialDelayRef.current, "close"), }; if (prevId !== null && prevId !== floatingId) { setIsInstantPhaseSignal(true); prevContext?.setIsInstantPhase(true); prevContext?.onOpenChange( false, createChangeEventDetails( REASONS.none, ) as BaseUIChangeEventDetails, ); } else { setIsInstantPhaseSignal(false); prevContext?.setIsInstantPhase(false); } }, ); // Effect #3: on unmount, clear our slot in `currentContextRef` so the // previous-owner branch above doesn't reach into a disposed member. // Matches React's `useIsoLayoutEffect(() => () => { ... }, [])`. onCleanup(() => { currentContextRef.current = null; }); return { get hasProvider() { return hasProvider; }, get delayRef() { return delayRef; }, get isInstantPhase() { return isInstantPhase(); }, }; }