Skip to content

Commit eb80876

Browse files
ciampojameskosterCGastrellmirka
authored
admin-ui / Breadcrumbs: stricter items[].to prop types (WordPress#76493)
* admin-ui/Breadcrumbs: enforce "to" prop on all but last item * Add JSDoc * Add type tests * build docs * Allow the rendering of an h1 only for the last item * Update docs * Format * fix eslint error * CHANGELOG * Auto-format * Remove unnecessary `key` attribute * Use runtime check (throws error in dev mode) instead of stricter types * Better error message * format * remove unused import --- Co-authored-by: ciampo <mciampini@git.wordpress.org> Co-authored-by: jameskoster <jameskoster@git.wordpress.org> Co-authored-by: CGastrell <cgastrell@git.wordpress.org> Co-authored-by: mirka <0mirka00@git.wordpress.org>
1 parent 2f143ce commit eb80876

File tree

5 files changed

+249
-34
lines changed

5 files changed

+249
-34
lines changed

packages/admin-ui/CHANGELOG.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,25 @@
22

33
## Unreleased
44

5+
### Bug Fixes
6+
7+
- `Breadcrumbs`: throw a runtime error when non-last items are missing a `to` prop [#76493](https://github.com/WordPress/gutenberg/pull/76493/)
8+
59
## 1.10.0 (2026-03-18)
610

7-
- Update Title and Breadcrumbs font sizes. [#76452](https://github.com/WordPress/gutenberg/pull/76452)
11+
- Update Title and Breadcrumbs font sizes. [#76452](https://github.com/WordPress/gutenberg/pull/76452)
812

913
## 1.9.0 (2026-03-04)
1014

1115
### Bug Fixes
1216

13-
- Fix type mismatch between Page `title` (ReactNode) and NavigableRegion `ariaLabel` (string) by adding an optional `ariaLabel` prop to Page that falls back to `title` when it is a string. [#75899](https://github.com/WordPress/gutenberg/pull/75899/)
17+
- Fix type mismatch between Page `title` (ReactNode) and NavigableRegion `ariaLabel` (string) by adding an optional `ariaLabel` prop to Page that falls back to `title` when it is a string. [#75899](https://github.com/WordPress/gutenberg/pull/75899/)
1418

1519
## 1.8.0 (2026-02-18)
1620

1721
### Enhancements
1822

19-
- Apply `text-wrap: pretty` for more balanced text in Page component [#74907](https://github.com/WordPress/gutenberg/pull/74907)
23+
- Apply `text-wrap: pretty` for more balanced text in Page component [#74907](https://github.com/WordPress/gutenberg/pull/74907)
2024

2125
## 1.7.0 (2026-01-29)
2226

packages/admin-ui/README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,26 @@ npm install @wordpress/admin-ui --save
1616

1717
### Breadcrumbs
1818

19-
Undocumented declaration.
19+
Renders a breadcrumb navigation trail.
20+
21+
All items except the last one must provide a `to` prop for navigation. In development mode, an error is thrown when a non-last item is missing `to`. The last item represents the current page and its `to` prop is optional. Only the last item (when it has no `to` prop) is rendered as an `h1`.
22+
23+
_Usage_
24+
25+
```jsx
26+
<Breadcrumbs
27+
items={ [
28+
{ label: 'Home', to: '/' },
29+
{ label: 'Settings', to: '/settings' },
30+
{ label: 'General' },
31+
] }
32+
/>
33+
```
34+
35+
_Parameters_
36+
37+
- _props_ `BreadcrumbsProps`:
38+
- _props.items_ `BreadcrumbsProps[ 'items' ]`: The breadcrumb items to display.
2039

2140
### NavigableRegion
2241

packages/admin-ui/src/breadcrumbs/index.tsx

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,38 +11,47 @@ import {
1111
/**
1212
* Internal dependencies
1313
*/
14-
import type {
15-
BreadcrumbsProps,
16-
BreadcrumbItem as BreadcrumbItemType,
17-
} from './types';
18-
19-
const BreadcrumbItem = ( {
20-
item: { label, to },
21-
}: {
22-
item: BreadcrumbItemType;
23-
} ) => {
24-
if ( ! to ) {
25-
return (
26-
<li>
27-
<Heading level={ 1 } truncate>
28-
{ label }
29-
</Heading>
30-
</li>
31-
);
32-
}
33-
34-
return (
35-
<li>
36-
<Link to={ to }>{ label }</Link>
37-
</li>
38-
);
39-
};
14+
import type { BreadcrumbsProps } from './types';
4015

16+
/**
17+
* Renders a breadcrumb navigation trail.
18+
*
19+
* All items except the last one must provide a `to` prop for navigation.
20+
* In development mode, an error is thrown when a non-last item is missing `to`.
21+
* The last item represents the current page and its `to` prop is optional.
22+
* Only the last item (when it has no `to` prop) is rendered as an `h1`.
23+
*
24+
* @param props
25+
* @param props.items The breadcrumb items to display.
26+
*
27+
* @example
28+
* ```jsx
29+
* <Breadcrumbs
30+
* items={ [
31+
* { label: 'Home', to: '/' },
32+
* { label: 'Settings', to: '/settings' },
33+
* { label: 'General' },
34+
* ] }
35+
* />
36+
* ```
37+
*/
4138
export const Breadcrumbs = ( { items }: BreadcrumbsProps ) => {
4239
if ( ! items.length ) {
4340
return null;
4441
}
4542

43+
const precedingItems = items.slice( 0, -1 );
44+
const lastItem = items[ items.length - 1 ];
45+
46+
if ( process.env.NODE_ENV !== 'production' ) {
47+
const invalidItem = precedingItems.find( ( item ) => ! item.to );
48+
if ( invalidItem ) {
49+
throw new Error(
50+
`Breadcrumbs: item "${ invalidItem.label }" is missing a \`to\` prop. All items except the last one must have a \`to\` prop.`
51+
);
52+
}
53+
}
54+
4655
return (
4756
<nav aria-label={ __( 'Breadcrumbs' ) }>
4857
<HStack
@@ -52,9 +61,20 @@ export const Breadcrumbs = ( { items }: BreadcrumbsProps ) => {
5261
justify="flex-start"
5362
alignment="center"
5463
>
55-
{ items.map( ( item, index ) => (
56-
<BreadcrumbItem key={ index } item={ item } />
64+
{ precedingItems.map( ( item, index ) => (
65+
<li key={ index }>
66+
<Link to={ item.to }>{ item.label }</Link>
67+
</li>
5768
) ) }
69+
<li>
70+
{ lastItem.to ? (
71+
<Link to={ lastItem.to }>{ lastItem.label }</Link>
72+
) : (
73+
<Heading level={ 1 } truncate>
74+
{ lastItem.label }
75+
</Heading>
76+
) }
77+
</li>
5878
</HStack>
5979
</nav>
6080
);
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import { render, screen } from '@testing-library/react';
5+
6+
/**
7+
* Internal dependencies
8+
*/
9+
import { Breadcrumbs } from '..';
10+
11+
jest.mock( '@wordpress/route', () => ( {
12+
Link: ( { to, children }: { to: string; children: React.ReactNode } ) => (
13+
<a href={ to }>{ children }</a>
14+
),
15+
} ) );
16+
17+
describe( 'Breadcrumbs', () => {
18+
describe( 'validation', () => {
19+
it( 'should throw when a preceding item is missing `to`', () => {
20+
expect( () =>
21+
render(
22+
<Breadcrumbs
23+
items={ [
24+
{ label: 'Home' },
25+
{ label: 'Settings', to: '/settings' },
26+
{ label: 'General' },
27+
] }
28+
/>
29+
)
30+
).toThrow( /item "Home" is missing a `to` prop/ );
31+
expect( console ).toHaveErrored();
32+
} );
33+
34+
it( 'should throw for the first preceding item missing `to`', () => {
35+
expect( () =>
36+
render(
37+
<Breadcrumbs
38+
items={ [
39+
{ label: 'Home' },
40+
{ label: 'Settings' },
41+
{ label: 'General' },
42+
] }
43+
/>
44+
)
45+
).toThrow( /item "Home" is missing a `to` prop/ );
46+
expect( console ).toHaveErrored();
47+
} );
48+
49+
it( 'should not throw when all preceding items have `to`', () => {
50+
expect( () =>
51+
render(
52+
<Breadcrumbs
53+
items={ [
54+
{ label: 'Home', to: '/' },
55+
{ label: 'Settings', to: '/settings' },
56+
{ label: 'General' },
57+
] }
58+
/>
59+
)
60+
).not.toThrow();
61+
} );
62+
63+
it( 'should not throw when there is only one item without `to`', () => {
64+
expect( () =>
65+
render( <Breadcrumbs items={ [ { label: 'Dashboard' } ] } /> )
66+
).not.toThrow();
67+
} );
68+
69+
it( 'should not throw when items is empty', () => {
70+
expect( () =>
71+
render( <Breadcrumbs items={ [] } /> )
72+
).not.toThrow();
73+
} );
74+
} );
75+
76+
describe( 'rendering', () => {
77+
it( 'should render nothing when items is empty', () => {
78+
const { container } = render( <Breadcrumbs items={ [] } /> );
79+
expect( container ).toBeEmptyDOMElement();
80+
} );
81+
82+
it( 'should render the last item as an h1 when it has no `to`', () => {
83+
render(
84+
<Breadcrumbs
85+
items={ [
86+
{ label: 'Home', to: '/' },
87+
{ label: 'Current Page' },
88+
] }
89+
/>
90+
);
91+
92+
expect(
93+
screen.getByRole( 'heading', { level: 1 } )
94+
).toHaveTextContent( 'Current Page' );
95+
} );
96+
97+
it( 'should render the last item as a link when it has `to`', () => {
98+
render(
99+
<Breadcrumbs
100+
items={ [
101+
{ label: 'Home', to: '/' },
102+
{ label: 'Settings', to: '/settings' },
103+
] }
104+
/>
105+
);
106+
107+
expect(
108+
screen.queryByRole( 'heading', { level: 1 } )
109+
).not.toBeInTheDocument();
110+
111+
const links = screen.getAllByRole( 'link' );
112+
expect( links ).toHaveLength( 2 );
113+
expect( links[ 1 ] ).toHaveTextContent( 'Settings' );
114+
expect( links[ 1 ] ).toHaveAttribute( 'href', '/settings' );
115+
} );
116+
117+
it( 'should render preceding items as links', () => {
118+
render(
119+
<Breadcrumbs
120+
items={ [
121+
{ label: 'Home', to: '/' },
122+
{ label: 'Settings', to: '/settings' },
123+
{ label: 'General' },
124+
] }
125+
/>
126+
);
127+
128+
const links = screen.getAllByRole( 'link' );
129+
expect( links ).toHaveLength( 2 );
130+
expect( links[ 0 ] ).toHaveTextContent( 'Home' );
131+
expect( links[ 0 ] ).toHaveAttribute( 'href', '/' );
132+
expect( links[ 1 ] ).toHaveTextContent( 'Settings' );
133+
expect( links[ 1 ] ).toHaveAttribute( 'href', '/settings' );
134+
} );
135+
136+
it( 'should never render preceding items as headings', () => {
137+
render(
138+
<Breadcrumbs
139+
items={ [
140+
{ label: 'Home', to: '/' },
141+
{ label: 'Settings', to: '/settings' },
142+
{ label: 'General' },
143+
] }
144+
/>
145+
);
146+
147+
const headings = screen.getAllByRole( 'heading', { level: 1 } );
148+
expect( headings ).toHaveLength( 1 );
149+
expect( headings[ 0 ] ).toHaveTextContent( 'General' );
150+
} );
151+
152+
it( 'should render a single item without `to` as an h1', () => {
153+
render( <Breadcrumbs items={ [ { label: 'Dashboard' } ] } /> );
154+
155+
expect(
156+
screen.getByRole( 'heading', { level: 1 } )
157+
).toHaveTextContent( 'Dashboard' );
158+
expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument();
159+
} );
160+
161+
it( 'should render inside a nav with an accessible label', () => {
162+
render( <Breadcrumbs items={ [ { label: 'Home', to: '/' } ] } /> );
163+
164+
expect(
165+
screen.getByRole( 'navigation', { name: 'Breadcrumbs' } )
166+
).toBeInTheDocument();
167+
} );
168+
} );
169+
} );

packages/admin-ui/src/breadcrumbs/types.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@ export interface BreadcrumbItem {
66

77
/**
88
* The router path that the breadcrumb item should link to.
9-
* It is optional because the current item does not have a link.
9+
* It is optional for the last item (the current page).
10+
* All preceding items should provide a `to` prop.
1011
*/
1112
to?: string;
1213
}
1314

1415
export interface BreadcrumbsProps extends React.HTMLAttributes< HTMLElement > {
1516
/**
1617
* An array of items to display in the breadcrumb trail.
17-
* The last item is considered the current item.
18+
* The last item is considered the current item and has an optional `to` prop.
19+
* All preceding items must have a `to` prop — in development mode,
20+
* an error is thrown when this requirement is not met.
1821
*/
1922
items: BreadcrumbItem[];
2023
/**

0 commit comments

Comments
 (0)