Compare commits

...

31 Commits

Author SHA1 Message Date
Nolann Biron 88cd02f1d5 Fixes 2025-07-24 18:40:18 +02:00
Nolann Biron 53433efb65 Fix spacing 2025-07-24 17:09:58 +02:00
Nolann Biron b92e78b573 Fix 2025-07-24 16:46:56 +02:00
Nolann Biron b357acb586 Fixes 2025-07-24 16:40:00 +02:00
Nolann Biron 8795621331 Merge branch 'main' into nolann/revamp-mobile-navigation 2025-07-24 16:24:27 +02:00
Nolann Biron 040be0b6ca Fix spacing for innerHeader 2025-06-17 14:51:24 +02:00
Nolann Biron 06b8a279c7 Add tests for mobile menu 2025-06-17 10:47:58 +02:00
Nolann Biron 01db884fb2 Fix padding 2025-06-16 20:40:47 +02:00
Nolann Biron bc8d59fcb9 Fix z-index 2025-06-16 19:32:51 +02:00
Nolann Biron f855d49631 Add usePreventScroll 2025-06-16 16:58:18 +02:00
Nolann Biron 15e0adcc9e Fix bun.lock 2025-06-14 17:56:51 +02:00
Nolann Biron ab4af82b43 chores 2025-06-14 15:30:57 +02:00
Nolann Biron ed007c818d Fix classes 2025-06-14 15:30:57 +02:00
Nolann Biron 8d88eb6532 Revamp mobile navigation 2025-06-14 15:30:57 +02:00
Nolann Biron 098fe2d290 fix padding 2025-06-14 15:30:57 +02:00
Nolann Biron b8dc0e6e2c Fix dropdown 2025-06-14 15:30:57 +02:00
Nolann Biron abfba66fed Update overlay 2025-06-14 15:30:57 +02:00
Nolann Biron 92ae60fe87 Fix MobileMenu 2025-06-14 15:30:57 +02:00
Nolann Biron a2a852eb17 Always close sheet on pathname change 2025-06-14 15:30:57 +02:00
Nolann Biron eef6d2f391 Render TOCScrollContent only once 2025-06-14 15:30:57 +02:00
Nolann Biron 7b6dff7a9d Update comments 2025-06-14 15:30:57 +02:00
Nolann Biron cdcd991cd2 Fixes after review 2025-06-14 15:30:57 +02:00
Nolann Biron e848cf518d Fix layout 2025-06-14 15:30:57 +02:00
Nolann Biron 6659bfbc14 Missing comment 2025-06-14 15:30:57 +02:00
Nolann Biron e018b65ce6 Put scrollbar back 2025-06-14 15:30:57 +02:00
Nolann Biron 2d5fc43395 Fixes 2025-06-14 15:30:57 +02:00
Nolann Biron f5235756d4 Handle circular-corners 2025-06-14 15:30:57 +02:00
Nolann Biron 451290a9d8 Fix styling 2025-06-14 15:30:57 +02:00
Nolann Biron a56216d00d Update design and handle innerHeader 2025-06-14 15:30:57 +02:00
Nolann Biron bb850e5b30 Remove top/bottom sheet 2025-06-14 15:30:57 +02:00
Nolann Biron 3a762dc494 Revamp mobile navigation 2025-06-14 15:30:57 +02:00
19 changed files with 348 additions and 180 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Revamp mobile navigation
+4 -3
View File
@@ -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",
+51
View File
@@ -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);
+12 -3
View File
@@ -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 {
+1
View File
@@ -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",
@@ -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'
)}
>
<HeaderMobileMenu
<HeaderMobileMenuButton
className={tcls(
'lg:hidden',
'-ml-2',
@@ -1,57 +0,0 @@
'use client';
import { Icon } from '@gitbook/icons';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { useScrollListener } from '../hooks/useScrollListener';
const globalClassName = 'navigation-open';
const SCROLL_DISTANCE = 320;
/**
* Button to show/hide the table of content on mobile.
*/
export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLButtonElement>>) {
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 (
<button
{...props}
aria-label={tString(language, 'table_of_contents_button_label')}
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>
);
}
@@ -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>
);
}
@@ -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 (
<DropdownMenu
@@ -20,6 +21,7 @@ export function SpacesDropdown(props: {
'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"
@@ -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;
}
@@ -0,0 +1,2 @@
export * from './useMobileMenuSheet';
export * from './MobileMenuScript';
@@ -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 }),
}));
@@ -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: {
)}
>
<TableOfContents
context={context}
header={
withTopHeader ? null : (
<div
@@ -123,34 +122,43 @@ export function SpaceLayout(props: {
</div>
)
}
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 && (
<SiteSectionList
className={tcls('hidden', 'lg:block')}
sections={encodeClientSiteSections(
context,
sections
>
<TOCScrollContent
context={context}
innerHeader={
(!withTopHeader && !!searchAndAI) ||
(!withTopHeader && withSections && !!sections) ||
(isMultiVariants && !sections) ? (
// 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 && (
<SiteSectionList
className={tcls('hidden', 'lg:block')}
sections={encodeClientSiteSections(
context,
sections
)}
/>
)}
/>
)}
{isMultiVariants && !sections && (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className={tcls(
'w-full',
'page-no-toc:hidden',
'site-header-none:page-no-toc:flex'
{isMultiVariants && !sections && (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className={tcls(
'w-full',
'page-no-toc:hidden',
'site-header-none:page-no-toc:flex'
)}
withPortal={false}
/>
)}
/>
)}
</>
}
/>
</>
) : null
}
/>
</TableOfContents>
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
</div>
</div>
@@ -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'
)}
>
<TOCPageIcon page={page} />
@@ -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 (
<div // The actual sidebar, either shown with a filled bg or transparent.
className={tcls(
'lg:-ms-5',
'relative flex flex-grow flex-col overflow-hidden border-tint-subtle',
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'[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-filled.theme-gradient_&]:border',
'page-no-toc:!bg-transparent',
'page-no-toc:!border-none',
'sidebar-filled:rounded-xl',
'straight-corners:rounded-none'
)}
>
{!!innerHeader && (
<div className="inline-flex w-full flex-col gap-4 px-2 max-lg:pt-2 max-lg:pr-14 max-lg:pb-2 lg:pr-4 lg:first:mt-4">
{innerHeader}
</div>
)}
<TOCScrollContainer // The scrollview inside the sidebar
className={tcls(
'flex flex-grow flex-col p-2',
innerHeader ? 'mt-0 lg:mt-4' : 'mt-11 lg:mt-0',
customization.trademark.enabled && 'pb-[4.5rem]',
'gutter-stable overflow-y-auto',
'[&::-webkit-scrollbar]:bg-transparent',
'[&::-webkit-scrollbar-thumb]:bg-transparent',
'group-hover:[&::-webkit-scrollbar]:bg-tint-subtle',
'group-hover:[&::-webkit-scrollbar-thumb]:bg-tint-7',
'group-hover:[&::-webkit-scrollbar-thumb:hover]:bg-tint-8'
)}
>
<PagesList
pages={pages}
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
/>
{customization.trademark.enabled ? (
<Trademark
space={context.space}
customization={customization}
placement={SiteInsightsTrademarkPlacement.Sidebar}
/>
) : null}
</TOCScrollContainer>
</div>
);
}
@@ -1,43 +1,75 @@
import type { GitBookSiteContext } from '@/lib/context';
import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
'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 { tcls } from '@/lib/tailwind';
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 (
<>
<div
className="fixed inset-0 z-40 bg-tint-12/4 backdrop-blur-lg data-[state=closed]:pointer-events-none data-[state=closed]:invisible data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn lg:hidden dark:bg-tint-1/6"
data-state={open ? 'open' : 'closed'}
onClick={() => setOpen(false)}
/>
<aside // Sidebar container, responsible for setting the right dimensions and position for the sidebar.
data-testid="table-of-contents"
id="table-of-contents"
data-state={open ? 'open' : 'closed'}
className={tcls(
'group',
'flex',
'flex-col',
'gap-4',
'border-tint-subtle',
'fixed',
'z-50',
'max-lg:transition',
'max-lg:ease-in-out',
'max-lg:duration-500',
'max-lg:rounded-xl',
'max-lg:circular-corners:rounded-2xl',
'max-lg:straight-corners:rounded-none',
'max-lg:bg-tint-base',
'max-lg:sidebar-filled:bg-tint-subtle',
'max-lg:theme-muted:bg-tint-subtle',
'max-lg:[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'max-lg:[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'max-lg:[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'max-lg:pb-2',
'max-lg:w-10/12',
'max-lg:shadow-lg',
'max-lg:depth-flat:shadow-none',
'max-lg:inset-1.5',
'max-lg:transition-all',
'max-lg:duration-300',
'max-lg:max-w-sm',
'max-lg:data-[state=open]:left-1.5',
'max-lg:data-[state=closed]:-left-full',
'text-sm',
'grow-0',
'shrink-0',
'basis-full',
'lg:basis-72',
'page-no-toc:lg:basis-56',
'relative',
'z-[1]',
'lg:sticky',
'lg:z-[1]',
'lg:mr-12',
// Server-side static positioning
@@ -57,72 +89,36 @@ export async function TableOfContents(props: {
'[html[style*="--toc-top-offset"]_&]:lg:!top-[var(--toc-top-offset)]',
'[html[style*="--toc-height"]_&]:lg:!h-[var(--toc-height)]',
'pt-6',
'pb-4',
'lg:pt-6',
'lg:pb-4',
'sidebar-filled:lg:pr-6',
'page-no-toc:lg:pr-0',
'hidden',
'navigation-open:!flex',
'lg:flex',
'page-no-toc:lg:hidden',
'page-no-toc:xl:flex',
'site-header-none:page-no-toc:lg:flex',
'flex-col',
'gap-4',
'navigation-open:border-b',
'border-tint-subtle'
'site-header-none:page-no-toc:lg:flex'
)}
>
{header && header}
<div // The actual sidebar, either shown with a filled bg or transparent.
className={tcls(
'lg:-ms-5',
'relative flex flex-grow flex-col overflow-hidden border-tint-subtle',
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'[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-filled.theme-gradient_&]:border',
'page-no-toc:!bg-transparent',
'page-no-toc:!border-none',
'sidebar-filled:rounded-xl',
'straight-corners:rounded-none',
'[html.sidebar-filled.circular-corners_&]:page-has-toc:rounded-3xl'
)}
>
{innerHeader && <div className="px-5 *:my-4">{innerHeader}</div>}
<TOCScrollContainer // The scrollview inside the sidebar
className={tcls(
'flex flex-grow flex-col p-2',
customization.trademark.enabled && 'lg:pb-20',
'lg:gutter-stable overflow-y-auto',
'[&::-webkit-scrollbar]:bg-transparent',
'[&::-webkit-scrollbar-thumb]:bg-transparent',
'group-hover:[&::-webkit-scrollbar]:bg-tint-subtle',
'group-hover:[&::-webkit-scrollbar-thumb]:bg-tint-7',
'group-hover:[&::-webkit-scrollbar-thumb:hover]:bg-tint-8'
)}
{open ? (
<Button
variant="blank"
icon="close"
iconOnly
size="medium"
autoFocus={false}
className="absolute top-2 right-2 z-50 aspect-square size-[calc(2.25rem+1px)] justify-center bg-transparent text-tint opacity-8 shadow-none ring-transparent lg:hidden"
onClick={() => setOpen(false)}
>
<PagesList
pages={pages}
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
/>
{customization.trademark.enabled ? (
<Trademark
space={space}
customization={customization}
placement={SiteInsightsTrademarkPlacement.Sidebar}
/>
) : null}
</TOCScrollContainer>
</div>
<span className="sr-only">Close</span>
</Button>
) : null}
{children}
</aside>
<TableOfContentsScript />
<MobileMenuScript />
</>
);
}
@@ -21,13 +21,13 @@ export function Trademark(props: {
return (
<div
className={tcls(
'relative',
'z-[2]',
'lg:absolute',
'absolute',
'left-0',
'left-2',
'right-2',
'bottom-0',
'lg:left-0',
'pointer-events-none',
'sidebar-filled:pl-2',
@@ -44,8 +44,7 @@ export function Trademark(props: {
'straight-corners:rounded-none',
'circular-corners:rounded-2xl',
'before:hidden',
'lg:before:block',
'before:block',
'before:content-[""]',
'before:absolute',
'before:inset-x-0',
@@ -111,8 +110,7 @@ export function TrademarkLink(props: {
'hover:bg-tint',
'hover:text-tint-strong',
'ring-2',
'lg:ring-1',
'ring-1',
'ring-inset',
'ring-tint-subtle',
@@ -2,3 +2,4 @@ export { TableOfContents } from './TableOfContents';
export { PagesList } from './PagesList';
export { TOCScrollContainer } from './TOCScroller';
export { Trademark } from './Trademark';
export { TOCScrollContent } from './TOCScrollContent';
@@ -7,6 +7,7 @@ import { createContext, useCallback, useContext, 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 { assert } from 'ts-essentials';
import { Link, type LinkInsightsProps } from '.';
@@ -34,8 +35,14 @@ 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;
/**
* Side of the dropdown
* @default "bottom"
@@ -54,12 +61,15 @@ export function DropdownMenu(props: {
openOnHover = false,
side = 'bottom',
align = 'start',
withPortal = true,
} = props;
const [hovered, setHovered] = useState(false);
const [open, setOpen] = useState(false);
const isOpen = openOnHover ? open || hovered : open;
const Portal = withPortal ? RadixDropdownMenu.Portal : Slot;
return (
<DropdownMenuContext.Provider value={{ open: isOpen, setOpen }}>
<RadixDropdownMenu.Root modal={false} open={isOpen} onOpenChange={setOpen}>
@@ -73,7 +83,7 @@ export function DropdownMenu(props: {
{button}
</RadixDropdownMenu.Trigger>
<RadixDropdownMenu.Portal>
<Portal>
<RadixDropdownMenu.Content
data-testid="dropdown-menu"
hideWhenDetached
@@ -93,7 +103,7 @@ export function DropdownMenu(props: {
{children}
</div>
</RadixDropdownMenu.Content>
</RadixDropdownMenu.Portal>
</Portal>
</RadixDropdownMenu.Root>
</DropdownMenuContext.Provider>
);