269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { Tooltip } from "@base-ui/react/tooltip";
|
|
import { PanelLeft } from "lucide-react";
|
|
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 setSidebarSurface: (element: HTMLElement | 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 openMobileRef = useRef(false);
|
|
const userChangedDesktopState = useRef(false);
|
|
const insetTriggerRef = useRef<HTMLButtonElement | null>(null);
|
|
const sidebarSurfaceRef = useRef<HTMLElement | null>(null);
|
|
const wasMobileOpen = useRef(false);
|
|
const isControlled = controlledOpen !== undefined;
|
|
const open = controlledOpen ?? uncontrolledOpen;
|
|
const isMobile = viewport === "mobile";
|
|
|
|
const commitOpen = useCallback((nextOpen: boolean) => {
|
|
userChangedDesktopState.current = true;
|
|
if (!isControlled) setUncontrolledOpen(nextOpen);
|
|
onOpenChange?.(nextOpen);
|
|
}, [isControlled, onOpenChange]);
|
|
|
|
const setOpen = useCallback((nextOpen: boolean) => {
|
|
commitOpen(nextOpen);
|
|
}, [commitOpen]);
|
|
|
|
const setOpenMobile = useCallback((nextOpen: boolean) => {
|
|
if (openMobileRef.current === nextOpen) return;
|
|
openMobileRef.current = nextOpen;
|
|
setOpenMobileState(nextOpen);
|
|
onMobileOpenChange?.(nextOpen);
|
|
}, [onMobileOpenChange]);
|
|
|
|
useEffect(() => {
|
|
if (ready && !isControlled && !userChangedDesktopState.current) {
|
|
setUncontrolledOpen(defaultSidebarOpen(viewport));
|
|
}
|
|
}, [isControlled, ready, viewport]);
|
|
|
|
useEffect(() => {
|
|
if (viewport === "mobile" || !openMobile) return;
|
|
const closeMobileDrawer = window.requestAnimationFrame(() => setOpenMobile(false));
|
|
return () => window.cancelAnimationFrame(closeMobileDrawer);
|
|
}, [openMobile, setOpenMobile, viewport]);
|
|
|
|
useEffect(() => {
|
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
if (shouldHandleSidebarShortcut(event)) {
|
|
event.preventDefault();
|
|
if (isMobile) setOpenMobile(!openMobile);
|
|
else commitOpen(!open);
|
|
}
|
|
if (event.key === "Escape" && isMobile && openMobile && !escapeBlocked) {
|
|
event.preventDefault();
|
|
setOpenMobile(false);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeydown);
|
|
return () => window.removeEventListener("keydown", handleKeydown);
|
|
}, [commitOpen, escapeBlocked, isMobile, open, openMobile, setOpenMobile]);
|
|
|
|
useEffect(() => {
|
|
if (openMobile && !wasMobileOpen.current) {
|
|
const focusDrawer = window.requestAnimationFrame(() => sidebarSurfaceRef.current?.focus());
|
|
wasMobileOpen.current = true;
|
|
return () => window.cancelAnimationFrame(focusDrawer);
|
|
}
|
|
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; },
|
|
setSidebarSurface: (element) => { sidebarSurfaceRef.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({ id, className, ...props }: ComponentProps<"aside">) {
|
|
const { isMobile, open, openMobile, setOpenMobile, setSidebarSurface } = useSidebarContext();
|
|
const sidebar = <aside id={id ?? "chat-sidebar"} 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)} tabIndex={-1} {...props} ref={setSidebarSurface} />;
|
|
return <>
|
|
{sidebar}
|
|
{isMobile && openMobile ? <button type="button" data-sidebar="scrim" data-slot="sidebar-scrim" aria-label="关闭聊天记录" className="sidebar-scrim" onClick={() => setOpenMobile(false)} /> : null}
|
|
</>;
|
|
}
|
|
|
|
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, toggleSidebar } = useSidebarContext();
|
|
const expanded = isMobile ? openMobile : open;
|
|
const label = isMobile
|
|
? openMobile ? "关闭聊天记录" : "打开聊天记录"
|
|
: open ? "收起侧边栏" : "展开侧边栏";
|
|
const register = (element: HTMLButtonElement | null) => {
|
|
setRef(ref, element);
|
|
if (placement === "inset") setInsetTrigger(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}>
|
|
<PanelLeft aria-hidden="true" />
|
|
</button>
|
|
);
|
|
});
|
|
|
|
export function SidebarRail({ className, onClick, ...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={(event) => { onClick?.(event); if (!event.defaultPrevented) setOpen(!open); }} {...props} />;
|
|
}
|