diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index f73c1cc4..6746ecc9 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -118,6 +118,17 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: - **Request behavior:** existing sessions remain selectable for reading while a request is active; creating or sending another request stays locked until the active request settles. - **Surface:** translucent warm-gray sidebar; current uses a white glass surface and deep-brown marker. +### Sidebar shell + +- **Composition:** provider, fixed header, one scroll-owning content region, fixed footer, trigger, rail, and flexible chat inset. +- **Desktop:** 288px expanded by default at 1024px and above; 64px collapsed icon rail. +- **Tablet:** 64px collapsed by default from 768px through 1023px; 240px when expanded. +- **Mobile:** no icon rail; an off-canvas drawer uses `min(86vw, 320px)` and closes through its scrim, trigger, or Escape. +- **Collapsed content:** logo, new-chat action, one history expansion action, and account avatar. Individual sessions do not become indistinguishable repeated icons. +- **Scroll ownership:** header and footer remain fixed; `SidebarContent` is the sole sidebar scroll owner. +- **Accessibility:** Command/Control+B shortcut outside editable controls, contextual trigger labels, 44px targets, focus return, collapsed-only tooltips, reduced-motion, reduced-transparency, and increased-contrast support. +- **State:** session-local; reload uses breakpoint defaults rather than cookie or local-storage persistence. + ### Message - **Variants:** assistant editorial text on canvas; user text on warm card surface; streaming; error. diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx new file mode 100644 index 00000000..0e0aa519 --- /dev/null +++ b/frontend/src/components/ui/sidebar.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { Tooltip } from "@base-ui/react/tooltip"; +import { + createContext, + forwardRef, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ComponentProps, + type ForwardedRef, +} from "react"; + +import { useSidebarViewport } from "@/hooks/use-sidebar-viewport"; +import { + defaultSidebarOpen, + shouldHandleSidebarShortcut, + type SidebarViewport, +} from "@/lib/sidebar-state"; +import { cn } from "@/lib/utils"; + +export type SidebarState = "expanded" | "collapsed"; + +export type SidebarContextValue = { + readonly state: SidebarState; + readonly open: boolean; + readonly setOpen: (open: boolean) => void; + readonly openMobile: boolean; + readonly setOpenMobile: (open: boolean) => void; + readonly viewport: SidebarViewport; + readonly ready: boolean; + readonly isMobile: boolean; + readonly toggleSidebar: () => void; +}; + +type SidebarProviderContextValue = SidebarContextValue & { + readonly setInsetTrigger: (element: HTMLButtonElement | null) => void; + readonly setSidebarTrigger: (element: HTMLButtonElement | null) => void; +}; + +export type SidebarProviderProps = ComponentProps<"div"> & { + readonly defaultOpen?: boolean; + readonly open?: boolean; + readonly onOpenChange?: (open: boolean) => void; + readonly onMobileOpenChange?: (open: boolean) => void; + readonly escapeBlocked?: boolean; +}; + +export type SidebarTriggerProps = ComponentProps<"button"> & { + readonly placement?: "inset" | "sidebar"; +}; + +type SidebarMenuButtonProps = ComponentProps<"button"> & { + readonly isActive?: boolean; + readonly tooltip?: string; +}; + +const SidebarContext = createContext(null); + +function useSidebarContext(): SidebarProviderContextValue { + const context = useContext(SidebarContext); + if (context === null) throw new Error("Sidebar components must be used within SidebarProvider"); + return context; +} + +export function useSidebar(): SidebarContextValue { + return useSidebarContext(); +} + +export function SidebarProvider({ + className, + defaultOpen = true, + open: controlledOpen, + onOpenChange, + onMobileOpenChange, + escapeBlocked = false, + ...props +}: SidebarProviderProps) { + const { viewport, ready } = useSidebarViewport(); + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const [openMobile, setOpenMobileState] = useState(false); + const userChangedDesktopState = useRef(false); + const insetTriggerRef = useRef(null); + const sidebarTriggerRef = useRef(null); + const wasMobileOpen = useRef(false); + const isControlled = controlledOpen !== undefined; + const open = controlledOpen ?? uncontrolledOpen; + const isMobile = viewport === "mobile"; + + const setOpen = useCallback((nextOpen: boolean) => { + userChangedDesktopState.current = true; + if (!isControlled) setUncontrolledOpen(nextOpen); + onOpenChange?.(nextOpen); + }, [isControlled, onOpenChange]); + + const setOpenMobile = useCallback((nextOpen: boolean) => { + setOpenMobileState(nextOpen); + onMobileOpenChange?.(nextOpen); + }, [onMobileOpenChange]); + + useEffect(() => { + if (ready && !isControlled && !userChangedDesktopState.current) { + setUncontrolledOpen(defaultSidebarOpen(viewport)); + } + }, [isControlled, ready, viewport]); + + useEffect(() => { + if (viewport === "mobile") return; + const closeMobileDrawer = window.requestAnimationFrame(() => setOpenMobile(false)); + return () => window.cancelAnimationFrame(closeMobileDrawer); + }, [setOpenMobile, viewport]); + + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if (shouldHandleSidebarShortcut(event)) { + event.preventDefault(); + if (isMobile) setOpenMobile(!openMobile); + else setOpen(!open); + } + if (event.key === "Escape" && isMobile && openMobile && !escapeBlocked) { + event.preventDefault(); + setOpenMobile(false); + } + }; + + window.addEventListener("keydown", handleKeydown); + return () => window.removeEventListener("keydown", handleKeydown); + }, [escapeBlocked, isMobile, open, openMobile, setOpen, setOpenMobile]); + + useEffect(() => { + if (openMobile && !wasMobileOpen.current) window.requestAnimationFrame(() => sidebarTriggerRef.current?.focus()); + if (!openMobile && wasMobileOpen.current) insetTriggerRef.current?.focus(); + wasMobileOpen.current = openMobile; + }, [openMobile]); + + const toggleSidebar = useCallback(() => { + if (isMobile) setOpenMobile(!openMobile); + else setOpen(!open); + }, [isMobile, open, openMobile, setOpen, setOpenMobile]); + + const context: SidebarProviderContextValue = { + state: open ? "expanded" : "collapsed", + open, + setOpen, + openMobile, + setOpenMobile, + viewport, + ready, + isMobile, + toggleSidebar, + setInsetTrigger: (element) => { insetTriggerRef.current = element; }, + setSidebarTrigger: (element) => { sidebarTriggerRef.current = element; }, + }; + + return ( + +
+ + ); +} + +export function Sidebar({ className, ...props }: ComponentProps<"aside">) { + const { open, openMobile } = useSidebar(); + return