Skip to content
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
4a3a1c0
Admin UI: Add Navigation component
retrofox Jul 1, 2026
722e300
Admin UI: Add navigation slot to Page
retrofox Jul 1, 2026
19dded9
Admin UI: Fix Navigation test search mock
retrofox Jul 1, 2026
f5bab03
Admin UI: Switch Page navigation to a config API
retrofox Jul 2, 2026
b918a7f
Admin UI: Fix Page navigation item href handling
retrofox Jul 3, 2026
355ef86
Admin UI: Add 2.5.0 heading to the changelog
retrofox Jul 3, 2026
3b1c267
Merge branch 'trunk' into update/admin-ui-page-navigation
retrofox Jul 6, 2026
a88f905
replace Link with LinkButton
retrofox Jul 6, 2026
8ccc963
clean colors
retrofox Jul 6, 2026
23f8f29
Merge remote-tracking branch 'origin/trunk' into update/admin-ui-page…
retrofox Jul 15, 2026
d43acbb
Merge remote-tracking branch 'origin/trunk' into update/admin-ui-page…
retrofox Jul 15, 2026
10d1746
Admin UI: rename selected prop to currentHref
retrofox Jul 15, 2026
53158b7
Admin UI: validate unique navigation hrefs in dev
retrofox Jul 15, 2026
32eba68
Admin UI: make navigation aria-label configurable
retrofox Jul 15, 2026
e4f4372
Admin UI: accept a readonly navigation items array
retrofox Jul 15, 2026
158adca
Admin UI: destructure Navigation props in the signature
retrofox Jul 15, 2026
bd33cca
Admin UI: render navigation items with Link
retrofox Jul 15, 2026
4bdcd73
Admin UI: restore current-item styling and target size
retrofox Jul 15, 2026
3e40d75
Admin UI: add Page navigation integration tests
retrofox Jul 15, 2026
fe1b0a7
Merge remote-tracking branch 'origin/trunk' into update/admin-ui-page…
retrofox Jul 15, 2026
bea588b
Merge remote-tracking branch 'origin/trunk' into update/admin-ui-page…
retrofox Aug 10, 2026
16a787f
Admin UI: drop import grouping comments
retrofox Aug 10, 2026
8a54a4a
Admin UI: guard empty navigation in Page header
retrofox Aug 10, 2026
1d3ca3a
Admin UI: add explicit list role to navigation
retrofox Aug 10, 2026
38687cf
Admin UI: inline the navigation aria-label default
retrofox Aug 10, 2026
f3632a4
Admin UI: keep navigation focus ring visible
retrofox Aug 10, 2026
47e31c2
Admin UI: complete the navigation target size
retrofox Aug 10, 2026
65d92eb
Admin UI: space header navigation with margin
retrofox Aug 10, 2026
8abe4fc
Admin UI: link the changelog entry to the PR
retrofox Aug 10, 2026
11912bf
Admin UI: add navigation components.link support
retrofox Aug 10, 2026
3c7066e
Page: Fix Navigation intercepted link handling
aduth Aug 10, 2026
195ae6d
Page: Improve test coverage for scenarios flagged in review
aduth Aug 10, 2026
88b5f92
Admin UI: Improve styling alignment for Page navigation to spec
aduth Aug 10, 2026
8af048c
Admin UI: Flatten DOM by applying className to Navigation
aduth Aug 10, 2026
554a4c2
Admin UI: Simplify Navigation LinkComponent forwarding
aduth Aug 10, 2026
38ffb1f
Admin UI: Treat href as consistently present
aduth Aug 11, 2026
4cfaf48
Admin UI: Surface components prop to top-level Page component
aduth Aug 11, 2026
4259798
Admin UI: Update Link types to accommodate intrinsic `'a'` element
aduth Aug 11, 2026
085c839
Revert "Admin UI: Update Link types to accommodate intrinsic `'a'` el…
aduth Aug 11, 2026
6937f88
Admin UI: Split public and internal props for Navigation
aduth Aug 11, 2026
96ef59e
Merge remote-tracking branch 'origin/trunk' into update/admin-ui-page…
retrofox Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/admin-ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### New Features

- `Page`: Add a `navigation` prop (`{ items, currentHref, components }`) for link-based navigation between sections, rendered in the page header ([#79746](https://github.com/WordPress/gutenberg/pull/79746)).

### Bug Fixes

- `Breadcrumbs`: reset the list item margin that wp-admin's `common.css` applies, which left the trail misaligned with the rest of the page header ([#81134](https://github.com/WordPress/gutenberg/pull/81134)).
Expand Down
92 changes: 92 additions & 0 deletions packages/admin-ui/src/navigation/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { __ } from '@wordpress/i18n';
import { Link, Stack, Text } from '@wordpress/ui';
import type { NavigationProps } from './types';
import styles from './style.module.css';

/**
* Renders a horizontal list of links for navigating between the sections of a
* screen. The item whose `href` matches `currentHref` is marked with
* `aria-current="page"`.
*
* Internal to the package: configured through the `Page` `navigation` prop.
*/
export const Navigation = ( {
items,
currentHref,
ariaLabel = __( 'Sections' ),
components,
className,
}: NavigationProps ) => {
if ( ! items.length ) {
return null;
}

const LinkComponent = components?.link;

if ( process.env.NODE_ENV !== 'production' ) {
const invalidItem = items.find(
( item ) => typeof item.href !== 'string'
);
if ( invalidItem ) {
throw new Error(
`Navigation: item "${ invalidItem.label }" is missing an \`href\` prop.`
);
}

const duplicate = items.find(
( item, index ) =>
items.findIndex( ( other ) => other.href === item.href ) !==
index
);
if ( duplicate ) {
throw new Error(
`Navigation: duplicate \`href\` "${ duplicate.href }". Each item must have a unique \`href\` so a single item receives \`aria-current="page"\`.`
);
}
}
Comment thread
ciampo marked this conversation as resolved.
Resolved

return (
<nav aria-label={ ariaLabel } className={ className }>
{ /*
* Disable reason: The `list` ARIA role is redundant but
* Safari+VoiceOver won't announce the list otherwise.
*/
/* eslint-disable jsx-a11y/no-redundant-roles */ }
<Stack
render={ <ul role="list" /> }
direction="row"
align="center"
gap="md"
className={ styles.list }
>
{ items.map( ( item ) => (
<li key={ item.href } className={ styles.li }>
<Text
variant="body-md"
className={ styles.item }
render={
<Link
variant="unstyled"
href={ item.href }
aria-current={
item.href === currentHref
? 'page'
: undefined
}
render={
LinkComponent && <LinkComponent />
}
/>
}
>
{ item.label }
</Text>
</li>
) ) }
</Stack>
{ /* eslint-enable jsx-a11y/no-redundant-roles */ }
</nav>
);
};

export default Navigation;
44 changes: 44 additions & 0 deletions packages/admin-ui/src/navigation/style.module.css
Comment thread
ciampo marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
.list {
/**
* Give the scrollport room for the outset focus ring (outline width plus
* offset), which the overflow clipping would otherwise cut off. The
* negative margin cancels the padding out of the surrounding layout.
*/
--focus-ring-size: calc(2 * var(--wpds-border-width-focus));

list-style: none;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks the voice over of a list on Safari, should we add role="list" to the component it-self?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, added role="list" on the <ul> itself.

The jsx-a11y/no-redundant-roles rule flags the explicit role, so it comes with the same disable-reason note that the font library in global-styles-ui already uses for this exact Safari + VoiceOver case.

padding: var(--focus-ring-size);
margin: calc(-1 * var(--focus-ring-size));
/**
* Scroll horizontally rather than wrap or shrink when items overflow.
*/
overflow-x: auto;
scroll-padding: var(--focus-ring-size);
}

.li {
flex-shrink: 0;
}

.item {
display: flex;
align-items: center;
/**
* Meet the 44x44px pointer target size of WCAG 2.5.5 Target Size
* (Enhanced). No design token maps to this accessibility constant.
*/
min-block-size: 44px;
Comment on lines +26 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably add a 44px min width, too, to fully satisfy the criterion. It's unlikely that a link will ever be that short, but better to make sure, I guess

Also, WCAG 2.5.8 is not the correct criterion: That criterion is Target Size (Minimum) and specifies 24×24 CSS pixels (with exceptions); 44×44 is WCAG 2.5.5 Target Size (Enhanced). We should update the comment to 2.5.5

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both done 👍
min-inline-size: 44px is in, and the comment now cites the right criterion, 2.5.5 Target Size (Enhanced).

Labels stay start-aligned, so on a very short item the extra width widens the target without shifting the text.

min-inline-size: 44px;
color: var(--wpds-color-foreground-interactive-neutral-weak);
font-weight: var(--wpds-typography-font-weight-default);
text-decoration: none;
}
Comment thread
ciampo marked this conversation as resolved.
Resolved

.item:hover {
color: var(--wpds-color-foreground-interactive-neutral-weak-active);
}

.item[aria-current="page"] {
color: var(--wpds-color-foreground-interactive-neutral-weak-active);
font-weight: var(--wpds-typography-font-weight-emphasis);
}
191 changes: 191 additions & 0 deletions packages/admin-ui/src/navigation/test/index.test.tsx
Comment thread
ciampo marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { render, screen } from '@testing-library/react';
import { Navigation } from '..';

describe( 'Navigation', () => {
describe( 'validation', () => {
it( 'should throw when an item is missing `href`', () => {
expect( () =>
render(
<Navigation
items={ [
{ label: 'Overview', href: '/overview' },
{ label: 'Products' } as never,
] }
/>
)
).toThrow( /item "Products" is missing an `href` prop/ );
expect( console ).toHaveErrored();
} );

it( 'should throw when two items share the same `href`', () => {
expect( () =>
render(
<Navigation
items={ [
{ label: 'Overview', href: '/overview' },
{ label: 'Duplicate', href: '/overview' },
] }
/>
)
).toThrow( /duplicate `href` "\/overview"/ );
expect( console ).toHaveErrored();
} );

it( 'should not throw when all items have `href`', () => {
expect( () =>
render(
<Navigation
items={ [
{ label: 'Overview', href: '/overview' },
{ label: 'Products', href: '/products' },
] }
/>
)
).not.toThrow();
} );

it( 'should not throw when items is empty', () => {
expect( () => render( <Navigation items={ [] } /> ) ).not.toThrow();
} );

it( 'should allow an empty string `href`', () => {
expect( () =>
render(
<Navigation
items={ [
{ label: 'Overview', href: '' },
{ label: 'Products', href: '/products' },
] }
currentHref=""
/>
)
).not.toThrow();
} );
} );

describe( 'rendering', () => {
it( 'should render nothing when items is empty', () => {
const { container } = render( <Navigation items={ [] } /> );
expect( container ).toBeEmptyDOMElement();
} );

it( 'should render inside a nav with a default accessible label', () => {
render(
<Navigation
items={ [ { label: 'Overview', href: '/overview' } ] }
/>
);

expect(
screen.getByRole( 'navigation', {
name: 'Sections',
} )
).toBeInTheDocument();
} );

it( 'should use a custom `ariaLabel` when provided', () => {
render(
<Navigation
items={ [ { label: 'Overview', href: '/overview' } ] }
ariaLabel="Analytics sections"
/>
);

expect(
screen.getByRole( 'navigation', {
name: 'Analytics sections',
} )
).toBeInTheDocument();
} );

it( 'should render each item as a link with its `href`', () => {
render(
<Navigation
items={ [
{ label: 'Overview', href: '/overview' },
{ label: 'Products', href: '/products' },
] }
/>
);

const links = screen.getAllByRole( 'link' );
expect( links ).toHaveLength( 2 );
expect( links[ 0 ] ).toHaveTextContent( 'Overview' );
expect( links[ 0 ] ).toHaveAttribute( 'href', '/overview' );
expect( links[ 1 ] ).toHaveTextContent( 'Products' );
expect( links[ 1 ] ).toHaveAttribute( 'href', '/products' );
} );

it( 'should mark the item matching `currentHref` with aria-current="page"', () => {
render(
<Navigation
items={ [
{ label: 'Overview', href: '/overview' },
{ label: 'Products', href: '/products' },
] }
currentHref="/overview"
/>
);

expect(
screen.getByRole( 'link', { name: 'Overview' } )
).toHaveAttribute( 'aria-current', 'page' );
expect(
screen.getByRole( 'link', { name: 'Products' } )
).not.toHaveAttribute( 'aria-current' );
} );

it( 'should mark an empty string `href` as current when `currentHref` is empty', () => {
render(
<Navigation
items={ [
{ label: 'Overview', href: '' },
{ label: 'Products', href: '/products' },
] }
currentHref=""
/>
);

// Disable reason: A link with an empty `href` doesn't surface in
// the accessibility three via `getByRole` with `name`. In practice
// this would likely not be something we would recommend doing, but
// this test verifies the expected behavior of current link testing.
// eslint-disable-next-line testing-library/no-node-access
const overviewLink = screen.getByText( 'Overview' ).closest( 'a' );
expect( overviewLink ).toHaveAttribute( 'href', '' );
expect( overviewLink ).toHaveAttribute( 'aria-current', 'page' );
} );
} );

describe( 'components', () => {
it( 'should render items with the provided link component', () => {
const CustomLink = ( {
children,
...props
}: React.AnchorHTMLAttributes< HTMLAnchorElement > ) => (
<a data-custom="true" { ...props }>
{ children }
</a>
);

render(
<Navigation
items={ [
{ label: 'Overview', href: '/overview' },
{ label: 'Products', href: '/products' },
] }
currentHref="/overview"
components={ { link: CustomLink } }
/>
);

const link = screen.getByRole( 'link', { name: 'Overview' } );
expect( link ).toHaveAttribute( 'data-custom', 'true' );
expect( link ).toHaveAttribute( 'href', '/overview' );
expect( link ).toHaveAttribute( 'aria-current', 'page' );
expect(
screen.getByRole( 'link', { name: 'Products' } )
).toHaveAttribute( 'data-custom', 'true' );
} );
} );
} );
57 changes: 57 additions & 0 deletions packages/admin-ui/src/navigation/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { type AnchorHTMLAttributes, type ComponentType } from 'react';

export interface NavigationItem {
/**
* The label text for the navigation item.
*/
label: string;

/**
* The URL the item links to.
*/
href: string;
}

export interface NavigationComponents {
/**
* Component rendered in place of the default `<a>` for each item, e.g. a
* client-side router link. It receives standard anchor attributes
* (`href`, `aria-current`, `className`, `children`) and must forward
* them to the element it renders.
*
* @default 'a'
*/
link?: ComponentType< AnchorHTMLAttributes< HTMLAnchorElement > >;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With AnchorHTMLAttributes< HTMLAnchorElement >, the href prop/attribute is optional, but NavigationItem always requires it. This will also cause TS errors later, if we need to write adapters (ie for the wordpress/route link) such as:

type RouteLinkProps = {
	href: string;
	children: React.ReactNode;
	className?: string;
};

const RouteLink = ( { href, ...props }: RouteLinkProps ) => (
	<Link to={ href } { ...props } />
);

The current test and story hide this problem by typing their fake anchor with the same optional-href AnchorHTMLAttributes type. We should probably mark href on link's props as required, update stories and tests

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With AnchorHTMLAttributes< HTMLAnchorElement >, the href prop/attribute is optional, but NavigationItem always requires it.

This is a good catch, and it was a little tricky to navigate, between how exactly to make it required (e.g. Pick-ing the props we're actually passing vs. Omit-ing and & intersecting with the override) as well as semantics around render in the underlying Link component. The end result is 38ffb1f:

  • Opted for Omit-and-&-intersect, since we had already documented through props "It receives standard anchor attributes [...] them to the element it renders"
  • Passing href on the render element (both default and overridden) because otherwise the typings would assume that what's passed to LinkComponent could have omit href because the underlying Link primitive builds its prop types based on anchor props

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe Page.navigation is still typed as the complete internal NavigationProps, so TypeScript advertises navigation.linkComponent and navigation.className as supported public configuration?

Header then spreads the object and unconditionally overwrites both values. A consumer can therefore pass valid-looking props that silently do nothing. This also leaves a second apparent link-customization path after the supported one moved to Page.components.link.

I guess one approach here is to split this into a public data-only config (items, currentHref, ariaLabel) and internal renderer props (linkComponent, className), and type Page.navigation with only the public config? That preserves the minimal configuration API discussed in #76709/#77628 and prevents internal markup concerns from becoming part of the Page contract.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe Page.navigation is still typed as the complete internal NavigationProps, so TypeScript advertises navigation.linkComponent and navigation.className as supported public configuration?

Good call 👍 I split the internal and public-facing types in 6937f88.

}

export interface NavigationProps {
Comment thread
ciampo marked this conversation as resolved.
Resolved
/**
* The navigation items to display.
*/
items: readonly NavigationItem[];

/**
* The `href` of the current item. The matching item is rendered with
* `aria-current="page"` and a distinct visual treatment.
*/
currentHref?: string;

/**
* Accessible label for the navigation landmark. Give each navigation on a
* screen a unique label, and omit the word "navigation" since the landmark
* role already conveys it.
*
* @default 'Sections'
*/
ariaLabel?: string;

/**
* Overrides for the elements the navigation renders.
*/
components?: NavigationComponents;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question (cc @aduth ): should components.link be on Page instead, for a higher-level API, more on the line of the bigger refactor proposed?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likely yes, and in fact my initial proposal in #77040 included exactly that ("<Page components={ { link: Link } } />").

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated this in 4cfaf48 so that components is a top-level prop of Page. This switches back to the linkComponent semantics for Navigation itself, but since it's a private component it shouldn't matter much and I think we won't need as much of the 'bucket' approach for the sub-components where it's more clear and precise what we'll want to control.


/**
* Optional class name for the root element.
*/
className?: string;
}
Loading
Loading