diff --git a/src/shared/ui/Popover/popover-position.test.ts b/src/shared/ui/Popover/popover-position.test.ts new file mode 100644 index 0000000..25f13d8 --- /dev/null +++ b/src/shared/ui/Popover/popover-position.test.ts @@ -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 + }); +}); diff --git a/src/shared/ui/Popover/popover-position.ts b/src/shared/ui/Popover/popover-position.ts new file mode 100644 index 0000000..f9b1a11 --- /dev/null +++ b/src/shared/ui/Popover/popover-position.ts @@ -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 = { + 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 }; +}