Skip to content

Commit e7aa0a6

Browse files
authored
feat(packages): constrain popovers to positioning boundary (#1627)
1 parent 93c92ff commit e7aa0a6

2 files changed

Lines changed: 199 additions & 4 deletions

File tree

packages/core/src/dom/ui/popover/popover-positioning.ts

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface PopoverPositionStyle {
3636
alignSelf?: string;
3737
marginInlineStart?: string;
3838
marginBlockStart?: string;
39+
translate?: string;
3940
top?: string;
4041
bottom?: string;
4142
left?: string;
@@ -71,6 +72,33 @@ function getCrossAxisAvailable(
7172
return Math.min(center - boundaryStart, boundaryEnd - center) * 2;
7273
}
7374

75+
function shiftCrossAxis(value: number, boundaryStart: number, boundaryEnd: number, size: number): number {
76+
const max = boundaryEnd - size;
77+
return max < boundaryStart ? boundaryStart : clamp(value, boundaryStart, max);
78+
}
79+
80+
function getAnchorCrossAxisShift(
81+
start: number,
82+
end: number,
83+
size: number,
84+
boundaryStart: number,
85+
boundaryEnd: number,
86+
align: PopoverAlign,
87+
alignOffset: number,
88+
boundaryOffset: number
89+
): { base: string; translate: string } {
90+
const base =
91+
align === 'start' ? start + alignOffset : align === 'end' ? end + alignOffset : start + size / 2 + alignOffset;
92+
const desiredTranslate = align === 'start' ? '0px' : align === 'end' ? '-100%' : '-50%';
93+
94+
return {
95+
base: `${base}px`,
96+
translate: `clamp(${boundaryStart + boundaryOffset - base}px, ${desiredTranslate}, calc(${
97+
boundaryEnd - boundaryOffset - base
98+
}px - 100%))`,
99+
};
100+
}
101+
74102
/**
75103
* Get positioning styles for the popup element.
76104
*
@@ -97,7 +125,7 @@ export function getAnchorPositionStyle(
97125
): PopoverPositionStyle & Record<string, string | undefined> {
98126
if (supportsAnchorPositioning()) {
99127
return {
100-
...getAnchorPositionCSS(anchorName, opts, cssVars),
128+
...getAnchorPositionCSS(anchorName, opts, cssVars, triggerRect, boundaryRect, offsets),
101129
...(triggerRect && boundaryRect ? getPositioningCSSVars(triggerRect, boundaryRect, opts, offsets, cssVars) : {}),
102130
};
103131
}
@@ -106,7 +134,7 @@ export function getAnchorPositionStyle(
106134
if (triggerRect && popupRect) {
107135
const resolved: ManualOffsets = offsets ?? ZERO_OFFSETS;
108136
return {
109-
...getManualPositionStyle(triggerRect, popupRect, opts, resolved),
137+
...getManualPositionStyle(triggerRect, popupRect, opts, resolved, boundaryRect),
110138
...(boundaryRect ? getPositioningCSSVars(triggerRect, boundaryRect, opts, resolved, cssVars) : {}),
111139
position: 'fixed',
112140
// Reset UA [popover] defaults (inset: 0; margin: auto) which would
@@ -128,11 +156,15 @@ export function getAnchorNameStyle(anchorName: string) {
128156
function getAnchorPositionCSS(
129157
anchorName: string,
130158
opts: PositioningOptions,
131-
cssVars: PositioningCSSVars = PopoverCSSVars
159+
cssVars: PositioningCSSVars = PopoverCSSVars,
160+
triggerRect?: DOMRect,
161+
boundaryRect?: DOMRect,
162+
offsets: ManualOffsets = ZERO_OFFSETS
132163
): PopoverPositionStyle {
133164
const SIDE_OFFSET_VAR = `var(${cssVars.sideOffset}, 0px)`;
134165
const ALIGN_OFFSET_VAR = `var(${cssVars.alignOffset}, 0px)`;
135166
const { side, align } = opts;
167+
const boundaryOffset = offsets.boundaryOffset ?? 0;
136168
const style: PopoverPositionStyle = {
137169
positionAnchor: `--${anchorName}`,
138170
position: 'fixed',
@@ -146,6 +178,7 @@ function getAnchorPositionCSS(
146178
alignSelf: 'normal',
147179
marginInlineStart: '0',
148180
marginBlockStart: '0',
181+
translate: 'none',
149182
};
150183

151184
// The CSS inset property is the OPPOSITE of the desired side.
@@ -158,6 +191,24 @@ function getAnchorPositionCSS(
158191
if (side === 'top' || side === 'bottom') {
159192
style[insetProp] = `calc(anchor(${side}) + ${SIDE_OFFSET_VAR})`;
160193

194+
if (triggerRect && boundaryRect) {
195+
const { base, translate } = getAnchorCrossAxisShift(
196+
triggerRect.left,
197+
triggerRect.right,
198+
triggerRect.width,
199+
boundaryRect.left,
200+
boundaryRect.right,
201+
align,
202+
offsets.alignOffset,
203+
boundaryOffset
204+
);
205+
206+
style.left = base;
207+
style.translate = `${translate} 0`;
208+
209+
return style;
210+
}
211+
161212
// Alignment along the cross axis
162213
if (align === 'start') {
163214
style.left = `calc(anchor(left) + ${ALIGN_OFFSET_VAR})`;
@@ -170,6 +221,24 @@ function getAnchorPositionCSS(
170221
} else {
171222
style[insetProp] = `calc(anchor(${side}) + ${SIDE_OFFSET_VAR})`;
172223

224+
if (triggerRect && boundaryRect) {
225+
const { base, translate } = getAnchorCrossAxisShift(
226+
triggerRect.top,
227+
triggerRect.bottom,
228+
triggerRect.height,
229+
boundaryRect.top,
230+
boundaryRect.bottom,
231+
align,
232+
offsets.alignOffset,
233+
boundaryOffset
234+
);
235+
236+
style.top = base;
237+
style.translate = `0 ${translate}`;
238+
239+
return style;
240+
}
241+
173242
if (align === 'start') {
174243
style.top = `calc(anchor(top) + ${ALIGN_OFFSET_VAR})`;
175244
} else if (align === 'end') {
@@ -280,7 +349,8 @@ export function getManualPositionStyle(
280349
triggerRect: DOMRect,
281350
popupRect: DOMRect,
282351
opts: PositioningOptions,
283-
offsets: ManualOffsets = { sideOffset: 0, alignOffset: 0 }
352+
offsets: ManualOffsets = { sideOffset: 0, alignOffset: 0 },
353+
boundaryRect?: DOMRect
284354
) {
285355
const { side, align } = opts;
286356
const { sideOffset, alignOffset } = offsets;
@@ -318,6 +388,26 @@ export function getManualPositionStyle(
318388
}
319389
}
320390

391+
if (boundaryRect) {
392+
const boundaryOffset = offsets.boundaryOffset ?? 0;
393+
394+
if (side === 'top' || side === 'bottom') {
395+
left = shiftCrossAxis(
396+
left,
397+
boundaryRect.left + boundaryOffset,
398+
boundaryRect.right - boundaryOffset,
399+
popupRect.width
400+
);
401+
} else {
402+
top = shiftCrossAxis(
403+
top,
404+
boundaryRect.top + boundaryOffset,
405+
boundaryRect.bottom - boundaryOffset,
406+
popupRect.height
407+
);
408+
}
409+
}
410+
321411
return {
322412
top: `${top}px`,
323413
left: `${left}px`,

packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,95 @@ describe('getManualPositionStyle', () => {
112112
// top = trigger.top = 200
113113
expect(style.top).toBe('200px');
114114
});
115+
116+
it('shifts top and bottom popups horizontally inside the boundary', () => {
117+
const boundary = makeDOMRect(0, 0, 300, 200);
118+
const rightEdgeTrigger = makeDOMRect(250, 100, 40, 20);
119+
const leftEdgeTrigger = makeDOMRect(10, 100, 40, 20);
120+
const edgePopup = makeDOMRect(0, 0, 100, 50);
121+
122+
const topStyle = getManualPositionStyle(
123+
rightEdgeTrigger,
124+
edgePopup,
125+
{ side: 'top', align: 'center' },
126+
undefined,
127+
boundary
128+
);
129+
const bottomStyle = getManualPositionStyle(
130+
leftEdgeTrigger,
131+
edgePopup,
132+
{ side: 'bottom', align: 'center' },
133+
undefined,
134+
boundary
135+
);
136+
137+
expect(topStyle.top).toBe('50px');
138+
expect(topStyle.left).toBe('200px');
139+
expect(bottomStyle.top).toBe('120px');
140+
expect(bottomStyle.left).toBe('0px');
141+
});
142+
143+
it('shifts left and right popups vertically inside the boundary', () => {
144+
const boundary = makeDOMRect(0, 0, 300, 200);
145+
const bottomEdgeTrigger = makeDOMRect(100, 170, 40, 20);
146+
const topEdgeTrigger = makeDOMRect(100, 10, 40, 20);
147+
const edgePopup = makeDOMRect(0, 0, 80, 80);
148+
149+
const rightStyle = getManualPositionStyle(
150+
bottomEdgeTrigger,
151+
edgePopup,
152+
{ side: 'right', align: 'center' },
153+
undefined,
154+
boundary
155+
);
156+
const leftStyle = getManualPositionStyle(
157+
topEdgeTrigger,
158+
edgePopup,
159+
{ side: 'left', align: 'center' },
160+
undefined,
161+
boundary
162+
);
163+
164+
expect(rightStyle.top).toBe('120px');
165+
expect(rightStyle.left).toBe('140px');
166+
expect(leftStyle.top).toBe('0px');
167+
expect(leftStyle.left).toBe('20px');
168+
});
169+
170+
it('respects boundary offset when shifting cross-axis overflow', () => {
171+
const boundary = makeDOMRect(0, 0, 300, 200);
172+
const edgeTrigger = makeDOMRect(250, 100, 40, 20);
173+
const edgePopup = makeDOMRect(0, 0, 100, 50);
174+
const offsets: ManualOffsets = { sideOffset: 0, alignOffset: 0, boundaryOffset: 12 };
175+
176+
const style = getManualPositionStyle(
177+
edgeTrigger,
178+
edgePopup,
179+
{ side: 'bottom', align: 'center' },
180+
offsets,
181+
boundary
182+
);
183+
184+
expect(style.top).toBe('120px');
185+
expect(style.left).toBe('188px');
186+
});
187+
188+
it('does not shift side-axis overflow', () => {
189+
const boundary = makeDOMRect(0, 0, 300, 200);
190+
const edgeTrigger = makeDOMRect(100, 210, 40, 20);
191+
const edgePopup = makeDOMRect(0, 0, 80, 50);
192+
193+
const style = getManualPositionStyle(
194+
edgeTrigger,
195+
edgePopup,
196+
{ side: 'bottom', align: 'center' },
197+
undefined,
198+
boundary
199+
);
200+
201+
expect(style.top).toBe('230px');
202+
expect(style.left).toBe('80px');
203+
});
115204
});
116205

117206
describe('getPopoverCSSVars', () => {
@@ -326,6 +415,22 @@ describe('getAnchorPositionStyle (CSS Anchor Positioning)', () => {
326415
expect(style.position).toBe('fixed');
327416
});
328417

418+
it('uses CSS cross-axis shifting when boundary rects are available', async () => {
419+
const getStyle = await importWithAnchorSupport();
420+
const boundary = makeDOMRect(0, 0, 300, 200);
421+
const trigger = makeDOMRect(20, 100, 30, 20);
422+
const style = getStyle('my-popover', { side: 'top', align: 'center' }, trigger, undefined, boundary, {
423+
sideOffset: 0,
424+
alignOffset: 0,
425+
boundaryOffset: 8,
426+
});
427+
428+
expect(style.positionAnchor).toBe('--my-popover');
429+
expect(style.bottom).toBe('calc(anchor(top) + var(--media-popover-side-offset, 0px))');
430+
expect(style.left).toBe('35px');
431+
expect(style.translate).toBe('clamp(-27px, -50%, calc(257px - 100%)) 0');
432+
});
433+
329434
it('places popover above trigger for side=top using CSS var offset', async () => {
330435
const getStyle = await importWithAnchorSupport();
331436
const style = getStyle('a', { side: 'top', align: 'center' });

0 commit comments

Comments
 (0)