Feature/popover #48
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
type Align,
|
||||||
|
type Side,
|
||||||
|
computePosition,
|
||||||
|
} from './popover-position';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a DOMRect-like object (jsdom/node has no layout).
|
||||||
|
*/
|
||||||
|
function rect(x: number, y: number, width: number, height: number): DOMRect {
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
top: y,
|
||||||
|
left: x,
|
||||||
|
right: x + width,
|
||||||
|
bottom: y + height,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewport = { width: 1000, height: 800 };
|
||||||
|
const content = { width: 200, height: 100 };
|
||||||
|
|
||||||
|
function compute(side: Side, align: Align, sideOffset = 0, trigger = rect(400, 400, 100, 40)) {
|
||||||
|
return computePosition({ triggerRect: trigger, contentRect: content, viewport, side, align, sideOffset });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computePosition', () => {
|
||||||
|
it('places below the trigger for side="bottom"', () => {
|
||||||
|
const r = compute('bottom', 'center');
|
||||||
|
expect(r.side).toBe('bottom');
|
||||||
|
expect(r.y).toBe(440); // trigger.bottom (400+40)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places above the trigger for side="top"', () => {
|
||||||
|
const r = compute('top', 'center');
|
||||||
|
expect(r.side).toBe('top');
|
||||||
|
expect(r.y).toBe(300); // trigger.top (400) - content.height (100)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies sideOffset on the main axis', () => {
|
||||||
|
const r = compute('bottom', 'center', 8);
|
||||||
|
expect(r.y).toBe(448);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aligns center on the cross axis (vertical side)', () => {
|
||||||
|
const r = compute('bottom', 'center');
|
||||||
|
// trigger center x = 450; content half = 100 -> 350
|
||||||
|
expect(r.x).toBe(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aligns start and end on the cross axis (vertical side)', () => {
|
||||||
|
expect(compute('bottom', 'start').x).toBe(400); // trigger.left
|
||||||
|
expect(compute('bottom', 'end').x).toBe(300); // trigger.right(500) - content.width(200)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places left/right with vertical cross-axis alignment', () => {
|
||||||
|
const right = compute('right', 'start');
|
||||||
|
expect(right.side).toBe('right');
|
||||||
|
expect(right.x).toBe(500); // trigger.right
|
||||||
|
expect(right.y).toBe(400); // trigger.top (align start)
|
||||||
|
const left = compute('left', 'center');
|
||||||
|
expect(left.side).toBe('left');
|
||||||
|
expect(left.x).toBe(200); // trigger.left(400) - content.width(200)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips top->bottom when there is no room above', () => {
|
||||||
|
const nearTop = rect(400, 20, 100, 40); // only 20px above, content needs 100
|
||||||
|
const r = computePosition({
|
||||||
|
triggerRect: nearTop,
|
||||||
|
contentRect: content,
|
||||||
|
viewport,
|
||||||
|
side: 'top',
|
||||||
|
align: 'center',
|
||||||
|
sideOffset: 0,
|
||||||
|
});
|
||||||
|
expect(r.side).toBe('bottom');
|
||||||
|
expect(r.y).toBe(60); // nearTop.bottom
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT flip when neither side fits (keeps requested side)', () => {
|
||||||
|
const tall = { width: 200, height: 700 };
|
||||||
|
const r = computePosition({
|
||||||
|
triggerRect: rect(400, 400, 100, 40),
|
||||||
|
contentRect: tall,
|
||||||
|
viewport,
|
||||||
|
side: 'top',
|
||||||
|
align: 'center',
|
||||||
|
sideOffset: 0,
|
||||||
|
});
|
||||||
|
expect(r.side).toBe('top');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shifts on the cross axis to stay within the viewport', () => {
|
||||||
|
const nearRight = rect(950, 400, 40, 40); // center x ~970, content 200 would overflow right
|
||||||
|
const r = computePosition({
|
||||||
|
triggerRect: nearRight,
|
||||||
|
contentRect: content,
|
||||||
|
viewport,
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'center',
|
||||||
|
sideOffset: 0,
|
||||||
|
});
|
||||||
|
expect(r.x).toBe(800); // clamped to viewport.width(1000) - content.width(200)
|
||||||
|
const nearLeft = rect(10, 400, 40, 40);
|
||||||
|
const r2 = computePosition({
|
||||||
|
triggerRect: nearLeft,
|
||||||
|
contentRect: content,
|
||||||
|
viewport,
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'center',
|
||||||
|
sideOffset: 0,
|
||||||
|
});
|
||||||
|
expect(r2.x).toBe(0); // clamped to 0
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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