侧边栏
一个可组合、适配移动端的侧边栏组件。
安装
pnpm dlx shadcn@latest add @lumi-ui/sidebar
灵感来自 shadcn/ui,基于 react-resizable-panels 构建。
组件结构
<SidebarProvider>
<Sidebar>
<SidebarHeader />
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel />
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton />
<SidebarMenuSub>
<SidebarMenuSubItem />
</SidebarMenuSub>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarFooter />
</Sidebar>
<MainContent />
</SidebarProvider>配置项
侧边栏的行为由 sidebar.tsx 中的几个常量控制。你可以根据需要修改它们。
用法
CollapsibleType
你可以将 collapsibleType 属性设置为 icon 或 sidebar,以控制侧边栏的折叠方式。ICON_MODE_WIDTH_PX 是侧边栏在图标模式下的宽度。如果设置为 sidebar,侧边栏在折叠时会被完全隐藏。详见 示例。
与 Next.js 配合
为了避免水合不匹配并在刷新之间保持侧边栏状态,建议在服务端从 cookie 中读取状态,并将其传给 SidebarProvider。
import { AppSidebar } from "@/components/app-sidebar"
import {
MainContent,
SidebarProvider,
} from "@components/ui/sidebar"
import { cookies } from "next/headers"
import type { Layout } from "react-resizable-panels"
const GROUP_ID = "main-layout-persistence";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const layoutCookie = cookieStore.get(GROUP_ID);
const defaultLayout: Layout = layoutCookie
? JSON.parse(layoutCookie.value)
: undefined;
return (
<SidebarProvider
collapsibleType="icon"
defaultLayout={defaultLayout}
groupId={GROUP_ID}
>
<AppSidebar />
<MainContent>{children}</MainContent>
</SidebarProvider>
)
}与 TanStack Start 配合
import { createServerFn } from "@tanstack/react-start";
import { getCookie } from "@tanstack/react-start/server";
import type { Layout } from "react-resizable-panels";
const GROUP_ID = "main-layout-persistence";
export const getSidebarLayout = createServerFn({ method: "GET" }).handler(
async () => {
const raw = getCookie(GROUP_ID);
if (!raw) return undefined;
try {
return JSON.parse(raw) as Layout;
} catch {
return undefined;
}
},
);
function getCookieValue(name: string): string | undefined {
if (typeof document === "undefined") return undefined;
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const m = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]*)`));
return m ? m[1] : undefined;
}
export function getSidebarLayoutClient(): Layout | undefined {
const raw = getCookieValue(GROUP_ID);
if (!raw) return undefined;
try {
return JSON.parse(decodeURIComponent(raw)) as Layout;
} catch {
return undefined;
}
}import { AppSidebar } from "@/components/app-sidebar";
import { MainContent, SidebarProvider } from "@/components/ui/sidebar";
import { getSidebarLayout, getSidebarLayoutClient } from "@/utils";
import { createFileRoute, Outlet } from "@tanstack/react-router";
const GROUP_ID = "main-layout-persistence";
export const Route = createFileRoute("/dashboard/")({
component: RouteComponent,
beforeLoad: async () => {
if (typeof document === "undefined") {
return { defaultLayout: await getSidebarLayout() };
} else {
return { defaultLayout: getSidebarLayoutClient() };
}
},
});
function RouteComponent() {
const { defaultLayout } = Route.useRouteContext();
return (
<SidebarProvider
collapsibleType="icon"
groupId={GROUP_ID}
defaultLayout={defaultLayout}
>
<AppSidebar />
<MainContent>
<Outlet />
</MainContent>
</SidebarProvider>
);
}
配合客户端 Cookie 使用
将 SidebarProvider 替换为以下代码:
const SidebarProvider = ({
collapsibleType = "icon",
children,
groupId,
}: {
children: ReactNode;
collapsibleType?: Collapsible;
groupId: string;
}) => {
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: groupId,
storage: localStorage,
});
const isMobile = useIsMobile();
const sidebarRef = usePanelRef();
const mobileHandle = useMemo(() => createSheetHandle(), []);
const collapsedSize = collapsibleType === "icon" ? ICON_MODE_WIDTH_PX : 0;
// 图标模式
const [isCollapsed, setIsCollapsed] = useState(() => {
// 没有 cookie 时,默认展开
if (!defaultLayout || !defaultLayout.sidebar) return false;
// cookie 中保存的是百分比值(0-100)
const savedPercentage = defaultLayout.sidebar;
// 与安全阈值进行比较
return savedPercentage < COLLAPSED_PERCENTAGE_THRESHOLD;
});
const toggleSidebar = React.useCallback(() => {
const panel = sidebarRef.current;
if (!panel) return;
if (panel.isCollapsed()) {
panel.expand();
} else {
panel.collapse();
}
}, [sidebarRef]);
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
return (
<SidebarContext.Provider
value={{
collapsedSize,
collapsibleType,
isCollapsed,
isMobile,
mobileHandle,
setIsCollapsed,
sidebarRef,
toggleSidebar,
}}
>
<TooltipProvider>
<div className="h-screen w-screen overflow-hidden">
<ResizableGroup
data-slot="sidebar-wrapper"
id={groupId}
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
orientation="horizontal"
resizeTargetMinimumSize={{ coarse: 37, fine: 27 }}
>
{children}
</ResizableGroup>
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
};API 参考
SidebarProvider
渲染一个 <div>,用于包裹整个应用布局并处理状态管理。
Sidebar
在桌面端渲染为 ResizablePanel(即一个 <div>),在移动端渲染为 Sheet。
SidebarMenuButton
默认渲染为 <button>,或使用传入的 asChild。