Skip to content

Revamp mobile navigation #3282

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sweet-hornets-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gitbook': minor
---

Revamp mobile navigation
1 change: 1 addition & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 51 additions & 0 deletions packages/gitbook/e2e/internal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
15 changes: 12 additions & 3 deletions packages/gitbook/e2e/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/gitbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 15 additions & 5 deletions packages/gitbook/src/components/Header/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 (
<RadixDropdownMenu.Root
modal={false}
Expand All @@ -48,15 +57,16 @@ export function DropdownMenu(props: {
{button}
</RadixDropdownMenu.Trigger>

<RadixDropdownMenu.Portal>
<Portal>
<RadixDropdownMenu.Content
data-testid="dropdown-menu"
hideWhenDetached
collisionPadding={8}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
align="start"
className="z-40 animate-present pt-2"
sideOffset={8}
className="z-40 animate-present"
>
<div
className={tcls(
Expand All @@ -67,7 +77,7 @@ export function DropdownMenu(props: {
{children}
</div>
</RadixDropdownMenu.Content>
</RadixDropdownMenu.Portal>
</Portal>
</RadixDropdownMenu.Root>
);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/gitbook/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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'
)}
>
<HeaderMobileMenu
<HeaderMobileMenuButton
className={tcls(
'lg:hidden',
'-ml-2',
Expand Down
57 changes: 0 additions & 57 deletions packages/gitbook/src/components/Header/HeaderMobileMenu.tsx

This file was deleted.

36 changes: 36 additions & 0 deletions packages/gitbook/src/components/Header/HeaderMobileMenuButton.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ButtonHTMLAttributes<HTMLButtonElement>>
) {
const language = useLanguage();
const { open, setOpen } = useMobileMenuSheet();

const toggleNavigation = () => {
setOpen(!open);
};

return (
<button
{...props}
aria-label={tString(language, 'table_of_contents_button_label')}
data-testid="mobile-menu-button"
onClick={toggleNavigation}
className={tcls(
'flex flex-row items-center rounded straight-corners:rounded-sm px-2 py-1',
props.className
)}
>
<Icon icon="bars" className="size-4 text-inherit" />
</button>
);
}
4 changes: 3 additions & 1 deletion packages/gitbook/src/components/Header/SpacesDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ 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 (
<DropdownMenu
className={tcls(
'group-hover/dropdown:invisible', // Prevent hover from opening the dropdown, as it's annoying in this context
'group-focus-within/dropdown:group-hover/dropdown:visible' // When the dropdown is already open, it should remain visible when hovered
)}
withPortal={withPortal}
button={
<div
data-testid="space-dropdown-button"
Expand Down
23 changes: 23 additions & 0 deletions packages/gitbook/src/components/MobileMenu/MobileMenuScript.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use client';

import { useMobileMenuSheet } from '@/components/MobileMenu';
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';
import { usePreventScroll } from 'react-aria';

export function MobileMenuScript() {
const pathname = usePathname();
const { open, setOpen } = useMobileMenuSheet();

// biome-ignore lint/correctness/useExhaustiveDependencies: Close the navigation when navigating to a page
useEffect(() => {
setOpen(false);
}, [pathname]);

// Prevent scrolling when the menu is open
usePreventScroll({
isDisabled: !open,
});

return null;
}
2 changes: 2 additions & 0 deletions packages/gitbook/src/components/MobileMenu/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './useMobileMenuSheet';
export * from './MobileMenuScript';
12 changes: 12 additions & 0 deletions packages/gitbook/src/components/MobileMenu/useMobileMenuSheet.ts
Original file line number Diff line number Diff line change
@@ -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 }),
}));
Loading