From e7aa0a66699ffa7296ca714313a4bf05290192fb Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Fri, 29 May 2026 16:26:15 +1000 Subject: [PATCH] feat(packages): constrain popovers to positioning boundary (#1627) --- .../src/dom/ui/popover/popover-positioning.ts | 98 +++++++++++++++- .../popover/tests/popover-positioning.test.ts | 105 ++++++++++++++++++ 2 files changed, 199 insertions(+), 4 deletions(-) diff --git a/packages/core/src/dom/ui/popover/popover-positioning.ts b/packages/core/src/dom/ui/popover/popover-positioning.ts index dfd024fb..063fcfa5 100644 --- a/packages/core/src/dom/ui/popover/popover-positioning.ts +++ b/packages/core/src/dom/ui/popover/popover-positioning.ts @@ -36,6 +36,7 @@ export interface PopoverPositionStyle { alignSelf?: string; marginInlineStart?: string; marginBlockStart?: string; + translate?: string; top?: string; bottom?: string; left?: string; @@ -71,6 +72,33 @@ function getCrossAxisAvailable( return Math.min(center - boundaryStart, boundaryEnd - center) * 2; } +function shiftCrossAxis(value: number, boundaryStart: number, boundaryEnd: number, size: number): number { + const max = boundaryEnd - size; + return max < boundaryStart ? boundaryStart : clamp(value, boundaryStart, max); +} + +function getAnchorCrossAxisShift( + start: number, + end: number, + size: number, + boundaryStart: number, + boundaryEnd: number, + align: PopoverAlign, + alignOffset: number, + boundaryOffset: number +): { base: string; translate: string } { + const base = + align === 'start' ? start + alignOffset : align === 'end' ? end + alignOffset : start + size / 2 + alignOffset; + const desiredTranslate = align === 'start' ? '0px' : align === 'end' ? '-100%' : '-50%'; + + return { + base: `${base}px`, + translate: `clamp(${boundaryStart + boundaryOffset - base}px, ${desiredTranslate}, calc(${ + boundaryEnd - boundaryOffset - base + }px - 100%))`, + }; +} + /** * Get positioning styles for the popup element. * @@ -97,7 +125,7 @@ export function getAnchorPositionStyle( ): PopoverPositionStyle & Record { if (supportsAnchorPositioning()) { return { - ...getAnchorPositionCSS(anchorName, opts, cssVars), + ...getAnchorPositionCSS(anchorName, opts, cssVars, triggerRect, boundaryRect, offsets), ...(triggerRect && boundaryRect ? getPositioningCSSVars(triggerRect, boundaryRect, opts, offsets, cssVars) : {}), }; } @@ -106,7 +134,7 @@ export function getAnchorPositionStyle( if (triggerRect && popupRect) { const resolved: ManualOffsets = offsets ?? ZERO_OFFSETS; return { - ...getManualPositionStyle(triggerRect, popupRect, opts, resolved), + ...getManualPositionStyle(triggerRect, popupRect, opts, resolved, boundaryRect), ...(boundaryRect ? getPositioningCSSVars(triggerRect, boundaryRect, opts, resolved, cssVars) : {}), position: 'fixed', // Reset UA [popover] defaults (inset: 0; margin: auto) which would @@ -128,11 +156,15 @@ export function getAnchorNameStyle(anchorName: string) { function getAnchorPositionCSS( anchorName: string, opts: PositioningOptions, - cssVars: PositioningCSSVars = PopoverCSSVars + cssVars: PositioningCSSVars = PopoverCSSVars, + triggerRect?: DOMRect, + boundaryRect?: DOMRect, + offsets: ManualOffsets = ZERO_OFFSETS ): PopoverPositionStyle { const SIDE_OFFSET_VAR = `var(${cssVars.sideOffset}, 0px)`; const ALIGN_OFFSET_VAR = `var(${cssVars.alignOffset}, 0px)`; const { side, align } = opts; + const boundaryOffset = offsets.boundaryOffset ?? 0; const style: PopoverPositionStyle = { positionAnchor: `--${anchorName}`, position: 'fixed', @@ -146,6 +178,7 @@ function getAnchorPositionCSS( alignSelf: 'normal', marginInlineStart: '0', marginBlockStart: '0', + translate: 'none', }; // The CSS inset property is the OPPOSITE of the desired side. @@ -158,6 +191,24 @@ function getAnchorPositionCSS( if (side === 'top' || side === 'bottom') { style[insetProp] = `calc(anchor(${side}) + ${SIDE_OFFSET_VAR})`; + if (triggerRect && boundaryRect) { + const { base, translate } = getAnchorCrossAxisShift( + triggerRect.left, + triggerRect.right, + triggerRect.width, + boundaryRect.left, + boundaryRect.right, + align, + offsets.alignOffset, + boundaryOffset + ); + + style.left = base; + style.translate = `${translate} 0`; + + return style; + } + // Alignment along the cross axis if (align === 'start') { style.left = `calc(anchor(left) + ${ALIGN_OFFSET_VAR})`; @@ -170,6 +221,24 @@ function getAnchorPositionCSS( } else { style[insetProp] = `calc(anchor(${side}) + ${SIDE_OFFSET_VAR})`; + if (triggerRect && boundaryRect) { + const { base, translate } = getAnchorCrossAxisShift( + triggerRect.top, + triggerRect.bottom, + triggerRect.height, + boundaryRect.top, + boundaryRect.bottom, + align, + offsets.alignOffset, + boundaryOffset + ); + + style.top = base; + style.translate = `0 ${translate}`; + + return style; + } + if (align === 'start') { style.top = `calc(anchor(top) + ${ALIGN_OFFSET_VAR})`; } else if (align === 'end') { @@ -280,7 +349,8 @@ export function getManualPositionStyle( triggerRect: DOMRect, popupRect: DOMRect, opts: PositioningOptions, - offsets: ManualOffsets = { sideOffset: 0, alignOffset: 0 } + offsets: ManualOffsets = { sideOffset: 0, alignOffset: 0 }, + boundaryRect?: DOMRect ) { const { side, align } = opts; const { sideOffset, alignOffset } = offsets; @@ -318,6 +388,26 @@ export function getManualPositionStyle( } } + if (boundaryRect) { + const boundaryOffset = offsets.boundaryOffset ?? 0; + + if (side === 'top' || side === 'bottom') { + left = shiftCrossAxis( + left, + boundaryRect.left + boundaryOffset, + boundaryRect.right - boundaryOffset, + popupRect.width + ); + } else { + top = shiftCrossAxis( + top, + boundaryRect.top + boundaryOffset, + boundaryRect.bottom - boundaryOffset, + popupRect.height + ); + } + } + return { top: `${top}px`, left: `${left}px`, diff --git a/packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts b/packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts index 8489aa5f..37509d7b 100644 --- a/packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts +++ b/packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts @@ -112,6 +112,95 @@ describe('getManualPositionStyle', () => { // top = trigger.top = 200 expect(style.top).toBe('200px'); }); + + it('shifts top and bottom popups horizontally inside the boundary', () => { + const boundary = makeDOMRect(0, 0, 300, 200); + const rightEdgeTrigger = makeDOMRect(250, 100, 40, 20); + const leftEdgeTrigger = makeDOMRect(10, 100, 40, 20); + const edgePopup = makeDOMRect(0, 0, 100, 50); + + const topStyle = getManualPositionStyle( + rightEdgeTrigger, + edgePopup, + { side: 'top', align: 'center' }, + undefined, + boundary + ); + const bottomStyle = getManualPositionStyle( + leftEdgeTrigger, + edgePopup, + { side: 'bottom', align: 'center' }, + undefined, + boundary + ); + + expect(topStyle.top).toBe('50px'); + expect(topStyle.left).toBe('200px'); + expect(bottomStyle.top).toBe('120px'); + expect(bottomStyle.left).toBe('0px'); + }); + + it('shifts left and right popups vertically inside the boundary', () => { + const boundary = makeDOMRect(0, 0, 300, 200); + const bottomEdgeTrigger = makeDOMRect(100, 170, 40, 20); + const topEdgeTrigger = makeDOMRect(100, 10, 40, 20); + const edgePopup = makeDOMRect(0, 0, 80, 80); + + const rightStyle = getManualPositionStyle( + bottomEdgeTrigger, + edgePopup, + { side: 'right', align: 'center' }, + undefined, + boundary + ); + const leftStyle = getManualPositionStyle( + topEdgeTrigger, + edgePopup, + { side: 'left', align: 'center' }, + undefined, + boundary + ); + + expect(rightStyle.top).toBe('120px'); + expect(rightStyle.left).toBe('140px'); + expect(leftStyle.top).toBe('0px'); + expect(leftStyle.left).toBe('20px'); + }); + + it('respects boundary offset when shifting cross-axis overflow', () => { + const boundary = makeDOMRect(0, 0, 300, 200); + const edgeTrigger = makeDOMRect(250, 100, 40, 20); + const edgePopup = makeDOMRect(0, 0, 100, 50); + const offsets: ManualOffsets = { sideOffset: 0, alignOffset: 0, boundaryOffset: 12 }; + + const style = getManualPositionStyle( + edgeTrigger, + edgePopup, + { side: 'bottom', align: 'center' }, + offsets, + boundary + ); + + expect(style.top).toBe('120px'); + expect(style.left).toBe('188px'); + }); + + it('does not shift side-axis overflow', () => { + const boundary = makeDOMRect(0, 0, 300, 200); + const edgeTrigger = makeDOMRect(100, 210, 40, 20); + const edgePopup = makeDOMRect(0, 0, 80, 50); + + const style = getManualPositionStyle( + edgeTrigger, + edgePopup, + { side: 'bottom', align: 'center' }, + undefined, + boundary + ); + + expect(style.top).toBe('230px'); + expect(style.left).toBe('80px'); + }); }); describe('getPopoverCSSVars', () => { @@ -326,6 +415,22 @@ describe('getAnchorPositionStyle (CSS Anchor Positioning)', () => { expect(style.position).toBe('fixed'); }); + it('uses CSS cross-axis shifting when boundary rects are available', async () => { + const getStyle = await importWithAnchorSupport(); + const boundary = makeDOMRect(0, 0, 300, 200); + const trigger = makeDOMRect(20, 100, 30, 20); + const style = getStyle('my-popover', { side: 'top', align: 'center' }, trigger, undefined, boundary, { + sideOffset: 0, + alignOffset: 0, + boundaryOffset: 8, + }); + + expect(style.positionAnchor).toBe('--my-popover'); + expect(style.bottom).toBe('calc(anchor(top) + var(--media-popover-side-offset, 0px))'); + expect(style.left).toBe('35px'); + expect(style.translate).toBe('clamp(-27px, -50%, calc(257px - 100%)) 0'); + }); + it('places popover above trigger for side=top using CSS var offset', async () => { const getStyle = await importWithAnchorSupport(); const style = getStyle('a', { side: 'top', align: 'center' });