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(); + } + }} + 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 ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80' )} > - + @@ -107,7 +115,7 @@ export function AIChat() { - + ); } @@ -218,8 +226,8 @@ export function AIChatBody(props: { className="shrink grow basis-80 animate-fade-in-slow [container-type:size]" contentClassName="p-4 gutter-stable flex flex-col gap-4" orientation="vertical" - fadeEdges={['leading']} - active={`message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`} + trailing={{ fade: false, button: true }} + active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`} > {isEmpty ? (
diff --git a/packages/gitbook/src/components/Cookies/CookiesToast.tsx b/packages/gitbook/src/components/Cookies/CookiesToast.tsx index 271842e7b3..d16adb871e 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 ea06d9119b..2754e2d069 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx @@ -79,9 +79,10 @@ export async function EmbeddableDocsPage( orientation="vertical" className="not-hydrated:animate-blur-in-slow" contentClassName="p-4" - fadeEdges={context.sections ? [] : ['leading']} + leading={{ fade: !context.sections, button: true }} + trailing={{ fade: false, button: true }} > - + {children} {context.customization.trademark.enabled ? ( - diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index 7a12020ed9..6085682141 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -42,6 +42,7 @@ export function Header(props: { `h-[${HEADER_HEIGHT_DESKTOP}px]`, 'sticky', 'top-0', + 'pt-[env(safe-area-inset-top)]', 'z-30', 'w-full', 'flex-none', diff --git a/packages/gitbook/src/components/Header/HeaderMobileMenu.tsx b/packages/gitbook/src/components/Header/HeaderMobileMenu.tsx index 3155920f79..1803296a98 100644 --- a/packages/gitbook/src/components/Header/HeaderMobileMenu.tsx +++ b/packages/gitbook/src/components/Header/HeaderMobileMenu.tsx @@ -1,16 +1,13 @@ 'use client'; import { usePathname } from 'next/navigation'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect } from 'react'; import { tString, useLanguage } from '@/intl/client'; -import { useScrollListener } from '../hooks/useScrollListener'; import { Button, type ButtonProps } from '../primitives'; const globalClassName = 'navigation-open'; -const SCROLL_DISTANCE = 320; - /** * Button to show/hide the table of content on mobile. */ @@ -18,25 +15,6 @@ export function HeaderMobileMenu(props: ButtonProps) { const language = useLanguage(); const pathname = usePathname(); - const hasScrollRef = useRef(false); - - const [isOpen, setIsOpen] = useState(false); - - const toggleNavigation = () => { - 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) { iconOnly variant="blank" 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/PDF/PDFPage.tsx b/packages/gitbook/src/components/PDF/PDFPage.tsx index 3918283c14..bd9603e4a7 100644 --- a/packages/gitbook/src/components/PDF/PDFPage.tsx +++ b/packages/gitbook/src/components/PDF/PDFPage.tsx @@ -15,7 +15,7 @@ import { notFound } from 'next/navigation'; import * as React from 'react'; import { DocumentView } from '@/components/DocumentView'; -import { TrademarkLink } from '@/components/TableOfContents/Trademark'; +import { Trademark } from '@/components/TableOfContents/Trademark'; import type { PolymorphicComponentProp } from '@/components/utils/types'; import { getSpaceLanguage } from '@/intl/server'; import { tString } from '@/intl/translate'; @@ -148,7 +148,7 @@ export async function PDFPage(props: { total={total} trademark={ customization.trademark.enabled ? ( - diff --git a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx index 6e23d3533b..15d99514e7 100644 --- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx +++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx @@ -56,13 +56,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(); @@ -106,7 +108,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 )} > @@ -178,7 +181,7 @@ export async function CustomizationRootLayout(props: { } `} - +
{sectionsAndGroups.map((item) => { diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx index 3f98fe3cfe..ecb623084a 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx @@ -76,8 +76,12 @@ export function SiteSectionTabs(props: { ? 'md:-mr-8 -mr-4 sm:-mr-6' : 'after:contents[] after:absolute after:inset-y-2 after:right-0 after:border-transparent after:border-r after:transition-colors' )} - active={currentSection.id} - trailingEdgeScrollClassName={children ? 'after:border-tint' : ''} + active={`#${currentSection.id}`} + trailing={{ + fade: true, + button: true, + className: children ? 'after:border-tint' : '', + }} > + + {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 7bd05e409f..ae5eb9a2d6 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..ca02de92aa 100644 --- a/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx @@ -14,9 +14,9 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
  • diff --git a/packages/gitbook/src/components/TableOfContents/TOCScroller.tsx b/packages/gitbook/src/components/TableOfContents/TOCScroller.tsx deleted file mode 100644 index 5ae7e863a0..0000000000 --- a/packages/gitbook/src/components/TableOfContents/TOCScroller.tsx +++ /dev/null @@ -1,91 +0,0 @@ -'use client'; - -import React, { - useCallback, - useEffect, - useMemo, - useRef, - type ComponentPropsWithoutRef, -} from 'react'; -import { assert } from 'ts-essentials'; - -interface TOCScrollContainerContextType { - getContainer: (listener: (element: HTMLDivElement) => void) => () => void; -} - -const TOCScrollContainerContext = React.createContext(null); - -function useTOCScrollContainerContext() { - const ctx = React.useContext(TOCScrollContainerContext); - assert(ctx); - return ctx; -} - -/** - * Table of contents scroll container. - */ -export function TOCScrollContainer(props: ComponentPropsWithoutRef<'div'>) { - const ref = useRef(null); - const listeners = useRef<((element: HTMLDivElement) => void)[]>([]); - const getContainer: TOCScrollContainerContextType['getContainer'] = useCallback((listener) => { - if (ref.current) { - listener(ref.current); - return () => {}; - } - listeners.current.push(listener); - return () => { - listeners.current = listeners.current.filter((l) => l !== listener); - }; - }, []); - const value: TOCScrollContainerContextType = useMemo(() => ({ getContainer }), [getContainer]); - useEffect(() => { - const element = ref.current; - if (!element) { - return; - } - listeners.current.forEach((listener) => listener(element)); - return () => { - listeners.current = []; - }; - }, []); - - return ( - -
    - - ); -} - -// Offset to scroll the table of contents item by. -const TOC_ITEM_OFFSET = 100; - -/** - * Scrolls the table of contents container to the page item when it's initially active. - */ -export function useScrollToActiveTOCItem(props: { - anchorRef: React.RefObject; - isActive: boolean; -}) { - const { isActive, anchorRef } = props; - const { getContainer } = useTOCScrollContainerContext(); - useEffect(() => { - const anchor = anchorRef.current; - if (isActive && anchor) { - return getContainer((container) => { - if (isOutOfView(anchor, container)) { - container.scrollTo({ top: anchor.offsetTop - TOC_ITEM_OFFSET }); - } - }); - } - }, [isActive, getContainer, anchorRef]); -} - -function isOutOfView(element: HTMLElement, container: HTMLElement) { - const tocItemTop = element.offsetTop; - const containerTop = container.scrollTop; - const containerBottom = containerTop + container.clientHeight; - return ( - tocItemTop < containerTop + TOC_ITEM_OFFSET || - tocItemTop > containerBottom - TOC_ITEM_OFFSET - ); -} diff --git a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx index 729507d3d0..4312a6a8a3 100644 --- a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx +++ b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx @@ -3,42 +3,59 @@ import { SiteInsightsTrademarkPlacement } from '@gitbook/api'; import type React from 'react'; import { tcls } from '@/lib/tailwind'; +import { ScrollContainer } from '../primitives/ScrollContainer'; +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 innerHeader?: React.ReactNode; // Displayed outside the scrollable TOC, directly above the page list + withTrademark?: boolean; className?: string; }) { - const { innerHeader, context, header, className } = props; + const { innerHeader, context, header, className, withTrademark = true } = props; const { customization, revision } = context; const pages = await encodeClientTableOfContents(context, revision.pages, revision.pages); return ( <> - + ); diff --git a/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx b/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx index ad132d6fd4..a87c6690c2 100644 --- a/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx @@ -3,7 +3,6 @@ import { AnimatePresence, motion } from 'motion/react'; import React, { useRef } from 'react'; import { useCurrentPagePath } from '../hooks'; import { Button, Link, type LinkInsightsProps, type LinkProps, ToggleChevron } from '../primitives'; -import { useScrollToActiveTOCItem } from './TOCScroller'; /** * Client component for a page document to toggle its children and be marked as active. @@ -76,8 +75,6 @@ function LinkItem( } ) { const { isActive, href, insights, children, onActiveClick } = props; - const anchorRef = useRef(null); - useScrollToActiveTOCItem({ anchorRef, isActive }); const handleClick = (event: React.MouseEvent) => { if (isActive && onActiveClick) { @@ -88,7 +85,7 @@ function LinkItem( return ( - -
    - ); -} - -/** - * Trademark link to the GitBook. - */ -export function TrademarkLink(props: { - context: GitBookSpaceContext; - placement: SiteInsightsTrademarkPlacement; - className?: string; }) { const { context, placement, className } = props; const { space } = context; @@ -88,8 +25,10 @@ export function TrademarkLink(props: { url.searchParams.set('utm_campaign', space.id); return ( - } + label={tString(language, 'powered_by_gitbook')} insights={{ type: 'trademark_click', placement, }} - > - - {t(language, 'powered_by_gitbook')} - + /> ); } diff --git a/packages/gitbook/src/components/TableOfContents/index.ts b/packages/gitbook/src/components/TableOfContents/index.ts index 6eeff92697..03420d7a00 100644 --- a/packages/gitbook/src/components/TableOfContents/index.ts +++ b/packages/gitbook/src/components/TableOfContents/index.ts @@ -1,4 +1,3 @@ export { TableOfContents } from './TableOfContents'; export { PagesList } from './PagesList'; -export { TOCScrollContainer } from './TOCScroller'; export { Trademark } from './Trademark'; 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(
    ; @@ -39,10 +48,9 @@ export function ScrollContainer(props: ScrollContainerProps) { className, contentClassName, orientation, - fadeEdges = ['leading', 'trailing'], active, - leadingEdgeScrollClassName, - trailingEdgeScrollClassName, + leading = { fade: true, button: true }, + trailing = { fade: true, button: true }, ...rest } = props; @@ -102,7 +110,9 @@ export function ScrollContainer(props: ScrollContainerProps) { return; } const activeItem = - typeof active === 'string' ? document.getElementById(active) : active.current; + typeof active === 'string' + ? containerRef.current?.querySelector(active) + : active.current; if (!activeItem || !container.contains(activeItem)) { return; } @@ -138,28 +148,30 @@ export function ScrollContainer(props: ScrollContainerProps) { return (
    0 ? leadingEdgeScrollClassName : '', - scrollPosition < scrollSize ? trailingEdgeScrollClassName : '' + scrollPosition > 0 ? leading?.className : '', + scrollPosition < scrollSize ? trailing?.className : '' )} {...rest} > {/* Scrollable content */}
    0 + leading.fade && scrollPosition > 0 ? orientation === 'horizontal' - ? 'mask-l-from-[calc(100%-2rem)]' - : 'mask-t-from-[calc(100%-2rem)]' + ? 'mask-l-from-[calc(100%-1rem)]' + : 'mask-t-from-[calc(100%-1rem)]' : '', - fadeEdges.includes('trailing') && scrollPosition < scrollSize + trailing.fade && scrollPosition < scrollSize ? orientation === 'horizontal' - ? 'mask-r-from-[calc(100%-2rem)]' - : 'mask-b-from-[calc(100%-2rem)]' + ? 'mask-r-from-[calc(100%-1rem)]' + : 'mask-b-from-[calc(100%-1rem)]' : '', contentClassName )} @@ -169,44 +181,52 @@ export function ScrollContainer(props: ScrollContainerProps) {
    {/* Scroll buttons back & forward */} -
    ); } @@ -214,7 +234,7 @@ export function ScrollContainer(props: ScrollContainerProps) { /** * Scroll to an element in a container. */ -function scrollToElementInContainer(element: HTMLElement, container: HTMLElement) { +function scrollToElementInContainer(element: Element, container: HTMLElement) { const containerRect = container.getBoundingClientRect(); const rect = element.getBoundingClientRect(); @@ -229,6 +249,8 @@ function scrollToElementInContainer(element: HTMLElement, container: HTMLElement (rect.left - containerRect.left) - container.clientWidth / 2 + rect.width / 2, - behavior: 'smooth', + // Use 'auto' to avoid additional scroll animations when scrolling to an element + // as this may be called during layout/initialization when the page is not fully loaded. + behavior: '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..ee1782e152 --- /dev/null +++ b/packages/gitbook/src/components/primitives/SideSheet.tsx @@ -0,0 +1,275 @@ +'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'; + +const ANIMATION_DURATION = 300; + +/** + * SideSheet - A slide-in panel component that can appear from the left or right side. + * + * Supports both controlled and uncontrolled modes: + * - Controlled: Provide both `open` and `onOpenChange` props. Parent manages state. + * - Uncontrolled: Omit `open` prop. Component manages its own state internally. + */ +export function SideSheet( + props: { + /** Which side the sheet slides in from */ + side: 'left' | 'right'; + /** + * Optional CSS class to monitor and sync with `document.body.classList`. + * When set, a MutationObserver watches for the class and syncs the sheet state accordingly. + * Adding this class opens the sheet, removing it closes it. + * Works in both controlled and uncontrolled modes. + */ + toggleClass?: string; + /** + * Modal behavior: true (always modal), false (never modal), or 'mobile' (modal only on mobile). + * Defaults to 'mobile'. + */ + modal?: true | false | 'mobile'; + /** + * Controls visibility. If provided, component is controlled (parent manages state). + * If undefined, component is uncontrolled (manages its own state). + */ + open?: boolean; + /** Called when the open state changes. Receives the new state (true/false). Only used in controlled mode. */ + onOpenChange?: (open: boolean) => 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; + const asideRef = React.useRef(null); + + // 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 wasOpenRef = React.useRef(false); + const [shouldHide, setShouldHide] = React.useState(!isOpen); + + // Track if component has been opened to prevent initial animation + React.useEffect(() => { + if (isOpen) { + wasOpenRef.current = true; + setShouldHide(false); + } else if (wasOpenRef.current) { + // Delay hiding until after exit animation completes + const timer = setTimeout(() => { + setShouldHide(true); + }, ANIMATION_DURATION); + return () => clearTimeout(timer); + } else { + // Never been opened, hide immediately + setShouldHide(true); + } + }, [isOpen]); + + 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]); + + // Handle Escape key press to close the modal sheet + React.useEffect(() => { + if (!isModal || !isOpen) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + handleClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isModal, isOpen, handleClose]); + + // Focus trapping: prevent Tab from leaving the modal + React.useEffect(() => { + if (!isModal || !isOpen || !asideRef.current) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab') { + return; + } + + const aside = asideRef.current; + if (!aside) { + return; + } + + const focusable = aside.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const current = document.activeElement as HTMLElement; + + if (!aside.contains(current)) { + // Focus escaped, bring it back + (event.shiftKey ? last : first)?.focus(); + event.preventDefault(); + } else if (event.shiftKey && current === first) { + // Shift+Tab at first element, wrap to last + last?.focus(); + event.preventDefault(); + } else if (!event.shiftKey && current === last) { + // Tab at last element, wrap to first + first?.focus(); + event.preventDefault(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isModal, isOpen]); + + return ( + <> + {withScrim ? ( + + ) : null} + + + + ); +} + +/** Backdrop overlay shown behind the modal sheet */ +export function SideSheetScrim(props: { className?: ClassValue; onClick?: () => void }) { + const { className, onClick } = props; + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: global escape key handler is used to close the modal sheet +
    { + 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 ( +