diff --git a/.changeset/bright-subdomains-navigate.md b/.changeset/bright-subdomains-navigate.md new file mode 100644 index 0000000000..3ceeebba91 --- /dev/null +++ b/.changeset/bright-subdomains-navigate.md @@ -0,0 +1,5 @@ +--- +'@primer/react-brand': patch +--- + +Updated `SubdomainNavBar` responsive navigation behavior for tablet and mobile viewports. diff --git a/.changeset/tasty-cameras-search.md b/.changeset/tasty-cameras-search.md new file mode 100644 index 0000000000..51648b72c7 --- /dev/null +++ b/.changeset/tasty-cameras-search.md @@ -0,0 +1,84 @@ +--- +'@primer/react-brand': minor +'@primer/brand-primitives': patch +--- + +Updated `SubdomainNavBar` with a gridline visual design, content slots, search APIs, and responsive navigation behavior. + +- **Migration note:** The opinionated gridline design changes the component's default appearance. After upgrading, manually inspect affected sites, especially existing subdomain sites. If adjustments are needed, override the `--brand-SubdomainNavBar-*` custom properties through the root `className` or `style` props. +- Added `leadingComponent` and `trailingComponent` props for rendering custom content around the navigation links and actions. + +```tsx +Leading content} + trailingComponent={Trailing content} +> + Item 1 + Item 2 + Action + +``` + +- Added a responsive input-style search trigger that collapses to an icon-only button on smaller viewports, custom placeholder and shortcut labels, configurable keyboard shortcuts, grouped results, and a `labels` prop for localizing visible and accessible search text. The default `/` shortcut can be remapped or disabled with `keyboardShortcut`. +- The `SubdomainNavBar` ref now exposes `openSearch()` and `closeSearch()` methods. +- Improved desktop overflow handling. Overflowed links are removed from keyboard and assistive technology navigation, and focus returns to the More button when its menu closes. + + ```tsx + import * as React from 'react' + import { + Button, + SubdomainNavBar, + type SubdomainNavBarHandle, + type SubdomainNavBarSearchLabels, + type SubdomainNavBarSearchProps, + type SubdomainNavBarSearchResults, + } from '@primer/react-brand' + + function Example() { + const navRef = React.useRef(null) + const [searchTerm, setSearchTerm] = React.useState('') + const searchResults: SubdomainNavBarSearchResults = [ + { + title: 'Group', + results: [ + { + title: 'Result', + description: 'Result description', + url: '/result', + date: '2026-01-01', + }, + ], + }, + ] + const labels = { + searchLabel: 'Buscar', + closeLabel: 'Cerrar', + formatResultsHeading: (term: string) => `Resultados para “${term}”`, + formatSuggestions: (count: number) => `${count} sugerencias.`, + } satisfies Partial + const searchProps: SubdomainNavBarSearchProps = { + placeholder: 'Buscar', + keyboardShortcut: 'Command+Option+k', + shortcutLabel: '⌘+⌥+k', + labels, + searchResults, + searchTerm, + onChange: event => setSearchTerm(event.currentTarget.value), + onSubmit: event => event.preventDefault(), + } + + return ( + <> + + Item + + + + + + + ) + } + ``` diff --git a/apps/next-docs/content/components/SubdomainNavBar/index.mdx b/apps/next-docs/content/components/SubdomainNavBar/index.mdx index e6ddc5ec95..472c446ead 100644 --- a/apps/next-docs/content/components/SubdomainNavBar/index.mdx +++ b/apps/next-docs/content/components/SubdomainNavBar/index.mdx @@ -31,118 +31,271 @@ Please refer to our [Storybook examples](https://primer.style/brand/storybook/?p ### Basic ```jsx live - - Collections - Topics - Social - Primary CTA - Secondary CTA - +
+ + Collections + Topics + Social + Primary CTA + Secondary CTA + +
+``` + +### Leading and trailing content + +The `leadingComponent` and `trailingComponent` props are supported composition slots for custom React elements. Use `leadingComponent` for content between the title and navigation links, and `trailingComponent` for content after search and actions. On narrow viewports, both slots move into the menu while preserving that order. + +```jsx filename="noinline" +const App = () => ( +
+ Leading} + trailingComponent={Trailing} + style={{borderBlockEndColor: 'var(--brand-color-border-default)'}} + > + Get started + REST API + +
+) + +render() ``` ### Search -`SubdomainNavBar` offers an optional search form control. The form can operate in both `onSubmit` and `onChange` modes, with the latter facilitating inline results to appear. +`SubdomainNavBar` offers an optional search form control that supports both `onSubmit` and `onChange`; use `onChange` to display inline results. The `placeholder` labels the input in the opened dialog and defaults to `Search {title}`, or `Search` when no navigation title is available. + +Users can press `/` to open the search dialog, or use `keyboardShortcut` to remap or disable the shortcut. For programmatic control with `openSearch()` and `closeSearch()`, see the [Imperative Search API Storybook example](https://primer.style/brand/storybook/?path=/story/components-subdomainnavbar--imperative-search-api). ```jsx filename="noinline" -const App = () => { - const inputRef = React.useRef(null) - const [searchResults, setSearchResults] = React.useState([]) +const groupedResults = [ + { + title: 'Recommended', + results: [ + { + title: 'Getting started with GitHub', + description: 'Create an account and learn the basics.', + url: '/en/get-started', + date: '2026-07-01', + category: 'Guide', + }, + ], + }, + { + title: 'API reference', + results: [ + { + title: 'REST API documentation', + description: 'Integrate with GitHub using the REST API.', + url: 'https://docs.github.com/en/rest', + date: '2026-07-01', + category: 'Reference', + isExternal: true, + }, + ], + }, +] + +const SearchNav = () => { const [searchTerm, setSearchTerm] = React.useState('') - const mockSearchData = [ - { - title: 'How to transform your business in a digital world', - description: - 'GitHub Enterprise empowers developers with tools they already know and love, accelerates high-quality software development and secure delivery, and enhances the speed and power of innovation.\n', - url: 'https://resources.github.com/devops/github-enterprise-ebook', - date: '2022-08-29T00:00+02:00', - }, - { - title: 'The fundamentals of continuous deployment in DevOps', - description: - 'What is continuous deployment?\nContinuous deployment (CD) is an automated software release practice where code changes are deployed to different stages as they pass predefined tests. The goal of CD is to facilitate faster releases by using automation to help remove the need for human intervention as much as possible during the deployment process.', - url: 'https://resources.github.com/devops/fundamentals/ci-cd/deployment', - date: '2022-05-23T12:00+00:00', - }, - ] - - const handleChange = () => { - if (!inputRef.current) return - if (inputRef.current.value.length === 0) { - setSearchResults(undefined) - return - } - if (inputRef.current.value.length > 2) { - setTimeout(() => setSearchResults(mockSearchData), 1000) - setSearchTerm(inputRef.current.value) - return - } - } - - const handleSubmit = e => { - e.preventDefault() - if (!inputRef.current) return - if (!inputRef.current.value) { - alert(`Enter a value and try again.`) - return - } - - alert(`Name: ${inputRef.current.value}`) - } + return ( +
+ + Get started + setSearchTerm(event.currentTarget.value)} + onSubmit={event => { + event.preventDefault() + }} + /> + +
+ ) +} + +render() +``` + +Search results can be a flat list or grouped by title. Do not mix both formats in the same array. + +### Localized search + +Use the `labels` prop to localize search text. Any labels you omit fall back to English. + +```jsx filename="noinline" +const labels = { + searchLabel: 'Buscar', + closeLabel: 'Cerrar', + resultsLabel: 'Resultados', + searchResultsLabel: 'Resultados de búsqueda', + formatSearchWithTitle: title => `Buscar en ${title}`, + formatSearchTrigger: placeholder => `Abrir ${placeholder}`, + formatResultsHeading: searchTerm => `Resultados para «${searchTerm}»`, + formatResultsLabel: searchTerm => `Resultados para ${searchTerm}`, + formatSuggestions: count => `${count} sugerencia${count === 1 ? '' : 's'}.`, +} + +const searchProps = { + labels, + placeholder: 'Buscar documentación', +} + +const LocalizedSearch = () => { + const [searchTerm, setSearchTerm] = React.useState('') return ( - - Collections - Topics - - +
+ + setSearchTerm(event.currentTarget.value)} + onSubmit={event => event.preventDefault()} + /> + +
) } -render(App) +render() ``` -## Accessibility +### Localized narrow menu + +Use `menuLabels` to localize the narrow menu control. The label changes when the menu opens, and any omitted value falls back to English. + +```jsx filename="noinline" +const LocalizedMenu = () => ( +
+ + Primeros pasos + Registrarse + +
+) -When the menu is open on narrow viewports, ensure that the rest of the document is hidden from screen readers. This can be achieved by adding `aria-hidden="true"` or `inert` to the main content area when the menu is open. +render() +``` + +## Accessibility -Use the `onNarrowMenuToggle` prop to detect when the mobile menu is opened or closed. +- Provide a concise, meaningful `title`. It labels the navigation and communicates the subdomain to assistive technologies. +- When the menu opens on narrow viewports, hide the rest of the document from screen readers with `inert` or `aria-hidden="true"`. Use `onNarrowMenuToggle` to track the menu state. +- Ensure interactive content supplied through `leadingComponent` or `trailingComponent` has an accessible name and remains keyboard operable. +- Localize the narrow menu's visible and accessible labels with `menuLabels`. +- Choose a `keyboardShortcut` that does not conflict with browser, operating system, or application shortcuts. Always provide another visible way to open search. +- For search, localize its visible text, accessible labels, result headings, and live-region announcements. Supplying only some `labels` values produces a mix of localized text and English defaults. ## Component props ### SubdomainNavBar -| Name | Type | Default | Description | -| :------------------- | :------------------------------ | :------------------: | :------------------------------------------------------------------------------------------------------------------------------------- | -| `children` | | | Valid child nodes | -| `className` | `string` | | Sets a custom class | -| `id` | `string` | | Sets a custom id | -| `logoHref` | `string` | `https://github.com` | Optionally change the URL of the logo | -| `title` | `string` | | The title or name of the subdomain. Appears adjacent to the logo and is required for communicating content to assisitive technologies. | -| `titleHref` | `string` | `/` | The URL for the site. Typically used to link the title prop value to the site root. | -| `ref` | `React.RefObject` | | Forward a Ref to the underlying DOM node | -| `onNarrowMenuToggle` | `(isOpen: boolean) => void` | | When the mobile menu is opened or closed, this callback is called with the new open state. | +| Name | Type | Default | Description | +| :------------------- | :----------------------------------- | :------------------- | :---------------------------------------------------------------------- | +| `children` | | | Valid child nodes | +| `className` | `string` | | Sets a custom class on the root element | +| `id` | `string` | | Sets a custom ID on the root element | +| `style` | `React.CSSProperties` | | Forwards custom inline styles to the root element | +| `fixed` | `boolean` | `true` | Fixes the navigation bar to the top of the viewport | +| `fullWidth` | `boolean` | `false` | Allows the inner content to fill the available width | +| `logoHref` | `string` | `https://github.com` | Changes the URL of the GitHub logo | +| `title` | `string` | | Required subdomain name used visibly and by assistive technologies | +| `titleHref` | `string` | `/` | Links the title to the subdomain root | +| `leadingComponent` | `React.ReactElement` | | Custom element rendered after the title and before navigation links | +| `trailingComponent` | `React.ReactElement` | | Custom element rendered after search and actions | +| `menuLabels` | `Partial` | English labels | Overrides the narrow menu's visible and accessible labels | +| `ref` | `React.Ref` | | Ref to the root element with `openSearch()` and `closeSearch()` methods | +| `onNarrowMenuToggle` | `(isOpen: boolean) => void` | | Called with the new state when the narrow menu opens or closes | + +`SubdomainNavBarProps`, `SubdomainNavBarHandle`, and `SubdomainNavBarMenuLabels` are exported from `@primer/react-brand`. + +### Narrow menu labels + +| Field | Type | English default | Purpose | +| :----------- | :------- | :-------------- | :---------------------------------------------------- | +| `menuLabel` | `string` | `Menu` | Visible and accessible label while the menu is closed | +| `closeLabel` | `string` | `Close` | Visible and accessible label while the menu is open | + +### SubdomainNavBar.Search + +| Name | Type | Default | Description | +| :----------------- | :----------------------------------------------- | :--------------- | :------------------------------------------------------------------------ | +| `onSubmit` | `(event: FormEvent) => void` | | Required search form submit handler | +| `onChange` | `(event: ChangeEvent) => void` | | Required search input change handler | +| `placeholder` | `string` | `Search {title}` | Text shown in the input trigger and opened search input | +| `shortcutLabel` | `string` | Shortcut value | Visible input-trigger hint; pass an empty string to hide it | +| `keyboardShortcut` | `string \| false` | `/` | Global key or modifier combination that opens search; `false` disables it | +| `labels` | `Partial` | English labels | Overrides visible and accessible search text and formatting functions | +| `searchResults` | `SubdomainNavBarSearchResults` | | Flat or explicitly grouped results | +| `searchTerm` | `string` | | Current query used in result headings and accessible labels | +| `className` | `string` | | Sets a custom class on the search trigger container | +| `ref` | `React.Ref` | | Ref to the input inside the opened search dialog | + +`SubdomainNavBarSearchProps` and `SubdomainNavBarSearchLabels` are exported from `@primer/react-brand`. + +### Search labels + +| Field | Type | English default | Purpose | +| :---------------------- | :-------------------------------- | :---------------------------- | :------------------------------------------------------- | +| `searchLabel` | `string` | `Search` | Accessible label for the search input | +| `closeLabel` | `string` | `Close` | Visible and accessible close action | +| `resultsLabel` | `string` | `Results` | Accessible label for an untitled result group | +| `searchResultsLabel` | `string` | `Search results` | Accessible label for grouped results without a query | +| `formatSearchWithTitle` | `(title: string) => string` | `Search ${title}` | Formats the default placeholder and dialog label | +| `formatSearchTrigger` | `(placeholder: string) => string` | `${placeholder} search` | Formats the responsive search trigger's accessible label | +| `formatResultsHeading` | `(searchTerm: string) => string` | `Results for “${searchTerm}”` | Formats the visible heading for ungrouped results | +| `formatResultsLabel` | `(searchTerm: string) => string` | `Results for ${searchTerm}` | Formats the accessible label for grouped results | +| `formatSuggestions` | `(count: number) => string` | `${count} suggestions.` | Formats the polite live-region result-count announcement | + +### Search result types + +`SubdomainNavBarSearchResultProps`, `SubdomainNavBarSearchResultGroupProps`, and `SubdomainNavBarSearchResults` are exported from `@primer/react-brand`. + +#### `SubdomainNavBarSearchResultProps` + +| Field | Type | Required | Description | +| :------------ | :-------- | :------: | :----------------------------------------------------------------------- | +| `title` | `string` | Yes | Linked result title | +| `description` | `string` | Yes | Result summary | +| `url` | `string` | Yes | Link destination | +| `date` | `string` | Yes | Displayed date string; format it for the user's locale before passing it | +| `category` | `string` | | Optional metadata displayed after the date | +| `group` | `string` | | Groups flat results under a shared heading | +| `isExternal` | `boolean` | | Shows an external-link indicator for grouped results | + +#### `SubdomainNavBarSearchResultGroupProps` + +| Field | Type | Required | Description | +| :-------- | :----------------------------------- | :------: | :-------------------------------- | +| `title` | `string` | Yes | Visible and accessible group name | +| `results` | `SubdomainNavBarSearchResultProps[]` | Yes | Results in the group | -`SubdomainNavBar.Link` are anchor links. +`SubdomainNavBar.Link` renders an anchor link. -| Name | Type | Default | Description | -| :----------- | :---------------- | :-----: | :-------------------------------------------------------- | -| `children` | `string` | | Label text | -| `className` | `string` | | Applies a custom class | -| `href` | `string` | | Destination path for the anchor element | -| `id` | `string` | | Sets a custom id | -| `ref` | `React.RefObject` | | Forward a Ref to the underlying DOM node | -| `isExternal` | `boolean` | `false` | When true, renders a external link icon after to the link | +| Name | Type | Default | Description | +| :----------- | :---------------- | :-----: | :------------------------------------------------------- | +| `children` | `React.ReactNode` | | Link content | +| `className` | `string` | | Applies a custom class | +| `href` | `string` | | Destination path for the anchor element | +| `isExternal` | `boolean` | `false` | Renders an external-link icon after the link when `true` | -Additional props can be passed to the `` element. [See MDN for a list of props](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes) accepted by the `` element. +Additional props are passed to the wrapping `
  • ` element. [See MDN for accepted list item attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/li#attributes). diff --git a/packages/design-tokens/src/tokens/functional/components/subdomain-nav-bar/colors.js b/packages/design-tokens/src/tokens/functional/components/subdomain-nav-bar/colors.js index 1e01c5f2a0..ed1e3c30e2 100644 --- a/packages/design-tokens/src/tokens/functional/components/subdomain-nav-bar/colors.js +++ b/packages/design-tokens/src/tokens/functional/components/subdomain-nav-bar/colors.js @@ -28,6 +28,24 @@ module.exports = { }, }, }, + color: { + link: { + bgColor: { + value: 'var(--base-color-scale-gray-1)', + dark: 'var(--base-color-scale-gray-6)', + }, + }, + }, + searchDialog: { + shadowColor: { + value: 'rgba(0, 0, 0, 0.1)', + dark: 'rgba(0, 0, 0, 0.1)', + }, + backdropColor: { + value: 'rgba(0, 0, 0, 0.2)', + dark: 'rgba(0, 0, 0, 0.2)', + }, + }, border: { nav: { default: { diff --git a/packages/e2e/scripts/playwright/playwright.generate-tests.ts b/packages/e2e/scripts/playwright/playwright.generate-tests.ts index af99589393..eac23c3603 100644 --- a/packages/e2e/scripts/playwright/playwright.generate-tests.ts +++ b/packages/e2e/scripts/playwright/playwright.generate-tests.ts @@ -39,6 +39,9 @@ const waitForTimeoutLookup = { 'components-subdomainnavbar--mobile-menu-open-many-items': 5500, // for all staggered animations 'components-subdomainnavbar--mobile-search-results-visible': 5500, // for the animation 'components-subdomainnavbar--mobile-no-links': 5500, // for the animation + 'components-subdomainnavbar--mobile-leading-component-only-menu-open': 5500, // for the animation + 'components-subdomainnavbar--tablet-menu-open': 5500, // for all staggered animations + 'components-subdomainnavbar--overflow-menu-open': 1500, // wait for responsive overflow measurement 'components-subdomainnavbar--reversed-button-order-narrow': 5500, // for the animation 'components-button-features--primary-focus-non-standard-bg': 2000, // for the interaction test 'components-button-features--primary-focus': 2000, // for the interaction test @@ -113,6 +116,30 @@ const waitForTimeoutLookup = { 'recipes-flexsuite-details--ai': 7000, // for the youtube video posters to load } +const beforeScreenshotLookup: Partial> = { + 'components-subdomainnavbar--overflow-menu-open': ` + const moreButton = page.getByRole('button', {name: 'More'}) + if ((await moreButton.getAttribute('aria-expanded')) !== 'true') { + await moreButton.click() + } + const overflowMenu = page.locator(\`[id="\${await moreButton.getAttribute('aria-controls')}"]\`) + await expect(moreButton).toHaveAttribute('aria-expanded', 'true') + await expect(overflowMenu).toBeVisible() + await expect(overflowMenu.getByRole('link', {name: 'Resources'})).toBeVisible() + `, +} + +const screenshotOptionsLookup: Partial> = { + 'components-subdomainnavbar--overflow-menu-open': `{animations: 'allow'}`, +} + +const viewportLookup: Partial> = { + 'components-subdomainnavbar--desktop-pill-states': {width: 1440, height: 900}, + 'components-subdomainnavbar--overflow-menu-open': {width: 1440, height: 900}, + 'components-subdomainnavbar--tablet-menu-open': {width: 800, height: 900}, + 'components-subdomainnavbar--tablet-view': {width: 800, height: 900}, +} + // const skipLocalizationsTestsFor = [ // 'components-actionmenu-features--disabled-item', // for the menu to open // 'components-actionmenu-features--anchored-positioning', // for the menu to open @@ -145,7 +172,6 @@ const skipTestLookup = [ 'components-logosuite-features--mixed-width', // animation only 'components-logosuite-features--following-hero', // animation only 'components-logosuite-features--stacked', // animation only - 'components-subdomainnavbar--overflow-menu-open', // flakey despite timeout 'components-ide-features--editor-only', // animation too long 'components-ide-features--editor-no-replay-button', // animation too long 'components-ide-features--chat-only', // animation too long @@ -226,6 +252,7 @@ for (const key of Object.keys(categorisedStories)) { ) const requiresTabletViewport = storyName.toLowerCase().includes('tablet') + const viewport = viewportLookup[id] const requiresTouch = touchTestLookup.includes(id) if (skipTestLookup.includes(id)) { return acc @@ -242,8 +269,8 @@ for (const key of Object.keys(categorisedStories)) { await page.goto('http://localhost:${port}/iframe.html?${localeParam}args=&id=${id}&viewMode=story', { waitUntil: 'networkidle' }) await page.locator('body.sb-show-main').waitFor({ state: 'visible' }) - ${timeout ? `await page.waitForTimeout(${timeout})` : ''} - await expect(page).toHaveScreenshot({ fullPage: true }) + ${timeout ? `await page.waitForTimeout(${timeout})` : ''}${beforeScreenshotLookup[id] ?? ''} + await expect(page).toHaveScreenshot(${screenshotOptionsLookup[id] ?? '{fullPage: true}'}) }); ` @@ -252,6 +279,16 @@ for (const key of Object.keys(categorisedStories)) { const languagesToTest = ['en'] const allLanguageTests = languagesToTest.map(language => generateTestForLanguage(language)).join('') + if (viewport) { + return (acc += ` + // eslint-disable-next-line i18n-text/no-en + test.describe('Custom viewport test for ${storyName}', () => { + test.use({ viewport: { width: ${viewport.width}, height: ${viewport.height} } }); + ${allLanguageTests} + }); + `) + } + if (requiresMobileViewport) { return (acc += ` // eslint-disable-next-line i18n-text/no-en diff --git a/packages/react/src/SubdomainNavBar/NavigationVisbilityObserver.tsx b/packages/react/src/SubdomainNavBar/NavigationVisbilityObserver.tsx index 8b7b4e1fd2..645888fbee 100644 --- a/packages/react/src/SubdomainNavBar/NavigationVisbilityObserver.tsx +++ b/packages/react/src/SubdomainNavBar/NavigationVisbilityObserver.tsx @@ -1,4 +1,4 @@ -import React, {forwardRef, useCallback, useRef, useState, PropsWithChildren} from 'react' +import React, {forwardRef, useCallback, useEffect, useRef, useState, PropsWithChildren} from 'react' import {clsx} from 'clsx' import {ChevronDownIcon} from '@primer/octicons-react' @@ -9,9 +9,15 @@ import type {VisibilityMap} from './useVisibilityObserver' import type {SubdomainNavBarLinkProps} from './SubdomainNavBar' import styles from './SubdomainNavBar.module.css' +import {useAnchoredPosition} from '../hooks/useAnchoredPosition' import {useKeyboardEscape} from '../hooks/useKeyboardEscape' import {useWindowSize} from '../hooks/useWindowSize' import {useProvidedRefOrCreate} from '../hooks/useRef' +import {SubdomainNavBarLinkContext} from './SubdomainNavBarLinkContext' + +type NavigationLinkProps = SubdomainNavBarLinkProps & { + 'data-navitemid'?: string +} type NavigationVisibilityObserverProps = PropsWithChildren< BaseProps & React.HTMLAttributes @@ -22,102 +28,221 @@ export const NavigationVisbilityObserver = forwardRef( forwardedRef as React.RefObject | React.RefCallback | null, ) - const [visibilityMap] = useVisibilityObserver(navRef, children) + const overflowRef = useRef(null) + const overflowButtonRef = useRef(null) + const overflowMenuRef = useRef(null) const {isMedium} = useWindowSize() + const measurementKey = React.Children.toArray(children) + .map((child, index) => { + if (React.isValidElement(child)) { + return `${index}:${child.props['data-navitemid'] ?? ''}` + } + + return `${index}:` + }) + .join('|') + const [visibilityMap] = useVisibilityObserver(navRef, overflowRef, measurementKey, !isMedium) + const [menuOpen, setMenuOpen] = useState(false) const showOverflow = Object.values(visibilityMap).includes(false) + const overflowMenuId = React.useId() + const firstOverflowedItemIndex = React.Children.toArray(children).findIndex(child => { + if (React.isValidElement(child)) { + const visibilityKey = child.props['data-navitemid'] + return Boolean(visibilityKey && visibilityMap[visibilityKey] === false) + } + + return false + }) + const overflowOrder = + firstOverflowedItemIndex === -1 ? React.Children.count(children) * 2 : firstOverflowedItemIndex * 2 - 1 + + const navItems = React.Children.map(children, (child, index) => { + if (React.isValidElement(child)) { + const visibilityKey = child.props['data-navitemid'] + if (!visibilityKey) { + return child + } - return ( -
      - {React.Children.map(children, child => { - if (React.isValidElement(child)) { - const visibilityKey = child.props['data-navitemid'] - if (!visibilityKey) { - return child - } - const isVisible = visibilityMap[visibilityKey] - return React.cloneElement(child, { + const isOverflowed = isMedium && visibilityMap[visibilityKey] === false + return ( + + {React.cloneElement(child, { className: clsx( child.props.className, - isMedium && isVisible && styles['SubdomainNavBar-primary-nav-list-item--visible'], - isMedium && isVisible === false && styles['SubdomainNavBar-primary-nav-list-item--invisible'], + isOverflowed && styles['SubdomainNavBar-primary-nav-list-item--overflowed'], ), - }) - } - return child - })} + style: { + ...child.props.style, + order: index * 2, + }, + })} + + ) + } + + return child + }) + + const handleClose = useCallback(() => { + setMenuOpen(false) + }, []) + + const handleOverflowButtonClick = () => { + setMenuOpen(prevMenuOpen => !prevMenuOpen) + } + + const handleEscape = useCallback(() => { + if (menuOpen) { + handleClose() + overflowButtonRef.current?.focus() + } + }, [handleClose, menuOpen]) + + useEffect(() => { + if (!showOverflow) { + handleClose() + } + }, [handleClose, showOverflow]) + + useOnClickOutside(overflowRef, handleClose, overflowMenuRef) + useKeyboardEscape(handleEscape) + + const {position} = useAnchoredPosition( + { + floatingElementRef: overflowMenuRef, + anchorElementRef: overflowButtonRef, + align: 'end', + side: 'outside-bottom', + }, + [menuOpen, showOverflow, overflowOrder, visibilityMap], + ) + + if (!isMedium) { + return ( +
        + {children} +
      + ) + } + + return ( +
      +
        + {navItems} + +
      + + {children} + +
      + ) + }, +) - {showOverflow && {children}} -
    +type OverflowButtonProps = { + ariaControls: string + buttonRef: React.RefObject + menuOpen: boolean + onClick: () => void + order: number + visible: boolean +} & BaseProps + +const OverflowButton = forwardRef( + ({ariaControls, buttonRef, className, menuOpen, onClick, order, visible}, forwardedRef) => { + const ref = useProvidedRefOrCreate( + forwardedRef as React.RefObject | React.RefCallback | null, + ) + + return ( +
  • + +
  • ) }, ) -type AnchoredOverlayProps = { +type OverflowMenuProps = { + id: string + menuOpen: boolean + onClose: () => void + position?: {top: number; left: number} visibilityMap: VisibilityMap } & BaseProps -function AnchoredOverlay({children, className, visibilityMap}: React.PropsWithChildren) { - const [anchorEl, setAnchorEl] = useState(null) - const ref = useRef(null) - - useOnClickOutside(ref, () => handleClose()) - - const open = Boolean(anchorEl) - - const handleClick = event => { - if (anchorEl) { - handleClose() - } else { - setAnchorEl(event.currentTarget) +const OverflowMenu = forwardRef>( + ({children, className, id, menuOpen, onClose, position, visibilityMap}, forwardedRef) => { + if (!menuOpen) { + return null } - } - - const handleClose = useCallback(() => { - setAnchorEl(null) - }, []) - - useKeyboardEscape(handleClose) - - return ( -
  • - + return (
      {React.Children.map(children, child => { - if (React.isValidElement(child)) { + if (React.isValidElement(child)) { const navItemChild = child.props['data-navitemid'] if (!navItemChild || visibilityMap[navItemChild]) { return null } return ( - + {React.cloneElement(child, { - onClick: handleClose, className: clsx(styles['SubdomainNavBar-overflow-menu-item'], child.props.className), })} - + ) } return null })}
    -
  • - ) -} + ) + }, +) diff --git a/packages/react/src/SubdomainNavBar/SubdomainNavBar.module.css b/packages/react/src/SubdomainNavBar/SubdomainNavBar.module.css index a3623c41f5..5db3d2039b 100644 --- a/packages/react/src/SubdomainNavBar/SubdomainNavBar.module.css +++ b/packages/react/src/SubdomainNavBar/SubdomainNavBar.module.css @@ -2,25 +2,41 @@ * Main styles for the subdomain navigation bar. */ .SubdomainNavBar { + --SubdomainNavBar-height: var(--base-size-64); + position: relative; z-index: 90; font-weight: 550; - background: var(--brand-SubdomainNavBar-canvas-default); - -webkit-backdrop-filter: blur(16px); - backdrop-filter: blur(16px); - border-bottom: 1px solid rgba(0, 0, 0, 0.1); - transition: background-color var(--brand-animation-duration-fast) var(--brand-animation-easing-default), - border-color var(--brand-animation-duration-fast) var(--brand-animation-easing-default); - height: 75px; + background: var(--brand-color-canvas-default); + border-block-end: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + height: var(--SubdomainNavBar-height); +} + +.SubdomainNavBar::after { + content: ''; + position: absolute; + inset-inline: 0; + inset-block-start: calc(100% + var(--brand-borderWidth-thin)); + height: var(--brand-borderWidth-thin); + background-color: var(--brand-color-text-emphasized); + pointer-events: none; } /* * Outer container */ .SubdomainNavBar-outer-container { + --SubdomainNavBar-height: var(--base-size-64); + z-index: 90; } +@media screen and (max-width: 47.99rem) { + .SubdomainNavBar-outer-container { + --SubdomainNavBar-height: calc(var(--base-size-64) - var(--base-size-8)); + } +} + .SubdomainNavBar-outer-container:not(.SubdomainNavBar-outer-container--fixed) { position: relative; } @@ -95,13 +111,6 @@ transform: translateX(0); } -.SubdomainNavBar-title-separator { - color: var(--brand-color-text-muted); - font-weight: var(--base-text-weight-normal); - font-size: var(--base-size-32); - line-height: 29px; -} - .SubdomainNavBar-logo-mark svg { width: 32px; height: 32px; @@ -121,6 +130,21 @@ text-decoration: none; } +.SubdomainNavBar-leading-component, +.SubdomainNavBar-trailing-component { + display: inline-flex; + align-items: center; + min-width: 0; +} + +.SubdomainNavBar-leading-component { + margin-inline-end: var(--base-size-16); +} + +.SubdomainNavBar-trailing-component { + margin-inline-start: var(--base-size-16); +} + /* * Primary navigation link area */ @@ -128,6 +152,8 @@ display: inline-flex; list-style-type: none; align-items: center; + flex: 1 1 0; + min-width: 0; padding: 0; margin: 0; } @@ -151,20 +177,36 @@ padding: 0; } + .SubdomainNavBar-leading-component, + .SubdomainNavBar-trailing-component { + flex: none; + width: 100%; + box-sizing: border-box; + padding-block: var(--base-size-16); + padding-inline: var(--base-size-16); + } + + .SubdomainNavBar-leading-component { + margin-inline-end: 0; + } + + .SubdomainNavBar-trailing-component { + margin-inline-start: 0; + } + .SubdomainNavBar-menu-wrapper { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: flex-start; overflow-y: auto; position: absolute; - top: 75px; + top: var(--SubdomainNavBar-height); left: 0; right: 0; - bottom: 0; z-index: 2; background-color: var(--brand-color-canvas-default); - width: 100vw; - height: calc(100vh - 75px); + width: 100%; + max-height: calc(100vh - var(--SubdomainNavBar-height) - var(--SubdomainNavBar-menu-offset-block-start, 0px)); animation: fade-in-down 500ms; animation-timing-function: var(--brand-animation-easing-default); } @@ -172,50 +214,40 @@ .SubdomainNavBar-menu-wrapper--close { display: none; } -} -@media screen and (min-width: 48rem) { - .SubdomainNavBar-primary-nav-list { + .SubdomainNavBar-menu-wrapper-footer { + flex: none; display: flex; - align-items: center; + flex-direction: column; width: 100%; - max-width: 150px; - padding: 0; - margin: 0 24px 0 0; - /* overflow: hidden; */ - } -} - -@media screen and (min-width: 768px) { - .SubdomainNavBar-primary-nav-list { - max-width: 100px; - } -} - -@media screen and (min-width: 850px) { - .SubdomainNavBar-primary-nav-list { - max-width: 200px; } } -@media screen and (min-width: 1024px) { - .SubdomainNavBar-primary-nav-list { - max-width: 300px; +@media screen and (min-width: 48rem) { + .SubdomainNavBar-primary-nav-overflow { + display: flex; + align-items: center; + flex: 1 1 0; + min-width: 0; + margin: 0 var(--base-size-24) 0 0; } -} -@media screen and (min-width: 1280px) { .SubdomainNavBar-primary-nav-list { - max-width: 520px; - margin-right: 80px; - } - - .SubdomainNavBar-outer-container--has-actions .SubdomainNavBar-primary-nav-list { - max-width: 465px; + display: flex; + align-items: center; + flex: 1 1 auto; + flex-wrap: wrap; + min-width: 0; + block-size: var(--base-size-48); + max-block-size: var(--base-size-48); + overflow: hidden; + padding: 0; + margin: 0; } } .SubdomainNavBar-primary-nav-list-item { + flex: 0 0 auto; white-space: nowrap; } @@ -230,22 +262,24 @@ } @media screen and (min-width: 48rem) { - .SubdomainNavBar-primary-nav-list-item--visible { - order: 0; - visibility: visible; - opacity: 1; + .SubdomainNavBar-primary-nav-list-item--overflowed { + pointer-events: none; } - .SubdomainNavBar-primary-nav-list-item--invisible { - order: 100; + .SubdomainNavBar-primary-nav-list-item--overflow { + display: flex; + align-items: center; + flex: 0 0 auto; + position: absolute; + inset-inline-end: 0; visibility: hidden; pointer-events: none; } - .SubdomainNavBar-primary-nav-list-item--overflow { - order: 99; + .SubdomainNavBar-primary-nav-overflow[data-has-overflow='true'] .SubdomainNavBar-primary-nav-list-item--overflow { position: relative; - right: 0; + visibility: visible; + pointer-events: auto; } } @@ -272,6 +306,12 @@ position: relative; } +.SubdomainNavBar-link-content { + display: inline-flex; + align-items: center; + gap: var(--base-size-8); +} + @media screen and (max-width: 767px) { .SubdomainNavBar-link:first-of-type { padding-top: var(--base-size-24); @@ -366,8 +406,8 @@ .SubdomainNavBar-overflow-menu { position: absolute; - right: 0; - top: var(--base-size-48); + z-index: 100; + right: auto; background-color: var(--base-color-scale-white-0); border-radius: var(--brand-borderRadius-large); animation: fade-in-down 0.25s, enlarge-shadow 250ms forwards; @@ -391,16 +431,30 @@ .SubdomainNavBar-secondary-nav { display: flex; align-items: center; - margin-left: auto; + flex: 0 1 auto; min-width: 0; + margin-left: auto; } .SubdomainNavBar-search-trigger { + box-sizing: border-box; display: flex; align-items: center; + min-width: 0; +} + +@media screen and (min-width: 63.25rem) { + .SubdomainNavBar-search-trigger { + --SubdomainNavBar-search-input-width: calc(var(--base-size-128) * 2 + var(--base-size-24)); + + flex: 0 9999 var(--SubdomainNavBar-search-input-width); + width: var(--SubdomainNavBar-search-input-width); + max-width: 100%; + min-width: calc(var(--base-size-128) + var(--base-size-32)); + } } -.SubdomainNavBar-search-button, .SubdomainNavBar-menu-button { + position: relative; cursor: pointer; appearance: none; background-color: transparent; @@ -414,19 +468,130 @@ transition: border 250ms var(--brand-animation-easing-default), box-shadow 250ms ease; border-radius: var(--brand-borderRadius-medium); box-shadow: 0 0 0 2px transparent; + gap: 0; } -.SubdomainNavBar-search-button svg, .SubdomainNavBar-menu-button svg { width: 24px; height: 24px; } +.SubdomainNavBar-search-input-button { + cursor: pointer; + appearance: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--base-size-16); + width: 100%; + block-size: var(--base-size-32); + min-block-size: var(--base-size-32); + padding: var(--base-size-4) var(--base-size-4) var(--base-size-4) calc(var(--base-size-8) + var(--base-size-2)); + overflow: hidden; + color: var(--brand-color-text-muted); + background-color: var(--brand-color-canvas-subtle); + border: 0; + border-radius: var(--brand-borderRadius-medium); + font-family: var(--brand-body-fontFamily); + transition: background-color var(--brand-animation-duration-fast) var(--brand-animation-easing-default), + box-shadow var(--brand-animation-duration-fast) var(--brand-animation-easing-default); +} + +.SubdomainNavBar-search-input-button:hover { + box-shadow: inset 0 0 0 var(--brand-borderWidth-thin) var(--brand-color-border-muted); +} + +.SubdomainNavBar-search-input-button:focus-visible { + outline: var(--base-size-4) solid var(--brand-color-focus); + outline-offset: var(--base-size-2); +} + +.SubdomainNavBar-search-input-button:active { + box-shadow: inset 0 0 0 var(--brand-borderWidth-thin) var(--brand-color-border-default); +} + +.SubdomainNavBar-search-input-button-placeholder { + display: inline-flex; + align-items: center; + gap: var(--base-size-8); + min-width: 0; + overflow: hidden; + color: var(--brand-color-text-muted); + font-size: var(--brand-text-size-100); + font-weight: var(--base-text-weight-normal); + line-height: 1.2; + white-space: nowrap; + text-overflow: ellipsis; +} + +.SubdomainNavBar-search-input-button-placeholder svg { + flex: none; +} + +.SubdomainNavBar-search-input-button-placeholder span { + overflow: hidden; + text-overflow: ellipsis; +} + +.SubdomainNavBar-search-input-button-shortcut { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + align-self: stretch; + min-width: var(--base-size-24); + padding-inline: var(--base-size-8); + color: var(--brand-color-text-default); + background-color: var(--brand-color-canvas-default); + border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + border-radius: calc(var(--brand-borderRadius-medium) / 2); + font-family: var(--brand-fontStack-monospace); + font-size: var(--brand-text-size-100); + font-weight: var(--base-text-weight-normal); + line-height: 1.6; +} + +@media screen and (max-width: 47.99rem) { + .SubdomainNavBar-search-trigger { + flex: 0 0 var(--base-size-48); + justify-content: center; + width: var(--base-size-48); + } + + .SubdomainNavBar-search-input-button { + justify-content: center; + gap: 0; + width: var(--base-size-32); + padding: var(--base-size-8); + background-color: transparent; + } + + .SubdomainNavBar-search-input-button-placeholder { + gap: 0; + } + + .SubdomainNavBar-search-input-button-placeholder span, + .SubdomainNavBar-search-input-button-shortcut { + display: none; + } +} + .SubdomainNavBar-menu-button { - display: inline-block; + display: inline-flex; + align-items: center; align-self: center; } +.SubdomainNavBar-menu-button-icon { + display: inline-flex; + flex-direction: column; + gap: calc(var(--base-size-4) - var(--brand-borderWidth-thin)); +} + +.SubdomainNavBar-menu-button-label { + display: none; +} + .SubdomainNavBar-menu-button-bar { width: 22px; height: 2px; @@ -465,29 +630,230 @@ transform: rotate(45deg) translateY(3px); } -@media screen and (min-width: 48rem) { - .SubdomainNavBar-search-button:hover, +.SubdomainNavBar-mobile-menu-button.SubdomainNavBar-menu-button--close { + background-color: var(--brand-color-canvas-muted); +} + +.SubdomainNavBar-mobile-menu-button.SubdomainNavBar-menu-button--close::after { + content: ''; + position: absolute; + z-index: 1; + inset-inline: 0; + inset-block-end: calc(var(--brand-borderWidth-thin) * -2); + height: calc(var(--brand-borderWidth-thin) * 2); + background-color: var(--brand-color-canvas-muted); +} + +@media screen and (min-width: 63.25rem) { .SubdomainNavBar-menu-button:hover { + background-color: transparent; border: var(--brand-borderWidth-thick) solid var(--brand-SubdomainNavBar-border-button-hover); } - .SubdomainNavBar-search-button:active, .SubdomainNavBar-menu-button:active { + background-color: transparent; box-shadow: 0 0 0 1px var(--brand-SubdomainNavBar-border-button-hover); } .SubdomainNavBar-mobile-menu-button { display: none; } +} + +@media screen and (min-width: 48rem) and (max-width: 63.24rem) { + .SubdomainNavBar-primary-nav { + display: none; + } + + .SubdomainNavBar-secondary-nav { + position: relative; + flex: 0 0 auto; + align-self: stretch; + } - .SubdomainNavBar-search-dialog-control-area .SubdomainNavBar-search-button, - .SubdomainNavBar-search-dialog-control-area .SubdomainNavBar-menu-button { + .SubdomainNavBar-search-trigger { + --SubdomainNavBar-tablet-search-width: calc(var(--base-size-128) * 2 + var(--base-size-2)); + + flex: 0 0 var(--SubdomainNavBar-tablet-search-width); + width: var(--SubdomainNavBar-tablet-search-width); + padding-inline: var(--base-size-8); + border-inline-start: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + } + + .SubdomainNavBar-search-input-button { + width: 100%; + } + + .SubdomainNavBar-mobile-menu-button { + align-self: stretch; + flex: none; + flex-direction: row; + align-items: center; + justify-content: center; + gap: var(--base-size-8); + width: max-content; + min-width: calc(var(--base-size-64) + var(--base-size-32)); + height: 100%; + padding-inline: var(--base-size-16); + color: var(--brand-color-text-default); + border: 0; + border-inline-start: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + border-inline-end: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + border-radius: 0; + overflow: visible; + font-family: var(--brand-body-fontFamily); + font-size: var(--brand-text-size-100); + line-height: var(--brand-text-lineHeight-100); + } + + .SubdomainNavBar-menu-button-label { display: inline-flex; - width: 48px; + align-items: center; + white-space: nowrap; + } + + .SubdomainNavBar-menu-button-icon { + flex: none; + align-items: stretch; + justify-content: center; + width: var(--base-size-16); + height: var(--base-size-16); + overflow: visible; + } + + .SubdomainNavBar-menu-button-bar { + width: var(--base-size-16); + height: var(--base-size-2); + margin: 0; + transform-origin: center; + } + + .SubdomainNavBar-menu-button--close .SubdomainNavBar-menu-button-bar:nth-of-type(1) { + transform: translateY(calc(var(--base-size-6) - var(--brand-borderWidth-thin))) rotate(45deg); + } + + .SubdomainNavBar-menu-button--close .SubdomainNavBar-menu-button-bar:nth-of-type(3) { + transform: translateY(calc(var(--brand-borderWidth-thin) - var(--base-size-6))) rotate(-45deg); + } + + .SubdomainNavBar-menu-wrapper { + display: flex; + flex-direction: column; + justify-content: flex-start; + overflow-y: auto; + position: absolute; + inset-inline: 0; + top: var(--SubdomainNavBar-height); + z-index: 2; + box-sizing: border-box; + width: 100%; + max-height: calc(100vh - var(--SubdomainNavBar-height) - var(--SubdomainNavBar-menu-offset-block-start, 0px)); + background-color: var(--brand-color-canvas-default); + border-inline: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + border-block-end: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + animation: fade-in-down var(--brand-animation-duration-default); + animation-timing-function: var(--brand-animation-easing-default); + } + + .SubdomainNavBar-menu-wrapper--close { + display: none; + } + + .SubdomainNavBar-primary-nav-list--visible { + display: block; + margin: 0; + padding: 0; + } + + .SubdomainNavBar-menu-wrapper-footer { + flex: none; + display: flex; + flex-direction: column; + width: 100%; + } + + .SubdomainNavBar-menu-wrapper .SubdomainNavBar-leading-component, + .SubdomainNavBar-menu-wrapper .SubdomainNavBar-primary-nav-list--visible { + border-block-end: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + } + + .SubdomainNavBar-menu-wrapper .SubdomainNavBar-link { + box-sizing: border-box; + width: 100%; + padding: var(--base-size-16); + font-size: var(--brand-text-size-100); + line-height: var(--brand-text-lineHeight-100); + } + + .SubdomainNavBar-menu-wrapper .SubdomainNavBar-primary-nav-list-item:not(.SubdomainNavBar-overflow-menu-item) { + width: 100%; + height: auto; + } + + .SubdomainNavBar-menu-wrapper + .SubdomainNavBar-primary-nav-list-item:not(.SubdomainNavBar-overflow-menu-item) + > .SubdomainNavBar-link { + padding: var(--base-size-16); + } + + .SubdomainNavBar-menu-wrapper + .SubdomainNavBar-primary-nav-list-item:not(.SubdomainNavBar-overflow-menu-item) + > .SubdomainNavBar-link + > .SubdomainNavBar-link-content { + width: 100%; + padding: 0; + background-color: transparent; + border-radius: 0; } - .SubdomainNavBar-search-button { - margin: 0 var(--base-size-16) 0 auto; + .SubdomainNavBar-menu-wrapper + .SubdomainNavBar-primary-nav-list-item:not(.SubdomainNavBar-overflow-menu-item) + > .SubdomainNavBar-link:hover + > .SubdomainNavBar-link-content, + .SubdomainNavBar-menu-wrapper + .SubdomainNavBar-primary-nav-list-item:not(.SubdomainNavBar-overflow-menu-item) + > .SubdomainNavBar-link:active + > .SubdomainNavBar-link-content, + .SubdomainNavBar-menu-wrapper + .SubdomainNavBar-primary-nav-list-item:not(.SubdomainNavBar-overflow-menu-item) + > .SubdomainNavBar-link[aria-current]:not([aria-current='false']) + > .SubdomainNavBar-link-content { + background-color: transparent; + } + + .SubdomainNavBar-menu-wrapper .SubdomainNavBar-link--title { + font-size: var(--brand-text-size-100); + line-height: var(--brand-text-lineHeight-100); + } + + .SubdomainNavBar-menu-wrapper .SubdomainNavBar-leading-component, + .SubdomainNavBar-menu-wrapper .SubdomainNavBar-trailing-component { + box-sizing: border-box; + flex: none; + width: 100%; + height: auto; + margin-inline: 0; + padding: var(--base-size-16); + } + + .SubdomainNavBar-menu-wrapper .SubdomainNavBar-button-area { + display: flex; + width: 100%; + height: auto; + padding: 0; + border-block-start: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + } + + .SubdomainNavBar-menu-wrapper .SubdomainNavBar-button-area-inner { + box-sizing: border-box; + flex-direction: column; + width: 100%; + margin-block: var(--base-size-24); + padding-inline: var(--base-size-16); + } + + .SubdomainNavBar-menu-wrapper .SubdomainNavBar-trailing-component { + border-block-start: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); } } @@ -528,8 +894,10 @@ .SubdomainNavBar-button-area-inner { width: 100%; + box-sizing: border-box; flex-direction: column; - margin: 24px; + margin-block: var(--base-size-24); + padding-inline: var(--base-size-24); min-width: 0; } } @@ -538,78 +906,449 @@ white-space: nowrap; } -@media screen and (min-width: 1024px) { - .SubdomainNavBar-cta-button--secondary { - display: inline-flex; - } +.SubdomainNavBar .SubdomainNavBar-menu-wrapper { + top: var(--SubdomainNavBar-height); + height: auto; + max-height: calc(100vh - var(--SubdomainNavBar-height) - var(--SubdomainNavBar-menu-offset-block-start, 0px)); } -/* - * Search dialog control area - */ -.SubdomainNavBar-search-form { - display: flex; - width: 100%; +.SubdomainNavBar .SubdomainNavBar-inner-container--search-open { + animation: none; } -.SubdomainNavBar-search-dialog { - position: absolute; - z-index: 99; -} +@media screen and (max-width: 47.99rem) { + .SubdomainNavBar { + --SubdomainNavBar-height: calc(var(--base-size-64) - var(--base-size-8)); + --SubdomainNavBar-mobile-control-size: var(--base-size-48); + } -@media screen and (max-width: 767px) { - .SubdomainNavBar-search-dialog { + .SubdomainNavBar .SubdomainNavBar-inner-container { + height: 100%; + padding: 0; + } + + .SubdomainNavBar .SubdomainNavBar-title-area { + height: 100%; + max-width: none; + gap: 0; + padding: 0; + } + + .SubdomainNavBar .SubdomainNavBar-title-area > li { + display: flex; + align-items: center; + height: 100%; + padding-inline: var(--base-size-16); + border-inline-end: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + } + + .SubdomainNavBar .SubdomainNavBar-title-area > li:first-child { + justify-content: center; + width: var(--base-size-64); + padding-inline: 0; + } + + .SubdomainNavBar .SubdomainNavBar-logo-mark { + display: inline-flex; + align-items: center; + justify-content: center; + } + + .SubdomainNavBar .SubdomainNavBar-logo-mark svg { + width: var(--base-size-24); + height: var(--base-size-24); + } + + .SubdomainNavBar .SubdomainNavBar-title { top: 0; - left: 0; - right: 0; - background: var(--brand-SubdomainNavBar-canvas-search); - -webkit-backdrop-filter: blur(100px); - backdrop-filter: blur(100px); - height: 100vh; - width: 100vw; - animation: fade-in-down var(--brand-animation-duration-fast); - animation-timing-function: var(--brand-animation-easing-default); + padding-inline-end: 0; + } + + .SubdomainNavBar .SubdomainNavBar-title > * { + font-size: var(--brand-text-size-100); + line-height: var(--brand-text-lineHeight-100); + } + + .SubdomainNavBar .SubdomainNavBar-secondary-nav { + height: 100%; + } + + .SubdomainNavBar .SubdomainNavBar-search-trigger { + flex: 0 0 var(--SubdomainNavBar-mobile-control-size); + justify-content: center; + width: var(--SubdomainNavBar-mobile-control-size); + height: 100%; + padding-inline: 0; + border-inline-start: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + } + + .SubdomainNavBar .SubdomainNavBar-search-input-button, + .SubdomainNavBar .SubdomainNavBar-mobile-menu-button { + box-sizing: border-box; + display: inline-flex; + flex: 0 0 var(--SubdomainNavBar-mobile-control-size); + width: var(--SubdomainNavBar-mobile-control-size); + flex-direction: column; + height: 100%; + padding-inline: var(--base-size-16); + border-radius: 0; + } + + .SubdomainNavBar .SubdomainNavBar-mobile-menu-button { + gap: calc(var(--base-size-4) - var(--brand-borderWidth-thin)); + border-inline-start: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + } + + .SubdomainNavBar .SubdomainNavBar-menu-button-bar { + width: var(--base-size-16); + height: var(--base-size-2); + margin: 0 auto; + transform-origin: center; + } + + .SubdomainNavBar .SubdomainNavBar-menu-button--close .SubdomainNavBar-menu-button-bar:nth-of-type(1) { + transform: translateY(calc(var(--base-size-6) - var(--brand-borderWidth-thin))) rotate(45deg); + } + + .SubdomainNavBar .SubdomainNavBar-menu-button--close .SubdomainNavBar-menu-button-bar:nth-of-type(3) { + transform: translateY(calc(var(--brand-borderWidth-thin) - var(--base-size-6))) rotate(-45deg); + } + + .SubdomainNavBar-menu-wrapper .SubdomainNavBar-leading-component, + .SubdomainNavBar-menu-wrapper .SubdomainNavBar-primary-nav-list--visible { + border-block-end: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + } + + .SubdomainNavBar .SubdomainNavBar-menu-wrapper .SubdomainNavBar-link { + padding: var(--base-size-16); + font-size: var(--brand-text-size-100); + line-height: var(--brand-text-lineHeight-100); + } + + .SubdomainNavBar .SubdomainNavBar-menu-wrapper .SubdomainNavBar-link--title { + font-size: var(--brand-text-size-100); + line-height: var(--brand-text-lineHeight-100); + } + + .SubdomainNavBar .SubdomainNavBar-menu-wrapper .SubdomainNavBar-button-area-inner { + padding-inline: var(--base-size-16); + } + + .SubdomainNavBar .SubdomainNavBar-menu-wrapper .SubdomainNavBar-button-area, + .SubdomainNavBar .SubdomainNavBar-menu-wrapper .SubdomainNavBar-trailing-component { + border-block-start: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + } + + .SubdomainNavBar + .SubdomainNavBar-menu-wrapper-footer + .SubdomainNavBar-button-area + + .SubdomainNavBar-trailing-component { + border-block-start: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); } } @media screen and (min-width: 48rem) { - .SubdomainNavBar-search-dialog { - position: absolute; + .SubdomainNavBar .SubdomainNavBar-inner-container { + height: 100%; + max-width: none; + padding: 0; + } + + .SubdomainNavBar .SubdomainNavBar-title-area { + height: 100%; + max-width: none; + gap: 0; + padding: 0; + } + + .SubdomainNavBar .SubdomainNavBar-title-area > li { + display: flex; + align-items: center; + height: 100%; + padding-inline: var(--base-size-16); + border-inline-end: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + } + + .SubdomainNavBar .SubdomainNavBar-title-area > li:first-child { + justify-content: center; + width: var(--base-size-64); + padding-inline: 0; + } + + .SubdomainNavBar .SubdomainNavBar-logo-mark { + display: inline-flex; + align-items: center; + justify-content: center; + } + + .SubdomainNavBar .SubdomainNavBar-logo-mark svg { + width: var(--base-size-24); + height: var(--base-size-24); + } + + .SubdomainNavBar .SubdomainNavBar-title { top: 0; - bottom: 1px; - left: 0; - right: 0; - background-color: var(--brand-SubdomainNavBar-canvas-search); + padding-inline-end: 0; + } + + .SubdomainNavBar .SubdomainNavBar-title > * { + font-size: var(--base-size-16); + line-height: var(--base-size-16); + } + + .SubdomainNavBar .SubdomainNavBar-primary-nav, + .SubdomainNavBar .SubdomainNavBar-primary-nav-overflow, + .SubdomainNavBar .SubdomainNavBar-primary-nav-list, + .SubdomainNavBar .SubdomainNavBar-secondary-nav, + .SubdomainNavBar .SubdomainNavBar-button-area, + .SubdomainNavBar .SubdomainNavBar-leading-component, + .SubdomainNavBar .SubdomainNavBar-trailing-component { + height: 100%; + } + + .SubdomainNavBar .SubdomainNavBar-leading-component, + .SubdomainNavBar .SubdomainNavBar-trailing-component { + padding-inline: var(--base-size-16); + margin-inline: 0; + } + + .SubdomainNavBar .SubdomainNavBar-leading-component { + border-inline-end: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + } + + .SubdomainNavBar .SubdomainNavBar-primary-nav-list { + align-items: stretch; + margin: 0; + block-size: 100%; + max-block-size: 100%; + padding-inline-start: var(--base-size-16); + } + + .SubdomainNavBar .SubdomainNavBar-primary-nav-overflow { + margin: 0; + } + + .SubdomainNavBar .SubdomainNavBar-primary-nav-list-item { + display: flex; + align-items: center; + height: 100%; + } + + .SubdomainNavBar .SubdomainNavBar-link { + align-items: center; + height: 100%; + padding: 0 var(--base-size-16); + color: var(--brand-color-text-muted); + font-size: var(--brand-text-size-100); + line-height: var(--brand-text-lineHeight-200); + } + + .SubdomainNavBar + .SubdomainNavBar-primary-nav-list-item:not(.SubdomainNavBar-overflow-menu-item) + > .SubdomainNavBar-link, + .SubdomainNavBar .SubdomainNavBar-more-link { + padding-inline: var(--base-size-4); + } + + .SubdomainNavBar + .SubdomainNavBar-primary-nav-list-item:not(.SubdomainNavBar-overflow-menu-item) + > .SubdomainNavBar-link + > .SubdomainNavBar-link-content, + .SubdomainNavBar .SubdomainNavBar-more-link > .SubdomainNavBar-link-content { + gap: var(--base-size-4); + padding: var(--base-size-4) var(--base-size-12); + background-color: transparent; + border-radius: var(--brand-borderRadius-full); + } + + .SubdomainNavBar + .SubdomainNavBar-primary-nav-list-item:not(.SubdomainNavBar-overflow-menu-item) + > .SubdomainNavBar-link:hover + > .SubdomainNavBar-link-content, + .SubdomainNavBar + .SubdomainNavBar-primary-nav-list-item:not(.SubdomainNavBar-overflow-menu-item) + > .SubdomainNavBar-link:active + > .SubdomainNavBar-link-content, + .SubdomainNavBar + .SubdomainNavBar-primary-nav-list-item:not(.SubdomainNavBar-overflow-menu-item) + > .SubdomainNavBar-link[aria-current]:not([aria-current='false']) + > .SubdomainNavBar-link-content, + .SubdomainNavBar .SubdomainNavBar-more-link:hover > .SubdomainNavBar-link-content, + .SubdomainNavBar .SubdomainNavBar-more-link:active > .SubdomainNavBar-link-content, + .SubdomainNavBar .SubdomainNavBar-more-link[aria-expanded='true'] > .SubdomainNavBar-link-content { + background-color: var(--brand-SubdomainNavBar-color-link-bgColor); + } + + .SubdomainNavBar + .SubdomainNavBar-primary-nav-list-item:not(.SubdomainNavBar-overflow-menu-item) + > .SubdomainNavBar-link:focus-visible, + .SubdomainNavBar .SubdomainNavBar-more-link:focus-visible { + outline: none; + } + + .SubdomainNavBar + .SubdomainNavBar-primary-nav-list-item:not(.SubdomainNavBar-overflow-menu-item) + > .SubdomainNavBar-link:focus-visible + > .SubdomainNavBar-link-content, + .SubdomainNavBar .SubdomainNavBar-more-link:focus-visible > .SubdomainNavBar-link-content { + outline: var(--brand-borderWidth-thick) solid var(--brand-color-focus); + outline-offset: var(--base-size-2); + } + + .SubdomainNavBar .SubdomainNavBar-link-text::after { + display: none; + } + + .SubdomainNavBar .SubdomainNavBar-overflow-menu { + animation: fade-in-down-overflow var(--brand-animation-duration-faster); + animation-timing-function: var(--brand-animation-easing-default); + border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + border-start-start-radius: 0; + border-start-end-radius: 0; + border-end-start-radius: var(--brand-borderRadius-medium); + border-end-end-radius: var(--brand-borderRadius-medium); + box-shadow: none; + min-width: calc(var(--base-size-128) + var(--base-size-32)); + transform: translateY(calc(0px - var(--base-size-4))); + } + + .SubdomainNavBar .SubdomainNavBar-overflow-menu-list { + gap: var(--brand-borderWidth-thin); + padding: var(--base-size-8); + width: 100%; + } + + .SubdomainNavBar .SubdomainNavBar-overflow-menu-item .SubdomainNavBar-link { + width: 100%; + min-height: var(--base-size-40); + padding: var(--base-size-8) var(--base-size-12); + color: var(--brand-color-text-muted); + background-color: var(--brand-color-canvas-default); + border-radius: var(--brand-borderRadius-medium); + font-size: var(--brand-text-size-100); + line-height: var(--brand-text-lineHeight-200); + } + + .SubdomainNavBar .SubdomainNavBar-overflow-menu-item .SubdomainNavBar-link:hover, + .SubdomainNavBar .SubdomainNavBar-overflow-menu-item .SubdomainNavBar-link:focus, + .SubdomainNavBar .SubdomainNavBar-overflow-menu-item .SubdomainNavBar-link:focus-visible { + color: var(--brand-color-text-default); + background-color: var(--brand-color-canvas-subtle); + } + + @media (prefers-reduced-motion: no-preference) { + .SubdomainNavBar + .SubdomainNavBar-primary-nav-list-item:not(.SubdomainNavBar-overflow-menu-item) + > .SubdomainNavBar-link + > .SubdomainNavBar-link-content, + .SubdomainNavBar .SubdomainNavBar-more-link > .SubdomainNavBar-link-content { + transition: background-color var(--brand-animation-duration-fast) var(--brand-animation-easing-default); + } + } + + .SubdomainNavBar .SubdomainNavBar-secondary-nav { + border-inline-start: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + } + + .SubdomainNavBar .SubdomainNavBar-search-trigger { + height: 100%; + padding-inline: var(--base-size-16); + } + + .SubdomainNavBar .SubdomainNavBar-search-trigger--has-trailing-item { + border-inline-end: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); + } + + .SubdomainNavBar .SubdomainNavBar-button-area { + align-items: center; + padding-inline: var(--base-size-16); + } + + .SubdomainNavBar .SubdomainNavBar-button-area--has-trailing-item { + border-inline-end: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted); } } -.SubdomainNavBar-search-dialog .SubdomainNavBar-search-trigger .SubdomainNavBar-search-button { - margin-right: 0; +@media screen and (min-width: 63.25rem) { + .SubdomainNavBar .SubdomainNavBar-search-trigger { + --SubdomainNavBar-search-input-width: clamp( + calc(var(--base-size-128) + var(--base-size-32)), + 28.5vw, + calc(var(--base-size-128) * 3 + var(--base-size-36)) + ); + + align-self: stretch; + } } -.SubdomainNavBar-search-dialog-control-area { - height: 74px; - background: var(--brand-SubdomainNavBar-canvas-default); - -webkit-backdrop-filter: blur(16px); - backdrop-filter: blur(16px); - padding: var(--base-size-12) var(--base-size-28); +@media screen and (min-width: 1024px) { + .SubdomainNavBar-cta-button--secondary { + display: inline-flex; + } +} + +/* + * Search dialog control area + */ +.SubdomainNavBar-search-form { + display: flex; + width: 100%; +} + +.SubdomainNavBar-search-input-area { display: flex; align-items: center; - animation: fade-in-down 500ms; - gap: var(--base-size-4); + gap: var(--base-size-8); + width: 100%; } -@media screen and (min-width: 48rem) { - .SubdomainNavBar-search-dialog-control-area { - max-width: 570px; - margin: 0 auto; - } +.SubdomainNavBar-search-close-button { + cursor: pointer; + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + min-block-size: var(--base-size-32); + padding: var(--base-size-8) var(--base-size-12); + color: var(--brand-color-text-default); + background-color: var(--brand-color-canvas-subtle); + border: 0; + border-radius: var(--brand-borderRadius-medium); + font-family: var(--brand-body-fontFamily); + font-size: var(--brand-text-size-100); + font-weight: var(--base-text-weight-medium); + line-height: var(--base-size-14); + letter-spacing: var(--brand-text-letterSpacing-100); +} + +.SubdomainNavBar-search-close-button:hover { + background-color: var(--brand-color-canvas-inset); +} + +.SubdomainNavBar-search-close-button:focus-visible { + outline: var(--base-size-4) solid var(--brand-color-focus); + outline-offset: var(--base-size-2); +} + +.SubdomainNavBar-search-dialog-control-area { + height: var(--base-size-40); + max-width: none; + padding: 0; + margin: 0; + display: flex; + align-items: center; + background: transparent; } .SubdomainNavBar-search-text-input { border-color: transparent; } +.SubdomainNavBar-search-results-area { + width: 100%; +} + .SubdomainNavBar-search-results-container { animation: fade-in-down 500ms; padding: var(--base-size-24); @@ -641,13 +1380,34 @@ color: var(--base-color-scale-black-0); } +.SubdomainNavBar-search-result-group, +.SubdomainNavBar-search-result-group-list { + list-style: none; + padding: 0; + margin: 0; +} + +.SubdomainNavBar-search-result-group + .SubdomainNavBar-search-result-group { + margin-block-start: var(--base-size-24); +} + +.SubdomainNavBar-search-result-group-heading { + display: block; + margin: 0 0 var(--base-size-20); + padding: 0 var(--base-size-8); + color: var(--brand-color-text-muted); + font-weight: var(--brand-heading-weight-600); + font-size: var(--brand-text-size-100); + line-height: var(--brand-text-lineHeight-100); +} + .SubdomainNavBar-search-result-item { padding: var(--base-size-16) var(--base-size-8); border-bottom: solid var(--brand-borderWidth-thin, 1px) var(--brand-SubdomainNavBar-search-results-border-default, #b7bfc7); } -.SubdomainNavBar-search-result-item[aria-selected='true'] a { +.SubdomainNavBar-search-result-item-container a[aria-selected='true'] { outline: var(--brand-color-focus) auto var(--brand-borderWidth-thick); } @@ -667,6 +1427,10 @@ line-height: var(--brand-text-lineHeight-400); } +.SubdomainNavBar-search-result-item-container a svg { + flex: none; +} + .SubdomainNavBar-search-result-item-container a:hover { text-decoration: none; } @@ -675,9 +1439,158 @@ color: var(--base-color-scale-black-0); } +.SubdomainNavBar-search-dialog { + position: fixed; + inset-block-start: calc(var(--base-size-64) + var(--base-size-8)); + inset-block-end: auto; + inset-inline: calc(var(--base-size-64) + var(--base-size-8)); + z-index: 99; + width: min( + calc(100vw - ((var(--base-size-64) + var(--base-size-8)) * 2)), + calc((var(--base-size-128) * 6) + var(--base-size-36)) + ); + max-height: calc(100vh - ((var(--base-size-64) + var(--base-size-8)) * 2)); + height: auto; + margin-inline: auto; + padding: var(--base-size-16) var(--base-size-16) var(--base-size-32); + overflow: hidden; + color: var(--brand-color-text-default); + background-color: var(--brand-color-canvas-default); + border: var(--brand-borderWidth-thin) solid var(--brand-color-border-default); + border-radius: var(--brand-borderRadius-large); + box-shadow: 0 0 calc(var(--base-size-32) + var(--base-size-2)) var(--brand-SubdomainNavBar-searchDialog-shadowColor); +} + +.SubdomainNavBar-search-dialog::backdrop { + background-color: var(--brand-SubdomainNavBar-searchDialog-backdropColor); + -webkit-backdrop-filter: blur(var(--base-size-24)); + backdrop-filter: blur(var(--base-size-24)); +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-input-area { + gap: var(--base-size-16); +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-form { + min-width: 0; +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-form section > span:focus-within { + border-color: transparent; + box-shadow: none; +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-text-input { + color: var(--brand-color-text-default); + font-size: calc(var(--base-size-16) + var(--base-size-2)); + font-weight: var(--base-text-weight-medium); + line-height: var(--brand-text-lineHeight-300); + letter-spacing: var(--brand-text-letterSpacing-100); +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-close-button { + background-color: var(--brand-color-border-muted); +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-results-area--visible { + margin-block-start: var(--base-size-24); + padding-block-start: var(--base-size-24); + border-block-start: var(--brand-borderWidth-thin) dashed var(--brand-color-border-muted); +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-results-container { + width: 100%; + height: auto; + max-width: none; + max-height: calc(100vh - (((var(--base-size-64) + var(--base-size-8)) * 2) + var(--base-size-128))); + padding: 0; + margin: 0; + overflow-y: auto; + background-color: transparent; + border-radius: 0; + box-shadow: none; + animation: none; +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-results { + height: auto; + padding: 0; + margin: 0; + overflow: visible; +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-results-heading, +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-result-group-heading { + margin-block-end: var(--base-size-20); + padding: 0; + color: var(--brand-color-text-muted); + font-size: var(--brand-text-size-100); + font-weight: var(--brand-heading-weight-600); + line-height: var(--brand-text-lineHeight-100); +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-result-group + .SubdomainNavBar-search-result-group { + margin-block-start: var(--base-size-24); +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-result-item { + padding: var(--base-size-16) var(--base-size-4); + border-block-end: 0; + border-block-start: var(--brand-borderWidth-thin) dashed var(--brand-color-border-muted); +} + +.SubdomainNavBar-search-dialog + .SubdomainNavBar-search-result-group-list + > .SubdomainNavBar-search-result-item:first-child, +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-results > .SubdomainNavBar-search-result-item:first-child { + padding-block-start: var(--base-size-12); + border-block-start: 0; +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-result-item-container { + font-size: var(--brand-text-size-300); + letter-spacing: var(--brand-text-letterSpacing-100); +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-result-item-container a { + display: flex; + align-items: center; + gap: calc(var(--base-size-8) + var(--base-size-2)); + width: 100%; + margin: 0; + color: var(--brand-color-text-default); + font-size: var(--brand-text-size-300); + font-weight: var(--brand-heading-weight-600); + line-height: var(--brand-text-lineHeight-300); + letter-spacing: var(--brand-text-letterSpacing-100); +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-result-item-container a span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-result-item-desc, +.SubdomainNavBar-search-dialog .SubdomainNavBar-search-result-item-meta { + display: none; +} + +@media screen and (max-width: 47.99rem) { + .SubdomainNavBar-search-dialog { + inset-block-start: var(--base-size-16); + inset-block-end: auto; + inset-inline: var(--base-size-16); + width: calc(100vw - (var(--base-size-16) * 2)); + max-height: calc(100vh - (var(--base-size-16) * 2)); + height: auto; + } +} + .SubdomainNavBar-skip-to-content { position: absolute; - top: 75px; + top: var(--SubdomainNavBar-height); border-radius: 0px; width: 100%; z-index: 100; @@ -697,6 +1610,17 @@ } } +@keyframes fade-in-down-overflow { + 0% { + opacity: 0; + transform: translateY(calc(0px - var(--base-size-24))); + } + 100% { + opacity: 1; + transform: translateY(calc(0px - var(--base-size-4))); + } +} + @keyframes fade-out { 0% { opacity: 1; diff --git a/packages/react/src/SubdomainNavBar/SubdomainNavBar.module.css.d.ts b/packages/react/src/SubdomainNavBar/SubdomainNavBar.module.css.d.ts index d5bad7c01e..d9d56c4cfb 100644 --- a/packages/react/src/SubdomainNavBar/SubdomainNavBar.module.css.d.ts +++ b/packages/react/src/SubdomainNavBar/SubdomainNavBar.module.css.d.ts @@ -2,6 +2,7 @@ declare const styles: { readonly "SubdomainNavBar": string; readonly "SubdomainNavBar-back-arrow": string; readonly "SubdomainNavBar-button-area": string; + readonly "SubdomainNavBar-button-area--has-trailing-item": string; readonly "SubdomainNavBar-button-area--visible": string; readonly "SubdomainNavBar-button-area-inner": string; readonly "SubdomainNavBar-cta-button": string; @@ -9,20 +10,24 @@ declare const styles: { readonly "SubdomainNavBar-inner-container": string; readonly "SubdomainNavBar-inner-container--centered": string; readonly "SubdomainNavBar-inner-container--search-open": string; + readonly "SubdomainNavBar-leading-component": string; readonly "SubdomainNavBar-link": string; readonly "SubdomainNavBar-link--title": string; + readonly "SubdomainNavBar-link-content": string; readonly "SubdomainNavBar-link-text": string; readonly "SubdomainNavBar-logo-mark": string; readonly "SubdomainNavBar-menu-button": string; readonly "SubdomainNavBar-menu-button--close": string; readonly "SubdomainNavBar-menu-button-bar": string; + readonly "SubdomainNavBar-menu-button-icon": string; + readonly "SubdomainNavBar-menu-button-label": string; readonly "SubdomainNavBar-menu-wrapper": string; readonly "SubdomainNavBar-menu-wrapper--close": string; + readonly "SubdomainNavBar-menu-wrapper-footer": string; readonly "SubdomainNavBar-mobile-menu-button": string; readonly "SubdomainNavBar-more-link": string; readonly "SubdomainNavBar-outer-container": string; readonly "SubdomainNavBar-outer-container--fixed": string; - readonly "SubdomainNavBar-outer-container--has-actions": string; readonly "SubdomainNavBar-overflow-menu": string; readonly "SubdomainNavBar-overflow-menu-item": string; readonly "SubdomainNavBar-overflow-menu-list": string; @@ -31,28 +36,40 @@ declare const styles: { readonly "SubdomainNavBar-primary-nav-list--invisible": string; readonly "SubdomainNavBar-primary-nav-list--visible": string; readonly "SubdomainNavBar-primary-nav-list-item": string; - readonly "SubdomainNavBar-primary-nav-list-item--invisible": string; readonly "SubdomainNavBar-primary-nav-list-item--overflow": string; - readonly "SubdomainNavBar-primary-nav-list-item--visible": string; - readonly "SubdomainNavBar-search-button": string; + readonly "SubdomainNavBar-primary-nav-list-item--overflowed": string; + readonly "SubdomainNavBar-primary-nav-overflow": string; + readonly "SubdomainNavBar-search-close-button": string; readonly "SubdomainNavBar-search-dialog": string; readonly "SubdomainNavBar-search-dialog-control-area": string; readonly "SubdomainNavBar-search-form": string; + readonly "SubdomainNavBar-search-input-area": string; + readonly "SubdomainNavBar-search-input-button": string; + readonly "SubdomainNavBar-search-input-button-placeholder": string; + readonly "SubdomainNavBar-search-input-button-shortcut": string; + readonly "SubdomainNavBar-search-result-group": string; + readonly "SubdomainNavBar-search-result-group-heading": string; + readonly "SubdomainNavBar-search-result-group-list": string; readonly "SubdomainNavBar-search-result-item": string; readonly "SubdomainNavBar-search-result-item-container": string; readonly "SubdomainNavBar-search-result-item-desc": string; + readonly "SubdomainNavBar-search-result-item-meta": string; readonly "SubdomainNavBar-search-results": string; + readonly "SubdomainNavBar-search-results-area": string; + readonly "SubdomainNavBar-search-results-area--visible": string; readonly "SubdomainNavBar-search-results-container": string; readonly "SubdomainNavBar-search-results-heading": string; readonly "SubdomainNavBar-search-text-input": string; readonly "SubdomainNavBar-search-trigger": string; + readonly "SubdomainNavBar-search-trigger--has-trailing-item": string; readonly "SubdomainNavBar-secondary-nav": string; readonly "SubdomainNavBar-skip-to-content": string; readonly "SubdomainNavBar-title": string; readonly "SubdomainNavBar-title-area": string; - readonly "SubdomainNavBar-title-separator": string; + readonly "SubdomainNavBar-trailing-component": string; readonly "enlarge-shadow": string; readonly "fade-in-down": string; + readonly "fade-in-down-overflow": string; readonly "fade-in-down-staggered": string; readonly "fade-out": string; }; diff --git a/packages/react/src/SubdomainNavBar/SubdomainNavBar.stories.tsx b/packages/react/src/SubdomainNavBar/SubdomainNavBar.stories.tsx index 4234430b3b..a52a408ff3 100644 --- a/packages/react/src/SubdomainNavBar/SubdomainNavBar.stories.tsx +++ b/packages/react/src/SubdomainNavBar/SubdomainNavBar.stories.tsx @@ -1,12 +1,18 @@ import type {Meta, StoryObj} from '@storybook/react' +import {GlobeIcon} from '@primer/octicons-react' import {expect, userEvent, within} from 'storybook/test' import {INITIAL_VIEWPORTS} from 'storybook/viewport' import React, {useEffect, useState} from 'react' -import {Hero, River, Heading, Text, Link} from '../' +import {ActionMenu, Button, Hero, River, Heading, Text, Link, Token} from '../' import placeholderImage from '../fixtures/images/placeholder.png' -import {SubdomainNavBar, SubdomainNavBarProps} from '.' +import { + SubdomainNavBar, + type SubdomainNavBarHandle, + type SubdomainNavBarProps, + type SubdomainNavBarSearchResults, +} from '.' import {waitFor} from '@testing-library/dom' type StoryArgs = { @@ -16,6 +22,26 @@ type StoryArgs = { fullWidth: boolean } & SubdomainNavBarProps +const viewports = { + ...INITIAL_VIEWPORTS, + desktop1440: { + name: 'Desktop 1440', + styles: { + width: '1440px', + height: '900px', + }, + type: 'desktop', + }, + tablet800: { + name: 'Tablet 800', + styles: { + width: '800px', + height: '900px', + }, + type: 'tablet', + }, +} + const meta = { title: 'Components/SubdomainNavBar', component: SubdomainNavBar as Meta['component'], @@ -41,7 +67,7 @@ const meta = { }, parameters: { viewport: { - viewports: INITIAL_VIEWPORTS, + options: viewports, }, }, } satisfies Meta @@ -375,6 +401,65 @@ const mockSearchData = [ }, ] +const mockGroupedSearchData: SubdomainNavBarSearchResults = [ + { + title: 'AI results', + results: [ + { + title: 'How do I connect to GitHub with my SSH?', + description: 'Learn how to generate and add SSH keys for GitHub authentication.', + url: '#ai-ssh', + date: '2026-07-01T00:00+02:00', + }, + { + title: 'How do I sign commits?', + description: 'Learn how to sign commits with GPG or SSH keys.', + url: '#ai-sign-commits', + date: '2026-07-01T00:00+02:00', + }, + { + title: 'How do I create webhooks?', + description: 'Learn how to create and configure webhooks.', + url: '#ai-webhooks', + date: '2026-07-01T00:00+02:00', + }, + ], + }, + { + title: 'Docs results', + results: [ + { + title: 'Frequently asked questions', + description: 'Browse common GitHub Docs questions.', + url: '#frequently-asked-questions', + date: '2026-07-01T00:00+02:00', + isExternal: true, + }, + { + title: 'How GitHub works', + description: 'Understand GitHub concepts and platform workflows.', + url: '#how-github-works', + date: '2026-07-01T00:00+02:00', + isExternal: true, + }, + { + title: 'Using the GitHub CLI across GitHub platforms', + description: 'Use the GitHub CLI across GitHub products and workflows.', + url: '#using-github-cli', + date: '2026-07-01T00:00+02:00', + isExternal: true, + }, + { + title: 'Long article name lorem ipsum dolor sit amet using the GitHub CLI across GitHub platforms', + description: 'A longer docs result title that truncates in the search modal.', + url: '#long-article-name', + date: '2026-07-01T00:00+02:00', + isExternal: true, + }, + ], + }, +] + const SubdomainNavBarTemplate = ({showSearch, numLinks, ...args}: StoryArgs) => { const inputRef = React.useRef(null) const [searchResults, setSearchResults] = React.useState< @@ -411,7 +496,21 @@ const SubdomainNavBarTemplate = ({showSearch, numLinks, ...args}: StoryArgs) => <>
    - {['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 + +
    + + +
    + + ) +} + +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() //
    + {isLarge && hasLeadingComponent && ( +
    {leadingComponent}
    + )} {hasLinks && (
    @@ -360,15 +631,43 @@ function Root({ export type SubdomainNavBarLinkProps = { href: string isExternal?: boolean - 'data-navitemid'?: string } & React.DetailedHTMLProps, HTMLLIElement> -function Link({href, className, children, isExternal, ...rest}: PropsWithChildren) { +type SubdomainNavBarLinkMeasurementProps = SubdomainNavBarLinkProps & { + 'data-navitemid'?: string +} + +function Link({ + href, + className, + children, + isExternal, + 'aria-current': ariaCurrent, + 'aria-hidden': ariaHidden, + tabIndex, + ...rest +}: PropsWithChildren) { + const {isOverflowed, onLinkClick} = useSubdomainNavBarLinkContext() + return ( -
  • - - {children} - {isExternal && } +
  • + + + {children} + {isExternal && } +
  • ) @@ -380,58 +679,264 @@ export type SubdomainNavBarSearchResultProps = { url: string date: string category?: string + group?: string + isExternal?: boolean +} + +export type SubdomainNavBarSearchResultGroupProps = { + title: string + results: SubdomainNavBarSearchResultProps[] +} + +export type SubdomainNavBarSearchResults = SubdomainNavBarSearchResultProps[] | SubdomainNavBarSearchResultGroupProps[] + +export type SubdomainNavBarSearchLabels = { + /** + * Accessible label for the search input. Defaults to "Search". + */ + searchLabel: string + /** + * Visible and accessible label for the close action. Defaults to "Close". + */ + closeLabel: string + /** + * Accessible label for an untitled search result group. Defaults to "Results". + */ + resultsLabel: string + /** + * Accessible label for grouped search results without a search term. Defaults to "Search results". + */ + searchResultsLabel: string + /** + * Formats the default search placeholder and dialog label using the navigation title. + */ + formatSearchWithTitle: (title: string) => string + /** + * Formats the accessible label for the responsive search trigger. + */ + formatSearchTrigger: (placeholder: string) => string + /** + * Formats the visible heading for ungrouped search results. + */ + formatResultsHeading: (searchTerm: string) => string + /** + * Formats the accessible label for grouped search results. + */ + formatResultsLabel: (searchTerm: string) => string + /** + * Formats the search result count announcement. + */ + formatSuggestions: (count: number) => string } -type HandlerEvent = MouseEvent | TouchEvent | FocusEvent +const defaultSearchLabels: SubdomainNavBarSearchLabels = { + searchLabel: 'Search', + closeLabel: 'Close', + resultsLabel: 'Results', + searchResultsLabel: 'Search results', + formatSearchWithTitle: title => `Search ${title}`, + formatSearchTrigger: placeholder => `${placeholder} search`, + formatResultsHeading: searchTerm => `Results for “${searchTerm}”`, + formatResultsLabel: searchTerm => `Results for ${searchTerm}`, + formatSuggestions: count => `${count} suggestions.`, +} -type SearchProps = { +export type SubdomainNavBarSearchProps = { onSubmit: (e: React.FormEvent) => void onChange: (e: React.ChangeEvent) => void - ref: React.RefObject active?: boolean + className?: string title?: string - handlerFn?: (event: HandlerEvent) => void - autoComplete?: boolean - searchResults?: SubdomainNavBarSearchResultProps[] + onSearchOpen?: () => void + onSearchClose?: () => void + /** + * Placeholder text shown in the search trigger and the opened search input. + */ + placeholder?: string + /** + * Optional keyboard shortcut hint shown in the search trigger. Pass an empty string to hide it. + */ + shortcutLabel?: string + /** + * Keyboard shortcut that opens the search dialog. Pass `false` to disable it. + * Supports single keys and modifier combinations, such as `/` or `Command+Option+k`. + */ + keyboardShortcut?: string | false + searchResults?: SubdomainNavBarSearchResults searchTerm?: string + /** + * Customizable visible and accessible search text. Unspecified labels use the English defaults. + */ + labels?: Partial +} + +type NormalizedSearchResultGroup = { + title?: string + results: Array<{ + result: SubdomainNavBarSearchResultProps + index: number + }> +} + +function isSearchResultGroup( + item: SubdomainNavBarSearchResultProps | SubdomainNavBarSearchResultGroupProps, +): item is SubdomainNavBarSearchResultGroupProps { + return 'results' in item } -const _SearchInternal = forwardRef( - ({active, title, searchResults, searchTerm, handlerFn, onSubmit, onChange}, ref) => { - const dialogRef = useRef(null) +function normalizeSearchResults(searchResults: SubdomainNavBarSearchResults = []): NormalizedSearchResultGroup[] { + const groups: NormalizedSearchResultGroup[] = [] + let resultIndex = 0 - useFocusTrap({containerRef: dialogRef, restoreFocusOnCleanUp: true, disabled: !active}) - useOnClickOutside(dialogRef, handlerFn) + for (const item of searchResults) { + if (isSearchResultGroup(item)) { + if (item.results.length === 0) continue + + groups.push({ + title: item.title, + results: item.results.map(result => ({ + result, + index: resultIndex++, + })), + }) + + continue + } + + const groupTitle = item.group + let group = groups.find(existingGroup => existingGroup.title === groupTitle) + + if (!group) { + group = { + title: groupTitle, + results: [], + } + groups.push(group) + } + + group.results.push({ + result: item, + index: resultIndex++, + }) + } + + return groups +} + +const _SearchInternal = forwardRef( + ( + { + active, + className, + title, + searchResults, + searchTerm, + onSearchOpen, + onSearchClose, + onSubmit, + onChange, + placeholder, + shortcutLabel, + keyboardShortcut = '/', + labels, + }, + forwardedRef, + ) => { + const dialogRef = useRef(null) + const inputRef = useRef(null) + const resolvedLabels = {...defaultSearchLabels, ...labels} + const resolvedPlaceholder = + placeholder ?? (title ? resolvedLabels.formatSearchWithTitle(title) : resolvedLabels.searchLabel) + const dialogLabel = title ? resolvedLabels.formatSearchWithTitle(title) : resolvedLabels.searchLabel + const resolvedShortcutLabel = shortcutLabel ?? (keyboardShortcut || '') + const normalizedSearchResultGroups = useMemo(() => normalizeSearchResults(searchResults), [searchResults]) + const hasGroupedSearchResults = normalizedSearchResultGroups.some(group => group.title) + const searchResultsLength = normalizedSearchResultGroups.reduce((count, group) => count + group.results.length, 0) + const hasSearchResults = searchResultsLength > 0 const [activeDescendant, setActiveDescendant] = useState(-1) - const [listboxActive, setListboxActive] = useState() const [liveRegion, setLiveRegion] = useState(false) - const handleClose = useCallback( - (event?: React.MouseEvent | HandlerEvent | null) => { - if (handlerFn) handlerFn(event as HandlerEvent) - setActiveDescendant(-1) + const handleClose = useCallback(() => { + onSearchClose?.() + setActiveDescendant(-1) + }, [onSearchClose]) + + const setInputRef = useCallback( + (input: HTMLInputElement | null) => { + inputRef.current = input + + if (typeof forwardedRef === 'function') { + forwardedRef(input) + return + } + + if (forwardedRef) { + forwardedRef.current = input + } }, - [handlerFn], + [forwardedRef], ) - useOnClickOutside(dialogRef, handleClose as (event) => void) - useKeyboardEscape(() => { - // Close the dialog if combobox is already collapsed - if (!listboxActive && active) { - handleClose() - return false + useEffect(() => { + const dialog = dialogRef.current + + if (!dialog) return + + if (active) { + if (!dialog.open) { + if (typeof dialog.showModal === 'function') { + dialog.showModal() + } else { + dialog.setAttribute('open', '') + } + } + + inputRef.current?.focus() + + return } - setListboxActive(false) - setActiveDescendant(-1) - }) + if (dialog.open) { + if (typeof dialog.close === 'function') { + dialog.close() + } else { + dialog.removeAttribute('open') + } + } + }, [active, inputRef]) + + const handleDialogClick = useCallback( + (event: React.MouseEvent) => { + if (event.target !== event.currentTarget) return + + const dialog = event.currentTarget + const dialogRect = dialog.getBoundingClientRect() + const clickIsInsideDialog = + event.clientX >= dialogRect.left && + event.clientX <= dialogRect.right && + event.clientY >= dialogRect.top && + event.clientY <= dialogRect.bottom + + if (clickIsInsideDialog) return + + handleClose() + }, + [handleClose], + ) + + const handleDialogCancel = useCallback( + (event: React.SyntheticEvent) => { + event.preventDefault() + handleClose() + }, + [handleClose], + ) const handleAriaFocus = useCallback( - event => { - const supportedKeys = ['ArrowDown', 'ArrowUp', 'Escape', 'Enter'] + (event: React.KeyboardEvent) => { + const supportedKeys = ['ArrowDown', 'ArrowUp', 'Enter'] const currentCount = activeDescendant - const searchResultsLength = searchResults ? searchResults.length : 0 const dialog = dialogRef.current let count @@ -458,11 +963,11 @@ const _SearchInternal = forwardRef( } if (event.key === 'Enter') { - const link = dialog.querySelector(`#subdomainnavbar-search-result-${activeDescendant} a`) as HTMLAnchorElement + const link = dialog.querySelector(`#subdomainnavbar-search-result-${activeDescendant}`) as HTMLAnchorElement link.click() } }, - [searchResults, activeDescendant], + [searchResultsLength, activeDescendant], ) const searchLiveRegion = useCallback(() => { @@ -476,131 +981,190 @@ const _SearchInternal = forwardRef( }, [active]) useEffect(() => { - // We want to set "listboxActive" when search results are present, - // or the user pressed "Escape". We watch for "searchTerm", as we - - // want the listbox to become active if they pressed "Escape", and - - // adjusted their existing value. - const search = searchResults && searchResults.length ? true : false - setListboxActive(search) searchLiveRegion() - }, [searchResults, searchTerm, searchLiveRegion]) + }, [searchResultsLength, searchTerm, searchLiveRegion]) + + const renderSearchResult = ({result, index}: NormalizedSearchResultGroup['results'][number]) => ( +
  • + + + + {result.description} + +
    + + {result.date} + + {result.category && ( + <> + + {' '} + •{' '} + + + {result.category} + + + )} +
    +
  • + ) return ( <> -
    +
    - {active && ( -
    -
    -
    - - Search - } - aria-activedescendant={ - activeDescendant === -1 ? undefined : `subdomainnavbar-search-result-${activeDescendant}` - } - onKeyDown={handleAriaFocus} - /> - -
    - -
    - -
    - {listboxActive && ( -
    - - Results for “{searchTerm}” - -
      + {active && ( + <> +
      +
      +
      + + {resolvedLabels.searchLabel} + } + aria-activedescendant={ + activeDescendant === -1 ? undefined : `subdomainnavbar-search-result-${activeDescendant}` + } + onKeyDown={handleAriaFocus} + /> + +
      +
      +
      + +
      + {hasSearchResults && ( +
      + {!hasGroupedSearchResults && ( + - + {resolvedLabels.formatResultsHeading(searchTerm ?? '')} + + )} +
        + {hasGroupedSearchResults + ? normalizedSearchResultGroups.map((group, groupIndex) => { + const groupHeadingId = `subdomainnavbar-search-result-group-${groupIndex}` - - {result.description} - -
        - - {result.date} - - {result.category && ( - <> - - {' '} - •{' '} - - - {result.category} - - - )} -
        - - ))} -
      + return ( +
    • + {group.title && ( + + {group.title} + + )} +
        + {group.results.map(renderSearchResult)} +
      +
    • + ) + }) + : normalizedSearchResultGroups.flatMap(group => group.results.map(renderSearchResult))} +
    +
    + )} +
    + {resolvedLabels.formatSuggestions(searchResultsLength)} + {liveRegion &&  }
    - )} -
    - {`${searchResults?.length} suggestions.`} - {liveRegion &&  }
    -
    -
    - )} + + )} + ) }, @@ -608,18 +1172,23 @@ const _SearchInternal = forwardRef( const Search = _SearchInternal +const RootWithRef = forwardRef(Root) +RootWithRef.displayName = 'SubdomainNavBar' + type CTAActionProps = { href: string } & React.HTMLAttributes function PrimaryAction({children, href, ...rest}: PropsWithChildren) { + const size = React.useContext(SubdomainNavBarActionSizeContext) + return (