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