Skip to content

chore: add optional parent boundary clamping for popover trigger positioning#4344

Merged
jperals merged 14 commits intocloudscape-design:mainfrom
jsilll:main
Mar 16, 2026
Merged

chore: add optional parent boundary clamping for popover trigger positioning#4344
jperals merged 14 commits intocloudscape-design:mainfrom
jsilll:main

Conversation

@jsilll
Copy link
Copy Markdown
Contributor

@jsilll jsilll commented Mar 11, 2026

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:

  • Added parentRef?: React.RefObject<HTMLElement> to ChartPopover and PopoverContainer components

Implementation Details:

  • Introduced getClampedTrackRect() function in use-popover-position.ts that:
    • Clamps the track center point to parent boundaries when parentRef is provided
    • Calculates adjusted track position while preserving original dimensions
    • Uses logical properties (insetInlineStart, blockSize, etc.) for RTL/writing-mode support
    • Falls back to normal behavior when parentRef is absent (backward compatible)
Screen.Recording.2026-03-11.at.14.08.39.mov

@jsilll jsilll requested a review from a team as a code owner March 11, 2026 13:37
@jsilll jsilll requested review from srungta08 and removed request for a team March 11, 2026 13:37
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 11, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.45%. Comparing base (41acf98) to head (620c8e5).
⚠️ Report is 3 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jperals jperals changed the title feat(popover): add optional parent boundary clamping for popover trigger positioning feat: add optional parent boundary clamping for popover trigger positioning Mar 11, 2026
@jperals
Copy link
Copy Markdown
Member

jperals commented Mar 11, 2026

I updated the PR title to fit the format accepted by our release notes script (just feat: instead of feat(popover):)

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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add unit testing coverage for this new logic?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, sure! I just wanted to get your feedback on the implementation first.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the latter approach as well!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

return ['top', 'bottom'].includes(internalPosition.split('-')[0]);
}

export function clampRectStart<T extends BoundingBox>(rect: T, parentRect: BoundingBox): T {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the generic T? Can we be more specific?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I missed the already defined Rect type

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Now the tests complain about the requiring insetBlockEnd and insetInlineEnd not being passed (see, TypeScript doing its job 😛)

...rect,
insetInlineStart: Math.max(parentRect.insetInlineStart, Math.min(rect.insetInlineStart, parentInlineEnd)),
insetBlockStart: Math.max(parentRect.insetBlockStart, Math.min(rect.insetBlockStart, parentBlockEnd)),
} as T;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I meant is that we are clamping on the "start" (of both inline and block directions) but not on the "end".

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

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));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason to modify the original passed-in object rather than creating a new one?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

trackKey?: string | number;
minVisibleBlockSize?: number;
/** Optional parent element to clamp popover position within its bounds */
parentRef?: React.RefObject<HTMLElement>;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking, but this prop name does not convey very well what it is used for. What about something like triggerClipRef, triggerBoundaryRef or triggerClampRef?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go with triggerClampRef :) Thanks for the suggestion!

import { Rect } from '../../../lib/components/popover/interfaces';
import {
calculatePosition,
clampRectStart,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be updated to the new function name

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for missing this

});
});

describe('clampStart', () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also update the function name in this test description

@jperals
Copy link
Copy Markdown
Member

jperals commented Mar 16, 2026

Actually I'd suggest prefixing the PR title with chore: instead of feat: because this is not a public feature

@jsilll jsilll changed the title feat: add optional parent boundary clamping for popover trigger positioning chore: add optional parent boundary clamping for popover trigger positioning Mar 16, 2026
@jperals jperals added this pull request to the merge queue Mar 16, 2026
Merged via the queue into cloudscape-design:main with commit a3d7548 Mar 16, 2026
82 of 84 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants