Skip to content

fix(DST-1754): keep popovers inside the body's clip box at the window edge - #5776

Open
OsamaAbdellateef wants to merge 6 commits into
mainfrom
dst-1745_fix-popover
Open

fix(DST-1754): keep popovers inside the body's clip box at the window edge#5776
OsamaAbdellateef wants to merge 6 commits into
mainfrom
dst-1745_fix-popover

Conversation

@OsamaAbdellateef

@OsamaAbdellateef OsamaAbdellateef commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Description

A <Menu> / <ActionMenu> opened near the right edge of the window lost its right border and rounded corners. Reported from a product in AVW-6159 and reproducible in our own docs at /examples/general (the user menu in the top bar).

The cause is ours, in themes/theme-rui/src/preflight.css:

  • html { scrollbar-gutter: stable } reserves a gutter (15px on the measured setup).
  • body { overflow-x: clip } makes the body's own box the line where anything is cut.

react-aria keeps an overlay inside its boundary by measuring the visual viewport, which still counts that reserved gutter — so it positions against a box 15px wider than the one that actually clips. Both rules are deliberate (no reflow when the scrollbar appears; a single scroll axis, with clip chosen over hidden so position: sticky keeps working), so this PR leaves them alone and fixes the positioning side instead: the reserved gutter is folded into containerPadding, so the overlay stops at the body's clip box.

Pages without a reserved gutter measure 0 and keep the current 12px, so nothing changes there. This covers every overlay that goes through our <Popover>: Menu, ActionMenu, Select, ComboBox, ContextualHelp.

Closes DST-1754

Screenshots / Preview

No image capture — the browser pane in my environment would not composite frames, so the page could not be screenshotted. Measured instead on /examples/general at a 1280px viewport:

Before After
window.innerWidth 1280 1280
body right edge — the real clip line 1265 1265
popover right edge 1268.42 ≤ 1253
clipped 3.42px 0

Test Instructions

  1. pnpm test:sb packages/components/src/Overlay/Popover.stories.tsx — the new AtViewportEdge story test opens an overlay flush against the right edge and asserts it stays behind the body's clip line.
  2. To see it catch the bug, revert containerPadding = getContainerPadding() to DEFAULT_CONTAINER_PADDING in Popover.tsx and rerun — it fails with expected 1187.6 to be less than or equal to 1185, the same ~3px slice.
  3. Manual: pnpm start, open /examples/general, click the user menu in the top bar. Its right border and rounded corner should be intact.

Note on the test: headless browsers use overlay scrollbars, so scrollbar-gutter: stable reserves nothing and the case cannot reproduce on its own. The story's decorator stands in for the gutter with a body narrower than the visual viewport. Without that the test passes against the broken code — it was vacuous before the decorator was added.

Breaking Changes

No. Overlay geometry is unchanged wherever no gutter is reserved. Where one is, overlays near a viewport edge move inward by the gutter width (15px on a typical desktop) — that is the fix.

Checklist

  • Storybook preview and Marigold docs preview are available
  • Stories added/updated (with component-test tag where applicable)
  • Unit tests added/updated — covered by the story test instead; the defect is geometric and jsdom has no layout
  • Component documentation added/updated (if it exists)
  • Accessibility reviewed against ARIA APG (for new/changed interactive components) — no interactive behaviour changed
  • Visual regression tests updated (for UI changes) — Chromatic not dispatched
  • Changeset added (pnpm changeset)

… edge

react-aria positions an overlay against the visual viewport, which still
counts the gutter reserved by `html { scrollbar-gutter: stable }`. The body
is that much narrower and clips at its own box (`body { overflow-x: clip }`),
so an overlay pinned to the right edge landed inside the gutter and was
sliced — 3.42px on a 1280px viewport, taking the right border and the
rounded corners with it.

Fold the reserved gutter into the boundary padding so the overlay stops at
the body's clip box instead of the viewport's. Pages without a reserved
gutter measure 0 and keep the current 12px, so nothing changes there.

Reported from a product in AVW-6159, reproducible in our own docs at
/examples/general.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 63ea2b5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@marigold/components Patch
@marigold/docs Patch
@marigold/theme-rui Patch
@marigold/system Patch
@marigold/icons Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
marigold-docs Ready Ready Preview Sep 2, 2026 12:11pm UTC
marigold-storybook Ready Ready Preview Sep 2, 2026 12:11pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
marigold-production Ignored Ignored Sep 2, 2026 12:11pm UTC

Request Review

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Marigold Code Coverage

Status Category Percentage Covered / Total
🔵 Lines 98.78% 3011 / 3048
🔵 Statements 97.79% 3156 / 3227
🔵 Functions 97.98% 827 / 844
🔵 Branches 91.22% 1945 / 2132
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/components/src/Overlay/Popover.tsx 100% 100% 100% 100%
packages/components/src/Overlay/containerPadding.ts 94.11% 83.33% 100% 94.11% 31
packages/components/src/Table/TableEditableCell.tsx 98.3% 82.69% 92.3% 98.27% 260
packages/components/src/Tooltip/Tooltip.tsx 100% 100% 100% 100%
Generated in workflow #23777 for commit 63ea2b5 by the Vitest Coverage Report Action

Comment thread packages/components/src/Overlay/Popover.tsx Outdated

const DEFAULT_CONTAINER_PADDING = 12;

const getContainerPadding = () => {

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.

⚠️ Scope gap. Tooltip.tsx renders react-aria-components/Tooltip directly with the default containerPadding: 12, and Table/TableEditableCell.tsx imports RAC's Popover directly. Both take the same getContainerDimensions path, so both lose their edge in the same situation. The ticket excludes <Tray> and <Modal> correctly, since neither is trigger-positioned, but it never mentions Tooltip. Either export this helper and use it there too, or record why Tooltip is out of scope.

Worth filing upstream as well (getContainerDimensions should clamp visualViewport.width to documentElement.clientWidth) and linking the issue here, so the workaround can be deleted when it lands.

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.

Done — the helper moved to packages/components/src/Overlay/containerPadding.ts and both call sites use it:

  • Tooltip.tsx takes it as a containerPadding default, same shape as <Popover>.
  • Table/TableEditableCell.tsx passes getContainerPadding() to its RAC <Popover>.

So Tooltip was never out of scope; it was just missed. Thanks for catching it.

Upstream is not filed yet. Two reasons worth stating rather than quietly skipping:

  1. It publishes under the repo owner's name on a repo we don't own, so it needs an explicit go-ahead.
  2. The framing in your comment — "getContainerDimensions should clamp visualViewport.width to documentElement.clientWidth" — doesn't hold. In the failing case both read 1280 (see my reply on the measurement above), so that clamp is a no-op. The accurate report is narrower: getContainerDimensions takes visualViewport.width as the boundary, but an overlay that locks scrolling removes the scrollbar while a reserved scrollbar-gutter keeps the layout narrower, so the boundary ends up a gutter wider than the box that clips.

I have the repro and the numbers for that version. Happy to file it and link it here on your say-so.

One caveat for <Tooltip>: it doesn't lock scrolling, so on a page already showing its scrollbar it now gets a correction it didn't need and stops a gutter's width earlier than required. Bounded by the scrollbar width, 0 without a gutter, and written down in the changeset.

Comment thread packages/components/src/Overlay/Popover.tsx
Comment thread packages/components/src/Overlay/Popover.tsx
// Stands in for a reserved scrollbar gutter; see the decorator below.
const SIMULATED_GUTTER = 15;

export const AtViewportEdge = meta.story({

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.

🔴 Visual Regression Tests — not run

This PR changes UI-affecting files but the Visual-Regression-Tests workflow has not run for this branch, and the change moves overlay geometry by design.

Please trigger it by commenting /run-chromatic on this PR, or by manually dispatching the workflow.

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.

Still not run — flagging rather than quietly leaving it. Dispatching consumes Chromatic quota, so it's the repo owner's call.

Two things that make it more worthwhile than when you wrote this:

  • The AtViewportEdge story now opts back into Storybook (dev) and into Chromatic (chromatic: { disableSnapshot: false }), so there's an actual snapshot of the geometry this PR changes. As you noted, the meta's !dev meant nothing here was ever visible or snapshotted.
  • Overlay geometry only moves on pages that reserve a scrollbar gutter, so a Chromatic run is unlikely to show a diff by itself — worth setting expectations before reading the result.

Comment thread packages/components/src/Overlay/Popover.stories.tsx Outdated
Comment thread packages/components/src/Overlay/Popover.stories.tsx
Comment thread packages/components/src/Overlay/Popover.stories.tsx Outdated
Comment thread .changeset/popover-scrollbar-gutter-clip.md Outdated
OsamaAbdellateef and others added 5 commits September 2, 2026 10:21
Review follow-up on #5776.

`body { overflow-x: clip }` with a visible `html` propagates the clip to the
viewport, so the line that clips is `documentElement.clientWidth`, not the
body's margin box. The two coincide in our docs because the body has no
margin; they stop coinciding for any consumer whose body carries a margin or
a centred `max-width`, and the old measurement folded that whole distance
into `containerPadding` permanently.

The correction is now react-aria's own error — visual viewport width minus
the clip box — which is 0 whenever react-aria is already right and immune to
body margins. It moves to `Overlay/containerPadding.ts` so `<Tooltip>` and
the `<Table>` editable-cell overlay, which take the same
`getContainerDimensions` path, get the same fix. The measurement is cached
against `window.innerWidth` so a forced layout no longer runs on every render
of every menu and select.

Tests split along what can be tested honestly: a headless browser reserves no
gutter, so the size of the correction is unit-tested against stubbed viewport
measurements, and the story test now proves only what a browser can prove —
that `containerPadding` moves the overlay and that `<Popover>` forwards it.
The old story simulated a body margin, which clips nothing, so it passed for
the wrong reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous measurement, `documentElement.clientWidth`, is 0 in exactly the
case this fix is about, so the menu was still clipped.

Measured in a headed Chrome at 1280px wide with a 15px gutter, against where
an absolutely positioned child actually stops being painted:

  config                        | true clip | body.right | clientWidth | fixed
  ------------------------------|-----------|------------|-------------|------
  gutter, no scrollbar rendered |      1265 |       1265 |      *1280* |  1265
  gutter, scrollbar rendered    |      1265 |       1265 |        1265 |  1265
  no gutter, body margin 100    |      1280 |     *1180* |        1280 |  1280
  gutter + body margin 100      |      1265 |     *1165* |      *1280* |  1265
  gutter + html margin 50       |      1265 |     *1215* |      *1280* |  1265

Chrome only subtracts a scrollbar it actually renders, so a reserved but
unused gutter narrows the layout without narrowing `clientWidth` — the first
row, which is the reported case. The review was right that `body.right` is
wrong wherever the page carries a margin (rows 3-5): the clip propagates from
the body to the viewport, so the body's own box does not clip.

What holds in every case is the fixed-positioning containing block, which is
the viewport's scrollport. It costs a throwaway element to measure, which the
existing cache already covers.

End-to-end on a real `<Menu>` in headed Chrome with the gutter reserved:
before 1268.14 against a clip line of 1265 (3.14px sliced, matching the
report); after 1253.14.

The unit tests now stub only react-aria's input and measure the real clip
line, and cover the margin cases the review raised — a headless browser
reserves no gutter, so the gutter case stays a manual check.

Also opts `AtViewportEdge` back into Storybook (`dev`) and into Chromatic:
the meta hides this file because the other two stories are bare fixtures, but
this one shows the geometry the fix is about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opting it back into Storybook exposed that it was only ever built to be
asserted on, not looked at.

The decorator rendered a second element with `id="storybook-root"`. That was
invisible while the file was hidden; in the real preview it collides with
Storybook's own root. The story now owns a container with its own id and
points `OverlayContainerProvider` at it, which works in the preview and in
the test runner both.

Note that `OverlayContainerProvider` with no `container` is not a fallback to
the body, despite what its doc comment says: `getContainer` returns null and
react-aria's `Overlay` bails out, rendering nothing.

The rest is presentation, since the story is now browsable and snapshotted: a
real `<Button>` as the trigger rather than a bare text node, and content in an
`<Inset>` so it is not pressed against the popover's rim.

The assertion now measures against react-aria's own boundary
(`visualViewport.width`) rather than `documentElement.clientWidth`. The two
disagree exactly when a gutter is reserved, which is the subject of this fix,
so the boundary is the honest reference for what `containerPadding` promises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both costs of the correction now read together: `containerPadding` is
symmetric, and an overlay that does not lock scrolling gets a correction it
did not need. Also corrects the mechanism, which is the overlay's own scroll
lock rather than a page that happens not to scroll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The story hard-coded `containerPadding={100}`, so what Storybook rendered was
a popover shoved 100px off its trigger and stretched to fill the gap — a
number picked to make an assertion sharp, on display as if it were the
component's behaviour.

The padding now comes from args, so the story shows the geometry the fix
actually produces and only the test overrides it. The trigger also sits just
off the edge rather than flush against it, which read as clipped — the same
thing the fix is about — and the copy is short enough that the popover is not
a slab.
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:feature New feature or component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants