diff --git a/.changeset/sweet-hornets-change.md b/.changeset/sweet-hornets-change.md new file mode 100644 index 0000000000..a5ae91aa82 --- /dev/null +++ b/.changeset/sweet-hornets-change.md @@ -0,0 +1,5 @@ +--- +'gitbook': minor +--- + +Revamp mobile navigation diff --git a/bun.lock b/bun.lock index 156aa4b34b..9333dafbf3 100644 --- a/bun.lock +++ b/bun.lock @@ -48,7 +48,7 @@ }, "packages/gitbook": { "name": "gitbook", - "version": "0.14.0", + "version": "0.14.1", "dependencies": { "@gitbook/api": "catalog:", "@gitbook/cache-tags": "workspace:*", @@ -98,6 +98,7 @@ "openapi-types": "^12.1.3", "p-map": "^7.0.3", "quick-lru": "^7.0.1", + "react-aria": "^3.37.0", "react-hotkeys-hook": "^4.4.1", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", @@ -166,7 +167,7 @@ }, "packages/openapi-parser": { "name": "@gitbook/openapi-parser", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@scalar/openapi-parser": "^0.18.0", "@scalar/openapi-types": "^0.1.9", @@ -213,7 +214,7 @@ }, "packages/react-openapi": { "name": "@gitbook/react-openapi", - "version": "1.3.3", + "version": "1.3.4", "dependencies": { "@gitbook/openapi-parser": "workspace:*", "@scalar/api-client-react": "^1.3.16", diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 0db15e0517..9caffb58e6 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -1695,6 +1695,57 @@ const testCases: TestsCase[] = [ ]), ], }, + { + name: 'Mobile menu', + contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/', + tests: [ + { + name: 'Mobile menu open', + viewports: ['iphone-x'], + url: '', + run: async (page) => { + // Set mobile viewport size to ensure mobile menu is visible + await page.setViewportSize({ width: 375, height: 812 }); // iPhone X dimensions + + await page.locator('[data-testid="mobile-menu-button"]').click(); + + // Wait for table of contents to appear + const tableOfContents = page.locator('[data-testid="table-of-contents"]'); + await tableOfContents.waitFor({ state: 'visible', timeout: 5000 }); + await expect(tableOfContents).toBeVisible(); + }, + }, + { + name: 'Mobile menu with dropdown menu', + viewports: ['iphone-x'], + url: 'multi-variants/', + run: async (page) => { + // Set mobile viewport size to ensure mobile menu is visible + await page.setViewportSize({ width: 375, height: 812 }); // iPhone X dimensions + + await page.locator('[data-testid="mobile-menu-button"]').click(); + + // Wait for table of contents to appear + const tableOfContents = page.locator('[data-testid="table-of-contents"]'); + await tableOfContents.waitFor({ state: 'visible', timeout: 5000 }); + await expect(tableOfContents).toBeVisible(); + + // Wait for space dropdown button to be visible + const spaceDropdownButton = tableOfContents.locator( + '[data-testid="space-dropdown-button"]' + ); + await spaceDropdownButton.waitFor({ state: 'visible', timeout: 5000 }); + await expect(spaceDropdownButton).toBeVisible(); + await spaceDropdownButton.click(); + + // Wait for space dropdown to appear + const spaceDropdown = page.locator('[data-testid="dropdown-menu"]'); + await spaceDropdown.waitFor({ state: 'visible', timeout: 5000 }); + await expect(spaceDropdown).toBeVisible(); + }, + }, + ], + }, ]; runTestCases(testCases); diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index a38cc94f60..05e4b73211 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -67,6 +67,10 @@ export interface Test { * Whether to only run this test. */ only?: boolean; + /** + * Viewport to use for the test. + */ + viewports?: ('macbook-16' | 'macbook-13' | 'ipad-2' | 'iphone-x')[]; } export type TestsCase = { @@ -162,7 +166,7 @@ export function runTestCases(testCases: TestsCase[]) { test.describe(testCase.name, () => { for (const testEntry of testCase.tests) { - const { mode = 'page' } = testEntry; + const { mode = 'page', viewports } = testEntry; const testFn = testEntry.only ? test.only : test; testFn(testEntry.name, async ({ page, context }) => { const testEntryPathname = @@ -207,13 +211,18 @@ export function runTestCases(testCases: TestsCase[]) { const screenshotName = `${testCase.name} - ${testEntry.name}`; if (mode === 'image') { await argosScreenshot(page, screenshotName, { - viewports: ['macbook-13'], + viewports: viewports ?? ['macbook-13'], threshold: screenshotOptions?.threshold ?? undefined, fullPage: true, }); } else { await argosScreenshot(page, screenshotName, { - viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'], + viewports: viewports ?? [ + 'macbook-16', + 'macbook-13', + 'ipad-2', + 'iphone-x', + ], argosCSS: ` /* Hide Intercom */ .intercom-lightweight-app { diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index ef1f3208f4..6b123cd198 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -24,6 +24,7 @@ "@tailwindcss/typography": "^0.5.16", "ai": "^4.2.2", "assert-never": "^1.2.1", + "react-aria": "^3.37.0", "bun-types": "^1.1.20", "classnames": "^2.5.1", "direction": "^2.0.1", diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index abc24f53f8..665c07c962 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -1,5 +1,6 @@ import type { GitBookSiteContext } from '@/lib/context'; +import { HeaderMobileMenuButton } from '@/components/Header/HeaderMobileMenuButton'; import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout'; import { getSpaceLanguage, t } from '@/intl/server'; import { tcls } from '@/lib/tailwind'; @@ -8,7 +9,6 @@ import { HeaderLink } from './HeaderLink'; import { HeaderLinkMore } from './HeaderLinkMore'; import { HeaderLinks } from './HeaderLinks'; import { HeaderLogo } from './HeaderLogo'; -import { HeaderMobileMenu } from './HeaderMobileMenu'; import { SpacesDropdown } from './SpacesDropdown'; /** @@ -78,7 +78,7 @@ export function Header(props: { 'min-w-0 shrink items-center justify-start gap-2 lg:gap-4' )} > - >) { - const language = useLanguage(); - - const pathname = usePathname(); - const hasScrollRef = useRef(false); - - const toggleNavigation = () => { - if (!hasScrollRef.current && document.body.classList.contains(globalClassName)) { - document.body.classList.remove(globalClassName); - } else { - document.body.classList.add(globalClassName); - window.scrollTo(0, 0); - } - }; - - const windowRef = useRef(typeof window === 'undefined' ? null : window); - useScrollListener(() => { - hasScrollRef.current = window.scrollY >= SCROLL_DISTANCE; - }, windowRef); - - // Close the navigation when navigating to a page - useEffect(() => { - document.body.classList.remove(globalClassName); - }, [pathname]); - - return ( - - ); -} diff --git a/packages/gitbook/src/components/Header/HeaderMobileMenuButton.tsx b/packages/gitbook/src/components/Header/HeaderMobileMenuButton.tsx new file mode 100644 index 0000000000..5bb5cf1466 --- /dev/null +++ b/packages/gitbook/src/components/Header/HeaderMobileMenuButton.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { Icon } from '@gitbook/icons'; + +import { useMobileMenuSheet } from '@/components/MobileMenu/useMobileMenuSheet'; +import { tString, useLanguage } from '@/intl/client'; +import { tcls } from '@/lib/tailwind'; + +/** + * Button to show/hide the table of content on mobile. + */ +export function HeaderMobileMenuButton( + props: Partial> +) { + const language = useLanguage(); + const { open, setOpen } = useMobileMenuSheet(); + + const toggleNavigation = () => { + setOpen(!open); + }; + + return ( + + ); +} diff --git a/packages/gitbook/src/components/Header/SpacesDropdown.tsx b/packages/gitbook/src/components/Header/SpacesDropdown.tsx index 26846602b3..4f600f1b5b 100644 --- a/packages/gitbook/src/components/Header/SpacesDropdown.tsx +++ b/packages/gitbook/src/components/Header/SpacesDropdown.tsx @@ -11,8 +11,9 @@ export function SpacesDropdown(props: { siteSpace: SiteSpace; siteSpaces: SiteSpace[]; className?: string; + withPortal?: boolean; }) { - const { context, siteSpace, siteSpaces, className } = props; + const { context, siteSpace, siteSpaces, className, withPortal } = props; return ( { + setOpen(false); + }, [pathname]); + + // Prevent scrolling when the menu is open + usePreventScroll({ + isDisabled: !open, + }); + + return null; +} diff --git a/packages/gitbook/src/components/MobileMenu/index.ts b/packages/gitbook/src/components/MobileMenu/index.ts new file mode 100644 index 0000000000..e9ea4d0ce0 --- /dev/null +++ b/packages/gitbook/src/components/MobileMenu/index.ts @@ -0,0 +1,2 @@ +export * from './useMobileMenuSheet'; +export * from './MobileMenuScript'; diff --git a/packages/gitbook/src/components/MobileMenu/useMobileMenuSheet.ts b/packages/gitbook/src/components/MobileMenu/useMobileMenuSheet.ts new file mode 100644 index 0000000000..8de4d1071f --- /dev/null +++ b/packages/gitbook/src/components/MobileMenu/useMobileMenuSheet.ts @@ -0,0 +1,12 @@ +import { create } from 'zustand'; + +/** + * Hooks to manage the mobile menu sheet state. + */ +export const useMobileMenuSheet = create<{ + open: boolean; + setOpen: (open: boolean) => void; +}>((set) => ({ + open: false, + setOpen: (open) => set({ open }), +})); diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index f57aec64cd..27e1ed7634 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { Footer } from '@/components/Footer'; import { Header, HeaderLogo } from '@/components/Header'; -import { TableOfContents } from '@/components/TableOfContents'; +import { TOCScrollContent, TableOfContents } from '@/components/TableOfContents'; import { CONTAINER_STYLE } from '@/components/layout'; import { tcls } from '@/lib/tailwind'; @@ -105,7 +105,6 @@ export function SpaceLayout(props: { )} > ) } - innerHeader={ - // displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC. - <> - {!withTopHeader && searchAndAI} - {!withTopHeader && withSections && sections && ( - + + {!withTopHeader && searchAndAI} + {!withTopHeader && withSections && sections && ( + )} - /> - )} - {isMultiVariants && !sections && ( - )} - /> - )} - - } - /> + + ) : null + } + /> +
{children}
diff --git a/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx b/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx index d1f946de98..1ac8d4f5e8 100644 --- a/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx @@ -25,8 +25,8 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup }) { '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle', '[html.sidebar-filled.theme-muted_&]:bg-tint-base', '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base', - '[html.sidebar-default.theme-gradient_&]:bg-gradient-primary', - '[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint' + 'lg:[html.sidebar-default.theme-gradient_&]:bg-gradient-primary', + 'lg:[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint' )} > diff --git a/packages/gitbook/src/components/TableOfContents/TOCScrollContent.tsx b/packages/gitbook/src/components/TableOfContents/TOCScrollContent.tsx new file mode 100644 index 0000000000..d850922e77 --- /dev/null +++ b/packages/gitbook/src/components/TableOfContents/TOCScrollContent.tsx @@ -0,0 +1,70 @@ +import { PagesList } from '@/components/TableOfContents'; +import { Trademark } from '@/components/TableOfContents'; +import { TOCScrollContainer } from '@/components/TableOfContents/TOCScroller'; +import { encodeClientTableOfContents } from '@/components/TableOfContents/encodeClientTableOfContents'; +import type { GitBookSiteContext } from '@/lib/context'; +import { tcls } from '@/lib/tailwind'; +import { SiteInsightsTrademarkPlacement } from '@gitbook/api'; + +export async function TOCScrollContent(props: { + context: GitBookSiteContext; + innerHeader?: React.ReactNode; +}) { + const { context, innerHeader } = props; + const { customization, revision } = context; + + const pages = await encodeClientTableOfContents(context, revision.pages, revision.pages); + + return ( +
+ {!!innerHeader && ( +
+ {innerHeader} +
+ )} + + + + {customization.trademark.enabled ? ( + + ) : null} + +
+ ); +} diff --git a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx index bbccbca991..2ae5e78de6 100644 --- a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx +++ b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx @@ -1,43 +1,75 @@ -import type { GitBookSiteContext } from '@/lib/context'; -import { SiteInsightsTrademarkPlacement } from '@gitbook/api'; -import type React from 'react'; +'use client'; +import { MobileMenuScript, useMobileMenuSheet } from '@/components/MobileMenu'; +import { TableOfContentsScript } from '@/components/TableOfContents/TableOfContentsScript'; +import { Button } from '@/components/primitives'; import { tcls } from '@/lib/tailwind'; +import type React from 'react'; -import { PagesList } from './PagesList'; -import { TOCScrollContainer } from './TOCScroller'; -import { TableOfContentsScript } from './TableOfContentsScript'; -import { Trademark } from './Trademark'; -import { encodeClientTableOfContents } from './encodeClientTableOfContents'; - -export async function TableOfContents(props: { - context: GitBookSiteContext; +export function TableOfContents(props: { header?: React.ReactNode; // Displayed outside the scrollable TOC as a sticky header - innerHeader?: React.ReactNode; // Displayed outside the scrollable TOC, directly above the page list + children: React.ReactNode; }) { - const { innerHeader, context, header } = props; - const { space, customization, revision } = context; - - const pages = await encodeClientTableOfContents(context, revision.pages, revision.pages); + const { header, children } = props; + const { open, setOpen } = useMobileMenuSheet(); return ( <> +
setOpen(false)} + /> + ); } diff --git a/packages/gitbook/src/components/TableOfContents/Trademark.tsx b/packages/gitbook/src/components/TableOfContents/Trademark.tsx index dac24155d5..ed4068e99f 100644 --- a/packages/gitbook/src/components/TableOfContents/Trademark.tsx +++ b/packages/gitbook/src/components/TableOfContents/Trademark.tsx @@ -21,13 +21,13 @@ export function Trademark(props: { return (
@@ -73,7 +83,7 @@ export function DropdownMenu(props: { {button} - + - + );