From 6f9e4485a107afdcd3eac73b52924a874b1261b2 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:00:05 +0000 Subject: [PATCH 01/47] Fix failing GitHub Actions job 'Main' (#8047) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: joshblack <3901764+joshblack@users.noreply.github.com> --- .github/workflows/release.yml | 1 + package-lock.json | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index daa847f64d4..b60da6c39aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,6 +57,7 @@ jobs: uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d with: title: Release tracking + commitMode: github-api # This expects you to have a script called release which does a build for your packages and calls changeset publish publish: npm run release env: diff --git a/package-lock.json b/package-lock.json index fcfc29cace9..13a61236ac9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,7 +86,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@primer/react": "38.28.0", + "@primer/react": "38.29.0", "@primer/styled-react": "1.1.0", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", @@ -100,7 +100,7 @@ "name": "example-nextjs", "version": "0.0.0", "dependencies": { - "@primer/react": "38.28.0", + "@primer/react": "38.29.0", "@primer/styled-react": "1.1.0", "next": "^16.1.7", "react": "^19.2.0", @@ -143,7 +143,7 @@ "version": "0.0.0", "dependencies": { "@primer/octicons-react": "^19.21.0", - "@primer/react": "38.28.0", + "@primer/react": "38.29.0", "@primer/styled-react": "1.1.0", "clsx": "^2.1.1", "next": "^16.1.7", @@ -29915,7 +29915,7 @@ }, "packages/react": { "name": "@primer/react", - "version": "38.28.0", + "version": "38.29.0", "license": "MIT", "dependencies": { "@github/mini-throttle": "^2.1.1", From cb79351f29b9a1aea7bacf890eca97a5c9d97590 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:23:16 -0700 Subject: [PATCH 02/47] Add NavList.Heading slot for accessible section-nav headings (#8031) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/navlist-heading-slot.md | 9 ++ packages/react/src/NavList/NavList.docs.json | 45 +++++- .../src/NavList/NavList.features.stories.tsx | 44 ++++++ packages/react/src/NavList/NavList.module.css | 6 + packages/react/src/NavList/NavList.test.tsx | 133 ++++++++++++++++++ packages/react/src/NavList/NavList.tsx | 120 +++++++++++++--- 6 files changed, 338 insertions(+), 19 deletions(-) create mode 100644 .changeset/navlist-heading-slot.md diff --git a/.changeset/navlist-heading-slot.md b/.changeset/navlist-heading-slot.md new file mode 100644 index 00000000000..4e2cb3e7558 --- /dev/null +++ b/.changeset/navlist-heading-slot.md @@ -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. diff --git a/packages/react/src/NavList/NavList.docs.json b/packages/react/src/NavList/NavList.docs.json index f44605ffe60..6ea9ab1e73f 100644 --- a/packages/react/src/NavList/NavList.docs.json +++ b/packages/react/src/NavList/NavList.docs.json @@ -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." + } + ] + }, { "name": "NavList.Item", "props": [ @@ -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'" } ] }, diff --git a/packages/react/src/NavList/NavList.features.stories.tsx b/packages/react/src/NavList/NavList.features.stories.tsx index e575ffe71f6..fb3983f64c0 100644 --- a/packages/react/src/NavList/NavList.features.stories.tsx +++ b/packages/react/src/NavList/NavList.features.stories.tsx @@ -646,4 +646,48 @@ export const WithItemGap: StoryFn = () => ( WithItemGap.storyName = 'With gap between items (behind feature flag)' +export const WithHeading: StoryFn = () => ( + + + + Settings + + + Profile + + Appearance + + + Password and authentication + Sessions + + + + + +) + +export const WithVisuallyHiddenHeading: StoryFn = () => ( + + + + Settings + + + Profile + + Appearance + + + Password and authentication + Sessions + + + + + +) + +WithVisuallyHiddenHeading.storyName = 'With Heading (hidden)' + export default meta diff --git a/packages/react/src/NavList/NavList.module.css b/packages/react/src/NavList/NavList.module.css index 9908361e112..93dbb6674eb 100644 --- a/packages/react/src/NavList/NavList.module.css +++ b/packages/react/src/NavList/NavList.module.css @@ -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; diff --git a/packages/react/src/NavList/NavList.test.tsx b/packages/react/src/NavList/NavList.test.tsx index b70f1314d4b..cedde128ac5 100644 --- a/packages/react/src/NavList/NavList.test.tsx +++ b/packages/react/src/NavList/NavList.test.tsx @@ -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( + + Settings + Item 1 + , + ) + + expect(getByRole('heading', {level: 2, name: 'Settings'})).toBeInTheDocument() + }) + + it('respects an explicit heading level via the `as` prop', () => { + const {getByRole} = render( + + Settings + Item 1 + , + ) + + expect(getByRole('heading', {level: 3, name: 'Settings'})).toBeInTheDocument() + }) + + it('labels the nav landmark with the heading', () => { + const {getByRole} = render( + + Settings + Item 1 + , + ) + + 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( + + Settings + Item 1 + , + ) + + 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( + + Settings + + Profile + + , + ) + + 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( + + Settings + + Profile + + , + ) + + 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( + + + Profile + + , + ) + + expect(getByRole('heading', {level: 3, name: 'Account'})).toBeInTheDocument() + }) + + it('lets group headings override their computed level with `as`', () => { + const {getByRole} = render( + + Settings + + Account + Profile + + , + ) + + expect(getByRole('heading', {level: 4, name: 'Account'})).toBeInTheDocument() + }) + + it('keeps a visually hidden heading in the accessibility tree', () => { + const {getByRole} = render( + + Settings + Item 1 + , + ) + + 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( + + + Settings + + Item 1 + , + ) + + const heading = getByRole('heading', {level: 2, name: 'Settings'}) + expect(heading).toHaveAttribute('data-testid', 'nav-heading') + expect(heading).toHaveAttribute('title', 'Section navigation') + }) + }) }) diff --git a/packages/react/src/NavList/NavList.tsx b/packages/react/src/NavList/NavList.tsx index 6af6985976b..3deb4662531 100644 --- a/packages/react/src/NavList/NavList.tsx +++ b/packages/react/src/NavList/NavList.tsx @@ -19,6 +19,31 @@ import navListClasses from './NavList.module.css' import {flushSync} from 'react-dom' import {useSlots} from '../hooks/useSlots' import {fixedForwardRef, type PolymorphicProps} from '../utils/modern-polymorphic' +import HeadingComponent from '../Heading' +import visuallyHiddenClasses from '../_VisuallyHidden.module.css' +import type {FCWithSlotMarker} from '../utils/types/Slots' + +type HeadingLevels = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + +// NavList establishes a shallow hierarchy: the NavList itself is named by a +// heading (h2 or h3) and its groups render one level deeper (h3 or h4). We don't +// expose an h1 (that's the page title) and we don't go deeper than h4. +type NavListHeadingLevels = 'h2' | 'h3' + +// Publishes the resolved numeric level of a NavList.Heading (e.g. 2 for an h2) so +// NavList.Group/NavList.GroupHeading can default to one level deeper. `null` when +// there is no NavList.Heading, in which case groups keep their historical h3 default. +const NavListHeadingLevelContext = React.createContext(null) + +function headingTagToLevel(as: NavListHeadingLevels): number { + return Number.parseInt(as.slice(1), 10) +} + +function levelToHeadingTag(level: number): HeadingLevels { + // Clamp to h4 so group headings never go deeper than the supported hierarchy. + const clamped = Math.min(Math.max(level, 1), 4) + return `h${clamped}` as HeadingLevels +} // ---------------------------------------------------------------------------- // NavList @@ -27,21 +52,77 @@ export type NavListProps = { children: React.ReactNode } & React.ComponentProps<'nav'> -const Root = React.forwardRef(({children, ...props}, ref) => { +const Root = React.forwardRef( + ({children, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, ...props}, ref) => { + const [slots, childrenWithoutHeading] = useSlots(children, { + heading: Heading, + }) + + const fallbackHeadingId = useId() + const heading = slots.heading + const headingId = heading ? (heading.props.id ?? fallbackHeadingId) : undefined + const headingLevel = heading ? headingTagToLevel(heading.props.as ?? 'h2') : null + + // Don't override a consumer-supplied accessible name; otherwise label the + //