import { clampNumber } from '$shared/lib/utils'; /** * Side of the trigger the content prefers to open toward. */ export type Side = 'top' | 'bottom' | 'left' | 'right'; /** * Cross-axis alignment of the content relative to the trigger. */ export type Align = 'start' | 'center' | 'end'; /** * Inputs for a single placement computation. All geometry is injected * (no DOM reads) so the function stays pure and unit-testable. */ type ComputeArgs = { /** * Trigger bounding rect (viewport coordinates). */ triggerRect: DOMRect; /** * Measured content size. */ contentRect: { width: number; height: number }; /** * Viewport size. */ viewport: { width: number; height: number }; /** * Preferred side. */ side: Side; /** * Cross-axis alignment. */ align: Align; /** * Gap between trigger and content on the main axis. */ sideOffset: number; }; /** * Resolved placement: fixed-position coordinates plus the side actually used * (may differ from the requested side after a flip). */ type ComputeResult = { x: number; y: number; side: Side }; const OPPOSITE: Record = { top: 'bottom', bottom: 'top', left: 'right', right: 'left', }; /** * True for sides whose main axis is vertical (content sits above/below). */ function isVertical(side: Side): boolean { return side === 'top' || side === 'bottom'; } /** * Main-axis coordinate (top for vertical sides, left for horizontal sides). */ function mainAxisCoord(side: Side, t: DOMRect, c: { width: number; height: number }, offset: number): number { switch (side) { case 'top': return t.top - c.height - offset; case 'bottom': return t.bottom + offset; case 'left': return t.left - c.width - offset; case 'right': return t.right + offset; } } /** * Whether the content fits on the given side within the viewport. */ function fitsOnSide( side: Side, t: DOMRect, c: { width: number; height: number }, v: { width: number; height: number }, offset: number, ): boolean { const coord = mainAxisCoord(side, t, c, offset); switch (side) { case 'top': return coord >= 0; case 'left': return coord >= 0; case 'bottom': return coord + c.height <= v.height; case 'right': return coord + c.width <= v.width; } } /** * Cross-axis coordinate for the requested alignment. */ function crossAxisCoord(side: Side, align: Align, t: DOMRect, c: { width: number; height: number }): number { if (isVertical(side)) { if (align === 'start') { return t.left; } if (align === 'end') { return t.right - c.width; } return t.left + t.width / 2 - c.width / 2; } if (align === 'start') { return t.top; } if (align === 'end') { return t.bottom - c.height; } return t.top + t.height / 2 - c.height / 2; } /** * Compute an anchored placement with flip (to the opposite side when the * preferred side doesn't fit but the opposite does) and shift (clamp the * cross axis so the content stays within the viewport). */ export function computePosition(args: ComputeArgs): ComputeResult { const { triggerRect: t, contentRect: c, viewport: v, align, sideOffset } = args; let side = args.side; if (!fitsOnSide(side, t, c, v, sideOffset) && fitsOnSide(OPPOSITE[side], t, c, v, sideOffset)) { side = OPPOSITE[side]; } let x: number; let y: number; if (isVertical(side)) { y = mainAxisCoord(side, t, c, sideOffset); x = clampNumber(crossAxisCoord(side, align, t, c), 0, Math.max(0, v.width - c.width)); } else { x = mainAxisCoord(side, t, c, sideOffset); y = clampNumber(crossAxisCoord(side, align, t, c), 0, Math.max(0, v.height - c.height)); } return { x, y, side }; }