chore: add optional parent boundary clamping for popover trigger positioning#4344
chore: add optional parent boundary clamping for popover trigger positioning#4344jperals merged 14 commits intocloudscape-design:mainfrom
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4344 +/- ##
=======================================
Coverage 97.44% 97.45%
=======================================
Files 901 901
Lines 26419 26435 +16
Branches 9521 9524 +3
=======================================
+ Hits 25745 25761 +16
- Misses 631 668 +37
+ Partials 43 6 -37 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
I updated the PR title to fit the format accepted by our release notes script (just |
src/popover/use-popover-position.ts
Outdated
| const parentBlockEnd = parentRect.insetBlockStart + parentRect.blockSize; | ||
|
|
||
| // Find where the arrow should point (closest visible point to actual track center) | ||
| const trackCenterInline = trackRect.insetInlineStart + trackRect.inlineSize / 2; |
There was a problem hiding this comment.
Why do we need to take the center of the target into account? Wouldn't it be enough to return the rectangle resulting from intersecting the track and the parent?
There was a problem hiding this comment.
I made it in such a way that the popover arrow always points to the closest visible point to the actual track center. I think we could do that yes but it would behave slightly differently.
There was a problem hiding this comment.
I'll try to make a much simpler version of this
| return Math.max(min, Math.min(value, max)); | ||
| } | ||
|
|
||
| function getClampedTrackRect(track: HTMLElement | SVGElement, parentRef?: HTMLElement | null) { |
There was a problem hiding this comment.
Can you add unit testing coverage for this new logic?
There was a problem hiding this comment.
Yes, sure! I just wanted to get your feedback on the implementation first.
There was a problem hiding this comment.
Thanks! Note, Codecov is still complaining about line 280 below not being covered. You might want to either add coverage for getClampedTrackRect specifically, or some case(s) for the popover component using a value for parent (if not too complicated, I'd personally prefer the latter, because it would test the integration of the logic in the component rather than in isolation).
There was a problem hiding this comment.
I agree with the latter approach as well!
There was a problem hiding this comment.
Thanks for adding the test, although Codecov still complains about line 280 not being covered. Could this be because by asserting
expect(usePopoverPositionSpy.mock.calls[0][0].parentRef?.current).toBe(parentRef);
you are only ever checking the first call, which corresponds to when the ref is null?
src/popover/utils/positions.ts
Outdated
| return ['top', 'bottom'].includes(internalPosition.split('-')[0]); | ||
| } | ||
|
|
||
| export function clampRectStart<T extends BoundingBox>(rect: T, parentRect: BoundingBox): T { |
There was a problem hiding this comment.
Why the generic T? Can we be more specific?
There was a problem hiding this comment.
Oh I missed the already defined Rect type
There was a problem hiding this comment.
Thanks! Now the tests complain about the requiring insetBlockEnd and insetInlineEnd not being passed (see, TypeScript doing its job 😛)
src/popover/utils/positions.ts
Outdated
| ...rect, | ||
| insetInlineStart: Math.max(parentRect.insetInlineStart, Math.min(rect.insetInlineStart, parentInlineEnd)), | ||
| insetBlockStart: Math.max(parentRect.insetBlockStart, Math.min(rect.insetBlockStart, parentBlockEnd)), | ||
| } as T; |
There was a problem hiding this comment.
Can we get rid of the as T for better type safety?
| export function clampRectStart<T extends BoundingBox>(rect: T, parentRect: BoundingBox): T { | ||
| const parentInlineEnd = parentRect.insetInlineStart + parentRect.inlineSize; | ||
| const parentBlockEnd = parentRect.insetBlockStart + parentRect.blockSize; | ||
| return { |
There was a problem hiding this comment.
Although in the case we are trying to solve we only care about the "inline start" side, and the new API is internal, nothing in it indicates that the parent will be used to clamp only from that side. Unless it adds considerable complexity, would it be possible to clamp all sides?
There was a problem hiding this comment.
We're already clamping both the inline and block directions. While working on this I found out that doing it for the block direction actually fixes a problem in the current version of the chart-components library.
Screen.Recording.2026-03-12.at.09.35.21.mov
There was a problem hiding this comment.
In this new simplified implementation I'm not clamping inlineSize or blockSize which means the center of the track can still overflow by a very tiny amount outside of the container in some cases. I think this is small enough issue for us to ignore.
There was a problem hiding this comment.
What I meant is that we are clamping on the "start" (of both inline and block directions) but not on the "end".
There was a problem hiding this comment.
Thanks for the changes! Should the function be renamed accordingly, e.g., to just clampRect? :)
| return Math.max(min, Math.min(value, max)); | ||
| } | ||
|
|
||
| function getClampedTrackRect(track: HTMLElement | SVGElement, parentRef?: HTMLElement | null) { |
There was a problem hiding this comment.
Thanks! Note, Codecov is still complaining about line 280 below not being covered. You might want to either add coverage for getClampedTrackRect specifically, or some case(s) for the popover component using a value for parent (if not too complicated, I'd personally prefer the latter, because it would test the integration of the logic in the component rather than in isolation).
src/popover/utils/positions.ts
Outdated
| export function clampRectStart(rect: Rect, bounds: BoundingBox) { | ||
| const parentInlineEnd = bounds.insetInlineStart + bounds.inlineSize; | ||
| const parentBlockEnd = bounds.insetBlockStart + bounds.blockSize; | ||
| rect.insetInlineStart = Math.max(bounds.insetInlineStart, Math.min(rect.insetInlineStart, parentInlineEnd)); |
There was a problem hiding this comment.
Any particular reason to modify the original passed-in object rather than creating a new one?
There was a problem hiding this comment.
Looking again at the function I preferred to have it mutate the argument instead of creating a new one. I know this is mostly a matter of personal preference so if you prefer to create the new object I'm also fine with that.
There was a problem hiding this comment.
In general it is preferred to not mutate objects, because from within the function you don't know what else is the object is used for, so its mutation might be unexpected to a user outside of it, leading to hard-to-debug issues.
src/popover/container.tsx
Outdated
| trackKey?: string | number; | ||
| minVisibleBlockSize?: number; | ||
| /** Optional parent element to clamp popover position within its bounds */ | ||
| parentRef?: React.RefObject<HTMLElement>; |
There was a problem hiding this comment.
Not blocking, but this prop name does not convey very well what it is used for. What about something like triggerClipRef, triggerBoundaryRef or triggerClampRef?
There was a problem hiding this comment.
Let's go with triggerClampRef :) Thanks for the suggestion!
| import { Rect } from '../../../lib/components/popover/interfaces'; | ||
| import { | ||
| calculatePosition, | ||
| clampRectStart, |
There was a problem hiding this comment.
This needs to be updated to the new function name
There was a problem hiding this comment.
Sorry for missing this
| }); | ||
| }); | ||
|
|
||
| describe('clampStart', () => { |
There was a problem hiding this comment.
Also update the function name in this test description
|
Actually I'd suggest prefixing the PR title with |
a3d7548
Summary of Changes
Adds optional parent boundary clamping for popover trigger positioning. This allows popovers to respect parent container boundaries when positioning their arrows.
New Props:
parentRef?: React.RefObject<HTMLElement>toChartPopoverandPopoverContainercomponentsImplementation Details:
getClampedTrackRect()function inuse-popover-position.tsthat:parentRefis providedparentRefis absent (backward compatible)Screen.Recording.2026-03-11.at.14.08.39.mov