` nested inside. When used as ` `,
+ * the trigger primitive clones merged props onto this component; we
+ * route them to the matching DOM element:
+ * - `onClick` → outer `` (fires `handleRowClick` → `onClick()`)
+ * - `aria-expanded` /
+ * `aria-controls` /
+ * `aria-haspopup` → inner `` (the only focusable element)
+ * - `ref` → inner `` (focus-return target on close)
+ *
+ * TODO: Restructure SummaryButton so a single element carries the
+ * click/keyboard/ARIA contract (the row becomes a real button, or the
+ * pencil becomes purely decorative). That removes the need for this
+ * manual prop split and makes the trigger surface unambiguous to
+ * assistive tech. Tracked as a follow-up to PR #78028.
+ */
+function SummaryButtonImpl< Item >(
+ {
+ data,
+ field,
+ fieldLabel,
+ summaryFields,
+ validity,
+ touched,
+ disabled,
+ onClick,
+ 'aria-expanded': ariaExpanded,
+ 'aria-controls': ariaControls,
+ 'aria-haspopup': ariaHasPopup,
+ }: SummaryButtonProps< Item >,
+ ref: Ref< HTMLButtonElement >
+) {
const { labelPosition, editVisibility } =
field.layout as NormalizedPanelLayout;
const errorMessage = getFirstValidationError( validity );
@@ -63,7 +115,7 @@ export default function SummaryButton< Item >( {
);
const controlId = useInstanceId(
- SummaryButton,
+ SummaryButtonImpl,
'dataforms-layouts-panel__field-control'
);
@@ -81,13 +133,13 @@ export default function SummaryButton< Item >( {
const rowRef = useRef< HTMLDivElement >( null );
- const handleRowClick = () => {
+ const handleRowClick: MouseEventHandler = ( event ) => {
const selection =
rowRef.current?.ownerDocument.defaultView?.getSelection();
if ( selection && selection.toString().length > 0 ) {
return;
}
- onClick();
+ onClick?.( event );
};
const handleKeyDown = ( event: React.KeyboardEvent ) => {
@@ -96,7 +148,7 @@ export default function SummaryButton< Item >( {
( event.key === 'Enter' || event.key === ' ' )
) {
event.preventDefault();
- onClick();
+ onClick?.( event );
}
};
@@ -155,15 +207,23 @@ export default function SummaryButton< Item >( {
{ ! disabled && (
) }
);
}
+
+const SummaryButton = forwardRef( SummaryButtonImpl ) as < Item >(
+ props: SummaryButtonProps< Item > & { ref?: Ref< HTMLButtonElement > }
+) => ReactElement;
+
+export default SummaryButton;
diff --git a/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx b/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx
index 63d2a5ae72afb4..ba5185bc56a5a3 100644
--- a/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx
+++ b/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx
@@ -1,18 +1,20 @@
-/**
- * External dependencies
- */
-import type { ReactElement } from 'react';
-
/**
* WordPress dependencies
*/
import { Button, CheckboxControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
-import { useMemo, useState, useRef, useContext } from '@wordpress/element';
+import {
+ useCallback,
+ useMemo,
+ useState,
+ useRef,
+ useContext,
+} from '@wordpress/element';
import { useRegistry } from '@wordpress/data';
import { closeSmall } from '@wordpress/icons';
import { useViewportMatch } from '@wordpress/compose';
-import { Stack } from '@wordpress/ui';
+// eslint-disable-next-line @wordpress/use-recommended-components
+import { Dialog, Stack } from '@wordpress/ui';
/**
* Internal dependencies
@@ -23,39 +25,36 @@ import type { Action, ActionModal as ActionModalType } from '../../types';
import type { SetSelection } from '../../types/private';
import type { ActionTriggerProps } from '../dataviews-item-actions';
import getFooterMessage from '../../utils/get-footer-message';
+import genericForwardRef from '../../utils/generic-forward-ref';
+import getActionLabel from '../../utils/get-action-label';
interface ActionWithModalProps< Item > {
action: ActionModalType< Item >;
items: Item[];
- ActionTriggerComponent: (
- props: ActionTriggerProps< Item >
- ) => ReactElement;
}
function ActionWithModal< Item >( {
action,
items,
- ActionTriggerComponent,
}: ActionWithModalProps< Item > ) {
const [ isModalOpen, setIsModalOpen ] = useState( false );
- const actionTriggerProps = {
- action,
- onClick: () => {
- setIsModalOpen( true );
- },
- items,
- };
+ const closeModal = useCallback( () => setIsModalOpen( false ), [] );
+
return (
- <>
-
- { isModalOpen && (
- setIsModalOpen( false ) }
- />
- ) }
- >
+
+ }
+ />
+
+
);
}
@@ -178,19 +177,23 @@ interface ToolbarContentProps< Item > {
};
}
-function ActionTrigger< Item >( {
- action,
- onClick,
- isBusy,
- items,
-}: ActionTriggerProps< Item > ) {
- const label =
- typeof action.label === 'string' ? action.label : action.label( items );
+// `forwardRef` + `{ ...rest }` so this component composes under
+// ` } />`: the trigger's
+// merged `onClick` and ARIA state (`aria-haspopup="dialog"`,
+// `aria-expanded`, `aria-controls`) flow straight onto the underlying
+// `Button`. Direct callers (the inline `ActionButton` path) keep
+// passing `onClick` / `isBusy` explicitly.
+const ActionTrigger = genericForwardRef( function ActionTrigger< Item >(
+ { action, onClick, isBusy, items, ...rest }: ActionTriggerProps< Item >,
+ ref: React.Ref< HTMLButtonElement >
+) {
+ const label = getActionLabel( action, items );
const isMobile = useViewportMatch( 'medium', '<' );
if ( isMobile ) {
return (
( {
size="compact"
onClick={ onClick }
isBusy={ isBusy }
+ { ...rest }
/>
);
}
return (
{ label }
);
-}
+} );
const EMPTY_ARRAY: [] = [];
@@ -235,7 +241,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 26098143baf356..c56aada982d2e8 100644
--- a/packages/dataviews/src/components/dataviews-item-actions/index.tsx
+++ b/packages/dataviews/src/components/dataviews-item-actions/index.tsx
@@ -1,42 +1,67 @@
/**
* External dependencies
*/
-import type { MouseEventHandler } from 'react';
+import type { MouseEventHandler, ReactElement } from 'react';
/**
* WordPress dependencies
*/
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';
+// eslint-disable-next-line @wordpress/use-recommended-components
+import { Dialog, Stack, VisuallyHidden } from '@wordpress/ui';
/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';
import type { Action, ActionModal as ActionModalType } from '../../types';
+import useMapFocusOnMount from '../../hooks/use-map-focus-on-mount';
+import genericForwardRef from '../../utils/generic-forward-ref';
+import getActionLabel from '../../utils/get-action-label';
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';
}
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; opening/closing happens through the surrounding
+ * `` (controlled or uncontrolled) rather than props on
+ * this component.
+ */
action: ActionModalType< Item >;
items: Item[];
+ /**
+ * 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;
}
@@ -44,7 +69,14 @@ interface ActionsMenuGroupProps< Item > {
actions: Action< Item >[];
item: Item;
registry: ReturnType< typeof useRegistry >;
- setActiveModalAction: ( action: ActionModalType< Item > | null ) => void;
+ /**
+ * Invoked when the user selects a modal action from the menu. The
+ * caller is expected to render a sibling `` outside this
+ * menu that hosts the action's popup body — keeping the dialog out of
+ * the `Menu.Popover`'s `unmountOnHide` subtree so it survives the
+ * menu's exit transition.
+ */
+ onModalAction: ( action: ActionModalType< Item > ) => void;
}
interface ItemActionsProps< Item > {
@@ -67,34 +99,44 @@ interface PrimaryActionsProps< Item > {
buttonVariant?: 'primary' | 'secondary' | 'tertiary' | 'link';
}
-function ButtonTrigger< Item >( {
- action,
- onClick,
- items,
- variant,
-}: ActionTriggerProps< Item > ) {
- const label =
- typeof action.label === 'string' ? action.label : action.label( items );
+// `ButtonTrigger` forwards refs and unknown props onto its underlying
+// `Button`, so it can be used directly (parent supplies `onClick`) or
+// composed via render props
+// (e.g. ` } />`).
+const ButtonTrigger = genericForwardRef( function ButtonTrigger< Item >(
+ { action, items, variant, ...rest }: ActionTriggerProps< Item >,
+ ref: React.Ref< HTMLButtonElement >
+) {
+ const label = getActionLabel( action, items );
return (
{ label }
);
-}
+} );
+// `MenuItemTrigger` is always rendered as a child of ``
+// and never composed under `` — modal actions hoist
+// their `Dialog.Root` outside the menu (see `ItemActionsMenu` below) so
+// the `Menu.Item` only needs to fire its own `onClick`. No `forwardRef`,
+// no `render` prop forwarding, no generic cast.
function MenuItemTrigger< Item >( {
action,
- onClick,
items,
-}: ActionTriggerProps< Item > ) {
- const label =
- typeof action.label === 'string' ? action.label : action.label( items );
+ onClick,
+}: {
+ action: Action< Item >;
+ items: Item[];
+ onClick: () => void;
+} ) {
+ const label = getActionLabel( action, items );
return (
{ label }
@@ -102,31 +144,105 @@ function MenuItemTrigger< Item >( {
);
}
+function mapModalSize(
+ size: ActionModalType< unknown >[ 'modalSize' ]
+): 'small' | 'medium' | 'large' | 'full' {
+ if ( size === 'fill' ) {
+ return 'full';
+ }
+ 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,
closeModal,
}: ActionModalProps< Item > ) {
- const label =
- typeof action.label === 'string' ? action.label : action.label( items );
+ const contentRef = useRef< HTMLDivElement >( null );
+ const initialFocus = useMapFocusOnMount(
+ action.modalFocusOnMount ?? true,
+ contentRef
+ );
+ const label = getActionLabel( action, items );
const modalHeader =
typeof action.modalHeader === 'function'
? action.modalHeader( items )
: 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 an inline button that opens the
+// action's dialog. The `Dialog.Root` lives at this call site (outside
+// any host that unmounts on click — `Menu.Popover` is the relevant one
+// in the menu path, see `ItemActionsMenu`) so its lifecycle is not
+// affected by surrounding popover transitions.
+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 ), [] );
+ return (
+
+
+ }
+ />
+
+
);
}
@@ -134,7 +250,7 @@ export function ActionsMenuGroup< Item >( {
actions,
item,
registry,
- setActiveModalAction,
+ onModalAction,
}: ActionsMenuGroupProps< Item > ) {
const { primaryActions, regularActions } = useMemo( () => {
return actions.reduce(
@@ -157,13 +273,11 @@ export function ActionsMenuGroup< Item >( {
{
- if ( 'RenderModal' in action ) {
- setActiveModalAction( action );
- return;
- }
- action.callback( [ item ], { registry } );
- } }
+ onClick={
+ 'RenderModal' in action
+ ? () => onModalAction( action )
+ : () => action.callback( [ item ], { registry } )
+ }
items={ [ item ] }
/>
) );
@@ -176,6 +290,84 @@ export function ActionsMenuGroup< Item >( {
);
}
+// Hosts a kebab-style menu of item actions plus the `Dialog.Root` that
+// owns the active modal action's popup body. The dialog is rendered as
+// a sibling of `` (not as a descendant of `Menu.Popover`) so it
+// survives the menu's `unmountOnHide` exit transition. Both
+// `CompactItemActions` and the list-layout's per-row menu use this
+// component; they only differ in the trigger button passed in via
+// `renderTrigger`.
+export function ItemActionsMenu< Item >( {
+ item,
+ actions,
+ registry,
+ renderTrigger,
+}: {
+ item: Item;
+ actions: Action< Item >[];
+ registry: ReturnType< typeof useRegistry >;
+ renderTrigger: ReactElement;
+} ) {
+ const [ activeModalAction, setActiveModalAction ] =
+ useState< ActionModalType< Item > | null >( null );
+ // Snapshot of the most recently active action — kept around so the
+ // popup body stays mounted through the exit animation and is then
+ // cleared from `onOpenChangeComplete` once the close transition
+ // finishes (mirrors the `sessionKey` / defensive-setter patterns in
+ // `PanelModal` and the page-templates duplicate dialog).
+ const [ lastActiveModalAction, setLastActiveModalAction ] =
+ useState< ActionModalType< Item > | null >( null );
+ const renderedAction = activeModalAction ?? lastActiveModalAction;
+ const closeModal = useCallback( () => setActiveModalAction( null ), [] );
+ const onModalAction = useCallback( ( action: ActionModalType< Item > ) => {
+ setActiveModalAction( action );
+ setLastActiveModalAction( action );
+ }, [] );
+
+ return (
+ <>
+
+
+
+
+
+
+ {
+ if ( ! open ) {
+ setActiveModalAction( null );
+ }
+ } }
+ onOpenChangeComplete={ ( open ) => {
+ if ( ! open ) {
+ setLastActiveModalAction( null );
+ }
+ } }
+ disablePointerDismissal={ renderedAction?.hideModalHeader }
+ >
+ { renderedAction && (
+ // `key` per action.id force-remounts the popup body when
+ // the user closes one action and opens a different one,
+ // preventing the new action's `RenderModal` from
+ // inheriting the previous session's state.
+
+ ) }
+
+ >
+ );
+}
+
export default function ItemActions< Item >( {
item,
actions,
@@ -245,41 +437,22 @@ function CompactItemActions< Item >( {
isSmall,
registry,
}: CompactItemActionsProps< Item > ) {
- const [ activeModalAction, setActiveModalAction ] = useState(
- null as ActionModalType< Item > | null
- );
return (
- <>
-
-
- }
- />
-
-
-
-
- { !! activeModalAction && (
- setActiveModalAction( null ) }
+
- ) }
- >
+ }
+ />
);
}
@@ -289,7 +462,6 @@ export function PrimaryActions< Item >( {
registry,
buttonVariant,
}: PrimaryActionsProps< Item > ) {
- const [ activeModalAction, setActiveModalAction ] = useState( null as any );
const isMobileViewport = useViewportMatch( 'medium', '<' );
if ( isMobileViewport ) {
@@ -301,27 +473,25 @@ export function PrimaryActions< Item >( {
}
return (
<>
- { actions.map( ( action ) => (
- {
- if ( 'RenderModal' in action ) {
- setActiveModalAction( action );
- return;
+ { actions.map( ( action ) =>
+ 'RenderModal' in action ? (
+
+ ) : (
+
+ action.callback( [ item ], { registry } )
}
- action.callback( [ item ], { registry } );
- } }
- items={ [ item ] }
- variant={ buttonVariant }
- />
- ) ) }
- { !! activeModalAction && (
- setActiveModalAction( null ) }
- />
+ items={ [ item ] }
+ variant={ buttonVariant }
+ />
+ )
) }
>
);
diff --git a/packages/dataviews/src/components/dataviews-item-actions/style.scss b/packages/dataviews/src/components/dataviews-item-actions/style.scss
index dbd4d7944e57a5..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 {
- 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/components/dataviews-item-actions/test/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx
new file mode 100644
index 00000000000000..7df61b1092b2af
--- /dev/null
+++ b/packages/dataviews/src/components/dataviews-item-actions/test/index.tsx
@@ -0,0 +1,368 @@
+/**
+ * External dependencies
+ */
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+// `Dialog.Popup` only reflects `size` through CSS-module class names, which
+// are stripped in unit tests. Wrap the real `Dialog.Popup` so we can assert
+// what `ActionModal` forwards across the boundary while still rendering the
+// real popup behaviour (focus, role, backdrop, portal). Must be defined
+// before the module is imported below — `jest.mock` is hoisted, so the
+// captured spy needs a `mock`-prefixed name to satisfy the babel hoist
+// guard.
+const mockDialogPopupSpy = jest.fn();
+jest.mock( '@wordpress/ui', () => {
+ const actual = jest.requireActual( '@wordpress/ui' );
+ const { forwardRef, createElement } =
+ jest.requireActual( '@wordpress/element' );
+ const PopupSpy = forwardRef(
+ ( props: Record< string, unknown >, ref: unknown ) => {
+ mockDialogPopupSpy( props );
+ return createElement( actual.Dialog.Popup, { ...props, ref } );
+ }
+ );
+ return {
+ ...actual,
+ Dialog: { ...actual.Dialog, Popup: PopupSpy },
+ };
+} );
+
+/**
+ * WordPress dependencies
+ */
+// eslint-disable-next-line @wordpress/use-recommended-components
+import { Dialog } from '@wordpress/ui';
+
+/**
+ * Internal dependencies
+ */
+import ItemActions, { 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 } ) => (
+
+ ),
+ ...overrides,
+ };
+}
+
+// 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();
+
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ } );
+
+ await waitFor( () => {
+ expect( screen.getByRole( 'dialog' ) ).toBeVisible();
+ } );
+ } );
+
+ it( 'renders with an alertdialog role when hideModalHeader is true', async () => {
+ const action = createAction( { hideModalHeader: true } );
+
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ } );
+
+ await waitFor( () => {
+ expect( screen.getByRole( 'alertdialog' ) ).toBeVisible();
+ } );
+ } );
+
+ it( 'focuses the first input when modalFocusOnMount is "firstInputElement"', async () => {
+ const action = createAction( {
+ modalFocusOnMount: 'firstInputElement',
+ RenderModal: () => (
+
+ ),
+ } );
+
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ } );
+
+ await waitFor( () => {
+ expect( screen.getByTestId( 'first-input' ) ).toHaveFocus();
+ } );
+ } );
+
+ it( 'falls back to the popup smart default when modalFocusOnMount is unset', async () => {
+ // `Dialog.Popup`'s default focus-on-mount lands on the first
+ // content tabbable rather than the close icon.
+ const action = createAction( {
+ RenderModal: () => (
+
+
Some text
+
Content button
+
+ ),
+ } );
+
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ } );
+
+ await waitFor( () => {
+ expect( screen.getByTestId( 'content-button' ) ).toHaveFocus();
+ } );
+ } );
+
+ it.each( [ 'small', 'medium', 'large' ] as const )(
+ 'forwards modalSize %p to Dialog.Popup without emitting a deprecation warning',
+ async ( modalSize ) => {
+ const action = createAction( { modalSize } );
+
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ } );
+
+ await screen.findByRole( 'dialog' );
+ expect( console ).not.toHaveWarned();
+ }
+ );
+
+ it( "translates modalSize 'fill' to Dialog.Popup size 'full' silently", async () => {
+ mockDialogPopupSpy.mockClear();
+ const action = createAction( { modalSize: 'fill' } );
+
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ } );
+
+ await screen.findByRole( 'dialog' );
+ expect( mockDialogPopupSpy ).toHaveBeenCalledWith(
+ expect.objectContaining( { size: 'full' } )
+ );
+ expect( console ).not.toHaveWarned();
+ } );
+
+ it( 'renders the popup inside the dataviews-action-modal__portal element', async () => {
+ const action = createAction();
+
+ 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
+ // 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 onOpenChange = jest.fn();
+ const action = createAction();
+
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ onOpenChange,
+ } );
+
+ await screen.findByRole( 'dialog' );
+ await user.keyboard( '{Escape}' );
+
+ expect( onOpenChange ).toHaveBeenCalledWith( false );
+ } );
+
+ it( 'closes when the user clicks the backdrop by default', async () => {
+ const user = userEvent.setup();
+ const onOpenChange = jest.fn();
+ const action = createAction();
+
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ onOpenChange,
+ } );
+
+ await screen.findByRole( 'dialog' );
+ const backdrop = screen.getByTestId( 'dialog-backdrop' );
+ await user.click( backdrop );
+
+ expect( onOpenChange ).toHaveBeenCalledWith( false );
+ } );
+
+ it( 'does not close on backdrop click for alert dialogs (hideModalHeader)', async () => {
+ const user = userEvent.setup();
+ const onOpenChange = jest.fn();
+ const action = createAction( { hideModalHeader: true } );
+
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ onOpenChange,
+ } );
+
+ await screen.findByRole( 'alertdialog' );
+ const backdrop = screen.getByTestId( 'dialog-backdrop' );
+ await user.click( backdrop );
+
+ 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();
+
+ renderActionModal( {
+ action,
+ items: [ { id: 1, title: 'Item' } ],
+ onOpenChange,
+ } );
+
+ await screen.findByRole( 'dialog' );
+ await user.click( screen.getByRole( 'button', { name: /done/i } ) );
+
+ expect( onOpenChange ).toHaveBeenCalledWith( false );
+ } );
+} );
+
+describe( 'ItemActions — kebab menu → modal action', () => {
+ // Regression coverage for the composition between `Menu.Popover`
+ // (`unmountOnHide` + `Menu.Item.hideOnClick`) and the per-action
+ // `Dialog.Root` that owns each modal action's open state. The bug:
+ // hosting `Dialog.Root` inside the compact menu's popover means the
+ // dialog mounts and immediately unmounts when the menu hides on
+ // `Menu.Item` click, so consumers can't reach the modal body. These
+ // tests assert that selecting a modal action from the kebab menu
+ // opens its dialog and that the dialog body remains interactive long
+ // enough to dispatch its own actions.
+ const item = { id: 1, title: 'Item' };
+
+ function createMenuModalAction(
+ overrides: Partial< ActionModalType< TestItem > > = {}
+ ): ActionModalType< TestItem > {
+ return {
+ id: 'menu-modal-action',
+ label: 'Menu modal action',
+ RenderModal: ( { closeModal } ) => (
+
+
Menu modal content
+
Done
+
+ ),
+ ...overrides,
+ };
+ }
+
+ it( 'opens the dialog when a modal action is selected from the kebab menu', async () => {
+ const user = userEvent.setup();
+ const action = createMenuModalAction();
+
+ render(
+
+ );
+
+ await user.click( screen.getByRole( 'button', { name: /actions/i } ) );
+ await user.click(
+ screen.getByRole( 'menuitem', { name: /menu modal action/i } )
+ );
+
+ // The dialog body must be mounted and reachable after the menu
+ // closes; on the buggy host it unmounts together with the menu
+ // popover and the assertion times out.
+ await waitFor( () => {
+ expect(
+ screen.getByTestId( 'menu-modal-content' )
+ ).toBeInTheDocument();
+ } );
+ expect(
+ screen.getByRole( 'button', { name: /done/i } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'keeps the dialog interactive — closeModal from the body still dismisses it', async () => {
+ const user = userEvent.setup();
+ const action = createMenuModalAction();
+
+ render(
+
+ );
+
+ await user.click( screen.getByRole( 'button', { name: /actions/i } ) );
+ await user.click(
+ screen.getByRole( 'menuitem', { name: /menu modal action/i } )
+ );
+
+ const doneButton = await screen.findByRole( 'button', {
+ name: /done/i,
+ } );
+ await user.click( doneButton );
+
+ await waitFor( () => {
+ expect(
+ screen.queryByTestId( 'menu-modal-content' )
+ ).not.toBeInTheDocument();
+ } );
+ } );
+} );
diff --git a/packages/dataviews/src/components/dataviews-layouts/list/index.tsx b/packages/dataviews/src/components/dataviews-layouts/list/index.tsx
index 573f026c832edf..4860c23783fbc1 100644
--- a/packages/dataviews/src/components/dataviews-layouts/list/index.tsx
+++ b/packages/dataviews/src/components/dataviews-layouts/list/index.tsx
@@ -7,12 +7,7 @@ import clsx from 'clsx';
* WordPress dependencies
*/
import { useInstanceId, usePrevious } from '@wordpress/compose';
-import {
- Button,
- privateApis as componentsPrivateApis,
- Spinner,
- Composite,
-} from '@wordpress/components';
+import { Button, Spinner, Composite } from '@wordpress/components';
import {
useCallback,
useEffect,
@@ -24,13 +19,13 @@ 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
*/
-import { unlock } from '../../../lock-unlock';
-import { ActionsMenuGroup, ActionModal } from '../../dataviews-item-actions';
+import { ActionModal, ItemActionsMenu } from '../../dataviews-item-actions';
import DataViewsContext from '../../dataviews-context';
import { useDelayedLoading } from '../../../hooks/use-delayed-loading';
import type {
@@ -38,9 +33,9 @@ import type {
NormalizedField,
ViewList as ViewListType,
ViewListProps,
- ActionModal as ActionModalType,
} from '../../../types';
import getDataByGroup from '../utils/get-data-by-group';
+import getActionLabel from '../../../utils/get-action-label';
interface ListViewItemProps< Item > {
view: ViewListType;
@@ -57,8 +52,6 @@ interface ListViewItemProps< Item > {
posinset?: number;
}
-const { Menu } = unlock( componentsPrivateApis );
-
function generateItemWrapperCompositeId( idPrefix: string ) {
return `${ idPrefix }-item-wrapper`;
}
@@ -83,41 +76,52 @@ function PrimaryActionGridCell< Item >( {
} ) {
const registry = useRegistry();
const [ isModalOpen, setIsModalOpen ] = useState( false );
+ const closeModal = useCallback( () => setIsModalOpen( false ), [] );
const compositeItemId = generatePrimaryActionCompositeId(
idPrefix,
primaryAction.id
);
- const label =
- typeof primaryAction.label === 'string'
- ? primaryAction.label
- : primaryAction.label( [ item ] );
+ const label = getActionLabel( primaryAction, [ item ] );
- return 'RenderModal' in primaryAction ? (
-
-
setIsModalOpen( true ) }
+ if ( 'RenderModal' in primaryAction ) {
+ // Compose `Composite.Item` → `Dialog.Trigger` → `Button` so all
+ // three layers' props merge onto the same DOM button:
+ // composite-item navigation, dialog-trigger ARIA, and the
+ // visual `Button` styling.
+ return (
+
+
+
+ }
+ />
+ }
/>
- }
- >
- { isModalOpen && (
action={ primaryAction }
items={ [ item ] }
- closeModal={ () => setIsModalOpen( false ) }
+ 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 );
@@ -235,44 +236,28 @@ function ListItem< Item >( {
) }
{ ! hasOnlyOnePrimaryAction && (
-
-
- }
- />
- }
- />
-
-
+ }
/>
-
-
- { !! activeModalAction && (
-
setActiveModalAction( null ) }
- />
- ) }
+ }
+ />
) }
diff --git a/packages/dataviews/src/dataform/test/dataform.tsx b/packages/dataviews/src/dataform/test/dataform.tsx
index 972c613d588267..b26849b21c650a 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,14 @@ 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 the close animation resolves.
+ await waitFor( () => {
+ expect(
+ screen.queryByRole( 'dialog' )
+ ).not.toBeInTheDocument();
+ } );
} );
it( 'should apply changes and close modal when apply button is clicked', async () => {
@@ -398,11 +404,90 @@ 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 the close animation resolves.
+ await waitFor( () => {
+ expect(
+ screen.queryByRole( 'dialog' )
+ ).not.toBeInTheDocument();
+ } );
expect( onChange ).toHaveBeenCalledWith( { title: 'New Title' } );
} );
+ it( 'should disable Apply when the form is invalid (Cancel stays enabled)', async () => {
+ const onChange = jest.fn();
+ const requiredTitleFields = fields.map( ( field ) =>
+ field.id === 'title'
+ ? { ...field, isValid: { required: true } }
+ : field
+ );
+ const formWithRequiredTitle = {
+ fields: [ 'title' ],
+ layout: {
+ type: 'panel',
+ labelPosition: 'side',
+ openAs: 'modal',
+ } as const,
+ };
+
+ render(
+
+ );
+
+ const user = await userEvent.setup();
+ await user.click( fieldsSelector.title.view() );
+
+ expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument();
+
+ // Apply is enabled while the title is non-empty.
+ const applyButton = screen.getByRole( 'button', {
+ name: /apply/i,
+ } );
+ const cancelButton = screen.getByRole( 'button', {
+ name: /cancel/i,
+ } );
+ expect( applyButton ).not.toHaveAttribute(
+ 'aria-disabled',
+ 'true'
+ );
+ expect( cancelButton ).not.toHaveAttribute(
+ 'aria-disabled',
+ 'true'
+ );
+
+ // Clear the title to violate the `required` constraint.
+ await user.clear( fieldsSelector.title.edit() );
+
+ // Apply is disabled, Cancel stays enabled — users must always
+ // be able to discard the draft. `Dialog.Action` uses the
+ // focusable-when-disabled pattern (`aria-disabled="true"`)
+ // rather than the native `disabled` attribute, so assert on
+ // the ARIA state and on the functional consequence (click does
+ // not dispatch the change).
+ expect( applyButton ).toHaveAttribute( 'aria-disabled', 'true' );
+ expect( cancelButton ).not.toHaveAttribute(
+ 'aria-disabled',
+ 'true'
+ );
+ await user.click( applyButton );
+ expect( onChange ).not.toHaveBeenCalled();
+
+ // Cancel still closes the modal.
+ await user.click( cancelButton );
+ await waitFor( () => {
+ expect(
+ screen.queryByRole( 'dialog' )
+ ).not.toBeInTheDocument();
+ } );
+ expect( onChange ).not.toHaveBeenCalled();
+ } );
+
it( 'should call onChange with the correct value for each typed character', async () => {
const onChange = jest.fn();
render(
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/hooks/use-map-focus-on-mount.ts b/packages/dataviews/src/hooks/use-map-focus-on-mount.ts
new file mode 100644
index 00000000000000..376e75f0eb32f8
--- /dev/null
+++ b/packages/dataviews/src/hooks/use-map-focus-on-mount.ts
@@ -0,0 +1,67 @@
+/**
+ * WordPress dependencies
+ */
+import { useCallback } from '@wordpress/element';
+import type { RefObject } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import type { ActionModal } from '../types';
+
+const FIRST_INPUT_SELECTOR =
+ 'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled])';
+
+/**
+ * Maps the legacy `Modal.focusOnMount` semantics onto the
+ * `Dialog.Popup.initialFocus` prop accepted by `@wordpress/ui`.
+ *
+ * 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 ` ` / `` / `