From 5ce67c4bac94ac7a296bba2589fc4c8b7aa2fa75 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 26 Mar 2026 15:52:31 +0100 Subject: [PATCH 01/34] DataViews: Migrate modals from @wordpress/components Modal to @wordpress/ui Dialog Replace all usages of `@wordpress/components` `Modal` in `@wordpress/dataviews` with `@wordpress/ui` `Dialog` compound components. Key changes: - Migrate `ActionModal` to use `Dialog.Root`, `Dialog.Popup`, `Dialog.Header`, `Dialog.Title`, `Dialog.CloseIcon`, and `VisuallyHidden` for hidden headers. - Migrate `ModalContent` (DataForm panel) to use Dialog with `Dialog.Footer` for Cancel/Apply buttons. - Expose `initialFocus` and `finalFocus` props on `@wordpress/ui` `Dialog.Popup`. - Add `event.stopPropagation()` to Escape key handler in `@wordpress/compose` `useDialog` to prevent bubbling to parent Base UI overlays. - Set `--wp-ui-dialog-z-index` via SCSS `z-index()` function for backwards compatibility with existing overlay z-index hierarchy. - Add `'stretch'` and `'full'` to `modalSize` type; deprecate `'fill'`. - Add `modalSize: 'small'` to duplicate-template-part and duplicate-pattern actions, replacing CSS-based width overrides. - Remove CSS overrides targeting `.components-modal__frame` and `[role="document"]` in `@wordpress/edit-site`. - Remove `.dataforms-layouts-panel__modal-footer` margin-top rule. - Update `WithModal` story to use Dialog. Made-with: Cursor --- packages/dataviews/CHANGELOG.md | 2 + .../dataform-layouts/panel/modal.tsx | 141 ++++++++++-------- .../dataform-layouts/panel/style.scss | 4 - .../dataviews-item-actions/index.tsx | 113 ++++++++++++-- .../dataviews-item-actions/style.scss | 4 +- .../dataviews-picker/stories/index.story.tsx | 35 +++-- packages/dataviews/src/types/dataviews.ts | 9 +- packages/edit-site/CHANGELOG.md | 4 + .../src/components/page-patterns/style.scss | 13 -- packages/fields/CHANGELOG.md | 4 + .../fields/src/actions/duplicate-pattern.tsx | 1 + .../src/actions/duplicate-template-part.tsx | 1 + 12 files changed, 217 insertions(+), 114 deletions(-) diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 3122729d43562a..34004ae5375b25 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -35,6 +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. ### Enhancements diff --git a/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx b/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx index cd7caee2d7150d..b98ae7015beb2c 100644 --- a/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx +++ b/packages/dataviews/src/components/dataform-layouts/panel/modal.tsx @@ -6,15 +6,16 @@ import deepMerge from 'deepmerge'; /** * WordPress dependencies */ +import { Button } from '@wordpress/components'; import { - __experimentalSpacer as Spacer, - Button, - Modal, -} from '@wordpress/components'; - -import { useContext, useMemo, useRef, useState } from '@wordpress/element'; -import { useFocusOnMount, useMergeRefs } from '@wordpress/compose'; -import { Stack } from '@wordpress/ui'; + useCallback, + useContext, + useMemo, + useRef, + useState, +} from '@wordpress/element'; +// eslint-disable-next-line @wordpress/use-recommended-components +import { Dialog } from '@wordpress/ui'; /** * Internal dependencies @@ -99,69 +100,85 @@ function ModalContent< Item >( { ); }; - const focusOnMountRef = useFocusOnMount( 'firstInputElement' ); const contentRef = useRef< HTMLDivElement >( null ); - const mergedRef = useMergeRefs( [ focusOnMountRef, contentRef ] ); + + 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 ); return ( - { + if ( ! open ) { + onClose(); + } + } } > -
- - { ( - FieldLayout, - childField, - childFieldValidity, - markWhenOptional - ) => ( - - ) } - -
- - - - - -
+ + { fieldLabel } + + +
+ + { ( + FieldLayout, + childField, + childFieldValidity, + markWhenOptional + ) => ( + + ) } + +
+ + + + + + ); } diff --git a/packages/dataviews/src/components/dataform-layouts/panel/style.scss b/packages/dataviews/src/components/dataform-layouts/panel/style.scss index 86725adcae452b..50932940a0bc60 100644 --- a/packages/dataviews/src/components/dataform-layouts/panel/style.scss +++ b/packages/dataviews/src/components/dataform-layouts/panel/style.scss @@ -159,10 +159,6 @@ margin-bottom: $grid-unit-20; } -.dataforms-layouts-panel__modal-footer { - margin-top: $grid-unit-20; -} - .components-popover.components-dropdown__content.dataforms-layouts-panel__field-dropdown { z-index: z-index(".components-popover.components-dropdown__content.dataforms-layouts-panel__field-dropdown"); } diff --git a/packages/dataviews/src/components/dataviews-item-actions/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/index.tsx index 26098143baf356..e860710295e4c1 100644 --- a/packages/dataviews/src/components/dataviews-item-actions/index.tsx +++ b/packages/dataviews/src/components/dataviews-item-actions/index.tsx @@ -8,15 +8,16 @@ import type { MouseEventHandler } from 'react'; */ import { Button, - Modal, privateApis as componentsPrivateApis, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { useMemo, 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'; -import { Stack } from '@wordpress/ui'; +import deprecated from '@wordpress/deprecated'; +// eslint-disable-next-line @wordpress/use-recommended-components +import { AlertDialog, Dialog, Stack, VisuallyHidden } from '@wordpress/ui'; /** * Internal dependencies @@ -102,6 +103,59 @@ function MenuItemTrigger< Item >( { ); } +function mapModalSize( + size: ActionModalType< unknown >[ 'modalSize' ] +): 'small' | 'medium' | 'large' | 'stretch' | 'full' { + if ( size === 'fill' ) { + deprecated( "modalSize: 'fill'", { + since: '7.8', + alternative: "'stretch'", + } ); + return 'stretch'; + } + return ( + ( size as 'small' | 'medium' | 'large' | 'stretch' | 'full' ) ?? + 'medium' + ); +} + +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( + focusOnMount: ActionModalType< unknown >[ 'modalFocusOnMount' ], + contentRef: React.RefObject< HTMLElement | null > +) { + const callback = useCallback( () => { + if ( contentRef.current ) { + const selector = + focusOnMount === 'firstInputElement' + ? FIRST_INPUT_SELECTOR + : FIRST_TABBABLE_SELECTOR; + const target = + contentRef.current.querySelector< HTMLElement >( selector ); + if ( target ) { + return target; + } + } + return true as const; + }, [ focusOnMount, contentRef ] ); + + if ( focusOnMount === false ) { + return false; + } + if ( + focusOnMount === 'firstContentElement' || + focusOnMount === 'firstInputElement' + ) { + return callback; + } + return true; +} + export function ActionModal< Item >( { action, items, @@ -114,19 +168,50 @@ export function ActionModal< Item >( { typeof action.modalHeader === 'function' ? action.modalHeader( items ) : action.modalHeader; + + const title = modalHeader || label; + const contentRef = useRef< HTMLDivElement >( null ); + const initialFocus = useInitialFocus( + action.modalFocusOnMount ?? true, + contentRef + ); + + const DialogRoot = action.hideModalHeader ? AlertDialog.Root : Dialog.Root; + return ( - { + if ( ! open ) { + closeModal(); + } + } } > - - + + { action.hideModalHeader ? ( + + { title } + + ) : ( + + { title } + + + ) } +
+ +
+
+ ); } diff --git a/packages/dataviews/src/components/dataviews-item-actions/style.scss b/packages/dataviews/src/components/dataviews-item-actions/style.scss index dbd4d7944e57a5..da6e679125d8b9 100644 --- a/packages/dataviews/src/components/dataviews-item-actions/style.scss +++ b/packages/dataviews/src/components/dataviews-item-actions/style.scss @@ -2,8 +2,8 @@ @use "@wordpress/base-styles/colors" as *; @use "@wordpress/base-styles/z-index" as *; -.dataviews-action-modal { - z-index: z-index(".dataviews-action-modal"); +:root { + --wp-ui-dialog-z-index: #{z-index(".dataviews-action-modal")}; } // TODO: the way forward here would be to use the new unstyle button coming diff --git a/packages/dataviews/src/dataviews-picker/stories/index.story.tsx b/packages/dataviews/src/dataviews-picker/stories/index.story.tsx index 3d97873cf0085a..061d2995da853e 100644 --- a/packages/dataviews/src/dataviews-picker/stories/index.story.tsx +++ b/packages/dataviews/src/dataviews-picker/stories/index.story.tsx @@ -7,8 +7,9 @@ import type { Meta } from '@storybook/react-vite'; * WordPress dependencies */ import { useState, useMemo, useEffect } from '@wordpress/element'; -import { Modal, Button } from '@wordpress/components'; -import { Stack } from '@wordpress/ui'; +import { Button } from '@wordpress/components'; +// eslint-disable-next-line @wordpress/use-recommended-components +import { Dialog, Stack } from '@wordpress/ui'; /** * Internal dependencies @@ -275,21 +276,19 @@ export const WithModal = ( {

) } { isModalOpen && ( - <> - - setIsModalOpen( false ) } - isFullScreen={ false } - size="fill" - > + } } + > + + + Select Items + + - - + + ) } ); diff --git a/packages/dataviews/src/types/dataviews.ts b/packages/dataviews/src/types/dataviews.ts index 263783593a3023..caf8377983bbc2 100644 --- a/packages/dataviews/src/types/dataviews.ts +++ b/packages/dataviews/src/types/dataviews.ts @@ -426,9 +426,16 @@ export interface ActionModal< Item > extends ActionBase< Item > { /** * The size of the modal. * + * - `'small'` — narrow max-width. + * - `'medium'` — moderate max-width. + * - `'large'` — wide max-width. + * - `'stretch'` — no max-width, stretches to fill available space. + * - `'full'` — stretches to fill available width and height. + * - `'fill'` — deprecated, use `'stretch'` instead. + * * @default 'medium' */ - modalSize?: 'small' | 'medium' | 'large' | 'fill'; + modalSize?: 'small' | 'medium' | 'large' | 'stretch' | 'full' | 'fill'; /** * The focus on mount property of the modal. diff --git a/packages/edit-site/CHANGELOG.md b/packages/edit-site/CHANGELOG.md index 27e12aef878f8d..bd050b250252e7 100644 --- a/packages/edit-site/CHANGELOG.md +++ b/packages/edit-site/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Code Quality + +- Remove CSS overrides targeting `.components-modal__frame` and `[role="document"]` for duplicate template part and duplicate pattern modals, replaced by declarative `modalSize` prop. ([#76837](https://github.com/WordPress/gutenberg/pull/76837)) + ## 6.45.0 (2026-04-29) ## 6.44.0 (2026-04-15) diff --git a/packages/edit-site/src/components/page-patterns/style.scss b/packages/edit-site/src/components/page-patterns/style.scss index 39d91557f4b113..d0a6b94f2912b7 100644 --- a/packages/edit-site/src/components/page-patterns/style.scss +++ b/packages/edit-site/src/components/page-patterns/style.scss @@ -76,11 +76,6 @@ } .dataviews-action-modal__duplicate-pattern { - // Fix the modal width to prevent added categories from stretching the modal. - [role="dialog"] > [role="document"] { - width: 350px; - } - .patterns-menu-items__convert-modal-categories { position: relative; } @@ -101,14 +96,6 @@ } } -.dataviews-action-modal__duplicate-template-part { - .components-modal__frame { - @include break-small { - max-width: 500px; - } - } -} - @container (max-width: 430px) { .edit-site-page-patterns-dataviews .edit-site-patterns__section-header { padding-left: $grid-unit-30; diff --git a/packages/fields/CHANGELOG.md b/packages/fields/CHANGELOG.md index db7e4b0b0aac3d..6ee11cffa0dd20 100644 --- a/packages/fields/CHANGELOG.md +++ b/packages/fields/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Enhancements + +- Set `modalSize: 'small'` on `duplicateTemplatePart` and `duplicatePattern` actions to replace CSS-based width overrides. ([#76837](https://github.com/WordPress/gutenberg/pull/76837)) + ## 0.37.0 (2026-04-29) ## 0.36.0 (2026-04-15) diff --git a/packages/fields/src/actions/duplicate-pattern.tsx b/packages/fields/src/actions/duplicate-pattern.tsx index 2df348a1cef3c0..1e6e627c8d8b62 100644 --- a/packages/fields/src/actions/duplicate-pattern.tsx +++ b/packages/fields/src/actions/duplicate-pattern.tsx @@ -21,6 +21,7 @@ const duplicatePattern: Action< Pattern > = { label: _x( 'Duplicate', 'action label' ), isEligible: ( item ) => item.type !== 'wp_template_part', modalHeader: _x( 'Duplicate pattern', 'action label' ), + modalSize: 'small', modalFocusOnMount: 'firstContentElement', RenderModal: ( { items, closeModal } ) => { const [ item ] = items; diff --git a/packages/fields/src/actions/duplicate-template-part.tsx b/packages/fields/src/actions/duplicate-template-part.tsx index 0524f5308da78c..ea3aa85302065a 100644 --- a/packages/fields/src/actions/duplicate-template-part.tsx +++ b/packages/fields/src/actions/duplicate-template-part.tsx @@ -24,6 +24,7 @@ const duplicateTemplatePart: Action< TemplatePart > = { label: _x( 'Duplicate', 'action label' ), isEligible: ( item ) => item.type === 'wp_template_part', modalHeader: _x( 'Duplicate template part', 'action label' ), + modalSize: 'small', modalFocusOnMount: 'firstContentElement', RenderModal: ( { items, closeModal } ) => { const [ item ] = items; From 8567e19761cd86db8a4f84fcd35b94290c63693d Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 8 Apr 2026 19:46:32 +0200 Subject: [PATCH 02/34] DataViews: Add ActionModal unit tests Cover the migrated `ActionModal` behaviour: - Dialog and `alertdialog` role rendering (the latter used by destructive actions via `hideModalHeader`). - `modalSize: 'fill'` emits the `15.0.0` deprecation and maps to `'stretch'`; the other size values flow through `Dialog.Popup` without warning. - Per-instance portal scoping via the `dataviews-action-modal__portal` class so the per-portal `--wp-ui-dialog-z-index` override has a target to attach to. - `modalFocusOnMount: 'firstInputElement'` focuses the first input; unset falls back to the popup's smart default (first content tabbable, not the close icon). - Escape closes regular dialogs, the backdrop closes regular dialogs but not alert dialogs (covering the `disablePointerDismissal` path). --- .../dataviews-item-actions/test/index.tsx | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 packages/dataviews/src/components/dataviews-item-actions/test/index.tsx diff --git a/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx new file mode 100644 index 00000000000000..16aaa89683fac7 --- /dev/null +++ b/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx @@ -0,0 +1,236 @@ +/** + * External dependencies + */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import { ActionModal } from '../index'; +import type { ActionModal as ActionModalType } from '../../../types'; + +type TestItem = { id: number; title: string }; + +function createAction( + overrides: Partial< ActionModalType< TestItem > > = {} +): ActionModalType< TestItem > { + return { + id: 'test-action', + label: 'Test Action', + RenderModal: ( { closeModal } ) => ( +
+

Modal content

+ +
+ ), + ...overrides, + }; +} + +describe( 'ActionModal', () => { + it( 'renders with a dialog role by default', async () => { + const action = createAction(); + + render( + + ); + + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeVisible(); + } ); + } ); + + it( 'renders with an alertdialog role when hideModalHeader is true', async () => { + const action = createAction( { hideModalHeader: true } ); + + render( + + ); + + await waitFor( () => { + expect( screen.getByRole( 'alertdialog' ) ).toBeVisible(); + } ); + } ); + + it( "maps modalSize 'fill' to 'stretch' and emits a deprecation warning", async () => { + const action = createAction( { + modalSize: 'fill', + } ); + + render( + + ); + + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeVisible(); + } ); + + expect( console ).toHaveWarnedWith( + "modalSize: 'fill' is deprecated since version 7.8. Please use 'stretch' instead." + ); + } ); + + it( 'focuses the first input when modalFocusOnMount is "firstInputElement"', async () => { + const action = createAction( { + modalFocusOnMount: 'firstInputElement', + RenderModal: () => ( +
+

Some text

+ + +
+ ), + } ); + + 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 } -
+ ( { /> ) } -
+ - - -
-
+ + + { fieldLabel } + + + + + { ( + FieldLayout, + childField, + childFieldValidity, + markWhenOptional + ) => ( + + ) } + + + + + + + ); } @@ -175,6 +166,14 @@ function PanelModal< 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 ); @@ -200,16 +199,30 @@ function PanelModal< Item >( { onClick={ () => setIsOpen( true ) } aria-expanded={ isOpen } /> - { isOpen && ( - - ) } + { + if ( ! open ) { + handleClose(); + } + } } + onOpenChangeComplete={ ( open ) => { + if ( ! open ) { + setRenderPopup( false ); + } + } } + > + { renderPopup && ( + + ) } + ); } diff --git a/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx b/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx index 63d2a5ae72afb4..c3e3976c0e8622 100644 --- a/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx +++ b/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx @@ -48,13 +48,11 @@ function ActionWithModal< Item >( { return ( <> - { isModalOpen && ( - setIsModalOpen( false ) } - /> - ) } + setIsModalOpen( false ) } + /> ); } diff --git a/packages/dataviews/src/components/dataviews-item-actions/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/index.tsx index e388cd6831786a..62ec3e974c700b 100644 --- a/packages/dataviews/src/components/dataviews-item-actions/index.tsx +++ b/packages/dataviews/src/components/dataviews-item-actions/index.tsx @@ -37,7 +37,13 @@ export interface ActionTriggerProps< Item > { } export interface ActionModalProps< Item > { - action: ActionModalType< 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. + */ + action: ActionModalType< Item > | null; items: Item[]; closeModal: () => void; } @@ -122,61 +128,89 @@ export function ActionModal< Item >( { items, closeModal, }: ActionModalProps< Item > ) { - const label = - typeof action.label === 'string' ? action.label : action.label( items ); - - const modalHeader = - typeof action.modalHeader === 'function' - ? action.modalHeader( items ) - : action.modalHeader; + // 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; - const title = modalHeader || label; const contentRef = useRef< HTMLDivElement >( null ); const initialFocus = useMapFocusOnMount( - action.modalFocusOnMount ?? true, + renderedAction?.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(); + return ( { - if ( ! open ) { + open={ open } + onOpenChange={ ( isOpen ) => { + if ( ! isOpen ) { closeModal(); } } } - disablePointerDismissal={ action.hideModalHeader } - > - + onOpenChangeComplete={ ( isOpen ) => { + if ( ! isOpen ) { + setRenderedAction( null ); } - initialFocus={ initialFocus } - { ...( action.hideModalHeader && { - role: 'alertdialog' as const, - } ) } - > - { action.hideModalHeader ? ( - { title } } - /> - ) : ( - - { title } - - - ) } - - - - + } } + 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 ( <> @@ -357,11 +345,19 @@ function CompactItemActions< Item >( { /> - 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={ + ) + } + /> - + ); } @@ -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 ( +
+ - -
- ) : ( + disablePointerDismissal={ primaryAction.hideModalHeader } + > + + } + /> + } + /> + + action={ primaryAction } + items={ [ item ] } + closeModal={ closeModal } + /> + +
+ ); + } + return (
( { const registry = useRegistry(); const [ isHovered, setIsHovered ] = useState( false ); - const [ activeModalAction, setActiveModalAction ] = useState( - null as ActionModalType< Item > | null - ); const handleHover: React.MouseEventHandler = ( { type } ) => { const isHover = type === 'mouseenter'; setIsHovered( isHover ); @@ -182,7 +194,7 @@ function ListItem< Item >( { } }, [ isSelected ] ); - const { primaryAction, eligibleActions, modalActions } = useMemo( () => { + const { primaryAction, eligibleActions } = useMemo( () => { // If an action is eligible for all items, doesn't need // to provide the `isEligible` function. const _eligibleActions = actions.filter( @@ -191,14 +203,9 @@ 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 ] ); @@ -266,23 +273,9 @@ function ListItem< Item >( { actions={ eligibleActions } item={ item } registry={ registry } - setActiveModalAction={ setActiveModalAction } /> - { modalActions.map( ( action ) => ( - { - if ( ! isOpen ) { - setActiveModalAction( null ); - } - } } - /> - ) ) }
) } 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 >( { - - +
); @@ -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 ( ); -} +} ) 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 `` / `