diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md
index 1be594d5650c74..ae50ed3b1ec04c 100644
--- a/packages/ui/CHANGELOG.md
+++ b/packages/ui/CHANGELOG.md
@@ -5,6 +5,7 @@
### Enhancements
- Export `getWpCompatOverlaySlot()` so consumers can route their own portals into the compat overlay slot ([#78183](https://github.com/WordPress/gutenberg/pull/78183)).
+- `Select`, `SelectControl`: Default the popup's portal container to the `@wordpress/ui` compat overlay slot when present, so select popups stack reliably above other overlays in mixed-library compositions. A caller-supplied `Select.Portal` `container` prop continues to take precedence ([#78372](https://github.com/WordPress/gutenberg/pull/78372)).
## 0.13.0 (2026-05-14)
diff --git a/packages/ui/src/form/primitives/select/portal.tsx b/packages/ui/src/form/primitives/select/portal.tsx
index 88370aa2215e4c..d635ea259946b0 100644
--- a/packages/ui/src/form/primitives/select/portal.tsx
+++ b/packages/ui/src/form/primitives/select/portal.tsx
@@ -1,14 +1,23 @@
import { Select as _Select } from '@base-ui/react/select';
import { forwardRef } from '@wordpress/element';
import type { PortalProps } from './types';
+import { getWpCompatOverlaySlot } from '../../../utils/wp-compat-overlay-slot';
/**
* Used to apply custom portal behavior to `Select`'s listbox content.
+ * `container` defaults to the `@wordpress/ui` compat overlay slot.
*/
-const Portal = forwardRef< HTMLDivElement, PortalProps >(
- function SelectPortal( props, ref ) {
- return <_Select.Portal ref={ ref } { ...props } />;
- }
-);
+const Portal = forwardRef< HTMLDivElement, PortalProps >( function SelectPortal(
+ { container, ...restProps },
+ ref
+) {
+ return (
+ <_Select.Portal
+ container={ container ?? getWpCompatOverlaySlot() }
+ { ...restProps }
+ ref={ ref }
+ />
+ );
+} );
export { Portal };
diff --git a/packages/ui/src/form/primitives/select/test/index.test.tsx b/packages/ui/src/form/primitives/select/test/index.test.tsx
index 3376123835a0dc..0ed0161ef330ec 100644
--- a/packages/ui/src/form/primitives/select/test/index.test.tsx
+++ b/packages/ui/src/form/primitives/select/test/index.test.tsx
@@ -1,7 +1,9 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createRef } from '@wordpress/element';
+import type { ReactNode } from 'react';
import * as Select from '../index';
+import { useEnableWpCompatOverlaySlot } from '../../../../utils/use-enable-wp-compat-overlay-slot';
describe( 'Select', () => {
it( 'supports object item values', async () => {
@@ -239,4 +241,108 @@ describe( 'Select', () => {
expect( positioner ).toContainElement( item );
} );
} );
+
+ // Slot is identified by a data attribute, not a user-facing role/text.
+ /* eslint-disable testing-library/no-node-access */
+ describe( 'wp compat overlay slot', () => {
+ const SLOT_SELECTOR = '[data-wp-compat-overlay-slot]';
+
+ // Exercises the public opt-in path rather than poking the flag.
+ function WithSlotEnabled( { children }: { children: ReactNode } ) {
+ useEnableWpCompatOverlaySlot();
+ return <>{ children }>;
+ }
+
+ afterEach( () => {
+ // The hook is one-way at runtime; reset explicitly between tests.
+ delete ( window as { __wpUiCompatOverlaySlotEnabled?: boolean } )
+ .__wpUiCompatOverlaySlotEnabled;
+ document
+ .querySelectorAll( SLOT_SELECTOR )
+ .forEach( ( el ) => el.remove() );
+ } );
+
+ it( 'portals the popup into the slot when the consumer opts in', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+
+ Item 1
+
+
+
+ );
+
+ await user.click( screen.getByRole( 'combobox' ) );
+
+ const item = await screen.findByRole( 'option', {
+ name: 'Item 1',
+ } );
+ expect( item ).toBeVisible();
+
+ const slot = document.querySelector( SLOT_SELECTOR );
+ expect( slot ).not.toBeNull();
+ expect( slot ).toContainElement( item );
+ } );
+
+ it( 'does not create a slot when the consumer has not opted in (dormant default)', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ Item 1
+
+
+ );
+
+ await user.click( screen.getByRole( 'combobox' ) );
+
+ const item = await screen.findByRole( 'option', {
+ name: 'Item 1',
+ } );
+ expect( item ).toBeVisible();
+
+ expect( document.querySelector( SLOT_SELECTOR ) ).toBeNull();
+ } );
+
+ it( 'lets a caller-supplied portal container override the slot', async () => {
+ const user = userEvent.setup();
+ const containerRef = createRef< HTMLDivElement >();
+
+ render(
+
+
+
+
+
+ }
+ >
+ Item 1
+
+
+
+ );
+
+ await user.click( screen.getByRole( 'combobox' ) );
+
+ const item = await screen.findByRole( 'option', {
+ name: 'Item 1',
+ } );
+ expect( item ).toBeVisible();
+ expect( screen.getByTestId( 'custom-container' ) ).toContainElement(
+ item
+ );
+ } );
+ } );
+ /* eslint-enable testing-library/no-node-access */
} );
diff --git a/packages/ui/src/form/select-control/test/index.test.tsx b/packages/ui/src/form/select-control/test/index.test.tsx
index 850d685c4ca726..08c525f518cd93 100644
--- a/packages/ui/src/form/select-control/test/index.test.tsx
+++ b/packages/ui/src/form/select-control/test/index.test.tsx
@@ -1,7 +1,9 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createRef } from '@wordpress/element';
+import type { ReactNode } from 'react';
import { SelectControl } from '../index';
+import { useEnableWpCompatOverlaySlot } from '../../../utils/use-enable-wp-compat-overlay-slot';
describe( 'SelectControl', () => {
const mockItems = [
@@ -193,4 +195,66 @@ describe( 'SelectControl', () => {
expect( formData.get( 'country' ) ).toBe( '' );
} );
} );
+
+ // Slot is identified by a data attribute, not a user-facing role/text.
+ /* eslint-disable testing-library/no-node-access */
+ describe( 'wp compat overlay slot', () => {
+ const SLOT_SELECTOR = '[data-wp-compat-overlay-slot]';
+
+ // Exercises the public opt-in path rather than poking the flag.
+ function WithSlotEnabled( { children }: { children: ReactNode } ) {
+ useEnableWpCompatOverlaySlot();
+ return <>{ children }>;
+ }
+
+ afterEach( () => {
+ // The hook is one-way at runtime; reset explicitly between tests.
+ delete ( window as { __wpUiCompatOverlaySlotEnabled?: boolean } )
+ .__wpUiCompatOverlaySlotEnabled;
+ document
+ .querySelectorAll( SLOT_SELECTOR )
+ .forEach( ( el ) => el.remove() );
+ } );
+
+ it( 'portals the popup into the slot when the consumer opts in', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ );
+
+ await user.click(
+ screen.getByRole( 'combobox', { name: 'Country' } )
+ );
+
+ const option = await screen.findByRole( 'option', {
+ name: 'Option 1',
+ } );
+ expect( option ).toBeVisible();
+
+ const slot = document.querySelector( SLOT_SELECTOR );
+ expect( slot ).not.toBeNull();
+ expect( slot ).toContainElement( option );
+ } );
+
+ it( 'does not create a slot when the consumer has not opted in (dormant default)', async () => {
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.click(
+ screen.getByRole( 'combobox', { name: 'Country' } )
+ );
+
+ const option = await screen.findByRole( 'option', {
+ name: 'Option 1',
+ } );
+ expect( option ).toBeVisible();
+
+ expect( document.querySelector( SLOT_SELECTOR ) ).toBeNull();
+ } );
+ } );
+ /* eslint-enable testing-library/no-node-access */
} );
diff --git a/storybook/stories/playground/wp-compat-overlay-slot.story.jsx b/storybook/stories/playground/wp-compat-overlay-slot.story.jsx
index dda4b41b83ac69..476dcda6a71686 100644
--- a/storybook/stories/playground/wp-compat-overlay-slot.story.jsx
+++ b/storybook/stories/playground/wp-compat-overlay-slot.story.jsx
@@ -8,18 +8,27 @@ import { useState } from '@wordpress/element';
* Internal dependencies
*/
import * as Tooltip from '../../../packages/ui/src/tooltip';
+import * as Select from '../../../packages/ui/src/form/primitives/select';
+import { SelectControl } from '../../../packages/ui/src/form/select-control';
import { WithWpCompatOverlaySlot } from './with-wp-compat-overlay-slot';
-// Cross-library stacking: a `@wordpress/ui` Tooltip inside a
-// `@wordpress/components` Modal / Popover should sit above the
-// components-side overlay via the compat overlay slot.
+const selectItems = [
+ { value: 'option-1', label: 'Option 1' },
+ { value: 'option-2', label: 'Option 2' },
+ { value: 'option-3', label: 'Option 3' },
+];
+
+// Cross-library stacking: `@wordpress/ui` overlays (`Tooltip`, `Select`,
+// `SelectControl`) inside a `@wordpress/components` Modal / Popover
+// should sit above the components-side overlay via the compat overlay
+// slot.
export default {
title: 'Playground/Debug fixtures/WP Compat Overlay Slot',
decorators: [ WithWpCompatOverlaySlot ],
};
export const InsideComponentsModal = {
- name: 'Tooltip inside @wordpress/components Modal',
+ name: 'Overlays inside @wordpress/components Modal',
render: function Render() {
const [ isOpen, setIsOpen ] = useState( false );
return (
@@ -33,9 +42,9 @@ export const InsideComponentsModal = {
onRequestClose={ () => setIsOpen( false ) }
>
- The Tooltip below is from `@wordpress/ui`. Hover its
- trigger; the tooltip popup should render above this
- modal, not behind it.
+ The overlays below are from `@wordpress/ui`. Their
+ popups should render above this modal, not behind
+ it.
@@ -46,6 +55,29 @@ export const InsideComponentsModal = {
+
+
+
+
+
+ { selectItems.map( ( item ) => (
+
+ { item.label }
+
+ ) ) }
+
+
+
+
+
+
+
) }
>
@@ -54,7 +86,7 @@ export const InsideComponentsModal = {
};
export const InsideComponentsPopover = {
- name: 'Tooltip inside @wordpress/components Popover',
+ name: 'Overlays inside @wordpress/components Popover',
render: function Render() {
const [ anchor, setAnchor ] = useState( null );
const [ isOpen, setIsOpen ] = useState( false );
@@ -74,9 +106,8 @@ export const InsideComponentsPopover = {
>
- The Tooltip below is from `@wordpress/ui`. Hover
- its trigger; the tooltip popup should render
- above this popover.
+ The overlays below are from `@wordpress/ui`.
+ Their popups should render above this popover.
@@ -87,6 +118,29 @@ export const InsideComponentsPopover = {
+
+
+
+
+
+ { selectItems.map( ( item ) => (
+
+ { item.label }
+
+ ) ) }
+
+
+
+
+
+
+
) }