feat(popover): add pure anchored-positioning math
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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<Side, Side> = {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user