DataViews: Migrate ActionModal and PanelModal from Modal to @wordpress/ui Dialog#78028
DataViews: Migrate ActionModal and PanelModal from Modal to @wordpress/ui Dialog#78028ciampo wants to merge 34 commits into
ActionModal and PanelModal from Modal to @wordpress/ui Dialog#78028Conversation
…ess/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
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).
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
- 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
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
Replace the global `:root` z-index override with a scoped `.dataviews-action-modal-portal` class applied to the portal via the `portal` prop (`<Dialog.Portal className="dataviews-action-modal-portal" />`), ensuring the `--wp-ui-dialog-z-index` override only applies to action modal dialogs. Made-with: Cursor
- 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
- 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.
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.
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 `<ActionModal>` 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.
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.
…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={<Button />}` so Base UI's
ARIA wiring (`aria-haspopup="dialog"`, `aria-expanded`,
`aria-controls`) flows through automatically.
`closeModal` stays on `RenderModalProps` because the public contract
allows consumers to call it from async code; the wrapper component owns
the `useState` so the imperative path remains a one-liner
(`() => setOpen(false)`) without any direct Base UI store access.
Tests updated to render `<ActionModal>` inside a controlled
`<Dialog.Root>` instead of injecting `open`/`onOpenChange` props on
`<ActionModal>` directly.
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 `<Dialog.Action variant="outline">`. It
closes via Base UI's `Dialog.Close`; the existing `setTouched(true)`
side effect runs through the parent's `onOpenChange` handler.
- Apply becomes `<Dialog.Action onClick={() => 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.
|
Size Change: +230 kB (+2.9%) Total Size: 8.18 MB 📦 View Changed
ℹ️ View Unchanged
|
Base UI is an implementation detail of `@wordpress/ui` and shouldn't leak through dataviews comments. Reword the nine remaining mentions across `panel/summary-button.tsx`, `dataviews-bulk-actions/index.tsx`, the item-actions and dataform tests, and the list layout's primary action gridcell to refer to the surfaced primitives (`Dialog.Root`, `Dialog.Trigger`, `Dialog.Popup`, "the dialog primitive", "the close animation") instead. No behavioural change.
`useFormValidity` already returns `{ validity, isValid }`, so wiring
the disabled-Apply state through a re-exported `isFormValid` helper
plus a local `useMemo` was duplicating work the hook had already done.
Destructure `isValid` directly instead, and unexport the helper so it
goes back to being a private detail of the hook module.
|
Flaky tests detected in 190cc4b. 🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/25501252954
|
The previous entry described a pre-regression-fix branch state where both ButtonTrigger and MenuItemTrigger forwarded refs and a (now deleted) ModalActionMenuItem reused them. After the kebab-menu hoist the menu path uses a plain MenuItemTrigger and ItemActionsMenu owns the Dialog.Root as a sibling of <Menu>; refresh the entry to describe that final shape.
Aligns the prop name with the surrounding codebase convention for element-prop trigger slots (renderXxx). triggerRender reads as 'a function that renders the trigger', which it isn't \u2014 it accepts a ReactElement.
The previous default of "dialog" was inherited from the legacy hardcoded value but was inaccurate for PanelDropdown, whose popover contains form fields rather than a dialog. Drop the default so consumers must declare what their popup actually opens, and pass aria-haspopup="true" from PanelDropdown's renderToggle. PanelModal is unaffected because Dialog.Trigger injects aria-haspopup="dialog" automatically when composing through render props.
Widen the onClick prop type from MouseEventHandler to (event: React.SyntheticEvent) => void so the keyboard activation path can forward the KeyboardEvent without an as-unknown-as double-cast. Both consumer paths still typecheck: PanelDropdown's renderToggle passes () => void (assignable as a function with fewer parameters) and Dialog.Trigger merges its onClick at runtime via cloneElement, which TypeScript doesn't constrain on the trigger child.
Centralises the 'static string vs items=>string' discrimination for action.label that was repeated in five places across item-actions, bulk-actions, and the list layout. Pure refactor with no behaviour change. Follow-up: dataviews-picker-footer also has this pattern but is outside the PR's diff and intentionally left for a later cleanup.
Document why the z-index overrides intentionally target :root rather than a .dataviews-wrapper scope: portaled overlays sit outside the wrapper, the bridge is a global concern wherever the two stacking systems coexist, and per-instance overrides already happen on the portal class. Comment-only change.
The 'stretch' value earns its place as the documented replacement for the deprecated 'fill'. 'full' rode along by inertia from Dialog.Popup's native size set: zero consumers in the repo use it and nothing in the Modal → Dialog migration depends on it. Removing it now keeps the dataviews public API surface narrow; we can re-add it as a non-breaking widen if a real use case appears.
Trunk's Modal treats `size: 'fill'` as `isFullScreen` (fills both dimensions). The PR's deprecation mapped `'fill'` to Dialog.Popup's `'stretch'`, which fills width only — the wrong visual match and a silent UX regression for any consumer that followed the suggested migration path. The actual equivalent is Dialog.Popup's `'full'`. There are no internal consumers of `modalSize: 'fill'`, so silent translation at the boundary is safe and keeps the public modalSize union trunk-identical (no deprecation warning, no new value, no CHANGELOG churn). `mapModalSize` now translates `'fill'` → `'full'` on the way to `Dialog.Popup`; consumers see no API change.
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
| :root { | ||
| --wp-ui-dialog-z-index: #{z-index(".components-modal__screen-overlay")}; | ||
| --wp-ui-tooltip-z-index: #{z-index(".components-tooltip")}; | ||
| } | ||
|
|
||
| .dataviews-action-modal__portal { | ||
| --wp-ui-dialog-z-index: #{z-index(".dataviews-action-modal")}; | ||
| } |
There was a problem hiding this comment.
Thoughts on how we coordinate this with the prime slot system?
My rough idea was:
- All
@wordpress/uioverlays get hooked into the (gated) prime slot system. - If you use a
@wordpress/uioverlay in a context where the prime slot system is enabled, that means you get the prime slot behavior, not the legacy z-index. - If you want to use the
@wordpress/uioverlay and keep the legacy z-index in a prime slot context, that means you need a per-instance z-index override.
We'll probably want some kind of ergonomic way to do number 3. But first, what are your thoughts on the overall approach?
What
Migrates DataViews
ActionModal(item-action confirm/edit dialogs) and DataFormPanelModalfrom@wordpress/componentsModalto@wordpress/uiDialog.Why
Aligns with the new design-system primitives, picks up real entry/exit animations, and replaces parent-owned modal state with primitive-owned state via
Dialog.Root/Dialog.Trigger.How
Dialog.Rootlives at its trigger site (kebab menu, inline button, bulk-action button) and stays mounted across the exit transition.ItemActionsMenuhost that renders the<Menu>and a siblingDialog.Root, so the dialog survivesMenu.Popover'sunmountOnHide.hideModalHeader: trueconfirmations render asDialog.Popupwithrole="alertdialog"anddisablePointerDismissal.Dialog.Portal(UI: Portal prop and Portal subcomponents for overlay Popups #77452); a smallwp-ui-legacy-compat.scssshim bridges@wordpress/uiand legacy stacking values globally while both systems coexist.Implementation notes
Dialog.Rootis kept mounted across sessions and toggled viaopen/onOpenChange. Session state is torn down inonOpenChangeCompleteso contents don't flash empty during the exit animation.Dialog.Triggercomposes withButtonTriggervia render props; the bulk-actions trigger reuses the sameActionTriggercomponent instead of duplicating button markup.getActionLabel( action, items )helper centralises the static-vs-functionalaction.labelsplit (5 sites).genericForwardRefhelper centralises theforwardRef-with-generics TypeScript workaround.useMapFocusOnMountshares the legacymodalFocusOnMountmapping across both modal layouts.Breaking changes (limited)
Detailed in
packages/dataviews/CHANGELOG.md..components-modal__*inside action modals no longer applies.dataforms-layouts-panel__modal{,-footer}CSS classes are removed.modalFocusOnMount: 'firstElement'now behaves like'firstContentElement'(the newDialogprimitive's smart default already skips the close icon).Action.RenderModalcallback contract is unchanged — consumers still write({ items, closeModal, onActionPerformed }) => ReactElement. Only the wrapping markup around it changes.Piggy-backed enhancement
PanelModal's Apply button now disables while the form is invalid (Cancel stays enabled). Self-contained, independently revertable; covered by its ownEnhancementsCHANGELOG entry and a regression test.Testing instructions
Follow-ups (not blocking)
Dialog.Root+Trigger+ActionModaltriplet (3 sites) via a shareduseActionDialoghook.getActionLabelhelper todataviews-picker-footer(a 6th site, left out for scope).aria-describedbyonrole="alertdialog"popups.SummaryButton's row<div>+ inner pencil<Button>into one focusable element (TODO in source).ModalActionMenuItemmentions from comments indataviews-item-actions/index.tsx.wp-ui-legacy-compat.scss's:rootshim once both stacking systems unify.hideModalHeaderconfirmations to the dedicatedAlertDialogprimitive onceRenderModal's body contract supports itsonConfirmlifecycle.Screencast
Kapture.2026-05-07.at.18.10.38.mp4
Kapture.2026-05-07.at.18.24.40.mp4
Context
First of 5 PRs splitting #76837. Independent — can be reviewed/merged on its own.