Skip to content

fix: prevent dropdown menus from overflowing the editor root - #562

Draft
dcalhoun wants to merge 3 commits into
trunkfrom
fix/prevent-dropdown-menu-overflow
Draft

fix: prevent dropdown menus from overflowing the editor root#562
dcalhoun wants to merge 3 commits into
trunkfrom
fix/prevent-dropdown-menu-overflow

Conversation

@dcalhoun

@dcalhoun dcalhoun commented Jul 23, 2026

Copy link
Copy Markdown
Member

What?

Opening a block toolbar dropdown menu (e.g. the "Options" more menu) can add horizontal overflow to the editor's root element, letting the canvas be swiped sideways to reveal the empty background behind it.

Why?

Ref CMM-997.

The editor is not iframed on mobile, so the whole editor lives in a single document. Block toolbar popovers render into a fallback container appended to <body> and are positioned relative to the viewport. When a popover is anchored near the right edge—such as after scrolling the block toolbar so the toggle sits at the far right—it extends past the viewport. Because nothing clips horizontal overflow on <html>/<body>/#root, this grows the scrollable width of the root element, and the canvas can be swiped horizontally to reveal the empty background behind the editor styles wrapper.

Measured with the menu open on a 375px-wide viewport: the popover's right edge lands at 419px (44px past the viewport), html.scrollWidth grows to 419 while clientWidth stays 375, and only <html> becomes horizontally scrollable.

How?

Render popovers into slots within body-level containers, split by whether the popover needs clipping:

  • Dropdown popovers use the default slot, placed in a position: fixed; inset: 0; overflow: hidden container. Being out of flow, it clips the overflow without contributing to the document's scrollable size, so the document remains the scroll container. The container spans the viewport, so it sets pointer-events: none and the popovers within restore pointer-events: auto.
  • The full-screen block settings menu opts into its own slot in an unclipped container via __unstableSlotName. It fills the viewport and never overflows, so it does not need clipping—and WebKit renders a position: fixed element semi-transparent when an ancestor clips its overflow, so keeping it clear of any clipping ancestor avoids that bug outright rather than working around it.

Both containers are body children, so modalize can keep the active popover accessible while marking the rest of the document inert.

A few supporting changes:

  • modalize now walks the ancestor path (first commit). It previously hid only document.body's children, which assumed the modal element was itself a body child—true for the fallback container, not for a popover nested in a slot. It now walks from each modal element up to the body, hiding the other children of every ancestor, collecting ancestors first so they are never hidden. It also accepts multiple modal elements.
  • The layout is wrapped in a SlotFillProvider so the slots and the editor's popovers share one registry. BlockEditorProvider renders <SlotFillProvider passthrough>, which creates its own registry when no provider exists above it, leaving the slots unreachable and silently sending popovers back to the fallback container.

Alternatives considered

contain: paint on the body (the previous approach in this PR) clips the overflow, but it also stops the document from scrolling content taller than the viewport. Restoring vertical scroll on .gutenberg-kit-root moves the scroll off the document, which breaks iOS status-bar-tap scroll-to-top—that gesture only drives the web view's main scroll view. Both rules are reverted here, so the document is the scroll container again.

overflow-x: hidden/clip on html/body does not clip out-of-flow popovers at all (the value propagates to the viewport), and clipping on #root misses them entirely since the fallback container is a sibling of #root, not a descendant.

Testing Instructions

  1. Run the iOS demo app and open an editor with theme styles (e.g. a site with a non-white editor background).
  2. Focus the first empty placeholder Paragraph block.
  3. Scroll the block toolbar to the end to reveal the more menu (three-dots) toggle, and tap it to open the menu.
  4. Scroll the block toolbar back to its beginning so the expanded menu is at least partially off screen.
  5. Swipe horizontally (right to left) on the editor canvas beneath the focused block.
  6. Confirm the canvas stays put and no empty/background band is revealed on the right. (On trunk, the canvas shifts left and reveals the root element's background.)

Please also confirm the following, as they cover the constraints this approach had to satisfy:

  1. Open the block settings menu (cog). Confirm it renders fully opaque, with no editor content bleeding through.
  2. Add enough content to overflow the viewport and confirm it scrolls, then tap the status bar and confirm the editor scrolls to the top.
  3. Open the block inserter and the block settings menu and confirm both still dismiss via outside tap and close button.

Accessibility Testing Instructions

Popovers now render into slots rather than Gutenberg's fallback container, and modalize was refactored to match, so the inert behaviour from #145 is worth re-checking with VoiceOver:

  1. Open the block inserter (or the block settings menu) with VoiceOver enabled.
  2. Confirm the popover's contents are reachable and announced.
  3. Confirm swiping past the popover does not reach the editor canvas or toolbar behind it.
  4. Dismiss the popover and confirm the editor content becomes reachable again.

Verified in the DOM: with a popover open, #root is aria-hidden="true" while both popover containers, the popover's ancestor chain, and the aria-live regions are untouched; dismissing removes every attribute that was added.

Screenshots or screencast

@dcalhoun dcalhoun added the [Type] Bug An existing feature does not function as intended label Jul 23, 2026
@wpmobilebot

wpmobilebot commented Jul 23, 2026

Copy link
Copy Markdown

XCFramework Build

This PR's XCFramework is available for testing. Add the following to your Package.swift:

.package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/562")

Built from 2684d95

@dcalhoun
dcalhoun force-pushed the fix/prevent-dropdown-menu-overflow branch from 107607c to b28c1e4 Compare July 23, 2026 18:50
Block toolbar popovers (e.g. the "Options" menu) render into a fallback
container appended to the body and are positioned relative to the
viewport. When anchored near the right edge—such as after scrolling the
block toolbar—they extend past the viewport, growing the scrollable
width of the root element. The canvas could then be swiped horizontally,
revealing the empty background behind the editor styles wrapper.

Apply `contain: paint` to the body, which fills the viewport, so
off-screen popovers are clipped there instead of expanding the
document's scrollable width. `overflow-x` on the body does not clip them
(the value propagates to the viewport rather than clipping out-of-flow
descendants), and clipping on `#root` misses them since the fallback
container is a sibling of `#root`, not a descendant.

Because `contain: paint` on the body would otherwise clip content taller
than the viewport instead of letting it scroll, give `.gutenberg-kit-root`
its own vertical scroll so overflowing editor content scrolls there
rather than relying on the (now paint-contained) document.

Clip at the body rather than moving popovers into a `Popover.Slot` within
the editor: popovers must stay in the default fallback container—a
sibling of `#root`—so the modalize helper can mark the rest of the editor
inert while keeping the active popover accessible when the block
inspector or inserter is open (see #145). Document this constraint inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dcalhoun
dcalhoun force-pushed the fix/prevent-dropdown-menu-overflow branch from b28c1e4 to 686e739 Compare July 23, 2026 19:20
dcalhoun and others added 2 commits July 23, 2026 16:26
`modalize` hid only the body's children, which assumed the modal element
was itself a body child. That holds for popovers in Gutenberg's fallback
container, but not for popovers rendered into a slot nested within a
body-level container.

Walk from each modal element up to the body instead, hiding the other
children of every ancestor along the way. Ancestors of a modal element
are collected first so they are never hidden, which would hide the modal
element with them.

Also accept multiple modal elements, as popovers may render into more
than one container.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Block toolbar popovers—e.g. the "Options" menu—rendered into Gutenberg's
fallback container, a body child positioned relative to the viewport.
When anchored near the right edge, such as after scrolling the block
toolbar, they extended past the viewport and grew the document's
scrollable width. The canvas could then be swiped horizontally, revealing
the empty background beside the editor styles wrapper.

Render popovers into slots within body-level containers instead, split by
whether the popover needs clipping:

- Dropdown popovers use the default slot, in a fixed, `overflow: hidden`
  container. Being out of flow, it clips the overflow without
  contributing to the document's scrollable size, so the document remains
  the scroll container.
- The full-screen block settings menu opts into its own slot in an
  unclipped container via `__unstableSlotName`. WebKit renders a
  `position: fixed` element semi-transparent when an ancestor clips its
  overflow, and the menu fills the viewport and never overflows, so it
  does not need clipping.

Both containers are body children, so `modalize` keeps the active popover
accessible while marking the rest of the document inert.

This replaces `contain: paint` on the body, which clipped the overflow
but required moving vertical scroll onto `.gutenberg-kit-root`. That
broke iOS scroll-to-top, which only drives the web view's main scroll
view—the document scroller.

Wrap the layout in a `SlotFillProvider` so the slots and the editor's
popovers share one registry. `BlockEditorProvider` otherwise creates its
own, leaving the slots unreachable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants