unnamed-ui
布局

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
  • 状态管理:支持受控和非受控的选中状态、折叠状态
  • 空状态:内置空状态和搜索无结果提示
  • 响应式:折叠时自动调整布局和显示内容

安装

pnpm dlx shadcn@latest add http://localhost:3000/r/wuhan/sidebar.json

代码演示

完整侧边栏

完整的侧边栏示例,包含所有部分:

问学
历史对话
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

属性类型默认值说明
conversationsSidebarConversation[]-历史列表数据(必传)
selectedIdstring | null-当前选中项 id
historyTitleReact.ReactNode"历史对话"历史标题
emptyTextReact.ReactNode"暂无对话历史"空状态文案
headerSidebarHeaderConfig | null内置默认头部配置;传 null 隐藏
newButtonSidebarNewButtonConfig | null内置默认新建按钮配置;传 null 隐藏
searchSidebarSearchConfig | null内置默认搜索配置;传 null 隐藏
footerReact.ReactNode | (({ collapsed }) => ReactNode) | null-底部内容;函数可获取折叠状态
collapsiblebooleantrue是否显示/启用折叠按钮
collapsedboolean-受控折叠状态
defaultCollapsedbooleanfalse非受控初始折叠状态
onCollapsedChange(collapsed: boolean) => void-折叠状态变化回调
classNamestring-外层容器样式扩展
contentClassNamestring-内容区域样式扩展

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

头部配置对象。

属性类型默认值说明
titleReact.ReactNode"对话"标题内容
iconReact.ReactNodeSparkles标题左侧图标
actionReact.ReactNode | null内置折叠按钮右侧操作;传 null 隐藏

SidebarNewButtonConfig

新建按钮配置对象。

属性类型默认值说明
labelReact.ReactNode"新对话"按钮文本
iconReact.ReactNodePlus按钮图标
onClick() => void-点击回调

SidebarSearchConfig

搜索配置对象。

属性类型默认值说明
valuestring""输入值
onChange(value: string) => void-输入变化回调
placeholderstring"搜索"占位文案
iconReact.ReactNodeSearch搜索图标

SidebarConversation

对话项数据类型。

interface SidebarConversation {
  id: string;
  title: React.ReactNode;
  onClick?: () => void;
  trailing?: React.ReactNode;
  hoverTrailing?: React.ReactNode;
}

使用场景

  • 聊天应用:对话列表、历史记录
  • 邮件客户端:邮件文件夹、标签分类
  • 文档管理:文件夹树、最近文档
  • 项目管理:项目列表、任务分组
  • 设置面板:导航菜单、选项分类

最佳实践

  1. 数据结构:使用规范的 SidebarConversation 类型定义数据
  2. 性能优化:大列表时使用虚拟滚动或分页加载
  3. 搜索功能:使用 useMemo 缓存过滤结果
  4. 状态管理:复杂场景使用受控模式管理选中和折叠状态
  5. 空状态:提供友好的空状态和无搜索结果提示
  6. 响应式:根据屏幕尺寸调整侧边栏宽度和折叠状态

行为说明

  • header/newButton/searchnull 可隐藏对应区域;不传则使用默认配置
  • 收起时顶部显示两个带 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>
        ),
      }))}
    />
  );
}