Skip to content

Commit 176453e

Browse files
jsilllamanabiy
authored andcommitted
chore: add optional parent boundary clamping for popover trigger positioning (#4344)
1 parent 8af1e53 commit 176453e

File tree

6 files changed

+116
-4
lines changed

6 files changed

+116
-4
lines changed

src/internal/components/chart-popover/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ export interface ChartPopoverProps extends PopoverProps {
3434
trackKey?: string | number;
3535
minVisibleBlockSize?: number;
3636

37+
/** Optional element to clamp the popover trigger position within its bounds */
38+
triggerClampRef?: React.RefObject<HTMLElement>;
39+
3740
/** Optional container element that prevents any clicks in there from dismissing the popover */
3841
container: Element | null;
3942

@@ -74,6 +77,7 @@ function ChartPopover(
7477
trackKey,
7578
onDismiss,
7679
container,
80+
triggerClampRef,
7781
minVisibleBlockSize,
7882

7983
onMouseEnter,
@@ -137,6 +141,7 @@ function ChartPopover(
137141
trackRef={trackRef}
138142
getTrack={getTrack}
139143
trackKey={trackKey}
144+
triggerClampRef={triggerClampRef}
140145
minVisibleBlockSize={minVisibleBlockSize}
141146
arrow={position => (
142147
<div className={clsx(popoverStyles.arrow, popoverStyles[`arrow-position-${position}`])}>

src/popover/__tests__/popover-container.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,17 @@ test.each([null, document.createElement('div')])('accepts track element with get
4242
const getTrack = usePopoverPositionSpy.mock.calls[0][0].getTrack;
4343
expect(getTrack()).toBe(track);
4444
});
45+
46+
test.each([null, document.createElement('div')])('passes parentRef=%s to usePopoverPosition', parentRef => {
47+
render(
48+
<PopoverContainer
49+
{...defaultProps}
50+
triggerClampRef={{ current: parentRef }}
51+
trackRef={{ current: document.createElement('div') }}
52+
>
53+
content
54+
</PopoverContainer>
55+
);
56+
57+
expect(usePopoverPositionSpy.mock.calls[0][0].triggerClampRef?.current).toBe(parentRef);
58+
});

src/popover/__tests__/positions.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3+
import { Rect } from '../../../lib/components/popover/interfaces';
34
import {
45
calculatePosition,
6+
clampRect,
57
intersectRectangles,
68
isCenterOutside,
79
PRIORITY_MAPPING,
@@ -280,3 +282,56 @@ describe('isCenterOutside', () => {
280282
).toBe(false);
281283
});
282284
});
285+
286+
describe('clampRect', () => {
287+
const parent = { insetInlineStart: 100, insetBlockStart: 100, inlineSize: 400, blockSize: 300 };
288+
function rect(insetInlineStart: number, insetBlockStart: number, inlineSize: number, blockSize: number): Rect {
289+
return {
290+
insetInlineStart,
291+
insetBlockStart,
292+
inlineSize,
293+
blockSize,
294+
insetInlineEnd: insetInlineStart + inlineSize,
295+
insetBlockEnd: insetBlockStart + blockSize,
296+
};
297+
}
298+
299+
test('returns rect unchanged when bounds is not provided', () => {
300+
const testRect = rect(150, 200, 100, 80);
301+
expect(clampRect(testRect)).toEqual(testRect);
302+
});
303+
304+
test('returns rect unchanged when fully inside parent', () => {
305+
expect(clampRect(rect(200, 200, 50, 50), parent)).toEqual(rect(200, 200, 50, 50));
306+
});
307+
308+
test('clamps start to parent start when rect is before parent', () => {
309+
const result = clampRect(rect(50, 30, 20, 20), parent);
310+
expect(result.insetInlineStart).toBe(100);
311+
expect(result.insetBlockStart).toBe(100);
312+
});
313+
314+
test('clamps start to parent end when rect is past parent', () => {
315+
const result = clampRect(rect(600, 500, 20, 20), parent);
316+
expect(result.insetInlineStart).toBe(500);
317+
expect(result.insetBlockStart).toBe(400);
318+
});
319+
320+
test('clamps size when rect would overflow parent end', () => {
321+
const result = clampRect(rect(400, 350, 200, 100), parent);
322+
expect(result.inlineSize).toBe(100);
323+
expect(result.blockSize).toBe(50);
324+
});
325+
326+
test('reduces size to zero when start is clamped to parent end', () => {
327+
const result = clampRect(rect(600, 500, 50, 50), parent);
328+
expect(result.inlineSize).toBe(0);
329+
expect(result.blockSize).toBe(0);
330+
});
331+
332+
test('computes insetInlineEnd and insetBlockEnd correctly', () => {
333+
const result = clampRect(rect(150, 200, 100, 80), parent);
334+
expect(result.insetInlineEnd).toBe(250);
335+
expect(result.insetBlockEnd).toBe(280);
336+
});
337+
});

src/popover/container.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ interface PopoverContainerProps {
2727
*/
2828
trackKey?: string | number;
2929
minVisibleBlockSize?: number;
30+
/** Optional parent element to clamp popover position within its bounds */
31+
triggerClampRef?: React.RefObject<HTMLElement>;
3032
position: PopoverProps.Position;
3133
zIndex?: React.CSSProperties['zIndex'];
3234
arrow: (position: InternalPosition | null) => React.ReactNode;
@@ -52,6 +54,7 @@ export default function PopoverContainer({
5254
trackRef,
5355
getTrack: externalGetTrack,
5456
trackKey,
57+
triggerClampRef,
5558
minVisibleBlockSize,
5659
arrow,
5760
children,
@@ -90,6 +93,7 @@ export default function PopoverContainer({
9093
popoverRef,
9194
bodyRef,
9295
arrowRef,
96+
triggerClampRef,
9397
getTrack: getTrack.current,
9498
contentRef,
9599
allowScrollToFit,

src/popover/use-popover-position.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@ import {
1313
scrollRectangleIntoView,
1414
} from '../internal/utils/scrollable-containers';
1515
import { BoundingBox, InternalPosition, Offset, PopoverProps, Rect } from './interfaces';
16-
import { calculatePosition, getDimensions, getOffsetDimensions, isCenterOutside } from './utils/positions';
16+
import { calculatePosition, clampRect, getDimensions, getOffsetDimensions, isCenterOutside } from './utils/positions';
1717

1818
export default function usePopoverPosition({
1919
popoverRef,
2020
bodyRef,
2121
arrowRef,
2222
getTrack,
23+
triggerClampRef,
2324
contentRef,
2425
allowScrollToFit,
2526
allowVerticalOverflow,
@@ -34,6 +35,7 @@ export default function usePopoverPosition({
3435
arrowRef: React.RefObject<HTMLDivElement | null>;
3536
getTrack: () => null | HTMLElement | SVGElement;
3637
contentRef: React.RefObject<HTMLDivElement | null>;
38+
triggerClampRef?: React.RefObject<HTMLElement>;
3739
allowScrollToFit?: boolean;
3840
allowVerticalOverflow?: boolean;
3941
preferredPosition: PopoverProps.Position;
@@ -88,7 +90,7 @@ export default function usePopoverPosition({
8890
// Get rects representing key elements
8991
// Use getComputedStyle for arrowRect to avoid modifications made by transform
9092
const viewportRect = getViewportRect(document.defaultView!);
91-
const trackRect = getLogicalBoundingClientRect(track);
93+
const trackRect = getClampedTrackRect(track, triggerClampRef?.current);
9294
const arrowRect = getDimensions(arrow);
9395
const { containingBlock, boundary } = findUpUntilMultiple({
9496
startElement: popover,
@@ -183,7 +185,7 @@ export default function usePopoverPosition({
183185
if (!track) {
184186
return;
185187
}
186-
const trackRect = getLogicalBoundingClientRect(track);
188+
const trackRect = getClampedTrackRect(track, triggerClampRef?.current);
187189

188190
const newTrackOffset = toRelativePosition(
189191
trackRect,
@@ -209,13 +211,14 @@ export default function usePopoverPosition({
209211
bodyRef,
210212
contentRef,
211213
arrowRef,
214+
triggerClampRef,
212215
keepPosition,
213216
preferredPosition,
214217
renderWithPortal,
215218
allowVerticalOverflow,
219+
minVisibleBlockSize,
216220
allowScrollToFit,
217221
hideOnOverscroll,
218-
minVisibleBlockSize,
219222
]
220223
);
221224
return { updatePositionHandler, popoverStyle, internalPosition, positionHandlerRef, isOverscrolling };
@@ -262,3 +265,9 @@ function isBoundary(element: HTMLElement) {
262265
const computedStyle = getComputedStyle(element);
263266
return !!computedStyle.clipPath && computedStyle.clipPath !== 'none';
264267
}
268+
269+
function getClampedTrackRect(track: HTMLElement | SVGElement, parentRef?: HTMLElement | null) {
270+
const rect = getLogicalBoundingClientRect(track);
271+
const bounds = parentRef ? getLogicalBoundingClientRect(parentRef) : undefined;
272+
return clampRect(rect, bounds);
273+
}

src/popover/utils/positions.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,31 @@ function isTopOrBottom(internalPosition: InternalPosition) {
345345
return ['top', 'bottom'].includes(internalPosition.split('-')[0]);
346346
}
347347

348+
export function clampRect(rect: Rect, bounds?: BoundingBox) {
349+
if (!bounds) {
350+
return rect;
351+
}
352+
const parentInlineEnd = bounds.insetInlineStart + bounds.inlineSize;
353+
const parentBlockEnd = bounds.insetBlockStart + bounds.blockSize;
354+
355+
const clampedInsetInlineStart = Math.max(bounds.insetInlineStart, Math.min(rect.insetInlineStart, parentInlineEnd));
356+
const clampedInsetBlockStart = Math.max(bounds.insetBlockStart, Math.min(rect.insetBlockStart, parentBlockEnd));
357+
358+
const maxInlineSize = parentInlineEnd - clampedInsetInlineStart;
359+
const maxBlockSize = parentBlockEnd - clampedInsetBlockStart;
360+
const clampedInlineSize = Math.min(rect.inlineSize, maxInlineSize);
361+
const clampedBlockSize = Math.min(rect.blockSize, maxBlockSize);
362+
363+
return {
364+
insetInlineStart: clampedInsetInlineStart,
365+
insetBlockStart: clampedInsetBlockStart,
366+
inlineSize: clampedInlineSize,
367+
blockSize: clampedBlockSize,
368+
insetInlineEnd: clampedInsetInlineStart + clampedInlineSize,
369+
insetBlockEnd: clampedInsetBlockStart + clampedBlockSize,
370+
};
371+
}
372+
348373
export function isCenterOutside(child: Rect, parent: Rect) {
349374
const childCenter = child.insetBlockStart + child.blockSize / 2;
350375
const overflowsBlockStart = childCenter < parent.insetBlockStart;

0 commit comments

Comments
 (0)