Skip to content

Commit d2e3a54

Browse files
Dashboard v2: Add PageHeader component (#103159)
* Dashboard v2: Add PageHeader component * move PageHeader under `client/dashboard` * change default level to `1` * remove `level` prop and adjust composition * remove changelog entry * remove old files * Style adjustments * remove `Heading` and rename breadcrumbs prop * Style adjustments * feedback * Style adjustments * update `actions` type * breadcrumb composition * rename to breadcrumbs --------- Co-authored-by: James Koster <[email protected]>
1 parent ba34191 commit d2e3a54

File tree

28 files changed

+468
-158
lines changed

28 files changed

+468
-158
lines changed

client/dashboard/.eslintrc.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ module.exports = {
3232
'@automattic/components/src/*',
3333
'!@automattic/components/src/summary-button',
3434
'!@automattic/components/src/core-badge',
35+
'!@automattic/components/src/breadcrumbs',
36+
'!@automattic/components/src/breadcrumbs/types',
3537
'!@automattic/dataviews',
3638
// Please do not add exceptions unless agreed on
3739
// with the #architecture group.
Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
import { Button } from '@wordpress/components';
22
import { __ } from '@wordpress/i18n';
3+
import { PageHeader } from '../components/page-header';
34
import PageLayout from '../components/page-layout';
45

56
export default function AgencyOverview() {
67
return (
7-
<PageLayout
8-
title={ __( 'Agency Overview' ) }
9-
actions={
10-
<>
11-
<Button variant="primary" __next40pxDefaultSize>
12-
{ __( 'Add Sites' ) }
13-
</Button>
14-
<Button variant="secondary" __next40pxDefaultSize>
15-
{ __( 'Add Products' ) }
16-
</Button>
17-
</>
18-
}
19-
description={ __( 'This is a sample overview page.' ) }
20-
/>
8+
<PageLayout>
9+
<PageHeader
10+
title={ __( 'Agency Overview' ) }
11+
actions={
12+
<>
13+
<Button variant="primary" __next40pxDefaultSize>
14+
{ __( 'Add Sites' ) }
15+
</Button>
16+
<Button variant="secondary" __next40pxDefaultSize>
17+
{ __( 'Add Products' ) }
18+
</Button>
19+
</>
20+
}
21+
description={ __( 'This is a sample overview page.' ) }
22+
/>
23+
</PageLayout>
2124
);
2225
}

client/dashboard/app/404/index.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import { __ } from '@wordpress/i18n';
2+
import { PageHeader } from '../../components/page-header';
23
import PageLayout from '../../components/page-layout';
34
import RouterLinkButton from '../../components/router-link-button';
45

56
function NotFound() {
67
return (
7-
<PageLayout
8-
title={ __( '404 Not Found' ) }
9-
description={ __( 'The page you are looking for does not exist.' ) }
10-
actions={
11-
<RouterLinkButton to="/sites" variant="primary" __next40pxDefaultSize>
12-
{ __( 'Go to Sites' ) }
13-
</RouterLinkButton>
14-
}
15-
/>
8+
<PageLayout>
9+
<PageHeader
10+
title={ __( '404 Not Found' ) }
11+
description={ __( 'The page you are looking for does not exist.' ) }
12+
actions={
13+
<RouterLinkButton to="/sites" variant="primary" __next40pxDefaultSize>
14+
{ __( 'Go to Sites' ) }
15+
</RouterLinkButton>
16+
}
17+
/>
18+
</PageLayout>
1619
);
1720
}
1821

client/dashboard/app/500/index.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import { Notice } from '@wordpress/components';
22
import { __ } from '@wordpress/i18n';
3+
import { PageHeader } from '../../components/page-header';
34
import PageLayout from '../../components/page-layout';
45
import RouterLinkButton from '../../components/router-link-button';
56

67
function UnknownError( { error }: { error: Error } ) {
78
return (
8-
<PageLayout
9-
title={ __( '500 Error' ) }
10-
description={ __( 'Something wrong happened.' ) }
11-
actions={
12-
<RouterLinkButton to="/sites" variant="primary" __next40pxDefaultSize>
13-
{ __( 'Go to Sites' ) }
14-
</RouterLinkButton>
15-
}
16-
>
9+
<PageLayout>
10+
<PageHeader
11+
title={ __( '500 Error' ) }
12+
description={ __( 'Something wrong happened.' ) }
13+
actions={
14+
<RouterLinkButton to="/sites" variant="primary" __next40pxDefaultSize>
15+
{ __( 'Go to Sites' ) }
16+
</RouterLinkButton>
17+
}
18+
/>
1719
<Notice status="error" isDismissible={ false }>
1820
{ error.message }
1921
</Notice>
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Breadcrumbs } from '@automattic/components/src/breadcrumbs';
2+
import { Meta, StoryObj } from '@storybook/react';
3+
import { Button, Icon, DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
4+
import { help, wordpress, moreVertical } from '@wordpress/icons';
5+
import { PageHeader } from './index';
6+
7+
const meta = {
8+
title: 'client/dashboard/PageHeader',
9+
component: PageHeader,
10+
tags: [ 'autodocs' ],
11+
parameters: {
12+
actions: { argTypesRegex: '^on.*' },
13+
},
14+
} satisfies Meta< typeof PageHeader >;
15+
16+
export default meta;
17+
type Story = StoryObj< typeof meta >;
18+
19+
export const Default: Story = {
20+
args: {
21+
title: 'Settings',
22+
description: 'Configure your application settings',
23+
},
24+
};
25+
26+
export const WithActions: Story = {
27+
args: {
28+
title: 'Site Settings',
29+
description: `Manage how your site works and appears. Configure your site's basic functionality,
30+
appearance, and behavior. These settings control everything from your site title to how your
31+
content is displayed to visitors.`,
32+
actions: (
33+
<>
34+
<Button variant="secondary">Cancel</Button>
35+
<Button variant="primary">Save Changes</Button>
36+
</>
37+
),
38+
},
39+
};
40+
41+
export const ImageDecoration: Story = {
42+
args: {
43+
title: 'Site Customization',
44+
description: 'Make your site look exactly how you want it to',
45+
decoration: <img src="https://placecats.com/300/200" alt="Cat" />,
46+
actions: (
47+
<>
48+
<Button variant="secondary">Cancel</Button>
49+
<Button variant="primary">Save Changes</Button>
50+
</>
51+
),
52+
},
53+
};
54+
55+
export const FullExample: Story = {
56+
args: {
57+
title: 'Site Customization',
58+
description: 'Make your site look exactly how you want it to',
59+
decoration: <Icon icon={ wordpress } />,
60+
breadcrumbs: (
61+
<Breadcrumbs
62+
items={ [
63+
{ label: 'Dashboard', href: 'javascript:void(0)' },
64+
{ label: 'Appearance', href: 'javascript:void(0)' },
65+
{ label: 'Customize', href: 'javascript:void(0)' },
66+
{ label: 'Theme', href: 'javascript:void(0)' },
67+
{ label: 'Advanced', href: 'javascript:void(0)' },
68+
] }
69+
/>
70+
),
71+
actions: (
72+
<>
73+
<Button icon={ help } variant="tertiary" __next40pxDefaultSize>
74+
Help
75+
</Button>
76+
<Button variant="secondary" __next40pxDefaultSize>
77+
Preview
78+
</Button>
79+
<DropdownMenu
80+
icon={ moreVertical }
81+
label="More actions"
82+
toggleProps={ { __next40pxDefaultSize: true } }
83+
>
84+
{ () => (
85+
<>
86+
<MenuGroup>
87+
<MenuItem>Import</MenuItem>
88+
<MenuItem>Export</MenuItem>
89+
<MenuItem>Settings</MenuItem>
90+
</MenuGroup>
91+
<MenuGroup>
92+
<MenuItem>Help</MenuItem>
93+
</MenuGroup>
94+
</>
95+
) }
96+
</DropdownMenu>
97+
</>
98+
),
99+
},
100+
};
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {
2+
__experimentalVStack as VStack,
3+
__experimentalHStack as HStack,
4+
__experimentalText as Text,
5+
} from '@wordpress/components';
6+
import type { PageHeaderProps } from './types';
7+
8+
import './style.scss';
9+
10+
/**
11+
* The PageHeader component provides a structured introduction to a page or section,
12+
* combining a title, optional description, and contextual actions. It supports
13+
* varying levels of hierarchy through semantic heading levels, and can include
14+
* visual decorations, navigational aids like breadcrumbs, and utility controls
15+
* such as buttons or dropdowns.
16+
*
17+
* ```jsx
18+
* import { PageHeader } from '@automattic/components';
19+
* import { Button } from '@wordpress/components';
20+
* import { cog } from '@wordpress/icons';
21+
*
22+
* function MyComponent() {
23+
* return (
24+
* <PageHeader
25+
* title="Settings"
26+
* description="Configure your application settings"
27+
* decoration={<Icon icon={cog} />}
28+
* actions={<Button variant="primary">Save Changes</Button>}
29+
* />
30+
* );
31+
* }
32+
* ```
33+
*/
34+
export const PageHeader = ( {
35+
title,
36+
description,
37+
actions,
38+
decoration,
39+
breadcrumbs,
40+
}: PageHeaderProps ) => {
41+
return (
42+
<VStack spacing={ 2 }>
43+
{ breadcrumbs }
44+
<HStack spacing={ 4 } justify="flex-start" alignment="flex-start">
45+
{ decoration && (
46+
<span className="client-dashboard-components-page-header__decoration">
47+
{ decoration }
48+
</span>
49+
) }
50+
<HStack spacing={ 3 } justify="space-between" alignment="flex-start">
51+
<h1 className="client-dashboard-components-page-header__heading">{ title }</h1>
52+
{ !! actions && (
53+
<HStack
54+
spacing={ 2 }
55+
justify="flex-end"
56+
expanded={ false }
57+
className="client-dashboard-components-page-header__actions"
58+
>
59+
{ actions }
60+
</HStack>
61+
) }
62+
</HStack>
63+
</HStack>
64+
{ description && (
65+
<Text variant="muted" className="client-dashboard-components-page-header__description">
66+
{ description }
67+
</Text>
68+
) }
69+
</VStack>
70+
);
71+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
@import "@wordpress/base-styles/variables";
2+
3+
.client-dashboard-components-page-header__heading {
4+
font-size: $font-size-2x-large;
5+
line-height: $font-line-height-2x-large;
6+
margin-block: 0;
7+
}
8+
9+
.client-dashboard-components-page-header__decoration {
10+
display: inline-flex;
11+
width: $grid-unit-50;
12+
height: $grid-unit-50;
13+
flex-shrink: 0;
14+
align-items: center;
15+
justify-content: center;
16+
17+
svg {
18+
fill: $gray-700;
19+
flex-shrink: 0;
20+
width: $grid-unit-30;
21+
height: $grid-unit-30;
22+
}
23+
24+
img {
25+
flex-shrink: 0;
26+
width: 100%;
27+
height: 100%;
28+
object-fit: cover;
29+
border-radius: $radius-small;
30+
}
31+
}
32+
33+
.client-dashboard-components-page-header__actions {
34+
flex-shrink: 0;
35+
}
36+
37+
.client-dashboard-components-page-header__description {
38+
max-width: 75ch; /* stylelint-disable-line unit-allowed-list */
39+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
import { Breadcrumbs } from '@automattic/components/src/breadcrumbs';
5+
import { render, screen } from '@testing-library/react';
6+
import { Button, Icon } from '@wordpress/components';
7+
import { cog } from '@wordpress/icons';
8+
import { PageHeader } from '..';
9+
10+
describe( 'PageHeader', () => {
11+
test( 'should render with title', () => {
12+
render( <PageHeader title="Test Title" /> );
13+
expect( screen.getByRole( 'heading', { name: 'Test Title' } ) ).toBeVisible();
14+
} );
15+
test( 'should render with description', () => {
16+
render( <PageHeader title="Test Title" description="Test Description" /> );
17+
expect( screen.getByText( 'Test Description' ) ).toBeVisible();
18+
} );
19+
test( 'should render with action buttons', () => {
20+
render(
21+
<PageHeader
22+
title="Test Title"
23+
actions={
24+
<>
25+
<Button>Cancel</Button>
26+
<Button>Save</Button>
27+
</>
28+
}
29+
/>
30+
);
31+
expect( screen.getByRole( 'button', { name: 'Cancel' } ) ).toBeVisible();
32+
expect( screen.getByRole( 'button', { name: 'Save' } ) ).toBeVisible();
33+
} );
34+
test( 'should render with decoration', () => {
35+
render(
36+
<PageHeader
37+
title="Test Title"
38+
decoration={ <Icon data-testid="decoration" icon={ cog } /> }
39+
/>
40+
);
41+
expect( screen.getByTestId( 'decoration' ) ).toBeVisible();
42+
} );
43+
test( 'should render with breadcrumbs', () => {
44+
render(
45+
<PageHeader
46+
title="Test Title"
47+
breadcrumbs={
48+
<Breadcrumbs
49+
items={ [
50+
{ label: 'Home', href: '/' },
51+
{ label: 'Products', href: '/products' },
52+
{ label: 'Categories', href: '/products/categories' },
53+
] }
54+
/>
55+
}
56+
/>
57+
);
58+
expect( screen.getByRole( 'heading', { name: 'Test Title' } ).tagName ).toBe( 'H1' );
59+
expect( screen.getByRole( 'navigation' ) ).toHaveAccessibleName( 'Breadcrumbs' );
60+
expect( screen.getAllByRole( 'listitem' ) ).toHaveLength( 3 );
61+
} );
62+
} );

0 commit comments

Comments
 (0)