+ ),
+ } );
+
+ render(
+
+ );
+
+ await waitFor( () => {
+ expect( screen.getByTestId( 'first-input' ) ).toHaveFocus();
+ } );
+ } );
+
+ it( 'falls back to the popup smart default when modalFocusOnMount is unset', async () => {
+ // With Base UI's smart default (and the close-icon de-prioritisation
+ // installed by `Dialog.Popup`), focus should land on the first content
+ // tabbable rather than the close button.
+ const action = createAction( {
+ RenderModal: () => (
+
+
Some text
+
+
+ ),
+ } );
+
+ render(
+
+ );
+
+ await waitFor( () => {
+ expect( screen.getByTestId( 'content-button' ) ).toHaveFocus();
+ } );
+ } );
+
+ it.each( [ 'small', 'medium', 'large', 'stretch', 'full' ] as const )(
+ 'forwards modalSize %p to Dialog.Popup without emitting a deprecation warning',
+ async ( modalSize ) => {
+ const action = createAction( { modalSize } );
+
+ render(
+
+ );
+
+ await screen.findByRole( 'dialog' );
+ expect( console ).not.toHaveWarned();
+ }
+ );
+
+ it( 'renders the popup inside the dataviews-action-modal__portal element', async () => {
+ const action = createAction();
+
+ render(
+
+ );
+
+ const dialog = await screen.findByRole( 'dialog' );
+ // The portal is a structural CSS wrapper with no semantic role, so
+ // there's no Testing Library query that can reach it directly.
+ // Walking up the tree is the most precise way to assert the popup
+ // renders inside the scoped portal that owns the per-instance
+ // `--wp-ui-dialog-z-index` override.
+ expect(
+ // eslint-disable-next-line testing-library/no-node-access
+ dialog.closest( '.dataviews-action-modal__portal' )
+ ).not.toBeNull();
+ } );
+
+ it( 'closes when the user presses Escape', async () => {
+ const user = userEvent.setup();
+ const closeModal = jest.fn();
+ const action = createAction();
+
+ render(
+
+ );
+
+ await screen.findByRole( 'dialog' );
+ await user.keyboard( '{Escape}' );
+
+ expect( closeModal ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'closes when the user clicks the backdrop by default', async () => {
+ const user = userEvent.setup();
+ const closeModal = jest.fn();
+ const action = createAction();
+
+ render(
+
+ );
+
+ await screen.findByRole( 'dialog' );
+ const backdrop = screen.getByTestId( 'dialog-backdrop' );
+ await user.click( backdrop );
+
+ expect( closeModal ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'does not close on backdrop click for alert dialogs (hideModalHeader)', async () => {
+ const user = userEvent.setup();
+ const closeModal = jest.fn();
+ const action = createAction( { hideModalHeader: true } );
+
+ render(
+
+ );
+
+ await screen.findByRole( 'alertdialog' );
+ const backdrop = screen.getByTestId( 'dialog-backdrop' );
+ await user.click( backdrop );
+
+ expect( closeModal ).not.toHaveBeenCalled();
+ } );
+} );
From 1999c9282410a773f94e095866ffd55fb527f74b Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Mon, 30 Mar 2026 19:09:48 +0200
Subject: [PATCH 03/34] Update CHANGELOG
---
packages/dataviews/CHANGELOG.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md
index 34004ae5375b25..5fa606a6e9d771 100644
--- a/packages/dataviews/CHANGELOG.md
+++ b/packages/dataviews/CHANGELOG.md
@@ -35,8 +35,8 @@
- DataViews: Use intersectionObserver to improve performance by unloading invisible items. Change how infinite scroll is enabled to require only 2 view properties: `infiniteScrollEnabled` and `startPosition`. [#74378](https://github.com/WordPress/gutenberg/pull/74378)
- DataForm: The card layout now uses `Card` and `CollapsibleCard` from `@wordpress/ui` instead of `Card`, `CardHeader`, and `CardBody` from `@wordpress/components`. This changes the card's visual appearance (spacing, typography, and removal of the header/content separator). Custom CSS targeting `.components-card__body` within DataViews has been removed. Consumers wrapping DataViews or DataForm in a card should migrate to the `Card` and `CollapsibleCard` components from `@wordpress/ui`. [#76282](https://github.com/WordPress/gutenberg/pull/76282)
-- DataViews: Migrate action modals and DataForm panel modals from `@wordpress/components` `Modal` to `@wordpress/ui` `Dialog` (and `AlertDialog` for confirmation dialogs with `hideModalHeader`). The `modalSize` value `'fill'` is deprecated in favour of `'stretch'`. New values `'stretch'` and `'full'` are available. Custom CSS targeting `.components-modal__*` classes inside action modals will no longer work.
-- DataForm: The panel modal footer CSS class `.dataforms-layouts-panel__modal-footer` has been removed; the modal now uses `Dialog.Footer` for default spacing.
+- DataViews: Migrate action modals and DataForm panel modals from `@wordpress/components` `Modal` to `@wordpress/ui` `Dialog` and `AlertDialog`. The `modalSize` value `'fill'` is deprecated in favour of `'stretch'`. New values `'stretch'` and `'full'` are available. Custom CSS targeting `.components-modal__*` classes inside action modals will no longer work. [#76837](https://github.com/WordPress/gutenberg/pull/76837)
+- DataForm: The panel modal footer CSS class `.dataforms-layouts-panel__modal-footer` has been removed; the modal now uses `Dialog.Footer` for default spacing. [#76837](https://github.com/WordPress/gutenberg/pull/76837)
### Enhancements
From 6a4e7afa76e6bb73b6e4b75f3cd351c349ecb08f Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Mon, 30 Mar 2026 21:04:56 +0200
Subject: [PATCH 04/34] DataViews: Simplify focus callbacks using Dialog's
smart default
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Remove custom initialFocus workarounds that are no longer needed now that
Dialog.Popup deprioritizes the close icon by default:
- panel/modal.tsx: Remove focusFirstInput callback — Dialog's default
focuses the first input (first non-close-icon tabbable in content).
- dataviews-item-actions/index.tsx: Simplify useMapFocusOnMount — the
'firstContentElement' case now maps to Dialog's default. Only
'firstInputElement' still needs a custom callback.
Made-with: Cursor
---
.../dataform-layouts/panel/modal.tsx | 21 +-------------
.../dataviews-item-actions/index.tsx | 28 ++++++++-----------
2 files changed, 12 insertions(+), 37 deletions(-)
diff --git a/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx b/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
index b98ae7015beb2c..ea0e2c6c0e3ce6 100644
--- a/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
+++ b/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
@@ -7,13 +7,7 @@ import deepMerge from 'deepmerge';
* WordPress dependencies
*/
import { Button } from '@wordpress/components';
-import {
- useCallback,
- useContext,
- useMemo,
- useRef,
- useState,
-} from '@wordpress/element';
+import { useContext, useMemo, useRef, useState } from '@wordpress/element';
// eslint-disable-next-line @wordpress/use-recommended-components
import { Dialog } from '@wordpress/ui';
@@ -102,18 +96,6 @@ function ModalContent< Item >( {
const contentRef = useRef< HTMLDivElement >( null );
- const initialFocus = useCallback( () => {
- if ( contentRef.current ) {
- const input = contentRef.current.querySelector< HTMLElement >(
- 'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled])'
- );
- if ( input ) {
- return input;
- }
- }
- return true as const;
- }, [] );
-
// When the modal is opened after being previously closed (touched),
// trigger reportValidity to show field-level errors.
useReportValidity( contentRef, touched );
@@ -130,7 +112,6 @@ function ModalContent< Item >( {
{ fieldLabel }
diff --git a/packages/dataviews/src/components/dataviews-item-actions/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
index e860710295e4c1..8731683bee5fde 100644
--- a/packages/dataviews/src/components/dataviews-item-actions/index.tsx
+++ b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
@@ -119,40 +119,34 @@ function mapModalSize(
);
}
-const FIRST_TABBABLE_SELECTOR =
- 'a[href], button:not([disabled]), input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
-
const FIRST_INPUT_SELECTOR =
'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled])';
-function useInitialFocus(
+function useMapFocusOnMount(
focusOnMount: ActionModalType< unknown >[ 'modalFocusOnMount' ],
contentRef: React.RefObject< HTMLElement | null >
) {
- const callback = useCallback( () => {
+ const focusFirstInput = useCallback( () => {
if ( contentRef.current ) {
- const selector =
- focusOnMount === 'firstInputElement'
- ? FIRST_INPUT_SELECTOR
- : FIRST_TABBABLE_SELECTOR;
const target =
- contentRef.current.querySelector< HTMLElement >( selector );
+ contentRef.current.querySelector< HTMLElement >(
+ FIRST_INPUT_SELECTOR
+ );
if ( target ) {
return target;
}
}
return true as const;
- }, [ focusOnMount, contentRef ] );
+ }, [ contentRef ] );
if ( focusOnMount === false ) {
return false;
}
- if (
- focusOnMount === 'firstContentElement' ||
- focusOnMount === 'firstInputElement'
- ) {
- return callback;
+ if ( focusOnMount === 'firstInputElement' ) {
+ return focusFirstInput;
}
+ // 'firstContentElement', true, 'firstElement' — Dialog's smart default
+ // already skips the close icon and focuses the first content tabbable.
return true;
}
@@ -171,7 +165,7 @@ export function ActionModal< Item >( {
const title = modalHeader || label;
const contentRef = useRef< HTMLDivElement >( null );
- const initialFocus = useInitialFocus(
+ const initialFocus = useMapFocusOnMount(
action.modalFocusOnMount ?? true,
contentRef
);
From 58bf6607aa7b05e9765d2c90de3e947a9baa67ac Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Wed, 8 Apr 2026 19:24:26 +0200
Subject: [PATCH 05/34] DataViews: Minor cleanups for Dialog migration
- Remove unused `dataforms-layouts-panel__modal` className from
Dialog.Popup in panel modal (no CSS rules target it).
- Use VisuallyHidden render prop for Dialog.Title composition,
producing one DOM node instead of two.
Made-with: Cursor
---
.../src/components/dataform-layouts/panel/modal.tsx | 5 +----
.../src/components/dataviews-item-actions/index.tsx | 9 ++++++---
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx b/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
index ea0e2c6c0e3ce6..29b3c50023e955 100644
--- a/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
+++ b/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
@@ -109,10 +109,7 @@ function ModalContent< Item >( {
}
} }
>
-
+ { fieldLabel }
diff --git a/packages/dataviews/src/components/dataviews-item-actions/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
index 8731683bee5fde..c687d4d3151feb 100644
--- a/packages/dataviews/src/components/dataviews-item-actions/index.tsx
+++ b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
@@ -170,6 +170,9 @@ export function ActionModal< Item >( {
contentRef
);
+ // AlertDialog.Root provides `role="alertdialog"` and blocks backdrop-click
+ // dismissal via the shared Base UI DialogStore. Dialog.Popup is used instead
+ // of AlertDialog.Popup because RenderModal provides its own buttons.
const DialogRoot = action.hideModalHeader ? AlertDialog.Root : Dialog.Root;
return (
@@ -189,9 +192,9 @@ export function ActionModal< Item >( {
initialFocus={ initialFocus }
>
{ action.hideModalHeader ? (
-
- { title }
-
+ { title } }
+ />
) : (
{ title }
From d06056c33741fda94db8c895bd8a3f42f7052571 Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Wed, 8 Apr 2026 19:24:38 +0200
Subject: [PATCH 06/34] Update CHANGELOG: move entries to Unreleased,
consolidate
Move breaking change entries from the already-released 14.0.0 section
to Unreleased. Consolidate the two separate entries into one that also
covers the removed dataforms-layouts-panel__modal CSS class.
Made-with: Cursor
---
packages/dataviews/CHANGELOG.md | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md
index 5fa606a6e9d771..4dc4cba1417638 100644
--- a/packages/dataviews/CHANGELOG.md
+++ b/packages/dataviews/CHANGELOG.md
@@ -2,11 +2,11 @@
## Unreleased
-## 14.2.0 (2026-04-29)
+### Breaking Changes
-### Enhancements
+- DataViews: Migrate action modals and DataForm panel modals from `@wordpress/components` `Modal` to `@wordpress/ui` `Dialog` and `AlertDialog`. The `modalSize` value `'fill'` is deprecated in favour of `'stretch'`. New values `'stretch'` and `'full'` are available. Custom CSS targeting `.components-modal__*` classes inside action modals will no longer work. The `dataforms-layouts-panel__modal` CSS class on the panel modal and the `dataforms-layouts-panel__modal-footer` CSS class have been removed. ([#76837](https://github.com/WordPress/gutenberg/pull/76837))
-- DataForm: Render field `description` as help text in the `array` control.[#77554](https://github.com/WordPress/gutenberg/pull/77554)
+## 14.2.0 (2026-04-29)
## 14.1.0 (2026-04-15)
@@ -35,8 +35,6 @@
- DataViews: Use intersectionObserver to improve performance by unloading invisible items. Change how infinite scroll is enabled to require only 2 view properties: `infiniteScrollEnabled` and `startPosition`. [#74378](https://github.com/WordPress/gutenberg/pull/74378)
- DataForm: The card layout now uses `Card` and `CollapsibleCard` from `@wordpress/ui` instead of `Card`, `CardHeader`, and `CardBody` from `@wordpress/components`. This changes the card's visual appearance (spacing, typography, and removal of the header/content separator). Custom CSS targeting `.components-card__body` within DataViews has been removed. Consumers wrapping DataViews or DataForm in a card should migrate to the `Card` and `CollapsibleCard` components from `@wordpress/ui`. [#76282](https://github.com/WordPress/gutenberg/pull/76282)
-- DataViews: Migrate action modals and DataForm panel modals from `@wordpress/components` `Modal` to `@wordpress/ui` `Dialog` and `AlertDialog`. The `modalSize` value `'fill'` is deprecated in favour of `'stretch'`. New values `'stretch'` and `'full'` are available. Custom CSS targeting `.components-modal__*` classes inside action modals will no longer work. [#76837](https://github.com/WordPress/gutenberg/pull/76837)
-- DataForm: The panel modal footer CSS class `.dataforms-layouts-panel__modal-footer` has been removed; the modal now uses `Dialog.Footer` for default spacing. [#76837](https://github.com/WordPress/gutenberg/pull/76837)
### Enhancements
From 0a851726178ed3b00b35a7d63b8edaa325d64b24 Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Wed, 8 Apr 2026 19:42:56 +0200
Subject: [PATCH 07/34] DataViews: Use the `portal` prop for per-instance
dialog z-index
Replace the global `:root` z-index override with a scoped
`.dataviews-action-modal-portal` class applied to the portal via the
`portal` prop (``),
ensuring the `--wp-ui-dialog-z-index` override only applies to action
modal dialogs.
Made-with: Cursor
---
.../dataviews/src/components/dataviews-item-actions/index.tsx | 3 +++
.../dataviews/src/components/dataviews-item-actions/style.scss | 2 +-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/packages/dataviews/src/components/dataviews-item-actions/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
index c687d4d3151feb..fcf328725f83fc 100644
--- a/packages/dataviews/src/components/dataviews-item-actions/index.tsx
+++ b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
@@ -189,6 +189,9 @@ export function ActionModal< Item >( {
className={ `dataviews-action-modal dataviews-action-modal__${ kebabCase(
action.id
) }` }
+ portal={
+
+ }
initialFocus={ initialFocus }
>
{ action.hideModalHeader ? (
diff --git a/packages/dataviews/src/components/dataviews-item-actions/style.scss b/packages/dataviews/src/components/dataviews-item-actions/style.scss
index da6e679125d8b9..4f59f603b72782 100644
--- a/packages/dataviews/src/components/dataviews-item-actions/style.scss
+++ b/packages/dataviews/src/components/dataviews-item-actions/style.scss
@@ -2,7 +2,7 @@
@use "@wordpress/base-styles/colors" as *;
@use "@wordpress/base-styles/z-index" as *;
-:root {
+.dataviews-action-modal-portal {
--wp-ui-dialog-z-index: #{z-index(".dataviews-action-modal")};
}
From b32b2cff8ae98954cb9c85f60c00692bfadfa983 Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Wed, 15 Apr 2026 14:00:02 +0200
Subject: [PATCH 08/34] Fix ts errors in types
---
package-lock.json | 1 +
packages/dataviews/global.d.ts | 1 +
packages/dataviews/package.json | 1 +
3 files changed, 3 insertions(+)
diff --git a/package-lock.json b/package-lock.json
index 161a005bd7fa37..80c51b1964aa05 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -59594,6 +59594,7 @@
"@storybook/react-vite": "^10.2.8",
"@testing-library/jest-dom": "^6.9.1",
"@types/jest": "^29.5.14",
+ "@wordpress/jest-console": "file:../jest-console",
"esbuild": "^0.27.2",
"storybook": "^10.2.8"
},
diff --git a/packages/dataviews/global.d.ts b/packages/dataviews/global.d.ts
index 07d96c55dc3554..707804b8732422 100644
--- a/packages/dataviews/global.d.ts
+++ b/packages/dataviews/global.d.ts
@@ -3,3 +3,4 @@
// To ensure that global types are included, we need to
// explicitly reference them here.
import '@testing-library/jest-dom';
+import '@wordpress/jest-console';
diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json
index 87e7a6e5b2ea01..90795bb38f5d1c 100644
--- a/packages/dataviews/package.json
+++ b/packages/dataviews/package.json
@@ -75,6 +75,7 @@
"remove-accents": "^0.5.0"
},
"devDependencies": {
+ "@wordpress/jest-console": "file:../jest-console",
"@storybook/addon-docs": "^10.2.8",
"@storybook/react-vite": "^10.2.8",
"@testing-library/jest-dom": "^6.9.1",
From b3031414a1704249e0c0b8452cf9080a3b0927c4 Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Wed, 15 Apr 2026 14:43:32 +0200
Subject: [PATCH 09/34] DataViews: Fix focus trap, tooltip z-index, and
legacy-compat CSS layer
- Replace AlertDialog.Root + Dialog.Popup hybrid with Dialog.Root using
disablePointerDismissal and role="alertdialog" via conditional spread,
fixing the broken focus trap.
- Add a dedicated legacy-compat CSS layer (wp-ui-legacy-compat.scss)
following the Phase 2 overlay migration strategy. Sets generic
--wp-ui-*-z-index defaults on :root matching @wordpress/components
base values (Modal screen overlay for dialog, Tooltip for tooltip).
- Use portalClassName to apply the higher .dataviews-action-modal
z-index only to action modal dialogs that need to stack above
legacy popovers.
Made-with: Cursor
---
.../dataviews-item-actions/index.tsx | 15 ++++++-------
.../dataviews-item-actions/style.scss | 6 -----
packages/dataviews/src/style.scss | 1 +
.../dataviews/src/wp-ui-legacy-compat.scss | 22 +++++++++++++++++++
4 files changed, 30 insertions(+), 14 deletions(-)
create mode 100644 packages/dataviews/src/wp-ui-legacy-compat.scss
diff --git a/packages/dataviews/src/components/dataviews-item-actions/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
index fcf328725f83fc..491709f3af6f36 100644
--- a/packages/dataviews/src/components/dataviews-item-actions/index.tsx
+++ b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
@@ -17,7 +17,7 @@ import { useRegistry } from '@wordpress/data';
import { useViewportMatch } from '@wordpress/compose';
import deprecated from '@wordpress/deprecated';
// eslint-disable-next-line @wordpress/use-recommended-components
-import { AlertDialog, Dialog, Stack, VisuallyHidden } from '@wordpress/ui';
+import { Dialog, Stack, VisuallyHidden } from '@wordpress/ui';
/**
* Internal dependencies
@@ -170,19 +170,15 @@ export function ActionModal< Item >( {
contentRef
);
- // AlertDialog.Root provides `role="alertdialog"` and blocks backdrop-click
- // dismissal via the shared Base UI DialogStore. Dialog.Popup is used instead
- // of AlertDialog.Popup because RenderModal provides its own buttons.
- const DialogRoot = action.hideModalHeader ? AlertDialog.Root : Dialog.Root;
-
return (
- {
if ( ! open ) {
closeModal();
}
} }
+ disablePointerDismissal={ action.hideModalHeader }
>
( {
}
initialFocus={ initialFocus }
+ { ...( action.hideModalHeader && {
+ role: 'alertdialog' as const,
+ } ) }
>
{ action.hideModalHeader ? (
( {
/>
-
+
);
}
diff --git a/packages/dataviews/src/components/dataviews-item-actions/style.scss b/packages/dataviews/src/components/dataviews-item-actions/style.scss
index 4f59f603b72782..0338df97a72e9c 100644
--- a/packages/dataviews/src/components/dataviews-item-actions/style.scss
+++ b/packages/dataviews/src/components/dataviews-item-actions/style.scss
@@ -1,10 +1,4 @@
@use "@wordpress/base-styles/variables" as *;
-@use "@wordpress/base-styles/colors" as *;
-@use "@wordpress/base-styles/z-index" as *;
-
-.dataviews-action-modal-portal {
- --wp-ui-dialog-z-index: #{z-index(".dataviews-action-modal")};
-}
// TODO: the way forward here would be to use the new unstyle button coming
// from wordpress/ui package, but we're not ready to use it yet.
diff --git a/packages/dataviews/src/style.scss b/packages/dataviews/src/style.scss
index 9f4833c2046773..98e57411561add 100644
--- a/packages/dataviews/src/style.scss
+++ b/packages/dataviews/src/style.scss
@@ -1,3 +1,4 @@
+@use "./wp-ui-legacy-compat.scss" as *;
@use "./dataviews/style.scss" as *;
@use "./components/dataviews-bulk-actions/style.scss" as *;
@use "./components/dataviews-layout/style.scss" as *;
diff --git a/packages/dataviews/src/wp-ui-legacy-compat.scss b/packages/dataviews/src/wp-ui-legacy-compat.scss
new file mode 100644
index 00000000000000..c23583aafbf826
--- /dev/null
+++ b/packages/dataviews/src/wp-ui-legacy-compat.scss
@@ -0,0 +1,22 @@
+// Legacy z-index compatibility for @wordpress/ui overlays.
+//
+// Sets --wp-ui-*-z-index custom properties so that @wordpress/ui
+// overlays (Dialog, Tooltip, etc.) stack correctly alongside legacy
+// @wordpress/components overlays during the transition period.
+//
+// These overrides follow the Phase 2 strategy described in
+// docs/explanations/architecture/overlay-migration-plan.md and should
+// be removed once all overlays share a single portal context (Phase 5).
+
+@use "@wordpress/base-styles/z-index" as *;
+
+// Generic defaults — match the base @wordpress/components overlay values.
+:root {
+ --wp-ui-dialog-z-index: #{z-index(".components-modal__screen-overlay")};
+ --wp-ui-tooltip-z-index: #{z-index(".components-tooltip")};
+}
+
+// Specific override — action modals must stack above legacy popovers.
+.dataviews-action-modal-portal {
+ --wp-ui-dialog-z-index: #{z-index(".dataviews-action-modal")};
+}
From e521e68d089281f2d1d13dc16d71371db3626ef2 Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Thu, 16 Apr 2026 11:24:50 +0200
Subject: [PATCH 10/34] fix dep order
---
packages/dataviews/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json
index 90795bb38f5d1c..94c539bfa2b45d 100644
--- a/packages/dataviews/package.json
+++ b/packages/dataviews/package.json
@@ -75,11 +75,11 @@
"remove-accents": "^0.5.0"
},
"devDependencies": {
- "@wordpress/jest-console": "file:../jest-console",
"@storybook/addon-docs": "^10.2.8",
"@storybook/react-vite": "^10.2.8",
"@testing-library/jest-dom": "^6.9.1",
"@types/jest": "^29.5.14",
+ "@wordpress/jest-console": "file:../jest-console",
"esbuild": "^0.27.2",
"storybook": "^10.2.8"
},
From a7af87c7eb2647381f5b98d04638c4feba12aa1b Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Thu, 30 Apr 2026 14:56:51 +0200
Subject: [PATCH 11/34] =?UTF-8?q?DataViews:=20Polish=20ActionModal=20?=
=?UTF-8?q?=E2=80=94=20version,=20types,=20scoping,=20scrolling?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Correct the `modalSize: 'fill'` deprecation `since` to `15.0.0` (next major
for @wordpress/dataviews) and update the matching test assertion. The
previous `'7.8'` value was copied over from an unrelated package and would
have shown an inaccurate version in the runtime warning.
- Drop the loose `as` cast in `mapModalSize` now that the function's return
type is satisfied by `?? 'medium'` directly. The cast widened the type
unnecessarily and would have hidden future breakage.
- Wrap the action-modal body in `Dialog.Content` so long forms scroll and
Header/Footer stay sticky (`Dialog.Content` is the official scroll
container).
- Rename the portal class to `dataviews-action-modal__portal` for BEM
consistency with the popup's `dataviews-action-modal` block, and update
the per-portal `--wp-ui-dialog-z-index` override accordingly.
- Rewrite the `wp-ui-legacy-compat.scss` header comment as a self-contained
explanation. The previous wording referenced an unpublished migration
plan; the rules themselves are unchanged.
---
.../dataviews-item-actions/index.tsx | 13 +++++-------
.../dataviews-item-actions/test/index.tsx | 2 +-
.../dataviews/src/wp-ui-legacy-compat.scss | 20 +++++++++----------
3 files changed, 16 insertions(+), 19 deletions(-)
diff --git a/packages/dataviews/src/components/dataviews-item-actions/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
index 491709f3af6f36..bd03bcd7bddee1 100644
--- a/packages/dataviews/src/components/dataviews-item-actions/index.tsx
+++ b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
@@ -108,15 +108,12 @@ function mapModalSize(
): 'small' | 'medium' | 'large' | 'stretch' | 'full' {
if ( size === 'fill' ) {
deprecated( "modalSize: 'fill'", {
- since: '7.8',
+ since: '15.0.0',
alternative: "'stretch'",
} );
return 'stretch';
}
- return (
- ( size as 'small' | 'medium' | 'large' | 'stretch' | 'full' ) ??
- 'medium'
- );
+ return size ?? 'medium';
}
const FIRST_INPUT_SELECTOR =
@@ -186,7 +183,7 @@ export function ActionModal< Item >( {
action.id
) }` }
portal={
-
+
}
initialFocus={ initialFocus }
{ ...( action.hideModalHeader && {
@@ -203,12 +200,12 @@ export function ActionModal< Item >( {
) }
-
+
-
+
);
diff --git a/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx
index 16aaa89683fac7..22512e113fd7d2 100644
--- a/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx
+++ b/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx
@@ -79,7 +79,7 @@ describe( 'ActionModal', () => {
} );
expect( console ).toHaveWarnedWith(
- "modalSize: 'fill' is deprecated since version 7.8. Please use 'stretch' instead."
+ "modalSize: 'fill' is deprecated since version 15.0.0. Please use 'stretch' instead."
);
} );
diff --git a/packages/dataviews/src/wp-ui-legacy-compat.scss b/packages/dataviews/src/wp-ui-legacy-compat.scss
index c23583aafbf826..2964b9efd134ee 100644
--- a/packages/dataviews/src/wp-ui-legacy-compat.scss
+++ b/packages/dataviews/src/wp-ui-legacy-compat.scss
@@ -1,22 +1,22 @@
-// Legacy z-index compatibility for @wordpress/ui overlays.
+// Bridges @wordpress/ui overlay z-indexes with the legacy
+// @wordpress/components stacking values while both systems coexist.
//
-// Sets --wp-ui-*-z-index custom properties so that @wordpress/ui
-// overlays (Dialog, Tooltip, etc.) stack correctly alongside legacy
-// @wordpress/components overlays during the transition period.
+// `:root` defaults align @wordpress/ui's Dialog and Tooltip with the
+// equivalent @wordpress/components overlays so they don't disappear
+// behind (or float over) each other. The per-portal override raises
+// action modals back above legacy Popovers, matching the previous
+// `.dataviews-action-modal` z-index.
//
-// These overrides follow the Phase 2 strategy described in
-// docs/explanations/architecture/overlay-migration-plan.md and should
-// be removed once all overlays share a single portal context (Phase 5).
+// These rules can go away once all overlays share a single stacking
+// system.
@use "@wordpress/base-styles/z-index" as *;
-// Generic defaults — match the base @wordpress/components overlay values.
:root {
--wp-ui-dialog-z-index: #{z-index(".components-modal__screen-overlay")};
--wp-ui-tooltip-z-index: #{z-index(".components-tooltip")};
}
-// Specific override — action modals must stack above legacy popovers.
-.dataviews-action-modal-portal {
+.dataviews-action-modal__portal {
--wp-ui-dialog-z-index: #{z-index(".dataviews-action-modal")};
}
From 876c37cf59d26c4b034c046685ffb5be0037aeef Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Thu, 30 Apr 2026 14:58:39 +0200
Subject: [PATCH 12/34] DataViews: Share focus-on-mount mapping between modal
layouts
Extract the `useMapFocusOnMount` helper out of `dataviews-item-actions`
into `hooks/use-map-focus-on-mount` and reuse it from the DataForm panel
modal.
`PanelModal` previously dropped to Base UI's smart default after the
Dialog migration. The smart default is fine in many cases, but for a
field-edit popup we want the first input focused (matching the legacy
`Modal.focusOnMount: 'firstInputElement'` behaviour). Reusing the same
helper keeps both layouts in sync and avoids duplicating the
input-selector heuristic.
Also wraps the `PanelModal` body in `Dialog.Content` so long forms scroll
while the title and footer stay sticky, matching the action-modal
treatment.
---
.../dataform-layouts/panel/modal.tsx | 12 +++-
.../dataviews-item-actions/index.tsx | 34 +----------
.../src/hooks/use-map-focus-on-mount.ts | 56 +++++++++++++++++++
3 files changed, 67 insertions(+), 35 deletions(-)
create mode 100644 packages/dataviews/src/hooks/use-map-focus-on-mount.ts
diff --git a/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx b/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
index 29b3c50023e955..b310a14ad03dce 100644
--- a/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
+++ b/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
@@ -26,6 +26,7 @@ import { DataFormLayout } from '../data-form-layout';
import { DEFAULT_LAYOUT } from '../normalize-form';
import SummaryButton from './summary-button';
import useFormValidity from '../../../hooks/use-form-validity';
+import useMapFocusOnMount from '../../../hooks/use-map-focus-on-mount';
import useReportValidity from '../../../hooks/use-report-validity';
import DataFormContext from '../../dataform-context';
import useFieldFromFormField from './utils/use-field-from-form-field';
@@ -100,6 +101,11 @@ function ModalContent< Item >( {
// trigger reportValidity to show field-level errors.
useReportValidity( contentRef, touched );
+ // Preserve the legacy `Modal.focusOnMount: 'firstInputElement'` behaviour
+ // by mapping it onto Base UI's `initialFocus`. PanelModal opens to edit a
+ // single field, so focusing the first input is a sensible default.
+ const initialFocus = useMapFocusOnMount( 'firstInputElement', contentRef );
+
return (
( {
}
} }
>
-
+ { fieldLabel }
-
+ ( {
/>
) }
-
+
+ } }
+ disablePointerDismissal={ renderedAction?.hideModalHeader }
+ >
+ { renderedAction && (
+
+ }
+ initialFocus={ initialFocus }
+ { ...( renderedAction.hideModalHeader && {
+ role: 'alertdialog' as const,
+ } ) }
+ >
+ { renderedAction.hideModalHeader ? (
+ { title } }
+ />
+ ) : (
+
+ { title }
+
+
+ ) }
+
+
+
+
+ ) }
);
}
@@ -323,13 +357,11 @@ function CompactItemActions< Item >( {
/>
- { !! activeModalAction && (
- setActiveModalAction( null ) }
- />
- ) }
+ setActiveModalAction( null ) }
+ />
>
);
}
@@ -367,13 +399,11 @@ export function PrimaryActions< Item >( {
variant={ buttonVariant }
/>
) ) }
- { !! activeModalAction && (
- setActiveModalAction( null ) }
- />
- ) }
+ setActiveModalAction( null ) }
+ />
>
);
}
diff --git a/packages/dataviews/src/components/dataviews-layouts/list/index.tsx b/packages/dataviews/src/components/dataviews-layouts/list/index.tsx
index 573f026c832edf..08f05718777de9 100644
--- a/packages/dataviews/src/components/dataviews-layouts/list/index.tsx
+++ b/packages/dataviews/src/components/dataviews-layouts/list/index.tsx
@@ -108,13 +108,11 @@ function PrimaryActionGridCell< Item >( {
/>
}
>
- { isModalOpen && (
-
- action={ primaryAction }
- items={ [ item ] }
- closeModal={ () => setIsModalOpen( false ) }
- />
- ) }
+
+ action={ isModalOpen ? primaryAction : null }
+ items={ [ item ] }
+ closeModal={ () => setIsModalOpen( false ) }
+ />
) : (
@@ -266,13 +264,11 @@ function ListItem< Item >( {
/>
- { !! activeModalAction && (
- setActiveModalAction( null ) }
- />
- ) }
+ setActiveModalAction( null ) }
+ />
) }
diff --git a/packages/dataviews/src/dataform/test/dataform.tsx b/packages/dataviews/src/dataform/test/dataform.tsx
index 972c613d588267..ba9cf25ed4e756 100644
--- a/packages/dataviews/src/dataform/test/dataform.tsx
+++ b/packages/dataviews/src/dataform/test/dataform.tsx
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
@@ -354,8 +354,15 @@ describe( 'DataForm component', () => {
} );
await user.click( cancelButton );
- // Modal should be closed
- expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
+ // Modal should be closed once the exit transition completes.
+ // `Dialog.Root` stays mounted in the React tree and the popup
+ // unmounts asynchronously after Base UI's animation tracking
+ // resolves.
+ await waitFor( () => {
+ expect(
+ screen.queryByRole( 'dialog' )
+ ).not.toBeInTheDocument();
+ } );
} );
it( 'should apply changes and close modal when apply button is clicked', async () => {
@@ -398,8 +405,15 @@ describe( 'DataForm component', () => {
} );
await user.click( applyButton );
- // Modal should be closed and onChange should be called
- expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
+ // Modal should be closed once the exit transition completes.
+ // `Dialog.Root` stays mounted in the React tree and the popup
+ // unmounts asynchronously after Base UI's animation tracking
+ // resolves.
+ await waitFor( () => {
+ expect(
+ screen.queryByRole( 'dialog' )
+ ).not.toBeInTheDocument();
+ } );
expect( onChange ).toHaveBeenCalledWith( { title: 'New Title' } );
} );
From e0dc85d2713c65b1f7519e196702a3320d2cd667 Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Wed, 6 May 2026 18:11:04 +0200
Subject: [PATCH 15/34] DataViews: Drop optional renderPopup gate in PanelModal
---
.../dataform-layouts/panel/modal.tsx | 52 ++++++++++---------
1 file changed, 27 insertions(+), 25 deletions(-)
diff --git a/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx b/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
index b3b23f6d180e43..16394975329bb7 100644
--- a/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
+++ b/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
@@ -7,7 +7,13 @@ import deepMerge from 'deepmerge';
* WordPress dependencies
*/
import { Button } from '@wordpress/components';
-import { useContext, useMemo, useRef, useState } from '@wordpress/element';
+import {
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from '@wordpress/element';
// eslint-disable-next-line @wordpress/use-recommended-components
import { Dialog } from '@wordpress/ui';
@@ -38,6 +44,7 @@ function ModalPopup< Item >( {
fieldLabel,
onClose,
touched,
+ isOpen,
}: {
data: Item;
field: NormalizedFormField;
@@ -45,11 +52,21 @@ function ModalPopup< Item >( {
onClose: () => void;
fieldLabel: string;
touched: boolean;
+ isOpen: boolean;
} ) {
const { openAs } = field.layout as NormalizedPanelLayout;
const { applyLabel, cancelLabel } = openAs as PanelOpenAsModal;
const { fields } = useContext( DataFormContext );
const [ changes, setChanges ] = useState< Partial< Item > >( {} );
+
+ // `Dialog.Root` stays mounted in the React tree, so reset the in-progress
+ // edits whenever the dialog closes — otherwise they would leak into the
+ // next opening of the modal.
+ useEffect( () => {
+ if ( ! isOpen ) {
+ setChanges( {} );
+ }
+ }, [ isOpen ] );
const modalData = useMemo( () => {
return deepMerge( data, changes, {
arrayMerge: ( target, source ) => source,
@@ -164,16 +181,7 @@ function PanelModal< Item >( {
validity,
}: FieldLayoutProps< Item > ) {
const [ touched, setTouched ] = useState( false );
-
const [ isOpen, setIsOpen ] = useState( false );
- // Keep `Dialog.Root` always mounted in the React tree so Base UI sees the
- // `false → true` transition and plays the entry animation. The popup
- // contents stay rendered while the dialog is open or transitioning closed,
- // then unmount once `onOpenChangeComplete` fires.
- const [ renderPopup, setRenderPopup ] = useState( false );
- if ( isOpen && ! renderPopup ) {
- setRenderPopup( true );
- }
const { fieldDefinition, fieldLabel, summaryFields } =
useFieldFromFormField( field );
@@ -206,22 +214,16 @@ function PanelModal< Item >( {
handleClose();
}
} }
- onOpenChangeComplete={ ( open ) => {
- if ( ! open ) {
- setRenderPopup( false );
- }
- } }
>
- { renderPopup && (
-
- ) }
+
>
);
From bccb670d3ac7715a57da93c195689cfe44517e1e Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Thu, 30 Apr 2026 23:08:48 +0200
Subject: [PATCH 16/34] DataViews: Make ActionModal own one stable action per
instance
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Refactor the internal `ActionModal` component so each instance owns one
specific action for its lifetime, rather than sharing a single
`Dialog.Root` across multiple actions and swapping which one to render.
Parents now render one `` per modal action and toggle a
controlled `open` prop, instead of toggling between `null` and the
active action on a single shared instance. This removes the need for
`renderedAction` state, the setter-during-render trick, and the
`onOpenChangeComplete` callback that cleared it; the popup contents
stay rendered through the exit animation naturally because the
`action` prop never changes for that instance.
The component's prop shape changes from
`{ action: Action | null, items, closeModal }` to
`{ action: Action, items, open, onOpenChange }`. This is fully
internal — `ActionModal` is not re-exported from the package — so no
public API changes.
---
packages/dataviews/CHANGELOG.md | 2 +-
.../dataviews-bulk-actions/index.tsx | 5 +-
.../dataviews-item-actions/index.tsx | 196 ++++++++++--------
.../dataviews-item-actions/test/index.tsx | 62 ++++--
.../dataviews-layouts/list/index.tsx | 30 ++-
5 files changed, 178 insertions(+), 117 deletions(-)
diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md
index f4b4f5f6be7379..1b527f5329ec52 100644
--- a/packages/dataviews/CHANGELOG.md
+++ b/packages/dataviews/CHANGELOG.md
@@ -4,7 +4,7 @@
### Breaking Changes
-- DataViews: Migrate action modals and DataForm panel modals from `@wordpress/components` `Modal` to `@wordpress/ui` `Dialog` and `AlertDialog`. The `modalSize` value `'fill'` is deprecated in favour of `'stretch'`. New values `'stretch'` and `'full'` are available. Custom CSS targeting `.components-modal__*` classes inside action modals will no longer work. The `dataforms-layouts-panel__modal` CSS class on the panel modal and the `dataforms-layouts-panel__modal-footer` CSS class have been removed. The `modalFocusOnMount` value `'firstElement'` now behaves like `'firstContentElement'` (the new Dialog primitive's smart default already skips the close icon and focuses the first content tabbable, so the legacy distinction between the two is no longer meaningful). The internal `ActionModal` component now accepts a nullable `action` prop and stays mounted with `Dialog.Root` toggling `open` so Base UI can play the entry/exit animations; the dialog popup unmounts asynchronously after the exit animation completes. ([#76837](https://github.com/WordPress/gutenberg/pull/76837))
+- DataViews: Migrate action modals and DataForm panel modals from `@wordpress/components` `Modal` to `@wordpress/ui` `Dialog` and `AlertDialog`. The `modalSize` value `'fill'` is deprecated in favour of `'stretch'`. New values `'stretch'` and `'full'` are available. Custom CSS targeting `.components-modal__*` classes inside action modals will no longer work. The `dataforms-layouts-panel__modal` CSS class on the panel modal and the `dataforms-layouts-panel__modal-footer` CSS class have been removed. The `modalFocusOnMount` value `'firstElement'` now behaves like `'firstContentElement'` (the new Dialog primitive's smart default already skips the close icon and focuses the first content tabbable, so the legacy distinction between the two is no longer meaningful). ([#76837](https://github.com/WordPress/gutenberg/pull/76837))
## 14.2.0 (2026-04-29)
diff --git a/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx b/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx
index c3e3976c0e8622..1a6cf64711c12c 100644
--- a/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx
+++ b/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx
@@ -49,9 +49,10 @@ function ActionWithModal< Item >( {
<>
setIsModalOpen( false ) }
+ open={ isModalOpen }
+ onOpenChange={ setIsModalOpen }
/>
>
);
diff --git a/packages/dataviews/src/components/dataviews-item-actions/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
index 62ec3e974c700b..5b362726511549 100644
--- a/packages/dataviews/src/components/dataviews-item-actions/index.tsx
+++ b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
@@ -38,14 +38,15 @@ export interface ActionTriggerProps< Item > {
export interface ActionModalProps< Item > {
/**
- * The action whose modal should be rendered. When `null`, the underlying
- * `Dialog.Root` stays mounted with `open={ false }` so its exit animation
- * can play; the popup contents are unmounted once the dialog has finished
- * closing.
+ * The action whose modal should be rendered. Stable for the lifetime of
+ * this `ActionModal` instance — the parent renders one `ActionModal` per
+ * modal action and toggles the `open` prop, rather than swapping the
+ * action on a single shared instance.
*/
- action: ActionModalType< Item > | null;
+ action: ActionModalType< Item >;
items: Item[];
- closeModal: () => void;
+ open: boolean;
+ onOpenChange: ( open: boolean ) => void;
}
interface ActionsMenuGroupProps< Item > {
@@ -126,91 +127,70 @@ function mapModalSize(
export function ActionModal< Item >( {
action,
items,
- closeModal,
+ open,
+ onOpenChange,
}: ActionModalProps< Item > ) {
- // Keep `Dialog.Root` always mounted in the React tree and toggle `open` so
- // Base UI sees the `false → true` transition and plays the entry animation.
- // `renderedAction` retains the last non-null action through the exit
- // animation; it's cleared once the dialog has finished closing via
- // `onOpenChangeComplete`.
- const [ renderedAction, setRenderedAction ] =
- useState< ActionModalType< Item > | null >( action );
- if ( action && action !== renderedAction ) {
- setRenderedAction( action );
- }
- const open = action !== null;
+ // Each `ActionModal` instance owns one specific action for its lifetime,
+ // so the popup contents never need to "remember" the previous action
+ // through the exit animation: when the parent toggles `open` to `false`,
+ // only the `open` prop changes — the `action` prop stays stable, the
+ // popup unmounts asynchronously after Base UI's exit animation
+ // completes, and there's no risk of rendering a stale or null action.
+ const closeModal = () => onOpenChange( false );
const contentRef = useRef< HTMLDivElement >( null );
const initialFocus = useMapFocusOnMount(
- renderedAction?.modalFocusOnMount ?? true,
+ action.modalFocusOnMount ?? true,
contentRef
);
- const getLabel = () => {
- if ( ! renderedAction ) {
- return '';
- }
- return typeof renderedAction.label === 'string'
- ? renderedAction.label
- : renderedAction.label( items );
- };
- const getModalHeader = () => {
- if ( ! renderedAction ) {
- return undefined;
- }
- return typeof renderedAction.modalHeader === 'function'
- ? renderedAction.modalHeader( items )
- : renderedAction.modalHeader;
- };
- const title = getModalHeader() || getLabel();
+ const label =
+ typeof action.label === 'string' ? action.label : action.label( items );
+ const modalHeader =
+ typeof action.modalHeader === 'function'
+ ? action.modalHeader( items )
+ : action.modalHeader;
+ const title = modalHeader || label;
return (
{
- if ( ! isOpen ) {
- closeModal();
- }
- } }
- onOpenChangeComplete={ ( isOpen ) => {
- if ( ! isOpen ) {
- setRenderedAction( null );
- }
- } }
- disablePointerDismissal={ renderedAction?.hideModalHeader }
+ // Wrap to drop Base UI's `eventDetails` second argument: callers
+ // only need `(open: boolean) => void` and shouldn't depend on
+ // Base UI internals leaking through.
+ onOpenChange={ ( isOpen ) => onOpenChange( isOpen ) }
+ disablePointerDismissal={ action.hideModalHeader }
>
- { renderedAction && (
-
- }
- initialFocus={ initialFocus }
- { ...( renderedAction.hideModalHeader && {
- role: 'alertdialog' as const,
- } ) }
- >
- { renderedAction.hideModalHeader ? (
- { title } }
- />
- ) : (
-
- { title }
-
-
- ) }
-
-
-
-
- ) }
+
+ }
+ initialFocus={ initialFocus }
+ { ...( action.hideModalHeader && {
+ role: 'alertdialog' as const,
+ } ) }
+ >
+ { action.hideModalHeader ? (
+ { title } }
+ />
+ ) : (
+
+ { title }
+
+
+ ) }
+
+
+
+
);
}
@@ -333,6 +313,14 @@ function CompactItemActions< Item >( {
const [ activeModalAction, setActiveModalAction ] = useState(
null as ActionModalType< Item > | null
);
+ const modalActions = useMemo(
+ () =>
+ actions.filter(
+ ( action ): action is ActionModalType< Item > =>
+ 'RenderModal' in action
+ ),
+ [ actions ]
+ );
return (
<>
- setActiveModalAction( null ) }
- />
+ { modalActions.map( ( action ) => (
+ {
+ if ( ! isOpen ) {
+ setActiveModalAction( null );
+ }
+ } }
+ />
+ ) ) }
>
);
}
@@ -372,8 +368,20 @@ export function PrimaryActions< Item >( {
registry,
buttonVariant,
}: PrimaryActionsProps< Item > ) {
- const [ activeModalAction, setActiveModalAction ] = useState( null as any );
+ const [ activeModalAction, setActiveModalAction ] = useState(
+ null as ActionModalType< Item > | null
+ );
const isMobileViewport = useViewportMatch( 'medium', '<' );
+ const modalActions = useMemo(
+ () =>
+ Array.isArray( actions )
+ ? actions.filter(
+ ( action ): action is ActionModalType< Item > =>
+ 'RenderModal' in action
+ )
+ : [],
+ [ actions ]
+ );
if ( isMobileViewport ) {
return null;
@@ -399,11 +407,19 @@ export function PrimaryActions< Item >( {
variant={ buttonVariant }
/>
) ) }
- setActiveModalAction( null ) }
- />
+ { modalActions.map( ( action ) => (
+ {
+ if ( ! isOpen ) {
+ setActiveModalAction( null );
+ }
+ } }
+ />
+ ) ) }
>
);
}
diff --git a/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx
index 22512e113fd7d2..81d3341a0a4d30 100644
--- a/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx
+++ b/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx
@@ -36,7 +36,8 @@ describe( 'ActionModal', () => {
);
@@ -52,7 +53,8 @@ describe( 'ActionModal', () => {
);
@@ -70,7 +72,8 @@ describe( 'ActionModal', () => {
);
@@ -99,7 +102,8 @@ describe( 'ActionModal', () => {
);
@@ -125,7 +129,8 @@ describe( 'ActionModal', () => {
);
@@ -143,7 +148,8 @@ describe( 'ActionModal', () => {
);
@@ -159,7 +165,8 @@ describe( 'ActionModal', () => {
);
@@ -177,33 +184,35 @@ describe( 'ActionModal', () => {
it( 'closes when the user presses Escape', async () => {
const user = userEvent.setup();
- const closeModal = jest.fn();
+ const onOpenChange = jest.fn();
const action = createAction();
render(
);
await screen.findByRole( 'dialog' );
await user.keyboard( '{Escape}' );
- expect( closeModal ).toHaveBeenCalledTimes( 1 );
+ expect( onOpenChange ).toHaveBeenCalledWith( false );
} );
it( 'closes when the user clicks the backdrop by default', async () => {
const user = userEvent.setup();
- const closeModal = jest.fn();
+ const onOpenChange = jest.fn();
const action = createAction();
render(
);
@@ -211,19 +220,20 @@ describe( 'ActionModal', () => {
const backdrop = screen.getByTestId( 'dialog-backdrop' );
await user.click( backdrop );
- expect( closeModal ).toHaveBeenCalledTimes( 1 );
+ expect( onOpenChange ).toHaveBeenCalledWith( false );
} );
it( 'does not close on backdrop click for alert dialogs (hideModalHeader)', async () => {
const user = userEvent.setup();
- const closeModal = jest.fn();
+ const onOpenChange = jest.fn();
const action = createAction( { hideModalHeader: true } );
render(
);
@@ -231,6 +241,26 @@ describe( 'ActionModal', () => {
const backdrop = screen.getByTestId( 'dialog-backdrop' );
await user.click( backdrop );
- expect( closeModal ).not.toHaveBeenCalled();
+ expect( onOpenChange ).not.toHaveBeenCalled();
+ } );
+
+ it( 'invokes onOpenChange(false) when the RenderModal calls closeModal', async () => {
+ const user = userEvent.setup();
+ const onOpenChange = jest.fn();
+ const action = createAction();
+
+ render(
+
+ );
+
+ await screen.findByRole( 'dialog' );
+ await user.click( screen.getByRole( 'button', { name: /done/i } ) );
+
+ expect( onOpenChange ).toHaveBeenCalledWith( false );
} );
} );
diff --git a/packages/dataviews/src/components/dataviews-layouts/list/index.tsx b/packages/dataviews/src/components/dataviews-layouts/list/index.tsx
index 08f05718777de9..9a9bc9027e4a52 100644
--- a/packages/dataviews/src/components/dataviews-layouts/list/index.tsx
+++ b/packages/dataviews/src/components/dataviews-layouts/list/index.tsx
@@ -109,9 +109,10 @@ function PrimaryActionGridCell< Item >( {
}
>
- action={ isModalOpen ? primaryAction : null }
+ action={ primaryAction }
items={ [ item ] }
- closeModal={ () => setIsModalOpen( false ) }
+ open={ isModalOpen }
+ onOpenChange={ setIsModalOpen }
/>
@@ -181,7 +182,7 @@ function ListItem< Item >( {
}
}, [ isSelected ] );
- const { primaryAction, eligibleActions } = useMemo( () => {
+ const { primaryAction, eligibleActions, modalActions } = useMemo( () => {
// If an action is eligible for all items, doesn't need
// to provide the `isEligible` function.
const _eligibleActions = actions.filter(
@@ -190,9 +191,14 @@ function ListItem< Item >( {
const _primaryActions = _eligibleActions.filter(
( action ) => action.isPrimary
);
+ const _modalActions = _eligibleActions.filter(
+ ( action ): action is ActionModalType< Item > =>
+ 'RenderModal' in action
+ );
return {
primaryAction: _primaryActions[ 0 ],
eligibleActions: _eligibleActions,
+ modalActions: _modalActions,
};
}, [ actions, item ] );
@@ -264,11 +270,19 @@ function ListItem< Item >( {
/>
- setActiveModalAction( null ) }
- />
+ { modalActions.map( ( action ) => (
+ {
+ if ( ! isOpen ) {
+ setActiveModalAction( null );
+ }
+ } }
+ />
+ ) ) }
) }
From 3b520e3381e61d19bcb4a2257a60706694242e7c Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Thu, 30 Apr 2026 23:10:25 +0200
Subject: [PATCH 17/34] DataViews: Bind PanelModal session state to popup
lifecycle
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Refactor `PanelModal` so that the per-session state (in-progress
`changes`, validity refs, content ref) lives in a dedicated
`PanelModalSession` component remounted via `key`-bump from
`onOpenChangeComplete` after the exit animation finishes.
Previously the session component was always mounted and reset its
`changes` state synchronously via a `useEffect( … on isOpen )` —
which caused the form contents to flash to their reset state during
the exit animation. The new approach keeps `changes` intact through
the exit animation (so the form looks right while it's animating
out) while still preserving the existing "Cancel/close always wipes
the draft" semantic by force-remounting the session once the dialog
has finished closing.
---
.../dataform-layouts/panel/modal.tsx | 37 +++++++++----------
1 file changed, 17 insertions(+), 20 deletions(-)
diff --git a/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx b/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
index 16394975329bb7..c248591721c6c4 100644
--- a/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
+++ b/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
@@ -7,13 +7,7 @@ import deepMerge from 'deepmerge';
* WordPress dependencies
*/
import { Button } from '@wordpress/components';
-import {
- useContext,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from '@wordpress/element';
+import { useContext, useMemo, useRef, useState } from '@wordpress/element';
// eslint-disable-next-line @wordpress/use-recommended-components
import { Dialog } from '@wordpress/ui';
@@ -37,14 +31,13 @@ import useReportValidity from '../../../hooks/use-report-validity';
import DataFormContext from '../../dataform-context';
import useFieldFromFormField from './utils/use-field-from-form-field';
-function ModalPopup< Item >( {
+function PanelModalSession< Item >( {
data,
field,
onChange,
fieldLabel,
onClose,
touched,
- isOpen,
}: {
data: Item;
field: NormalizedFormField;
@@ -52,21 +45,12 @@ function ModalPopup< Item >( {
onClose: () => void;
fieldLabel: string;
touched: boolean;
- isOpen: boolean;
} ) {
const { openAs } = field.layout as NormalizedPanelLayout;
const { applyLabel, cancelLabel } = openAs as PanelOpenAsModal;
const { fields } = useContext( DataFormContext );
const [ changes, setChanges ] = useState< Partial< Item > >( {} );
- // `Dialog.Root` stays mounted in the React tree, so reset the in-progress
- // edits whenever the dialog closes — otherwise they would leak into the
- // next opening of the modal.
- useEffect( () => {
- if ( ! isOpen ) {
- setChanges( {} );
- }
- }, [ isOpen ] );
const modalData = useMemo( () => {
return deepMerge( data, changes, {
arrayMerge: ( target, source ) => source,
@@ -182,6 +166,14 @@ function PanelModal< Item >( {
}: FieldLayoutProps< Item > ) {
const [ touched, setTouched ] = useState( false );
const [ isOpen, setIsOpen ] = useState( false );
+ // `Dialog.Root` stays mounted across opens, so the session component
+ // holding the in-progress `changes` state would also persist by default.
+ // Bump `sessionKey` on `onOpenChangeComplete` (when the exit animation
+ // finishes) to force-remount `` between sessions —
+ // this preserves the existing "Cancel/close always wipes the draft"
+ // semantic without disturbing the form contents during the exit
+ // animation itself.
+ const [ sessionKey, setSessionKey ] = useState( 0 );
const { fieldDefinition, fieldLabel, summaryFields } =
useFieldFromFormField( field );
@@ -214,15 +206,20 @@ function PanelModal< Item >( {
handleClose();
}
} }
+ onOpenChangeComplete={ ( open ) => {
+ if ( ! open ) {
+ setSessionKey( ( k ) => k + 1 );
+ }
+ } }
>
-
>
From 442257bcb3a20348c06bbd4361dcda49574888fa Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Fri, 1 May 2026 15:11:37 +0200
Subject: [PATCH 18/34] DataViews: pull Dialog.Root and Dialog.Trigger out of
ActionModal to call sites
`ActionModal` now renders only `Dialog.Popup` and accepts a `closeModal`
callback. Each call site wraps it in a `Dialog.Root` paired with a
`Dialog.Trigger` (rendered via `Menu.Item`, plain `Button`, or
`Composite.Item`), so the trigger and the popup share the dialog's
context and the open state lives in a small per-action wrapper instead
of a parent-owned `activeModalAction` map.
This eliminates the imperative `setActiveModalAction(action)` plumbing
in `CompactItemActions`, `PrimaryActions`, `ListItem`, and
`PrimaryActionGridCell`, and replaces it with two new internal helpers
(`ModalActionMenuItem`, `ModalActionInlineButton`) that own a leaf-level
`useState`. Bulk-action triggers move from a custom `ActionTrigger`
component to a direct `Dialog.Trigger render={}` so Base UI's
ARIA wiring (`aria-haspopup="dialog"`, `aria-expanded`,
`aria-controls`) flows through automatically.
`closeModal` stays on `RenderModalProps` because the public contract
allows consumers to call it from async code; the wrapper component owns
the `useState` so the imperative path remains a one-liner
(`() => setOpen(false)`) without any direct Base UI store access.
Tests updated to render `` inside a controlled
`` instead of injecting `open`/`onOpenChange` props on
`` directly.
---
.../dataviews-bulk-actions/index.tsx | 66 ++--
.../dataviews-item-actions/index.tsx | 326 ++++++++++--------
.../dataviews-item-actions/test/index.tsx | 177 +++++-----
.../dataviews-layouts/list/index.tsx | 85 +++--
4 files changed, 346 insertions(+), 308 deletions(-)
diff --git a/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx b/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx
index 1a6cf64711c12c..f68ddf0bd0dc59 100644
--- a/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx
+++ b/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx
@@ -1,18 +1,20 @@
-/**
- * External dependencies
- */
-import type { ReactElement } from 'react';
-
/**
* WordPress dependencies
*/
import { Button, CheckboxControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
-import { useMemo, useState, useRef, useContext } from '@wordpress/element';
+import {
+ useCallback,
+ useMemo,
+ useState,
+ useRef,
+ useContext,
+} from '@wordpress/element';
import { useRegistry } from '@wordpress/data';
import { closeSmall } from '@wordpress/icons';
import { useViewportMatch } from '@wordpress/compose';
-import { Stack } from '@wordpress/ui';
+// eslint-disable-next-line @wordpress/use-recommended-components
+import { Dialog, Stack } from '@wordpress/ui';
/**
* Internal dependencies
@@ -27,34 +29,51 @@ import getFooterMessage from '../../utils/get-footer-message';
interface ActionWithModalProps< Item > {
action: ActionModalType< Item >;
items: Item[];
- ActionTriggerComponent: (
- props: ActionTriggerProps< Item >
- ) => ReactElement;
}
function ActionWithModal< Item >( {
action,
items,
- ActionTriggerComponent,
}: ActionWithModalProps< Item > ) {
const [ isModalOpen, setIsModalOpen ] = useState( false );
- const actionTriggerProps = {
- action,
- onClick: () => {
- setIsModalOpen( true );
- },
- items,
- };
+ const closeModal = useCallback( () => setIsModalOpen( false ), [] );
+ const label =
+ typeof action.label === 'string' ? action.label : action.label( items );
+ const isMobile = useViewportMatch( 'medium', '<' );
+
+ // `Dialog.Trigger` renders into a real `Button` element here (rather
+ // than delegating to a custom component), so Base UI's merged props
+ // — `onClick`, `aria-haspopup="dialog"`, `aria-expanded`,
+ // `aria-controls` — flow straight through and the dialog opens
+ // without any imperative state plumbing in this component.
return (
- <>
-
+
+
+ ) : (
+
+ { label }
+
+ )
+ }
+ />
- >
+
);
}
@@ -234,7 +253,6 @@ function ActionButton< Item >( {
key={ action.id }
action={ action }
items={ selectedEligibleItems }
- ActionTriggerComponent={ ActionTrigger }
/>
);
}
diff --git a/packages/dataviews/src/components/dataviews-item-actions/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
index 5b362726511549..95a8c0f305f09a 100644
--- a/packages/dataviews/src/components/dataviews-item-actions/index.tsx
+++ b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
@@ -11,7 +11,7 @@ import {
privateApis as componentsPrivateApis,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
-import { useMemo, useRef, useState } from '@wordpress/element';
+import { useCallback, useMemo, useRef, useState } from '@wordpress/element';
import { moreVertical } from '@wordpress/icons';
import { useRegistry } from '@wordpress/data';
import { useViewportMatch } from '@wordpress/compose';
@@ -39,21 +39,29 @@ export interface ActionTriggerProps< Item > {
export interface ActionModalProps< Item > {
/**
* The action whose modal should be rendered. Stable for the lifetime of
- * this `ActionModal` instance — the parent renders one `ActionModal` per
- * modal action and toggles the `open` prop, rather than swapping the
- * action on a single shared instance.
+ * this `ActionModal` instance — the parent renders one `ActionModal`
+ * per modal action; opening/closing happens through the surrounding
+ * `` (controlled or uncontrolled) rather than props on
+ * this component.
*/
action: ActionModalType< Item >;
items: Item[];
- open: boolean;
- onOpenChange: ( open: boolean ) => void;
+ /**
+ * Imperative close callback exposed to the action's `RenderModal`
+ * implementation as `closeModal`. The wrapping component (e.g.
+ * `ModalActionMenuItem` / `ModalActionInlineButton`) owns the
+ * dialog's open state and supplies a setter that toggles it back to
+ * `false`. Public `RenderModalProps` consumers may call this from
+ * async code (e.g. after a network request) — that's why a callback
+ * is required even though the dialog's primitives can also close it.
+ */
+ closeModal: () => void;
}
interface ActionsMenuGroupProps< Item > {
actions: Action< Item >[];
item: Item;
registry: ReturnType< typeof useRegistry >;
- setActiveModalAction: ( action: ActionModalType< Item > | null ) => void;
}
interface ItemActionsProps< Item > {
@@ -124,20 +132,15 @@ function mapModalSize(
return size ?? 'medium';
}
+// Renders the popup half of a dataviews action modal. Must be wrapped
+// in a `` that owns the open lifecycle (paired with
+// `` at the call site, e.g. `ModalActionMenuItem` or
+// `ModalActionInlineButton`).
export function ActionModal< Item >( {
action,
items,
- open,
- onOpenChange,
+ closeModal,
}: ActionModalProps< Item > ) {
- // Each `ActionModal` instance owns one specific action for its lifetime,
- // so the popup contents never need to "remember" the previous action
- // through the exit animation: when the parent toggles `open` to `false`,
- // only the `open` prop changes — the `action` prop stays stable, the
- // popup unmounts asynchronously after Base UI's exit animation
- // completes, and there's no risk of rendering a stale or null action.
- const closeModal = () => onOpenChange( false );
-
const contentRef = useRef< HTMLDivElement >( null );
const initialFocus = useMapFocusOnMount(
action.modalFocusOnMount ?? true,
@@ -152,45 +155,115 @@ export function ActionModal< Item >( {
: action.modalHeader;
const title = modalHeader || label;
+ return (
+
+ }
+ initialFocus={ initialFocus }
+ { ...( action.hideModalHeader && {
+ role: 'alertdialog' as const,
+ } ) }
+ >
+ { action.hideModalHeader ? (
+ { title } }
+ />
+ ) : (
+
+ { title }
+
+
+ ) }
+
+
+
+
+ );
+}
+
+// Wraps a single modal action as a menu item that opens the action's
+// dialog. Owns the dialog's open state locally so each modal action is
+// self-contained: the surrounding parent doesn't need a "which action is
+// active" piece of state.
+function ModalActionMenuItem< Item >( {
+ action,
+ items,
+}: {
+ action: ActionModalType< Item >;
+ items: Item[];
+} ) {
+ const [ open, setOpen ] = useState( false );
+ // Stable reference so consumer `RenderModal` implementations can pass
+ // `closeModal` to event handlers / effects without remounting on every
+ // keystroke.
+ const closeModal = useCallback( () => setOpen( false ), [] );
+ const label =
+ typeof action.label === 'string' ? action.label : action.label( items );
return (
void` and shouldn't depend on
- // Base UI internals leaking through.
- onOpenChange={ ( isOpen ) => onOpenChange( isOpen ) }
+ onOpenChange={ setOpen }
disablePointerDismissal={ action.hideModalHeader }
>
-
- }
- initialFocus={ initialFocus }
- { ...( action.hideModalHeader && {
- role: 'alertdialog' as const,
- } ) }
+ }
>
- { action.hideModalHeader ? (
- { title } }
- />
- ) : (
-
- { title }
-
-
- ) }
-
- { label }
+
+
+
+ );
+}
+
+// Wraps a single modal action as an inline button that opens the
+// action's dialog. Same self-contained-state contract as
+// `ModalActionMenuItem`.
+function ModalActionInlineButton< Item >( {
+ action,
+ items,
+ variant,
+}: {
+ action: ActionModalType< Item >;
+ items: Item[];
+ variant?: 'primary' | 'secondary' | 'tertiary' | 'link';
+} ) {
+ const [ open, setOpen ] = useState( false );
+ const closeModal = useCallback( () => setOpen( false ), [] );
+ const label =
+ typeof action.label === 'string' ? action.label : action.label( items );
+ return (
+
+
-
-
+ }
+ >
+ { label }
+
+
);
}
@@ -199,7 +272,6 @@ export function ActionsMenuGroup< Item >( {
actions,
item,
registry,
- setActiveModalAction,
}: ActionsMenuGroupProps< Item > ) {
const { primaryActions, regularActions } = useMemo( () => {
return actions.reduce(
@@ -218,20 +290,22 @@ export function ActionsMenuGroup< Item >( {
}, [ actions ] );
const renderActionGroup = ( actionList: Action< Item >[] ) =>
- actionList.map( ( action ) => (
- {
- if ( 'RenderModal' in action ) {
- setActiveModalAction( action );
- return;
- }
- action.callback( [ item ], { registry } );
- } }
- items={ [ item ] }
- />
- ) );
+ actionList.map( ( action ) =>
+ 'RenderModal' in action ? (
+
+ ) : (
+ action.callback( [ item ], { registry } ) }
+ items={ [ item ] }
+ />
+ )
+ );
return (
@@ -310,55 +384,28 @@ function CompactItemActions< Item >( {
isSmall,
registry,
}: CompactItemActionsProps< Item > ) {
- const [ activeModalAction, setActiveModalAction ] = useState(
- null as ActionModalType< Item > | null
- );
- const modalActions = useMemo(
- () =>
- actions.filter(
- ( action ): action is ActionModalType< Item > =>
- 'RenderModal' in action
- ),
- [ actions ]
- );
return (
- <>
-
- { modalActions.map( ( action ) => (
- {
- if ( ! isOpen ) {
- setActiveModalAction( null );
- }
- } }
+ }
+ />
+
+
- ) ) }
- >
+
+
);
}
@@ -368,20 +415,7 @@ export function PrimaryActions< Item >( {
registry,
buttonVariant,
}: PrimaryActionsProps< Item > ) {
- const [ activeModalAction, setActiveModalAction ] = useState(
- null as ActionModalType< Item > | null
- );
const isMobileViewport = useViewportMatch( 'medium', '<' );
- const modalActions = useMemo(
- () =>
- Array.isArray( actions )
- ? actions.filter(
- ( action ): action is ActionModalType< Item > =>
- 'RenderModal' in action
- )
- : [],
- [ actions ]
- );
if ( isMobileViewport ) {
return null;
@@ -392,34 +426,26 @@ export function PrimaryActions< Item >( {
}
return (
<>
- { actions.map( ( action ) => (
- {
- if ( 'RenderModal' in action ) {
- setActiveModalAction( action );
- return;
- }
- action.callback( [ item ], { registry } );
- } }
- items={ [ item ] }
- variant={ buttonVariant }
- />
- ) ) }
- { modalActions.map( ( action ) => (
- {
- if ( ! isOpen ) {
- setActiveModalAction( null );
+ { actions.map( ( action ) =>
+ 'RenderModal' in action ? (
+
+ ) : (
+
+ action.callback( [ item ], { registry } )
}
- } }
- />
- ) ) }
+ items={ [ item ] }
+ variant={ buttonVariant }
+ />
+ )
+ ) }
>
);
}
diff --git a/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx
index 81d3341a0a4d30..8134654afb1620 100644
--- a/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx
+++ b/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx
@@ -4,6 +4,12 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+/**
+ * WordPress dependencies
+ */
+// eslint-disable-next-line @wordpress/use-recommended-components
+import { Dialog } from '@wordpress/ui';
+
/**
* Internal dependencies
*/
@@ -28,18 +34,49 @@ function createAction(
};
}
+// Renders an `` inside a controlled `` so each
+// test can drive the open state through `onOpenChange` (mirroring how
+// the dataviews call sites — `ModalActionMenuItem`,
+// `ModalActionInlineButton`, `PrimaryActionGridCell`, `ActionWithModal`
+// — own the dialog state).
+function renderActionModal( {
+ action,
+ items,
+ open = true,
+ onOpenChange = jest.fn(),
+}: {
+ action: ActionModalType< TestItem >;
+ items: TestItem[];
+ open?: boolean;
+ onOpenChange?: ( open: boolean ) => void;
+} ) {
+ const closeModal = () => onOpenChange( false );
+ return render(
+ onOpenChange( isOpen ) }
+ disablePointerDismissal={ action.hideModalHeader }
+ >
+
+
+ );
+}
+
describe( 'ActionModal', () => {
it( 'renders with a dialog role by default', async () => {
const action = createAction();
- render(
-
- );
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ } );
await waitFor( () => {
expect( screen.getByRole( 'dialog' ) ).toBeVisible();
@@ -49,14 +86,10 @@ describe( 'ActionModal', () => {
it( 'renders with an alertdialog role when hideModalHeader is true', async () => {
const action = createAction( { hideModalHeader: true } );
- render(
-
- );
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ } );
await waitFor( () => {
expect( screen.getByRole( 'alertdialog' ) ).toBeVisible();
@@ -68,14 +101,10 @@ describe( 'ActionModal', () => {
modalSize: 'fill',
} );
- render(
-
- );
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ } );
await waitFor( () => {
expect( screen.getByRole( 'dialog' ) ).toBeVisible();
@@ -98,14 +127,10 @@ describe( 'ActionModal', () => {
),
} );
- render(
-
- );
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ } );
await waitFor( () => {
expect( screen.getByTestId( 'first-input' ) ).toHaveFocus();
@@ -125,14 +150,10 @@ describe( 'ActionModal', () => {
),
} );
- render(
-
- );
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ } );
await waitFor( () => {
expect( screen.getByTestId( 'content-button' ) ).toHaveFocus();
@@ -144,14 +165,10 @@ describe( 'ActionModal', () => {
async ( modalSize ) => {
const action = createAction( { modalSize } );
- render(
-
- );
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ } );
await screen.findByRole( 'dialog' );
expect( console ).not.toHaveWarned();
@@ -161,14 +178,10 @@ describe( 'ActionModal', () => {
it( 'renders the popup inside the dataviews-action-modal__portal element', async () => {
const action = createAction();
- render(
-
- );
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ } );
const dialog = await screen.findByRole( 'dialog' );
// The portal is a structural CSS wrapper with no semantic role, so
@@ -187,14 +200,11 @@ describe( 'ActionModal', () => {
const onOpenChange = jest.fn();
const action = createAction();
- render(
-
- );
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ onOpenChange,
+ } );
await screen.findByRole( 'dialog' );
await user.keyboard( '{Escape}' );
@@ -207,14 +217,11 @@ describe( 'ActionModal', () => {
const onOpenChange = jest.fn();
const action = createAction();
- render(
-
- );
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ onOpenChange,
+ } );
await screen.findByRole( 'dialog' );
const backdrop = screen.getByTestId( 'dialog-backdrop' );
@@ -228,14 +235,11 @@ describe( 'ActionModal', () => {
const onOpenChange = jest.fn();
const action = createAction( { hideModalHeader: true } );
- render(
-
- );
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ onOpenChange,
+ } );
await screen.findByRole( 'alertdialog' );
const backdrop = screen.getByTestId( 'dialog-backdrop' );
@@ -249,14 +253,11 @@ describe( 'ActionModal', () => {
const onOpenChange = jest.fn();
const action = createAction();
- render(
-
- );
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ onOpenChange,
+ } );
await screen.findByRole( 'dialog' );
await user.click( screen.getByRole( 'button', { name: /done/i } ) );
diff --git a/packages/dataviews/src/components/dataviews-layouts/list/index.tsx b/packages/dataviews/src/components/dataviews-layouts/list/index.tsx
index 9a9bc9027e4a52..4d7ed960a916b6 100644
--- a/packages/dataviews/src/components/dataviews-layouts/list/index.tsx
+++ b/packages/dataviews/src/components/dataviews-layouts/list/index.tsx
@@ -24,7 +24,8 @@ import {
import { __, sprintf } from '@wordpress/i18n';
import { moreVertical } from '@wordpress/icons';
import { useRegistry } from '@wordpress/data';
-import { Stack, VisuallyHidden } from '@wordpress/ui';
+// eslint-disable-next-line @wordpress/use-recommended-components
+import { Dialog, Stack, VisuallyHidden } from '@wordpress/ui';
/**
* Internal dependencies
@@ -38,7 +39,6 @@ import type {
NormalizedField,
ViewList as ViewListType,
ViewListProps,
- ActionModal as ActionModalType,
} from '../../../types';
import getDataByGroup from '../utils/get-data-by-group';
@@ -83,6 +83,7 @@ function PrimaryActionGridCell< Item >( {
} ) {
const registry = useRegistry();
const [ isModalOpen, setIsModalOpen ] = useState( false );
+ const closeModal = useCallback( () => setIsModalOpen( false ), [] );
const compositeItemId = generatePrimaryActionCompositeId(
idPrefix,
@@ -94,29 +95,43 @@ function PrimaryActionGridCell< Item >( {
? primaryAction.label
: primaryAction.label( [ item ] );
- return 'RenderModal' in primaryAction ? (
-
- setIsModalOpen( true ) }
- />
- }
- >
-
- action={ primaryAction }
- items={ [ item ] }
+ if ( 'RenderModal' in primaryAction ) {
+ // Compose `Composite.Item` (Ariakit) → `Dialog.Trigger` (Base UI)
+ // → `Button` so all three layers' props merge onto the same DOM
+ // button: composite-item navigation, dialog-trigger ARIA, and
+ // the visual `Button` styling.
+ return (
+
) }
From 6c18def217bbf0de58518cd82e647dfc35064715 Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Fri, 1 May 2026 15:18:44 +0200
Subject: [PATCH 19/34] DataViews PanelModal: idiomatic Dialog.Action for
Cancel and Apply
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the Cancel/Apply `@wordpress/components` Buttons in
`PanelModalSession` with `Dialog.Action`, so closing flows through the
dialog primitive rather than an imperative `onClose` callback.
- Cancel becomes a propless ``. It
closes via Base UI's `Dialog.Close`; the existing `setTouched(true)`
side effect runs through the parent's `onOpenChange` handler.
- Apply becomes ` onChange(changes)}>`.
`onChange` runs synchronously before Base UI fires the close, so the
draft commits before the dialog dismisses; `setTouched` follows via
`onOpenChange` exactly as for Cancel.
- `PanelModalSession` drops its `onClose` prop entirely — both buttons
now close through the Dialog primitive.
- `PanelModal` keeps its controlled `isOpen` / `setTouched` /
`sessionKey` state so `SummaryButton` (a custom div-based trigger)
can still set `aria-expanded` and the existing key-bump preserves
"Cancel/close wipes the draft" semantics across reopenings.
Drops `@wordpress/components` `Button` and the legacy
`__next40pxDefaultSize` flag from this file.
---
.../dataform-layouts/panel/modal.tsx | 49 ++++++++-----------
1 file changed, 21 insertions(+), 28 deletions(-)
diff --git a/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx b/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
index c248591721c6c4..fe91db2f84e03b 100644
--- a/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
+++ b/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx
@@ -6,7 +6,6 @@ import deepMerge from 'deepmerge';
/**
* WordPress dependencies
*/
-import { Button } from '@wordpress/components';
import { useContext, useMemo, useRef, useState } from '@wordpress/element';
// eslint-disable-next-line @wordpress/use-recommended-components
import { Dialog } from '@wordpress/ui';
@@ -36,13 +35,11 @@ function PanelModalSession< Item >( {
field,
onChange,
fieldLabel,
- onClose,
touched,
}: {
data: Item;
field: NormalizedFormField;
onChange: ( data: Partial< Item > ) => void;
- onClose: () => void;
fieldLabel: string;
touched: boolean;
} ) {
@@ -83,11 +80,6 @@ function PanelModalSession< Item >( {
} ) );
const { validity } = useFormValidity( modalData, fieldsAsFieldType, form );
- const onApply = () => {
- onChange( changes );
- onClose();
- };
-
const handleOnChange = ( newValue: Partial< Item > ) => {
setChanges( ( prev ) =>
deepMerge( prev, newValue, {
@@ -139,20 +131,22 @@ function PanelModalSession< Item >( {
-
- { cancelLabel }
-
-
+ { /*
+ * Cancel: a propless `Dialog.Action` is enough — it closes
+ * via the dialog primitive, and `setTouched` runs through
+ * the parent's `onOpenChange` callback as a side effect of
+ * the close.
+ */ }
+ { cancelLabel }
+ { /*
+ * Apply: the `onClick` runs synchronously before Base UI
+ * fires the close, so `onChange( changes )` lands first;
+ * then the dialog closes and `onOpenChange` flips
+ * `setTouched` in the parent.
+ */ }
+ onChange( changes ) }>
{ applyLabel }
-
+
);
@@ -181,11 +175,6 @@ function PanelModal< Item >( {
return null;
}
- const handleClose = () => {
- setIsOpen( false );
- setTouched( true );
- };
-
return (
<>
( {
{
+ setIsOpen( open );
if ( ! open ) {
- handleClose();
+ // Mark the field as "touched" once the dialog has
+ // been opened and dismissed at least once, so
+ // validation messages on the summary trigger the
+ // next time the user opens it.
+ setTouched( true );
}
} }
onOpenChangeComplete={ ( open ) => {
@@ -218,7 +212,6 @@ function PanelModal< Item >( {
field={ field }
onChange={ onChange }
fieldLabel={ fieldLabel ?? '' }
- onClose={ handleClose }
touched={ touched }
/>
From 37b712c067d11752db6bca73994992708da3ffea Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Wed, 6 May 2026 18:11:30 +0200
Subject: [PATCH 20/34] DataViews item-actions: compose triggers via render
---
packages/dataviews/CHANGELOG.md | 4 +
.../dataviews-item-actions/index.tsx | 100 ++++++++++++------
2 files changed, 70 insertions(+), 34 deletions(-)
diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md
index 1b527f5329ec52..f3237eaab81ef0 100644
--- a/packages/dataviews/CHANGELOG.md
+++ b/packages/dataviews/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+### Code Quality
+
+- DataViews: `ButtonTrigger` and `MenuItemTrigger` (item-actions internals) now `forwardRef` and spread unknown props onto their underlying `Button` / `Menu.Item`, so they compose under `Dialog.Trigger` via the render-prop pattern. `ModalActionInlineButton` and `ModalActionMenuItem` reuse them instead of inlining the same trigger markup. ([#76837](https://github.com/WordPress/gutenberg/pull/76837))
+
### Breaking Changes
- DataViews: Migrate action modals and DataForm panel modals from `@wordpress/components` `Modal` to `@wordpress/ui` `Dialog` and `AlertDialog`. The `modalSize` value `'fill'` is deprecated in favour of `'stretch'`. New values `'stretch'` and `'full'` are available. Custom CSS targeting `.components-modal__*` classes inside action modals will no longer work. The `dataforms-layouts-panel__modal` CSS class on the panel modal and the `dataforms-layouts-panel__modal-footer` CSS class have been removed. The `modalFocusOnMount` value `'firstElement'` now behaves like `'firstContentElement'` (the new Dialog primitive's smart default already skips the close icon and focuses the first content tabbable, so the legacy distinction between the two is no longer meaningful). ([#76837](https://github.com/WordPress/gutenberg/pull/76837))
diff --git a/packages/dataviews/src/components/dataviews-item-actions/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
index 95a8c0f305f09a..285c0fa98fcc6f 100644
--- a/packages/dataviews/src/components/dataviews-item-actions/index.tsx
+++ b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import type { MouseEventHandler } from 'react';
+import type { MouseEventHandler, ReactElement } from 'react';
/**
* WordPress dependencies
@@ -11,7 +11,13 @@ import {
privateApis as componentsPrivateApis,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
-import { useCallback, useMemo, useRef, useState } from '@wordpress/element';
+import {
+ forwardRef,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from '@wordpress/element';
import { moreVertical } from '@wordpress/icons';
import { useRegistry } from '@wordpress/data';
import { useViewportMatch } from '@wordpress/compose';
@@ -30,7 +36,13 @@ const { Menu, kebabCase } = unlock( componentsPrivateApis );
export interface ActionTriggerProps< Item > {
action: Action< Item >;
- onClick: MouseEventHandler;
+ /**
+ * Click handler for direct usage. When the trigger is wrapped in a
+ * primitive that injects its own `onClick` via the render-prop pattern
+ * (e.g. `Dialog.Trigger render={ }`), the wrapper
+ * supplies the click handler and this prop should be omitted.
+ */
+ onClick?: MouseEventHandler;
isBusy?: boolean;
items: Item[];
variant?: 'primary' | 'secondary' | 'tertiary' | 'link';
@@ -84,40 +96,68 @@ interface PrimaryActionsProps< Item > {
buttonVariant?: 'primary' | 'secondary' | 'tertiary' | 'link';
}
-function ButtonTrigger< Item >( {
- action,
- onClick,
- items,
- variant,
-}: ActionTriggerProps< Item > ) {
+// `ButtonTrigger` and `MenuItemTrigger` forward both refs and unknown
+// props onto their underlying primitive (Button / Menu.Item), so the same
+// component can be used directly (parent supplies `onClick`) or composed
+// via render props (e.g. ` } />`,
+// ` } />`). This eliminates the
+// duplicated trigger markup that the modal-action wrappers used to inline.
+const ButtonTrigger = forwardRef( function ButtonTrigger< Item >(
+ { action, items, variant, ...rest }: ActionTriggerProps< Item >,
+ ref: React.Ref< HTMLButtonElement >
+) {
const label =
typeof action.label === 'string' ? action.label : action.label( items );
return (
{ label }
);
-}
+} ) as < Item >(
+ props: ActionTriggerProps< Item > & {
+ ref?: React.Ref< HTMLButtonElement >;
+ }
+) => ReactElement;
-function MenuItemTrigger< Item >( {
- action,
- onClick,
- items,
-}: ActionTriggerProps< Item > ) {
+const MenuItemTrigger = forwardRef( function MenuItemTrigger< Item >(
+ {
+ action,
+ items,
+ render,
+ ...rest
+ }: Pick< ActionTriggerProps< Item >, 'action' | 'items' | 'onClick' > & {
+ render?: ReactElement;
+ },
+ ref: React.Ref< HTMLDivElement >
+) {
const label =
typeof action.label === 'string' ? action.label : action.label( items );
return (
-
+ { label }
);
-}
+} ) as < Item >(
+ props: Pick<
+ ActionTriggerProps< Item >,
+ 'action' | 'items' | 'onClick'
+ > & {
+ render?: ReactElement;
+ ref?: React.Ref< HTMLDivElement >;
+ }
+) => ReactElement;
function mapModalSize(
size: ActionModalType< unknown >[ 'modalSize' ]
@@ -202,20 +242,17 @@ function ModalActionMenuItem< Item >( {
// `closeModal` to event handlers / effects without remounting on every
// keystroke.
const closeModal = useCallback( () => setOpen( false ), [] );
- const label =
- typeof action.label === 'string' ? action.label : action.label( items );
return (
- }
- >
- { label }
-
+ />
( {
} ) {
const [ open, setOpen ] = useState( false );
const closeModal = useCallback( () => setOpen( false ), [] );
- const label =
- typeof action.label === 'string' ? action.label : action.label( items );
return (
( {
>
}
- >
- { label }
-
+ />
Date: Thu, 7 May 2026 14:17:00 +0200
Subject: [PATCH 21/34] DataViews: tidy CHANGELOGs and useMapFocusOnMount JSDoc
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Self-review follow-ups for #78028:
- CHANGELOG: retarget the four `[#76837]` links to `[#78028]` across
`packages/dataviews`, `packages/edit-site`, and `packages/fields` so
the entries point at the active PR (CI `Check CHANGELOG diff` fails
otherwise).
- CHANGELOG: reword the dataviews Breaking-Changes entry — drop the
inaccurate `AlertDialog` mention (the migration imports `Dialog`
only), and call out the actual mechanism for destructive actions
(`Dialog.Popup` with `role="alertdialog"` + `disablePointerDismissal`).
- `useMapFocusOnMount`: enumerate all five legacy `modalFocusOnMount`
values (`false`, `'firstInputElement'`, `'firstContentElement'`,
`'firstElement'`, `true`) explicitly in the JSDoc, even where three
of them converge on the same Base UI smart default — so grepping for
any of the legacy strings lands here.
---
packages/dataviews/CHANGELOG.md | 4 +--
.../src/hooks/use-map-focus-on-mount.ts | 25 +++++++++++++------
packages/edit-site/CHANGELOG.md | 2 +-
packages/fields/CHANGELOG.md | 2 +-
4 files changed, 22 insertions(+), 11 deletions(-)
diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md
index f3237eaab81ef0..422eccce989984 100644
--- a/packages/dataviews/CHANGELOG.md
+++ b/packages/dataviews/CHANGELOG.md
@@ -4,11 +4,11 @@
### Code Quality
-- DataViews: `ButtonTrigger` and `MenuItemTrigger` (item-actions internals) now `forwardRef` and spread unknown props onto their underlying `Button` / `Menu.Item`, so they compose under `Dialog.Trigger` via the render-prop pattern. `ModalActionInlineButton` and `ModalActionMenuItem` reuse them instead of inlining the same trigger markup. ([#76837](https://github.com/WordPress/gutenberg/pull/76837))
+- DataViews: `ButtonTrigger` and `MenuItemTrigger` (item-actions internals) now `forwardRef` and spread unknown props onto their underlying `Button` / `Menu.Item`, so they compose under `Dialog.Trigger` via the render-prop pattern. `ModalActionInlineButton` and `ModalActionMenuItem` reuse them instead of inlining the same trigger markup. ([#78028](https://github.com/WordPress/gutenberg/pull/78028))
### Breaking Changes
-- DataViews: Migrate action modals and DataForm panel modals from `@wordpress/components` `Modal` to `@wordpress/ui` `Dialog` and `AlertDialog`. The `modalSize` value `'fill'` is deprecated in favour of `'stretch'`. New values `'stretch'` and `'full'` are available. Custom CSS targeting `.components-modal__*` classes inside action modals will no longer work. The `dataforms-layouts-panel__modal` CSS class on the panel modal and the `dataforms-layouts-panel__modal-footer` CSS class have been removed. The `modalFocusOnMount` value `'firstElement'` now behaves like `'firstContentElement'` (the new Dialog primitive's smart default already skips the close icon and focuses the first content tabbable, so the legacy distinction between the two is no longer meaningful). ([#76837](https://github.com/WordPress/gutenberg/pull/76837))
+- DataViews: Migrate action modals and DataForm panel modals from `@wordpress/components` `Modal` to `@wordpress/ui` `Dialog`. Action modals with `hideModalHeader: true` now render a `Dialog.Popup` with `role="alertdialog"` and `disablePointerDismissal`, requiring an explicit user action to dismiss. The `modalSize` value `'fill'` is deprecated in favour of `'stretch'`. New values `'stretch'` and `'full'` are available. Custom CSS targeting `.components-modal__*` classes inside action modals will no longer work. The `dataforms-layouts-panel__modal` CSS class on the panel modal and the `dataforms-layouts-panel__modal-footer` CSS class have been removed. The `modalFocusOnMount` value `'firstElement'` now behaves like `'firstContentElement'` (the new Dialog primitive's smart default already skips the close icon and focuses the first content tabbable, so the legacy distinction between the two is no longer meaningful). ([#78028](https://github.com/WordPress/gutenberg/pull/78028))
## 14.2.0 (2026-04-29)
diff --git a/packages/dataviews/src/hooks/use-map-focus-on-mount.ts b/packages/dataviews/src/hooks/use-map-focus-on-mount.ts
index 2d7a392fea0c3b..376e75f0eb32f8 100644
--- a/packages/dataviews/src/hooks/use-map-focus-on-mount.ts
+++ b/packages/dataviews/src/hooks/use-map-focus-on-mount.ts
@@ -16,13 +16,24 @@ const FIRST_INPUT_SELECTOR =
* Maps the legacy `Modal.focusOnMount` semantics onto the
* `Dialog.Popup.initialFocus` prop accepted by `@wordpress/ui`.
*
- * - `false` is forwarded as-is to skip focus-on-mount entirely.
- * - `'firstInputElement'` returns a callback that resolves to the first
- * focusable input/select/textarea inside `contentRef`, falling back to
- * the popup's smart default if no match is found.
- * - All other values (`'firstContentElement'`, `'firstElement'`, `true`)
- * defer to the popup's smart default, which already skips the close icon
- * and focuses the first content tabbable.
+ * Mapping for each of the five legacy values that `ActionModal.modalFocusOnMount`
+ * accepts (`Parameters< typeof useFocusOnMount >[ 0 ] | 'firstContentElement'`):
+ *
+ * - `false` — forwarded as-is to skip focus-on-mount entirely.
+ * - `'firstInputElement'` — returns a callback that resolves to the first
+ * focusable `` / `