CustomSelectControl: Portal dropdown popover into Popover.Slot by default#77969
CustomSelectControl: Portal dropdown popover into Popover.Slot by default#77969
Conversation
…ault
The dropdown popover was rendered inline in the DOM, which subjected
it to the trigger's ancestor stacking contexts (e.g. the block
toolbar's `position: sticky` header) and `overflow` containers (e.g.
the Style Panel). This caused the popover to be covered or clipped
near the bottom of the editor sidebar.
Internally, the v2 component now accepts `portal` and `portalElement`
props that forward to the underlying Ariakit `SelectPopover`. The
legacy `CustomSelectControl` adapter uses `useSlot('Popover')` to
portal the dropdown into the registered `Popover.Slot` by default,
falling back to the document `body` when no slot is available. A new
`inline` prop is exposed on the legacy adapter as an escape hatch for
consumers that need the previous inline rendering.
The legacy-only `flip={ ! isLegacy }` override (added in #63357 to
mask the same stacking glitch) is now applied only when the popover
is not portaled — once portaled, Floating UI's flipping behavior
matches user expectations near viewport edges.
There was a problem hiding this comment.
Pull request overview
This PR updates CustomSelectControl so its legacy dropdown popover renders in a React portal by default (targeting the registered Popover.Slot, falling back to document.body) to avoid clipping by overflow containers and stacking-context issues with sticky UI (e.g., block toolbar). It also adds an inline opt-out for consumers needing the previous inline DOM behavior, and exposes portal-related pass-through props on the internal v2 implementation.
Changes:
- Default legacy
CustomSelectControlpopover rendering to a portal, with a newinlineprop to opt back into inline rendering. - Add
portal/portalElementprops toCustomSelectControlV2(unstable) types and forward them to Ariakit’sSelectPopover. - Add tests asserting the listbox is portaled by default (and inline when opted out), plus changelog entries for both changes.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/components/src/custom-select-control/types.ts | Adds the new public inline?: boolean prop (legacy adapter) with documentation. |
| packages/components/src/custom-select-control/index.tsx | Implements default portaling into Popover.Slot (fallback to document.body) and wires inline to portal behavior. |
| packages/components/src/custom-select-control/test/index.tsx | Adds DOM-position assertions to validate portaled vs inline rendering. |
| packages/components/src/custom-select-control-v2/types.ts | Exposes portal and portalElement props (unstable) for v2 consumers. |
| packages/components/src/custom-select-control-v2/custom-select.tsx | Forwards portal props and adjusts legacy flip behavior based on whether the popover is portaled. |
| packages/components/CHANGELOG.md | Documents the legacy default portaling change and the new v2 portal props. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -149,8 +158,9 @@ function CustomSelect( | |||
| sameWidth | |||
| slide={ false } | |||
| onKeyDown={ onSelectPopoverKeyDown } | |||
| // Match legacy behavior | |||
| flip={ ! isLegacy } | |||
| flip={ flip } | |||
| portal={ portal } | |||
| - `TabPanel`: Fix tab indicator animation while switching tabs ([#77812](https://github.com/WordPress/gutenberg/pull/77812)). | ||
| - `ColorPicker`: Fix issue where clearing the hex input entirely doesn't reset the selected color to black ([#77912](https://github.com/WordPress/gutenberg/pull/77912)). | ||
| - `ExternalLink`: Fix focus outline rendered in wp-admin ([#77935](https://github.com/WordPress/gutenberg/pull/77935)). | ||
| - `CustomSelectControl`: Portal the dropdown popover into the registered `Popover.Slot` (with a `document.body` fallback) so it can no longer be clipped by ancestor `overflow` containers (e.g. the Style Panel) or covered by sticky elements (e.g. the block toolbar). Pass `inline` to opt back into the previous inline rendering ([#77969](https://github.com/WordPress/gutenberg/pull/77969)). | ||
|
|
||
| ### New Features | ||
|
|
||
| - `CustomSelectControlV2` (unstable): Add `portal` and `portalElement` props that forward to the underlying Ariakit `SelectPopover`, allowing the dropdown to be rendered in a React portal ([#77969](https://github.com/WordPress/gutenberg/pull/77969)). |
|
Size Change: +69 B (0%) Total Size: 7.94 MB 📦 View Changed
ℹ️ View Unchanged
|
…t.body Returning `element.ownerDocument.body` directly from the `portalElement` callback caused Ariakit to write a `portal/...` id onto `<body>` and skip cleanup, since `body.isConnected` is `true`. Hand `undefined` to Ariakit when no `Popover.Slot` is registered so its default kicks in: a fresh wrapper div appended to `body` and properly removed on unmount.
|
Flaky tests detected in 094b24c. 🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/25392403306
|
Closes #76126
Closes #63180
Follow up to #63357
Alt to #77947
What?
Render the legacy
CustomSelectControldropdown popover in a React portal by default — into the registeredPopover.Slot, with adocument.bodyfallback — so it can no longer be clipped by ancestoroverflowcontainers (e.g. the Style Panel) or covered by sticky elements (e.g. the block toolbar).A new
inlineprop is exposed as an opt-out for consumers that need the previous inline DOM rendering.Why?
The dropdown rendered inline in the DOM, so it inherited the trigger's ancestor stacking contexts and
overflowcontainers. That causes #63180 (toolbar overlap) and #76126 (Style Panel clipping). #63357 disabled flipping to mask #63180 and left #76126 unfixed. Portaling addresses both at the root.How?
custom-select-control-v2/custom-select.tsx) accepts and forwardsportalandportalElementto the AriakitSelectPopover.CustomSelectControlreadsPopover.SlotviauseSlot('Popover')and defaults toportal=true.flip={false}workaround now applies only when not portaled.inlineprop on the legacy adapter restores the previous behavior (no portal, no flip).API and design notes
inlinemirrorsPopover's existing public prop. Same name, same default (false).z-index: 1000000on the popover was a no-op while inline (it only operated within the trigger's stacking context). Once portaled it stacks atbody/Popover.Slotlevel, alongsidePopoverandMenu.Popover, and the value is meaningful.CustomSelectControlV2) gains the new props through pass-through. No default change.Testing Instructions
inlineto a legacyCustomSelectControland confirm the previous behavior is preserved (popover rendered inline next to the trigger, no flipping near viewport edges).Places in Gutenberg to spot-check
The legacy
CustomSelectControlis used in many places. A few representative spots covering different mounting contexts:Style Panel (inspector controls, in a scroll container):
FontFamilypicker —packages/block-editor/src/components/font-family/index.jsFontAppearancepicker —packages/block-editor/src/components/font-appearance-control/index.jsFontSizePicker(select variant, when many presets exist) —packages/components/src/font-size-picker/font-size-picker-select.tsxBorderRadiusControl— viaPresetInputControl(packages/block-editor/src/components/preset-input-control/index.js)SpacingSizesControl— viaPresetInputControlDimensionControl—packages/block-editor/src/components/dimension-control/index.jspackages/block-editor/src/hooks/position.jsEditor (outside the Style Panel):
packages/block-editor/src/components/date-format-picker/index.jsCustomSelectControl)packages/components/src/validated-form-controls/components/custom-select-control.tsxFor each: open the dropdown via mouse and via keyboard (Tab → Enter/Space, Up/Down to navigate, Enter to select, Escape to close, focus returns to trigger).
Testing Instructions for Keyboard
Screenshots or screencast
TODO / Follow-ups
CustomSelectControlconsumers all live in the parent document. If a cross-document case surfaces, followPopover's<Fill>+StyleProviderpattern.Use of AI Tools