From a0249587862494a57682d4423678ebe905d72944 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 3 Feb 2026 18:29:46 +0100 Subject: [PATCH 01/34] Initial commit --- packages/ui/src/dialog/action.tsx | 25 ++++ packages/ui/src/dialog/close-icon.tsx | 39 +++++++ packages/ui/src/dialog/context.tsx | 12 ++ packages/ui/src/dialog/footer.tsx | 28 +++++ packages/ui/src/dialog/header.tsx | 26 +++++ packages/ui/src/dialog/heading.tsx | 30 +++++ packages/ui/src/dialog/index.stories.tsx | 130 +++++++++++++++++++++ packages/ui/src/dialog/index.ts | 13 +++ packages/ui/src/dialog/popup.tsx | 41 +++++++ packages/ui/src/dialog/root.tsx | 25 ++++ packages/ui/src/dialog/style.module.css | 102 ++++++++++++++++ packages/ui/src/dialog/test/index.test.tsx | 55 +++++++++ packages/ui/src/dialog/trigger.tsx | 18 +++ packages/ui/src/dialog/types.ts | 83 +++++++++++++ 14 files changed, 627 insertions(+) create mode 100644 packages/ui/src/dialog/action.tsx create mode 100644 packages/ui/src/dialog/close-icon.tsx create mode 100644 packages/ui/src/dialog/context.tsx create mode 100644 packages/ui/src/dialog/footer.tsx create mode 100644 packages/ui/src/dialog/header.tsx create mode 100644 packages/ui/src/dialog/heading.tsx create mode 100644 packages/ui/src/dialog/index.stories.tsx create mode 100644 packages/ui/src/dialog/index.ts create mode 100644 packages/ui/src/dialog/popup.tsx create mode 100644 packages/ui/src/dialog/root.tsx create mode 100644 packages/ui/src/dialog/style.module.css create mode 100644 packages/ui/src/dialog/test/index.test.tsx create mode 100644 packages/ui/src/dialog/trigger.tsx create mode 100644 packages/ui/src/dialog/types.ts diff --git a/packages/ui/src/dialog/action.tsx b/packages/ui/src/dialog/action.tsx new file mode 100644 index 00000000000000..ca82b6cf908e95 --- /dev/null +++ b/packages/ui/src/dialog/action.tsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import { forwardRef } from 'react'; +import { Dialog } from '@base-ui/react/dialog'; + +/** + * Internal dependencies + */ +import { type ActionProps } from './types'; +import { Button } from '../button'; + +const Action = forwardRef< HTMLButtonElement, ActionProps >( + function DialogAction( { render, ...props }, ref ) { + return ( + } + { ...props } + /> + ); + } +); + +export { Action }; diff --git a/packages/ui/src/dialog/close-icon.tsx b/packages/ui/src/dialog/close-icon.tsx new file mode 100644 index 00000000000000..2e546909e12f63 --- /dev/null +++ b/packages/ui/src/dialog/close-icon.tsx @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { forwardRef } from 'react'; +import { Dialog } from '@base-ui/react/dialog'; + +/** + * WordPress dependencies + */ +import { close } from '@wordpress/icons'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { type CloseIconProps } from './types'; +import { IconButton } from '..'; + +const CloseIcon = forwardRef< HTMLButtonElement, CloseIconProps >( + function DialogCloseIcon( props, ref ) { + return ( + + } + /> + ); + } +); + +export { CloseIcon }; diff --git a/packages/ui/src/dialog/context.tsx b/packages/ui/src/dialog/context.tsx new file mode 100644 index 00000000000000..b7145273b60711 --- /dev/null +++ b/packages/ui/src/dialog/context.tsx @@ -0,0 +1,12 @@ +/** + * External dependencies + */ +import { createContext } from 'react'; + +interface DialogContextValue { + title?: string; +} + +const DialogContext = createContext< DialogContextValue >( {} ); + +export { DialogContext }; diff --git a/packages/ui/src/dialog/footer.tsx b/packages/ui/src/dialog/footer.tsx new file mode 100644 index 00000000000000..0588bffb5c8b5c --- /dev/null +++ b/packages/ui/src/dialog/footer.tsx @@ -0,0 +1,28 @@ +/** + * External dependencies + */ +import { forwardRef } from 'react'; +import clsx from 'clsx'; + +/** + * Internal dependencies + */ +import { type FooterProps } from './types'; +import styles from './style.module.css'; + +const Footer = forwardRef< HTMLDivElement, FooterProps >( function DialogFooter( + { className, children, ...props }, + ref +) { + return ( +
+ { children } +
+ ); +} ); + +export { Footer }; diff --git a/packages/ui/src/dialog/header.tsx b/packages/ui/src/dialog/header.tsx new file mode 100644 index 00000000000000..274fed0260ac94 --- /dev/null +++ b/packages/ui/src/dialog/header.tsx @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { forwardRef } from 'react'; +import clsx from 'clsx'; + +/** + * Internal dependencies + */ +import { type HeaderProps } from './types'; +import styles from './style.module.css'; + +const Header = forwardRef< HTMLDivElement, HeaderProps >( function DialogHeader( + { className, ...props }, + ref +) { + return ( +
+ ); +} ); + +export { Header }; diff --git a/packages/ui/src/dialog/heading.tsx b/packages/ui/src/dialog/heading.tsx new file mode 100644 index 00000000000000..7800699e7c6f05 --- /dev/null +++ b/packages/ui/src/dialog/heading.tsx @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { forwardRef, useContext } from 'react'; +import { Dialog as _Dialog } from '@base-ui/react/dialog'; + +/** + * Internal dependencies + */ +import { type HeadingProps } from './types'; +import styles from './style.module.css'; +import { DialogContext } from './context'; + +const Heading = forwardRef< HTMLDivElement, HeadingProps >( + function DialogHeading( props, ref ) { + const { title } = useContext( DialogContext ); + + return ( + <_Dialog.Title + ref={ ref } + className={ styles.heading } + { ...props } + > + { title } + + ); + } +); + +export { Heading }; diff --git a/packages/ui/src/dialog/index.stories.tsx b/packages/ui/src/dialog/index.stories.tsx new file mode 100644 index 00000000000000..7cc29ea72b8eac --- /dev/null +++ b/packages/ui/src/dialog/index.stories.tsx @@ -0,0 +1,130 @@ +/** + * External dependencies + */ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ComponentProps } from 'react'; + +/** + * Internal dependencies + */ +import { Dialog, CheckboxControl } from '..'; + +const meta: Meta< typeof Dialog.Root > = { + title: 'Design System/Dialog', + component: Dialog.Root, + subcomponents: { + 'Dialog.Trigger': Dialog.Trigger, + 'Dialog.Popup': Dialog.Popup, + 'Dialog.Header': Dialog.Header, + 'Dialog.Heading': Dialog.Heading, + 'Dialog.CloseIcon': Dialog.CloseIcon, + 'Dialog.Action': Dialog.Action, + 'Dialog.Footer': Dialog.Footer, + }, + args: { + title: 'Dialog Title', + }, + parameters: { + docs: { + description: { + component: ` +When using the Dialog component, make sure to always include a visible close button, either \`Dialog.CloseIcon\` or a clear dismissing action button. If your dialog has a "Cancel" button in the footer, the close icon may be redundant and create confusion about what clicking "X" means. + +Use \`Dialog.CloseIcon\` for informational dialogs where dismissing is safe and expected. For dialogs requiring explicit user choice (especially destructive actions), omit the close icon and rely on footer action buttons like "Cancel" and "Confirm" instead. + `, + }, + }, + }, +}; +export default meta; + +type Story = StoryObj< typeof Dialog.Root >; + +function DialogWithSize( { + size, +}: Pick< ComponentProps< typeof Dialog.Popup >, 'size' > ) { + return ( + <> + Open Dialog + + + + + +

+ This dialog demonstrates best practices for informational + dialogs. It includes a close icon because dismissing it is + safe and expected. +

+ + + Got it + +
+ + ); +} + +/** + * An informational dialog with a close icon, where there is no ambiguity on + * what happens when clicking the close icon. + */ +export const Default: Story = { + args: { + title: 'Welcome', + children: , + }, +}; + +/** + * A confirmation dialog that intentionally omits the close icon. The user + * must explicitly choose "Cancel" or "Confirm" to make their intent clear, + * since it is not obvious what would happen when clicking a close icon. + */ +export const ConfirmDialog: Story = { + args: { + title: 'Confirm Action', + children: ( + <> + Confirm Action + + + + +

+ Are you sure you want to proceed? This action cannot be + undone. +

+ + Cancel + Confirm + +
+ + ), + }, +}; + +export const SmallSize: Story = { + ...Default, + args: { + ...Default.args, + children: , + }, +}; + +export const MediumSize: Story = { + ...Default, + args: { + ...Default.args, + children: , + }, +}; + +export const LargeSize: Story = { + ...Default, + args: { + ...Default.args, + children: , + }, +}; diff --git a/packages/ui/src/dialog/index.ts b/packages/ui/src/dialog/index.ts new file mode 100644 index 00000000000000..6e94d604b4630a --- /dev/null +++ b/packages/ui/src/dialog/index.ts @@ -0,0 +1,13 @@ +/** + * Internal dependencies + */ +import { Root } from './root'; +import { Trigger } from './trigger'; +import { Popup } from './popup'; +import { Header } from './header'; +import { Heading } from './heading'; +import { CloseIcon } from './close-icon'; +import { Action } from './action'; +import { Footer } from './footer'; + +export { Root, Trigger, Popup, Header, Heading, CloseIcon, Action, Footer }; diff --git a/packages/ui/src/dialog/popup.tsx b/packages/ui/src/dialog/popup.tsx new file mode 100644 index 00000000000000..c91ee94480e2f0 --- /dev/null +++ b/packages/ui/src/dialog/popup.tsx @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import { forwardRef, useContext } from 'react'; +import clsx from 'clsx'; +import { Dialog } from '@base-ui/react/dialog'; + +/** + * Internal dependencies + */ +import { type PopupProps } from './types'; +import styles from './style.module.css'; +import { DialogContext } from './context'; + +const Popup = forwardRef< HTMLDivElement, PopupProps >( function DialogPopup( + { className, size, children, ...props }, + ref +) { + const { title } = useContext( DialogContext ); + + return ( + + + + { children } + + + ); +} ); + +export { Popup }; diff --git a/packages/ui/src/dialog/root.tsx b/packages/ui/src/dialog/root.tsx new file mode 100644 index 00000000000000..eaf4ec961833c6 --- /dev/null +++ b/packages/ui/src/dialog/root.tsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import { Dialog } from '@base-ui/react/dialog'; +import { useMemo } from 'react'; + +/** + * Internal dependencies + */ +import { type RootProps } from './types'; +import { DialogContext } from './context'; + +function Root( { title, ...props }: RootProps ) { + const contextValue = useMemo( () => ( { title } ), [ title ] ); + + return ( + + + { props.children } + + + ); +} + +export { Root }; diff --git a/packages/ui/src/dialog/style.module.css b/packages/ui/src/dialog/style.module.css new file mode 100644 index 00000000000000..641430b99814b4 --- /dev/null +++ b/packages/ui/src/dialog/style.module.css @@ -0,0 +1,102 @@ +@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides; + +@layer wp-ui-components { + .backdrop { + position: fixed; + inset: 0; + background-color: rgba( 0, 0, 0, 0.35 ); + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + } + + &[data-open] { + opacity: 1; + } + + @media not ( prefers-reduced-motion ) { + transition: opacity 0.2s ease-out; + } + } + + .popup { + position: fixed; + top: 50%; + left: 50%; + transform: translate( -50%, -50% ); + min-width: 384px; + max-width: 90vw; + max-height: 90vh; + padding: calc( + 8 * var( --wpds-dimension-base ) + ); /* TODO: Use token: surface-lg? */ + background-color: var( --wpds-color-bg-surface-neutral-strong ); + border-radius: var( --wpds-border-radius-surface-lg ); + overflow: auto; + font-family: var( --wpds-font-family-body ); + font-size: var( --wpds-font-size-md ); + line-height: var( --wpds-font-line-height-md ); + color: var( --wpds-color-fg-content-neutral ); + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + transform: translate( -50%, -50% ) scale( 0.9 ); + } + + @media not ( prefers-reduced-motion ) { + transition: + opacity 0.2s cubic-bezier( 1, 0, 0.2, 1 ), + transform 0.2s cubic-bezier( 1, 0, 0.2, 1 ); + + &[data-open] { + transition: + opacity 0.2s cubic-bezier( 0.29, 0, 0, 1 ), + transform 0.2s cubic-bezier( 0.29, 0, 0, 1 ); + } + } + + &.is-small { + width: clamp( 384px, 90vw, 384px ); + } + + &.is-medium { + width: clamp( 384px, 90vw, 512px ); + } + + &.is-large { + width: clamp( 384px, 90vw, 840px ); + } + } + + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: calc( + 4 * var( --wpds-dimension-base ) + ); /* TODO: Use or create new gap token? */ + } + + .heading { + margin: 0; + font-size: var( --wpds-font-size-xl ); + font-weight: 590; + line-height: var( --wpds-font-line-height-xl ); + color: var( --wpds-color-fg-content-neutral ); + } + + .footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: calc( + 2 * var( --wpds-dimension-base ) + ); /* TODO: Use or create new gap token? (possibly control/interactive gap) */ + margin: calc( 4 * var( --wpds-dimension-base ) ) 0 0 0; /* TODO: Use or create new gap token? */ + padding-top: calc( + 4 * var( --wpds-dimension-base ) + ); /* TODO: Use or create new gap token? */ + } +} diff --git a/packages/ui/src/dialog/test/index.test.tsx b/packages/ui/src/dialog/test/index.test.tsx new file mode 100644 index 00000000000000..9a961ca04bc8b8 --- /dev/null +++ b/packages/ui/src/dialog/test/index.test.tsx @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; + +/** + * Internal dependencies + */ +import * as Dialog from '../index'; + +jest.setTimeout( 10000 ); + +describe( 'Dialog', () => { + it( 'forwards ref', async () => { + const user = userEvent.setup(); + const triggerRef = createRef< HTMLButtonElement >(); + const popupRef = createRef< HTMLDivElement >(); + const actionRef = createRef< HTMLButtonElement >(); + const headingRef = createRef< HTMLDivElement >(); + const closeIconRef = createRef< HTMLButtonElement >(); + const footerRef = createRef< HTMLDivElement >(); + + render( + + Open Dialog + + + + + + + Close + + + + ); + + // Test trigger ref before interaction + expect( triggerRef.current ).toBeInstanceOf( HTMLButtonElement ); + + // Click trigger to open dialog + await user.click( triggerRef.current! ); + + // Wait for the dialog to appear, at which point inner refs will be available + await waitFor( () => { + expect( popupRef.current ).toBeInstanceOf( HTMLDivElement ); + expect( headingRef.current ).toBeInstanceOf( HTMLHeadingElement ); + expect( closeIconRef.current ).toBeInstanceOf( HTMLButtonElement ); + expect( actionRef.current ).toBeInstanceOf( HTMLButtonElement ); + expect( footerRef.current ).toBeInstanceOf( HTMLDivElement ); + } ); + } ); +} ); diff --git a/packages/ui/src/dialog/trigger.tsx b/packages/ui/src/dialog/trigger.tsx new file mode 100644 index 00000000000000..a273f285538e7b --- /dev/null +++ b/packages/ui/src/dialog/trigger.tsx @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +import { forwardRef } from 'react'; +import { Dialog } from '@base-ui/react/dialog'; + +/** + * Internal dependencies + */ +import { type TriggerProps } from './types'; + +const Trigger = forwardRef< HTMLButtonElement, TriggerProps >( + function DialogTrigger( props, ref ) { + return ; + } +); + +export { Trigger }; diff --git a/packages/ui/src/dialog/types.ts b/packages/ui/src/dialog/types.ts new file mode 100644 index 00000000000000..2ef5c4958a935c --- /dev/null +++ b/packages/ui/src/dialog/types.ts @@ -0,0 +1,83 @@ +/** + * External dependencies + */ +import { type ReactNode } from 'react'; +import { type Dialog as BaseUIDialog } from '@base-ui/react/dialog'; + +/** + * Internal dependencies + */ +import type { ComponentProps, DistributiveOmit } from '../utils/types'; +import { type Button, type IconButton } from '..'; + +export interface RootProps + extends Pick< BaseUIDialog.Root.Props, 'open' | 'onOpenChange' > { + /** + * The title text for the dialog. This is required to be a string to ensure + * accessible labeling of the dialog. + */ + title: string; + + /** + * The content to be rendered inside the component. + */ + children?: ReactNode; +} + +export interface TriggerProps extends ComponentProps< 'button' > { + /** + * The content to be rendered inside the component. + */ + children?: ReactNode; +} + +export interface PopupProps extends ComponentProps< 'div' > { + /** + * The content to be rendered inside the component. + */ + children?: ReactNode; + + /** + * Renders the dialog at a preset width. + */ + size?: 'small' | 'medium' | 'large'; +} +export interface ActionProps extends ComponentProps< typeof Button > { + /** + * The content to be rendered inside the component. + */ + children?: ReactNode; +} + +export interface FooterProps extends ComponentProps< 'div' > { + /** + * The content to be rendered inside the component. + */ + children?: ReactNode; +} + +export interface HeaderProps extends ComponentProps< 'div' > { + /** + * The content to be rendered inside the component. + */ + children?: ReactNode; +} + +export type HeadingProps = ComponentProps< 'div' >; + +export interface CloseIconProps + extends DistributiveOmit< + ComponentProps< typeof IconButton >, + 'label' | 'icon' | 'loading' | 'loadingAnnouncement' + > { + /** + * A label describing the button's action, shown as a tooltip and to + * assistive technology. + */ + label?: ComponentProps< typeof IconButton >[ 'label' ]; + + /** + * The icon to display in the button. + */ + icon?: ComponentProps< typeof IconButton >[ 'icon' ]; +} From 7c04cc49032bb42419663f377208ffa62c88a103 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 3 Feb 2026 18:45:10 +0100 Subject: [PATCH 02/34] Fix import/exports --- packages/ui/src/dialog/action.tsx | 17 +++++------- packages/ui/src/dialog/close-icon.tsx | 23 ++++++---------- packages/ui/src/dialog/context.tsx | 5 +--- packages/ui/src/dialog/footer.tsx | 13 ++++----- packages/ui/src/dialog/header.tsx | 13 ++++----- packages/ui/src/dialog/heading.tsx | 15 +++++------ packages/ui/src/dialog/index.ts | 17 +++++------- packages/ui/src/dialog/popup.tsx | 27 +++++++++---------- packages/ui/src/dialog/root.tsx | 21 +++++++-------- .../index.story.tsx} | 9 +------ packages/ui/src/dialog/test/index.test.tsx | 9 +------ packages/ui/src/dialog/trigger.tsx | 14 ++++------ packages/ui/src/dialog/types.ts | 18 +++++-------- packages/ui/src/index.ts | 1 + 14 files changed, 75 insertions(+), 127 deletions(-) rename packages/ui/src/dialog/{index.stories.tsx => stories/index.story.tsx} (96%) diff --git a/packages/ui/src/dialog/action.tsx b/packages/ui/src/dialog/action.tsx index ca82b6cf908e95..f3ee2698f8e2b2 100644 --- a/packages/ui/src/dialog/action.tsx +++ b/packages/ui/src/dialog/action.tsx @@ -1,19 +1,16 @@ -/** - * External dependencies - */ -import { forwardRef } from 'react'; -import { Dialog } from '@base-ui/react/dialog'; +import { Dialog as _Dialog } from '@base-ui/react/dialog'; +import { forwardRef } from '@wordpress/element'; +import { Button } from '../button'; +import type { ActionProps } from './types'; /** - * Internal dependencies + * Renders a button that closes the dialog when clicked. + * Accepts all Button component props for styling. */ -import { type ActionProps } from './types'; -import { Button } from '../button'; - const Action = forwardRef< HTMLButtonElement, ActionProps >( function DialogAction( { render, ...props }, ref ) { return ( - } { ...props } diff --git a/packages/ui/src/dialog/close-icon.tsx b/packages/ui/src/dialog/close-icon.tsx index 2e546909e12f63..c82398678fcbb8 100644 --- a/packages/ui/src/dialog/close-icon.tsx +++ b/packages/ui/src/dialog/close-icon.tsx @@ -1,25 +1,18 @@ -/** - * External dependencies - */ -import { forwardRef } from 'react'; -import { Dialog } from '@base-ui/react/dialog'; - -/** - * WordPress dependencies - */ -import { close } from '@wordpress/icons'; +import { Dialog as _Dialog } from '@base-ui/react/dialog'; +import { forwardRef } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { close } from '@wordpress/icons'; +import { IconButton } from '../icon-button'; +import type { CloseIconProps } from './types'; /** - * Internal dependencies + * Renders an icon button that closes the dialog when clicked. + * Provides a default close icon and accessible label. */ -import { type CloseIconProps } from './types'; -import { IconButton } from '..'; - const CloseIcon = forwardRef< HTMLButtonElement, CloseIconProps >( function DialogCloseIcon( props, ref ) { return ( - ( function DialogFooter( { className, children, ...props }, ref diff --git a/packages/ui/src/dialog/header.tsx b/packages/ui/src/dialog/header.tsx index 274fed0260ac94..e0bd44551fdf34 100644 --- a/packages/ui/src/dialog/header.tsx +++ b/packages/ui/src/dialog/header.tsx @@ -1,15 +1,12 @@ -/** - * External dependencies - */ -import { forwardRef } from 'react'; import clsx from 'clsx'; +import { forwardRef } from '@wordpress/element'; +import styles from './style.module.css'; +import type { HeaderProps } from './types'; /** - * Internal dependencies + * Renders the header section of the dialog, typically containing + * the heading and close button. */ -import { type HeaderProps } from './types'; -import styles from './style.module.css'; - const Header = forwardRef< HTMLDivElement, HeaderProps >( function DialogHeader( { className, ...props }, ref diff --git a/packages/ui/src/dialog/heading.tsx b/packages/ui/src/dialog/heading.tsx index 7800699e7c6f05..14ffd44eeeaeaf 100644 --- a/packages/ui/src/dialog/heading.tsx +++ b/packages/ui/src/dialog/heading.tsx @@ -1,16 +1,13 @@ -/** - * External dependencies - */ -import { forwardRef, useContext } from 'react'; import { Dialog as _Dialog } from '@base-ui/react/dialog'; +import { forwardRef, useContext } from '@wordpress/element'; +import { DialogContext } from './context'; +import styles from './style.module.css'; +import type { HeadingProps } from './types'; /** - * Internal dependencies + * Renders the dialog title heading. The title text is provided + * via the `title` prop on `Dialog.Root`. */ -import { type HeadingProps } from './types'; -import styles from './style.module.css'; -import { DialogContext } from './context'; - const Heading = forwardRef< HTMLDivElement, HeadingProps >( function DialogHeading( props, ref ) { const { title } = useContext( DialogContext ); diff --git a/packages/ui/src/dialog/index.ts b/packages/ui/src/dialog/index.ts index 6e94d604b4630a..7e1a592bb2af0e 100644 --- a/packages/ui/src/dialog/index.ts +++ b/packages/ui/src/dialog/index.ts @@ -1,13 +1,10 @@ -/** - * Internal dependencies - */ -import { Root } from './root'; -import { Trigger } from './trigger'; -import { Popup } from './popup'; -import { Header } from './header'; -import { Heading } from './heading'; -import { CloseIcon } from './close-icon'; import { Action } from './action'; +import { CloseIcon } from './close-icon'; import { Footer } from './footer'; +import { Header } from './header'; +import { Heading } from './heading'; +import { Popup } from './popup'; +import { Root } from './root'; +import { Trigger } from './trigger'; -export { Root, Trigger, Popup, Header, Heading, CloseIcon, Action, Footer }; +export { Action, CloseIcon, Footer, Header, Heading, Popup, Root, Trigger }; diff --git a/packages/ui/src/dialog/popup.tsx b/packages/ui/src/dialog/popup.tsx index c91ee94480e2f0..dee907ec953e1a 100644 --- a/packages/ui/src/dialog/popup.tsx +++ b/packages/ui/src/dialog/popup.tsx @@ -1,17 +1,14 @@ -/** - * External dependencies - */ -import { forwardRef, useContext } from 'react'; +import { Dialog as _Dialog } from '@base-ui/react/dialog'; import clsx from 'clsx'; -import { Dialog } from '@base-ui/react/dialog'; +import { forwardRef, useContext } from '@wordpress/element'; +import { DialogContext } from './context'; +import styles from './style.module.css'; +import type { PopupProps } from './types'; /** - * Internal dependencies + * Renders the dialog popup element that contains the dialog content. + * Uses a portal to render outside the DOM hierarchy. */ -import { type PopupProps } from './types'; -import styles from './style.module.css'; -import { DialogContext } from './context'; - const Popup = forwardRef< HTMLDivElement, PopupProps >( function DialogPopup( { className, size, children, ...props }, ref @@ -19,9 +16,9 @@ const Popup = forwardRef< HTMLDivElement, PopupProps >( function DialogPopup( const { title } = useContext( DialogContext ); return ( - - - + <_Dialog.Backdrop className={ styles.backdrop } /> + <_Dialog.Popup ref={ ref } className={ clsx( styles.popup, @@ -33,8 +30,8 @@ const Popup = forwardRef< HTMLDivElement, PopupProps >( function DialogPopup( aria-label={ title } > { children } - - + + ); } ); diff --git a/packages/ui/src/dialog/root.tsx b/packages/ui/src/dialog/root.tsx index eaf4ec961833c6..f3a1adfa1e050f 100644 --- a/packages/ui/src/dialog/root.tsx +++ b/packages/ui/src/dialog/root.tsx @@ -1,24 +1,23 @@ -/** - * External dependencies - */ -import { Dialog } from '@base-ui/react/dialog'; -import { useMemo } from 'react'; +import { Dialog as _Dialog } from '@base-ui/react/dialog'; +import { useMemo } from '@wordpress/element'; +import { DialogContext } from './context'; +import type { RootProps } from './types'; /** - * Internal dependencies + * Groups the dialog trigger and popup. + * + * `Dialog` is a collection of React components that combine to render + * an ARIA-compliant dialog pattern. */ -import { type RootProps } from './types'; -import { DialogContext } from './context'; - function Root( { title, ...props }: RootProps ) { const contextValue = useMemo( () => ( { title } ), [ title ] ); return ( - + <_Dialog.Root { ...props }> { props.children } - + ); } diff --git a/packages/ui/src/dialog/index.stories.tsx b/packages/ui/src/dialog/stories/index.story.tsx similarity index 96% rename from packages/ui/src/dialog/index.stories.tsx rename to packages/ui/src/dialog/stories/index.story.tsx index 7cc29ea72b8eac..c009b16f252046 100644 --- a/packages/ui/src/dialog/index.stories.tsx +++ b/packages/ui/src/dialog/stories/index.story.tsx @@ -1,13 +1,6 @@ -/** - * External dependencies - */ import type { Meta, StoryObj } from '@storybook/react-vite'; import type { ComponentProps } from 'react'; - -/** - * Internal dependencies - */ -import { Dialog, CheckboxControl } from '..'; +import * as Dialog from '../index'; const meta: Meta< typeof Dialog.Root > = { title: 'Design System/Dialog', diff --git a/packages/ui/src/dialog/test/index.test.tsx b/packages/ui/src/dialog/test/index.test.tsx index 9a961ca04bc8b8..45eefa268edf11 100644 --- a/packages/ui/src/dialog/test/index.test.tsx +++ b/packages/ui/src/dialog/test/index.test.tsx @@ -1,13 +1,6 @@ -/** - * External dependencies - */ import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createRef } from 'react'; - -/** - * Internal dependencies - */ +import { createRef } from '@wordpress/element'; import * as Dialog from '../index'; jest.setTimeout( 10000 ); diff --git a/packages/ui/src/dialog/trigger.tsx b/packages/ui/src/dialog/trigger.tsx index a273f285538e7b..30a266c78110d8 100644 --- a/packages/ui/src/dialog/trigger.tsx +++ b/packages/ui/src/dialog/trigger.tsx @@ -1,17 +1,13 @@ -/** - * External dependencies - */ -import { forwardRef } from 'react'; -import { Dialog } from '@base-ui/react/dialog'; +import { Dialog as _Dialog } from '@base-ui/react/dialog'; +import { forwardRef } from '@wordpress/element'; +import type { TriggerProps } from './types'; /** - * Internal dependencies + * Renders a button that opens the dialog popup when clicked. */ -import { type TriggerProps } from './types'; - const Trigger = forwardRef< HTMLButtonElement, TriggerProps >( function DialogTrigger( props, ref ) { - return ; + return <_Dialog.Trigger ref={ ref } { ...props } />; } ); diff --git a/packages/ui/src/dialog/types.ts b/packages/ui/src/dialog/types.ts index 2ef5c4958a935c..7dc4b48603b624 100644 --- a/packages/ui/src/dialog/types.ts +++ b/packages/ui/src/dialog/types.ts @@ -1,17 +1,11 @@ -/** - * External dependencies - */ -import { type ReactNode } from 'react'; -import { type Dialog as BaseUIDialog } from '@base-ui/react/dialog'; - -/** - * Internal dependencies - */ -import type { ComponentProps, DistributiveOmit } from '../utils/types'; -import { type Button, type IconButton } from '..'; +import type { Dialog as _Dialog } from '@base-ui/react/dialog'; +import type { ReactNode } from 'react'; +import type { Button } from '../button'; +import type { IconButton } from '../icon-button'; +import type { ComponentProps } from '../utils/types'; export interface RootProps - extends Pick< BaseUIDialog.Root.Props, 'open' | 'onOpenChange' > { + extends Pick< _Dialog.Root.Props, 'open' | 'onOpenChange' > { /** * The title text for the dialog. This is required to be a string to ensure * accessible labeling of the dialog. diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index b303bd87834b9d..6a6f1e12018fae 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,5 +1,6 @@ export * from './badge'; export * from './button'; +export * as Dialog from './dialog'; export * from './form/primitives'; export * from './icon'; export * from './icon-button'; From 9f0b9de2df59179d102571130227b3fef9e9a45b Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 3 Feb 2026 18:45:33 +0100 Subject: [PATCH 03/34] Remove translation domains --- packages/ui/src/dialog/close-icon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/dialog/close-icon.tsx b/packages/ui/src/dialog/close-icon.tsx index c82398678fcbb8..97cf4eeb79e761 100644 --- a/packages/ui/src/dialog/close-icon.tsx +++ b/packages/ui/src/dialog/close-icon.tsx @@ -21,7 +21,7 @@ const CloseIcon = forwardRef< HTMLButtonElement, CloseIconProps >( tone="neutral" { ...props } icon={ close } - label={ __( 'Close', 'wpds' ) } + label={ __( 'Close' ) } /> } /> From 0dea466d78e4fa5f7846b48749161dbbf6321e3c Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 3 Feb 2026 18:46:49 +0100 Subject: [PATCH 04/34] Fix Storybook --- .../ui/src/dialog/stories/index.story.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/dialog/stories/index.story.tsx b/packages/ui/src/dialog/stories/index.story.tsx index c009b16f252046..ba7a0dd9819140 100644 --- a/packages/ui/src/dialog/stories/index.story.tsx +++ b/packages/ui/src/dialog/stories/index.story.tsx @@ -3,7 +3,7 @@ import type { ComponentProps } from 'react'; import * as Dialog from '../index'; const meta: Meta< typeof Dialog.Root > = { - title: 'Design System/Dialog', + title: 'Design System/Components/Dialog', component: Dialog.Root, subcomponents: { 'Dialog.Trigger': Dialog.Trigger, @@ -33,9 +33,17 @@ export default meta; type Story = StoryObj< typeof Dialog.Root >; +const ThemedParagraph = ( { children }: { children: React.ReactNode } ) => ( +

+ { children } +

+); + function DialogWithSize( { size, -}: Pick< ComponentProps< typeof Dialog.Popup >, 'size' > ) { +}: { + size?: ComponentProps< typeof Dialog.Popup >[ 'size' ]; +} ) { return ( <> Open Dialog @@ -44,12 +52,11 @@ function DialogWithSize( { -

+ This dialog demonstrates best practices for informational dialogs. It includes a close icon because dismissing it is safe and expected. -

- + Got it @@ -84,10 +91,10 @@ export const ConfirmDialog: Story = { -

+ Are you sure you want to proceed? This action cannot be undone. -

+ Cancel Confirm From 34fa54637b0be104fa2b533115ad7b1ff3d35e64 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 3 Feb 2026 18:46:58 +0100 Subject: [PATCH 05/34] Fix ESLint error in unit test --- packages/ui/src/dialog/test/index.test.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/dialog/test/index.test.tsx b/packages/ui/src/dialog/test/index.test.tsx index 45eefa268edf11..ea2e75a5f41039 100644 --- a/packages/ui/src/dialog/test/index.test.tsx +++ b/packages/ui/src/dialog/test/index.test.tsx @@ -36,13 +36,15 @@ describe( 'Dialog', () => { // Click trigger to open dialog await user.click( triggerRef.current! ); - // Wait for the dialog to appear, at which point inner refs will be available + // Wait for the dialog to appear await waitFor( () => { expect( popupRef.current ).toBeInstanceOf( HTMLDivElement ); - expect( headingRef.current ).toBeInstanceOf( HTMLHeadingElement ); - expect( closeIconRef.current ).toBeInstanceOf( HTMLButtonElement ); - expect( actionRef.current ).toBeInstanceOf( HTMLButtonElement ); - expect( footerRef.current ).toBeInstanceOf( HTMLDivElement ); } ); + + // Now that the dialog is open, verify all inner refs + expect( headingRef.current ).toBeInstanceOf( HTMLHeadingElement ); + expect( closeIconRef.current ).toBeInstanceOf( HTMLButtonElement ); + expect( actionRef.current ).toBeInstanceOf( HTMLButtonElement ); + expect( footerRef.current ).toBeInstanceOf( HTMLDivElement ); } ); } ); From c1b6961db646d095df4360d5d48f6e2764f31c9e Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 3 Feb 2026 18:47:16 +0100 Subject: [PATCH 06/34] Use regular Omit (DistributiveOmit is not necessary) --- packages/ui/src/dialog/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/dialog/types.ts b/packages/ui/src/dialog/types.ts index 7dc4b48603b624..aacc99b5b63b8d 100644 --- a/packages/ui/src/dialog/types.ts +++ b/packages/ui/src/dialog/types.ts @@ -60,7 +60,7 @@ export interface HeaderProps extends ComponentProps< 'div' > { export type HeadingProps = ComponentProps< 'div' >; export interface CloseIconProps - extends DistributiveOmit< + extends Omit< ComponentProps< typeof IconButton >, 'label' | 'icon' | 'loading' | 'loadingAnnouncement' > { From c027a103043064c184793555b68f5da6d9e30901 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 3 Feb 2026 18:48:54 +0100 Subject: [PATCH 07/34] Use semantic dimension tokens --- packages/ui/src/dialog/style.module.css | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/dialog/style.module.css b/packages/ui/src/dialog/style.module.css index 641430b99814b4..304e0cfe5f5bb1 100644 --- a/packages/ui/src/dialog/style.module.css +++ b/packages/ui/src/dialog/style.module.css @@ -28,11 +28,9 @@ min-width: 384px; max-width: 90vw; max-height: 90vh; - padding: calc( - 8 * var( --wpds-dimension-base ) - ); /* TODO: Use token: surface-lg? */ background-color: var( --wpds-color-bg-surface-neutral-strong ); border-radius: var( --wpds-border-radius-surface-lg ); + padding: var(--wpds-dimension-padding-3xl); overflow: auto; font-family: var( --wpds-font-family-body ); font-size: var( --wpds-font-size-md ); @@ -74,9 +72,7 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: calc( - 4 * var( --wpds-dimension-base ) - ); /* TODO: Use or create new gap token? */ + margin-bottom: var(--wpds-dimension-gap-lg); } .heading { @@ -91,12 +87,8 @@ display: flex; justify-content: flex-end; align-items: center; - gap: calc( - 2 * var( --wpds-dimension-base ) - ); /* TODO: Use or create new gap token? (possibly control/interactive gap) */ - margin: calc( 4 * var( --wpds-dimension-base ) ) 0 0 0; /* TODO: Use or create new gap token? */ - padding-top: calc( - 4 * var( --wpds-dimension-base ) - ); /* TODO: Use or create new gap token? */ + gap: var(--wpds-dimension-gap-sm); + margin-top: var(--wpds-dimension-gap-lg); + padding-top: var(--wpds-dimension-padding-lg); } } From 68477afd2e12f07bf1ebe1e7b6458e544c379559 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 3 Feb 2026 18:49:33 +0100 Subject: [PATCH 08/34] Update tokens with the correct ones --- packages/ui/src/dialog/style.module.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/dialog/style.module.css b/packages/ui/src/dialog/style.module.css index 304e0cfe5f5bb1..caef9b9a3e0b70 100644 --- a/packages/ui/src/dialog/style.module.css +++ b/packages/ui/src/dialog/style.module.css @@ -28,9 +28,9 @@ min-width: 384px; max-width: 90vw; max-height: 90vh; - background-color: var( --wpds-color-bg-surface-neutral-strong ); - border-radius: var( --wpds-border-radius-surface-lg ); padding: var(--wpds-dimension-padding-3xl); + background-color: var(--wpds-color-bg-surface-neutral-strong); + border-radius: var(--wpds-border-radius-lg); overflow: auto; font-family: var( --wpds-font-family-body ); font-size: var( --wpds-font-size-md ); From 98a1c6f4b85e09e97d15dbad016cae551df75570 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 3 Feb 2026 18:49:46 +0100 Subject: [PATCH 09/34] Format and other lint fixes --- packages/ui/src/dialog/style.module.css | 39 +++++++++++++------------ 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/dialog/style.module.css b/packages/ui/src/dialog/style.module.css index caef9b9a3e0b70..c8767eb3dc518b 100644 --- a/packages/ui/src/dialog/style.module.css +++ b/packages/ui/src/dialog/style.module.css @@ -4,7 +4,7 @@ .backdrop { position: fixed; inset: 0; - background-color: rgba( 0, 0, 0, 0.35 ); + background-color: rgba(0, 0, 0, 0.35); &[data-starting-style], &[data-ending-style] { @@ -15,7 +15,7 @@ opacity: 1; } - @media not ( prefers-reduced-motion ) { + @media not (prefers-reduced-motion) { transition: opacity 0.2s ease-out; } } @@ -23,8 +23,9 @@ .popup { position: fixed; top: 50%; + /* stylelint-disable-next-line plugin/use-logical-properties-and-values -- Physical centering technique used with transform: translate(-50%, -50%) */ left: 50%; - transform: translate( -50%, -50% ); + transform: translate(-50%, -50%); min-width: 384px; max-width: 90vw; max-height: 90vh; @@ -32,39 +33,39 @@ background-color: var(--wpds-color-bg-surface-neutral-strong); border-radius: var(--wpds-border-radius-lg); overflow: auto; - font-family: var( --wpds-font-family-body ); - font-size: var( --wpds-font-size-md ); - line-height: var( --wpds-font-line-height-md ); - color: var( --wpds-color-fg-content-neutral ); + font-family: var(--wpds-font-family-body); + font-size: var(--wpds-font-size-md); + line-height: var(--wpds-font-line-height-md); + color: var(--wpds-color-fg-content-neutral); &[data-starting-style], &[data-ending-style] { opacity: 0; - transform: translate( -50%, -50% ) scale( 0.9 ); + transform: translate(-50%, -50%) scale(0.9); } - @media not ( prefers-reduced-motion ) { + @media not (prefers-reduced-motion) { transition: - opacity 0.2s cubic-bezier( 1, 0, 0.2, 1 ), - transform 0.2s cubic-bezier( 1, 0, 0.2, 1 ); + opacity 0.2s cubic-bezier(1, 0, 0.2, 1), + transform 0.2s cubic-bezier(1, 0, 0.2, 1); &[data-open] { transition: - opacity 0.2s cubic-bezier( 0.29, 0, 0, 1 ), - transform 0.2s cubic-bezier( 0.29, 0, 0, 1 ); + opacity 0.2s cubic-bezier(0.29, 0, 0, 1), + transform 0.2s cubic-bezier(0.29, 0, 0, 1); } } &.is-small { - width: clamp( 384px, 90vw, 384px ); + width: clamp(384px, 90vw, 384px); } &.is-medium { - width: clamp( 384px, 90vw, 512px ); + width: clamp(384px, 90vw, 512px); } &.is-large { - width: clamp( 384px, 90vw, 840px ); + width: clamp(384px, 90vw, 840px); } } @@ -77,10 +78,10 @@ .heading { margin: 0; - font-size: var( --wpds-font-size-xl ); + font-size: var(--wpds-font-size-xl); font-weight: 590; - line-height: var( --wpds-font-line-height-xl ); - color: var( --wpds-color-fg-content-neutral ); + line-height: var(--wpds-font-line-height-xl); + color: var(--wpds-color-fg-content-neutral); } .footer { From c02e254261f0dc7e229feadb11e4e50673138c6b Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 3 Feb 2026 18:50:49 +0100 Subject: [PATCH 10/34] CHANGELOG --- packages/ui/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index bf7b8567508cd6..b9f5be24393d2b 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -8,6 +8,7 @@ ### New Features +- Add `Dialog` primitive ([#75183](https://github.com/WordPress/gutenberg/pull/75183)). - Add `Tabs` primitive ([#74652](https://github.com/WordPress/gutenberg/pull/74652)). - Add `Textarea` primitive ([#74707](https://github.com/WordPress/gutenberg/pull/74707)). From 14cddb1dbe727bee2fe8eb7f0bf2470f3b20070c Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 4 Feb 2026 10:52:49 +0100 Subject: [PATCH 11/34] Dialog: Fix CloseIcon prop customization Destructure `icon` and `label` props and apply defaults via nullish coalescing instead of spreading props before hardcoded values. This fixes the API contract where these props were typed as optional but functionally ignored because hardcoded values always overwrote them. Co-authored-by: Cursor --- packages/ui/src/dialog/close-icon.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/dialog/close-icon.tsx b/packages/ui/src/dialog/close-icon.tsx index 97cf4eeb79e761..b70b1f0b114227 100644 --- a/packages/ui/src/dialog/close-icon.tsx +++ b/packages/ui/src/dialog/close-icon.tsx @@ -10,7 +10,7 @@ import type { CloseIconProps } from './types'; * Provides a default close icon and accessible label. */ const CloseIcon = forwardRef< HTMLButtonElement, CloseIconProps >( - function DialogCloseIcon( props, ref ) { + function DialogCloseIcon( { icon, label, ...props }, ref ) { return ( <_Dialog.Close ref={ ref } @@ -20,8 +20,8 @@ const CloseIcon = forwardRef< HTMLButtonElement, CloseIconProps >( size="compact" tone="neutral" { ...props } - icon={ close } - label={ __( 'Close' ) } + icon={ icon ?? close } + label={ label ?? __( 'Close' ) } /> } /> From b2fc44074ebcf01a7406ae825322390b3937a9b2 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 4 Feb 2026 10:54:26 +0100 Subject: [PATCH 12/34] Dialog: Add useRender pattern to Header and Footer Refactor Header and Footer components to use Base UI's useRender and mergeProps utilities, aligning with the package's documented design principles. This enables consumers to customize the underlying HTML element via the `render` prop (e.g., render Header as `
` instead of `
`), and ensures proper className and ref merging. Co-authored-by: Cursor --- packages/ui/src/dialog/footer.tsx | 21 +++++++++++---------- packages/ui/src/dialog/header.tsx | 19 +++++++++++-------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/dialog/footer.tsx b/packages/ui/src/dialog/footer.tsx index 67f5ac1471fb5e..98064c61a9af6b 100644 --- a/packages/ui/src/dialog/footer.tsx +++ b/packages/ui/src/dialog/footer.tsx @@ -1,3 +1,4 @@ +import { mergeProps, useRender } from '@base-ui/react'; import clsx from 'clsx'; import { forwardRef } from '@wordpress/element'; import styles from './style.module.css'; @@ -8,18 +9,18 @@ import type { FooterProps } from './types'; * action buttons. */ const Footer = forwardRef< HTMLDivElement, FooterProps >( function DialogFooter( - { className, children, ...props }, + { className, render, ...props }, ref ) { - return ( -
- { children } -
- ); + const element = useRender( { + render, + ref, + props: mergeProps< 'div' >( props, { + className: clsx( styles.footer, className ), + } ), + } ); + + return element; } ); export { Footer }; diff --git a/packages/ui/src/dialog/header.tsx b/packages/ui/src/dialog/header.tsx index e0bd44551fdf34..2e1b665fa33659 100644 --- a/packages/ui/src/dialog/header.tsx +++ b/packages/ui/src/dialog/header.tsx @@ -1,3 +1,4 @@ +import { mergeProps, useRender } from '@base-ui/react'; import clsx from 'clsx'; import { forwardRef } from '@wordpress/element'; import styles from './style.module.css'; @@ -8,16 +9,18 @@ import type { HeaderProps } from './types'; * the heading and close button. */ const Header = forwardRef< HTMLDivElement, HeaderProps >( function DialogHeader( - { className, ...props }, + { className, render, ...props }, ref ) { - return ( -
- ); + const element = useRender( { + render, + ref, + props: mergeProps< 'div' >( props, { + className: clsx( styles.header, className ), + } ), + } ); + + return element; } ); export { Header }; From 7ebb22181b6b656560242a7b2a378661a4f21fcf Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 4 Feb 2026 10:55:02 +0100 Subject: [PATCH 13/34] Dialog: Remove redundant ThemedParagraph from stories Remove the ThemedParagraph wrapper component that explicitly set text color. This styling is redundant because Dialog.Popup already applies `color: var(--wpds-color-fg-content-neutral)` to all content. Co-authored-by: Cursor --- packages/ui/src/dialog/stories/index.story.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/dialog/stories/index.story.tsx b/packages/ui/src/dialog/stories/index.story.tsx index ba7a0dd9819140..9e9b4437906c58 100644 --- a/packages/ui/src/dialog/stories/index.story.tsx +++ b/packages/ui/src/dialog/stories/index.story.tsx @@ -33,12 +33,6 @@ export default meta; type Story = StoryObj< typeof Dialog.Root >; -const ThemedParagraph = ( { children }: { children: React.ReactNode } ) => ( -

- { children } -

-); - function DialogWithSize( { size, }: { @@ -52,11 +46,11 @@ function DialogWithSize( { - +

This dialog demonstrates best practices for informational dialogs. It includes a close icon because dismissing it is safe and expected. - +

Got it @@ -91,10 +85,10 @@ export const ConfirmDialog: Story = { - +

Are you sure you want to proceed? This action cannot be undone. - +

Cancel Confirm From 410f4a7863c6dcd9d9e7ceda76c8a9a18f8e56bf Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 4 Feb 2026 10:55:32 +0100 Subject: [PATCH 14/34] Dialog: Remove unnecessary jest.setTimeout from tests Remove the extended 10 second timeout as the test completes in under 200ms, well within Jest's default 5 second timeout. Co-authored-by: Cursor --- packages/ui/src/dialog/test/index.test.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ui/src/dialog/test/index.test.tsx b/packages/ui/src/dialog/test/index.test.tsx index ea2e75a5f41039..a36b5525cd6ed4 100644 --- a/packages/ui/src/dialog/test/index.test.tsx +++ b/packages/ui/src/dialog/test/index.test.tsx @@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event'; import { createRef } from '@wordpress/element'; import * as Dialog from '../index'; -jest.setTimeout( 10000 ); - describe( 'Dialog', () => { it( 'forwards ref', async () => { const user = userEvent.setup(); From 29edc72dc2e779168552e0ccd7d845baab8df349 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 4 Feb 2026 10:53:13 +0100 Subject: [PATCH 15/34] Dialog: rework responsive sizing --- packages/ui/src/dialog/style.module.css | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/dialog/style.module.css b/packages/ui/src/dialog/style.module.css index c8767eb3dc518b..da6e76ce2e68b2 100644 --- a/packages/ui/src/dialog/style.module.css +++ b/packages/ui/src/dialog/style.module.css @@ -26,9 +26,10 @@ /* stylelint-disable-next-line plugin/use-logical-properties-and-values -- Physical centering technique used with transform: translate(-50%, -50%) */ left: 50%; transform: translate(-50%, -50%); - min-width: 384px; - max-width: 90vw; - max-height: 90vh; + box-sizing: border-box; + min-width: 320px; + width: calc(100vw - 2 * var(--wpds-dimension-padding-xl)); + max-height: calc(100vh - 2 * var(--wpds-dimension-padding-xl)); padding: var(--wpds-dimension-padding-3xl); background-color: var(--wpds-color-bg-surface-neutral-strong); border-radius: var(--wpds-border-radius-lg); @@ -57,15 +58,15 @@ } &.is-small { - width: clamp(384px, 90vw, 384px); + max-width: 384px; } &.is-medium { - width: clamp(384px, 90vw, 512px); + max-width: 512px; } &.is-large { - width: clamp(384px, 90vw, 840px); + max-width: 840px; } } From 47ed0d9d4685c1ca84355c7fedcac5969942ce69 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 4 Feb 2026 12:26:09 +0100 Subject: [PATCH 16/34] Add "full" size option --- packages/ui/src/dialog/stories/index.story.tsx | 8 ++++++++ packages/ui/src/dialog/style.module.css | 8 ++++++++ packages/ui/src/dialog/types.ts | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/dialog/stories/index.story.tsx b/packages/ui/src/dialog/stories/index.story.tsx index 9e9b4437906c58..8878bd49821447 100644 --- a/packages/ui/src/dialog/stories/index.story.tsx +++ b/packages/ui/src/dialog/stories/index.story.tsx @@ -122,3 +122,11 @@ export const LargeSize: Story = { children: , }, }; + +export const FullSize: Story = { + ...Default, + args: { + ...Default.args, + children: , + }, +}; diff --git a/packages/ui/src/dialog/style.module.css b/packages/ui/src/dialog/style.module.css index da6e76ce2e68b2..b45b1584a09ff5 100644 --- a/packages/ui/src/dialog/style.module.css +++ b/packages/ui/src/dialog/style.module.css @@ -68,6 +68,14 @@ &.is-large { max-width: 840px; } + + &.is-full { + /* + * Force full height (full width is already in default styles). + * The max-{width,height} properties will make sure some padding is shown. + */ + height: 100vh; + } } .header { diff --git a/packages/ui/src/dialog/types.ts b/packages/ui/src/dialog/types.ts index aacc99b5b63b8d..e0782fee9dc7dc 100644 --- a/packages/ui/src/dialog/types.ts +++ b/packages/ui/src/dialog/types.ts @@ -34,7 +34,7 @@ export interface PopupProps extends ComponentProps< 'div' > { /** * Renders the dialog at a preset width. */ - size?: 'small' | 'medium' | 'large'; + size?: 'small' | 'medium' | 'large' | 'full'; } export interface ActionProps extends ComponentProps< typeof Button > { /** From b1d5513cc0d213fcc3f0f46738b074c752f2c925 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 4 Feb 2026 12:26:16 +0100 Subject: [PATCH 17/34] Add defaultOpen and modal props --- packages/ui/src/dialog/types.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/dialog/types.ts b/packages/ui/src/dialog/types.ts index e0782fee9dc7dc..f89e86f32fc624 100644 --- a/packages/ui/src/dialog/types.ts +++ b/packages/ui/src/dialog/types.ts @@ -5,7 +5,10 @@ import type { IconButton } from '../icon-button'; import type { ComponentProps } from '../utils/types'; export interface RootProps - extends Pick< _Dialog.Root.Props, 'open' | 'onOpenChange' > { + extends Pick< + _Dialog.Root.Props, + 'open' | 'onOpenChange' | 'defaultOpen' | 'modal' + > { /** * The title text for the dialog. This is required to be a string to ensure * accessible labeling of the dialog. From a33b9a7f667a11006fce32779990ba68ef1dfbbe Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 4 Feb 2026 13:26:42 +0100 Subject: [PATCH 18/34] Dialog: Replace title prop with Dialog.Title component Refactor the Dialog API to mirror Base UI's native title handling: - Remove the `title` prop from `Dialog.Root` - Add `Dialog.Title` component that wraps Base UI's Dialog.Title - Rename `Dialog.Heading` to `Dialog.Title` for clarity - Add runtime validation that throws if `Dialog.Title` is missing This change provides several benefits: - Single source of truth: the visible title IS the accessible label - Works naturally with Base UI's automatic aria-labelledby handling - More flexible: consumers can include icons, badges, or complex content - Runtime check catches missing titles during development The Title component now accepts children directly: ```jsx My Dialog Title ``` Advanced use cases (visually hidden title, custom visual heading) are still supported through standard patterns like VisuallyHidden. The runtime validation: - Uses build-time constant for complete tree-shaking in production - Throws an error (not warning) since unlabelled dialogs violate a11y - Includes guidance that title can be visually hidden but not omitted Co-authored-by: Cursor --- packages/ui/src/dialog/context.tsx | 104 +++++++++++++++++- packages/ui/src/dialog/heading.tsx | 27 ----- packages/ui/src/dialog/index.ts | 4 +- packages/ui/src/dialog/popup.tsx | 12 +- packages/ui/src/dialog/root.tsx | 14 +-- .../ui/src/dialog/stories/index.story.tsx | 23 ++-- packages/ui/src/dialog/style.module.css | 2 +- packages/ui/src/dialog/test/index.test.tsx | 98 ++++++++++++++++- packages/ui/src/dialog/title.tsx | 36 ++++++ packages/ui/src/dialog/types.ts | 14 +-- 10 files changed, 252 insertions(+), 82 deletions(-) delete mode 100644 packages/ui/src/dialog/heading.tsx create mode 100644 packages/ui/src/dialog/title.tsx diff --git a/packages/ui/src/dialog/context.tsx b/packages/ui/src/dialog/context.tsx index 2a218adb1cee4a..5be083ec816f42 100644 --- a/packages/ui/src/dialog/context.tsx +++ b/packages/ui/src/dialog/context.tsx @@ -1,9 +1,103 @@ -import { createContext } from '@wordpress/element'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from '@wordpress/element'; -interface DialogContextValue { - title?: string; +/** + * Whether validation is enabled. This is a build-time constant that allows + * bundlers to tree-shake all validation code in production builds. + */ +const VALIDATION_ENABLED = process.env.NODE_ENV !== 'production'; + +type DialogValidationContextType = { + registerTitle: () => void; +}; + +// Context is only created in development mode. +const DialogValidationContext = VALIDATION_ENABLED + ? createContext< DialogValidationContextType | null >( null ) + : ( null as unknown as React.Context< DialogValidationContextType | null > ); + +/** + * Development-only hook to access the dialog validation context. + */ +function useDialogValidationContextDev() { + return useContext( DialogValidationContext ); } -const DialogContext = createContext< DialogContextValue >( {} ); +/** + * Production no-op hook. + */ +function useDialogValidationContextProd() { + return null; +} + +/** + * Hook to access the dialog validation context. + * Returns null in production or if not within a Dialog.Popup. + */ +export const useDialogValidationContext = VALIDATION_ENABLED + ? useDialogValidationContextDev + : useDialogValidationContextProd; + +/** + * Development-only provider that tracks whether Dialog.Title is rendered. + */ +function DialogValidationProviderDev( { + children, +}: { + children: React.ReactNode; +} ) { + const titleRegisteredRef = useRef( false ); + + const registerTitle = useCallback( () => { + titleRegisteredRef.current = true; + }, [] ); + + const contextValue = useMemo( + () => ( { registerTitle } ), + [ registerTitle ] + ); + + // Validate that Dialog.Title is rendered + useEffect( () => { + // useLayoutEffect in Title runs before this useEffect, + // so titleRegisteredRef should already be set if Title is present + if ( ! titleRegisteredRef.current ) { + throw new Error( + 'Dialog: Missing . ' + + 'For accessibility, every dialog requires a title. ' + + 'If needed, the title can be visually hidden but must not be omitted.' + ); + } + }, [] ); + + return ( + + { children } + + ); +} + +/** + * Production no-op provider that just renders children. + */ +function DialogValidationProviderProd( { + children, +}: { + children: React.ReactNode; +} ) { + return <>{ children }; +} -export { DialogContext }; +/** + * Provider component that validates Dialog.Title presence in development mode. + * In production, this component is a no-op and just renders children. + */ +export const DialogValidationProvider = VALIDATION_ENABLED + ? DialogValidationProviderDev + : DialogValidationProviderProd; diff --git a/packages/ui/src/dialog/heading.tsx b/packages/ui/src/dialog/heading.tsx deleted file mode 100644 index 14ffd44eeeaeaf..00000000000000 --- a/packages/ui/src/dialog/heading.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Dialog as _Dialog } from '@base-ui/react/dialog'; -import { forwardRef, useContext } from '@wordpress/element'; -import { DialogContext } from './context'; -import styles from './style.module.css'; -import type { HeadingProps } from './types'; - -/** - * Renders the dialog title heading. The title text is provided - * via the `title` prop on `Dialog.Root`. - */ -const Heading = forwardRef< HTMLDivElement, HeadingProps >( - function DialogHeading( props, ref ) { - const { title } = useContext( DialogContext ); - - return ( - <_Dialog.Title - ref={ ref } - className={ styles.heading } - { ...props } - > - { title } - - ); - } -); - -export { Heading }; diff --git a/packages/ui/src/dialog/index.ts b/packages/ui/src/dialog/index.ts index 7e1a592bb2af0e..22f7bae4e66e9b 100644 --- a/packages/ui/src/dialog/index.ts +++ b/packages/ui/src/dialog/index.ts @@ -2,9 +2,9 @@ import { Action } from './action'; import { CloseIcon } from './close-icon'; import { Footer } from './footer'; import { Header } from './header'; -import { Heading } from './heading'; import { Popup } from './popup'; import { Root } from './root'; +import { Title } from './title'; import { Trigger } from './trigger'; -export { Action, CloseIcon, Footer, Header, Heading, Popup, Root, Trigger }; +export { Action, CloseIcon, Footer, Header, Popup, Root, Title, Trigger }; diff --git a/packages/ui/src/dialog/popup.tsx b/packages/ui/src/dialog/popup.tsx index dee907ec953e1a..000c7227fa64d8 100644 --- a/packages/ui/src/dialog/popup.tsx +++ b/packages/ui/src/dialog/popup.tsx @@ -1,7 +1,7 @@ import { Dialog as _Dialog } from '@base-ui/react/dialog'; import clsx from 'clsx'; -import { forwardRef, useContext } from '@wordpress/element'; -import { DialogContext } from './context'; +import { forwardRef } from '@wordpress/element'; +import { DialogValidationProvider } from './context'; import styles from './style.module.css'; import type { PopupProps } from './types'; @@ -13,8 +13,6 @@ const Popup = forwardRef< HTMLDivElement, PopupProps >( function DialogPopup( { className, size, children, ...props }, ref ) { - const { title } = useContext( DialogContext ); - return ( <_Dialog.Portal> <_Dialog.Backdrop className={ styles.backdrop } /> @@ -26,10 +24,10 @@ const Popup = forwardRef< HTMLDivElement, PopupProps >( function DialogPopup( size && styles[ `is-${ size }` ] ) } { ...props } - aria-labelledby={ undefined } - aria-label={ title } > - { children } + + { children } + ); diff --git a/packages/ui/src/dialog/root.tsx b/packages/ui/src/dialog/root.tsx index f3a1adfa1e050f..bd5acb96390902 100644 --- a/packages/ui/src/dialog/root.tsx +++ b/packages/ui/src/dialog/root.tsx @@ -1,6 +1,4 @@ import { Dialog as _Dialog } from '@base-ui/react/dialog'; -import { useMemo } from '@wordpress/element'; -import { DialogContext } from './context'; import type { RootProps } from './types'; /** @@ -9,16 +7,8 @@ import type { RootProps } from './types'; * `Dialog` is a collection of React components that combine to render * an ARIA-compliant dialog pattern. */ -function Root( { title, ...props }: RootProps ) { - const contextValue = useMemo( () => ( { title } ), [ title ] ); - - return ( - <_Dialog.Root { ...props }> - - { props.children } - - - ); +function Root( props: RootProps ) { + return <_Dialog.Root { ...props } />; } export { Root }; diff --git a/packages/ui/src/dialog/stories/index.story.tsx b/packages/ui/src/dialog/stories/index.story.tsx index 8878bd49821447..3aced71ab64892 100644 --- a/packages/ui/src/dialog/stories/index.story.tsx +++ b/packages/ui/src/dialog/stories/index.story.tsx @@ -9,18 +9,17 @@ const meta: Meta< typeof Dialog.Root > = { 'Dialog.Trigger': Dialog.Trigger, 'Dialog.Popup': Dialog.Popup, 'Dialog.Header': Dialog.Header, - 'Dialog.Heading': Dialog.Heading, + 'Dialog.Title': Dialog.Title, 'Dialog.CloseIcon': Dialog.CloseIcon, 'Dialog.Action': Dialog.Action, 'Dialog.Footer': Dialog.Footer, }, - args: { - title: 'Dialog Title', - }, parameters: { docs: { description: { component: ` +Dialog is a popup that opens on top of the entire page. Every dialog must include a \`Dialog.Title\` component for accessibility — it serves as both the visible heading and the accessible label for the dialog. + When using the Dialog component, make sure to always include a visible close button, either \`Dialog.CloseIcon\` or a clear dismissing action button. If your dialog has a "Cancel" button in the footer, the close icon may be redundant and create confusion about what clicking "X" means. Use \`Dialog.CloseIcon\` for informational dialogs where dismissing is safe and expected. For dialogs requiring explicit user choice (especially destructive actions), omit the close icon and rely on footer action buttons like "Cancel" and "Confirm" instead. @@ -35,15 +34,17 @@ type Story = StoryObj< typeof Dialog.Root >; function DialogWithSize( { size, + title = 'Welcome', }: { size?: ComponentProps< typeof Dialog.Popup >[ 'size' ]; + title?: string; } ) { return ( <> Open Dialog - + { title }

@@ -65,7 +66,6 @@ function DialogWithSize( { */ export const Default: Story = { args: { - title: 'Welcome', children: , }, }; @@ -77,13 +77,12 @@ export const Default: Story = { */ export const ConfirmDialog: Story = { args: { - title: 'Confirm Action', children: ( <> Confirm Action - + Confirm Action

Are you sure you want to proceed? This action cannot be @@ -100,33 +99,25 @@ export const ConfirmDialog: Story = { }; export const SmallSize: Story = { - ...Default, args: { - ...Default.args, children: , }, }; export const MediumSize: Story = { - ...Default, args: { - ...Default.args, children: , }, }; export const LargeSize: Story = { - ...Default, args: { - ...Default.args, children: , }, }; export const FullSize: Story = { - ...Default, args: { - ...Default.args, children: , }, }; diff --git a/packages/ui/src/dialog/style.module.css b/packages/ui/src/dialog/style.module.css index b45b1584a09ff5..4569632617f3a1 100644 --- a/packages/ui/src/dialog/style.module.css +++ b/packages/ui/src/dialog/style.module.css @@ -85,7 +85,7 @@ margin-bottom: var(--wpds-dimension-gap-lg); } - .heading { + .title { margin: 0; font-size: var(--wpds-font-size-xl); font-weight: 590; diff --git a/packages/ui/src/dialog/test/index.test.tsx b/packages/ui/src/dialog/test/index.test.tsx index a36b5525cd6ed4..cb9c187fb6d8fc 100644 --- a/packages/ui/src/dialog/test/index.test.tsx +++ b/packages/ui/src/dialog/test/index.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createRef } from '@wordpress/element'; import * as Dialog from '../index'; @@ -9,16 +9,18 @@ describe( 'Dialog', () => { const triggerRef = createRef< HTMLButtonElement >(); const popupRef = createRef< HTMLDivElement >(); const actionRef = createRef< HTMLButtonElement >(); - const headingRef = createRef< HTMLDivElement >(); + const titleRef = createRef< HTMLHeadingElement >(); const closeIconRef = createRef< HTMLButtonElement >(); const footerRef = createRef< HTMLDivElement >(); render( - + Open Dialog - + + Test Dialog + @@ -40,9 +42,95 @@ describe( 'Dialog', () => { } ); // Now that the dialog is open, verify all inner refs - expect( headingRef.current ).toBeInstanceOf( HTMLHeadingElement ); + expect( titleRef.current ).toBeInstanceOf( HTMLHeadingElement ); expect( closeIconRef.current ).toBeInstanceOf( HTMLButtonElement ); expect( actionRef.current ).toBeInstanceOf( HTMLButtonElement ); expect( footerRef.current ).toBeInstanceOf( HTMLDivElement ); } ); + + describe( 'Development mode validation', () => { + // Suppress React's error boundary logging for these tests. + let originalConsoleError: typeof console.error; + + beforeEach( () => { + // eslint-disable-next-line no-console + originalConsoleError = console.error; + // eslint-disable-next-line no-console + console.error = jest.fn(); + } ); + + afterEach( () => { + // eslint-disable-next-line no-console + console.error = originalConsoleError; + } ); + + it( 'should throw when Dialog.Title is missing', async () => { + const user = userEvent.setup(); + + render( + + Open Dialog + + + { /* Missing Dialog.Title */ } + +

Content without a title

+ + Close + +
+ + ); + + // Open the dialog - this will trigger the error in useEffect + await expect( async () => { + await user.click( + screen.getByRole( 'button', { name: 'Open Dialog' } ) + ); + // Wait for effects to run + await waitFor( () => { + // This will throw due to React error boundary + } ); + } ).rejects.toThrow( + 'Dialog: Missing . ' + + 'For accessibility, every dialog requires a title. ' + + 'If needed, the title can be visually hidden but must not be omitted.' + ); + } ); + + it( 'should not throw when Dialog.Title is present', async () => { + const user = userEvent.setup(); + + render( + + Open Dialog + + + My Title + +

Content with a title

+ + Close + +
+
+ ); + + // Open the dialog - should not throw + await user.click( + screen.getByRole( 'button', { name: 'Open Dialog' } ) + ); + + // Wait for the dialog to appear and validation to run + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); + + // Wait a bit more to ensure validation has run without errors + await new Promise( ( resolve ) => setTimeout( resolve, 50 ) ); + + // If we got here without throwing, the test passes + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); + } ); } ); diff --git a/packages/ui/src/dialog/title.tsx b/packages/ui/src/dialog/title.tsx new file mode 100644 index 00000000000000..592c81bc9d3713 --- /dev/null +++ b/packages/ui/src/dialog/title.tsx @@ -0,0 +1,36 @@ +import { Dialog as _Dialog } from '@base-ui/react/dialog'; +import clsx from 'clsx'; +import { forwardRef, useLayoutEffect } from '@wordpress/element'; +import { useDialogValidationContext } from './context'; +import styles from './style.module.css'; +import type { TitleProps } from './types'; + +/** + * Renders the dialog title. This component is required for accessibility + * and serves as both the visible heading and the accessible label for + * the dialog. + * + * Base UI's Dialog.Title renders an `

` by default. Use the `render` prop + * to customize the element if needed. + */ +const Title = forwardRef< HTMLHeadingElement, TitleProps >( + function DialogTitle( { className, render, ...props }, ref ) { + const validationContext = useDialogValidationContext(); + + // Register this title with the parent Popup for validation (dev only) + useLayoutEffect( () => { + validationContext?.registerTitle(); + }, [ validationContext ] ); + + return ( + <_Dialog.Title + ref={ ref } + className={ clsx( styles.title, className ) } + render={ render } + { ...props } + /> + ); + } +); + +export { Title }; diff --git a/packages/ui/src/dialog/types.ts b/packages/ui/src/dialog/types.ts index f89e86f32fc624..3d9ec739ba8f6a 100644 --- a/packages/ui/src/dialog/types.ts +++ b/packages/ui/src/dialog/types.ts @@ -9,12 +9,6 @@ export interface RootProps _Dialog.Root.Props, 'open' | 'onOpenChange' | 'defaultOpen' | 'modal' > { - /** - * The title text for the dialog. This is required to be a string to ensure - * accessible labeling of the dialog. - */ - title: string; - /** * The content to be rendered inside the component. */ @@ -60,7 +54,13 @@ export interface HeaderProps extends ComponentProps< 'div' > { children?: ReactNode; } -export type HeadingProps = ComponentProps< 'div' >; +export interface TitleProps extends ComponentProps< 'h2' > { + /** + * The title content to be rendered. This serves as both the visible + * heading and the accessible label for the dialog. + */ + children: ReactNode; +} export interface CloseIconProps extends Omit< From 0d231a5cf3f680140cd3347df0d802aa4f6d830e Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 4 Feb 2026 13:48:23 +0100 Subject: [PATCH 19/34] Spacing --- packages/ui/src/dialog/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/dialog/types.ts b/packages/ui/src/dialog/types.ts index 3d9ec739ba8f6a..5827a98d66c1c6 100644 --- a/packages/ui/src/dialog/types.ts +++ b/packages/ui/src/dialog/types.ts @@ -33,6 +33,7 @@ export interface PopupProps extends ComponentProps< 'div' > { */ size?: 'small' | 'medium' | 'large' | 'full'; } + export interface ActionProps extends ComponentProps< typeof Button > { /** * The content to be rendered inside the component. From 3478fcfe043470bb0ffc1708999a33e38d2a24d6 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 4 Feb 2026 14:01:13 +0100 Subject: [PATCH 20/34] Dialog: Add validation for empty title content Extend runtime validation to also check that Dialog.Title has non-empty text content: - Pass element ref from Title to validation context - Check textContent.trim() is non-empty after render - Use useMergeRefs from @wordpress/compose for cleaner ref handling - Add tests for empty title, whitespace-only title, and mixed content Co-authored-by: Cursor --- packages/ui/src/dialog/context.tsx | 24 +- packages/ui/src/dialog/test/index.test.tsx | 250 ++++++++++++++++++--- packages/ui/src/dialog/title.tsx | 11 +- 3 files changed, 240 insertions(+), 45 deletions(-) diff --git a/packages/ui/src/dialog/context.tsx b/packages/ui/src/dialog/context.tsx index 5be083ec816f42..a3cedaed8da461 100644 --- a/packages/ui/src/dialog/context.tsx +++ b/packages/ui/src/dialog/context.tsx @@ -14,7 +14,7 @@ import { const VALIDATION_ENABLED = process.env.NODE_ENV !== 'production'; type DialogValidationContextType = { - registerTitle: () => void; + registerTitle: ( element: HTMLElement | null ) => void; }; // Context is only created in development mode. @@ -52,10 +52,10 @@ function DialogValidationProviderDev( { }: { children: React.ReactNode; } ) { - const titleRegisteredRef = useRef( false ); + const titleElementRef = useRef< HTMLElement | null >( null ); - const registerTitle = useCallback( () => { - titleRegisteredRef.current = true; + const registerTitle = useCallback( ( element: HTMLElement | null ) => { + titleElementRef.current = element; }, [] ); const contextValue = useMemo( @@ -63,17 +63,27 @@ function DialogValidationProviderDev( { [ registerTitle ] ); - // Validate that Dialog.Title is rendered + // Validate that Dialog.Title is rendered with non-empty text content useEffect( () => { // useLayoutEffect in Title runs before this useEffect, - // so titleRegisteredRef should already be set if Title is present - if ( ! titleRegisteredRef.current ) { + // so titleElementRef should already be set if Title is present + const titleElement = titleElementRef.current; + + if ( ! titleElement ) { throw new Error( 'Dialog: Missing . ' + 'For accessibility, every dialog requires a title. ' + 'If needed, the title can be visually hidden but must not be omitted.' ); } + + const textContent = titleElement.textContent?.trim(); + if ( ! textContent ) { + throw new Error( + 'Dialog: cannot be empty. ' + + 'Provide meaningful text content for the dialog title.' + ); + } }, [] ); return ( diff --git a/packages/ui/src/dialog/test/index.test.tsx b/packages/ui/src/dialog/test/index.test.tsx index cb9c187fb6d8fc..464a4b26832415 100644 --- a/packages/ui/src/dialog/test/index.test.tsx +++ b/packages/ui/src/dialog/test/index.test.tsx @@ -1,8 +1,38 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createRef } from '@wordpress/element'; +import { Component, createRef } from '@wordpress/element'; +import type { ReactNode } from 'react'; import * as Dialog from '../index'; +class TestErrorBoundary extends Component< + { children: ReactNode; onError: ( error: Error ) => void }, + { hasError: boolean } +> { + constructor( props: { + children: ReactNode; + onError: ( error: Error ) => void; + } ) { + super( props ); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch( error: Error ) { + this.props.onError( error ); + } + + render() { + if ( this.state.hasError ) { + return null; + } + + return this.props.children; + } +} + describe( 'Dialog', () => { it( 'forwards ref', async () => { const user = userEvent.setup(); @@ -66,54 +96,87 @@ describe( 'Dialog', () => { it( 'should throw when Dialog.Title is missing', async () => { const user = userEvent.setup(); + const onError = jest.fn(); render( - - Open Dialog - - - { /* Missing Dialog.Title */ } - -

Content without a title

- - Close - -
-
+ + + Open Dialog + + + { /* Missing Dialog.Title */ } + +

Content without a title

+ + Close + +
+
+
); // Open the dialog - this will trigger the error in useEffect - await expect( async () => { - await user.click( - screen.getByRole( 'button', { name: 'Open Dialog' } ) - ); - // Wait for effects to run - await waitFor( () => { - // This will throw due to React error boundary - } ); - } ).rejects.toThrow( + await user.click( + screen.getByRole( 'button', { name: 'Open Dialog' } ) + ); + + await waitFor( () => { + expect( onError ).toHaveBeenCalled(); + } ); + + expect( onError.mock.calls[ 0 ][ 0 ] ).toBeInstanceOf( Error ); + expect( ( onError.mock.calls[ 0 ][ 0 ] as Error ).message ).toBe( 'Dialog: Missing . ' + 'For accessibility, every dialog requires a title. ' + 'If needed, the title can be visually hidden but must not be omitted.' ); } ); + it( 'should not throw before opening the dialog', async () => { + const onError = jest.fn(); + + render( + + + Open Dialog + + + My Title + +

Content with a title

+ + Close + +
+
+
+ ); + + // Check that the dialog itself hasn't been rendered in the DOM. + await expect( screen.findByRole( 'dialog' ) ).rejects.toThrow(); + + expect( onError ).not.toHaveBeenCalled(); + } ); + it( 'should not throw when Dialog.Title is present', async () => { const user = userEvent.setup(); + const onError = jest.fn(); render( - - Open Dialog - - - My Title - -

Content with a title

- - Close - -
-
+ + + Open Dialog + + + My Title + +

Content with a title

+ + Close + +
+
+
); // Open the dialog - should not throw @@ -131,6 +194,125 @@ describe( 'Dialog', () => { // If we got here without throwing, the test passes expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + expect( onError ).not.toHaveBeenCalled(); + } ); + + it( 'should throw when Dialog.Title is empty', async () => { + const user = userEvent.setup(); + const onError = jest.fn(); + + render( + + + Open Dialog + + + { /* @ts-expect-error this is just for test purposes */ } + + { /* Empty title */ } + + +

Content with empty title

+ + Close + +
+
+
+ ); + + // Open the dialog - this will trigger the error + await user.click( + screen.getByRole( 'button', { name: 'Open Dialog' } ) + ); + + await waitFor( () => { + expect( onError ).toHaveBeenCalled(); + } ); + + expect( onError.mock.calls[ 0 ][ 0 ] ).toBeInstanceOf( Error ); + expect( ( onError.mock.calls[ 0 ][ 0 ] as Error ).message ).toBe( + 'Dialog: cannot be empty. ' + + 'Provide meaningful text content for the dialog title.' + ); + } ); + + it( 'should throw when Dialog.Title contains only whitespace', async () => { + const user = userEvent.setup(); + const onError = jest.fn(); + + render( + + + Open Dialog + + + + +

Content with whitespace-only title

+ + Close + +
+
+
+ ); + + // Open the dialog - this will trigger the error + await user.click( + screen.getByRole( 'button', { name: 'Open Dialog' } ) + ); + + await waitFor( () => { + expect( onError ).toHaveBeenCalled(); + } ); + + expect( onError.mock.calls[ 0 ][ 0 ] ).toBeInstanceOf( Error ); + expect( ( onError.mock.calls[ 0 ][ 0 ] as Error ).message ).toBe( + 'Dialog: cannot be empty. ' + + 'Provide meaningful text content for the dialog title.' + ); + } ); + + it( 'should not throw when Dialog.Title contains mixed content with text', async () => { + const user = userEvent.setup(); + const onError = jest.fn(); + + render( + + + Open Dialog + + + + + Settings + + +

Content with icon and text title

+ + Close + +
+
+
+ ); + + // Open the dialog - should not throw + await user.click( + screen.getByRole( 'button', { name: 'Open Dialog' } ) + ); + + // Wait for the dialog to appear and validation to run + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); + + // Wait a bit more to ensure validation has run without errors + await new Promise( ( resolve ) => setTimeout( resolve, 50 ) ); + + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + expect( onError ).not.toHaveBeenCalled(); } ); } ); } ); diff --git a/packages/ui/src/dialog/title.tsx b/packages/ui/src/dialog/title.tsx index 592c81bc9d3713..d5784b6f6da232 100644 --- a/packages/ui/src/dialog/title.tsx +++ b/packages/ui/src/dialog/title.tsx @@ -1,6 +1,7 @@ import { Dialog as _Dialog } from '@base-ui/react/dialog'; import clsx from 'clsx'; -import { forwardRef, useLayoutEffect } from '@wordpress/element'; +import { useMergeRefs } from '@wordpress/compose'; +import { forwardRef, useLayoutEffect, useRef } from '@wordpress/element'; import { useDialogValidationContext } from './context'; import styles from './style.module.css'; import type { TitleProps } from './types'; @@ -14,17 +15,19 @@ import type { TitleProps } from './types'; * to customize the element if needed. */ const Title = forwardRef< HTMLHeadingElement, TitleProps >( - function DialogTitle( { className, render, ...props }, ref ) { + function DialogTitle( { className, render, ...props }, forwardedRef ) { const validationContext = useDialogValidationContext(); + const internalRef = useRef< HTMLHeadingElement >( null ); + const mergedRef = useMergeRefs( [ internalRef, forwardedRef ] ); // Register this title with the parent Popup for validation (dev only) useLayoutEffect( () => { - validationContext?.registerTitle(); + validationContext?.registerTitle( internalRef.current ); }, [ validationContext ] ); return ( <_Dialog.Title - ref={ ref } + ref={ mergedRef } className={ clsx( styles.title, className ) } render={ render } { ...props } From 5b6a857e9bece66f8bfd983981d0f2e800bf9ceb Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 4 Feb 2026 16:49:12 +0100 Subject: [PATCH 21/34] Update paddings --- packages/ui/src/dialog/style.module.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/dialog/style.module.css b/packages/ui/src/dialog/style.module.css index 4569632617f3a1..7ad6a2b624ae45 100644 --- a/packages/ui/src/dialog/style.module.css +++ b/packages/ui/src/dialog/style.module.css @@ -28,9 +28,9 @@ transform: translate(-50%, -50%); box-sizing: border-box; min-width: 320px; - width: calc(100vw - 2 * var(--wpds-dimension-padding-xl)); - max-height: calc(100vh - 2 * var(--wpds-dimension-padding-xl)); - padding: var(--wpds-dimension-padding-3xl); + width: calc(100vw - 2 * var(--wpds-dimension-padding-2xl)); + max-height: calc(100vh - 2 * var(--wpds-dimension-padding-2xl)); + padding: var(--wpds-dimension-padding-2xl); background-color: var(--wpds-color-bg-surface-neutral-strong); border-radius: var(--wpds-border-radius-lg); overflow: auto; From fe24d73e41ed634643098ebfd5b0f81b0c39ae2a Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 6 Feb 2026 15:13:30 +0100 Subject: [PATCH 22/34] Set `medium` as the default size --- packages/ui/src/dialog/popup.tsx | 2 +- packages/ui/src/dialog/types.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/dialog/popup.tsx b/packages/ui/src/dialog/popup.tsx index 000c7227fa64d8..fb596e2a4283ab 100644 --- a/packages/ui/src/dialog/popup.tsx +++ b/packages/ui/src/dialog/popup.tsx @@ -10,7 +10,7 @@ import type { PopupProps } from './types'; * Uses a portal to render outside the DOM hierarchy. */ const Popup = forwardRef< HTMLDivElement, PopupProps >( function DialogPopup( - { className, size, children, ...props }, + { className, size = 'medium', children, ...props }, ref ) { return ( diff --git a/packages/ui/src/dialog/types.ts b/packages/ui/src/dialog/types.ts index 5827a98d66c1c6..60d1f14e7a0e10 100644 --- a/packages/ui/src/dialog/types.ts +++ b/packages/ui/src/dialog/types.ts @@ -30,6 +30,7 @@ export interface PopupProps extends ComponentProps< 'div' > { /** * Renders the dialog at a preset width. + * @default 'medium' */ size?: 'small' | 'medium' | 'large' | 'full'; } From 43cd4b380392fa7b6f20f4f6da0d3d3b134789a1 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 6 Feb 2026 15:13:42 +0100 Subject: [PATCH 23/34] Use DS token for font weight --- packages/ui/src/dialog/style.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/dialog/style.module.css b/packages/ui/src/dialog/style.module.css index 7ad6a2b624ae45..36ec324bae5879 100644 --- a/packages/ui/src/dialog/style.module.css +++ b/packages/ui/src/dialog/style.module.css @@ -88,7 +88,7 @@ .title { margin: 0; font-size: var(--wpds-font-size-xl); - font-weight: 590; + font-weight: var(--wpds-font-weight-medium); line-height: var(--wpds-font-line-height-xl); color: var(--wpds-color-fg-content-neutral); } From d6bc64dede8ddd46cadd289bed42f79471a6c0f4 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 6 Feb 2026 15:14:01 +0100 Subject: [PATCH 24/34] Increase min width for larger viewport sizes --- packages/ui/src/dialog/style.module.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ui/src/dialog/style.module.css b/packages/ui/src/dialog/style.module.css index 36ec324bae5879..da3fb9c78f5e00 100644 --- a/packages/ui/src/dialog/style.module.css +++ b/packages/ui/src/dialog/style.module.css @@ -57,6 +57,10 @@ } } + @media (min-width: 480px) { + min-width: 384px; + } + &.is-small { max-width: 384px; } From 8dc6ca523ce1b41f71887c0af157a12cb4063cea Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 6 Feb 2026 15:14:09 +0100 Subject: [PATCH 25/34] Add box shadow --- packages/ui/src/dialog/style.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/dialog/style.module.css b/packages/ui/src/dialog/style.module.css index da3fb9c78f5e00..714cb728c06183 100644 --- a/packages/ui/src/dialog/style.module.css +++ b/packages/ui/src/dialog/style.module.css @@ -33,6 +33,7 @@ padding: var(--wpds-dimension-padding-2xl); background-color: var(--wpds-color-bg-surface-neutral-strong); border-radius: var(--wpds-border-radius-lg); + box-shadow: var(--wpds-elevation-lg); overflow: auto; font-family: var(--wpds-font-family-body); font-size: var(--wpds-font-size-md); From 59028cf6c20b026b6ba7ff9bcc8b2aeab17842fb Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 6 Feb 2026 15:14:24 +0100 Subject: [PATCH 26/34] Rework Storybook examples --- .../ui/src/dialog/stories/index.story.tsx | 136 ++++++++++++------ 1 file changed, 89 insertions(+), 47 deletions(-) diff --git a/packages/ui/src/dialog/stories/index.story.tsx b/packages/ui/src/dialog/stories/index.story.tsx index 3aced71ab64892..e4fbf7baf34e63 100644 --- a/packages/ui/src/dialog/stories/index.story.tsx +++ b/packages/ui/src/dialog/stories/index.story.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useId, useState } from '@wordpress/element'; import type { ComponentProps } from 'react'; import * as Dialog from '../index'; @@ -32,41 +33,31 @@ export default meta; type Story = StoryObj< typeof Dialog.Root >; -function DialogWithSize( { - size, - title = 'Welcome', -}: { - size?: ComponentProps< typeof Dialog.Popup >[ 'size' ]; - title?: string; -} ) { - return ( - <> - Open Dialog - - - { title } - - -

- This dialog demonstrates best practices for informational - dialogs. It includes a close icon because dismissing it is - safe and expected. -

- - Got it - -
- - ); -} - /** * An informational dialog with a close icon, where there is no ambiguity on * what happens when clicking the close icon. */ -export const Default: Story = { +export const _Default: Story = { args: { - children: , + children: ( + <> + Open Dialog + + + Welcome + + +

+ This dialog demonstrates best practices for + informational dialogs. It includes a close icon because + dismissing it is safe and expected. +

+ + Got it + +
+ + ), }, }; @@ -98,26 +89,77 @@ export const ConfirmDialog: Story = { }, }; -export const SmallSize: Story = { - args: { - children: , - }, -}; +const ALL_SIZES = [ 'small', 'medium', 'large', 'full' ] as const; -export const MediumSize: Story = { - args: { - children: , - }, -}; +function SizeSelector( { + value, + onChange, +}: { + value: ComponentProps< typeof Dialog.Popup >[ 'size' ]; + onChange: ( size: ComponentProps< typeof Dialog.Popup >[ 'size' ] ) => void; +} ) { + const selectId = useId(); + return ( +
+ + +
+ ); +} -export const LargeSize: Story = { - args: { - children: , - }, -}; +function SizePlaygroundContent() { + const [ size, setSize ] = + useState< ComponentProps< typeof Dialog.Popup >[ 'size' ] >( 'medium' ); + return ( + <> +
+ + Open Dialog +
+ + + Size Playground + + + +

+ Use the dropdown above (or outside the dialog) to change the + popup size. Both controls stay in sync. +

+ + Got it + +
+ + ); +} -export const FullSize: Story = { +export const AllSizes: Story = { args: { - children: , + children: , }, }; From 8c9dbb49ae1808d42fff1aa5a10da05c23b2e873 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 6 Feb 2026 15:35:00 +0100 Subject: [PATCH 27/34] Add "stretch" size --- packages/ui/src/dialog/stories/index.story.tsx | 2 +- packages/ui/src/dialog/style.module.css | 5 +++++ packages/ui/src/dialog/types.ts | 9 ++++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/dialog/stories/index.story.tsx b/packages/ui/src/dialog/stories/index.story.tsx index e4fbf7baf34e63..dea56e70b14173 100644 --- a/packages/ui/src/dialog/stories/index.story.tsx +++ b/packages/ui/src/dialog/stories/index.story.tsx @@ -89,7 +89,7 @@ export const ConfirmDialog: Story = { }, }; -const ALL_SIZES = [ 'small', 'medium', 'large', 'full' ] as const; +const ALL_SIZES = [ 'small', 'medium', 'large', 'stretch', 'full' ] as const; function SizeSelector( { value, diff --git a/packages/ui/src/dialog/style.module.css b/packages/ui/src/dialog/style.module.css index 714cb728c06183..759da730c20e16 100644 --- a/packages/ui/src/dialog/style.module.css +++ b/packages/ui/src/dialog/style.module.css @@ -74,6 +74,11 @@ max-width: 840px; } + &.is-stretch { + /* The dialog stretches to fill available width. */ + max-width: none; + } + &.is-full { /* * Force full height (full width is already in default styles). diff --git a/packages/ui/src/dialog/types.ts b/packages/ui/src/dialog/types.ts index 60d1f14e7a0e10..4a646cb54707c9 100644 --- a/packages/ui/src/dialog/types.ts +++ b/packages/ui/src/dialog/types.ts @@ -30,9 +30,16 @@ export interface PopupProps extends ComponentProps< 'div' > { /** * Renders the dialog at a preset width. + * + * - `'small'` — max-width of 384px. + * - `'medium'` — max-width of 512px. + * - `'large'` — max-width of 840px. + * - `'stretch'` — no max-width, stretches to fill available space. + * - `'full'` — stretches to fill available width and height. + * * @default 'medium' */ - size?: 'small' | 'medium' | 'large' | 'full'; + size?: 'small' | 'medium' | 'large' | 'stretch' | 'full'; } export interface ActionProps extends ComponentProps< typeof Button > { From 73e084acaf17fc4d7139e656e157b6b6e0832a64 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 6 Feb 2026 15:41:42 +0100 Subject: [PATCH 28/34] better types --- packages/ui/src/dialog/types.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/dialog/types.ts b/packages/ui/src/dialog/types.ts index 4a646cb54707c9..75bb5e69a72e49 100644 --- a/packages/ui/src/dialog/types.ts +++ b/packages/ui/src/dialog/types.ts @@ -79,11 +79,14 @@ export interface CloseIconProps /** * A label describing the button's action, shown as a tooltip and to * assistive technology. + * + * @default __( 'Close' ) */ label?: ComponentProps< typeof IconButton >[ 'label' ]; - /** * The icon to display in the button. + * + * @default the `close` icon from `@wordpress/icons` */ icon?: ComponentProps< typeof IconButton >[ 'icon' ]; } From af8de7f4dd34c62294a74d514efd5a132f2e599d Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 6 Feb 2026 15:45:59 +0100 Subject: [PATCH 29/34] Remove unnecessary conditional --- packages/ui/src/dialog/popup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/dialog/popup.tsx b/packages/ui/src/dialog/popup.tsx index fb596e2a4283ab..fceb631bcbd976 100644 --- a/packages/ui/src/dialog/popup.tsx +++ b/packages/ui/src/dialog/popup.tsx @@ -21,7 +21,7 @@ const Popup = forwardRef< HTMLDivElement, PopupProps >( function DialogPopup( className={ clsx( styles.popup, className, - size && styles[ `is-${ size }` ] + styles[ `is-${ size }` ] ) } { ...props } > From f45d7cbc2ff63738e548ded1ffe6dd5e121ddfaf Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 6 Feb 2026 15:47:58 +0100 Subject: [PATCH 30/34] Test header ref --- packages/ui/src/dialog/test/index.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/dialog/test/index.test.tsx b/packages/ui/src/dialog/test/index.test.tsx index 464a4b26832415..88b1b2d47b4e6f 100644 --- a/packages/ui/src/dialog/test/index.test.tsx +++ b/packages/ui/src/dialog/test/index.test.tsx @@ -39,6 +39,7 @@ describe( 'Dialog', () => { const triggerRef = createRef< HTMLButtonElement >(); const popupRef = createRef< HTMLDivElement >(); const actionRef = createRef< HTMLButtonElement >(); + const headerRef = createRef< HTMLDivElement >(); const titleRef = createRef< HTMLHeadingElement >(); const closeIconRef = createRef< HTMLButtonElement >(); const footerRef = createRef< HTMLDivElement >(); @@ -47,7 +48,7 @@ describe( 'Dialog', () => { Open Dialog - + Test Dialog @@ -72,6 +73,7 @@ describe( 'Dialog', () => { } ); // Now that the dialog is open, verify all inner refs + expect( headerRef.current ).toBeInstanceOf( HTMLDivElement ); expect( titleRef.current ).toBeInstanceOf( HTMLHeadingElement ); expect( closeIconRef.current ).toBeInstanceOf( HTMLButtonElement ); expect( actionRef.current ).toBeInstanceOf( HTMLButtonElement ); From bf0722b5840409269afd9ffb9e070afce4092c50 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 6 Feb 2026 15:53:03 +0100 Subject: [PATCH 31/34] Wrap popup in themeprovider --- packages/ui/src/dialog/popup.tsx | 36 ++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/dialog/popup.tsx b/packages/ui/src/dialog/popup.tsx index fceb631bcbd976..f657aba3bb0b77 100644 --- a/packages/ui/src/dialog/popup.tsx +++ b/packages/ui/src/dialog/popup.tsx @@ -1,10 +1,18 @@ import { Dialog as _Dialog } from '@base-ui/react/dialog'; import clsx from 'clsx'; import { forwardRef } from '@wordpress/element'; +import { + type ThemeProvider as ThemeProviderType, + privateApis as themePrivateApis, +} from '@wordpress/theme'; +import { unlock } from '../lock-unlock'; import { DialogValidationProvider } from './context'; import styles from './style.module.css'; import type { PopupProps } from './types'; +const ThemeProvider: typeof ThemeProviderType = + unlock( themePrivateApis ).ThemeProvider; + /** * Renders the dialog popup element that contains the dialog content. * Uses a portal to render outside the DOM hierarchy. @@ -16,19 +24,21 @@ const Popup = forwardRef< HTMLDivElement, PopupProps >( function DialogPopup( return ( <_Dialog.Portal> <_Dialog.Backdrop className={ styles.backdrop } /> - <_Dialog.Popup - ref={ ref } - className={ clsx( - styles.popup, - className, - styles[ `is-${ size }` ] - ) } - { ...props } - > - - { children } - - + + <_Dialog.Popup + ref={ ref } + className={ clsx( + styles.popup, + className, + styles[ `is-${ size }` ] + ) } + { ...props } + > + + { children } + + + ); } ); From 25b7cb7ebb86226f6450734e3b28d0c2d4270dfa Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 6 Feb 2026 16:17:16 +0100 Subject: [PATCH 32/34] Fix `modal` prop storybook controls --- packages/ui/src/dialog/stories/index.story.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/ui/src/dialog/stories/index.story.tsx b/packages/ui/src/dialog/stories/index.story.tsx index dea56e70b14173..f2af5aa127d359 100644 --- a/packages/ui/src/dialog/stories/index.story.tsx +++ b/packages/ui/src/dialog/stories/index.story.tsx @@ -15,6 +15,18 @@ const meta: Meta< typeof Dialog.Root > = { 'Dialog.Action': Dialog.Action, 'Dialog.Footer': Dialog.Footer, }, + argTypes: { + modal: { + control: 'inline-radio', + options: [ true, false, 'trap-focus' ], + table: { + defaultValue: { summary: 'true' }, + type: { + summary: 'boolean | "trap-focus"', + }, + }, + }, + }, parameters: { docs: { description: { From c017d8860162083045973a2f6e0151aec5f317a7 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 6 Feb 2026 16:33:04 +0100 Subject: [PATCH 33/34] Remove unnecessary setTimeout in tests --- packages/ui/src/dialog/test/index.test.tsx | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/dialog/test/index.test.tsx b/packages/ui/src/dialog/test/index.test.tsx index 88b1b2d47b4e6f..efce84ee6de797 100644 --- a/packages/ui/src/dialog/test/index.test.tsx +++ b/packages/ui/src/dialog/test/index.test.tsx @@ -186,16 +186,10 @@ describe( 'Dialog', () => { screen.getByRole( 'button', { name: 'Open Dialog' } ) ); - // Wait for the dialog to appear and validation to run + // Wait for the dialog to appear and ensure validation does not trigger errors await waitFor( () => { expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); } ); - - // Wait a bit more to ensure validation has run without errors - await new Promise( ( resolve ) => setTimeout( resolve, 50 ) ); - - // If we got here without throwing, the test passes - expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); expect( onError ).not.toHaveBeenCalled(); } ); @@ -305,15 +299,10 @@ describe( 'Dialog', () => { screen.getByRole( 'button', { name: 'Open Dialog' } ) ); - // Wait for the dialog to appear and validation to run + // Wait for the dialog to appear and ensure validation does not trigger errors await waitFor( () => { expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); } ); - - // Wait a bit more to ensure validation has run without errors - await new Promise( ( resolve ) => setTimeout( resolve, 50 ) ); - - expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); expect( onError ).not.toHaveBeenCalled(); } ); } ); From b0aec889f961e9eabea22fabf2b027e60c9d9fbb Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 6 Feb 2026 16:34:47 +0100 Subject: [PATCH 34/34] better size type description --- packages/ui/src/dialog/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/dialog/types.ts b/packages/ui/src/dialog/types.ts index 75bb5e69a72e49..7d5bb547794be2 100644 --- a/packages/ui/src/dialog/types.ts +++ b/packages/ui/src/dialog/types.ts @@ -29,7 +29,8 @@ export interface PopupProps extends ComponentProps< 'div' > { children?: ReactNode; /** - * Renders the dialog at a preset width. + * Renders the dialog at a preset width (excluding additional padding from + * the viewport edges). * * - `'small'` — max-width of 384px. * - `'medium'` — max-width of 512px.