import { flip, offset, shift } from "@floating-ui/dom"; import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createEffect, createMemo, createSignal, omit, Show } from "solid-js"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { FloatingPortal } from "../../internals/floating-ui/components/floating-portal"; import { useFloating } from "../../internals/floating-ui/hooks/use-floating"; import { arrow } from "../../internals/floating-ui/middleware/arrow"; import type { MutableRef } from "../../internals/floating-ui/types"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps } from "../../internals/types"; import { type TooltipAlign, TooltipPositionerContext, type TooltipPositionerContextValue, type TooltipSide, } from "../tooltip-positioner-context"; import { useTooltipRootContext } from "../tooltip-root-context"; export interface TooltipPositionerState { open: boolean; side: TooltipSide; align: TooltipAlign; anchorHidden: boolean; } export interface TooltipPositionerProps extends BaseUIComponentProps<"div", TooltipPositionerState> { /** * Tag/component to render. Defaults to `"div"`. Replaces the * pre-spike `render` prop (axis G). */ as?: ValidComponent; side?: TooltipSide | undefined; align?: TooltipAlign | undefined; sideOffset?: number | undefined; alignOffset?: number | undefined; collisionPadding?: number | undefined; keepMounted?: boolean | undefined; } function parsePlacement(placement: string): { side: TooltipSide; align: TooltipAlign; } { const [rawSide, rawAlign = "center"] = placement.split("-") as [ string, string | undefined, ]; const side = (rawSide as TooltipSide) ?? "top"; const align: TooltipAlign = rawAlign === "start" || rawAlign === "end" ? rawAlign : "center"; return { side, align }; } /** * Positions the tooltip against the trigger. Renders a `
` element inside * a `FloatingPortal` (mounted at `document.body` by default). * * Solid 2.0-native rewire (§1 wave 2): replaces `renderElement` with * `dynamic(() => props.as ?? "div")` and inlines state-attr * getters via `mergeRootProps`. */ export function TooltipPositioner(props: TooltipPositionerProps): JSX.Element { const rootContext = useTooltipRootContext(); const side = (): TooltipSide => props.side ?? "top"; const align = (): TooltipAlign => props.align ?? "center"; const sideOffset = (): number => props.sideOffset ?? 0; void props.alignOffset; const collisionPadding = (): number => props.collisionPadding ?? 5; const keepMounted = (): boolean => props.keepMounted ?? false; const arrowRef: MutableRef = { current: null }; const [, setPositionerElement] = createSignal(null, { ownedWrite: true, }); type PhysicalSide = "top" | "bottom" | "left" | "right"; const physicalSide = (): PhysicalSide => { const s = side(); if (s === "inline-start") return "left"; if (s === "inline-end") return "right"; return s as PhysicalSide; }; const placement = createMemo(() => { const s = physicalSide(); const a = align(); if (a === "center") return s; return `${s}-${a}` as const; }); const floating = useFloating({ rootContext: rootContext.floatingRootContext, get placement() { return placement(); }, strategy: "absolute", get middleware() { return [ offset(sideOffset()), flip(), shift({ padding: collisionPadding() }), arrow({ element: arrowRef.current, offsetParent: "real" }), ]; }, }); const parsed = createMemo(() => parsePlacement(floating.placement)); const resolvedSide = (): TooltipSide => parsed().side; const resolvedAlign = (): TooltipAlign => parsed().align; const arrowUncentered = createMemo(() => { const arrowData = floating.middlewareData.arrow as | { centerOffset?: number } | undefined; return Boolean(arrowData?.centerOffset); }); const arrowStyles = createMemo>( () => { const arrowData = floating.middlewareData.arrow as | { x?: number; y?: number } | undefined; const resolved = resolvedSide(); const opposite: Record = { top: "bottom", bottom: "top", left: "right", right: "left", "inline-end": "inline-start", "inline-start": "inline-end", }; const styles: Record = { position: "absolute", }; if (arrowData?.x !== undefined) { styles.left = `${arrowData.x}px`; } if (arrowData?.y !== undefined) { styles.top = `${arrowData.y}px`; } const oppositeSide = opposite[resolved] ?? "bottom"; styles[oppositeSide] = "100%"; return styles; }, ); const anchorHidden = createMemo(() => { const hideData = floating.middlewareData.hide as | { referenceHidden?: boolean } | undefined; return Boolean(hideData?.referenceHidden); }); const state = createMemo(() => ({ open: rootContext.open(), side: resolvedSide(), align: resolvedAlign(), anchorHidden: anchorHidden(), })); const positionerContextValue: TooltipPositionerContextValue = { side: resolvedSide, align: resolvedAlign, arrowRef, arrowUncentered, arrowStyles, anchorHidden, }; const shouldRender = (): boolean => rootContext.mounted() || keepMounted(); let positionerNode: HTMLElement | null = null; const positionerRef = (el: HTMLDivElement | null) => { positionerNode = el; // opacity:0 before first paint (ref runs during DOM construction); the // reveal effect can run after the first paint in Solid 2. See // popover-positioner. `transition: none` suppresses consumer position // transitions during mount — computePosition's getBoundingClientRect // forces a layout at `transform: none`, so the first transform write // would otherwise animate in from the document origin (also see // popover-positioner). if (el) { el.style.opacity = "0"; el.style.transition = "none"; } setPositionerElement(el); floating.refs.setFloating(el); }; // Transform + reveal in ONE imperative write so the tooltip is never visible // at the stale (0,0) transform (the teleport). `opacity:0` until // `isPositioned`, matching Base UI's `useAnchorPositioning`. See // popover-positioner for the full rationale. createEffect( () => { if (!rootContext.mounted() && !keepMounted()) return null; return { transform: `translate(${Math.round(floating.x)}px, ${Math.round(floating.y)}px)`, positioned: rootContext.mounted() && (floating.isPositioned || !rootContext.open()), starting: rootContext.transitionStatus() === "starting", }; }, (data) => { if (!data || !positionerNode) return; positionerNode.style.transform = data.transform; positionerNode.style.opacity = data.positioned ? "" : "0"; // Suppress consumer position transitions until the mount settles // (see the ref above); re-enable after "starting" so anchor tracking // still animates. positionerNode.style.transition = data.starting ? "none" : ""; }, ); const positionerStyle = () => ({ position: "absolute" as const, top: 0, left: 0, "min-width": "max-content", }); const internal = { get style() { return positionerStyle(); }, get "data-open"() { return state().open ? "" : undefined; }, get "data-closed"() { return state().open ? undefined : ""; }, get "data-side"() { return state().side; }, get "data-align"() { return state().align; }, get "data-anchor-hidden"() { return state().anchorHidden ? "" : undefined; }, }; const userClassStyle = resolveClassStyle(props, () => state()); const userProps = omit( props, "as", "class", "style", "attrs", "side", "align", "sideOffset", "alignOffset", "collisionPadding", "keepMounted", ); let merged: Record = mergeRootProps( internal, userClassStyle, ); merged = mergeRootProps(merged, userProps); const Tag = dynamic(() => props.as ?? "div"); return ( ); }