feat: add composable sidebar primitives

This commit is contained in:
Jesse_Chen
2026-07-17 17:17:10 +08:00
parent 5aac5fd95d
commit c45f21e8d8
6 changed files with 406 additions and 0 deletions
+11
View File
@@ -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.
+247
View File
@@ -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<SidebarProviderContextValue | null>(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<HTMLButtonElement | null>(null);
const sidebarTriggerRef = useRef<HTMLButtonElement | null>(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 (
<SidebarContext.Provider value={context}>
<div
data-mobile-open={openMobile}
data-ready={ready}
data-state={context.state}
data-viewport={viewport}
className={cn("group/sidebar-provider flex min-h-0 w-full", className)}
{...props}
/>
</SidebarContext.Provider>
);
}
export function Sidebar({ className, ...props }: ComponentProps<"aside">) {
const { open, openMobile } = useSidebar();
return <aside data-sidebar="sidebar" data-slot="sidebar" data-state={open ? "expanded" : "collapsed"} data-mobile-open={openMobile} className={cn("flex min-h-0 shrink-0 flex-col", className)} {...props} />;
}
export function SidebarHeader({ className, ...props }: ComponentProps<"div">) {
return <div data-sidebar="header" data-slot="sidebar-header" className={cn("shrink-0", className)} {...props} />;
}
export function SidebarContent({ className, ...props }: ComponentProps<"div">) {
return <div data-sidebar="content" data-slot="sidebar-content" className={cn("min-h-0 flex-1 overflow-y-auto", className)} {...props} />;
}
export function SidebarGroup({ className, ...props }: ComponentProps<"section">) {
return <section data-sidebar="group" data-slot="sidebar-group" className={cn("min-w-0", className)} {...props} />;
}
export function SidebarGroupLabel({ className, ...props }: ComponentProps<"h2">) {
return <h2 data-sidebar="group-label" data-slot="sidebar-group-label" className={cn("min-h-11", className)} {...props} />;
}
export function SidebarGroupContent({ className, ...props }: ComponentProps<"div">) {
return <div data-sidebar="group-content" data-slot="sidebar-group-content" className={cn("min-w-0", className)} {...props} />;
}
export function SidebarMenu({ className, ...props }: ComponentProps<"ul">) {
return <ul data-sidebar="menu" data-slot="sidebar-menu" className={cn("grid min-w-0 gap-1", className)} {...props} />;
}
export function SidebarMenuItem({ className, ...props }: ComponentProps<"li">) {
return <li data-sidebar="menu-item" data-slot="sidebar-menu-item" className={cn("min-w-0", className)} {...props} />;
}
export function SidebarMenuButton({ className, isActive = false, tooltip, ...props }: SidebarMenuButtonProps) {
const { isMobile, state } = useSidebar();
const button = <button data-sidebar="menu-button" data-slot="sidebar-menu-button" data-active={isActive} className={cn("min-h-11 w-full", className)} {...props} />;
if (tooltip === undefined || state !== "collapsed" || isMobile) return button;
return <Tooltip.Root><Tooltip.Trigger render={button} /><Tooltip.Portal><Tooltip.Positioner side="right" sideOffset={8}><Tooltip.Popup>{tooltip}</Tooltip.Popup></Tooltip.Positioner></Tooltip.Portal></Tooltip.Root>;
}
export function SidebarFooter({ className, ...props }: ComponentProps<"div">) {
return <div data-sidebar="footer" data-slot="sidebar-footer" className={cn("shrink-0", className)} {...props} />;
}
export function SidebarInset({ className, inert, ...props }: ComponentProps<"section">) {
const { isMobile, openMobile } = useSidebar();
return <section data-sidebar="inset" data-slot="sidebar-inset" inert={Boolean(inert) || (isMobile && openMobile)} className={cn("min-w-0 flex-1", className)} {...props} />;
}
function setRef(ref: ForwardedRef<HTMLButtonElement>, element: HTMLButtonElement | null): void {
if (typeof ref === "function") ref(element);
else if (ref !== null) ref.current = element;
}
export const SidebarTrigger = forwardRef<HTMLButtonElement, SidebarTriggerProps>(function SidebarTrigger({
placement = "inset",
className,
onClick,
...props
}, ref) {
const { isMobile, open, openMobile, setInsetTrigger, setSidebarTrigger, toggleSidebar } = useSidebarContext();
const expanded = isMobile ? openMobile : open;
const label = expanded ? "Close sidebar" : "Open sidebar";
const register = (element: HTMLButtonElement | null) => {
setRef(ref, element);
if (placement === "inset") setInsetTrigger(element);
else setSidebarTrigger(element);
};
return <button ref={register} type="button" data-sidebar="trigger" data-slot="sidebar-trigger" aria-label={label} aria-expanded={expanded} className={cn("min-h-11 min-w-11", className)} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented) toggleSidebar(); }} {...props} />;
});
export function SidebarRail({ className, ...props }: ComponentProps<"button">) {
const { isMobile, open, setOpen } = useSidebar();
if (isMobile) return null;
const label = open ? "Collapse sidebar" : "Expand sidebar";
return <button type="button" data-sidebar="rail" data-slot="sidebar-rail" aria-label={label} className={cn("min-h-11 min-w-11", className)} onClick={() => setOpen(!open)} {...props} />;
}
@@ -0,0 +1,31 @@
"use client";
import { useEffect, useState } from "react";
import { sidebarViewportForWidth, type SidebarViewport } from "@/lib/sidebar-state";
type SidebarViewportState = {
readonly viewport: SidebarViewport;
readonly ready: boolean;
};
const initialViewportState: SidebarViewportState = {
viewport: "desktop",
ready: false,
};
export function useSidebarViewport(): SidebarViewportState {
const [state, setState] = useState(initialViewportState);
useEffect(() => {
const updateViewport = () => {
setState({ viewport: sidebarViewportForWidth(window.innerWidth), ready: true });
};
updateViewport();
window.addEventListener("resize", updateViewport);
return () => window.removeEventListener("resize", updateViewport);
}, []);
return state;
}
+39
View File
@@ -0,0 +1,39 @@
export type SidebarViewport = "mobile" | "tablet" | "desktop";
type EditableTarget = {
readonly tagName?: unknown;
readonly isContentEditable?: unknown;
};
export type SidebarShortcutEvent = Pick<
KeyboardEvent,
"key" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey"
> & {
readonly target: EventTarget | EditableTarget | null;
};
function isEditableTarget(target: SidebarShortcutEvent["target"]): target is EditableTarget {
return target !== null && typeof target === "object";
}
export function sidebarViewportForWidth(width: number): SidebarViewport {
if (width < 768) return "mobile";
if (width < 1024) return "tablet";
return "desktop";
}
export function defaultSidebarOpen(viewport: SidebarViewport): boolean {
return viewport === "desktop";
}
export function shouldHandleSidebarShortcut(event: SidebarShortcutEvent): boolean {
const target = event.target;
const editable = isEditableTarget(target)
&& (target.isContentEditable === true || /^(INPUT|TEXTAREA|SELECT)$/.test(String(target.tagName ?? "")));
return !editable
&& event.key.toLowerCase() === "b"
&& (event.metaKey || event.ctrlKey)
&& !event.altKey
&& !event.shiftKey;
}
+48
View File
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
const projectFile = (path: string) => new URL(`../${path}`, import.meta.url);
const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8");
test("provides the generic composable sidebar primitive", () => {
assert.equal(existsSync(projectFile("src/components/ui/sidebar.tsx")), true);
});
test("exports only the retained sidebar composition surface", () => {
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
for (const name of [
"SidebarProvider", "Sidebar", "SidebarHeader", "SidebarContent",
"SidebarGroup", "SidebarGroupLabel", "SidebarGroupContent",
"SidebarMenu", "SidebarMenuItem", "SidebarMenuButton",
"SidebarFooter", "SidebarInset", "SidebarTrigger", "SidebarRail", "useSidebar",
]) {
assert.match(sidebar, new RegExp(`export (?:function|const) ${name}\\b`));
}
assert.doesNotMatch(sidebar, /SidebarMenuBadge|SidebarMenuSkeleton|SidebarMenuSub|side\?:|variant\?:/);
});
test("keeps sidebar behavior in the generic primitive", () => {
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
assert.match(sidebar, /useSidebarViewport/);
assert.match(sidebar, /defaultSidebarOpen/);
assert.match(sidebar, /shouldHandleSidebarShortcut/);
assert.match(sidebar, /data-state/);
assert.match(sidebar, /data-viewport/);
assert.match(sidebar, /data-mobile-open/);
assert.match(sidebar, /addEventListener\("keydown"/);
assert.match(sidebar, /preventDefault\(\)/);
assert.match(sidebar, /const label = expanded \? "Close sidebar" : "Open sidebar"/);
assert.match(sidebar, /viewport === "mobile"\) return;[\s\S]*setOpenMobile\(false\)/);
assert.match(sidebar, /@base-ui\/react\/tooltip/);
assert.doesNotMatch(sidebar, /Sheet/);
assert.match(sidebar, /cn\(/);
assert.doesNotMatch(sidebar, /#[0-9a-fA-F]{3,8}|hsl\(/);
});
test("documents the sidebar shell design contract", () => {
const design = readProjectFile("DESIGN.md");
assert.match(design, /### Sidebar shell/);
assert.match(design, /Scroll ownership/);
assert.match(design, /session-local/);
});
+30
View File
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
defaultSidebarOpen,
shouldHandleSidebarShortcut,
sidebarViewportForWidth,
} from "../src/lib/sidebar-state.ts";
test("classifies the exact sidebar breakpoints", () => {
assert.equal(sidebarViewportForWidth(0), "mobile");
assert.equal(sidebarViewportForWidth(767), "mobile");
assert.equal(sidebarViewportForWidth(768), "tablet");
assert.equal(sidebarViewportForWidth(1023), "tablet");
assert.equal(sidebarViewportForWidth(1024), "desktop");
});
test("uses the approved reload defaults", () => {
assert.equal(defaultSidebarOpen("mobile"), false);
assert.equal(defaultSidebarOpen("tablet"), false);
assert.equal(defaultSidebarOpen("desktop"), true);
});
test("accepts Command or Control B only outside editable controls", () => {
const shortcut = { key: "b", metaKey: true, ctrlKey: false, altKey: false, shiftKey: false };
assert.equal(shouldHandleSidebarShortcut({ ...shortcut, target: null }), true);
assert.equal(shouldHandleSidebarShortcut({ ...shortcut, target: { tagName: "TEXTAREA" } }), false);
assert.equal(shouldHandleSidebarShortcut({ ...shortcut, target: { isContentEditable: true } }), false);
assert.equal(shouldHandleSidebarShortcut({ ...shortcut, key: "k", target: null }), false);
});