Skip to content

Commit 7c2cdf2

Browse files
committed
UI: Add Drawer primitive
Made-with: Cursor
1 parent b65f49a commit 7c2cdf2

23 files changed

+1327
-21
lines changed

packages/ui/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### New Features
6+
7+
- Add `Drawer` primitive ([#XXXXX](https://github.com/WordPress/gutenberg/pull/XXXXX)).
8+
59
### Bug Fixes
610

711
- `Card`: Add `overflow: clip` to `Card.Root` to prevent child content from overflowing rounded corners ([#76678](https://github.com/WordPress/gutenberg/pull/76678)).

packages/ui/src/card/style.module.css

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@
3939
}
4040

4141
.title {
42-
margin: 0;
4342
color: var(--wpds-color-fg-content-neutral);
4443
}
4544
}

packages/ui/src/dialog/context.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Dialog as _Dialog } from '@base-ui/react/dialog';
12
import {
23
createContext,
34
useCallback,
@@ -7,6 +8,31 @@ import {
78
useRef,
89
} from '@wordpress/element';
910

11+
// -- Modal context ----------------------------------------------------------
12+
13+
const DialogModalContext =
14+
createContext< _Dialog.Root.Props[ 'modal' ] >( true );
15+
16+
export function DialogModalProvider( {
17+
modal = true,
18+
children,
19+
}: {
20+
modal?: _Dialog.Root.Props[ 'modal' ];
21+
children: React.ReactNode;
22+
} ) {
23+
return (
24+
<DialogModalContext.Provider value={ modal }>
25+
{ children }
26+
</DialogModalContext.Provider>
27+
);
28+
}
29+
30+
export function useDialogModal() {
31+
return useContext( DialogModalContext );
32+
}
33+
34+
// -- Validation context (dev-only) ------------------------------------------
35+
1036
/**
1137
* Whether validation is enabled. This is a build-time constant that allows
1238
* bundlers to tree-shake all validation code in production builds.

packages/ui/src/dialog/popup.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
privateApis as themePrivateApis,
77
} from '@wordpress/theme';
88
import { unlock } from '../lock-unlock';
9-
import { DialogValidationProvider } from './context';
9+
import { DialogValidationProvider, useDialogModal } from './context';
1010
import styles from './style.module.css';
1111
import type { PopupProps } from './types';
1212

@@ -21,9 +21,13 @@ const Popup = forwardRef< HTMLDivElement, PopupProps >( function DialogPopup(
2121
{ className, size = 'medium', children, ...props },
2222
ref
2323
) {
24+
const modal = useDialogModal();
25+
2426
return (
2527
<_Dialog.Portal>
26-
<_Dialog.Backdrop className={ styles.backdrop } />
28+
{ modal === true && (
29+
<_Dialog.Backdrop className={ styles.backdrop } />
30+
) }
2731
<ThemeProvider>
2832
<_Dialog.Popup
2933
ref={ ref }

packages/ui/src/dialog/root.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,31 @@
11
import { Dialog as _Dialog } from '@base-ui/react/dialog';
2+
import { DialogModalProvider } from './context';
23
import type { RootProps } from './types';
34

45
/**
5-
* Groups the dialog trigger and popup.
6+
* A popup that opens on top of the entire page.
67
*
7-
* `Dialog` is a collection of React components that combine to render
8-
* an ARIA-compliant dialog pattern.
8+
* Every dialog must include a `Dialog.Title` component for accessibility — it
9+
* serves as both the visible heading and the accessible label for the dialog.
10+
*
11+
* Always include a visible close button, either `Dialog.CloseIcon` or a clear
12+
* dismissing action button. If your dialog has a "Cancel" button in the footer,
13+
* the close icon may be redundant and create confusion about what clicking "X"
14+
* means.
15+
*
16+
* Use `Dialog.CloseIcon` for informational dialogs where dismissing is safe and
17+
* expected. For dialogs requiring explicit user choice (especially destructive
18+
* actions), omit the close icon and rely on footer action buttons like "Cancel"
19+
* and "Confirm" instead.
920
*/
10-
function Root( props: RootProps ) {
11-
return <_Dialog.Root { ...props } />;
21+
function Root( { modal, children, ...props }: RootProps ) {
22+
return (
23+
<_Dialog.Root modal={ modal } { ...props }>
24+
<DialogModalProvider modal={ modal }>
25+
{ children }
26+
</DialogModalProvider>
27+
</_Dialog.Root>
28+
);
1229
}
1330

1431
export { Root };

packages/ui/src/dialog/stories/index.story.tsx

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,6 @@ const meta: Meta< typeof Dialog.Root > = {
2727
},
2828
},
2929
},
30-
parameters: {
31-
docs: {
32-
description: {
33-
component: `
34-
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.
35-
36-
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.
37-
38-
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.
39-
`,
40-
},
41-
},
42-
},
4330
};
4431
export default meta;
4532

packages/ui/src/drawer/action.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Drawer as _Drawer } from '@base-ui/react/drawer';
2+
import { forwardRef } from '@wordpress/element';
3+
import { Button } from '../button';
4+
import type { ActionProps } from './types';
5+
6+
/**
7+
* A button that closes the drawer when clicked.
8+
* Wraps the design system `Button` component.
9+
*/
10+
const Action = forwardRef< HTMLButtonElement, ActionProps >(
11+
function DrawerAction( { render, ...props }, ref ) {
12+
return (
13+
<_Drawer.Close
14+
ref={ ref }
15+
render={ <Button render={ render } /> }
16+
{ ...props }
17+
/>
18+
);
19+
}
20+
);
21+
22+
export { Action };
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Drawer as _Drawer } from '@base-ui/react/drawer';
2+
import { forwardRef } from '@wordpress/element';
3+
import { __ } from '@wordpress/i18n';
4+
import { close } from '@wordpress/icons';
5+
import { IconButton } from '../icon-button';
6+
import type { CloseIconProps } from './types';
7+
8+
/**
9+
* An icon button that closes the drawer when clicked.
10+
* Defaults to a close (×) icon with an accessible "Close" label.
11+
*/
12+
const CloseIcon = forwardRef< HTMLButtonElement, CloseIconProps >(
13+
function DrawerCloseIcon( { icon, label, ...props }, ref ) {
14+
return (
15+
<_Drawer.Close
16+
ref={ ref }
17+
render={
18+
<IconButton
19+
variant="minimal"
20+
size="compact"
21+
tone="neutral"
22+
{ ...props }
23+
icon={ icon ?? close }
24+
label={ label ?? __( 'Close' ) }
25+
/>
26+
}
27+
/>
28+
);
29+
}
30+
);
31+
32+
export { CloseIcon };

packages/ui/src/drawer/context.tsx

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { Drawer as _Drawer } from '@base-ui/react/drawer';
2+
import {
3+
createContext,
4+
useCallback,
5+
useContext,
6+
useEffect,
7+
useMemo,
8+
useRef,
9+
} from '@wordpress/element';
10+
11+
// -- Modal context ----------------------------------------------------------
12+
13+
const DrawerModalContext =
14+
createContext< _Drawer.Root.Props[ 'modal' ] >( true );
15+
16+
export function DrawerModalProvider( {
17+
modal = true,
18+
children,
19+
}: {
20+
modal?: _Drawer.Root.Props[ 'modal' ];
21+
children: React.ReactNode;
22+
} ) {
23+
return (
24+
<DrawerModalContext.Provider value={ modal }>
25+
{ children }
26+
</DrawerModalContext.Provider>
27+
);
28+
}
29+
30+
export function useDrawerModal() {
31+
return useContext( DrawerModalContext );
32+
}
33+
34+
// -- Validation context (dev-only) ------------------------------------------
35+
36+
/**
37+
* Whether validation is enabled. This is a build-time constant that allows
38+
* bundlers to tree-shake all validation code in production builds.
39+
*/
40+
const VALIDATION_ENABLED = process.env.NODE_ENV !== 'production';
41+
42+
type DrawerValidationContextType = {
43+
registerTitle: ( element: HTMLElement | null ) => void;
44+
};
45+
46+
const DrawerValidationContext = VALIDATION_ENABLED
47+
? createContext< DrawerValidationContextType | null >( null )
48+
: ( null as unknown as React.Context< DrawerValidationContextType | null > );
49+
50+
function useDrawerValidationContextDev() {
51+
return useContext( DrawerValidationContext );
52+
}
53+
54+
function useDrawerValidationContextProd() {
55+
return null;
56+
}
57+
58+
/**
59+
* Hook to access the drawer validation context.
60+
* Returns null in production or if not within a Drawer.Popup.
61+
*/
62+
export const useDrawerValidationContext = VALIDATION_ENABLED
63+
? useDrawerValidationContextDev
64+
: useDrawerValidationContextProd;
65+
66+
function DrawerValidationProviderDev( {
67+
children,
68+
}: {
69+
children: React.ReactNode;
70+
} ) {
71+
const titleElementRef = useRef< HTMLElement | null >( null );
72+
73+
const registerTitle = useCallback( ( element: HTMLElement | null ) => {
74+
titleElementRef.current = element;
75+
}, [] );
76+
77+
const contextValue = useMemo(
78+
() => ( { registerTitle } ),
79+
[ registerTitle ]
80+
);
81+
82+
useEffect( () => {
83+
const titleElement = titleElementRef.current;
84+
85+
if ( ! titleElement ) {
86+
throw new Error(
87+
'Drawer: Missing <Drawer.Title>. ' +
88+
'For accessibility, every drawer requires a title. ' +
89+
'If needed, the title can be visually hidden but must not be omitted.'
90+
);
91+
}
92+
93+
const textContent = titleElement.textContent?.trim();
94+
if ( ! textContent ) {
95+
throw new Error(
96+
'Drawer: <Drawer.Title> cannot be empty. ' +
97+
'Provide meaningful text content for the drawer title.'
98+
);
99+
}
100+
}, [] );
101+
102+
return (
103+
<DrawerValidationContext.Provider value={ contextValue }>
104+
{ children }
105+
</DrawerValidationContext.Provider>
106+
);
107+
}
108+
109+
function DrawerValidationProviderProd( {
110+
children,
111+
}: {
112+
children: React.ReactNode;
113+
} ) {
114+
return <>{ children }</>;
115+
}
116+
117+
/**
118+
* Provider component that validates Drawer.Title presence in development mode.
119+
* In production, this component is a no-op and just renders children.
120+
*/
121+
export const DrawerValidationProvider = VALIDATION_ENABLED
122+
? DrawerValidationProviderDev
123+
: DrawerValidationProviderProd;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Drawer as _Drawer } from '@base-ui/react/drawer';
2+
import clsx from 'clsx';
3+
import { forwardRef } from '@wordpress/element';
4+
import { Text } from '../text';
5+
import styles from './style.module.css';
6+
import type { DescriptionProps } from './types';
7+
8+
/**
9+
* A paragraph with additional information about the drawer.
10+
*/
11+
const Description = forwardRef< HTMLParagraphElement, DescriptionProps >(
12+
function DrawerDescription( { className, render, ...props }, ref ) {
13+
return (
14+
<_Drawer.Description
15+
ref={ ref }
16+
render={ <Text variant="body-md" render={ render ?? <p /> } /> }
17+
className={ clsx( styles.description, className ) }
18+
{ ...props }
19+
/>
20+
);
21+
}
22+
);
23+
24+
export { Description };

0 commit comments

Comments
 (0)