- {['collections', 'topics', 'articles', 'events', 'video', 'social', 'podcasts', 'books', 'guides', 'webcasts']
+ {[
+ 'collections',
+ 'topics',
+ 'articles',
+ 'events',
+ 'video',
+ 'social',
+ 'podcasts',
+ 'books',
+ 'guides',
+ 'webcasts',
+ 'customer stories',
+ 'learning paths',
+ 'resources',
+ ]
.slice(0, numLinks)
.map(link => {
return (
@@ -602,6 +701,144 @@ const ExternalLinkExample = () => (
)
+const LanguageDropdownExample = () => {
+ const [selectedLanguage, setSelectedLanguage] = React.useState('English')
+
+ return (
+
+ }>
+ {selectedLanguage}
+
+
+ {['English', 'Deutsch', 'Español', 'Français', '日本語'].map(language => (
+
+ {language}
+
+ ))}
+
+
+ )
+}
+
+const VersionTokenExample = () =>
v1.5.3
+
+const ImperativeSearchApiExample = () => {
+ const navRef = React.useRef
(null)
+ const [searchTerm, setSearchTerm] = React.useState('docs')
+ const handleOpenThenClose = () => {
+ navRef.current?.openSearch()
+ window.setTimeout(() => navRef.current?.closeSearch(), 1500)
+ }
+
+ return (
+ <>
+
+ Guides
+ API
+ Changelog
+ event.preventDefault()}
+ onChange={event => setSearchTerm(event.currentTarget.value)}
+ searchResults={mockGroupedSearchData}
+ />
+ Get started
+
+
+ navRef.current?.openSearch()}>Open search
+
+ Open then close search
+
+
+ >
+ )
+}
+
+const KeyboardShortcutRemapExample = () => {
+ const [searchTerm, setSearchTerm] = React.useState('docs')
+
+ return (
+ <>
+
+ Guides
+ API
+ Changelog
+ event.preventDefault()}
+ onChange={event => setSearchTerm(event.currentTarget.value)}
+ searchResults={mockGroupedSearchData}
+ />
+ Get started
+
+
+ Press ⌘+⌥+k to open search. The default{' '}
+ / shortcut is disabled for this example.
+
+ >
+ )
+}
+
+type SearchExampleProps = {
+ leadingComponent?: SubdomainNavBarProps['leadingComponent']
+ trailingComponent?: SubdomainNavBarProps['trailingComponent']
+ showSearch?: boolean
+ searchPlaceholder?: string
+ searchShortcutLabel?: string
+}
+
+const SearchExample = ({
+ leadingComponent,
+ trailingComponent,
+ showSearch = false,
+ searchPlaceholder,
+ searchShortcutLabel,
+}: SearchExampleProps) => {
+ const inputRef = React.useRef(null)
+
+ return (
+
+ Item 1
+ Item 2
+ Item 3
+ {showSearch && (
+ event.preventDefault()}
+ onChange={() => undefined}
+ searchResults={mockGroupedSearchData}
+ />
+ )}
+ Contact sales
+ Get started
+
+ )
+}
+
export const Playground: Story = {
render: (args: StoryArgs) => ,
parameters: {
@@ -629,7 +866,7 @@ export const SearchOpen: Story = {
},
play: async ({canvasElement}) => {
const canvas = within(canvasElement)
- await userEvent.click(canvas.getByLabelText('Toggle search bar'))
+ await userEvent.click(canvas.getByLabelText('Search Site title search'))
await expect(canvas.getByRole('combobox')).toHaveFocus()
},
@@ -640,21 +877,106 @@ export const SearchResultsVisible: Story = {
play: async ({canvasElement}) => {
const canvas = within(canvasElement)
- await userEvent.click(canvas.getByLabelText('Toggle search bar'))
+ await userEvent.click(canvas.getByLabelText('Search Site title search'))
await userEvent.type(canvas.getByRole('combobox'), 'devops')
await expect(canvas.getByRole('combobox')).toHaveFocus()
},
}
+export const ImperativeSearchApi: Story = {
+ render: () => ,
+ name: 'Imperative Search API',
+}
+
+export const KeyboardShortcutRemap: Story = {
+ render: () => ,
+ name: 'Keyboard Shortcut Remap',
+}
+
export const OverflowMenuOpen: Story = {
render: (args: StoryArgs) => ,
+ args: {
+ numLinks: 13,
+ },
+ globals: {
+ viewport: {value: 'desktop1440'},
+ },
play: async ({canvasElement}) => {
const canvas = within(canvasElement)
- await waitFor(async () => {
- const overflowMenu = await canvas.getByText('More')
- await userEvent.click(overflowMenu)
- })
+ await document.fonts.ready
+ await waitFor(() => expect(canvas.getByRole('button', {name: 'More'})).toBeVisible())
+ await userEvent.click(canvas.getByRole('button', {name: 'More'}))
+ await expect(canvas.getByRole('link', {name: 'Books'})).toBeVisible()
+ },
+ name: 'Overflow Menu Open',
+}
+
+export const DesktopPillStates: Story = {
+ render: () => (
+
+ Default
+ Hover
+ Focus
+
+ Current
+
+
+ ),
+ globals: {
+ viewport: {value: 'desktop1440'},
+ },
+ parameters: {
+ pseudo: {
+ hover: ['a[href="#hover"]'],
+ focusVisible: ['a[href="#focus"]'],
+ },
+ },
+ name: 'Desktop Pill States',
+}
+
+export const TabletView: Story = {
+ render: (args: StoryArgs) => ,
+ globals: {
+ viewport: {value: 'tablet800'},
},
+ name: 'Tablet View',
+}
+
+export const TabletMenuOpen: Story = {
+ render: () => (
+ }
+ trailingComponent={ }
+ />
+ ),
+ globals: {
+ viewport: {value: 'tablet800'},
+ },
+ play: async ({canvasElement}) => {
+ const canvas = within(canvasElement)
+ const searchButton = canvas.getByRole('button', {name: 'Search ... search'})
+ const menuButton = canvas.getByLabelText('Menu')
+ await userEvent.click(menuButton)
+
+ const closeButton = canvas.getByRole('button', {name: 'Close'})
+ const menu = document.getElementById(closeButton.getAttribute('aria-controls') as string)
+ const searchRect = searchButton.parentElement?.getBoundingClientRect()
+ const menuRect = menu?.getBoundingClientRect()
+ const navBarRect = closeButton.closest('header')?.getBoundingClientRect()
+ const menuStyles = menu ? getComputedStyle(menu) : undefined
+
+ await expect(closeButton).toHaveAttribute('aria-expanded', 'true')
+ await expect(Math.abs((menuRect?.left ?? 0) - (searchRect?.left ?? 0))).toBeLessThanOrEqual(1)
+ await expect(Math.abs((menuRect?.right ?? 0) - (navBarRect?.right ?? 0))).toBeLessThanOrEqual(1)
+ await expect(menuStyles?.borderInlineStartWidth).toBe('1px')
+ await expect(menuStyles?.borderInlineEndWidth).toBe('1px')
+ await expect(menuStyles?.borderBlockEndWidth).toBe('1px')
+ await expect(menuStyles?.borderBlockEndColor).toBe(menuStyles?.borderInlineStartColor)
+ },
+ name: 'Tablet Menu Open',
}
export const MobileView: Story = {
@@ -671,7 +993,14 @@ export const MobileMenuOpen: Story = {
},
play: async ({canvasElement}) => {
const canvas = within(canvasElement)
- await userEvent.click(canvas.getByLabelText('Menu'))
+ const menuButton = canvas.getByLabelText('Menu')
+ await userEvent.click(menuButton)
+
+ const closeButton = canvas.getByRole('button', {name: 'Close'})
+ const menu = document.getElementById(closeButton.getAttribute('aria-controls') as string)
+
+ await expect(closeButton).toHaveAttribute('aria-expanded', 'true')
+ await expect(menu?.getBoundingClientRect().height).toBeGreaterThan(0)
},
}
@@ -695,7 +1024,7 @@ export const MobileSearchResultsVisible: Story = {
play: async ({canvasElement}) => {
const canvas = within(canvasElement)
- await userEvent.click(canvas.getByLabelText('Toggle search bar'))
+ await userEvent.click(canvas.getByLabelText('Search Site title search'))
await userEvent.type(canvas.getByRole('combobox'), 'devops')
await expect(canvas.getByRole('combobox')).toHaveFocus()
},
@@ -708,6 +1037,17 @@ export const MobileNoLinks: Story = {
},
}
+export const MobileLeadingComponentOnlyMenuOpen: Story = {
+ render: () => } />,
+ globals: {
+ viewport: {value: 'iphonex'},
+ },
+ play: async ({canvasElement}) => {
+ const canvas = within(canvasElement)
+ await userEvent.click(canvas.getByLabelText('Menu'))
+ },
+}
+
export const NoOverflow: Story = {
name: 'No overflow menu (1 link)',
render: (args: StoryArgs) => ,
@@ -730,6 +1070,27 @@ export const FullWidth: Story = {
},
}
+export const WithLeadingComponent: Story = {
+ render: (args: StoryArgs) => } />,
+ name: 'With Leading Component',
+}
+
+export const WithTrailingComponent: Story = {
+ render: (args: StoryArgs) => } />,
+ name: 'With Trailing Component',
+}
+
+export const GroupedSearchResultsVisible: Story = {
+ render: () => ,
+ play: async ({canvasElement}) => {
+ const canvas = within(canvasElement)
+ await userEvent.click(canvas.getByRole('button', {name: 'Search ... search'}))
+ await expect(canvas.getByRole('dialog')).toBeVisible()
+ await expect(canvas.getAllByRole('option')).toHaveLength(7)
+ },
+ name: 'Grouped Search Results Visible',
+}
+
export const NoTitle: Story = {
render: (args: StoryArgs) => ,
args: {
diff --git a/packages/react/src/SubdomainNavBar/SubdomainNavBar.test.tsx b/packages/react/src/SubdomainNavBar/SubdomainNavBar.test.tsx
index f78eb1e49d..23d5065f93 100644
--- a/packages/react/src/SubdomainNavBar/SubdomainNavBar.test.tsx
+++ b/packages/react/src/SubdomainNavBar/SubdomainNavBar.test.tsx
@@ -1,7 +1,14 @@
-import React, {render, cleanup, fireEvent} from '@testing-library/react'
+import {createRef} from 'react'
+import {act, cleanup, fireEvent, render, within} from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'
-import {SubdomainNavBar, SubdomainNavBarSearchResultProps} from './SubdomainNavBar'
+import {
+ SubdomainNavBar,
+ type SubdomainNavBarHandle,
+ type SubdomainNavBarSearchResults,
+ type SubdomainNavBarProps,
+} from './SubdomainNavBar'
import {axe, toHaveNoViolations} from 'jest-axe'
import {useWindowSize} from '../hooks/useWindowSize'
@@ -12,21 +19,92 @@ jest.mock('../hooks/useWindowSize')
const mockUseWindowSize = useWindowSize as jest.Mock
mockUseWindowSize.mockImplementation(() => ({isSmall: false, isMedium: false}))
+let resizeObserverCallbacks: ResizeObserverCallback[] = []
+const originalResizeObserver = global.ResizeObserver
+const originalDocumentFontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts')
+
+class MockResizeObserver implements ResizeObserver {
+ constructor(callback: ResizeObserverCallback) {
+ resizeObserverCallbacks.push(callback)
+ }
+
+ observe = jest.fn()
+ unobserve = jest.fn()
+ disconnect = jest.fn()
+}
+
+const dispatchDialogCancel = (dialog: HTMLElement) => {
+ fireEvent(dialog, new Event('cancel', {cancelable: true}))
+}
+
+const updateNavigationLayout = async (
+ container: HTMLElement,
+ {
+ containerWidth = 150,
+ itemWidth = 50,
+ itemWidths,
+ moreWidth = 30,
+ }: {containerWidth?: number; itemWidth?: number; itemWidths?: number[]; moreWidth?: number},
+) => {
+ const navList = container.querySelector('.SubdomainNavBar-primary-nav-list')
+ const navItems = Array.from(navList?.querySelectorAll('[data-navitemid]') ?? [])
+ const moreMenu = container.querySelector('.SubdomainNavBar-primary-nav-list-item--overflow')
+
+ if (navList) {
+ Object.defineProperty(navList, 'clientWidth', {
+ configurable: true,
+ value: containerWidth,
+ })
+ }
+
+ for (const [index, item] of navItems.entries()) {
+ Object.defineProperty(item, 'offsetWidth', {
+ configurable: true,
+ value: itemWidths?.[index] ?? itemWidth,
+ })
+ }
+
+ if (moreMenu) {
+ Object.defineProperty(moreMenu, 'offsetWidth', {
+ configurable: true,
+ value: moreWidth,
+ })
+ }
+
+ await act(async () => {
+ window.dispatchEvent(new Event('resize'))
+ for (const callback of resizeObserverCallbacks) {
+ callback([], {} as ResizeObserver)
+ }
+ await new Promise(resolve => requestAnimationFrame(resolve))
+ })
+}
+
describe('SubdomainNavBar', () => {
afterEach(() => {
cleanup()
jest.clearAllMocks()
+ global.ResizeObserver = originalResizeObserver
+ document.body.style.overflow = ''
+
+ if (originalDocumentFontsDescriptor) {
+ Object.defineProperty(document, 'fonts', originalDocumentFontsDescriptor)
+ } else {
+ Reflect.deleteProperty(document, 'fonts')
+ }
})
beforeEach(() => {
- // IntersectionObserver isn't available in test environment
- const mockIntersectionObserver = jest.fn()
- mockIntersectionObserver.mockReturnValue({
- observe: () => null,
- unobserve: () => null,
- disconnect: () => null,
+ mockUseWindowSize.mockImplementation(() => ({isSmall: false, isMedium: false}))
+ resizeObserverCallbacks = []
+ global.ResizeObserver = MockResizeObserver
+
+ Object.defineProperty(document, 'fonts', {
+ configurable: true,
+ value: {
+ ready: Promise.resolve(),
+ },
})
- window.IntersectionObserver = mockIntersectionObserver
})
const Component = ({
@@ -34,13 +112,23 @@ describe('SubdomainNavBar', () => {
searchResults,
titleHref,
title = 'Subdomain',
+ leadingComponent,
+ trailingComponent,
}: {
fullWidth?: boolean
- searchResults?: SubdomainNavBarSearchResultProps[]
+ searchResults?: SubdomainNavBarSearchResults
titleHref?: string
title?: string
+ leadingComponent?: SubdomainNavBarProps['leadingComponent']
+ trailingComponent?: SubdomainNavBarProps['trailingComponent']
}) => (
-
+
Collections
Topics
Articles
@@ -65,6 +153,33 @@ describe('SubdomainNavBar', () => {
expect(getAllByRole('navigation').length > 0).toBeTruthy() //
})
+ it('forwards a custom id to the root element', () => {
+ const {getByRole} = render( )
+
+ expect(getByRole('banner')).toHaveAttribute('id', 'docs-navigation')
+ })
+
+ it('forwards custom styles to the root element', () => {
+ const {getByRole} = render( )
+
+ expect(getByRole('banner')).toHaveStyle({opacity: '0.5'})
+ })
+
+ it('forwards aria-current to the rendered link', () => {
+ const {getByRole} = render(
+
+
+ Collections
+
+ ,
+ )
+
+ const link = getByRole('link', {name: 'Collections'})
+
+ expect(link).toHaveAttribute('aria-current', 'page')
+ expect(link.closest('li')).not.toHaveAttribute('aria-current')
+ })
+
it('has no a11y violations by default', async () => {
const {container} = render( )
@@ -92,9 +207,271 @@ describe('SubdomainNavBar', () => {
const searchResultsLandmark = getByRole('search')
expect(searchResultsDialog).toBeInTheDocument()
+ expect(searchResultsDialog.tagName).toBe('DIALOG')
+ expect(searchResultsDialog).toHaveAttribute('open')
expect(searchResultsLandmark).toBeInTheDocument()
})
+ it('opens the search dialog with the "/" keyboard shortcut', () => {
+ const {getByRole, queryByRole} = render( )
+
+ expect(queryByRole('dialog')).not.toBeInTheDocument()
+
+ fireEvent.keyDown(document, {key: '/'})
+
+ expect(getByRole('dialog')).toHaveAttribute('open')
+ expect(getByRole('combobox')).toHaveFocus()
+ })
+
+ it('does not open the search dialog with "/" while focus is in another text input', () => {
+ const {getByLabelText, queryByRole} = render(
+ <>
+ Page search
+
+
+ >,
+ )
+
+ const pageSearchInput = getByLabelText('Page search')
+ pageSearchInput.focus()
+
+ fireEvent.keyDown(pageSearchInput, {key: '/'})
+
+ expect(queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('can remap the search dialog keyboard shortcut to a modifier combination', () => {
+ const {getByRole, queryByRole} = render(
+
+
+ ,
+ )
+
+ fireEvent.keyDown(document, {key: '/'})
+
+ expect(queryByRole('dialog')).not.toBeInTheDocument()
+
+ fireEvent.keyDown(document, {key: 'k'})
+
+ expect(queryByRole('dialog')).not.toBeInTheDocument()
+
+ fireEvent.keyDown(document, {code: 'KeyK', key: 'k', metaKey: true})
+
+ expect(queryByRole('dialog')).not.toBeInTheDocument()
+
+ fireEvent.keyDown(document, {altKey: true, code: 'KeyK', key: 'k', metaKey: true})
+
+ expect(getByRole('dialog')).toHaveAttribute('open')
+ expect(getByRole('combobox')).toHaveFocus()
+ })
+
+ it('can disable the search dialog keyboard shortcut', () => {
+ const {queryByRole} = render(
+
+
+ ,
+ )
+
+ fireEvent.keyDown(document, {key: '/'})
+
+ expect(queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('opens the search dialog through the SubdomainNavBar ref', () => {
+ const ref = createRef()
+ const {getByRole} = render(
+
+
+ ,
+ )
+
+ act(() => {
+ ref.current?.openSearch()
+ })
+
+ expect(getByRole('dialog')).toHaveAttribute('open')
+ expect(getByRole('combobox')).toHaveFocus()
+ })
+
+ it('renders grouped search results while preserving the listbox options', async () => {
+ const mockResultsData: SubdomainNavBarSearchResults = [
+ {
+ title: 'AI results',
+ results: [
+ {
+ title: 'How do I connect to GitHub with my SSH?',
+ description: 'A generated answer suggestion',
+ url: 'https://github.com',
+ date: '2026-07-01T00:00+02:00',
+ },
+ ],
+ },
+ {
+ title: 'Docs results',
+ results: [
+ {
+ title: 'Frequently asked questions',
+ description: 'A docs result',
+ url: 'https://docs.github.com',
+ date: '2026-07-01T00:00+02:00',
+ },
+ ],
+ },
+ ]
+
+ const {container, getByTestId, getByText, getAllByRole} = render( )
+
+ fireEvent.click(getByTestId('toggle-search'))
+
+ expect(getByText('AI results')).toBeInTheDocument()
+ expect(getByText('Docs results')).toBeInTheDocument()
+ const options = getAllByRole('option')
+ expect(options).toHaveLength(2)
+ expect(options[0].tagName).toBe('A')
+ expect(options[0].closest('li')).toHaveAttribute('role', 'presentation')
+
+ const results = await axe(container)
+
+ expect(results).toHaveNoViolations()
+ })
+
+ it('uses custom labels for search triggers and dialog controls', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true, isLarge: true}))
+
+ const labels = {
+ searchLabel: 'Buscar',
+ closeLabel: 'Cerrar',
+ formatSearchWithTitle: (title: string) => `Buscar en ${title}`,
+ formatSearchTrigger: (placeholder: string) => `Abrir ${placeholder}`,
+ }
+
+ const {getByRole, rerender} = render(
+
+
+ ,
+ )
+
+ fireEvent.click(getByRole('button', {name: 'Abrir Buscar documentación'}))
+
+ expect(getByRole('dialog', {name: 'Buscar en Documentación'})).toBeInTheDocument()
+ expect(getByRole('combobox', {name: 'Buscar'})).toBeInTheDocument()
+ expect(getByRole('button', {name: 'Cerrar'})).toHaveTextContent('Cerrar')
+
+ rerender(
+
+
+ ,
+ )
+
+ expect(getByRole('button', {name: 'Abrir Buscar en Documentación'})).toBeInTheDocument()
+ })
+
+ it('uses custom labels for ungrouped results and live-region announcements', () => {
+ const searchResults: SubdomainNavBarSearchResults = [
+ {
+ title: 'Configurar Git',
+ description: 'Aprende a configurar Git.',
+ url: 'https://docs.github.com',
+ date: '2026-07-01T00:00+02:00',
+ },
+ ]
+
+ const {getByRole, getByTestId, getByText} = render(
+
+ `Resultados para «${searchTerm}»`,
+ formatSuggestions: count => `${count} sugerencia${count === 1 ? '' : 's'}.`,
+ }}
+ searchTerm="Git"
+ searchResults={searchResults}
+ onChange={jest.fn}
+ onSubmit={jest.fn()}
+ />
+ ,
+ )
+
+ fireEvent.click(getByRole('button', {name: 'Search Documentación search'}))
+
+ expect(getByText('Resultados para «Git»')).toBeInTheDocument()
+ expect(getByTestId(SubdomainNavBar.testIds.liveRegion)).toHaveTextContent('1 sugerencia.')
+ })
+
+ it('uses custom labels for grouped and untitled search results', () => {
+ const searchResults: SubdomainNavBarSearchResults = [
+ {
+ title: 'Documentación',
+ results: [
+ {
+ title: 'Configurar Git',
+ description: 'Aprende a configurar Git.',
+ url: 'https://docs.github.com',
+ date: '2026-07-01T00:00+02:00',
+ },
+ ],
+ },
+ {
+ title: '',
+ results: [
+ {
+ title: 'GitHub CLI',
+ description: 'Aprende a usar GitHub CLI.',
+ url: 'https://cli.github.com',
+ date: '2026-07-01T00:00+02:00',
+ },
+ ],
+ },
+ ]
+
+ const labels = {
+ resultsLabel: 'Resultados',
+ searchResultsLabel: 'Resultados de búsqueda',
+ formatResultsLabel: (searchTerm: string) => `Resultados para ${searchTerm}`,
+ }
+
+ const {getByRole, rerender} = render(
+
+
+ ,
+ )
+
+ fireEvent.click(getByRole('button', {name: 'Search Documentación search'}))
+
+ expect(getByRole('listbox', {name: 'Resultados para Git'})).toBeInTheDocument()
+ expect(getByRole('group', {name: 'Resultados'})).toBeInTheDocument()
+
+ rerender(
+
+
+ ,
+ )
+
+ expect(getByRole('listbox', {name: 'Resultados de búsqueda'})).toBeInTheDocument()
+ })
+
it('applies "/" as the default title href', async () => {
const {getByRole} = render( )
const linkEl = getByRole('link', {name: 'Subdomain home'})
@@ -102,13 +479,11 @@ describe('SubdomainNavBar', () => {
expect(linkEl).toHaveAttribute('href', '/')
})
- it('removes the default separator and menu title when title is empty', async () => {
+ it('removes the menu title when title is empty', async () => {
const {container} = render( )
- const separator = container.querySelector('.SubdomainNavBar-title-separator')
const menuTitle = container.querySelector('.SubdomainNavBar-title')
- expect(separator).not.toBeInTheDocument()
expect(menuTitle).not.toBeInTheDocument()
})
@@ -144,6 +519,807 @@ describe('SubdomainNavBar', () => {
expect(menuButtonEl).toBe(null)
})
+ it('renders a functional menu control with a visible label on tablet viewports', async () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true, isLarge: false}))
+ const user = userEvent.setup()
+ const {getByRole, getByText, queryByRole} = render( )
+
+ const menuButton = getByRole('button', {name: 'Menu'})
+ const menuId = menuButton.getAttribute('aria-controls')
+ expect(getByText('Menu')).toHaveClass('SubdomainNavBar-menu-button-label')
+ expect(menuButton).toHaveAttribute('aria-expanded', 'false')
+ expect(menuId).toBeTruthy()
+ expect(queryByRole('link', {name: 'Primary CTA'})).not.toBeInTheDocument()
+ expect(queryByRole('link', {name: 'Secondary CTA'})).not.toBeInTheDocument()
+
+ await user.click(menuButton)
+
+ const menu = document.getElementById(menuId as string)
+ expect(menuButton).toHaveAttribute('aria-expanded', 'true')
+ expect(menuButton).toHaveAccessibleName('Close')
+ expect(getByText('Close')).toHaveClass('SubdomainNavBar-menu-button-label')
+ expect(within(menu as HTMLElement).queryByRole('link', {name: 'Subdomain home'})).not.toBeInTheDocument()
+ expect(within(menu as HTMLElement).getByRole('link', {name: 'Collections'})).toBeVisible()
+ expect(within(menu as HTMLElement).getByRole('link', {name: 'Primary CTA'})).toBeVisible()
+ expect(within(menu as HTMLElement).getByRole('link', {name: 'Secondary CTA'})).toBeVisible()
+ expect(menu).not.toHaveAttribute('role', 'dialog')
+ expect(menu).not.toHaveAttribute('aria-modal')
+ })
+
+ it.each([
+ {
+ menuLabels: {menuLabel: 'Navigation', closeLabel: 'Dismiss'},
+ expectedMenuLabel: 'Navigation',
+ expectedCloseLabel: 'Dismiss',
+ },
+ {
+ menuLabels: {menuLabel: 'Navigation'},
+ expectedMenuLabel: 'Navigation',
+ expectedCloseLabel: 'Close',
+ },
+ {
+ menuLabels: {closeLabel: 'Dismiss'},
+ expectedMenuLabel: 'Menu',
+ expectedCloseLabel: 'Dismiss',
+ },
+ ])(
+ 'uses custom narrow menu labels with English fallbacks',
+ async ({menuLabels, expectedMenuLabel, expectedCloseLabel}) => {
+ const user = userEvent.setup()
+ const {getByRole, getByText} = render(
+
+ Collections
+ ,
+ )
+
+ const menuButton = getByRole('button', {name: expectedMenuLabel})
+ expect(getByText(expectedMenuLabel)).toBeInTheDocument()
+
+ await user.click(menuButton)
+
+ expect(menuButton).toHaveAccessibleName(expectedCloseLabel)
+ expect(getByText(expectedCloseLabel)).toBeInTheDocument()
+ },
+ )
+
+ it('keeps the title visible on mobile viewports', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: false, isMedium: false, isLarge: false}))
+
+ const {getByRole} = render( )
+
+ expect(getByRole('link', {name: 'Subdomain home'})).toBeInTheDocument()
+ })
+
+ it('closes the narrow menu when search opens', async () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true, isLarge: false}))
+ const mockOnNarrowMenuToggle = jest.fn()
+ const user = userEvent.setup()
+ const {getByRole, getByTestId} = render(
+
+ Collections
+
+ ,
+ )
+
+ const menuButton = getByRole('button', {name: 'Menu'})
+ await user.click(menuButton)
+ await user.click(getByTestId('toggle-search'))
+
+ expect(menuButton).toHaveAttribute('aria-expanded', 'false')
+ expect(getByRole('dialog')).toBeInTheDocument()
+ expect(mockOnNarrowMenuToggle).toHaveBeenNthCalledWith(1, true)
+ expect(mockOnNarrowMenuToggle).toHaveBeenNthCalledWith(2, false)
+ })
+
+ it('closes search before opening the narrow menu', async () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true, isLarge: false}))
+ const user = userEvent.setup()
+ const {getByRole, getByTestId, queryByRole} = render( )
+
+ await user.click(getByTestId('toggle-search'))
+ await user.click(getByRole('button', {name: 'Menu'}))
+
+ expect(queryByRole('dialog')).not.toBeInTheDocument()
+ expect(getByRole('button', {name: 'Close'})).toHaveAttribute('aria-expanded', 'true')
+ })
+
+ it('restores body overflow and closes the menu when a narrow navigation link is activated', async () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: false, isMedium: false, isLarge: false}))
+ document.body.style.overflow = 'clip'
+ const mockOnNarrowMenuToggle = jest.fn()
+ const user = userEvent.setup()
+ const {getByRole} = render(
+
+ Collections
+ ,
+ )
+
+ const menuButton = getByRole('button', {name: 'Menu'})
+ await user.click(menuButton)
+ expect(document.body.style.overflow).toBe('hidden')
+
+ const menuId = menuButton.getAttribute('aria-controls')
+ const menu = document.getElementById(menuId as string)
+ await user.click(within(menu as HTMLElement).getByRole('link', {name: 'Collections'}))
+
+ expect(menuButton).toHaveAttribute('aria-expanded', 'false')
+ expect(document.body.style.overflow).toBe('clip')
+ expect(mockOnNarrowMenuToggle).toHaveBeenNthCalledWith(1, true)
+ expect(mockOnNarrowMenuToggle).toHaveBeenNthCalledWith(2, false)
+ })
+
+ it('closes the narrow menu and preserves the consumer handler when an action is activated', async () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true, isLarge: false}))
+ document.body.style.overflow = 'clip'
+ const mockOnClick = jest.fn()
+ const mockOnNarrowMenuToggle = jest.fn()
+ const user = userEvent.setup()
+ const {getByRole} = render(
+
+
+ Primary CTA
+
+ ,
+ )
+
+ const menuButton = getByRole('button', {name: 'Menu'})
+ await user.click(menuButton)
+ const menuId = menuButton.getAttribute('aria-controls')
+ const menu = document.getElementById(menuId as string)
+
+ await user.click(within(menu as HTMLElement).getByRole('link', {name: 'Primary CTA'}))
+
+ expect(mockOnClick).toHaveBeenCalledTimes(1)
+ expect(menuButton).toHaveAttribute('aria-expanded', 'false')
+ expect(document.body.style.overflow).toBe('clip')
+ expect(mockOnNarrowMenuToggle).toHaveBeenNthCalledWith(1, true)
+ expect(mockOnNarrowMenuToggle).toHaveBeenNthCalledWith(2, false)
+ })
+
+ it('keeps the narrow menu open when an action prevents navigation', async () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true, isLarge: false}))
+ const mockOnNarrowMenuToggle = jest.fn()
+ const user = userEvent.setup()
+ const {getByRole} = render(
+
+ event.preventDefault()}>
+ Primary CTA
+
+ ,
+ )
+
+ const menuButton = getByRole('button', {name: 'Menu'})
+ await user.click(menuButton)
+ const menuId = menuButton.getAttribute('aria-controls')
+ const menu = document.getElementById(menuId as string)
+
+ await user.click(within(menu as HTMLElement).getByRole('link', {name: 'Primary CTA'}))
+
+ expect(menuButton).toHaveAttribute('aria-expanded', 'true')
+ expect(document.body.style.overflow).toBe('hidden')
+ expect(mockOnNarrowMenuToggle).toHaveBeenCalledTimes(1)
+ expect(mockOnNarrowMenuToggle).toHaveBeenCalledWith(true)
+ })
+
+ it('only reports Escape and desktop resize when they close an open narrow menu', () => {
+ let windowSize = {isSmall: true, isMedium: true, isLarge: false}
+ mockUseWindowSize.mockImplementation(() => windowSize)
+ const mockOnNarrowMenuToggle = jest.fn()
+ const {getByRole, rerender} = render(
+
+ Collections
+ ,
+ )
+
+ fireEvent.keyDown(document, {key: 'Escape'})
+ expect(mockOnNarrowMenuToggle).not.toHaveBeenCalled()
+
+ fireEvent.click(getByRole('button', {name: 'Menu'}))
+ fireEvent.keyDown(document, {key: 'Escape'})
+ expect(mockOnNarrowMenuToggle).toHaveBeenNthCalledWith(1, true)
+ expect(mockOnNarrowMenuToggle).toHaveBeenNthCalledWith(2, false)
+
+ fireEvent.click(getByRole('button', {name: 'Menu'}))
+ windowSize = {isSmall: true, isMedium: true, isLarge: true}
+ rerender(
+
+ Collections
+ ,
+ )
+ expect(mockOnNarrowMenuToggle).toHaveBeenNthCalledWith(3, true)
+ expect(mockOnNarrowMenuToggle).toHaveBeenNthCalledWith(4, false)
+ })
+
+ it('discloses leading-only mobile content and makes it keyboard reachable', async () => {
+ const user = userEvent.setup()
+ const {getByRole} = render(
+ Leading action} />,
+ )
+
+ const menuButton = getByRole('button', {name: 'Menu'})
+ const menuId = menuButton.getAttribute('aria-controls')
+
+ expect(menuButton).toHaveAttribute('aria-expanded', 'false')
+ expect(menuId).toBeTruthy()
+
+ const menu = document.getElementById(menuId as string)
+ expect(menu).toBeInTheDocument()
+
+ await user.click(menuButton)
+
+ const leadingAction = within(menu as HTMLElement).getByRole('button', {name: 'Leading action'})
+ expect(menuButton).toHaveAttribute('aria-expanded', 'true')
+ expect(leadingAction).toBeVisible()
+
+ await user.tab()
+
+ expect(leadingAction).toHaveFocus()
+ })
+
+ it('discloses trailing-only mobile content and makes it keyboard reachable', async () => {
+ const user = userEvent.setup()
+ const {getByRole} = render(
+ Trailing action} />,
+ )
+
+ const menuButton = getByRole('button', {name: 'Menu'})
+ const menuId = menuButton.getAttribute('aria-controls')
+
+ expect(menuButton).toHaveAttribute('aria-expanded', 'false')
+ expect(menuId).toBeTruthy()
+
+ const menu = document.getElementById(menuId as string)
+ expect(menu).toBeInTheDocument()
+
+ await user.click(menuButton)
+
+ const trailingAction = within(menu as HTMLElement).getByRole('button', {name: 'Trailing action'})
+ expect(menuButton).toHaveAttribute('aria-expanded', 'true')
+ expect(trailingAction).toBeVisible()
+
+ await user.tab()
+
+ expect(trailingAction).toHaveFocus()
+ })
+
+ it('associates each mobile disclosure control with its own menu', () => {
+ const {getAllByRole} = render(
+ <>
+ First action} />
+ Second action} />
+ >,
+ )
+
+ const [firstMenuButton, secondMenuButton] = getAllByRole('button', {name: 'Menu'})
+ const firstMenuId = firstMenuButton.getAttribute('aria-controls')
+ const secondMenuId = secondMenuButton.getAttribute('aria-controls')
+
+ expect(firstMenuId).toBeTruthy()
+ expect(secondMenuId).toBeTruthy()
+ expect(firstMenuId).not.toBe(secondMenuId)
+ expect(document.getElementById(firstMenuId as string)).toBeInTheDocument()
+ expect(document.getElementById(secondMenuId as string)).toBeInTheDocument()
+ })
+
+ it('does not expose the More menu when all desktop navigation links fit', async () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+
+ const {container, queryByRole} = render(
+
+ Collections
+ Topics
+ Articles
+ ,
+ )
+
+ await updateNavigationLayout(container, {containerWidth: 150})
+
+ expect(queryByRole('button', {name: 'More'})).not.toBeInTheDocument()
+ })
+
+ it('renders only overflowed desktop navigation links in the More menu', async () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+
+ const {container, getByRole} = render(
+
+ Collections
+ Topics
+ Articles
+ ,
+ )
+
+ await updateNavigationLayout(container, {containerWidth: 130})
+
+ const moreButton = getByRole('button', {name: 'More'})
+ fireEvent.click(moreButton)
+
+ const overflowMenuId = moreButton.getAttribute('aria-controls')
+ const overflowMenu = overflowMenuId ? document.getElementById(overflowMenuId) : null
+
+ expect(moreButton).not.toHaveAttribute('aria-haspopup')
+ expect(overflowMenu).toBeInTheDocument()
+ expect(within(overflowMenu as HTMLElement).getByRole('link', {name: 'Articles'})).toHaveAttribute(
+ 'href',
+ '#articles',
+ )
+ expect(within(overflowMenu as HTMLElement).queryByRole('link', {name: 'Collections'})).not.toBeInTheDocument()
+ expect(within(overflowMenu as HTMLElement).queryByRole('link', {name: 'Topics'})).not.toBeInTheDocument()
+ })
+
+ it('accounts for inline list padding when measuring desktop navigation overflow', async () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+
+ const {container, getByRole} = render(
+
+ Collections
+ Topics
+ Articles
+ ,
+ )
+
+ const navList = container.querySelector('.SubdomainNavBar-primary-nav-list')
+ if (navList) {
+ navList.style.paddingInlineStart = '16px'
+ }
+
+ await updateNavigationLayout(container, {containerWidth: 150, itemWidth: 45, moreWidth: 30})
+
+ expect(getByRole('button', {name: 'More'})).toBeInTheDocument()
+ expect(container.querySelector('[data-navitemid="0-Collections"]')).not.toHaveAttribute('aria-hidden')
+ expect(container.querySelector('[data-navitemid="1-Topics"]')).not.toHaveAttribute('aria-hidden')
+ expect(container.querySelector('[data-navitemid="2-Articles"]')).toHaveAttribute('aria-hidden', 'true')
+ })
+
+ it('keeps overflowed desktop navigation links contiguous when later links are shorter', async () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+
+ const {container, getByRole} = render(
+
+ Collections
+ Enterprise Solutions
+ Docs
+ Pricing
+ ,
+ )
+
+ await updateNavigationLayout(container, {
+ containerWidth: 160,
+ itemWidths: [50, 100, 20, 40],
+ moreWidth: 30,
+ })
+
+ const moreButton = getByRole('button', {name: 'More'})
+ fireEvent.click(moreButton)
+
+ const overflowMenuId = moreButton.getAttribute('aria-controls')
+ const overflowMenu = overflowMenuId ? document.getElementById(overflowMenuId) : null
+
+ expect(container.querySelector('[data-navitemid="0-Collections"]')).not.toHaveAttribute('aria-hidden')
+ expect(container.querySelector('[data-navitemid="1-Enterprise Solutions"]')).toHaveAttribute('aria-hidden', 'true')
+ expect(container.querySelector('[data-navitemid="2-Docs"]')).toHaveAttribute('aria-hidden', 'true')
+ expect(container.querySelector('[data-navitemid="3-Pricing"]')).toHaveAttribute('aria-hidden', 'true')
+
+ expect(within(overflowMenu as HTMLElement).getByRole('link', {name: 'Enterprise Solutions'})).toHaveAttribute(
+ 'href',
+ '#enterprise',
+ )
+ expect(within(overflowMenu as HTMLElement).getByRole('link', {name: 'Docs'})).toHaveAttribute('href', '#docs')
+ expect(within(overflowMenu as HTMLElement).getByRole('link', {name: 'Pricing'})).toHaveAttribute('href', '#pricing')
+ expect(within(overflowMenu as HTMLElement).queryByRole('link', {name: 'Collections'})).not.toBeInTheDocument()
+ })
+
+ it('returns focus to the More button when the overflow menu closes with Escape', async () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+
+ const {container, getByRole} = render(
+
+ Collections
+ Topics
+ Articles
+ ,
+ )
+
+ await updateNavigationLayout(container, {containerWidth: 130})
+
+ const moreButton = getByRole('button', {name: 'More'})
+ fireEvent.click(moreButton)
+
+ const overflowMenuId = moreButton.getAttribute('aria-controls')
+ const overflowMenu = overflowMenuId ? document.getElementById(overflowMenuId) : null
+ const overflowLink = within(overflowMenu as HTMLElement).getByRole('link', {name: 'Articles'})
+
+ overflowLink.focus()
+ expect(overflowLink).toHaveFocus()
+
+ fireEvent.keyDown(document, {key: 'Escape'})
+
+ expect(moreButton).toHaveFocus()
+ expect(overflowMenuId ? document.getElementById(overflowMenuId) : null).not.toBeInTheDocument()
+ })
+
+ it('closes the overflow menu when focus moves to a visible desktop navigation link', async () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+
+ const {container, getByRole} = render(
+
+ Collections
+ Topics
+ Articles
+ ,
+ )
+
+ await updateNavigationLayout(container, {containerWidth: 130})
+
+ const moreButton = getByRole('button', {name: 'More'})
+ fireEvent.click(moreButton)
+
+ const overflowMenuId = moreButton.getAttribute('aria-controls')
+ expect(overflowMenuId ? document.getElementById(overflowMenuId) : null).toBeInTheDocument()
+
+ const visibleLink = getByRole('link', {name: 'Collections'})
+ fireEvent.focusIn(visibleLink)
+
+ expect(moreButton).toHaveAttribute('aria-expanded', 'false')
+ expect(overflowMenuId ? document.getElementById(overflowMenuId) : null).not.toBeInTheDocument()
+ })
+
+ it('removes overflowed desktop navigation links from keyboard and assistive technology navigation', async () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+
+ const {container} = render(
+
+ Collections
+ Topics
+ Articles
+ ,
+ )
+
+ await updateNavigationLayout(container, {containerWidth: 130})
+
+ const overflowedLinkItem = container.querySelector('[data-navitemid="2-Articles"]')
+ const overflowedLink = overflowedLinkItem?.querySelector('a')
+
+ expect(overflowedLinkItem).toHaveAttribute('aria-hidden', 'true')
+ expect(overflowedLinkItem).toHaveAttribute('tabindex', '-1')
+ expect(overflowedLink).toHaveAttribute('tabindex', '-1')
+ })
+
+ it('restores overflowed desktop navigation links when the available width expands', async () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+
+ const {container, queryByRole} = render(
+
+ Collections
+ Topics
+ Articles
+ ,
+ )
+
+ await updateNavigationLayout(container, {containerWidth: 130})
+
+ expect(queryByRole('button', {name: 'More'})).toBeInTheDocument()
+ expect(container.querySelector('[data-navitemid="2-Articles"]')).toHaveAttribute('aria-hidden', 'true')
+
+ await updateNavigationLayout(container, {containerWidth: 150})
+
+ expect(queryByRole('button', {name: 'More'})).not.toBeInTheDocument()
+ expect(container.querySelector('[data-navitemid="2-Articles"]')).not.toHaveAttribute('aria-hidden')
+ })
+
+ it('keeps mobile navigation links out of the More menu', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: false}))
+
+ const {getAllByRole, getByRole, queryByRole} = render(
+
+ Collections
+ Topics
+ Articles
+ ,
+ )
+
+ fireEvent.click(getByRole('button', {name: 'Menu'}))
+
+ expect(queryByRole('button', {name: 'More'})).not.toBeInTheDocument()
+ expect(getAllByRole('link', {name: 'Articles'}).length).toBeGreaterThan(0)
+ })
+
+ it('does not render an action container when no actions are provided', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+
+ const {container} = render(
+
+ Docs
+ ,
+ )
+
+ const actionContainer = container.querySelector('.SubdomainNavBar-button-area')
+ expect(actionContainer).not.toBeInTheDocument()
+ })
+
+ it('does not add a trailing border class to search when no actions are provided', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+
+ const {getByTestId} = render(
+
+
+ ,
+ )
+
+ const searchTrigger = getByTestId('toggle-search').parentElement
+ expect(searchTrigger).not.toHaveClass('SubdomainNavBar-search-trigger--has-trailing-item')
+ })
+
+ it('adds a trailing border class to search when actions follow it', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true, isLarge: true}))
+
+ const {getByTestId} = render(
+
+
+ Primary CTA
+ ,
+ )
+
+ const searchTrigger = getByTestId('toggle-search').parentElement
+ expect(searchTrigger).toHaveClass('SubdomainNavBar-search-trigger--has-trailing-item')
+ })
+
+ it('adds a trailing border class to search when a trailing component follows it', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true, isLarge: true}))
+
+ const {getByTestId} = render(
+ Trailing content}>
+
+ ,
+ )
+
+ const searchTrigger = getByTestId('toggle-search').parentElement
+ expect(searchTrigger).toHaveClass('SubdomainNavBar-search-trigger--has-trailing-item')
+ })
+
+ it('renders action buttons with the 32px Button size', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true, isLarge: true}))
+
+ const {getByRole} = render(
+
+ Primary CTA
+ Secondary CTA
+ ,
+ )
+
+ expect(getByRole('link', {name: 'Primary CTA'})).toHaveClass('Button--size-small')
+ expect(getByRole('link', {name: 'Secondary CTA'})).toHaveClass('Button--size-small')
+ })
+
+ it.each([
+ ['tablet', true, 'small'],
+ ['mobile', false, 'medium'],
+ ])('renders action buttons with the appropriate Button size on %s', async (_viewport, isMedium, size) => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: !isMedium, isMedium, isLarge: false}))
+
+ const {getByRole} = render(
+
+ Primary CTA
+ Secondary CTA
+ ,
+ )
+
+ await userEvent.click(getByRole('button', {name: 'Menu'}))
+
+ expect(getByRole('link', {name: 'Primary CTA'})).toHaveClass(`Button--size-${size}`)
+ expect(getByRole('link', {name: 'Secondary CTA'})).toHaveClass(`Button--size-${size}`)
+ })
+
+ it('renders the search trigger with placeholder and shortcut text', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true, isLarge: true}))
+
+ const {getByTestId, getByText} = render(
+
+
+ ,
+ )
+
+ const searchTrigger = getByTestId('toggle-search')
+ expect(searchTrigger).toHaveAccessibleName('Search ... search')
+ expect(searchTrigger).toHaveClass('SubdomainNavBar-search-input-button')
+ expect(searchTrigger).not.toHaveClass('Button--secondary')
+ expect(getByText('Search ...')).toBeInTheDocument()
+ expect(getByText('/')).toBeInTheDocument()
+ })
+
+ it('uses the search trigger placeholder for the opened search input', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true, isLarge: true}))
+
+ const {getByTestId, getByPlaceholderText, getByRole} = render(
+
+
+ ,
+ )
+
+ fireEvent.click(getByTestId('toggle-search'))
+
+ expect(getByRole('dialog')).toBeInTheDocument()
+ expect(getByPlaceholderText('Search docs')).toBeInTheDocument()
+ })
+
+ it('renders the search dialog as a native dialog with a text close button', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+
+ const {container, getByRole, getByTestId} = render(
+
+
+ ,
+ )
+
+ fireEvent.click(getByTestId('toggle-search'))
+
+ const dialog = getByRole('dialog')
+ expect(container.querySelector('.SubdomainNavBar-search-backdrop')).not.toBeInTheDocument()
+ expect(dialog.tagName).toBe('DIALOG')
+ expect(dialog).toHaveAttribute('open')
+ expect(dialog).toHaveClass('SubdomainNavBar-search-dialog')
+ expect(getByRole('button', {name: 'Close'})).toHaveTextContent('Close')
+ })
+
+ it('keeps the search dialog open when focus moves outside during initialization', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+
+ const {getByRole, getByTestId} = render(
+
+
+ ,
+ )
+
+ fireEvent.click(getByTestId('toggle-search'))
+ fireEvent.focusIn(document.body)
+
+ expect(getByRole('dialog')).toBeInTheDocument()
+ })
+
+ it('closes the search dialog on native cancel when results are visible', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+ const mockResultsData: SubdomainNavBarSearchResults = [
+ {
+ title: 'AI results',
+ results: [
+ {
+ title: 'How do I connect to GitHub with my SSH?',
+ description: 'A generated answer suggestion',
+ url: 'https://github.com',
+ date: '2026-07-01T00:00+02:00',
+ },
+ ],
+ },
+ ]
+
+ const {getByRole, getByTestId, queryByRole} = render(
+
+
+ ,
+ )
+
+ fireEvent.click(getByTestId('toggle-search'))
+ expect(getByRole('option')).toBeInTheDocument()
+
+ dispatchDialogCancel(getByRole('dialog'))
+ expect(queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('closes the search dialog on native cancel when no results are visible', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+
+ const {getByRole, getByTestId, queryByRole} = render(
+
+
+ ,
+ )
+
+ fireEvent.click(getByTestId('toggle-search'))
+ expect(getByRole('dialog')).toBeInTheDocument()
+ expect(queryByRole('option')).not.toBeInTheDocument()
+
+ dispatchDialogCancel(getByRole('dialog'))
+ expect(queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('disables native browser autocomplete on the search combobox', () => {
+ const {getByRole, getByTestId} = render(
+
+
+ ,
+ )
+
+ fireEvent.click(getByTestId('toggle-search'))
+
+ const combobox = getByRole('combobox')
+ expect(combobox).toHaveAttribute('autocomplete', 'off')
+ expect(combobox).toHaveAttribute('aria-autocomplete', 'list')
+ })
+
+ it('adds a trailing border class to the action area when a trailing component follows it', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true, isLarge: true}))
+
+ const {container} = render(
+ Trailing content}>
+ Primary CTA
+ ,
+ )
+
+ const actionContainer = container.querySelector('.SubdomainNavBar-button-area')
+ expect(actionContainer).toHaveClass('SubdomainNavBar-button-area--has-trailing-item')
+ })
+
+ it('renders leading and trailing components in the expected order', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true, isLarge: true}))
+
+ const {getByRole, getByText} = render(
+ Leading content} trailingComponent={Trailing content } />,
+ )
+
+ const titleLink = getByRole('link', {name: 'Subdomain home'})
+ const leadingComponent = getByText('Leading content')
+ const firstLink = getByRole('link', {name: 'Collections'})
+ const secondaryAction = getByRole('link', {name: 'Secondary CTA'})
+ const trailingComponent = getByText('Trailing content')
+
+ expect(titleLink.compareDocumentPosition(leadingComponent) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+ expect(leadingComponent.compareDocumentPosition(firstLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+ expect(secondaryAction.compareDocumentPosition(trailingComponent) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+ })
+
+ it('renders mobile leading content before links and groups actions with trailing content at the menu footer', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: false, isMedium: false}))
+
+ const {container, getByRole, getByTestId, getByText} = render(
+ Leading content} trailingComponent={Trailing content } />,
+ )
+ const header = getByTestId(SubdomainNavBar.testIds.root)
+ jest.spyOn(header, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 16,
+ top: 16,
+ right: 390,
+ bottom: 72,
+ left: 0,
+ width: 390,
+ height: 56,
+ toJSON: () => ({}),
+ } as DOMRect)
+
+ fireEvent.click(getByRole('button', {name: 'Menu'}))
+
+ const menuWrapper = container.querySelector('.SubdomainNavBar-menu-wrapper')
+ const menuFooter = container.querySelector('.SubdomainNavBar-menu-wrapper-footer')
+ const leadingComponent = getByText('Leading content').parentElement
+ const trailingComponent = getByText('Trailing content').parentElement
+ const menuWrapperElement = menuWrapper as HTMLElement
+ const menuFooterElement = menuFooter as HTMLElement
+ const firstLink = within(menuWrapperElement).getByRole('link', {name: 'Collections'})
+ const primaryAction = within(menuFooterElement).getByRole('link', {name: 'Primary CTA'})
+
+ expect(leadingComponent).toHaveClass('SubdomainNavBar-leading-component')
+ expect(trailingComponent).toHaveClass('SubdomainNavBar-trailing-component')
+ expect(header.style.getPropertyValue('--SubdomainNavBar-menu-offset-block-start')).toBe('16px')
+ expect(menuFooterElement.parentElement).toBe(menuWrapperElement)
+ expect(menuWrapperElement.lastElementChild).toBe(menuFooterElement)
+ expect(
+ (leadingComponent as HTMLElement).compareDocumentPosition(firstLink) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy()
+ expect(
+ primaryAction.compareDocumentPosition(trailingComponent as HTMLElement) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy()
+ })
+
it('can append a classname to the root element', () => {
const mockClass = 'custom-class'
const {getByTestId} = render( )
@@ -153,6 +1329,23 @@ describe('SubdomainNavBar', () => {
expect(headerEl.classList).toContain(mockClass)
})
+ it('renders without a variant modifier class', () => {
+ const {getByTestId} = render( )
+
+ const headerEl = getByTestId(SubdomainNavBar.testIds.root)
+ expect(headerEl).toHaveClass('SubdomainNavBar')
+ expect(Array.from(headerEl.classList).some(className => className.includes('variant'))).toBe(false)
+ })
+
+ it('does not render a title separator', () => {
+ mockUseWindowSize.mockImplementation(() => ({isSmall: true, isMedium: true}))
+
+ const {container} = render( )
+
+ const separator = container.querySelector('.SubdomainNavBar-title-separator')
+ expect(separator).not.toBeInTheDocument()
+ })
+
it('renders live region when search is active', async () => {
const {getByTestId, getByLabelText} = render( )
const searchTrigger = getByTestId('toggle-search')
diff --git a/packages/react/src/SubdomainNavBar/SubdomainNavBar.tsx b/packages/react/src/SubdomainNavBar/SubdomainNavBar.tsx
index 948fc1c174..5fdfe4ea77 100644
--- a/packages/react/src/SubdomainNavBar/SubdomainNavBar.tsx
+++ b/packages/react/src/SubdomainNavBar/SubdomainNavBar.tsx
@@ -1,10 +1,18 @@
-import React, {useState, useCallback, useRef, PropsWithChildren, forwardRef, useMemo, useEffect} from 'react'
+import React, {
+ useState,
+ useCallback,
+ useRef,
+ PropsWithChildren,
+ forwardRef,
+ useMemo,
+ useEffect,
+ useImperativeHandle,
+} from 'react'
import {clsx} from 'clsx'
-import {ChevronLeftIcon, LinkExternalIcon, MarkGithubIcon, SearchIcon, XIcon} from '@primer/octicons-react'
+import {ArrowUpRightIcon, ChevronLeftIcon, LinkExternalIcon, MarkGithubIcon, SearchIcon} from '@primer/octicons-react'
import {Button, FormControl, Text, TextInput} from '..'
import {NavigationVisbilityObserver} from './NavigationVisbilityObserver'
-import {useOnClickOutside} from '../hooks/useOnClickOutside'
import {useFocusTrap} from '../hooks/useFocusTrap'
import {useKeyboardEscape} from '../hooks/useKeyboardEscape'
import {useWindowSize} from '../hooks/useWindowSize'
@@ -17,6 +25,25 @@ import '@primer/brand-primitives/lib/design-tokens/css/tokens/functional/compone
/** * Main Stylesheet (as a CSS Module) */
import styles from './SubdomainNavBar.module.css'
import {useId} from '../hooks/useId'
+import {SubdomainNavBarLinkContext, useSubdomainNavBarLinkContext} from './SubdomainNavBarLinkContext'
+
+export type SubdomainNavBarMenuLabels = {
+ /**
+ * Visible and accessible label for the closed narrow menu control.
+ */
+ menuLabel: string
+ /**
+ * Visible and accessible label for the open narrow menu control.
+ */
+ closeLabel: string
+}
+
+const defaultMenuLabels: SubdomainNavBarMenuLabels = {
+ menuLabel: 'Menu',
+ closeLabel: 'Close',
+}
+
+const SubdomainNavBarActionSizeContext = React.createContext<'small' | 'medium'>('small')
export type SubdomainNavBarProps = {
/**
@@ -26,12 +53,20 @@ export type SubdomainNavBarProps = {
children?:
| React.ReactNode
| React.ReactElement
- | React.ReactElement
+ | React.ReactElement
| React.ReactElement
/**
* Forward a custom HTML class attribute
*/
className?: string
+ /**
+ * Forward a custom HTML ID to the root element.
+ */
+ id?: string
+ /**
+ * Forward custom styles to the root element.
+ */
+ style?: React.CSSProperties
/**
* Fixes the navigation bar to the top of the viewport. Defaults to `true`.
*/
@@ -41,7 +76,15 @@ export type SubdomainNavBarProps = {
*/
fullWidth?: boolean
/**
- * The title or name of the subdomain. Appears adjacent to the logo and is required for communicating content to assisitive technologies.
+ * Optional React element rendered after the title and before navigation links.
+ */
+ leadingComponent?: React.ReactElement
+ /**
+ * Optional React element rendered after the actions.
+ */
+ trailingComponent?: React.ReactElement
+ /**
+ * The title or name of the subdomain. Appears adjacent to the logo and is required for communicating content to assistive technologies.
*/
title: string
/**
@@ -56,7 +99,17 @@ export type SubdomainNavBarProps = {
* When the menu is opened or closed on narrow viewports, this callback is called with the new open state.
*/
onNarrowMenuToggle?: (isOpen: boolean) => void
+ /**
+ * Customizable visible and accessible narrow menu labels.
+ */
+ menuLabels?: Partial
+}
+
+export type SubdomainNavBarHandle = HTMLElement & {
+ openSearch: () => void
+ closeSearch: () => void
}
+
const testIds = {
root: 'SubdomainNavBar',
get innerContainer() {
@@ -73,31 +126,132 @@ const testIds = {
},
}
-function Root({
- children,
- className,
- fixed = true,
- fullWidth = false,
- logoHref = 'https://github.com',
- title,
- titleHref = '/',
- onNarrowMenuToggle,
- ...rest
-}: SubdomainNavBarProps) {
+function isEditableKeyboardTarget(target: EventTarget | Element | null): boolean {
+ if (!(target instanceof Element)) return false
+
+ const editableElement = target.closest(
+ 'input, textarea, select, [role="textbox"], [role="combobox"], [role="searchbox"], [contenteditable]',
+ )
+
+ if (!editableElement) return false
+
+ if (editableElement instanceof HTMLElement && editableElement.isContentEditable) return true
+
+ return editableElement.matches('input, textarea, select, [role="textbox"], [role="combobox"], [role="searchbox"]')
+}
+
+type SearchKeyboardShortcut = {
+ key: string
+ altKey: boolean
+ ctrlKey: boolean
+ metaKey: boolean
+ shiftKey: boolean
+}
+
+const searchKeyboardShortcutModifiers = new Set(['alt', 'option', 'ctrl', 'control', 'meta', 'cmd', 'command', 'shift'])
+
+function parseSearchKeyboardShortcut(keyboardShortcut: string | false): SearchKeyboardShortcut | false {
+ if (!keyboardShortcut) return false
+
+ const shortcutParts = keyboardShortcut
+ .split('+')
+ .map(part => part.trim())
+ .filter(Boolean)
+ const key = shortcutParts.pop()
+ if (!key) return false
+
+ const modifiers = shortcutParts.map(part => part.toLowerCase())
+
+ if (!modifiers.every(modifier => searchKeyboardShortcutModifiers.has(modifier))) return false
+
+ return {
+ key,
+ altKey: modifiers.includes('alt') || modifiers.includes('option'),
+ ctrlKey: modifiers.includes('ctrl') || modifiers.includes('control'),
+ metaKey: modifiers.includes('meta') || modifiers.includes('cmd') || modifiers.includes('command'),
+ shiftKey: modifiers.includes('shift'),
+ }
+}
+
+function keyboardEventMatchesShortcut(event: KeyboardEvent, shortcut: SearchKeyboardShortcut): boolean {
+ const shortcutKey = shortcut.key.toLowerCase()
+ const eventKey = event.key.toLowerCase()
+ const eventCode = event.code.toLowerCase()
+
+ if (event.altKey !== shortcut.altKey || event.ctrlKey !== shortcut.ctrlKey || event.metaKey !== shortcut.metaKey) {
+ return false
+ }
+
+ if (/^[^a-z0-9]$/.test(shortcutKey) && eventKey === shortcutKey) {
+ return shortcut.shiftKey ? event.shiftKey : true
+ }
+
+ if (event.shiftKey !== shortcut.shiftKey) {
+ return false
+ }
+
+ if (/^[a-z]$/.test(shortcutKey)) {
+ return eventKey === shortcutKey || eventCode === `key${shortcutKey}`
+ }
+
+ if (/^[0-9]$/.test(shortcutKey)) {
+ return eventKey === shortcutKey || eventCode === `digit${shortcutKey}`
+ }
+
+ return eventKey === shortcutKey
+}
+
+function Root(
+ {
+ children,
+ className,
+ fixed = true,
+ fullWidth = false,
+ logoHref = 'https://github.com',
+ style,
+ title,
+ titleHref = '/',
+ leadingComponent,
+ trailingComponent,
+ onNarrowMenuToggle,
+ menuLabels,
+ ...rest
+ }: SubdomainNavBarProps,
+ forwardedRef: React.ForwardedRef,
+) {
const [menuHidden, setMenuHidden] = useState(true)
const [searchVisible, setSearchVisible] = useState(false)
- const {isSmall, isMedium} = useWindowSize()
+ const [menuViewportOffsetBlockStart, setMenuViewportOffsetBlockStart] = useState(0)
+ const {isMedium, isLarge} = useWindowSize()
const [startOfContentButtonFocused, setStartOfContentButtonFocused] = useState(false)
+ const headerRef = useRef(null)
const mainElRef = useRef(null)
const startOfContentID = useId('start-of-content')
+ const narrowMenuID = useId()
+ const resolvedMenuLabels = {...defaultMenuLabels, ...menuLabels}
+
+ const updateMenuViewportOffsetBlockStart = useCallback(() => {
+ setMenuViewportOffsetBlockStart(Math.max(0, headerRef.current?.getBoundingClientRect().top ?? 0))
+ }, [])
+
+ const closeNarrowMenu = useCallback(() => {
+ if (menuHidden) return
+
+ setMenuHidden(true)
+ onNarrowMenuToggle?.(false)
+ }, [menuHidden, onNarrowMenuToggle])
const handleMobileMenuClick = () => {
- const nextMenuHidden = !menuHidden
- setMenuHidden(nextMenuHidden)
+ if (!menuHidden) {
+ closeNarrowMenu()
+ return
+ }
- onNarrowMenuToggle?.(!nextMenuHidden)
+ setSearchVisible(false)
+ updateMenuViewportOffsetBlockStart()
+ setMenuHidden(false)
+ onNarrowMenuToggle?.(true)
}
- const handleSearchVisibility = () => setSearchVisible(!searchVisible)
const focusTrapRef = useRef(null)
useEffect(() => {
@@ -109,21 +263,35 @@ function Root({
}, [startOfContentID])
useFocusTrap({containerRef: focusTrapRef, restoreFocusOnCleanUp: true, disabled: menuHidden})
- useKeyboardEscape(() => {
- setMenuHidden(true)
- onNarrowMenuToggle?.(false)
- })
+ useKeyboardEscape(closeNarrowMenu)
useEffect(() => {
- if (isMedium) {
- setMenuHidden(true)
- onNarrowMenuToggle?.(false)
- }
- }, [isMedium, menuHidden, onNarrowMenuToggle])
+ if (isLarge) closeNarrowMenu()
+ }, [closeNarrowMenu, isLarge])
useEffect(() => {
- const newOverflowState = menuHidden ? 'auto' : 'hidden'
- document.body.style.overflow = newOverflowState
+ if (menuHidden || isLarge) return
+
+ updateMenuViewportOffsetBlockStart()
+
+ const resizeObserver = new ResizeObserver(updateMenuViewportOffsetBlockStart)
+ const header = headerRef.current
+
+ if (header) resizeObserver.observe(header)
+ resizeObserver.observe(document.documentElement)
+
+ return () => resizeObserver.disconnect()
+ }, [isLarge, menuHidden, updateMenuViewportOffsetBlockStart])
+
+ useEffect(() => {
+ if (menuHidden) return
+
+ const previousOverflow = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+
+ return () => {
+ document.body.style.overflow = previousOverflow
+ }
}, [menuHidden])
const setStartOfContentButtonFocusedTrue = useCallback(() => setStartOfContentButtonFocused(true), [])
@@ -143,8 +311,9 @@ function Root({
() =>
React.Children.toArray(children)
.map((child, index) => {
- if (React.isValidElement(child) && child.type === Link) {
- const navItemId = typeof child.props.children === 'string' ? child.props.children : `${index}`
+ if (React.isValidElement(child) && child.type === Link) {
+ const navItemLabel = typeof child.props.children === 'string' ? child.props.children : 'item'
+ const navItemId = `${index}-${navItemLabel}`
return React.cloneElement(child, {
'data-navitemid': navItemId,
href: child.props.href,
@@ -160,15 +329,107 @@ function Root({
[children],
)
- const hasAllActions: boolean = useMemo(() => {
- const primaryAction = React.Children.toArray(children).find(
- child => React.isValidElement(child) && child.type === PrimaryAction,
- )
- const secondaryAction = React.Children.toArray(children).find(
- child => React.isValidElement(child) && child.type === SecondaryAction,
- )
- return !!primaryAction && !!secondaryAction
- }, [children])
+ const actionItems = useMemo(
+ () =>
+ React.Children.toArray(children).filter(
+ (child): child is React.ReactElement =>
+ React.isValidElement(child) &&
+ (child.type === PrimaryAction || child.type === SecondaryAction),
+ ),
+ [children],
+ )
+
+ const narrowActionItems = useMemo(
+ () =>
+ actionItems.map(child =>
+ React.cloneElement(child, {
+ onClick: event => {
+ child.props.onClick?.(event)
+ if (!event.defaultPrevented) closeNarrowMenu()
+ },
+ }),
+ ),
+ [actionItems, closeNarrowMenu],
+ )
+
+ const hasActions = actionItems.length > 0
+
+ const searchItem = useMemo(
+ () =>
+ React.Children.toArray(children).find(
+ (child): child is React.ReactElement =>
+ React.isValidElement(child) && child.type === Search,
+ ),
+ [children],
+ )
+ const hasSearch = Boolean(searchItem)
+ const searchKeyboardShortcut = useMemo(
+ () => parseSearchKeyboardShortcut(searchItem?.props.keyboardShortcut ?? '/'),
+ [searchItem?.props.keyboardShortcut],
+ )
+
+ const handleSearchOpen = useCallback(() => {
+ if (!hasSearch) return
+
+ closeNarrowMenu()
+ setSearchVisible(true)
+ }, [closeNarrowMenu, hasSearch])
+
+ const handleSearchClose = useCallback(() => setSearchVisible(false), [])
+
+ useImperativeHandle(
+ forwardedRef,
+ () => {
+ if (!headerRef.current) {
+ return {
+ openSearch: handleSearchOpen,
+ closeSearch: handleSearchClose,
+ } as SubdomainNavBarHandle
+ }
+
+ return Object.assign(headerRef.current, {
+ openSearch: handleSearchOpen,
+ closeSearch: handleSearchClose,
+ })
+ },
+ [handleSearchClose, handleSearchOpen],
+ )
+
+ useEffect(() => {
+ const handleDocumentKeyDown = (event: KeyboardEvent) => {
+ if (
+ !hasSearch ||
+ searchVisible ||
+ event.defaultPrevented ||
+ event.isComposing ||
+ !searchKeyboardShortcut ||
+ !keyboardEventMatchesShortcut(event, searchKeyboardShortcut)
+ ) {
+ return
+ }
+
+ if (isEditableKeyboardTarget(event.target) || isEditableKeyboardTarget(document.activeElement)) {
+ return
+ }
+
+ event.preventDefault()
+ handleSearchOpen()
+ }
+
+ document.addEventListener('keydown', handleDocumentKeyDown)
+
+ return () => {
+ document.removeEventListener('keydown', handleDocumentKeyDown)
+ }
+ }, [handleSearchOpen, hasSearch, searchKeyboardShortcut, searchVisible])
+
+ const hasLeadingComponent = leadingComponent != null
+ const hasTrailingComponent = trailingComponent != null
+ const hasNarrowMenuContent = hasLinks || hasActions || hasLeadingComponent || hasTrailingComponent
+ const subdomainNavBarStyle = {
+ ...style,
+ '--SubdomainNavBar-menu-offset-block-start': `${menuViewportOffsetBlockStart}px`,
+ } as React.CSSProperties
return (
<>
@@ -176,7 +437,6 @@ function Root({
className={clsx(
styles['SubdomainNavBar-outer-container'],
fixed && styles['SubdomainNavBar-outer-container--fixed'],
- hasAllActions && styles['SubdomainNavBar-outer-container--has-actions'],
)}
>
Skip to content
-
+
- {title && isSmall && (
+ {title && (
<>
-
- /
-
+ {isLarge && hasLeadingComponent && (
+ {leadingComponent}
+ )}
{hasLinks && (
@@ -360,15 +631,43 @@ function Root({
export type SubdomainNavBarLinkProps = {
href: string
isExternal?: boolean
- 'data-navitemid'?: string
} & React.DetailedHTMLProps