import type { JSX } from "@solidjs/web"; import { type Accessor, createEffect, createMemo, createSignal, onCleanup, runWithOwner, untrack, } from "solid-js"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { createControlledState } from "../../internals/create-controlled-state"; import { useClientPoint } from "../../internals/floating-ui/hooks/use-client-point"; import { useDismiss } from "../../internals/floating-ui/hooks/use-dismiss"; import { useFloatingRootContext } from "../../internals/floating-ui/hooks/use-floating-root-context"; import { useFocus } from "../../internals/floating-ui/hooks/use-focus"; import { useInteractions } from "../../internals/floating-ui/hooks/use-interactions"; import { useRole } from "../../internals/floating-ui/hooks/use-role"; import type { MutableRef } from "../../internals/floating-ui/types"; import { type MaybeAccessor, readOption } from "../../internals/maybe-accessor"; import { useOpenChangeComplete } from "../../internals/popups/use-open-change-complete"; import { useTransitionStatus } from "../../internals/use-transition-status/use-transition-status"; import { createTooltip } from "../create-tooltip"; import { type TooltipHandle, TooltipStore } from "../store/tooltip-store"; import { type TooltipInstantType, type TooltipRootChangeEventDetails, type TooltipRootChangeEventReason, TooltipRootContext, type TooltipRootContextValue, type TooltipRootState, } from "../tooltip-root-context"; export type { TooltipRootChangeEventDetails, TooltipRootChangeEventReason, TooltipRootState, }; export interface TooltipRootActions { close: () => void; unmount: () => void; } export type TooltipPayloadChildRenderFunction = (ctx: { payload: Payload | undefined; }) => JSX.Element; export interface TooltipRootProps { /** * Controlled open state. When provided (resolved value not * `undefined`), the consumer owns the state — pair with * {@link onOpenChange}. When omitted, the tooltip manages its own open * state (seeded from {@link defaultOpen}), matching React Base UI. */ open?: MaybeAccessor; /** * Initial open state when uncontrolled. Read once at mount. * @default false */ defaultOpen?: MaybeAccessor; /** * Fires on every open/close request (hover, focus, Escape, etc.). * Calling `eventDetails.cancel()` keeps the tooltip's own * (uncontrolled) state unchanged, mirroring React Base UI; controlled * consumers simply don't update their signal. */ onOpenChange?: | ((open: boolean, eventDetails: TooltipRootChangeEventDetails) => void) | undefined; onOpenChangeComplete?: ((open: boolean) => void) | undefined; /** @default false */ disabled?: MaybeAccessor; /** * Axis on which to track the cursor position. When set (!= `"none"`), * the tooltip positioner anchors to the cursor rather than to the * trigger's bounding box. Matches React's `trackCursorAxis`. * @default "none" */ trackCursorAxis?: MaybeAccessor<"none" | "x" | "y" | "both" | undefined>; /** * When true, the hover-intent `safePolygon` handler is NOT installed, * so the popup is not considered hoverable — any cursor movement off * the trigger closes the tooltip. Matches React's `disableHoverablePopup`. * @default false */ disableHoverablePopup?: MaybeAccessor; /** * Handle to associate the root with detached ``s. */ handle?: TooltipHandle | undefined; /** @default null */ triggerId?: string | null | undefined; /** Callback-ref variant of React's actionsRef. */ actionsRef?: | ((actions: TooltipRootActions | null) => void) | { current: TooltipRootActions | null } | undefined; children?: JSX.Element | TooltipPayloadChildRenderFunction; } /** * Groups all parts of the tooltip. Does not render its own HTML element. */ export function TooltipRoot( props: TooltipRootProps, ): JSX.Element { // Adopt the handle's store if supplied. const store = (props.handle?.store ?? new TooltipStore()) as | TooltipStore | TooltipStore; // Controlled / uncontrolled open state (Base UI `open` / `defaultOpen` // contract): the `open` prop wins while its resolved value is defined; // otherwise an internal signal (seeded once from `defaultOpen`) backs // the state and the root's `setOpen` (below) writes it. const [resolvedOpen, setUncontrolledOpen] = createControlledState({ controlled: () => { const controlled = readOption(props.open); return controlled === undefined ? undefined : Boolean(controlled); }, default: () => readOption(props.defaultOpen), fallback: false, name: "", state: "open", }); // Compose the headless Tooltip primitive — owns open (pass-through), // disabled, trackCursorAxis, and disableHoverablePopup. The wrapper // layers floating-UI, transitions, hover/focus interactions, portal, // and trigger/handle plumbing on top. const tooltipPrimitive = createTooltip({ open: resolvedOpen, disabled: () => Boolean(readOption(props.disabled)), trackCursorAxis: () => readOption(props.trackCursorAxis) ?? "none", disableHoverablePopup: () => readOption(props.disableHoverablePopup) ?? false, // onOpenChange is NOT wired here — the wrapper's own setOpen (below) // is the sole writer so it can thread reason/event details through // the full floating-UI pipeline. The primitive's setOpen is unused // in the wrapper path. }); const disabled = tooltipPrimitive.disabled; const trackCursorAxis = tooltipPrimitive.trackCursorAxis; const disableHoverablePopup = tooltipPrimitive.disableHoverablePopup; const rawOpen = tooltipPrimitive.open; const open = createMemo(() => !disabled() && rawOpen()); const { mounted, transitionStatus, setMounted } = useTransitionStatus( open, () => true, () => false, ); // Defer unmount until any CSS exit animation on the popup has finished. // Mirrors the tabs-panel `useOpenChangeComplete` pattern: the React source // keeps `mounted` true through the exit by // waiting on `Animation.finished` before dropping it. The ref is filled // by ``'s `setPopupElement` callback below; the hook is a // no-op when `getAnimations()` returns an empty list, so the happy-dom // path still unmounts on the next microtask. const popupRef: MutableRef = { current: null }; useOpenChangeComplete({ open, ref: popupRef, onComplete: () => { if (!open()) { setMounted(false); } }, }); // Fire onOpenChangeComplete when transition settles. createEffect( () => ({ open: open(), status: transitionStatus() }), ({ open: o, status }) => { if (status === undefined) { props.onOpenChangeComplete?.(o); } }, ); const [instantType, setInstantType] = createSignal( undefined, { ownedWrite: true }, ); const [isInstantPhase, setIsInstantPhase] = createSignal(false, { ownedWrite: true, }); const [triggerElement, setTriggerElement] = createSignal( null, { ownedWrite: true }, ); const [popupElement, setPopupElementInternal] = createSignal(null, { ownedWrite: true }); // Keep the plain-ref mirror in sync with the popup-element signal. The // `useOpenChangeComplete` hook reads `.current` lazily inside a closure // (see `useAnimationsFinished`); keeping a live mutable mirror means the // hook picks up the popup element once `` mounts without // an extra reactive effect. const setPopupElement = (el: HTMLElement | null): void => { popupRef.current = el; setPopupElementInternal(el); }; // triggerId controlled override (consumer-supplied) layered over internal state. const [activeTriggerIdInternal, setActiveTriggerIdInternal] = createSignal< string | null >(null, { ownedWrite: true }); const activeTriggerId = createMemo(() => { const controlled = props.triggerId; if (controlled !== undefined) return controlled; return activeTriggerIdInternal(); }); const setActiveTriggerId = (id: string | null): void => { setActiveTriggerIdInternal(id); }; createEffect( () => activeTriggerId(), (id) => { if (id === null) return; const el = store.context.triggerElements.getById(id) as | HTMLElement | null | undefined; if (el) setTriggerElement(el); }, ); const [payload, setPayload] = createSignal(undefined, { ownedWrite: true, }); const setOpen = ( next: boolean, details: TooltipRootChangeEventDetails, ): void => { const reason = details.reason; if (next && reason === REASONS.triggerFocus) { setInstantType("focus"); } else if ( !next && (reason === REASONS.triggerPress || reason === REASONS.escapeKey || reason === REASONS.outsidePress) ) { setInstantType("dismiss"); } else if (reason === REASONS.triggerHover) { setInstantType(undefined); } // Use runWithOwner(null, ...) to escape the current reactive owner so // that consumer signal writes inside onOpenChange (and the internal // uncontrolled write) don't trigger SIGNAL_WRITE_IN_OWNED_SCOPE when // this setOpen is called from inside a reactive scope (e.g. the // delay-group useIsoLayoutEffect apply phase, or any // createRenderEffect body). untrack() alone doesn't clear owner. // // Base UI change flow (`TooltipStore.setOpen`): fire `onOpenChange`, // honor `details.cancel()`, then write the uncontrolled state // (ignored for reads while the `open` prop is controlled — the // consumer's signal write is the source of truth). runWithOwner(null, () => { props.onOpenChange?.(next, details); if (details.isCanceled) return; setUncontrolledOpen(next); }); // Base UI returns before trigger-state mirroring when the change was // canceled via `details.cancel()`. if (details.isCanceled) return; // Forward active trigger selection from the details. if (details.trigger?.id) { setActiveTriggerIdInternal(details.trigger.id); setTriggerElement(details.trigger as HTMLElement); } }; store.attachSetOpen((next, details) => setOpen(next, details)); createEffect( () => open(), (o) => store.setOpenMirror(o), ); onCleanup(() => store.attachSetOpen(null)); // Implicit active trigger. Capture `activeTriggerId()` in compute // alongside `open()` — apply runs unowned (`[STRICT_READ_UNTRACKED]` // would fire on a direct read). createEffect( () => ({ openValue: open(), currentTriggerId: activeTriggerId() }), ({ openValue, currentTriggerId }) => { if (!openValue) return; if (currentTriggerId !== null) return; if (store.context.triggerElements.size !== 1) return; const entry = store.context.triggerElements.entries().next(); if (!entry.done) { const [id, el] = entry.value; setActiveTriggerIdInternal(id); setTriggerElement(el as HTMLElement); } }, ); const floatingRootContext = useFloatingRootContext({ open: untrack(() => open()), onOpenChange: (nextOpen, details) => { setOpen(nextOpen, details as TooltipRootChangeEventDetails); }, }); createEffect( () => open(), (next) => { floatingRootContext.sync("open", next); }, ); createEffect( () => triggerElement(), (el) => { floatingRootContext.sync("domReferenceElement", el); floatingRootContext.sync("referenceElement", el); }, ); createEffect( () => popupElement(), (el) => { floatingRootContext.sync("floatingElement", el); }, ); // Interaction hooks — with closeOnClick-aware dismiss. const closeOnClick = (): boolean => { // Track-cursor tooltips commonly want click-to-close, but the // default is still false (React's default). We respect the store's // `closeOnClick` flag below when the trigger writes to it — the // default on the trigger is `false`. return closeOnClickSignal(); }; const [closeOnClickSignal, setCloseOnClickSignal] = createSignal(false, { ownedWrite: true, }); const dismiss = useDismiss(floatingRootContext, { get enabled() { return !disabled(); }, referencePress: closeOnClick, }); const focus = useFocus(floatingRootContext, { get enabled() { return !disabled(); }, }); const role = useRole(floatingRootContext, { role: "tooltip" }); // useClientPoint — always install, gated via `enabled`. const clientPoint = useClientPoint(floatingRootContext, { get enabled() { return !disabled() && trackCursorAxis() !== "none"; }, get axis() { const a = trackCursorAxis(); return a === "none" ? "both" : a; }, }); const interactions = useInteractions([dismiss, focus, role, clientPoint]); // Expose client-point prop bags separately so trigger / popup can // merge them on their own schedule (legacy callers already merge // `interactions.getReferenceProps()` etc., which now also carries // client-point; duplicating into these separate accessors is a // safety net for future callers). const clientPointReferenceProps: Accessor> = () => (clientPoint.reference ?? {}) as Record; const clientPointFloatingProps: Accessor> = () => (clientPoint.floating ?? {}) as Record; const state: Accessor = createMemo(() => ({ open: open(), })); // Resolve children — render-function form. const resolveChildren = (): JSX.Element => { const ch = props.children; if (typeof ch === "function") { return (ch as TooltipPayloadChildRenderFunction)({ payload: payload() as Payload | undefined, }); } return ch as JSX.Element; }; // actionsRef. const makeImperativeDetails = (): TooltipRootChangeEventDetails => createChangeEventDetails( REASONS.imperativeAction as TooltipRootChangeEventReason, ) as TooltipRootChangeEventDetails; const actions: TooltipRootActions = { close: () => setOpen(false, makeImperativeDetails()), unmount: () => setOpen(false, makeImperativeDetails()), }; const ref = props.actionsRef; if (typeof ref === "function") { ref(actions); onCleanup(() => ref(null)); } else if (ref && typeof ref === "object") { ref.current = actions; onCleanup(() => { ref.current = null; }); } const contextValue: TooltipRootContextValue = { open, disabled, mounted, transitionStatus, instantType, isInstantPhase, state, setInstantType, setIsInstantPhase, setOpen, floatingRootContext, interactions, setTriggerElement, triggerElement, setPopupElement, popupElement, store: store as TooltipStore, activeTriggerId, setActiveTriggerId, payload, setPayload, disableHoverablePopup, trackCursorAxis, closeOnClick: closeOnClickSignal, setCloseOnClick: setCloseOnClickSignal, clientPointReferenceProps, clientPointFloatingProps, }; return ( {resolveChildren()} ); }