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 3ff6d61c4c..2ada8eeb1c 100644 --- a/bun.lock +++ b/bun.lock @@ -108,6 +108,7 @@ "parse-cache-control": "^1.0.1", "partial-json": "^0.1.7", "react": "^19.0.0", + "react-aria": "^3.37.0", "react-dom": "^19.0.0", "react-hotkeys-hook": "^4.4.1", "rehype-sanitize": "^6.0.0", diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 3efe00bd27..b35b0f1823 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -1621,6 +1621,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 f54b70fbe2..1d47459be9 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -64,6 +64,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 = { @@ -159,7 +163,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 = @@ -204,13 +208,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 379f975a1e..1464efe30d 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -37,6 +37,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", "event-iterator": "^2.0.0", diff --git a/packages/gitbook/src/components/Header/DropdownMenu.tsx b/packages/gitbook/src/components/Header/DropdownMenu.tsx index 8f51e4685c..caa506c358 100644 --- a/packages/gitbook/src/components/Header/DropdownMenu.tsx +++ b/packages/gitbook/src/components/Header/DropdownMenu.tsx @@ -7,6 +7,7 @@ import { useState } from 'react'; import { type ClassValue, tcls } from '@/lib/tailwind'; import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu'; +import { Slot } from '@radix-ui/react-slot'; import { Link, type LinkInsightsProps } from '../primitives'; @@ -25,13 +26,21 @@ export function DropdownMenu(props: { children: React.ReactNode; /** Custom styles */ className?: ClassValue; - /** Open the dropdown on hover */ + /** Open the dropdown on hover + * @default false + */ openOnHover?: boolean; + /** Whether to render the dropdown menu in a portal + * @default true + */ + withPortal?: boolean; }) { - const { button, children, className, openOnHover = false } = props; + const { button, children, className, openOnHover = false, withPortal = true } = props; const [hovered, setHovered] = useState(false); const [clicked, setClicked] = useState(false); + const Portal = withPortal ? RadixDropdownMenu.Portal : Slot; + return ( - + setHovered(true)} onMouseLeave={() => setHovered(false)} align="start" - className="z-40 animate-present pt-2" + sideOffset={8} + className="z-40 animate-present" >
- + ); } diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index 304e853139..6c57909d16 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -11,7 +11,7 @@ import { HeaderLink } from './HeaderLink'; import { HeaderLinkMore } from './HeaderLinkMore'; import { HeaderLinks } from './HeaderLinks'; import { HeaderLogo } from './HeaderLogo'; -import { HeaderMobileMenu } from './HeaderMobileMenu'; +import { HeaderMobileMenuButton } from './HeaderMobileMenuButton'; import { SpacesDropdown } from './SpacesDropdown'; /** @@ -76,7 +76,7 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo '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 dd6887d955..8e5cc46aab 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 cedc2eee89..4986a8de2e 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -5,13 +5,13 @@ import React from 'react'; import { Footer } from '@/components/Footer'; import { Header, HeaderLogo } from '@/components/Header'; import { SearchButton, SearchModal } from '@/components/Search'; -import { TableOfContents } from '@/components/TableOfContents'; +import { TOCScrollContent, TableOfContents } from '@/components/TableOfContents'; import { CONTAINER_STYLE } from '@/components/layout'; import { getSpaceLanguage } from '@/intl/server'; import { t } from '@/intl/translate'; +import type { VisitorAuthClaims } from '@/lib/adaptive'; import { tcls } from '@/lib/tailwind'; -import type { VisitorAuthClaims } from '@/lib/adaptive'; import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@v2/lib/env'; import { Announcement } from '../Announcement'; import { SpacesDropdown } from '../Header/SpacesDropdown'; @@ -81,7 +81,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 && ( -
- - - - {t( - getSpaceLanguage(customization), - customization.aiSearch.enabled - ? 'search_or_ask' - : 'search' - )} - ... - - - -
- )} - {!withTopHeader && withSections && sections && ( - - )} - {isMultiVariants && ( - + + {!withTopHeader && ( +
+ + + + {t( + getSpaceLanguage(customization), + customization.aiSearch.enabled + ? 'search_or_ask' + : 'search' + )} + ... + + + +
)} - /> - )} - - } - /> + {!withTopHeader && withSections && sections && ( + + )} + {isMultiVariants && ( + + )} + + ) : null + } + /> +
{children}
diff --git a/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx b/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx index 24ea366a2e..60a400ca40 100644 --- a/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx @@ -29,8 +29,8 @@ export function PageGroupItem(props: { '[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..cf79882b69 --- /dev/null +++ b/packages/gitbook/src/components/TableOfContents/TOCScrollContent.tsx @@ -0,0 +1,69 @@ +import { PagesList } from '@/components/TableOfContents'; +import { Trademark } from '@/components/TableOfContents'; +import { TOCScrollContainer } from '@/components/TableOfContents/TOCScroller'; +import { tcls } from '@/lib/tailwind'; +import { SiteInsightsTrademarkPlacement } from '@gitbook/api'; +import type { GitBookSiteContext } from '@v2/lib/context'; + +export function TOCScrollContent(props: { + context: GitBookSiteContext; + innerHeader?: React.ReactNode; +}) { + const { context, innerHeader } = props; + const { customization } = context; + + 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 7ca7a2c0c0..0cc55d198f 100644 --- a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx +++ b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx @@ -1,40 +1,75 @@ -import { SiteInsightsTrademarkPlacement } from '@gitbook/api'; -import type { GitBookSiteContext } from '@v2/lib/context'; -import type React from 'react'; +'use client'; +import { MobileMenuScript, useMobileMenuSheet } from '@/components/MobileMenu'; +import { Button } from '@/components/primitives'; import { tcls } from '@/lib/tailwind'; - -import { PagesList } from './PagesList'; -import { TOCScrollContainer } from './TOCScroller'; +import type React from 'react'; import { TableOfContentsScript } from './TableOfContentsScript'; -import { Trademark } from './Trademark'; export function TableOfContents(props: { - context: GitBookSiteContext; 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, pages } = context; + 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 aa2d37c0ac..fe0eddedfb 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 (