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 (
);
}