diff --git a/data/ui.yml b/data/ui.yml index 3c0042112d20..4d05da595a18 100644 --- a/data/ui.yml +++ b/data/ui.yml @@ -19,6 +19,10 @@ header: menu: Menu open_menu_label: Open menu go_home: Home + collapse_sidebar: Collapse sidebar + expand_sidebar: Expand sidebar + scroll_breadcrumbs_left: Scroll breadcrumbs left + scroll_breadcrumbs_right: Scroll breadcrumbs right picker: language_picker_label: Language diff --git a/src/automated-pipelines/components/AutomatedPage.tsx b/src/automated-pipelines/components/AutomatedPage.tsx index 8c3208d7b6e9..e16a2042f9b8 100644 --- a/src/automated-pipelines/components/AutomatedPage.tsx +++ b/src/automated-pipelines/components/AutomatedPage.tsx @@ -8,7 +8,6 @@ import { ArticleGridLayout } from '@/frame/components/article/ArticleGridLayout' import { ArticleInlineLayout } from '@/frame/components/article/ArticleInlineLayout' import { MiniTocs } from '@/frame/components/ui/MiniTocs' import { useAutomatedPageContext } from '@/automated-pipelines/components/AutomatedPageContext' -import { Breadcrumbs } from '@/frame/components/page-header/Breadcrumbs' import { JourneyTrackCard, JourneyTrackNav } from '@/journeys/components' type Props = { @@ -70,7 +69,6 @@ export const AutomatedPage = ({ children, rawChildren, fullWidth }: Props) => { topper={{title}} intro={introProp} toc={toc} - breadcrumbs={} > {articleContents} @@ -84,14 +82,7 @@ export const AutomatedPage = ({ children, rawChildren, fullWidth }: Props) => {
-
- -
- {title} - - } + topper={{title}} intro={introProp} toc={toc} > diff --git a/src/fixtures/fixtures/data/ui.yml b/src/fixtures/fixtures/data/ui.yml index 3c0042112d20..4d05da595a18 100644 --- a/src/fixtures/fixtures/data/ui.yml +++ b/src/fixtures/fixtures/data/ui.yml @@ -19,6 +19,10 @@ header: menu: Menu open_menu_label: Open menu go_home: Home + collapse_sidebar: Collapse sidebar + expand_sidebar: Expand sidebar + scroll_breadcrumbs_left: Scroll breadcrumbs left + scroll_breadcrumbs_right: Scroll breadcrumbs right picker: language_picker_label: Language diff --git a/src/fixtures/tests/breadcrumbs.ts b/src/fixtures/tests/breadcrumbs.ts index ce435f85f3ab..efa8f699d9b9 100644 --- a/src/fixtures/tests/breadcrumbs.ts +++ b/src/fixtures/tests/breadcrumbs.ts @@ -7,16 +7,21 @@ import { getDOM } from '@/tests/helpers/e2etest' describe('breadcrumbs', () => { test('links always prefixed with language', async () => { const $ = await getDOM('/get-started/start-your-journey/hello-world') - const links = $('[data-testid=breadcrumbs-in-article] a') + const links = $('[data-testid=breadcrumbs-bar] a') links.each((i, element) => { - expect($(element).attr('href')!.startsWith('/en/')).toBe(true) + const href = $(element).attr('href')! + // The Home crumb points at the locale root (`/en` on the default version, + // no trailing slash); every other crumb is under `/en/…`. Both are + // language-prefixed, which is what this test guards. + expect(href === '/en' || href.startsWith('/en/')).toBe(true) }) - expect.assertions(3) + // Home crumb + the three trail crumbs for this path. + expect.assertions(4) }) test('top-level hidden /search page has no breadcrumbs', async () => { const $ = await getDOM('/search') - const links = $('[data-testid=breadcrumbs-in-article] a') + const links = $('[data-testid=breadcrumbs-bar] a') expect(links.length).toBe(0) const headers = $('[data-testid=breadcrumbs-header]') expect(headers.length).toBe(0) @@ -24,53 +29,58 @@ describe('breadcrumbs', () => { test('short titles are preferred', async () => { const $ = await getDOM('/get-started/foo/bar') - const links = $('[data-testid=breadcrumbs-in-article] li:last-child a') + const links = $('[data-testid=breadcrumbs-bar] li:last-child a') expect(links.text()).toBe('Bar') }) - test('article pages have breadcrumbs in article with product, category, subcategory, and article and last breadcrumb is not viewable', async () => { + test('article pages have breadcrumbs in the secondary bar with home, product, category, subcategory, and article (all shown)', async () => { const $ = await getDOM('/get-started/start-your-journey/hello-world') - const links = $('[data-testid=breadcrumbs-in-article] a') - expect(links.length).toBe(3) - expect($(links[0]).text()).toBe('Get started') - expect($(links[0]).attr('class')!.includes('d-none')).toBe(false) - expect($(links[1]).text()).toBe('Start your journey') + const links = $('[data-testid=breadcrumbs-bar] a') + // The secondary bar leads with a Home crumb, then the page trail. + expect(links.length).toBe(4) + expect($(links[0]).text()).toBe('Home') + expect($(links[1]).text()).toBe('Get started') expect($(links[1]).attr('class')!.includes('d-none')).toBe(false) - expect($(links[2]).text()).toBe('Hello World') - expect($(links[2]).attr('class')!.includes('d-none')).toBe(true) + expect($(links[2]).text()).toBe('Start your journey') + expect($(links[2]).attr('class')!.includes('d-none')).toBe(false) + expect($(links[3]).text()).toBe('Hello World') + // The secondary-bar variant shows the full trail (no hidden last crumb). + expect($(links[3]).attr('class')!.includes('d-none')).toBe(false) }) test('works for enterprise-server articles too', async () => { const $ = await getDOM('/enterprise-server@latest/get-started/start-your-journey/hello-world') - const links = $('[data-testid=breadcrumbs-in-article] a') - expect(links.length).toBe(3) - expect($(links[0]).text()).toBe('Get started') - expect($(links[1]).text()).toBe('Start your journey') - expect($(links[2]).text()).toBe('Hello World') + const links = $('[data-testid=breadcrumbs-bar] a') + expect(links.length).toBe(4) + expect($(links[0]).text()).toBe('Home') + expect($(links[1]).text()).toBe('Get started') + expect($(links[2]).text()).toBe('Start your journey') + expect($(links[3]).text()).toBe('Hello World') }) test('works for titles that depend on Liquid', async () => { const $fpt = await getDOM('/get-started/start-your-journey/dynamic-title') - const fptLinks = $fpt('[data-testid=breadcrumbs-in-article] a') - expect($fpt(fptLinks[2]).text()).toBe('Hello HubGit') + const fptLinks = $fpt('[data-testid=breadcrumbs-bar] a') + // [0] is the Home crumb; the article is the last crumb. + expect($fpt(fptLinks[3]).text()).toBe('Hello HubGit') const $ghec = await getDOM( '/enterprise-cloud@latest/get-started/start-your-journey/dynamic-title', ) - const ghecLinks = $ghec('[data-testid=breadcrumbs-in-article] a') - expect($ghec(ghecLinks[2]).text()).toBe('Greetings HubGit Enterprise Cloud') + const ghecLinks = $ghec('[data-testid=breadcrumbs-bar] a') + expect($ghec(ghecLinks[3]).text()).toBe('Greetings HubGit Enterprise Cloud') }) - test('early access article pages have breadcrumbs with product, category, and article', async () => { + test('early access article pages have breadcrumbs with home, product, category, and article', async () => { const $ = await getDOM('/early-access/secrets/deeper/mariana-trench') - const $breadcrumbTitles = $( - '[data-testid=breadcrumbs-in-article] [data-testid=breadcrumb-title]', - ) - const $breadcrumbLinks = $('[data-testid=breadcrumbs-in-article] a') + const $breadcrumbTitles = $('[data-testid=breadcrumbs-bar] [data-testid=breadcrumb-title]') + const $breadcrumbLinks = $('[data-testid=breadcrumbs-bar] a') expect($breadcrumbTitles.length).toBe(0) - expect($breadcrumbLinks.length).toBe(2) - expect(($breadcrumbLinks[0] as Element).attribs.title).toBe('Deeper secrets') - expect(($breadcrumbLinks[1] as Element).attribs.title).toBe('Mariana Trench') + // Home crumb + the two early-access crumbs. + expect($breadcrumbLinks.length).toBe(3) + expect(($breadcrumbLinks[0] as Element).attribs.title).toBe('Home') + expect(($breadcrumbLinks[1] as Element).attribs.title).toBe('Deeper secrets') + expect(($breadcrumbLinks[2] as Element).attribs.title).toBe('Mariana Trench') }) }) diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index a48fd3675477..a8eeed35e7d0 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -540,21 +540,21 @@ test.describe('test nav at different viewports', () => { }) await page.goto('/get-started/foo/bar') - // in article breadcrumbs at our custom xl viewport should remove last - // breadcrumb so for this page we should only have 'Get Started / Foo' - expect(await page.getByTestId('breadcrumbs-in-article').getByRole('link').all()).toHaveLength(2) - await expect(page.getByTestId('breadcrumbs-in-article').getByText('Foo')).toBeVisible() - await expect(page.getByTestId('breadcrumbs-in-article').getByText('Bar')).not.toBeVisible() + // The Docs 2026 secondary bar leads with a Home crumb, then the full trail + // 'Get started / Foo / Bar' (no hidden last crumb). + expect(await page.getByTestId('breadcrumbs-bar').getByRole('link').all()).toHaveLength(4) + await expect(page.getByTestId('breadcrumbs-bar').getByText('Foo')).toBeVisible() + await expect(page.getByTestId('breadcrumbs-bar').getByText('Bar')).toBeVisible() // breadcrumbs show up in rest reference pages await page.goto('/rest/actions/artifacts') - await expect(page.getByTestId('breadcrumbs-in-article')).toBeVisible() + await expect(page.getByTestId('breadcrumbs-bar')).toBeVisible() // breadcrumbs show up in one of the pages that use the AutomatedPage // component (e.g. graphql, audit log, etc.) -- we test the webhooks // reference page here await page.goto('/webhooks/webhook-events-and-payloads') - await expect(page.getByTestId('breadcrumbs-in-article')).toBeVisible() + await expect(page.getByTestId('breadcrumbs-bar')).toBeVisible() }) test('large -> x-large viewports - 1012+', async ({ page }) => { @@ -584,17 +584,15 @@ test.describe('test nav at different viewports', () => { }) await page.goto('/get-started/foo/bar') - // breadcrumbs show up in the header, for this page we should have - // 3 items 'Get Started / Foo / Bar' - // in-article breadcrumbs don't show up - await expect(page.getByTestId('breadcrumbs-header')).toBeVisible() - expect(await page.getByTestId('breadcrumbs-header').getByRole('link').all()).toHaveLength(3) - await expect(page.getByTestId('breadcrumbs-in-article')).not.toBeVisible() + // breadcrumbs show up in the secondary bar; for this page we should have + // a Home crumb plus 'Get started / Foo / Bar' + await expect(page.getByTestId('breadcrumbs-bar')).toBeVisible() + expect(await page.getByTestId('breadcrumbs-bar').getByRole('link').all()).toHaveLength(4) - // hamburger button for sidebar overlay is visible - await expect(page.getByTestId('sidebar-hamburger')).toBeVisible() - await page.getByTestId('sidebar-hamburger').click() - await expect(page.locator('[role="dialog"][class*="Header_dialog"]')).toBeVisible() + // the mobile nav toggle is visible and expands the doc-tree nav inline + await expect(page.getByTestId('sidebar-mobile-toggle')).toBeVisible() + await page.getByTestId('sidebar-mobile-toggle').click() + await expect(page.getByTestId('sidebar')).toBeVisible() }) test('medium viewports - 768-1011', async ({ page }) => { @@ -617,9 +615,9 @@ test.describe('test nav at different viewports', () => { await expect(page.getByTestId('mobile-signup')).toBeVisible() // hamburger button for sidebar overlay is visible - await expect(page.getByTestId('sidebar-hamburger')).toBeVisible() - await page.getByTestId('sidebar-hamburger').click() - await expect(page.locator('[role="dialog"][class*="Header_dialog"]')).toBeVisible() + await expect(page.getByTestId('sidebar-mobile-toggle')).toBeVisible() + await page.getByTestId('sidebar-mobile-toggle').click() + await expect(page.getByTestId('sidebar')).toBeVisible() }) test('small viewports - 544-767', async ({ page }) => { @@ -646,9 +644,9 @@ test.describe('test nav at different viewports', () => { await expect(page.getByTestId('mobile-signup')).toBeVisible() // hamburger button for sidebar overlay is visible - await expect(page.getByTestId('sidebar-hamburger')).toBeVisible() - await page.getByTestId('sidebar-hamburger').click() - await expect(page.locator('[role="dialog"][class*="Header_dialog"]')).toBeVisible() + await expect(page.getByTestId('sidebar-mobile-toggle')).toBeVisible() + await page.getByTestId('sidebar-mobile-toggle').click() + await expect(page.getByTestId('sidebar')).toBeVisible() }) test('x-small viewports - 0-544', async ({ page }) => { @@ -680,9 +678,9 @@ test.describe('test nav at different viewports', () => { await expect(page.getByTestId('mobile-signup')).toBeVisible() // hamburger button for sidebar overlay is visible - await expect(page.getByTestId('sidebar-hamburger')).toBeVisible() - await page.getByTestId('sidebar-hamburger').click() - await expect(page.locator('[role="dialog"][class*="Header_dialog"]')).toBeVisible() + await expect(page.getByTestId('sidebar-mobile-toggle')).toBeVisible() + await page.getByTestId('sidebar-mobile-toggle').click() + await expect(page.getByTestId('sidebar')).toBeVisible() }) test('do a search when the viewport is x-small', async ({ page }) => { @@ -881,7 +879,10 @@ test.describe('survey', () => { await expect(page.getByRole('button', { name: 'Send' })).toBeVisible() await expect(page.locator('[for=survey-comment]')).toBeVisible() - await page.getByTestId('product-sidebar').getByLabel('Bar', { exact: true }).click() + await page + .getByTestId('product-sidebar') + .getByRole('link', { name: 'Bar', exact: true }) + .click() await expect(page.getByRole('button', { name: 'Send' })).not.toBeVisible() await expect(page.locator('[for=survey-comment]')).not.toBeVisible() }) @@ -894,8 +895,13 @@ test.describe('rest API reference pages', () => { // URL that has that `?apiVersion=` query parameter. await expect(page).toHaveURL(/\/en\/rest\?apiVersion=/) await page.getByTestId('sidebar').getByText('Actions').click() - await page.getByTestId('sidebar').getByLabel('Artifacts').click() - await page.getByLabel('About artifacts in HubGit Actions').click() + // Brand NavList renders leaf articles as links (not the label-associated + // controls Primer used), so locate them by link role rather than getByLabel. + await page.getByTestId('sidebar').getByRole('link', { name: 'Artifacts' }).click() + await page + .getByTestId('sidebar') + .getByRole('link', { name: 'About artifacts in HubGit Actions' }) + .click() await expect(page).toHaveURL(/\/en\/rest\/actions\/artifacts\?apiVersion=/) await expect(page).toHaveTitle(/GitHub Actions Artifacts - GitHub Docs/) }) diff --git a/src/fixtures/tests/sidebar.ts b/src/fixtures/tests/sidebar.ts index 568b5bb6a369..8607f0ed57ee 100644 --- a/src/fixtures/tests/sidebar.ts +++ b/src/fixtures/tests/sidebar.ts @@ -10,9 +10,9 @@ describe('sidebar', () => { const sidebarProduct = $('[data-testid="sidebar-product-xl"]') expect(sidebarProduct.text()).toBe('Get started') expect(sidebarProduct.attr('href')).toBe('/en/get-started') - // Mobile - expect($('[data-testid="header-subnav"]').length).toBe(1) - expect($('[data-testid="header-subnav-hamburger"]').length).toBe(1) + // Docs 2026 secondary bar (breadcrumbs + nav toggle) replaces the old subnav + expect($('[data-testid="docs-secondary-bar"]').length).toBe(1) + expect($('[data-testid="sidebar-mobile-toggle"]').length).toBe(1) }) test('REST pages get the REST sidebar', async () => { @@ -73,9 +73,9 @@ describe('sidebar', () => { const $: CheerioAPI = await getDOM('/early-access/secrets/deeper/mariana-trench') // Deskop expect($('[data-testid="sidebar-product-xl"]').length).toBe(0) - // Mobile - expect($('[data-testid="header-subnav"]').length).toBe(1) - expect($('[data-testid="header-subnav-hamburger"]').length).toBe(0) + // The secondary bar renders, but early-access has no nav toggle + expect($('[data-testid="docs-secondary-bar"]').length).toBe(1) + expect($('[data-testid="sidebar-mobile-toggle"]').length).toBe(0) }) test('category-landing pages show title entry in sidebar', async () => { diff --git a/src/frame/components/DefaultLayout.module.scss b/src/frame/components/DefaultLayout.module.scss index 3612da8c340b..9d113e41e172 100644 --- a/src/frame/components/DefaultLayout.module.scss +++ b/src/frame/components/DefaultLayout.module.scss @@ -1,3 +1,16 @@ +@import "src/frame/stylesheets/breakpoint-xxl.scss"; + .mainContent { scroll-margin-top: 5rem; } + +// When the inline mobile/tablet nav is open, it takes over the viewport — hide +// the content column so the full-width rail isn't squeezed beside it. Above the +// xxl breakpoint the rail is a fixed-width sibling, so content always shows. +.contentHiddenForNav { + display: none; + + @include breakpoint-xxl { + display: flex; + } +} diff --git a/src/frame/components/DefaultLayout.tsx b/src/frame/components/DefaultLayout.tsx index abff184d2be5..64bc41cc6744 100644 --- a/src/frame/components/DefaultLayout.tsx +++ b/src/frame/components/DefaultLayout.tsx @@ -1,9 +1,15 @@ import React from 'react' import Head from 'next/head' import { useRouter } from 'next/router' +import cx from 'classnames' import { SidebarNav } from '@/frame/components/sidebar/SidebarNav' import { Header } from '@/frame/components/page-header/Header' +import { DocsSecondaryBar } from '@/frame/components/page-header/DocsSecondaryBar' +import { + SidebarCollapseProvider, + useSidebarCollapsed, +} from '@/frame/components/sidebar/SidebarCollapseContext' import { LegalFooter } from '@/frame/components/page-footer/LegalFooter' import { ScrollButton } from '@/frame/components/ui/ScrollButton' import { SupportSection } from '@/frame/components/page-footer/SupportSection' @@ -216,29 +222,73 @@ export const DefaultLayout = (props: Props) => { > Skip to main content -
- -
- {isHomepageVersion ? null : } - {/* Need to set an explicit height for sticky elements since we also - set overflow to auto */} -
-
- - - - {props.children} -
-
- - - -
-
-
+ +
+ + {isHomepageVersion ? ( +
+
+
+ + + + {props.children} +
+
+ + + +
+
+
+ ) : ( + <> + + {props.children} + + )} + ) } + +// The doc-tree rail + content column, split out so it can read the collapse +// context that DefaultLayout provides. On desktop the rail shows unless +// collapsed; on mobile it shows inline (in the page flow, like desktop) only +// when the nav is opened from the secondary bar. The content column (flex-1) +// fills the row when the rail is absent. +type LayoutBodyProps = { children?: React.ReactNode; scrollToTopLabel: string } +const LayoutBody = ({ children, scrollToTopLabel }: LayoutBodyProps) => { + const { collapsed, mobileNavOpen } = useSidebarCollapsed() + return ( +
+ {collapsed ? null : } + {/* Need to set an explicit height for sticky elements since we also + set overflow to auto */} +
+
+ + + + {children} +
+
+ + + +
+
+
+ ) +} diff --git a/src/frame/components/article/ArticleInlineLayout.module.scss b/src/frame/components/article/ArticleInlineLayout.module.scss index 05b340089892..85c9e381b29c 100644 --- a/src/frame/components/article/ArticleInlineLayout.module.scss +++ b/src/frame/components/article/ArticleInlineLayout.module.scss @@ -10,7 +10,6 @@ column-gap: 80px; row-gap: 0; grid-template-areas: - "breadcrumbs" "topper" "sidebar" "intro" diff --git a/src/frame/components/article/ArticleInlineLayout.tsx b/src/frame/components/article/ArticleInlineLayout.tsx index 7833145e5284..f148604b14b2 100644 --- a/src/frame/components/article/ArticleInlineLayout.tsx +++ b/src/frame/components/article/ArticleInlineLayout.tsx @@ -5,7 +5,6 @@ import { SupportPortalVaIframe, SupportPortalVaIframeProps } from './SupportPort import styles from './ArticleInlineLayout.module.scss' type Props = { - breadcrumbs?: React.ReactNode intro?: React.ReactNode introCallOuts?: React.ReactNode topper?: React.ReactNode @@ -15,7 +14,6 @@ type Props = { supportPortalVaIframeProps?: SupportPortalVaIframeProps } export const ArticleInlineLayout = ({ - breadcrumbs, intro, introCallOuts, topper, @@ -26,14 +24,6 @@ export const ArticleInlineLayout = ({ }: Props) => { return (
- {breadcrumbs && ( -
- {breadcrumbs} -
- )}
{topper &&
{topper}
} diff --git a/src/frame/components/article/ArticlePage.tsx b/src/frame/components/article/ArticlePage.tsx index c2c58c042e8b..dd8da9394851 100644 --- a/src/frame/components/article/ArticlePage.tsx +++ b/src/frame/components/article/ArticlePage.tsx @@ -1,6 +1,5 @@ import { useRouter } from 'next/router' import dynamic from 'next/dynamic' -import cx from 'classnames' import { useArticleContext } from '@/frame/components/context/ArticleContext' import { DefaultLayout } from '@/frame/components/DefaultLayout' @@ -14,7 +13,6 @@ import { PlatformPicker } from '@/tools/components/PlatformPicker' import { ToolPicker } from '@/tools/components/ToolPicker' import { MiniTocs } from '@/frame/components/ui/MiniTocs' import { RestRedirect } from '@/rest/components/RestRedirect' -import { Breadcrumbs } from '@/frame/components/page-header/Breadcrumbs' import { LinkPreviewPopover } from '@/links/components/LinkPreviewPopover' import { UtmPreserver } from '@/frame/components/UtmPreserver' import { JourneyTrackCard, JourneyTrackNav } from '@/journeys/components' @@ -112,7 +110,6 @@ export const ArticlePage = () => { intro={introProp} introCallOuts={introCalloutsProp} toc={toc} - breadcrumbs={} > {articleContents} @@ -124,10 +121,6 @@ export const ArticlePage = () => { ) : (
-
- -
- {title}} diff --git a/src/frame/components/context/MainContext.tsx b/src/frame/components/context/MainContext.tsx index 31e2afc7e877..de15901f3f9c 100644 --- a/src/frame/components/context/MainContext.tsx +++ b/src/frame/components/context/MainContext.tsx @@ -5,6 +5,7 @@ import type { Response } from 'express' import type { BreadcrumbT } from '@/frame/components/page-header/Breadcrumbs' import type { FeatureFlags } from '@/frame/components/hooks/useFeatureFlags' import type { ExtendedRequest, Permalink, SidebarLink } from '@/types' +import { SIDEBAR_EXPANDED_COOKIE_NAME, SIDEBAR_COLLAPSED_COOKIE_NAME } from '@/frame/lib/constants' export type ProductT = { external: boolean @@ -130,6 +131,14 @@ export type MainContextT = { } | null relativePath?: string | null sidebarTree?: ProductTreeNode | null + // Per-category expand/collapse overrides for the doc-tree sidebar, read from the + // sidebar_expanded cookie during SSR so the tree renders in its persisted state + // on first paint (no post-mount flash). Keyed by locale-prefixed href. + sidebarExpanded?: Record | null + // Whether the desktop doc-tree rail is collapsed, read from the sidebar_collapsed + // cookie during SSR so the rail renders in its persisted state on first paint + // (no flash of the open sidebar before it collapses post-mount). + sidebarCollapsed?: boolean status: number xHost?: string } @@ -169,6 +178,19 @@ export function addUINamespaces(req: ExtendedRequest, ui: UIStrings, namespaces: } } +// Parse the sidebar_expanded cookie (a JSON map of href -> bool) from the request. +// Guarded so a malformed or absent cookie degrades to no overrides. +function parseSidebarExpandedCookie(req: ExtendedRequest): Record { + const raw = req.cookies?.[SIDEBAR_EXPANDED_COOKIE_NAME] + if (!raw) return {} + try { + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? (parsed as Record) : {} + } catch { + return {} + } +} + export const getMainContext = async ( req: ExtendedRequest, res: Response, @@ -293,6 +315,10 @@ export const getMainContext = async ( // The minimal product tree is needed on all pages that depend on // the product sidebar or the rest sidebar. sidebarTree: (includeSidebarTree && context.sidebarTree) || null, + sidebarExpanded: includeSidebarTree ? parseSidebarExpandedCookie(req) : null, + sidebarCollapsed: includeSidebarTree + ? req.cookies?.[SIDEBAR_COLLAPSED_COOKIE_NAME] === 'true' + : false, status: res.statusCode, xHost: req.get('x-host') || '', } diff --git a/src/frame/components/page-header/Breadcrumbs.tsx b/src/frame/components/page-header/Breadcrumbs.tsx index a2a6c44442f5..b3e7b854159e 100644 --- a/src/frame/components/page-header/Breadcrumbs.tsx +++ b/src/frame/components/page-header/Breadcrumbs.tsx @@ -4,9 +4,16 @@ import cx from 'classnames' import { Breadcrumbs as BrandBreadcrumbs } from '@primer/react-brand' import { useMainContext } from '../context/MainContext' +import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion' +import { useTranslation } from '@/languages/components/useTranslation' type Props = { inHeader?: boolean + // Placement variant. Defaults derived from `inHeader` for back-compat: + // - 'in-article' (default): sits above the article; hides the last (current) crumb. + // - 'header': the mobile subnav row; shows all crumbs. + // - 'bar': the Docs 2026 secondary bar; shows all crumbs and a leading Home crumb. + variant?: 'in-article' | 'header' | 'bar' } export type BreadcrumbT = { @@ -14,9 +21,28 @@ export type BreadcrumbT = { href?: string } -export const Breadcrumbs = ({ inHeader }: Props) => { +export const Breadcrumbs = ({ inHeader, variant }: Props) => { const { breadcrumbs } = useMainContext() const router = useRouter() + const { currentVersion } = useVersion() + const { t } = useTranslation('header') + + const placement = variant ?? (inHeader ? 'header' : 'in-article') + // Only the in-article placement hides the current (last) crumb; the header and + // secondary-bar placements show the full trail. + const hideLastCrumb = placement === 'in-article' + // The secondary bar leads with a Home crumb (replacing the old "← Home" rail link). + const showHomeCrumb = placement === 'bar' + const testId = + placement === 'bar' + ? 'breadcrumbs-bar' + : placement === 'header' + ? 'breadcrumbs-header' + : 'breadcrumbs-in-article' + + const homeHref = `/${router.locale}${ + currentVersion === DEFAULT_VERSION ? '' : `/${currentVersion}` + }` // BrandBreadcrumbs.Item renders a plain , so intercept clicks to restore // next/link-style client-side navigation. Modifier/middle clicks fall through @@ -41,11 +67,17 @@ export const Breadcrumbs = ({ inHeader }: Props) => { } return ( - + + {showHomeCrumb && ( + handleClick(event, homeHref)} + > + {t('go_home')} + + )} {Object.values(breadcrumbs) .filter(Boolean) .map((breadcrumb, i, arr) => { @@ -65,9 +97,9 @@ export const Breadcrumbs = ({ inHeader }: Props) => { title={title} onClick={(event) => handleClick(event, breadcrumb.href!)} className={cx( - // Show the last breadcrumb if it's in the header, but not if it's in the article. + // Show the last breadcrumb if it's in the header/bar, but not if it's in the article. // If there's only 1 breadcrumb, show it. - !inHeader && i === arr.length - 1 && arr.length !== 1 && 'd-none', + hideLastCrumb && i === arr.length - 1 && arr.length !== 1 && 'd-none', )} > {breadcrumb.title} diff --git a/src/frame/components/page-header/BreadcrumbsScroller.module.scss b/src/frame/components/page-header/BreadcrumbsScroller.module.scss new file mode 100644 index 000000000000..1eed9814d65a --- /dev/null +++ b/src/frame/components/page-header/BreadcrumbsScroller.module.scss @@ -0,0 +1,92 @@ +// Horizontal scroll wrapper for the secondary-bar breadcrumbs. When the trail +// overflows, the scroll area is anchored to the right (current page visible) +// and a left chevron reveals ancestor crumbs. A left-edge fade signals that +// there is more content scrolled off to the left. + +.scroller { + display: flex; + align-items: center; + flex: 1 1 auto; + min-width: 0; + min-height: 44px; + position: relative; +} + +// The chevrons overlay the ends of the scroll area (absolutely positioned) +// rather than sitting in the flex flow. This keeps the scroll area full-width +// and constant — so its scrollable range never shifts when a chevron toggles — +// while a hidden chevron leaves NO reserved gap at that edge. Each chevron +// carries the secondary bar's own background so a crumb scrolling underneath is +// masked rather than showing through the icon. +.leftChevron { + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + z-index: 1; + color: var(--fgColor-muted, var(--color-fg-muted)); + background-color: var(--bgColor-default, var(--color-canvas-default)); +} + +.rightChevron { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + z-index: 1; + color: var(--fgColor-muted, var(--color-fg-muted)); + background-color: var(--bgColor-default, var(--color-canvas-default)); +} + +// Hidden chevrons are removed from view and interaction (and from the tab +// order / a11y tree in the component). Because they're absolutely positioned +// they already occupy no layout space, so the scroll area is unaffected. +.chevronHidden { + visibility: hidden; + pointer-events: none; +} + +.scrollArea { + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + // Constant 16px on both ends: keeps a crumb off the border at the scroll + // extremes, and mid-scroll the chevron overlays the end of the trail (its + // solid background masks whatever passes underneath). Keeping this padding + // fixed — never toggled with the chevrons — means the scroll content's width + // never changes, so toggling a chevron can't reflow the trail or nudge the + // scroll position (no hop, no mount bounce, no click-lands-short). + padding: 0 16px; + scrollbar-width: none; // Firefox — hide the horizontal scrollbar + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; // Chrome/Safari + } + + // Make the brand Breadcrumbs
- {!isHomepageVersion && !isSearchResultsPage && ( -
- {!isEarlyAccessPage && ( -
- - {isSidebarOpen && ( - } - width="medium" - > - - - )} -
- )} -
- -
-
- )} void + setCollapsed: (collapsed: boolean) => void + // Mobile: whether the doc-tree nav is expanded inline (not persisted). The + // nav renders in the page flow, same as desktop — not in a dialog overlay. + mobileNavOpen: boolean + toggleMobileNav: () => void + closeMobileNav: () => void +} + +const SidebarCollapseContext = createContext(null) + +export function SidebarCollapseProvider({ + children, + initialCollapsed, +}: { + children: ReactNode + initialCollapsed?: boolean +}) { + const { asPath } = useRouter() + // Seed from the SSR-read cookie value so server and first client render agree. + // When no initial is supplied, fall back to reading the cookie client-side. + const [collapsed, setCollapsedState] = useState(() => initialCollapsed ?? readCollapsed()) + const [mobileNavOpen, setMobileNavOpen] = useState(false) + + const setCollapsed = useCallback((next: boolean) => { + setCollapsedState(next) + persistCollapsed(next) + }, []) + + const toggleCollapsed = useCallback(() => { + setCollapsedState((prev) => { + const next = !prev + persistCollapsed(next) + return next + }) + }, []) + + const toggleMobileNav = useCallback(() => setMobileNavOpen((prev) => !prev), []) + const closeMobileNav = useCallback(() => setMobileNavOpen(false), []) + + // Client-side navigation doesn't unmount the inline mobile nav, so close it + // when the route (or REST in-page hash) changes. + useEffect(() => { + setMobileNavOpen(false) + }, [asPath]) + + const value = useMemo( + () => ({ + collapsed, + toggleCollapsed, + setCollapsed, + mobileNavOpen, + toggleMobileNav, + closeMobileNav, + }), + [collapsed, toggleCollapsed, setCollapsed, mobileNavOpen, toggleMobileNav, closeMobileNav], + ) + + return {children} +} + +/** + * Read/toggle the desktop rail's collapsed state and the inline mobile nav's + * open state. Falls back to a no-op expanded/closed state if used outside the + * provider. + */ +export function useSidebarCollapsed(): SidebarCollapseContextValue { + const ctx = useContext(SidebarCollapseContext) + if (!ctx) { + return { + collapsed: false, + toggleCollapsed: () => {}, + setCollapsed: () => {}, + mobileNavOpen: false, + toggleMobileNav: () => {}, + closeMobileNav: () => {}, + } + } + return ctx +} diff --git a/src/frame/components/sidebar/SidebarNav.module.scss b/src/frame/components/sidebar/SidebarNav.module.scss index 252926924764..4f30e7bdaccf 100644 --- a/src/frame/components/sidebar/SidebarNav.module.scss +++ b/src/frame/components/sidebar/SidebarNav.module.scss @@ -1,15 +1,27 @@ +@import "src/frame/stylesheets/breakpoint-xxl.scss"; + .sidebarFull { - // Fixed width for consistent sidebar layout - width: 326px; - // 65px accounts for the header height - height: calc(100vh - 65px); - top: 65px; + @include breakpoint-xxl { + // Fixed width for consistent sidebar layout + width: 326px; + // 109px accounts for the header (65px) + the Docs 2026 secondary bar (44px) + height: calc(100vh - 109px); + top: 109px; + } +} + +// When the mobile nav is expanded inline, the rail spans the full width and +// flows in the page (no fixed height / sticky) below the xxl breakpoint. +.sidebarFullMobileOpen { + width: 100%; } .sidebarContentFull { - width: 326px; - // 175px accounts for header (65px) + sidebar header (110px) - height: calc(100vh - 175px); + @include breakpoint-xxl { + width: 326px; + // 219px accounts for header (65px) + secondary bar (44px) + sidebar header (110px) + height: calc(100vh - 219px); + } } .sidebarContentOverlay { diff --git a/src/frame/components/sidebar/SidebarNav.tsx b/src/frame/components/sidebar/SidebarNav.tsx index e907a700681d..689582bf098a 100644 --- a/src/frame/components/sidebar/SidebarNav.tsx +++ b/src/frame/components/sidebar/SidebarNav.tsx @@ -4,7 +4,6 @@ import { useRouter } from 'next/router' import { useMainContext } from '@/frame/components/context/MainContext' import { SidebarProduct } from '@/landings/components/SidebarProduct' import { SidebarSearchAggregates } from '@/search/components/results/SidebarSearchAggregates' -import { AllProductsLink } from './AllProductsLink' import { ApiVersionPicker } from '@/rest/components/ApiVersionPicker' import { Link } from '@/frame/components/Link' @@ -12,9 +11,13 @@ import styles from './SidebarNav.module.scss' type Props = { variant?: 'full' | 'overlay' + // When true (full variant only), the rail is also shown on mobile, inline in + // the page flow — the Docs 2026 mobile nav expands like the desktop view + // rather than opening a dialog overlay. + mobileOpen?: boolean } -export const SidebarNav = ({ variant = 'full' }: Props) => { +export const SidebarNav = ({ variant = 'full', mobileOpen = false }: Props) => { const { currentProduct, currentProductName } = useMainContext() const router = useRouter() const isRestPage = currentProduct && currentProduct.id === 'rest' @@ -29,9 +32,18 @@ export const SidebarNav = ({ variant = 'full' }: Props) => { return (