Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7768e43
Draggable: Migrate clone wrapper to wp compat overlay slot
ciampo May 12, 2026
d66e4ce
Draggable: Storybook: render docs-page stories in iframes
ciampo May 14, 2026
3c09678
Draggable: Storybook: polish cross-document fallback playground story
ciampo May 14, 2026
a53d116
CHANGELOG: Restore entries dropped during rebase
ciampo May 14, 2026
b2713a2
Storybook: Fix popover-with-slotfill cross-iframe collision boundary
ciampo May 14, 2026
fa37c7d
Draggable: Storybook: refresh AppendElementToOwnerDocument JSDoc
ciampo May 14, 2026
66e1e7a
Draggable: CHANGELOG: Call out default-mode in-flow ancestor change
ciampo May 14, 2026
c218161
Draggable: Add e2e regression for chip-inside-compat-slot
ciampo May 14, 2026
25d07c4
Storybook: Trim file-level docblocks on playground stories
ciampo May 15, 2026
55b0e5e
Storybook: popover-with-slotfill story: use public @wordpress/ui API
ciampo May 15, 2026
ef62906
Storybook: draggable cross-doc story: load components styles via Stor…
ciampo May 15, 2026
5835688
Storybook: Move cross-document fallback story under "Debug fixtures"
ciampo May 15, 2026
54e5070
Storybook: Drop redundant `parameters.sourceLink` from playground sto…
ciampo May 15, 2026
479051b
Draggable: Migrate styles from SCSS to a CSS module
ciampo May 15, 2026
888f2ef
Storybook: Simplify cross-document fallback story with StyleProvider
ciampo May 15, 2026
3798ccb
Draggable: CHANGELOG: Move entry to Unreleased and slim it down
ciampo May 15, 2026
9ebae4b
Draggable: Trim verbose inline code comments
ciampo May 15, 2026
db5e5c8
@wordpress/ui CHANGELOG: Move #78183 entry to Unreleased
ciampo May 15, 2026
3ebeca5
Draggable: Keep physical `left` for the invisible drag image
ciampo May 15, 2026
12225ff
@wordpress/ui CHANGELOG: Trim #78183 entry
ciampo May 15, 2026
dfbf2d2
@wordpress/ui: Restore unrelated tsconfig change
ciampo May 15, 2026
7bce5a9
Storybook: Drop redundant story-name overrides
ciampo May 15, 2026
a5fb5c1
Draggable: Keep body cursor class global
ciampo May 15, 2026
d0276b7
Draggable: Guard class arrays against the Jest CSS-module mock
ciampo May 15, 2026
cb9b980
Draggable: Address minor self-review nits
ciampo May 15, 2026
76a277c
Storybook: Group compat-slot fixtures under Debug fixtures
ciampo May 15, 2026
6500cbd
Draggable: Use kebab-case for CSS module class names
ciampo May 15, 2026
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
31 changes: 21 additions & 10 deletions .stylelintrc.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// CSS Baseline 2024 stepped-value functions not yet recognized by Stylelint.
const CSS_BASELINE_2024_FUNCTIONS = [ 'round', 'rem', 'mod' ];

/** @type {import('stylelint').Config} */
module.exports = {
extends: '@wordpress/stylelint-config/scss-stylistic',
Expand Down Expand Up @@ -68,16 +71,7 @@ module.exports = {
rules: {
'function-no-unknown': [
true,
{
ignoreFunctions: [
// CSS stepped value math functions in Baseline 2024.
// This rule exception can likely be removed when
// updating to a more recent version of Stylelint.
'round',
'rem',
'mod',
],
},
{ ignoreFunctions: CSS_BASELINE_2024_FUNCTIONS },
],
'declaration-property-max-values': {
// Prevents left/right values with shorthand property names (unclear for RTL)
Expand Down Expand Up @@ -135,6 +129,23 @@ module.exports = {
],
},
},
{
// SCSS-only: use the Sass-aware `function-no-unknown` variant.
files: [ '**/*.module.scss', 'routes/**/*.scss' ],
rules: {
'function-no-unknown': null,
'scss/function-no-unknown': [
true,
{
ignoreFunctions: [
...CSS_BASELINE_2024_FUNCTIONS,
// Sass helpers from `@wordpress/base-styles`.
'z-index',
],
},
],
},
},
],
reportDescriptionlessDisables: true,
};
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Enhancements

- `Draggable`: Render the drag clone inside the `@wordpress/ui` compat overlay slot so it shares stacking with `@wordpress/ui` overlays opened mid-drag. Auto-enabled in WordPress environments; other hosts can opt in via `useEnableWpCompatOverlaySlot()` ([#78183](https://github.com/WordPress/gutenberg/pull/78183)).

## 33.1.0 (2026-05-14)

### Enhancements
Expand Down
1 change: 1 addition & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"@wordpress/private-apis": "file:../private-apis",
"@wordpress/rich-text": "file:../rich-text",
"@wordpress/style-runtime": "file:../style-runtime",
"@wordpress/ui": "file:../ui",
"@wordpress/warning": "file:../warning",
"change-case": "^4.1.2",
"clsx": "^2.1.1",
Expand Down
41 changes: 31 additions & 10 deletions packages/components/src/draggable/index.tsx
Comment thread
ciampo marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,30 @@ import type { DragEvent } from 'react';
*/
import { throttle } from '@wordpress/compose';
import { useEffect, useRef } from '@wordpress/element';
import { getWpCompatOverlaySlot } from '@wordpress/ui';

/**
* Internal dependencies
*/
import type { DraggableProps } from './types';

const dragImageClass = 'components-draggable__invisible-drag-image';
const cloneWrapperClass = 'components-draggable__clone';
const clonePadding = 0;
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.
const dragImageClasses = [
styles[ 'invisible-drag-image' ],
'components-draggable__invisible-drag-image',
].filter( Boolean );
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.
const bodyClass = 'is-dragging-components-draggable';
const clonePadding = 0;

/**
* `Draggable` is a Component that provides a way to set up a cross-browser
Expand Down Expand Up @@ -99,6 +113,11 @@ 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.
const slot = getWpCompatOverlaySlot();
const compatSlot = slot?.ownerDocument === ownerDocument ? slot : null;

event.dataTransfer.setData(
transferDataType,
Expand All @@ -116,12 +135,14 @@ export function Draggable( {
// right after. event.dataTransfer.setDragImage is not supported yet in
// IE, we need to check for its existence first.
if ( 'function' === typeof event.dataTransfer.setDragImage ) {
dragImage.classList.add( dragImageClass );
dragImage.classList.add( ...dragImageClasses );
// Stays at the document body — invisible, so the slot's stacking
// guarantees aren't needed here.
ownerDocument.body.appendChild( dragImage );
event.dataTransfer.setDragImage( dragImage, 0, 0 );
}

cloneWrapper.classList.add( cloneWrapperClass );
cloneWrapper.classList.add( ...cloneWrapperClasses );

if ( cloneClassname ) {
cloneWrapper.classList.add( cloneClassname );
Expand All @@ -141,8 +162,7 @@ export function Draggable( {
clonedDragComponent.innerHTML = dragComponentRef.current.innerHTML;
cloneWrapper.appendChild( clonedDragComponent );

// Inject the cloneWrapper into the DOM.
ownerDocument.body.appendChild( cloneWrapper );
( compatSlot ?? ownerDocument.body ).appendChild( cloneWrapper );
} else {
const element = ownerDocument.getElementById(
elementId
Expand Down Expand Up @@ -173,8 +193,9 @@ export function Draggable( {

cloneWrapper.appendChild( clone );

// Inject the cloneWrapper into the DOM.
if ( appendToOwnerDocument ) {
if ( compatSlot ) {
compatSlot.appendChild( cloneWrapper );
} else if ( appendToOwnerDocument ) {
ownerDocument.body.appendChild( cloneWrapper );
} else {
elementWrapper?.appendChild( cloneWrapper );
Expand Down
17 changes: 11 additions & 6 deletions packages/components/src/draggable/stories/index.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ const meta: Meta< typeof Draggable > = {
},
parameters: {
controls: { expanded: true },
docs: { source: { code: '' } },
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.
story: { inline: false, height: '250px' },
},
componentStatus: {
status: 'use-with-caution',
whereUsed: 'global',
Expand Down Expand Up @@ -115,11 +121,10 @@ export const Default: StoryFn< typeof Draggable > = DefaultTemplate.bind( {} );
Default.args = {};

/**
* `appendToOwnerDocument` is used to append the element being dragged to the body of the owner document.
*
* This is useful when the element being dragged should not receive styles from its parent.
* For example, when the element's parent sets a `z-index` value that would cause the dragged
* element to be rendered behind other elements.
* `appendToOwnerDocument` appends the dragged element's clone to the owner
* document's body instead of the element's parent, which is useful when an
* ancestor's stacking context (e.g. its `z-index`) would otherwise place the
* clone behind other content.
*/
export const AppendElementToOwnerDocument: StoryFn< typeof Draggable > =
DefaultTemplate.bind( {} );
Expand Down
26 changes: 26 additions & 0 deletions packages/components/src/draggable/style.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
@use "@wordpress/base-styles/z-index" as *;

.invisible-drag-image {
position: fixed;
/* stylelint-disable-next-line plugin/use-logical-properties-and-values -- Offscreen drag-image stand-in; flipping with writing direction has no benefit. */
left: -1000px;
height: 50px;
width: 50px;
}

.clone {
position: fixed;
padding: 0; // Should match clonePadding variable.
background: transparent;
pointer-events: none;
// Stacking fallback for when the clone is not routed into the
// `@wordpress/ui` compat overlay 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(body.is-dragging-components-draggable) {
cursor: move; /* Fallback for IE/Edge < 14 */
cursor: grabbing !important;
}
21 changes: 0 additions & 21 deletions packages/components/src/draggable/style.scss

This file was deleted.

4 changes: 4 additions & 0 deletions packages/components/src/draggable/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ 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.
*
* @default false
*/
appendToOwnerDocument?: boolean;
Expand Down
1 change: 0 additions & 1 deletion packages/components/src/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
@use "./combobox-control/style.scss" as *;
@use "./color-palette/style.scss" as *;
@use "./custom-gradient-picker/style.scss" as *;
@use "./draggable/style.scss" as *;
@use "./drop-zone/style.scss" as *;
@use "./dropdown/style.scss" as *;
@use "./dropdown-menu/style.scss" as *;
Expand Down
1 change: 1 addition & 0 deletions packages/components/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
{ "path": "../react-i18n" },
{ "path": "../rich-text" },
{ "path": "../style-runtime" },
{ "path": "../ui" },
{ "path": "../warning" }
],
"files": [ "global.d.ts", "storybook.d.ts" ],
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Enhancements

- Export `getWpCompatOverlaySlot()` so consumers can route their own portals into the compat overlay slot ([#78183](https://github.com/WordPress/gutenberg/pull/78183)).

## 0.13.0 (2026-05-14)

### Breaking Changes
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ export * from './stack';
export * as Tabs from './tabs';
export * from './text';
export * as Tooltip from './tooltip';
export { getWpCompatOverlaySlot } from './utils/wp-compat-overlay-slot';
export { useEnableWpCompatOverlaySlot } from './utils/use-enable-wp-compat-overlay-slot';
export * from './visually-hidden';
97 changes: 0 additions & 97 deletions packages/ui/src/popover/stories/index.story.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useId, useRef, useState } from '@wordpress/element';
import { SlotFillProvider, Slot } from '@wordpress/components';
import { close, info } from '@wordpress/icons';
import * as Popover from '../';
import { VisuallyHidden } from '../../visually-hidden';
Expand Down Expand Up @@ -697,102 +696,6 @@ export const CrossIframe: Story = {
},
};

/**
* Same cross-iframe scenario, but using `SlotFillProvider` and `Slot` from
* `@wordpress/components` as the render target.
*
* The `Slot` renders a `div` in the parent document, and its forwarded ref
* is passed to `Popover.Portal`'s `container` prop (via `Popover.Popup`'s
* `portal` prop) so the popup portals into the slot element. This mirrors the
* legacy Popover's `WithSlotOutsideIframe` pattern.
*/
export const CrossIframeWithSlotFill: Story = {
name: 'Cross-Iframe (SlotFill)',
args: { defaultOpen: true },
argTypes: { defaultOpen: { control: false } },
render: function Render( { children: _children, ...args } ) {
const slotRef = useRef< HTMLDivElement >( null );
const [ iframeBoundary, setIframeBoundary ] =
useState< HTMLIFrameElement | null >( null );

return (
<SlotFillProvider>
<Slot
name="popover-container"
bubblesVirtually
ref={ slotRef }
/>
<GenericIframe
ref={ setIframeBoundary }
style={ {
width: '100%',
height: 400,
border: 0,
outline: '1px solid purple',
} }
>
<div
style={ {
height: '200vh',
paddingTop: '10vh',
} }
>
<div
style={ {
maxWidth: 200,
marginTop: 100,
marginInline: 'auto',
} }
>
<Popover.Root { ...args }>
<Popover.Trigger
style={ {
padding: 8,
background: 'salmon',
} }
>
Popover&apos;s anchor (inside iframe)
</Popover.Trigger>
<Popover.Popup
portal={
<Popover.Portal
container={
slotRef as React.RefObject< HTMLElement >
}
/>
}
positioner={
<Popover.Positioner
collisionBoundary={
iframeBoundary ?? undefined
}
/>
}
>
<Popover.Arrow />
<Popover.Title
style={ {
marginBottom:
'var(--wpds-dimension-gap-xs)',
} }
>
Cross-Iframe (SlotFill)
</Popover.Title>
<Popover.Description>
This popup renders in the parent
document via a `Slot` from
`@wordpress/components`.
</Popover.Description>
</Popover.Popup>
</Popover.Root>
</div>
</div>
</GenericIframe>
</SlotFillProvider>
);
},
};

/**
* Popovers in Gutenberg are managed with explicit z-index values, which can
* create situations where a popover renders below another popover when you
Expand Down
Loading
Loading