Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
- `Select`: Add a `placeholder` prop to `Select.Trigger`, and support `null` item values for clearable placeholder options ([#78076](https://github.com/WordPress/gutenberg/pull/78076)).
- `Drawer`: Fade the popup elevation shadow alongside the slide ([#77800](https://github.com/WordPress/gutenberg/pull/77800)).
- `Drawer`: Allow mouse-drag swipe-dismiss in the popup-edge padding gutter ([#77800](https://github.com/WordPress/gutenberg/pull/77800)).
- `Tooltip`: Default the floating popup's portal container to the `@wordpress/ui` compat overlay slot when present, so tooltips stack reliably above other overlays in mixed-library compositions. A caller-supplied `Tooltip.Portal` `container` prop continues to take precedence ([#78095](https://github.com/WordPress/gutenberg/pull/78095)).
- `IconButton`: Add a `positioner` prop, accepting a `<Tooltip.Positioner />` element, to customize how the tooltip is positioned relative to the button ([#78089](https://github.com/WordPress/gutenberg/pull/78089)).
- `CollapsibleCard.Header`: Pass `render={ <h2 /> }` (or any of `<h1>`–`<h6>`) to wrap the trigger in a heading and contribute to the document outline, following the W3C APG accordion pattern (heading wraps button) ([#77962](https://github.com/WordPress/gutenberg/pull/77962)).
- `Select`: Add a `Select.Positioner` subcomponent and a `positioner` slot prop on `Select.Popup` to customize the popup placement, mirroring the existing `portal` slot pattern ([#78168](https://github.com/WordPress/gutenberg/pull/78168)).
Expand All @@ -42,9 +43,6 @@
### Internal

- `Dialog`: Use `--wpds-motion-*` design tokens for animation duration and easing ([#76097](https://github.com/WordPress/gutenberg/pull/76097)).

### Internal

- Add internal `getWpCompatOverlaySlot()` helper and a co-located unlayered CSS module that lazily provide a body-level `[data-wp-compat-overlay-slot]` container at z-index `1000000003`, gated by `useEnableWpCompatOverlaySlot()` and by auto-detection of `window.wp.components` ([#77851](https://github.com/WordPress/gutenberg/pull/77851)).

## 0.12.0 (2026-04-29)
Expand Down
15 changes: 15 additions & 0 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ body {
}
```

#### Mixing with `@wordpress/components`

If your app pairs `@wordpress/ui` with `@wordpress/components` overlays (popovers, modals, tooltips, …) and you bundle both packages directly — i.e. you aren't relying on the `window.wp.components` global that WordPress's script-loader exposes — call `useEnableWpCompatOverlaySlot()` once from a component that mounts for the lifetime of your app (typically the root):

```tsx
import { useEnableWpCompatOverlaySlot } from '@wordpress/ui';

function App() {
useEnableWpCompatOverlaySlot();
return <YourApp />;
}
```

This opts the app into a shared body-level overlay container so `@wordpress/ui` overlays reliably stack above `@wordpress/components` overlays. The opt-in is one-way (a single component shouldn't be able to turn off shared infrastructure for everyone else) and idempotent (safe to call from multiple components). It is not needed in standard WordPress editor screens, where the slot auto-enables based on the presence of `window.wp.components`.

## Usage

### Basic Component Usage
Expand Down
5 changes: 1 addition & 4 deletions packages/ui/src/alert-dialog/portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ import { forwardRef } from '@wordpress/element';
import type { PortalProps } from './types';

/**
* Root element that portals `AlertDialog` overlay content. Pass to
* `AlertDialog.Popup`'s `portal` prop to customize the portal target and
* wrapper. When `portal` is omitted, `AlertDialog.Popup` uses this component
* with default props.
* Used to apply custom portal behavior to `AlertDialog`'s overlay content.
*/
const Portal = forwardRef< HTMLDivElement, PortalProps >(
function AlertDialogPortal( props, ref ) {
Expand Down
6 changes: 1 addition & 5 deletions packages/ui/src/dialog/portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@ import { forwardRef } from '@wordpress/element';
import type { PortalProps } from './types';

/**
* Root element that portals `Dialog` overlay content (`Backdrop`, inner
* `Popup`, etc.) outside the DOM hierarchy. Pass to `Dialog.Popup`'s
* `portal` prop to customize `container`, `className`, `style`, and other
* Base UI portal options. When `portal` is omitted, `Dialog.Popup` uses this
* component with default props.
* Used to apply custom portal behavior to `Dialog`'s overlay content.
*/
const Portal = forwardRef< HTMLDivElement, PortalProps >(
function DialogPortal( props, ref ) {
Expand Down
6 changes: 1 addition & 5 deletions packages/ui/src/drawer/portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@ import { forwardRef } from '@wordpress/element';
import type { PortalProps } from './types';

/**
* Root element that portals `Drawer` overlay content (`Backdrop`, `Viewport`
* with the inner `Popup`, etc.) outside the DOM hierarchy. Pass to
* `Drawer.Popup`'s `portal` prop to customize `container`, `className`,
* `style`, and other Base UI portal options. When `portal` is omitted,
* `Drawer.Popup` uses this component with default props.
* Used to apply custom portal behavior to `Drawer`'s overlay content.
*/
const Portal = forwardRef< HTMLDivElement, PortalProps >(
function DrawerPortal( props, ref ) {
Expand Down
4 changes: 1 addition & 3 deletions packages/ui/src/form/primitives/autocomplete/portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { forwardRef } from '@wordpress/element';
import type { PortalProps } from './types';

/**
* Root element that portals `Autocomplete` popup content. Pass to
* `Autocomplete.Popup`'s `portal` prop. When `portal` is omitted,
* `Autocomplete.Popup` uses this component with default props.
* Used to apply custom portal behavior to `Autocomplete`'s popup content.
*/
const Portal = forwardRef< HTMLDivElement, PortalProps >(
function AutocompletePortal( props, ref ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@ import styles from './style.module.css';
import { ITEM_POPUP_POSITIONER_PROPS } from '../constants';

/**
* Positions the floating autocomplete popup relative to its input. Pass to
* `Autocomplete.Popup`'s `positioner` prop to customize `side`, `align`,
* `sideOffset`, collision behavior, etc. When `positioner` is omitted,
* `Autocomplete.Popup` uses this component with default props.
* Used to apply custom positioning to `Autocomplete`'s popup content.
*/
const Positioner = forwardRef< HTMLDivElement, PositionerProps >(
function AutocompletePositioner( { className, ...props }, ref ) {
Expand Down
4 changes: 1 addition & 3 deletions packages/ui/src/form/primitives/select/portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { forwardRef } from '@wordpress/element';
import type { PortalProps } from './types';

/**
* Root element that portals `Select` listbox content. Pass to
* `Select.Popup`'s `portal` prop. When `portal` is omitted, `Select.Popup`
* uses this component with default props.
* Used to apply custom portal behavior to `Select`'s listbox content.
*/
const Portal = forwardRef< HTMLDivElement, PortalProps >(
function SelectPortal( props, ref ) {
Expand Down
5 changes: 1 addition & 4 deletions packages/ui/src/form/primitives/select/positioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@ import styles from './style.module.css';
import { ITEM_POPUP_POSITIONER_PROPS } from '../constants';

/**
* Positions the floating select popup relative to its trigger. Pass to
* `Select.Popup`'s `positioner` prop to customize `side`, `align`,
* `sideOffset`, collision behavior, etc. When `positioner` is omitted,
* `Select.Popup` uses this component with default props.
* Used to apply custom positioning to `Select`'s listbox content.
*/
const Positioner = forwardRef< HTMLDivElement, PositionerProps >(
function SelectPositioner( { className, ...props }, ref ) {
Expand Down
5 changes: 1 addition & 4 deletions packages/ui/src/popover/portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ import { forwardRef } from '@wordpress/element';
import type { PortalProps } from './types';

/**
* Root element that portals `Popover` floating content. Pass to
* `Popover.Popup`'s `portal` prop (for example `container` for
* cross-document rendering). When `portal` is omitted, `Popover.Popup` uses
* this component with default props.
* Used to apply custom portal behavior to `Popover`'s floating content.
*/
const Portal = forwardRef< HTMLDivElement, PortalProps >(
function PopoverPortal( props, ref ) {
Expand Down
5 changes: 1 addition & 4 deletions packages/ui/src/popover/positioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ import resetStyles from '../utils/css/resets.module.css';
import styles from './style.module.css';

/**
* Positions the floating popover content relative to the anchor. Pass to
* `Popover.Popup`'s `positioner` prop to customize `side`, `align`,
* `sideOffset`, collision behavior, etc. When `positioner` is omitted,
* `Popover.Popup` uses this component with default props.
* Used to apply custom positioning to `Popover`'s floating content.
*/
const Positioner = forwardRef< HTMLDivElement, PositionerProps >(
function PopoverPositioner(
Expand Down
16 changes: 11 additions & 5 deletions packages/ui/src/tooltip/portal.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { Tooltip as _Tooltip } from '@base-ui/react/tooltip';
import { forwardRef } from '@wordpress/element';
import type { PortalProps } from './types';
import { getWpCompatOverlaySlot } from '../utils/wp-compat-overlay-slot';

/**
* Root element that portals `Tooltip` floating content. Pass to
* `Tooltip.Popup`'s `portal` prop. When `portal` is omitted, `Tooltip.Popup`
* uses this component with default props.
* Used to apply custom portal behavior to `Tooltip`'s floating content.
* Defaults the `container` prop to the `@wordpress/ui` compat overlay slot.
*/
const Portal = forwardRef< HTMLDivElement, PortalProps >(
function TooltipPortal( props, ref ) {
return <_Tooltip.Portal ref={ ref } { ...props } />;
function TooltipPortal( { container, ...restProps }, ref ) {
return (
<_Tooltip.Portal
container={ container ?? getWpCompatOverlaySlot() }
{ ...restProps }
ref={ ref }
/>
);
}
);

Expand Down
5 changes: 1 addition & 4 deletions packages/ui/src/tooltip/positioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ import resetStyles from '../utils/css/resets.module.css';
import styles from './style.module.css';

/**
* Positions the floating tooltip content relative to the trigger. Pass to
* `Tooltip.Popup`'s `positioner` prop to customize `side`, `align`,
* `sideOffset`, collision behavior, etc. When `positioner` is omitted,
* `Tooltip.Popup` uses this component with default props.
* Used to apply custom positioning to `Tooltip`'s floating content.
*/
const Positioner = forwardRef< HTMLDivElement, PositionerProps >(
function TooltipPositioner(
Expand Down
117 changes: 117 additions & 0 deletions packages/ui/src/tooltip/test/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createRef } from '@wordpress/element';
import type { ReactNode } from 'react';
import * as Tooltip from '../index';
import { useEnableWpCompatOverlaySlot } from '../../utils/use-enable-wp-compat-overlay-slot';
import type { ProviderProps } from '../types';

// Test wrapper that sets delay={0} to avoid real-time delays in tests.
Expand Down Expand Up @@ -151,4 +153,119 @@ describe( 'Tooltip', () => {
);
} );
} );

// The slot is identified by a data attribute (cross-tooling marker, no
// user-facing role/text), so direct DOM queries are appropriate here —
// Testing Library's role/text accessors don't apply.
/* eslint-disable testing-library/no-node-access */
describe( 'wp compat overlay slot', () => {
const SLOT_SELECTOR = '[data-wp-compat-overlay-slot]';

// Wrapper that exercises the public opt-in path
// (`useEnableWpCompatOverlaySlot`), so the integration tests
// reflect how a real consumer would activate the slot rather
// than poking at the internal flag directly.
function WithSlotEnabled( { children }: { children: ReactNode } ) {
useEnableWpCompatOverlaySlot();
return <>{ children }</>;
}

afterEach( () => {
// Tear down anything the hook left behind so the next test
// starts from the dormant baseline. The hook is intentionally
// one-way at runtime; tests need to reset it explicitly.
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(
<WithSlotEnabled>
<TestProvider>
<Tooltip.Root>
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
<Tooltip.Popup>Tooltip content</Tooltip.Popup>
</Tooltip.Root>
</TestProvider>
</WithSlotEnabled>
);

await user.hover(
screen.getByRole( 'button', { name: 'Hover me' } )
);

const content = await screen.findByText( 'Tooltip content' );
expect( content ).toBeVisible();

const slot = document.querySelector( SLOT_SELECTOR );
expect( slot ).not.toBeNull();
expect( slot ).toContainElement( content );
} );

it( 'does not create a slot when the consumer has not opted in (dormant default)', async () => {
const user = userEvent.setup();

render(
<TestProvider>
<Tooltip.Root>
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
<Tooltip.Popup>Tooltip content</Tooltip.Popup>
</Tooltip.Root>
</TestProvider>
);

await user.hover(
screen.getByRole( 'button', { name: 'Hover me' } )
);

const content = await screen.findByText( 'Tooltip content' );
expect( content ).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(
<WithSlotEnabled>
<TestProvider>
<Tooltip.Root>
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
<div
ref={ containerRef }
data-testid="custom-container"
/>
<Tooltip.Popup
portal={
<Tooltip.Portal
container={ containerRef }
/>
}
>
Tooltip content
</Tooltip.Popup>
</Tooltip.Root>
</TestProvider>
</WithSlotEnabled>
);

await user.hover(
screen.getByRole( 'button', { name: 'Hover me' } )
);

const content = await screen.findByText( 'Tooltip content' );
expect( content ).toBeVisible();
expect( screen.getByTestId( 'custom-container' ) ).toContainElement(
content
);
} );
} );
/* eslint-enable testing-library/no-node-access */
} );
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,12 @@ describe( 'useEnableWpCompatOverlaySlot', () => {
} );

it( 'enables the slot once mounted, so getWpCompatOverlaySlot() returns the slot', () => {
expect( getWpCompatOverlaySlot() ).toBeNull();
expect( getWpCompatOverlaySlot() ).toBeUndefined();

render( <HookHost /> );

const slot = getWpCompatOverlaySlot();
expect( slot ).not.toBeNull();
expect( slot ).toBeDefined();
expect( slot?.parentElement ).toBe( document.body );
expect( findSlots() ).toHaveLength( 1 );
} );
Expand All @@ -55,7 +55,7 @@ describe( 'useEnableWpCompatOverlaySlot', () => {
</>
);

expect( getWpCompatOverlaySlot() ).not.toBeNull();
expect( getWpCompatOverlaySlot() ).toBeDefined();
expect( findSlots() ).toHaveLength( 1 );
} );

Expand All @@ -67,12 +67,12 @@ describe( 'useEnableWpCompatOverlaySlot', () => {
// not flip the gate back off.
const { unmount } = render( <HookHost /> );

expect( getWpCompatOverlaySlot() ).not.toBeNull();
expect( getWpCompatOverlaySlot() ).toBeDefined();

unmount();

expect( internalWindow.__wpUiCompatOverlaySlotEnabled ).toBe( true );
expect( getWpCompatOverlaySlot() ).not.toBeNull();
expect( getWpCompatOverlaySlot() ).toBeDefined();
} );
} );

Expand Down
Loading
Loading