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 ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80' )} > - + @@ -109,7 +117,7 @@ export function AIChat() { - + ); } @@ -221,8 +229,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/Announcement/AnnouncementBanner.tsx b/packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx index 815df44b87..ee8115247d 100644 --- a/packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx +++ b/packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx @@ -35,66 +35,68 @@ export function AnnouncementBanner(props: { data-nosnippet="" >
-
- - -
- {announcement.message} - {hasLink ? ( -
- {contentRef?.icon ? ( - - {contentRef?.icon} - - ) : null} - {announcement.link?.title && ( - {announcement.link?.title} - )} - -
- ) : null} -
-
- {closeable ? ( -
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 f33b1c56fe..6edc6cc838 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx @@ -79,9 +79,9 @@ 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 }} > - + { - 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: { } `} - +
{sectionsAndGroups.map((item) => { diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx index e176a3f826..37465a64b2 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 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..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..6c3d38fa38 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 { 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 @@ -22,23 +26,33 @@ export async function TableOfContents(props: { return ( <> - + ); diff --git a/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx b/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx index 4d222da04f..a907cd3dd6 100644 --- a/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx @@ -8,7 +8,6 @@ import { tcls } from '@/lib/tailwind'; import { useCurrentPagePath } from '../hooks'; import { Link, type LinkInsightsProps, type LinkProps } from '../primitives'; -import { useScrollToActiveTOCItem } from './TOCScroller'; /** * Client component for a page document to toggle its children and be marked as active. @@ -81,8 +80,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) { @@ -93,7 +90,7 @@ function LinkItem( return ( +
    ); @@ -109,8 +65,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/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,6 @@ function scrollToElementInContainer(element: HTMLElement, container: HTMLElement (rect.left - containerRect.left) - container.clientWidth / 2 + rect.width / 2, - behavior: 'smooth', + 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..7e5b88464a --- /dev/null +++ b/packages/gitbook/src/components/primitives/SideSheet.tsx @@ -0,0 +1,194 @@ +'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'; + +/** + * 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; + + // 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 ( +