mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
feat(packages): constrain popovers to positioning boundary (#1627)
This commit is contained in:
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user