From 9e669f162a7c611c4b9c03bce1374a34f5b872d0 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Tue, 2 Dec 2025 10:09:24 +0100 Subject: [PATCH 01/12] Add SideSheet component, refactor TOC and AIChat to use it Also fixes RND-7803 --- .../gitbook/src/components/AIChat/AIChat.tsx | 16 +- .../src/components/Cookies/CookiesToast.tsx | 8 +- .../gitbook/src/components/Header/Header.tsx | 5 +- .../components/Header/HeaderMobileMenu.tsx | 7 +- .../RootLayout/CustomizationRootLayout.tsx | 3 +- .../src/components/SiteLayout/SiteLayout.tsx | 1 + .../components/SpaceLayout/SpaceLayout.tsx | 2 +- .../TableOfContents/PageGroupItem.tsx | 4 +- .../TableOfContents/TableOfContents.tsx | 42 +++-- packages/gitbook/src/components/layout.ts | 6 +- .../src/components/primitives/SideSheet.tsx | 165 ++++++++++++++++++ packages/gitbook/tailwind.config.ts | 22 ++- 12 files changed, 227 insertions(+), 54 deletions(-) create mode 100644 packages/gitbook/src/components/primitives/SideSheet.tsx diff --git a/packages/gitbook/src/components/AIChat/AIChat.tsx b/packages/gitbook/src/components/AIChat/AIChat.tsx index aa3ca9787e..cdef68278d 100644 --- a/packages/gitbook/src/components/AIChat/AIChat.tsx +++ b/packages/gitbook/src/components/AIChat/AIChat.tsx @@ -26,6 +26,7 @@ import { useTrackEvent } from '../Insights'; import { useNow } from '../hooks'; import { Button } from '../primitives'; import { ScrollContainer } from '../primitives/ScrollContainer'; +import { SideSheet } from '../primitives/SideSheet'; import { AIChatControlButton } from './AIChatControlButton'; import { AIChatIcon } from './AIChatIcon'; import { AIChatInput } from './AIChatInput'; @@ -68,16 +69,17 @@ export function AIChat() { }, [chat.opened, trackEvent]); return ( -
chatController.close()} data-testid="ai-chat" + withShim={true} className={tcls( - 'ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl scroll-mt-36 px-4 py-4 transition-[width,opacity,margin,display] transition-discrete duration-300 sm:px-6 lg:fixed lg:w-80 lg:p-0 xl:w-96', - chat.opened - ? 'lg:starting:ml-0 lg:starting:w-0 lg:starting:opacity-0' - : 'hidden lg:ml-0 lg:w-0! lg:opacity-0' + 'ai-chat z-40 mx-auto not-hydrated:hidden w-96 max-w-full pl-8 transition-[width] duration-300 ease-quint lg:max-xl:w-80' )} > - + @@ -102,7 +104,7 @@ export function AIChat() { -
+ ); } diff --git a/packages/gitbook/src/components/Cookies/CookiesToast.tsx b/packages/gitbook/src/components/Cookies/CookiesToast.tsx index caba3a2cfa..3de0c2b47e 100644 --- a/packages/gitbook/src/components/Cookies/CookiesToast.tsx +++ b/packages/gitbook/src/components/Cookies/CookiesToast.tsx @@ -41,7 +41,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) { aria-describedby={describedById} className={tcls( 'fixed', - 'z-10', + 'z-50', 'bg-tint-base', 'rounded-sm', 'straight-corners:rounded-none', @@ -52,9 +52,9 @@ export function CookiesToast(props: { privacyPolicy?: string }) { 'depth-flat:shadow-none', 'p-4', 'pr-8', - 'bottom-4', - 'right-4', - 'left-16', + 'bottom-[max(env(safe-area-inset-bottom),1rem)]', + 'right-[max(env(safe-area-inset-right),1rem)]', + 'left-[max(env(safe-area-inset-left),4rem)]', 'max-w-md', 'text-balance', 'sm:left-auto', diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index 89d6cce7a0..8caf04ade5 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -42,11 +42,12 @@ export function Header(props: { `h-[${HEADER_HEIGHT_DESKTOP}px]`, 'sticky', 'top-0', + 'pt-[env(safe-area-inset-top)]', 'z-30', 'w-full', 'flex-none', - 'shadow-[0px_1px_0px]', - 'shadow-tint-12/2', + 'border-b', + 'border-tint-subtle', 'bg-tint-base/9', 'theme-muted:bg-tint-subtle/9', diff --git a/packages/gitbook/src/components/Header/HeaderMobileMenu.tsx b/packages/gitbook/src/components/Header/HeaderMobileMenu.tsx index 3b0ed49dfc..652081c636 100644 --- a/packages/gitbook/src/components/Header/HeaderMobileMenu.tsx +++ b/packages/gitbook/src/components/Header/HeaderMobileMenu.tsx @@ -23,12 +23,7 @@ export function HeaderMobileMenu(props: Partial { - if (!hasScrollRef.current && document.body.classList.contains(globalClassName)) { - document.body.classList.remove(globalClassName); - } else { - document.body.classList.add(globalClassName); - window.scrollTo(0, 0); - } + document.body.classList.toggle(globalClassName); }; const windowRef = useRef(typeof window === 'undefined' ? null : window); diff --git a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx index be6dd59815..edaa0b0374 100644 --- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx +++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx @@ -91,6 +91,7 @@ export async function CustomizationRootLayout(props: { suppressHydrationWarning lang={customization.internationalization.locale} className={tcls( + 'gutter-stable', customization.styling.corners && `${customization.styling.corners}-corners`, 'theme' in customization.styling && `theme-${customization.styling.theme}`, tintColor ? ' tint' : 'no-tint', @@ -179,7 +180,7 @@ export async function CustomizationRootLayout(props: { } `} - + diff --git a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx index 632135a283..5ad72bd96d 100644 --- a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx +++ b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx @@ -3,12 +3,16 @@ import { SiteInsightsTrademarkPlacement } from '@gitbook/api'; import type React from 'react'; import { tcls } from '@/lib/tailwind'; +import { SideSheet } from '../primitives/SideSheet'; import { PagesList } from './PagesList'; import { TOCScrollContainer } from './TOCScroller'; import { TableOfContentsScript } from './TableOfContentsScript'; import { Trademark } from './Trademark'; import { encodeClientTableOfContents } from './encodeClientTableOfContents'; +/** + * Sidebar container, responsible for setting the right dimensions and position for the sidebar. + */ export async function TableOfContents(props: { context: GitBookSiteContext; header?: React.ReactNode; // Displayed outside the scrollable TOC as a sticky header @@ -21,23 +25,33 @@ export async function TableOfContents(props: { return ( <> - + ); diff --git a/packages/gitbook/src/components/layout.ts b/packages/gitbook/src/components/layout.ts index 4f6579ab6a..93dad216bd 100644 --- a/packages/gitbook/src/components/layout.ts +++ b/packages/gitbook/src/components/layout.ts @@ -9,9 +9,9 @@ export const HEADER_HEIGHT_DESKTOP = 64 as const; * Style for the container to adapt between normal and full width. */ export const CONTAINER_STYLE: ClassValue = [ - 'px-4', - 'sm:px-6', - 'md:px-8', + 'px-4 pl-[max(env(safe-area-inset-left),1rem)] pr-[max(env(safe-area-inset-right),1rem)]', + 'sm:px-6 sm:pl-[max(env(safe-area-inset-left),1.5rem)] sm:pr-[max(env(safe-area-inset-right),1.5rem)]', + 'md:px-8 md:pl-[max(env(safe-area-inset-left),2rem)] md:pr-[max(env(safe-area-inset-right),2rem)]', 'max-w-screen-2xl', 'mx-auto', ]; diff --git a/packages/gitbook/src/components/primitives/SideSheet.tsx b/packages/gitbook/src/components/primitives/SideSheet.tsx new file mode 100644 index 0000000000..de332aface --- /dev/null +++ b/packages/gitbook/src/components/primitives/SideSheet.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useLanguage } from '@/intl/client'; +import { tString } from '@/intl/translate'; +import { type ClassValue, tcls } from '@/lib/tailwind'; +import React from 'react'; +import { useIsMobile } from '../hooks/useIsMobile'; +import { Button } from './Button'; + +export function SideSheet( + props: { + side: 'left' | 'right'; + open?: boolean; + toggleClass?: string; + modal?: true | false | 'mobile'; + onClose?: () => void; + withShim?: boolean; + withCloseButton?: boolean; + } & React.HTMLAttributes +) { + const { + side, + children, + className, + toggleClass, + open: openState, + modal = 'mobile', + withShim, + withCloseButton, + onClose, + ...rest + } = props; + + const isMobile = useIsMobile(); + const isModal = modal === 'mobile' ? isMobile : modal; + + const [open, setOpen] = React.useState(openState ?? false); + + // Use prop if provided (controlled), otherwise use internal state (uncontrolled) + const isOpen = openState !== undefined ? openState : open; + + const handleClose = React.useCallback(() => { + if (openState !== undefined) { + // Controlled mode: notify parent + onClose?.(); + } else { + // Uncontrolled mode: update internal state + setOpen(false); + if (toggleClass) { + document.body.classList.remove(toggleClass); + } + } + }, [openState, onClose, toggleClass]); + + React.useEffect(() => { + if (!toggleClass) { + return; + } + + const callback = (mutationList: MutationRecord[]) => { + for (const mutation of mutationList) { + if (mutation.attributeName === 'class') { + const shouldBeOpen = document.body.classList.contains(toggleClass); + if (openState !== undefined) { + // Controlled mode: notify parent if state should change + if (shouldBeOpen !== openState) { + if (shouldBeOpen) { + // Opening via class - no callback, just sync + // Parent should handle this via toggleClass observation + } else { + onClose?.(); + } + } + } else { + // Uncontrolled mode: update internal state + setOpen(shouldBeOpen); + } + } + } + }; + + const observer = new MutationObserver(callback); + observer.observe(document.body, { attributes: true }); + + return () => observer.disconnect(); + }, [toggleClass, openState, onClose]); + + return ( + <> + {isModal && withShim ? ( + + ) : null} + + + ); +} + +export function SideSheetShim(props: { className?: ClassValue; onClick?: () => void }) { + const { className, onClick } = props; + return ( +
{ + onClick?.(); + }} + onKeyUp={(e) => { + if (e.key === 'Escape') { + onClick?.(); + } + }} + className={tcls( + 'fixed inset-0 z-40 items-start bg-tint-base/3 not-hydrated:opacity-0 starting:opacity-0 backdrop-blur-md transition-[opacity,display,filter] transition-discrete duration-250', + className + )} + /> + ); +} + +export function SideSheetCloseButton(props: { className?: ClassValue; onClick?: () => void }) { + const { className, onClick } = props; + const language = useLanguage(); + return ( +