feat: add composable sidebar primitives
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user