feat(packages): constrain popovers to positioning boundary (#1627)

This commit is contained in:
Sam Potts
2026-05-29 16:26:15 +10:00
committed by GitHub
parent 93c92ff646
commit e7aa0a6669
2 changed files with 199 additions and 4 deletions
@@ -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<string, string | undefined> {
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`,
@@ -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' });