Skip to content

Commit f768c1e

Browse files
authored
Add EmptyState component to @wordpress/ui (#74719)
* add EmptyState component to @wordpress/ui compound component with Root, Visual, Icon, Title, Description, Actions * add EmptyState unit tests * add EmptyState stories * align EmptyState with @wordpress/ui conventions remove docblock comments, use semantic CSS tokens * fix code review feedback on EmptyState component use _Icon import convention, remove CSS var() spacing * use neutral-weak bg for EmptyState icon avoids invisible icon background when rendered on Page surfaces after #76548 * move max-width to EmptyState root with token replaces hardcoded 350px on description with --wpds-dimension-surface-width-sm on container * use Text component for EmptyState title and description delegates typography styles to Text variants instead of manual font-size/weight/line-height declarations
1 parent 25bfe75 commit f768c1e

File tree

17 files changed

+403
-0
lines changed

17 files changed

+403
-0
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useRender, mergeProps } from '@base-ui/react';
2+
import clsx from 'clsx';
3+
import { forwardRef } from '@wordpress/element';
4+
import type { EmptyStateActionsProps } from './types';
5+
import styles from './style.module.css';
6+
7+
/**
8+
* A container for action buttons in an empty state. Actions are optional, and
9+
* can include a primary and optional secondary action button.
10+
*/
11+
export const Actions = forwardRef< HTMLDivElement, EmptyStateActionsProps >(
12+
function EmptyStateActions( { render, ...props }, ref ) {
13+
const className = clsx( styles.actions );
14+
15+
const element = useRender( {
16+
defaultTagName: 'div',
17+
render,
18+
ref,
19+
props: mergeProps< 'div' >( { className }, props ),
20+
} );
21+
22+
return element;
23+
}
24+
);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import clsx from 'clsx';
2+
import { forwardRef } from '@wordpress/element';
3+
import { Text } from '../text';
4+
import type { EmptyStateDescriptionProps } from './types';
5+
import styles from './style.module.css';
6+
7+
/**
8+
* The description text for an empty state, providing additional context and
9+
* guidance on what the user should do next.
10+
*/
11+
export const Description = forwardRef<
12+
HTMLParagraphElement,
13+
EmptyStateDescriptionProps
14+
>( function EmptyStateDescription(
15+
{ render, className, children, ...props },
16+
ref
17+
) {
18+
return (
19+
<Text
20+
variant="body-md"
21+
render={ render ?? <p ref={ ref } { ...props } /> }
22+
className={ clsx( styles.description, className ) }
23+
>
24+
{ children }
25+
</Text>
26+
);
27+
} );
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import clsx from 'clsx';
2+
import { forwardRef } from '@wordpress/element';
3+
import { Icon as _Icon } from '../icon';
4+
import { Visual } from './visual';
5+
import type { EmptyStateIconProps } from './types';
6+
import styles from './style.module.css';
7+
8+
/**
9+
* An icon visual for empty states. Renders an icon with styling treatment for
10+
* empty states.
11+
*/
12+
export const Icon = forwardRef< HTMLDivElement, EmptyStateIconProps >(
13+
function EmptyStateIcon( { icon, className, ...restProps }, ref ) {
14+
return (
15+
<Visual
16+
ref={ ref }
17+
className={ clsx( styles.icon, className ) }
18+
{ ...restProps }
19+
>
20+
<_Icon icon={ icon } />
21+
</Visual>
22+
);
23+
}
24+
);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Root } from './root';
2+
import { Visual } from './visual';
3+
import { Icon } from './icon';
4+
import { Title } from './title';
5+
import { Description } from './description';
6+
import { Actions } from './actions';
7+
8+
export { Root, Visual, Icon, Title, Description, Actions };
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { useRender, mergeProps } from '@base-ui/react';
2+
import clsx from 'clsx';
3+
import { forwardRef } from '@wordpress/element';
4+
import type { EmptyStateRootProps } from './types';
5+
import styles from './style.module.css';
6+
7+
/**
8+
* The root container for an empty state component.
9+
*/
10+
export const Root = forwardRef< HTMLDivElement, EmptyStateRootProps >(
11+
function EmptyStateRoot( { render, ...props }, ref ) {
12+
const className = clsx( styles.root );
13+
14+
const element = useRender( {
15+
defaultTagName: 'div',
16+
render,
17+
ref,
18+
props: mergeProps< 'div' >( { className }, props ),
19+
} );
20+
21+
return element;
22+
}
23+
);
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { search } from '@wordpress/icons';
3+
import { Button, EmptyState } from '../..';
4+
5+
const meta: Meta< typeof EmptyState.Root > = {
6+
title: 'Design System/Components/EmptyState',
7+
component: EmptyState.Root,
8+
subcomponents: {
9+
'EmptyState.Visual': EmptyState.Visual,
10+
'EmptyState.Icon': EmptyState.Icon,
11+
'EmptyState.Title': EmptyState.Title,
12+
'EmptyState.Description': EmptyState.Description,
13+
'EmptyState.Actions': EmptyState.Actions,
14+
},
15+
};
16+
export default meta;
17+
18+
type Story = StoryObj< typeof EmptyState.Root >;
19+
20+
export const Default: Story = {
21+
args: {
22+
children: (
23+
<>
24+
<EmptyState.Icon icon={ search } />
25+
<EmptyState.Title>No results found</EmptyState.Title>
26+
<EmptyState.Description>
27+
Try adjusting your search or filter to find what you&apos;re
28+
looking for.
29+
</EmptyState.Description>
30+
<EmptyState.Actions>
31+
<Button variant="outline">Clear filters</Button>
32+
<Button>Add item</Button>
33+
</EmptyState.Actions>
34+
</>
35+
),
36+
},
37+
};
38+
39+
export const WithCustomVisual: Story = {
40+
args: {
41+
children: (
42+
<>
43+
<EmptyState.Visual>
44+
<svg
45+
width="50"
46+
height="50"
47+
viewBox="0 0 50 50"
48+
fill="none"
49+
xmlns="http://www.w3.org/2000/svg"
50+
>
51+
<circle cx="25" cy="25" r="25" fill="currentColor" />
52+
</svg>
53+
</EmptyState.Visual>
54+
<EmptyState.Title>All caught up!</EmptyState.Title>
55+
<EmptyState.Description>
56+
You&apos;ve completed all your tasks. Great work!
57+
</EmptyState.Description>
58+
<EmptyState.Actions>
59+
<Button>Create new task</Button>
60+
</EmptyState.Actions>
61+
</>
62+
),
63+
},
64+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;
2+
3+
@layer wp-ui-components {
4+
.root {
5+
display: flex;
6+
flex-direction: column;
7+
align-items: center;
8+
text-align: center;
9+
max-width: var(--wpds-dimension-surface-width-sm);
10+
gap: var(--wpds-dimension-gap-xs);
11+
font-family: var(--wpds-font-family-body);
12+
color: var(--wpds-color-fg-content-neutral);
13+
text-wrap: balance;
14+
}
15+
16+
.visual {
17+
display: flex;
18+
margin-block-end: var(--wpds-dimension-gap-xs);
19+
align-items: center;
20+
justify-content: center;
21+
color: var(--wpds-color-fg-content-neutral-weak);
22+
line-height: 1;
23+
}
24+
25+
.icon {
26+
padding: var(--wpds-dimension-padding-xs);
27+
border: 1px solid var(--wpds-color-stroke-surface-neutral-weak);
28+
border-radius: 50%;
29+
background-color: var(--wpds-color-bg-surface-neutral-weak);
30+
}
31+
32+
.title {
33+
margin: 0;
34+
}
35+
36+
.description {
37+
margin: 0;
38+
color: var(--wpds-color-fg-content-neutral-weak);
39+
}
40+
41+
.actions {
42+
display: flex;
43+
margin-block-start: var(--wpds-dimension-gap-md);
44+
flex-direction: column;
45+
gap: var(--wpds-dimension-gap-xs);
46+
align-items: center;
47+
48+
@media (min-width: 480px) {
49+
flex-direction: row;
50+
justify-content: center;
51+
}
52+
}
53+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { render } from '@testing-library/react';
2+
import { createRef } from '@wordpress/element';
3+
import { Actions } from '../index';
4+
import { Button } from '../../button';
5+
6+
describe( 'EmptyState.Actions', () => {
7+
it( 'forwards ref', () => {
8+
const ref = createRef< HTMLDivElement >();
9+
10+
render(
11+
<Actions ref={ ref }>
12+
<Button>Action</Button>
13+
</Actions>
14+
);
15+
16+
expect( ref.current ).toBeInstanceOf( HTMLDivElement );
17+
} );
18+
} );
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { render } from '@testing-library/react';
2+
import { createRef } from '@wordpress/element';
3+
import { Description } from '../index';
4+
5+
describe( 'EmptyState.Description', () => {
6+
it( 'forwards ref', () => {
7+
const ref = createRef< HTMLParagraphElement >();
8+
9+
render( <Description ref={ ref }>Description text</Description> );
10+
11+
expect( ref.current ).toBeInstanceOf( HTMLParagraphElement );
12+
} );
13+
} );
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { render } from '@testing-library/react';
2+
import { createRef } from '@wordpress/element';
3+
import { Icon } from '../index';
4+
5+
describe( 'EmptyState.Icon', () => {
6+
it( 'forwards ref', () => {
7+
const ref = createRef< HTMLDivElement >();
8+
9+
render( <Icon ref={ ref } icon={ <svg /> } /> );
10+
11+
expect( ref.current ).toBeInstanceOf( HTMLDivElement );
12+
} );
13+
} );

0 commit comments

Comments
 (0)