Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions .changeset/navlist-heading-slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@primer/react': minor
---

Add a `NavList.Heading` slot that names the navigation region. It renders an `h2`
by default (configurable to `h3` via `as`), supports a `visuallyHidden` variant,
labels the `nav` landmark via `aria-labelledby`, and makes `NavList.Group`
headings default to one level deeper (`h3`, or `h4` under an `h3` heading) for a
correct heading hierarchy.
45 changes: 42 additions & 3 deletions packages/react/src/NavList/NavList.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,46 @@
}
],
"subcomponents": [
{
"name": "NavList.Heading",
"props": [
{
"name": "as",
"type": "'h2' | 'h3'",
"defaultValue": "\"h2\"",
"required": false,
"description": "Semantic heading level for the NavList. Also sets the default level for child group headings, which render one level deeper (h3 under an h2, h4 under an h3). Constrained to h2/h3 so the hierarchy stays shallow."
},
{
"name": "visuallyHidden",
"type": "boolean",
"defaultValue": "false",
"required": false,
"description": "Visually hide the heading while keeping it available to assistive technology and as the accessible name for the `nav` landmark."
},
{
"name": "children",
"type": "ReactNode",
"required": true,
"description": "The text rendered as the NavList's heading."
},
{
"name": "id",
"type": "string",
"description": "Custom id for the heading element. When set, it is used as the `aria-labelledby` target that names the `nav` landmark; otherwise a generated id is used."
},
{
"name": "className",
"type": "string",
"description": "Custom CSS class name."
},
{
"name": "style",
"type": "React.CSSProperties",
"description": "Custom CSS styles."
}
]
Comment thread
janmaarten-a11y marked this conversation as resolved.
},
{
"name": "NavList.Item",
"props": [
Expand Down Expand Up @@ -194,10 +234,9 @@
},
{
"name": "as",
"description": "Heading level for the group heading. Sets the semantic heading tag.",
"description": "Heading level for the group heading. Sets the semantic heading tag. Defaults to one level below a `NavList.Heading` (h3 under an h2, h4 under an h3), or `h3` when there is no `NavList.Heading`.",
"required": false,
"type": "'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'",
"defaultValue": "\"h3\""
"type": "'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'"
}
]
},
Expand Down
44 changes: 44 additions & 0 deletions packages/react/src/NavList/NavList.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -646,4 +646,48 @@ export const WithItemGap: StoryFn = () => (

WithItemGap.storyName = 'With gap between items (behind feature flag)'

export const WithHeading: StoryFn = () => (
<PageLayout>
<PageLayout.Pane position="start">
<NavList>
<NavList.Heading>Settings</NavList.Heading>
<NavList.Group title="Account">
<NavList.Item href="#" aria-current="page">
Profile
</NavList.Item>
<NavList.Item href="#">Appearance</NavList.Item>
</NavList.Group>
<NavList.Group title="Security">
<NavList.Item href="#">Password and authentication</NavList.Item>
<NavList.Item href="#">Sessions</NavList.Item>
</NavList.Group>
</NavList>
</PageLayout.Pane>
<PageLayout.Content></PageLayout.Content>
</PageLayout>
)

export const WithVisuallyHiddenHeading: StoryFn = () => (
<PageLayout>
<PageLayout.Pane position="start">
<NavList>
<NavList.Heading visuallyHidden>Settings</NavList.Heading>
<NavList.Group title="Account">
<NavList.Item href="#" aria-current="page">
Profile
</NavList.Item>
<NavList.Item href="#">Appearance</NavList.Item>
</NavList.Group>
<NavList.Group title="Security">
<NavList.Item href="#">Password and authentication</NavList.Item>
<NavList.Item href="#">Sessions</NavList.Item>
</NavList.Group>
</NavList>
</PageLayout.Pane>
<PageLayout.Content></PageLayout.Content>
</PageLayout>
)

WithVisuallyHiddenHeading.storyName = 'With Heading (hidden)'

export default meta
6 changes: 6 additions & 0 deletions packages/react/src/NavList/NavList.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
.Heading {
margin-block-end: var(--base-size-8);
/* stylelint-disable-next-line primer/spacing */
margin-inline-start: calc(var(--control-medium-paddingInline-condensed) + var(--base-size-8));
}

.GroupHeading > a {
color: var(--fgColor-default);
text-decoration: inherit;
Expand Down
133 changes: 133 additions & 0 deletions packages/react/src/NavList/NavList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -619,4 +619,137 @@ describe('NavList.ShowMoreItem with pages', () => {
expect(getComputedStyle(subGroup as HTMLElement).marginBlockStart).toBe('0px')
})
})

describe('NavList.Heading', () => {
it('renders an h2 heading by default', () => {
const {getByRole} = render(
<NavList>
<NavList.Heading>Settings</NavList.Heading>
<NavList.Item href="#">Item 1</NavList.Item>
</NavList>,
)

expect(getByRole('heading', {level: 2, name: 'Settings'})).toBeInTheDocument()
})

it('respects an explicit heading level via the `as` prop', () => {
const {getByRole} = render(
<NavList>
<NavList.Heading as="h3">Settings</NavList.Heading>
<NavList.Item href="#">Item 1</NavList.Item>
</NavList>,
)

expect(getByRole('heading', {level: 3, name: 'Settings'})).toBeInTheDocument()
})

it('labels the nav landmark with the heading', () => {
const {getByRole} = render(
<NavList>
<NavList.Heading>Settings</NavList.Heading>
<NavList.Item href="#">Item 1</NavList.Item>
</NavList>,
)

const nav = getByRole('navigation', {name: 'Settings'})
const heading = getByRole('heading', {name: 'Settings'})
expect(nav).toHaveAttribute('aria-labelledby', heading.id)
})

it('does not override a consumer-supplied aria-label', () => {
const {getByRole} = render(
<NavList aria-label="Custom label">
<NavList.Heading>Settings</NavList.Heading>
<NavList.Item href="#">Item 1</NavList.Item>
</NavList>,
)

const nav = getByRole('navigation', {name: 'Custom label'})
expect(nav).not.toHaveAttribute('aria-labelledby')
})

it('defaults group headings to one level below the NavList.Heading', () => {
const {getByRole} = render(
<NavList>
<NavList.Heading>Settings</NavList.Heading>
<NavList.Group title="Account">
<NavList.Item href="#">Profile</NavList.Item>
</NavList.Group>
</NavList>,
)

expect(getByRole('heading', {level: 2, name: 'Settings'})).toBeInTheDocument()
expect(getByRole('heading', {level: 3, name: 'Account'})).toBeInTheDocument()
})

it('derives group headings to h4 when the NavList.Heading is an h3', () => {
const {getByRole} = render(
<NavList>
<NavList.Heading as="h3">Settings</NavList.Heading>
<NavList.Group title="Account">
<NavList.Item href="#">Profile</NavList.Item>
</NavList.Group>
</NavList>,
)

expect(getByRole('heading', {level: 3, name: 'Settings'})).toBeInTheDocument()
expect(getByRole('heading', {level: 4, name: 'Account'})).toBeInTheDocument()
})

it('keeps the h3 group heading default when there is no NavList.Heading', () => {
const {getByRole} = render(
<NavList>
<NavList.Group title="Account">
<NavList.Item href="#">Profile</NavList.Item>
</NavList.Group>
</NavList>,
)

expect(getByRole('heading', {level: 3, name: 'Account'})).toBeInTheDocument()
})

it('lets group headings override their computed level with `as`', () => {
const {getByRole} = render(
<NavList>
<NavList.Heading>Settings</NavList.Heading>
<NavList.Group>
<NavList.GroupHeading as="h4">Account</NavList.GroupHeading>
<NavList.Item href="#">Profile</NavList.Item>
</NavList.Group>
</NavList>,
)

expect(getByRole('heading', {level: 4, name: 'Account'})).toBeInTheDocument()
})

it('keeps a visually hidden heading in the accessibility tree', () => {
const {getByRole} = render(
<NavList>
<NavList.Heading visuallyHidden>Settings</NavList.Heading>
<NavList.Item href="#">Item 1</NavList.Item>
</NavList>,
)

const heading = getByRole('heading', {level: 2, name: 'Settings'})
expect(heading).toBeInTheDocument()
expect(getByRole('navigation', {name: 'Settings'})).toBeInTheDocument()
// The visually-hidden styles are applied to the heading itself, not a wrapping element.
expect(heading.parentElement?.tagName).not.toBe('SPAN')
})

it('forwards standard HTML attributes to the heading element', () => {
const {getByRole} = render(
<NavList>
<NavList.Heading data-testid="nav-heading" title="Section navigation">
Settings
</NavList.Heading>
<NavList.Item href="#">Item 1</NavList.Item>
</NavList>,
)

const heading = getByRole('heading', {level: 2, name: 'Settings'})
expect(heading).toHaveAttribute('data-testid', 'nav-heading')
expect(heading).toHaveAttribute('title', 'Section navigation')
})
})
})
Loading
Loading