diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 82bdc79c82297c..ec50750e90f10b 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -15,12 +15,20 @@ - `Drawer`: Restore the slide-out animation when the popup closes ([#77800](https://github.com/WordPress/gutenberg/pull/77800)). - `Drawer`: Forward the `render` prop on `Drawer.Content` to the scroll container instead of leaking it as a DOM attribute, matching `Dialog.Content` ([#77941](https://github.com/WordPress/gutenberg/pull/77941)). +### New Features + +- Add `useEnableWpCompatOverlaySlot()` hook to opt into a body-level overlay container that stacks `@wordpress/ui` overlays above `@wordpress/components` overlays in mixed-library compositions. The slot auto-enables wherever `window.wp.components` is on the global (the typical script-loader setup for WordPress plugins and admin screens), so the hook is mostly relevant for hosts that bundle `@wordpress/components` (or only `@wordpress/ui`) directly — apps that aren't built with standard WordPress build tooling. Per-component support will be added incrementally ([#77851](https://github.com/WordPress/gutenberg/pull/77851)). + ### Enhancements - `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)). +### 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) ### Breaking Changes diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 893284bc294e0d..7b7efd78856256 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -17,4 +17,5 @@ export * from './stack'; export * as Tabs from './tabs'; export * from './text'; export * as Tooltip from './tooltip'; +export { useEnableWpCompatOverlaySlot } from './utils/use-enable-wp-compat-overlay-slot'; export * from './visually-hidden'; diff --git a/packages/ui/src/utils/css/wp-compat-overlay-slot.module.css b/packages/ui/src/utils/css/wp-compat-overlay-slot.module.css new file mode 100644 index 00000000000000..3e0bda4fff397d --- /dev/null +++ b/packages/ui/src/utils/css/wp-compat-overlay-slot.module.css @@ -0,0 +1,46 @@ +/* + * WP compat overlay slot — body-level positioned container that hosts + * `@wordpress/ui` overlays so they reliably stack in mixed-library + * compositions. The `data-wp-compat-overlay-slot` attribute is the + * architectural identifier (see `getWpCompatOverlaySlot()`); `.slot` is + * the styling vehicle. + * + * Authored unlayered — outside `@layer wp-ui-*` — so the slot's z-index + * and positioning win against any `@layer`-scoped rule. CSS cascade + * layers always lose to unlayered styles, so unlayering is the ceiling. + * + * Why each declaration is what it is: + * + * - `z-index: 1000000003` — sits in a reserved billion-scale band above + * the legacy z-index map in `packages/base-styles/_z-index.scss`. + * + * - `position: fixed` — load-bearing for two reasons. (1) `z-index` is + * ignored on `position: static`. (2) A non-static position makes the + * slot a containing block for absolute-positioned descendants; + * floating-ui writes physical viewport-relative `top` / `left` onto + * the floating element, so the containing block must sit at viewport + * `(0, 0)`. `position: fixed` (with `top: 0; left: 0`) pins it there + * regardless of scroll; `position: relative` would anchor at body's + * flow position and silently break popover positioning. + * + * - `top: 0; left: 0` — physical, not logical. `inset-inline-start: 0` + * would resolve to `right: 0` in RTL, anchoring the (zero-width) slot + * at the viewport's top-right corner; an absolute child with + * `left: 200px` would then resolve off the right edge of the screen. + * Same hazard for `inset-block-start` under vertical writing modes. + * + * - `isolation: isolate` — redundant once `position: fixed` and + * `z-index` are set (the slot is already a stacking context). Kept as + * an explicit declaration of intent. + * + * The slot has zero content size, so it never intercepts pointer events. + */ + +.slot { + position: fixed; + top: 0; + /* stylelint-disable-next-line plugin/use-logical-properties-and-values -- Physical anchoring required so floating-ui's viewport-relative coordinates resolve correctly; see file header. */ + left: 0; + z-index: 1000000003; + isolation: isolate; +} diff --git a/packages/ui/src/utils/test/use-enable-wp-compat-overlay-slot.test.tsx b/packages/ui/src/utils/test/use-enable-wp-compat-overlay-slot.test.tsx new file mode 100644 index 00000000000000..6bcb4b1649e689 --- /dev/null +++ b/packages/ui/src/utils/test/use-enable-wp-compat-overlay-slot.test.tsx @@ -0,0 +1,79 @@ +import { render } from '@testing-library/react'; +import { + WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE, + getWpCompatOverlaySlot, + __resetWpCompatOverlaySlotCacheForTests, +} from '../wp-compat-overlay-slot'; +import { useEnableWpCompatOverlaySlot } from '../use-enable-wp-compat-overlay-slot'; + +const internalWindow = window as unknown as { + __wpUiCompatOverlaySlotEnabled?: boolean; +}; + +// The slot is identified by a data attribute (cross-tooling marker, not a +// 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 */ + +function findSlots(): HTMLElement[] { + return Array.from( + document.querySelectorAll< HTMLElement >( + `[${ WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE }]` + ) + ); +} + +function HookHost() { + useEnableWpCompatOverlaySlot(); + return null; +} + +describe( 'useEnableWpCompatOverlaySlot', () => { + afterEach( () => { + __resetWpCompatOverlaySlotCacheForTests(); + findSlots().forEach( ( el ) => el.remove() ); + delete internalWindow.__wpUiCompatOverlaySlotEnabled; + } ); + + it( 'enables the slot once mounted, so getWpCompatOverlaySlot() returns the slot', () => { + expect( getWpCompatOverlaySlot() ).toBeNull(); + + render( ); + + const slot = getWpCompatOverlaySlot(); + expect( slot ).not.toBeNull(); + expect( slot?.parentElement ).toBe( document.body ); + expect( findSlots() ).toHaveLength( 1 ); + } ); + + it( 'is idempotent across multiple components calling the hook', () => { + render( + <> + + + + + ); + + expect( getWpCompatOverlaySlot() ).not.toBeNull(); + expect( findSlots() ).toHaveLength( 1 ); + } ); + + it( 'leaves the slot enabled after the hook caller unmounts (one-way opt-in)', () => { + // The slot is shared infrastructure across all `@wordpress/ui` + // consumers in the document; a single component shouldn't be + // able to disable it for everyone else once enabled. This test + // pins that one-way behavior — unmounting the hook caller does + // not flip the gate back off. + const { unmount } = render( ); + + expect( getWpCompatOverlaySlot() ).not.toBeNull(); + + unmount(); + + expect( internalWindow.__wpUiCompatOverlaySlotEnabled ).toBe( true ); + expect( getWpCompatOverlaySlot() ).not.toBeNull(); + } ); +} ); + +/* eslint-enable testing-library/no-node-access */ diff --git a/packages/ui/src/utils/test/wp-compat-overlay-slot.test.ts b/packages/ui/src/utils/test/wp-compat-overlay-slot.test.ts new file mode 100644 index 00000000000000..25d4b8aef68607 --- /dev/null +++ b/packages/ui/src/utils/test/wp-compat-overlay-slot.test.ts @@ -0,0 +1,329 @@ +import { + getWpCompatOverlaySlot, + WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE, + __resetWpCompatOverlaySlotCacheForTests, +} from '../wp-compat-overlay-slot'; + +/** + * Typed accessor for the internal opt-in flag the helper reads. The flag + * is intentionally undeclared on the global `Window` interface — the + * public API is `useEnableWpCompatOverlaySlot()` (tested separately) — so + * tests that exercise the gating mechanism directly stay behind this + * cast, mirroring how the helper itself reads the flag. + */ +const internalWindow = window as unknown as { + __wpUiCompatOverlaySlotEnabled?: unknown; +}; + +/** + * Typed accessor for the WordPress runtime global the auto-detect heuristic + * reads. Mirrors the helper's local `WpEnvironmentWindow` cast pattern (kept + * off the global `Window` interface to avoid leaking a `Window.wp` + * augmentation into downstream TS consumers via the package's published + * types). Tests use this accessor to plant / observe the runtime shape the + * heuristic inspects. + */ +const wpEnvWindow = window as unknown as { + wp?: { components?: unknown }; +}; + +function findSlots(): HTMLElement[] { + return Array.from( + document.querySelectorAll< HTMLElement >( + `[${ WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE }]` + ) + ); +} + +describe( 'getWpCompatOverlaySlot', () => { + afterEach( () => { + __resetWpCompatOverlaySlotCacheForTests(); + findSlots().forEach( ( el ) => el.remove() ); + delete internalWindow.__wpUiCompatOverlaySlotEnabled; + delete wpEnvWindow.wp; + } ); + + describe( 'explicit opt-in via internal flag', () => { + it( 'returns null when no gate is open', () => { + expect( getWpCompatOverlaySlot() ).toBeNull(); + expect( findSlots() ).toHaveLength( 0 ); + } ); + + it( 'returns null when the flag is explicitly false', () => { + internalWindow.__wpUiCompatOverlaySlotEnabled = false; + + expect( getWpCompatOverlaySlot() ).toBeNull(); + expect( findSlots() ).toHaveLength( 0 ); + } ); + + it.each( [ + [ '1', 1 ], + [ "'yes'", 'yes' ], + [ 'null', null ], + [ 'undefined', undefined ], + ] )( + 'returns null when the flag is %s (strict-equality gate)', + ( _label, value ) => { + internalWindow.__wpUiCompatOverlaySlotEnabled = value; + + expect( getWpCompatOverlaySlot() ).toBeNull(); + expect( findSlots() ).toHaveLength( 0 ); + } + ); + + it( 'creates and returns the slot when the flag is true', () => { + internalWindow.__wpUiCompatOverlaySlotEnabled = true; + + const slot = getWpCompatOverlaySlot(); + + expect( slot ).not.toBeNull(); + expect( slot ).toBeInstanceOf( HTMLDivElement ); + expect( slot?.parentElement ).toBe( document.body ); + expect( + slot?.hasAttribute( WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE ) + ).toBe( true ); + expect( findSlots() ).toHaveLength( 1 ); + } ); + } ); + + describe( 'WordPress environment auto-detection', () => { + it( 'auto-enables when window.wp.components is an object', () => { + wpEnvWindow.wp = { components: {} }; + + const slot = getWpCompatOverlaySlot(); + + expect( slot ).not.toBeNull(); + expect( findSlots() ).toHaveLength( 1 ); + } ); + + it.each( [ + [ 'a string', 'something' ], + [ 'a number', 42 ], + [ 'a boolean', true ], + [ 'undefined', undefined ], + ] )( + 'does not auto-enable when window.wp.components is %s', + ( _label, value ) => { + wpEnvWindow.wp = { components: value }; + + expect( getWpCompatOverlaySlot() ).toBeNull(); + expect( findSlots() ).toHaveLength( 0 ); + } + ); + + it( 'does not auto-enable when window.wp.components is null', () => { + // `typeof null === 'object'` so the check needs an explicit null + // guard. This test pins that behavior. + wpEnvWindow.wp = { components: null }; + + expect( getWpCompatOverlaySlot() ).toBeNull(); + expect( findSlots() ).toHaveLength( 0 ); + } ); + + it( 'does not auto-enable when window.wp itself is missing', () => { + expect( getWpCompatOverlaySlot() ).toBeNull(); + expect( findSlots() ).toHaveLength( 0 ); + } ); + + it( 'opens the gate even with the explicit flag absent', () => { + wpEnvWindow.wp = { components: {} }; + expect( + internalWindow.__wpUiCompatOverlaySlotEnabled + ).toBeUndefined(); + + expect( getWpCompatOverlaySlot() ).not.toBeNull(); + } ); + + // The cross-origin `window.top` throw path (where `.wp` access + // throws because the top window is in another origin) isn't unit- + // tested: jsdom defines `window.top` as a non-configurable, non- + // writable getter, so neither `Object.defineProperty` nor + // `jest.spyOn(window, 'top', 'get')` nor `jest.replaceProperty` + // can simulate the throw. The helper's `try/catch` is readable in + // place and the same-origin happy path (`window.top === window` in + // jsdom, exercised by every other auto-detect test in this suite) + // covers the no-throw branch. Real cross-origin embeddings are + // validated via manual smoke testing. + } ); + + describe( 'singleton caching', () => { + beforeEach( () => { + internalWindow.__wpUiCompatOverlaySlotEnabled = true; + } ); + + it( 'returns the same element on repeated calls', () => { + const first = getWpCompatOverlaySlot(); + const second = getWpCompatOverlaySlot(); + const third = getWpCompatOverlaySlot(); + + expect( first ).not.toBeNull(); + expect( second ).toBe( first ); + expect( third ).toBe( first ); + expect( findSlots() ).toHaveLength( 1 ); + } ); + + it( 'creates a fresh element when the previous one was removed from the DOM, and re-caches it', () => { + const first = getWpCompatOverlaySlot(); + expect( first ).not.toBeNull(); + + first?.remove(); + expect( findSlots() ).toHaveLength( 0 ); + + const second = getWpCompatOverlaySlot(); + + expect( second ).not.toBeNull(); + expect( second ).not.toBe( first ); + expect( second?.isConnected ).toBe( true ); + expect( findSlots() ).toHaveLength( 1 ); + + // The recreated element should now be cached: a third call must + // return it directly without creating a third slot. + const third = getWpCompatOverlaySlot(); + expect( third ).toBe( second ); + expect( findSlots() ).toHaveLength( 1 ); + } ); + + it( 'returns null after the gate is closed, even if a slot was previously created', () => { + const slot = getWpCompatOverlaySlot(); + expect( slot ).not.toBeNull(); + + delete internalWindow.__wpUiCompatOverlaySlotEnabled; + + expect( getWpCompatOverlaySlot() ).toBeNull(); + } ); + + it( 'invalidates the cache and detaches the stale slot when the cached element belongs to a different document', () => { + // Drives the `cachedSlot.ownerDocument !== ownerDocument` branch + // and the subsequent `if ( cachedSlot?.isConnected ) + // cachedSlot.remove();` cleanup. Triggered in real environments + // by a runtime-detected switch in the owning document (e.g. a + // jsdom test teardown that tears down the realm, or a host + // swapping the active document). Simulated here by moving the + // cached slot into a foreign parsed document so its + // `ownerDocument` differs from the helper's local `document` + // while staying `isConnected` to that foreign document — the + // exact shape the cleanup branch was written to handle. + const first = getWpCompatOverlaySlot(); + expect( first ).not.toBeNull(); + + const foreignDocument = new DOMParser().parseFromString( + '', + 'text/html' + ); + foreignDocument.body.appendChild( + foreignDocument.adoptNode( first! ) + ); + expect( first?.ownerDocument ).toBe( foreignDocument ); + expect( first?.isConnected ).toBe( true ); + expect( findSlots() ).toHaveLength( 0 ); + + const second = getWpCompatOverlaySlot(); + + expect( second ).not.toBeNull(); + expect( second ).not.toBe( first ); + expect( second?.ownerDocument ).toBe( document ); + expect( second?.parentElement ).toBe( document.body ); + expect( first?.isConnected ).toBe( false ); + expect( foreignDocument.body.children ).toHaveLength( 0 ); + expect( findSlots() ).toHaveLength( 1 ); + } ); + } ); + + describe( 'DOM-level singleton (cross-instance coordination)', () => { + beforeEach( () => { + internalWindow.__wpUiCompatOverlaySlotEnabled = true; + } ); + + it( 'adopts a pre-existing slot element rather than appending a duplicate', () => { + // Simulate a second `@wordpress/ui` package instance having + // already created the slot before this instance's call. The + // module-level `cachedSlot` is null, but the DOM has the slot. + const preExisting = document.createElement( 'div' ); + preExisting.setAttribute( WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE, '' ); + document.body.appendChild( preExisting ); + + const slot = getWpCompatOverlaySlot(); + + expect( slot ).toBe( preExisting ); + expect( findSlots() ).toHaveLength( 1 ); + } ); + + it( 'caches the adopted slot for subsequent calls', () => { + const preExisting = document.createElement( 'div' ); + preExisting.setAttribute( WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE, '' ); + document.body.appendChild( preExisting ); + + const first = getWpCompatOverlaySlot(); + const second = getWpCompatOverlaySlot(); + + expect( first ).toBe( preExisting ); + expect( second ).toBe( preExisting ); + expect( findSlots() ).toHaveLength( 1 ); + } ); + } ); + + describe( 'document.body unavailable', () => { + beforeEach( () => { + internalWindow.__wpUiCompatOverlaySlotEnabled = true; + } ); + + it( 'returns null without throwing when document.body is missing', () => { + const realBody = document.body; + const bodyDescriptor = Object.getOwnPropertyDescriptor( + Document.prototype, + 'body' + ); + + Object.defineProperty( document, 'body', { + configurable: true, + get: () => null, + } ); + + try { + expect( () => getWpCompatOverlaySlot() ).not.toThrow(); + expect( getWpCompatOverlaySlot() ).toBeNull(); + } finally { + if ( bodyDescriptor ) { + Object.defineProperty( document, 'body', bodyDescriptor ); + } else { + // jsdom typically defines `body` on Document.prototype; if + // it isn't present, fall back to deleting the override so + // `document.body` resolves to the live element again. + delete ( document as unknown as { body: unknown } ).body; + } + expect( document.body ).toBe( realBody ); + } + } ); + } ); + + describe( 'DOM identification', () => { + beforeEach( () => { + internalWindow.__wpUiCompatOverlaySlotEnabled = true; + } ); + + it( 'tags the element with the data-wp-compat-overlay-slot attribute (no value)', () => { + const slot = getWpCompatOverlaySlot(); + + expect( + slot?.getAttribute( WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE ) + ).toBe( '' ); + } ); + + it( 'is discoverable via [data-wp-compat-overlay-slot] selector', () => { + const slot = getWpCompatOverlaySlot(); + + expect( + document.querySelector( + `[${ WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE }]` + ) + ).toBe( slot ); + } ); + + it( 'appends the slot to the local document body', () => { + const slot = getWpCompatOverlaySlot(); + + expect( slot?.ownerDocument ).toBe( document ); + expect( slot?.parentElement ).toBe( document.body ); + } ); + } ); +} ); diff --git a/packages/ui/src/utils/use-enable-wp-compat-overlay-slot.ts b/packages/ui/src/utils/use-enable-wp-compat-overlay-slot.ts new file mode 100644 index 00000000000000..e1b794a7a8545e --- /dev/null +++ b/packages/ui/src/utils/use-enable-wp-compat-overlay-slot.ts @@ -0,0 +1,41 @@ +/** + * Opts the host application into the `@wordpress/ui` compat overlay slot — + * a body-level positioned container into which `@wordpress/ui` overlays + * portal so they reliably stack above `@wordpress/components` overlays in + * mixed-library compositions. + * + * Call once from a component that mounts for the lifetime of the app + * (typically the root). The opt-in is intentionally one-way: the slot is + * shared infrastructure across every `@wordpress/ui` consumer in the same + * document, and a single component shouldn't be able to turn it off for + * everyone else. If the slot isn't wanted, simply don't call this hook. + * + * Anywhere `window.wp.components` is on the global — the typical setup + * for plugins enqueueing `wp-components` through WordPress's script- + * loader — the slot auto-enables and this hook is a no-op. The hook + * exists for apps that aren't built with standard WordPress build + * tooling. + * + * Idempotent and safe to call from multiple components. + */ +export function useEnableWpCompatOverlaySlot(): void { + if ( typeof window === 'undefined' ) { + return; + } + + // The opt-in is applied during render (not in `useLayoutEffect`) so + // descendants in the same render pass — e.g. `Tooltip.Portal`, which + // reads `getWpCompatOverlaySlot()` on every render — see the gate + // open on first mount. Render-phase visibility extends only to + // components rendered *after* this hook in the same pass; calling + // from a top-level component keeps that invariant trivially + // satisfied. An idempotent boolean write is the kind of side effect + // render is allowed to emit: re-renders, StrictMode double-renders, + // and multiple hook callers all collapse to the same final state. + const internalWindow = window as { + __wpUiCompatOverlaySlotEnabled?: boolean; + }; + if ( internalWindow.__wpUiCompatOverlaySlotEnabled !== true ) { + internalWindow.__wpUiCompatOverlaySlotEnabled = true; + } +} diff --git a/packages/ui/src/utils/wp-compat-overlay-slot.ts b/packages/ui/src/utils/wp-compat-overlay-slot.ts new file mode 100644 index 00000000000000..87c993c1e7163f --- /dev/null +++ b/packages/ui/src/utils/wp-compat-overlay-slot.ts @@ -0,0 +1,175 @@ +import styles from './css/wp-compat-overlay-slot.module.css'; + +/** + * Minimal shape of the WordPress runtime global. Local cast so the auto- + * detect heuristic type-checks without leaking a `Window.wp` augmentation + * into downstream TS consumers via this package's published `.d.ts`. + */ +type WpEnvironmentWindow = { + wp?: { + components?: unknown; + }; +}; + +/** + * Cross-`@wordpress/ui`-instance shared store for the explicit opt-in. + * Set by `useEnableWpCompatOverlaySlot()`; read here. Intentionally not + * declared on the global `Window` interface — direct access is in-package + * only and stays behind a local cast. + */ +type CompatOverlaySlotInternalWindow = { + __wpUiCompatOverlaySlotEnabled?: boolean; +}; + +/** + * Identifies the compat overlay slot DOM element. Used as the cross- + * `@wordpress/ui`-instance singleton marker (see `getWpCompatOverlaySlot()`); + * styling is delivered via the CSS-module class on the same element. + */ +export const WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE = 'data-wp-compat-overlay-slot'; + +/** + * Resolves the document that should own the slot — always the local + * document, i.e., the one the JS realm calling this helper sees as + * `globalThis.document`. Not `window.top?.document`, which would put the + * slot in a document where this bundle's CSS modules aren't loaded + * (Storybook's preview iframe being the canonical example). "Is this a + * WordPress environment?" (auto-detect) and "which document hosts the + * slot?" (placement) are orthogonal; the helper always answers the + * second with the local realm. + */ +function resolveOwnerDocument(): Document | null { + if ( typeof document === 'undefined' ) { + return null; + } + return document; +} + +/** + * Detects whether the runtime is a WordPress-flavored environment by + * checking for `window.wp.components`. Tries the top window first so an + * iframe (e.g., the editor canvas) inherits the parent's WP environment; + * falls back to the local window. The `typeof === 'object'` check is + * deliberately stricter than `!== undefined` so a stray non-object + * `components` doesn't trigger auto-enable, and the explicit null + * comparison covers `typeof null === 'object'`. + */ +function isInWordPressEnvironment(): boolean { + let topWp: WpEnvironmentWindow[ 'wp' ]; + try { + topWp = ( window.top as WpEnvironmentWindow | undefined )?.wp; + } catch { + // Cross-origin top window — fall through to the local window. + } + const wp = topWp ?? ( window as WpEnvironmentWindow ).wp; + return typeof wp?.components === 'object' && wp.components !== null; +} + +/** + * Module-level cache. Revalidated on each call against the current owner + * document and the slot's connection state. On miss, the helper falls + * back to a DOM query for an existing slot before creating one — that's + * what coordinates multiple `@wordpress/ui` package instances loaded on + * the same page around a single DOM-level singleton via the + * `[data-wp-compat-overlay-slot]` attribute. + */ +let cachedSlot: HTMLDivElement | null = null; + +/** + * Creates the slot element, tags it with the cross-instance singleton + * attribute, applies the co-located CSS-module class, and appends it to + * the given document's body. Callers must have already verified the gate + * is open and `ownerDocument.body` exists. + * + * @param ownerDocument The document that should own and host the slot. + */ +function createSlot( ownerDocument: Document ): HTMLDivElement { + const element = ownerDocument.createElement( 'div' ); + element.setAttribute( WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE, '' ); + if ( styles.slot ) { + element.classList.add( styles.slot ); + } + ownerDocument.body.appendChild( element ); + return element; +} + +/** + * Returns the body-level compat overlay slot element when the runtime + * opts in, lazily creating it on first call. Returns `null` otherwise, + * leaving the underlying overlay primitives' default portal container + * in effect. + * + * Two opt-in paths: + * + * - Auto-detected when `window.wp.components` is on the global — the + * typical script-loader setup for WordPress plugins and admin + * screens. Zero developer intervention required. + * - Explicit, via `useEnableWpCompatOverlaySlot()` from a top-level + * component — for hosts that bundle `@wordpress/components` (or only + * `@wordpress/ui`) directly rather than relying on the global. + * + * The slot is a single `
` appended to + * the local document's body (see `resolveOwnerDocument`) with styles + * pinned via the co-located CSS module. Subsequent calls return the + * same element; if it's been removed from the DOM it's recreated, and + * if a different `@wordpress/ui` instance already created a slot in + * the same document this call adopts it rather than appending a + * duplicate. + */ +export function getWpCompatOverlaySlot(): HTMLDivElement | null { + if ( typeof window === 'undefined' ) { + return null; + } + + if ( + ! isInWordPressEnvironment() && + ( window as CompatOverlaySlotInternalWindow ) + .__wpUiCompatOverlaySlotEnabled !== true + ) { + return null; + } + + const ownerDocument = resolveOwnerDocument(); + // `document.body` can be null if the helper runs before `` is + // parsed (e.g. a `