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 { 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 PreviewCardAlign, PreviewCardPositionerContext, type PreviewCardPositionerContextValue, type PreviewCardSide, } from "../preview-card-positioner-context"; import { type PreviewCardInstantType, usePreviewCardRootContext, } from "../preview-card-root-context"; export interface PreviewCardPositionerState { /** Whether the preview card is currently open. */ open: boolean; /** Side of the anchor the popup is on. */ side: PreviewCardSide; /** Alignment of the popup along `side`. */ align: PreviewCardAlign; /** Whether the anchor element is hidden (off-screen). */ anchorHidden: boolean; /** Whether transitions should be skipped. */ instant: PreviewCardInstantType; } export interface PreviewCardPositionerProps extends BaseUIComponentProps<"div", PreviewCardPositionerState> { /** * Tag/component to render. Defaults to `"div"`. Replaces the * pre-spike `render` prop (axis G). */ as?: ValidComponent; /** Side of the anchor the popup should be positioned against. @default 'bottom' */ side?: PreviewCardSide | undefined; /** Alignment of the popup along the chosen side. @default 'center' */ align?: PreviewCardAlign | undefined; /** Distance between the popup and the anchor, in pixels. @default 0 */ sideOffset?: number | undefined; /** Offset along the alignment axis, in pixels. @default 0 */ alignOffset?: number | undefined; /** Padding between the popup and the viewport edge for collision avoidance. @default 5 */ collisionPadding?: number | undefined; /** Whether to keep the popup in the DOM while hidden. @default false */ keepMounted?: boolean | undefined; } /** Map `@floating-ui/dom` placement strings to Base UI's `side` / `align`. */ function parsePlacement(placement: string): { side: PreviewCardSide; align: PreviewCardAlign; } { const [rawSide, rawAlign = "center"] = placement.split("-") as [ string, string | undefined, ]; const side = (rawSide as PreviewCardSide) ?? "bottom"; const align: PreviewCardAlign = rawAlign === "start" || rawAlign === "end" ? rawAlign : "center"; return { side, align }; } /** * Positions the popup against the trigger. Renders a `
` element. * * Solid 2.0-native rewire (ยง1 wave 2): replaces `renderElement` with * `dynamic(() => props.as ?? "div")` and inlines state-attr * getters via `mergeRootProps`. */ export function PreviewCardPositioner( props: PreviewCardPositionerProps, ): JSX.Element { const root = usePreviewCardRootContext(); const side = (): PreviewCardSide => props.side ?? "bottom"; const align = (): PreviewCardAlign => props.align ?? "center"; const sideOffset = (): number => props.sideOffset ?? 0; const alignOffset = (): number => props.alignOffset ?? 0; 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: root.floatingRootContext, get placement() { return placement(); }, strategy: "absolute", get middleware() { return [ offset({ mainAxis: sideOffset(), alignmentAxis: alignOffset(), }), flip(), shift({ padding: collisionPadding() }), arrow({ element: arrowRef.current, offsetParent: "real" }), ]; }, }); const parsed = createMemo(() => parsePlacement(floating.placement)); const resolvedSide = (): PreviewCardSide => parsed().side; const resolvedAlign = (): PreviewCardAlign => 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: root.open(), side: resolvedSide(), align: resolvedAlign(), anchorHidden: anchorHidden(), instant: root.instantType(), })); const positionerContextValue: PreviewCardPositionerContextValue = { side: resolvedSide, align: resolvedAlign, arrowRef, arrowUncentered, arrowStyles, anchorHidden, }; const shouldRender = (): boolean => root.mounted() || keepMounted(); let positionerNode: HTMLElement | null = null; const positionerRef = (el: HTMLDivElement | null): void => { positionerNode = el; setPositionerElement(el); floating.refs.setFloating(el); }; // Re-run positioning when the popup mounts so arrow middleware sees a // real element (first pass may run before ``'s ref). createEffect( () => root.mounted(), () => { floating.update(); }, ); createEffect( () => { if (!root.mounted() && !keepMounted()) return null; return `translate(${Math.round(floating.x)}px, ${Math.round(floating.y)}px)`; }, (transform) => { if (!transform || !positionerNode) return; positionerNode.style.transform = transform; }, ); 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; }, get "data-instant"() { const v = state().instant; return v ? v : 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 ( ); }