布局
Sidebar
Composed sidebar with header, search, and history list
问学
历史对话
User
"use client";
import * as React from "react";
import { SidebarComposed } from "@/components/composed/sidebar/sidebar";
import { AvatarHeader } from "@/components/composed/avatar-header/avatar-header";
import { Button } from "@/components/ui/button";
import { Menu, Plus, Search, Sparkles, Trash2 } from "lucide-react";
const DEMO_CONVERSATIONS_UPDATED_AT = 1_700_000_000_000;
export function SidebarDemo() {
const [searchQuery, setSearchQuery] = React.useState("");
const [conversations] = React.useState([
{
id: "1",
title: "如何学习 React",
updatedAt: DEMO_CONVERSATIONS_UPDATED_AT,
},
{
id: "2",
title: "TypeScript 最佳实践",
updatedAt: DEMO_CONVERSATIONS_UPDATED_AT,
},
{
id: "3",
title: "前端性能优化技巧",
updatedAt: DEMO_CONVERSATIONS_UPDATED_AT,
},
]);
const [currentConversationId, setCurrentConversationId] = React.useState<
string | null
>("1");
const filteredConversations = React.useMemo(() => {
if (!searchQuery) return conversations;
return conversations.filter((conv) =>
conv.title.toLowerCase().includes(searchQuery.toLowerCase()),
);
}, [conversations, searchQuery]);
return (
<div className="w-full max-w-[240px] h-[600px] border border-[var(--Border-border-neutral)] rounded-lg overflow-hidden">
<div className="h-full p-[var(--Padding-padding-com-lg)] bg-[var(--Page-bg-page-secondary)]">
<SidebarComposed
header={{
title: "问学",
icon: <Sparkles className="size-4" />,
action: (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="展开/收起侧边栏"
className="hover:bg-[var(--Container-bg-neutral-light)] text-[var(--Text-text-secondary)]"
>
<Menu className="size-4" />
</Button>
),
}}
newButton={{
label: "新对话",
icon: <Plus className="size-4" />,
onClick: () => {
console.log("创建新对话");
},
}}
search={{
value: searchQuery,
onChange: setSearchQuery,
placeholder: "搜索",
icon: <Search className="size-4" />,
}}
historyTitle="历史对话"
conversations={filteredConversations.map((conv) => {
const isSelected = conv.id === currentConversationId;
return {
id: conv.id,
title: conv.title,
onClick: () => {
setCurrentConversationId(conv.id);
console.log("切换到对话:", conv.id);
},
hoverTrailing: !isSelected ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
console.log("删除对话:", conv.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
e.preventDefault();
console.log("删除对话:", conv.id);
}
}}
className="inline-flex items-center justify-center h-6 w-6 rounded-md hover:bg-[var(--Container-bg-neutral-light-hover)] text-[var(--Text-text-secondary)] cursor-pointer transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--ring)] focus:ring-offset-1"
aria-label="删除对话"
>
<Trash2 className="size-3" />
</span>
) : undefined,
};
})}
selectedId={currentConversationId}
emptyText={searchQuery ? "未找到匹配的对话" : "暂无对话历史"}
footer={<AvatarHeader name="User" />}
/>
</div>
</div>
);
}
Sidebar 是组合组件,开箱即用:默认包含头部、新建按钮、历史对话区域,并支持展开/收起。适合直接用于对话列表、消息历史等场景;需要深度定制时可使用 primitives 自由组合。
概述
- 开箱即用:默认配置即可使用,零配置快速开始
- 高度可定制:支持自定义头部、按钮、搜索、底部等所有区域
- 折叠功能:内置展开/收起能力,支持受控和非受控模式
- 搜索过滤:内置搜索功能,支持对话列表过滤
- 原语组合:基于灵活的原语组件构建,可自由组合
快速开始
最小可用示例(无需额外配置):
import { SidebarComposed } from "@/registry/wuhan/composed/sidebar/sidebar";
export function Example() {
return (
<SidebarComposed
conversations={[
{ id: "1", title: "Conversation 1" },
{ id: "2", title: "Conversation 2" },
]}
/>
);
}特性
- 完整功能:头部、新建按钮、搜索框、历史列表、底部区域
- 配置灵活:每个区域都可自定义或隐藏(传
null) - 状态管理:支持受控和非受控的选中状态、折叠状态
- 空状态:内置空状态和搜索无结果提示
- 响应式:折叠时自动调整布局和显示内容
安装
代码演示
完整侧边栏
完整的侧边栏示例,包含所有部分:
问学
历史对话
User
"use client";
import * as React from "react";
import { SidebarComposed } from "@/components/composed/sidebar/sidebar";
import { AvatarHeader } from "@/components/composed/avatar-header/avatar-header";
import { Button } from "@/components/ui/button";
import { Menu, Plus, Search, Sparkles, Trash2 } from "lucide-react";
const DEMO_CONVERSATIONS_UPDATED_AT = 1_700_000_000_000;
export function SidebarDemo() {
const [searchQuery, setSearchQuery] = React.useState("");
const [conversations] = React.useState([
{
id: "1",
title: "如何学习 React",
updatedAt: DEMO_CONVERSATIONS_UPDATED_AT,
},
{
id: "2",
title: "TypeScript 最佳实践",
updatedAt: DEMO_CONVERSATIONS_UPDATED_AT,
},
{
id: "3",
title: "前端性能优化技巧",
updatedAt: DEMO_CONVERSATIONS_UPDATED_AT,
},
]);
const [currentConversationId, setCurrentConversationId] = React.useState<
string | null
>("1");
const filteredConversations = React.useMemo(() => {
if (!searchQuery) return conversations;
return conversations.filter((conv) =>
conv.title.toLowerCase().includes(searchQuery.toLowerCase()),
);
}, [conversations, searchQuery]);
return (
<div className="w-full max-w-[240px] h-[600px] border border-[var(--Border-border-neutral)] rounded-lg overflow-hidden">
<div className="h-full p-[var(--Padding-padding-com-lg)] bg-[var(--Page-bg-page-secondary)]">
<SidebarComposed
header={{
title: "问学",
icon: <Sparkles className="size-4" />,
action: (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="展开/收起侧边栏"
className="hover:bg-[var(--Container-bg-neutral-light)] text-[var(--Text-text-secondary)]"
>
<Menu className="size-4" />
</Button>
),
}}
newButton={{
label: "新对话",
icon: <Plus className="size-4" />,
onClick: () => {
console.log("创建新对话");
},
}}
search={{
value: searchQuery,
onChange: setSearchQuery,
placeholder: "搜索",
icon: <Search className="size-4" />,
}}
historyTitle="历史对话"
conversations={filteredConversations.map((conv) => {
const isSelected = conv.id === currentConversationId;
return {
id: conv.id,
title: conv.title,
onClick: () => {
setCurrentConversationId(conv.id);
console.log("切换到对话:", conv.id);
},
hoverTrailing: !isSelected ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
console.log("删除对话:", conv.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
e.preventDefault();
console.log("删除对话:", conv.id);
}
}}
className="inline-flex items-center justify-center h-6 w-6 rounded-md hover:bg-[var(--Container-bg-neutral-light-hover)] text-[var(--Text-text-secondary)] cursor-pointer transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--ring)] focus:ring-offset-1"
aria-label="删除对话"
>
<Trash2 className="size-3" />
</span>
) : undefined,
};
})}
selectedId={currentConversationId}
emptyText={searchQuery ? "未找到匹配的对话" : "暂无对话历史"}
footer={<AvatarHeader name="User" />}
/>
</div>
</div>
);
}
自定义头部
自定义头部内容,包括图标、标题和操作按钮:
消息中心
历史对话
"use client";
import * as React from "react";
import { SidebarComposed } from "@/components/composed/sidebar/sidebar";
import { MessageSquare, Settings } from "lucide-react";
export function SidebarCustomHeader() {
const [selected, setSelected] = React.useState("1");
const conversations = [
{ id: "1", title: "项目讨论", onClick: () => setSelected("1") },
{ id: "2", title: "技术方案", onClick: () => setSelected("2") },
{ id: "3", title: "需求评审", onClick: () => setSelected("3") },
];
return (
<div className="w-[240px] h-[500px]">
<SidebarComposed
header={{
title: "消息中心",
icon: <MessageSquare className="size-4" />,
action: (
<button
type="button"
className="inline-flex items-center justify-center h-8 w-8 rounded-md hover:bg-[var(--Container-bg-neutral-light-hover)] text-[var(--Text-text-secondary)] transition-colors"
aria-label="设置"
>
<Settings className="size-4" />
</button>
),
}}
conversations={conversations}
selectedId={selected}
/>
</div>
);
}
自定义新建按钮
自定义新建按钮的文本、图标和点击行为:
对话
最近对话
"use client";
import * as React from "react";
import { SidebarComposed } from "@/components/composed/sidebar/sidebar";
import { Plus } from "lucide-react";
export function SidebarCustomNewButton() {
const [selected, setSelected] = React.useState("1");
const [conversations, setConversations] = React.useState([
{ id: "1", title: "对话 1" },
{ id: "2", title: "对话 2" },
]);
const handleCreate = () => {
const newId = String(conversations.length + 1);
setConversations([{ id: newId, title: `对话 ${newId}` }, ...conversations]);
setSelected(newId);
};
return (
<div className="w-[240px] h-[500px]">
<SidebarComposed
newButton={{
label: "开始新对话",
icon: <Plus className="size-4" />,
onClick: handleCreate,
}}
historyTitle="最近对话"
conversations={conversations.map((conv) => ({
id: conv.id,
title: conv.title,
onClick: () => setSelected(conv.id),
}))}
selectedId={selected}
/>
</div>
);
}
带搜索功能
带搜索框的历史对话区域,支持实时过滤:
对话
历史对话
"use client";
import * as React from "react";
import { SidebarComposed } from "@/components/composed/sidebar/sidebar";
import { Search } from "lucide-react";
const ALL_CONVERSATIONS = [
{ id: "1", title: "如何学习 React" },
{ id: "2", title: "TypeScript 最佳实践" },
{ id: "3", title: "CSS Grid 布局" },
{ id: "4", title: "Next.js 路由配置" },
{ id: "5", title: "前端性能优化" },
] as const;
export function SidebarWithSearch() {
const [selected, setSelected] = React.useState("1");
const [searchQuery, setSearchQuery] = React.useState("");
const filteredConversations = React.useMemo(() => {
if (!searchQuery) return [...ALL_CONVERSATIONS];
return ALL_CONVERSATIONS.filter((conv) =>
conv.title.toLowerCase().includes(searchQuery.toLowerCase()),
);
}, [searchQuery]);
return (
<div className="w-[240px] h-[500px]">
<SidebarComposed
search={{
value: searchQuery,
onChange: setSearchQuery,
placeholder: "搜索",
icon: <Search className="size-4" />,
}}
conversations={filteredConversations.map((conv) => ({
id: conv.id,
title: conv.title,
onClick: () => setSelected(conv.id),
}))}
selectedId={selected}
emptyText="未找到匹配的对话"
/>
</div>
);
}
空状态
无对话历史时的空状态展示:
对话
历史对话
暂无对话历史
"use client";
import * as React from "react";
import { SidebarComposed } from "@/components/composed/sidebar/sidebar";
export function SidebarEmptyState() {
return (
<div className="w-[240px] h-[500px]">
<SidebarComposed conversations={[]} emptyText="暂无对话历史" />
</div>
);
}
受控折叠
通过外部状态控制侧边栏的展开/收起:
对话
历史对话
"use client";
import * as React from "react";
import { SidebarComposed } from "@/components/composed/sidebar/sidebar";
import { Button } from "@/components/ui/button";
export function SidebarControlledCollapse() {
const [collapsed, setCollapsed] = React.useState(false);
const [selected, setSelected] = React.useState("1");
const conversations = [
{ id: "1", title: "React 学习笔记" },
{ id: "2", title: "项目架构设计" },
{ id: "3", title: "代码审查要点" },
];
return (
<div className="flex gap-4 items-start">
<div className="flex flex-col gap-2">
<Button onClick={() => setCollapsed(!collapsed)} size="sm">
{collapsed ? "展开" : "收起"}
</Button>
</div>
<div
className={`h-[500px] border border-[var(--Border-border-neutral)] rounded-lg overflow-hidden transition-all ${
collapsed ? "w-[56px]" : "w-[240px]"
}`}
>
<SidebarComposed
collapsed={collapsed}
onCollapsedChange={setCollapsed}
conversations={conversations.map((conv) => ({
id: conv.id,
title: conv.title,
onClick: () => setSelected(conv.id),
}))}
selectedId={selected}
/>
</div>
</div>
);
}
根据折叠状态渲染底部
底部内容根据折叠状态显示不同样式:
对话
历史对话
用户名在线
"use client";
import * as React from "react";
import { SidebarComposed } from "@/components/composed/sidebar/sidebar";
import { AvatarHeader } from "@/components/composed/avatar-header/avatar-header";
export function SidebarFooterCollapse() {
const [selected, setSelected] = React.useState("1");
const conversations = [
{ id: "1", title: "对话 1" },
{ id: "2", title: "对话 2" },
{ id: "3", title: "对话 3" },
];
return (
<div className="w-[240px] h-[500px] border border-[var(--Border-border-neutral)] rounded-lg overflow-hidden">
<SidebarComposed
conversations={conversations.map((conv) => ({
id: conv.id,
title: conv.title,
onClick: () => setSelected(conv.id),
}))}
selectedId={selected}
footer={({ collapsed }) =>
collapsed ? (
<div className="flex justify-center">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-400 to-purple-500" />
</div>
) : (
<AvatarHeader name="用户名" time="在线" />
)
}
/>
</div>
);
}
API
SidebarComposed
组合侧边栏主组件,提供完整的侧边栏功能。
Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
conversations | SidebarConversation[] | - | 历史列表数据(必传) |
selectedId | string | null | - | 当前选中项 id |
historyTitle | React.ReactNode | "历史对话" | 历史标题 |
emptyText | React.ReactNode | "暂无对话历史" | 空状态文案 |
header | SidebarHeaderConfig | null | 内置默认 | 头部配置;传 null 隐藏 |
newButton | SidebarNewButtonConfig | null | 内置默认 | 新建按钮配置;传 null 隐藏 |
search | SidebarSearchConfig | null | 内置默认 | 搜索配置;传 null 隐藏 |
footer | React.ReactNode | (({ collapsed }) => ReactNode) | null | - | 底部内容;函数可获取折叠状态 |
collapsible | boolean | true | 是否显示/启用折叠按钮 |
collapsed | boolean | - | 受控折叠状态 |
defaultCollapsed | boolean | false | 非受控初始折叠状态 |
onCollapsedChange | (collapsed: boolean) => void | - | 折叠状态变化回调 |
className | string | - | 外层容器样式扩展 |
contentClassName | string | - | 内容区域样式扩展 |
Example
import { SidebarComposed } from "@/registry/wuhan/composed/sidebar/sidebar";
function MySidebar() {
const [selected, setSelected] = React.useState("1");
return (
<SidebarComposed
conversations={[
{ id: "1", title: "对话 1", onClick: () => setSelected("1") },
{ id: "2", title: "对话 2", onClick: () => setSelected("2") },
]}
selectedId={selected}
header={{
title: "我的对话",
icon: <MessageIcon />,
}}
newButton={{
label: "新建对话",
onClick: () => console.log("create"),
}}
/>
);
}配置类型
SidebarHeaderConfig
头部配置对象。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
title | React.ReactNode | "对话" | 标题内容 |
icon | React.ReactNode | Sparkles | 标题左侧图标 |
action | React.ReactNode | null | 内置折叠按钮 | 右侧操作;传 null 隐藏 |
SidebarNewButtonConfig
新建按钮配置对象。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
label | React.ReactNode | "新对话" | 按钮文本 |
icon | React.ReactNode | Plus | 按钮图标 |
onClick | () => void | - | 点击回调 |
SidebarSearchConfig
搜索配置对象。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
value | string | "" | 输入值 |
onChange | (value: string) => void | - | 输入变化回调 |
placeholder | string | "搜索" | 占位文案 |
icon | React.ReactNode | Search | 搜索图标 |
SidebarConversation
对话项数据类型。
interface SidebarConversation {
id: string;
title: React.ReactNode;
onClick?: () => void;
trailing?: React.ReactNode;
hoverTrailing?: React.ReactNode;
}使用场景
- 聊天应用:对话列表、历史记录
- 邮件客户端:邮件文件夹、标签分类
- 文档管理:文件夹树、最近文档
- 项目管理:项目列表、任务分组
- 设置面板:导航菜单、选项分类
最佳实践
- 数据结构:使用规范的
SidebarConversation类型定义数据 - 性能优化:大列表时使用虚拟滚动或分页加载
- 搜索功能:使用
useMemo缓存过滤结果 - 状态管理:复杂场景使用受控模式管理选中和折叠状态
- 空状态:提供友好的空状态和无搜索结果提示
- 响应式:根据屏幕尺寸调整侧边栏宽度和折叠状态
行为说明
header/newButton/search传null可隐藏对应区域;不传则使用默认配置- 收起时顶部显示两个带 tooltip 的 icon 按钮(展开 / 新建)
- 收起时宽度为
56px,内容区域自动收紧 - 未传
footer时不显示底部区域 - 搜索功能需要在外部实现过滤逻辑并传入
conversations
原语组件
Sidebar 由多个原语组件构建,支持完全自定义:
主容器
- SidebarPrimitive - 侧边栏主容器(flex 布局,上下结构)
- SidebarContentPrimitive - 内容区域容器(可滚动,flex-1)
- SidebarDividerPrimitive - 分隔线原语
头部原语
- SidebarHeaderPrimitive - 头部容器(flex 布局)
- SidebarHeaderLeading - 左侧区域(图标+标题)
- SidebarHeaderIcon - 图标容器
- SidebarHeaderTitle - 标题文本
- SidebarHeaderAction - 右侧操作按钮容器
新建按钮
- SidebarNewButtonPrimitive - 新建按钮样式原语(原生 button 元素)
历史区域
- SidebarHistoryPrimitive - 历史区域容器
- SidebarHistoryTitle - 历史标题
- SidebarHistorySearchPrimitive - 搜索容器
- SidebarHistorySearchContainer - 搜索框容器
- SidebarHistorySearchIcon - 搜索图标容器
- SidebarHistorySearchInput - 搜索输入框(原生 input 元素)
- SidebarHistoryListPrimitive - 列表容器(可滚动)
- SidebarHistoryEmpty - 空状态容器
底部原语
- SidebarFooterPrimitive - 底部容器
原语组件提供了基础的样式和结构,可以在 registry/wuhan/blocks/sidebar/sidebar-01.tsx 中找到。
扩展示例
使用原语自定义布局
import {
SidebarPrimitive,
SidebarHeaderPrimitive,
SidebarContentPrimitive,
} from "@/registry/wuhan/blocks/sidebar/sidebar-01";
function CustomSidebar() {
return (
<SidebarPrimitive>
<SidebarHeaderPrimitive>
{/* 自定义头部 */}
</SidebarHeaderPrimitive>
<SidebarContentPrimitive>
{/* 自定义内容 */}
</SidebarContentPrimitive>
</SidebarPrimitive>
);
}带分组的历史列表
import { SidebarComposed } from "@/registry/wuhan/composed/sidebar/sidebar";
function GroupedSidebar() {
const conversations = [
{ id: "1", title: "今天的对话", group: "today" },
{ id: "2", title: "昨天的对话", group: "yesterday" },
];
// 按分组渲染
return <SidebarComposed conversations={conversations} />;
}带操作按钮的列表项
import { SidebarComposed } from "@/registry/wuhan/composed/sidebar/sidebar";
import { Trash2 } from "lucide-react";
function SidebarWithActions() {
return (
<SidebarComposed
conversations={conversations.map((conv) => ({
id: conv.id,
title: conv.title,
hoverTrailing: (
<button onClick={() => handleDelete(conv.id)}>
<Trash2 className="size-3" />
</button>
),
}))}
/>
);
}