diff --git a/.changeset/curly-eels-carry.md b/.changeset/curly-eels-carry.md new file mode 100644 index 0000000000..9bc3b46a39 --- /dev/null +++ b/.changeset/curly-eels-carry.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add sidesheet component, use it for TOC and AIChat diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/(content)/layout.tsx b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/(content)/layout.tsx index 53fdd28629..aa267b9647 100644 --- a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/(content)/layout.tsx +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/(content)/layout.tsx @@ -23,7 +23,8 @@ export default async function SiteDynamicLayout({ return ( diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/(content)/layout.tsx b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/(content)/layout.tsx index 890caca50d..b86e8a3875 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/(content)/layout.tsx +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/(content)/layout.tsx @@ -19,7 +19,11 @@ export default async function SiteStaticLayout({ const withTracking = shouldTrackEvents(); return ( - + { + if (open) { + chatController.open(); + } else { + chatController.close(); + } + }} data-testid="ai-chat" + withScrim={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 mx-auto not-hydrated:hidden w-96 max-w-full pl-8 transition-[width] duration-300 ease-quint lg:max-xl:w-80' )} > - + @@ -109,7 +117,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/Embeddable/EmbeddableDocsPage.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx index 08ee0b12b9..18a55e6e87 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx @@ -79,7 +79,7 @@ export async function EmbeddableDocsPage(props: EmbeddableDocsPageProps) { contentClassName="p-4" fadeEdges={context.sections ? [] : ['leading']} > - + { - if (!hasScrollRef.current && document.body.classList.contains(globalClassName)) { - document.body.classList.remove(globalClassName); - setIsOpen(false); - } else { - document.body.classList.add(globalClassName); - window.scrollTo(0, 0); - setIsOpen(true); - } - }; - - 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(() => { @@ -50,8 +28,10 @@ export function HeaderMobileMenu(props: ButtonProps) { variant="blank" size="default" label={tString(language, 'table_of_contents_button_label')} - onClick={toggleNavigation} - active={isOpen} + onClick={() => { + document.body.classList.toggle(globalClassName); + }} + // Since the button is hidden behind the TOC after toggling, we don't need to keep track of its active state. {...props} /> ); diff --git a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx index be6dd59815..c81b316588 100644 --- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx +++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx @@ -57,13 +57,15 @@ function preloadFont(fontData: FontData) { * It takes care of setting the theme and the language. */ export async function CustomizationRootLayout(props: { + /** The class name to apply to the html element. */ + htmlClassName?: string; /** The class name to apply to the body element. */ - className?: string; + bodyClassName?: string; forcedTheme?: CustomizationThemeMode | null; context: GitBookAnyContext; children: React.ReactNode; }) { - const { className, context, forcedTheme, children } = props; + const { htmlClassName, bodyClassName, context, forcedTheme, children } = props; const customization = 'customization' in context ? context.customization : defaultCustomization(); @@ -107,7 +109,8 @@ export async function CustomizationRootLayout(props: { // Set the dark/light class statically to avoid flashing and make it work when JS is disabled (forcedTheme ?? customization.themes.default) === CustomizationThemeMode.Dark ? 'dark' - : '' + : '', + htmlClassName )} > @@ -179,7 +182,7 @@ export async function CustomizationRootLayout(props: { } `} - + + + {variants.translations.length > 1 ? ( + space.id === siteSpace.id + ) ?? siteSpace + } + siteSpaces={variants.translations} + className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden" + /> + ) : null} + + } + // 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. + innerHeader={ + !withTopHeader || variants.generic.length > 1 ? (
1 ? '' : 'max-lg:hidden' )} > - - {variants.translations.length > 1 ? ( - + 1} + withSiteVariants={ + visibleSections?.list.some( + (s) => + s.object === 'site-section' && + s.siteSpaces.length > 1 + ) ?? false + } + withSections={withSections} + section={visibleSections?.current} + siteSpace={siteSpace} + siteSpaces={visibleSiteSpaces} + viewport="desktop" + /> +
+ )} + {!withTopHeader && withSections && visibleSections && ( + + )} + {variants.generic.length > 1 ? ( + space.id === siteSpace.id - ) ?? siteSpace - } - siteSpaces={variants.translations} - className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden" + siteSpace={siteSpace} + siteSpaces={variants.generic} + className="w-full px-3" /> ) : null} - ) - } - // 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. - innerHeader={ - <> - {!withTopHeader && ( -
- 1} - withSiteVariants={ - visibleSections?.list.some( - (s) => - s.object === 'site-section' && - s.siteSpaces.length > 1 - ) ?? false - } - withSections={withSections} - section={visibleSections?.current} - siteSpace={siteSpace} - siteSpaces={visibleSiteSpaces} - className="max-lg:hidden" - viewport="desktop" - /> -
- )} - {!withTopHeader && withSections && visibleSections && ( - - )} - {variants.generic.length > 1 ? ( - - ) : null} - + ) : null } /> {children} diff --git a/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx b/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx index 13b3988873..3180e197e7 100644 --- a/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx @@ -32,7 +32,8 @@ export function PageDocumentItem(props: { page: ClientTOCPageDocument }) { 'my-2', 'border-tint-subtle', 'sidebar-list-default:border-l', - 'sidebar-list-line:border-l' + 'sidebar-list-line:border-l', + 'break-anywhere' )} /> ) : null diff --git a/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx b/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx index 5f07a2decf..05e250b3b1 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; isFirst?: boole '[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', isFirst ? '-mt-6' : '' )} > diff --git a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx index 729507d3d0..85bce61584 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 @@ -22,23 +26,33 @@ export async function TableOfContents(props: { return ( <> - + ); diff --git a/packages/gitbook/src/components/TableOfContents/Trademark.tsx b/packages/gitbook/src/components/TableOfContents/Trademark.tsx index f5dbe8387d..20aa580467 100644 --- a/packages/gitbook/src/components/TableOfContents/Trademark.tsx +++ b/packages/gitbook/src/components/TableOfContents/Trademark.tsx @@ -19,46 +19,28 @@ export function Trademark(props: { return (
@@ -109,8 +91,7 @@ export function TrademarkLink(props: { 'hover:bg-tint', 'hover:text-tint-strong', - 'ring-2', - 'lg:ring-1', + 'ring-1', 'ring-inset', 'ring-tint-subtle', 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/HoverCard.tsx b/packages/gitbook/src/components/primitives/HoverCard.tsx index 62bf42bee9..9edea89820 100644 --- a/packages/gitbook/src/components/primitives/HoverCard.tsx +++ b/packages/gitbook/src/components/primitives/HoverCard.tsx @@ -28,7 +28,7 @@ export function HoverCard(
void; + /** Show a backdrop overlay when modal */ + withScrim?: boolean; + /** Show a close button when modal */ + withCloseButton?: boolean; + } & React.HTMLAttributes +) { + const { + side, + children, + className, + toggleClass, + open: openState, + modal = 'mobile', + withScrim, + withCloseButton, + onOpenChange, + ...rest + } = props; + + const isMobile = useIsMobile(); + const isModal = modal === 'mobile' ? isMobile : modal; + + // Internal state for uncontrolled mode (only used when open prop is undefined) + const [open, setOpen] = React.useState(openState ?? false); + + // Determine actual open state: controlled (from prop) or uncontrolled (from internal state) + const isOpen = openState !== undefined ? openState : open; + + const handleClose = React.useCallback(() => { + if (openState !== undefined) { + // Controlled mode: parent manages state, notify via callback with new state + onOpenChange?.(false); + } else { + // Uncontrolled mode: update internal state and sync body class if needed + setOpen(false); + if (toggleClass) { + document.body.classList.remove(toggleClass); + } + } + }, [openState, onOpenChange, toggleClass]); + + // Sync the sheet state with the body class if the toggleClass is set + 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: sync with parent's state + // Notify parent of state change via onOpenChange + if (shouldBeOpen !== openState) { + onOpenChange?.(shouldBeOpen); + } + } else { + // Uncontrolled mode: sync internal state with body class + setOpen(shouldBeOpen); + } + } + } + }; + + const observer = new MutationObserver(callback); + observer.observe(document.body, { attributes: true }); + + return () => observer.disconnect(); + }, [toggleClass, openState, onOpenChange]); + + return ( + <> + {withScrim ? ( + + ) : null} + + + + ); +} + +/** Backdrop overlay shown behind the modal sheet */ +export function SideSheetScrim(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 starting:backdrop-blur-none transition-[opacity,display,backdrop-filter] transition-discrete duration-250 dark:bg-tint-base/6', + className + )} + /> + ); +} + +/** Close button displayed outside the sheet when modal */ +export function SideSheetCloseButton(props: { className?: ClassValue; onClick?: () => void }) { + const { className, onClick } = props; + const language = useLanguage(); + return ( +