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
16 changes: 6 additions & 10 deletions packages/components/src/draggable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@ import { getWpCompatOverlaySlot } from '@wordpress/ui';
import type { DraggableProps } from './types';
import styles from './style.module.scss';

// The hardcoded legacy class names are preserved alongside the
// CSS-module hashed ones for backwards compatibility. `filter(Boolean)`
// keeps Jest's CSS-module mock (which returns `undefined`) from leaking
// a literal "undefined" class.
// Legacy class names preserved alongside the CSS-module hashed ones for
// backwards compatibility. `filter(Boolean)` strips `undefined` from Jest's
// CSS-module mock.
const dragImageClasses = [
styles[ 'invisible-drag-image' ],
'components-draggable__invisible-drag-image',
Expand All @@ -28,8 +27,7 @@ const cloneWrapperClasses = [
styles.clone,
'components-draggable__clone',
].filter( Boolean );
// Body-level signal shared with external code (e.g. block-editor keyboard
// drag), so it stays as a plain global class rather than module-scoped.
// Global class — shared with external code (e.g. block-editor keyboard drag).
const bodyClass = 'is-dragging-components-draggable';
const clonePadding = 0;

Expand Down Expand Up @@ -114,8 +112,7 @@ export function Draggable( {
function start( event: DragEvent ) {
const { ownerDocument } = event.target as HTMLElement;
// Only use the slot when it lives in the same document as the
// dragged element, so the clone's viewport-relative coordinates
// resolve in one coordinate space.
// dragged element, so coordinate resolution stays in one space.
const slot = getWpCompatOverlaySlot();
const compatSlot = slot?.ownerDocument === ownerDocument ? slot : null;

Expand All @@ -136,8 +133,7 @@ export function Draggable( {
// IE, we need to check for its existence first.
if ( 'function' === typeof event.dataTransfer.setDragImage ) {
dragImage.classList.add( ...dragImageClasses );
// Stays at the document body — invisible, so the slot's stacking
// guarantees aren't needed here.
// Invisible — stays at the document body, no slot needed.
ownerDocument.body.appendChild( dragImage );
event.dataTransfer.setDragImage( dragImage, 0, 0 );
}
Expand Down
6 changes: 3 additions & 3 deletions packages/components/src/draggable/stories/index.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ const meta: Meta< typeof Draggable > = {
controls: { expanded: true },
docs: {
source: { code: '' },
// Render each story in its own iframe — Storybook's docs-page
// wrappers create transform-based containing blocks that break
// the drag clone's `position: fixed` resolution.
// Render in its own iframe — Storybook's docs-page wrappers
// create transform-based containing blocks that break the
// clone's `position: fixed` resolution.
story: { inline: false, height: '250px' },
},
componentStatus: {
Expand Down
11 changes: 5 additions & 6 deletions packages/components/src/draggable/style.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,19 @@

.clone {
position: fixed;
padding: 0; // Should match clonePadding variable.
padding: 0; // Matches the `clonePadding` JS constant.
background: transparent;
pointer-events: none;
}

// Apply the stacking fallback only outside the `@wordpress/ui` compat
// overlay slot — inside it, the slot's isolated stacking context
// already handles ordering.
// Fallback for clones placed outside the compat overlay slot — the
// slot's stacking context handles ordering inside it.
.clone:not(.is-in-compat-slot) {
z-index: z-index(".components-draggable__clone");
}

// Keep this selector global so external code that toggles the same
// body class (e.g. block-editor keyboard drag) gets the same cursor.
// Global selector external code (e.g. block-editor keyboard drag)
// toggles the same body class.
:global(body.is-dragging-components-draggable) {
cursor: move; /* Fallback for IE/Edge < 14 */
cursor: grabbing !important;
Expand Down
6 changes: 3 additions & 3 deletions packages/components/src/draggable/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ export type DraggableProps = {
* Whether to append the cloned element to the `ownerDocument` body.
* By default, elements sourced by id are appended to the element's wrapper.
*
* Has no effect while the `@wordpress/ui` compat overlay slot is in use
* in the same document (the clone is placed in the slot instead). Cross-
* document drags fall back to this prop's regular semantics.
* Has no effect when the `@wordpress/ui` compat overlay slot is in use in
* the same document the clone goes into the slot instead. Cross-document
* drags fall back to this prop's regular semantics.
*
* @default false
*/
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ 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):
If your app pairs `@wordpress/ui` with `@wordpress/components` overlays and bundles both packages directly (i.e. without relying on the `window.wp.components` global exposed by WordPress's script-loader), call `useEnableWpCompatOverlaySlot()` once from a long-lived root component:

```tsx
import { useEnableWpCompatOverlaySlot } from '@wordpress/ui';
Expand All @@ -75,7 +75,7 @@ function App() {
}
```

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`.
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 and idempotent. It is not needed in standard WordPress editor screens, where the slot auto-enables based on `window.wp.components`.

## Usage

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/tooltip/portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getWpCompatOverlaySlot } from '../utils/wp-compat-overlay-slot';

/**
* Used to apply custom portal behavior to `Tooltip`'s floating content.
* Defaults the `container` prop to the `@wordpress/ui` compat overlay slot.
* `container` defaults to the `@wordpress/ui` compat overlay slot.
*/
const Portal = forwardRef< HTMLDivElement, PortalProps >(
function TooltipPortal( { container, ...restProps }, ref ) {
Expand Down
13 changes: 3 additions & 10 deletions packages/ui/src/tooltip/test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,26 +154,19 @@ 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.
// 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]';

// 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.
// Exercises the public opt-in path rather than poking the flag.
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.
// The hook is one-way at runtime; reset explicitly between tests.
delete ( window as { __wpUiCompatOverlaySlotEnabled?: boolean } )
.__wpUiCompatOverlaySlotEnabled;
document
Expand Down
46 changes: 12 additions & 34 deletions packages/ui/src/utils/css/wp-compat-overlay-slot.module.css
Original file line number Diff line number Diff line change
@@ -1,46 +1,24 @@
/*
* WP compat overlay slot — body-level positioned container that hosts
* 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.
* compositions. See `getWpCompatOverlaySlot()` for the runtime side.
*
* 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.
* Authored unlayered (outside `@layer wp-ui-*`) so the slot's z-index and
* positioning win against any `@layer`-scoped rule.
*/

.slot {
/* `position: fixed` is load-bearing: `z-index` is ignored on `static`,
* and the slot must be a containing block at viewport `(0, 0)` so
* floating-ui's viewport-relative `top`/`left` resolve correctly. */
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. */
/* Physical, not logical. `inset-inline-start: 0` would resolve to
* `right: 0` in RTL and offset every absolute child off-viewport. */
/* stylelint-disable-next-line plugin/use-logical-properties-and-values -- See comment above. */
left: 0;
/* Sits in a reserved billion-scale band above the legacy z-index map in
* `packages/base-styles/_z-index.scss`. */
z-index: 1000000003;
isolation: isolate;
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ 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.
// Slot is identified by a data attribute, not a user-facing role/text.
/* eslint-disable testing-library/no-node-access */

function findSlots(): HTMLElement[] {
Expand Down Expand Up @@ -60,11 +58,8 @@ describe( 'useEnableWpCompatOverlaySlot', () => {
} );

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.
// Pins the one-way behavior — unmounting must not flip the gate
// back off; the slot is shared infrastructure.
const { unmount } = render( <HookHost /> );

expect( getWpCompatOverlaySlot() ).toBeDefined();
Expand Down
61 changes: 16 additions & 45 deletions packages/ui/src/utils/test/wp-compat-overlay-slot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,12 @@ import {
__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.
*/
// Typed accessors mirroring the helper's local casts: the flag and the
// `wp` global are both intentionally undeclared on `Window` so the
// package's published types don't leak augmentations.
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 };
};
Expand Down Expand Up @@ -112,8 +99,7 @@ describe( 'getWpCompatOverlaySlot', () => {
);

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.
// `typeof null === 'object'` — pins the explicit null guard.
wpEnvWindow.wp = { components: null };

expect( getWpCompatOverlaySlot() ).toBeUndefined();
Expand All @@ -134,15 +120,10 @@ describe( 'getWpCompatOverlaySlot', () => {
expect( getWpCompatOverlaySlot() ).toBeDefined();
} );

// 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
// The cross-origin `window.top` throw path isn't unit-tested:
// jsdom's `window.top` is a non-configurable, non-writable getter,
// so the throw can't be simulated. Same-origin happy path is
// covered by every other auto-detect test; cross-origin is
// validated via manual smoke testing.
} );

Expand Down Expand Up @@ -176,8 +157,7 @@ describe( 'getWpCompatOverlaySlot', () => {
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.
// A third call returns the cached recreated slot directly.
const third = getWpCompatOverlaySlot();
expect( third ).toBe( second );
expect( findSlots() ).toHaveLength( 1 );
Expand All @@ -193,16 +173,10 @@ describe( 'getWpCompatOverlaySlot', () => {
} );

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.
// Exercises the foreign-document cleanup branch by moving the
// cached slot into a parsed foreign document, so it stays
// `isConnected` but `ownerDocument` differs from the helper's
// local `document`.
const first = getWpCompatOverlaySlot();
expect( first ).toBeDefined();

Expand Down Expand Up @@ -235,9 +209,8 @@ describe( 'getWpCompatOverlaySlot', () => {
} );

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.
// Simulates a second `@wordpress/ui` instance creating the slot
// first: `cachedSlot` is null but the slot already exists in the DOM.
const preExisting = document.createElement( 'div' );
preExisting.setAttribute( WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE, '' );
document.body.appendChild( preExisting );
Expand Down Expand Up @@ -286,9 +259,7 @@ describe( 'getWpCompatOverlaySlot', () => {
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.
// Fallback if `body` wasn't on Document.prototype.
delete ( document as unknown as { body: unknown } ).body;
}
expect( document.body ).toBe( realBody );
Expand Down
Loading
Loading