Skip to content

Commit 9c6faa6

Browse files
committed
UI: Add Drawer primitive
Add a new Drawer component to @wordpress/ui that wraps Base UI's Drawer, following the same patterns established by the Dialog component. Made-with: Cursor
1 parent b65f49a commit 9c6faa6

File tree

19 files changed

+1147
-1
lines changed

19 files changed

+1147
-1
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/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: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import {
2+
createContext,
3+
useCallback,
4+
useContext,
5+
useEffect,
6+
useMemo,
7+
useRef,
8+
} from '@wordpress/element';
9+
10+
/**
11+
* Whether validation is enabled. This is a build-time constant that allows
12+
* bundlers to tree-shake all validation code in production builds.
13+
*/
14+
const VALIDATION_ENABLED = process.env.NODE_ENV !== 'production';
15+
16+
type DrawerValidationContextType = {
17+
registerTitle: ( element: HTMLElement | null ) => void;
18+
};
19+
20+
const DrawerValidationContext = VALIDATION_ENABLED
21+
? createContext< DrawerValidationContextType | null >( null )
22+
: ( null as unknown as React.Context< DrawerValidationContextType | null > );
23+
24+
function useDrawerValidationContextDev() {
25+
return useContext( DrawerValidationContext );
26+
}
27+
28+
function useDrawerValidationContextProd() {
29+
return null;
30+
}
31+
32+
/**
33+
* Hook to access the drawer validation context.
34+
* Returns null in production or if not within a Drawer.Popup.
35+
*/
36+
export const useDrawerValidationContext = VALIDATION_ENABLED
37+
? useDrawerValidationContextDev
38+
: useDrawerValidationContextProd;
39+
40+
function DrawerValidationProviderDev( {
41+
children,
42+
}: {
43+
children: React.ReactNode;
44+
} ) {
45+
const titleElementRef = useRef< HTMLElement | null >( null );
46+
47+
const registerTitle = useCallback( ( element: HTMLElement | null ) => {
48+
titleElementRef.current = element;
49+
}, [] );
50+
51+
const contextValue = useMemo(
52+
() => ( { registerTitle } ),
53+
[ registerTitle ]
54+
);
55+
56+
useEffect( () => {
57+
const titleElement = titleElementRef.current;
58+
59+
if ( ! titleElement ) {
60+
throw new Error(
61+
'Drawer: Missing <Drawer.Title>. ' +
62+
'For accessibility, every drawer requires a title. ' +
63+
'If needed, the title can be visually hidden but must not be omitted.'
64+
);
65+
}
66+
67+
const textContent = titleElement.textContent?.trim();
68+
if ( ! textContent ) {
69+
throw new Error(
70+
'Drawer: <Drawer.Title> cannot be empty. ' +
71+
'Provide meaningful text content for the drawer title.'
72+
);
73+
}
74+
}, [] );
75+
76+
return (
77+
<DrawerValidationContext.Provider value={ contextValue }>
78+
{ children }
79+
</DrawerValidationContext.Provider>
80+
);
81+
}
82+
83+
function DrawerValidationProviderProd( {
84+
children,
85+
}: {
86+
children: React.ReactNode;
87+
} ) {
88+
return <>{ children }</>;
89+
}
90+
91+
/**
92+
* Provider component that validates Drawer.Title presence in development mode.
93+
* In production, this component is a no-op and just renders children.
94+
*/
95+
export const DrawerValidationProvider = VALIDATION_ENABLED
96+
? DrawerValidationProviderDev
97+
: 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 };

packages/ui/src/drawer/footer.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { mergeProps, useRender } from '@base-ui/react';
2+
import clsx from 'clsx';
3+
import { forwardRef } from '@wordpress/element';
4+
import styles from './style.module.css';
5+
import type { FooterProps } from './types';
6+
7+
/**
8+
* A layout component for the drawer footer area.
9+
*/
10+
const Footer = forwardRef< HTMLDivElement, FooterProps >( function DrawerFooter(
11+
{ className, render, ...props },
12+
ref
13+
) {
14+
const element = useRender( {
15+
render,
16+
ref,
17+
props: mergeProps< 'div' >( props, {
18+
className: clsx( styles.footer, className ),
19+
} ),
20+
} );
21+
22+
return element;
23+
} );
24+
25+
export { Footer };

packages/ui/src/drawer/header.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { mergeProps, useRender } from '@base-ui/react';
2+
import clsx from 'clsx';
3+
import { forwardRef } from '@wordpress/element';
4+
import styles from './style.module.css';
5+
import type { HeaderProps } from './types';
6+
7+
/**
8+
* A layout component for the drawer header area.
9+
*/
10+
const Header = forwardRef< HTMLDivElement, HeaderProps >( function DrawerHeader(
11+
{ className, render, ...props },
12+
ref
13+
) {
14+
const element = useRender( {
15+
render,
16+
ref,
17+
props: mergeProps< 'div' >( props, {
18+
className: clsx( styles.header, className ),
19+
} ),
20+
} );
21+
22+
return element;
23+
} );
24+
25+
export { Header };

packages/ui/src/drawer/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Action } from './action';
2+
import { CloseIcon } from './close-icon';
3+
import { Description } from './description';
4+
import { Footer } from './footer';
5+
import { Header } from './header';
6+
import { Popup } from './popup';
7+
import { Root } from './root';
8+
import { Title } from './title';
9+
import { Trigger } from './trigger';
10+
11+
export {
12+
Action,
13+
CloseIcon,
14+
Description,
15+
Footer,
16+
Header,
17+
Popup,
18+
Root,
19+
Title,
20+
Trigger,
21+
};

packages/ui/src/drawer/popup.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Drawer as _Drawer } from '@base-ui/react/drawer';
2+
import clsx from 'clsx';
3+
import { forwardRef } from '@wordpress/element';
4+
import {
5+
type ThemeProvider as ThemeProviderType,
6+
privateApis as themePrivateApis,
7+
} from '@wordpress/theme';
8+
import { unlock } from '../lock-unlock';
9+
import { DrawerValidationProvider } from './context';
10+
import styles from './style.module.css';
11+
import type { PopupProps } from './types';
12+
13+
const ThemeProvider: typeof ThemeProviderType =
14+
unlock( themePrivateApis ).ThemeProvider;
15+
16+
/**
17+
* Renders the drawer popup element that contains the drawer content.
18+
* Uses a portal to render outside the DOM hierarchy.
19+
*/
20+
const Popup = forwardRef< HTMLDivElement, PopupProps >( function DrawerPopup(
21+
{ className, children, ...props },
22+
ref
23+
) {
24+
return (
25+
<_Drawer.Portal>
26+
<_Drawer.Backdrop className={ styles.backdrop } />
27+
<ThemeProvider>
28+
<_Drawer.Viewport className={ styles.viewport }>
29+
<_Drawer.Popup
30+
ref={ ref }
31+
className={ clsx( styles.popup, className ) }
32+
{ ...props }
33+
>
34+
<_Drawer.Content className={ styles.content }>
35+
<DrawerValidationProvider>
36+
{ children }
37+
</DrawerValidationProvider>
38+
</_Drawer.Content>
39+
</_Drawer.Popup>
40+
</_Drawer.Viewport>
41+
</ThemeProvider>
42+
</_Drawer.Portal>
43+
);
44+
} );
45+
46+
export { Popup };

0 commit comments

Comments
 (0)