diff --git a/packages/gitbook/src/components/AIChat/AIChat.tsx b/packages/gitbook/src/components/AIChat/AIChat.tsx index cdef68278..d15d53386 100644 --- a/packages/gitbook/src/components/AIChat/AIChat.tsx +++ b/packages/gitbook/src/components/AIChat/AIChat.tsx @@ -72,11 +72,17 @@ export function AIChat() { chatController.close()} + onOpenChange={(open) => { + if (open) { + chatController.open(); + } else { + chatController.close(); + } + }} data-testid="ai-chat" - withShim={true} + withScrim={true} className={tcls( - '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' + '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' )} > diff --git a/packages/gitbook/src/components/primitives/SideSheet.tsx b/packages/gitbook/src/components/primitives/SideSheet.tsx index de332afac..30c6c7836 100644 --- a/packages/gitbook/src/components/primitives/SideSheet.tsx +++ b/packages/gitbook/src/components/primitives/SideSheet.tsx @@ -7,14 +7,39 @@ 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'; - open?: boolean; + /** + * 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'; - onClose?: () => void; - withShim?: boolean; + /** + * 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 ) { @@ -25,33 +50,35 @@ export function SideSheet( toggleClass, open: openState, modal = 'mobile', - withShim, + withScrim, withCloseButton, - onClose, + 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); - // Use prop if provided (controlled), otherwise use internal state (uncontrolled) + // 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: notify parent - onClose?.(); + // Controlled mode: parent manages state, notify via callback with new state + onOpenChange?.(false); } else { - // Uncontrolled mode: update internal state + // Uncontrolled mode: update internal state and sync body class if needed setOpen(false); if (toggleClass) { document.body.classList.remove(toggleClass); } } - }, [openState, onClose, toggleClass]); + }, [openState, onOpenChange, toggleClass]); + // Sync the sheet state with the body class if the toggleClass is set React.useEffect(() => { if (!toggleClass) { return; @@ -62,17 +89,13 @@ export function SideSheet( if (mutation.attributeName === 'class') { const shouldBeOpen = document.body.classList.contains(toggleClass); if (openState !== undefined) { - // Controlled mode: notify parent if state should change + // Controlled mode: sync with parent's state + // Notify parent of state change via onOpenChange if (shouldBeOpen !== openState) { - if (shouldBeOpen) { - // Opening via class - no callback, just sync - // Parent should handle this via toggleClass observation - } else { - onClose?.(); - } + onOpenChange?.(shouldBeOpen); } } else { - // Uncontrolled mode: update internal state + // Uncontrolled mode: sync internal state with body class setOpen(shouldBeOpen); } } @@ -83,13 +106,17 @@ export function SideSheet( observer.observe(document.body, { attributes: true }); return () => observer.disconnect(); - }, [toggleClass, openState, onClose]); + }, [toggleClass, openState, onOpenChange]); return ( <> - {isModal && withShim ? ( - + {isModal && withScrim ? ( + ) : null} +