列表
History Item
Composed history list item with trailing slots and interactive states
"use client";
import * as React from "react";
import { HistoryItem } from "@/components/composed/history-item/history-item";
import { cn } from "@/lib/utils";
import * as Popover from "@radix-ui/react-popover";
import { Clock, Pin, Trash2, Ellipsis, Copy } from "lucide-react";
function HoverMorePopover({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) {
const closeTimer = React.useRef<number | null>(null);
const clearCloseTimer = () => {
if (closeTimer.current != null) {
window.clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const scheduleClose = () => {
clearCloseTimer();
closeTimer.current = window.setTimeout(() => onOpenChange(false), 120);
};
React.useEffect(() => {
return () => clearCloseTimer();
}, []);
return (
<Popover.Root open={open} onOpenChange={onOpenChange}>
<Popover.Trigger asChild>
<span
className="inline-flex items-center"
onMouseEnter={() => {
clearCloseTimer();
onOpenChange(true);
}}
onMouseLeave={() => {
scheduleClose();
}}
>
{children}
</span>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side="bottom"
align="end"
sideOffset={8}
onMouseEnter={() => {
clearCloseTimer();
onOpenChange(true);
}}
onMouseLeave={() => {
scheduleClose();
}}
className={cn(
"z-50",
"min-w-[160px]",
"rounded-[var(--radius-lg)]",
"border border-[var(--Border-border-neutral)]",
"bg-[var(--Container-bg-container)]",
"shadow-[var(--shadow-basic)]",
"p-[var(--Padding-padding-com-sm)]",
)}
>
<div className="flex flex-col">
<button
type="button"
className="flex items-center gap-2 px-2 py-1 rounded-md hover:bg-[var(--Container-bg-neutral-light)]"
>
<Pin className="size-4 text-[var(--Text-text-secondary)]" />
<span className="text-[var(--Text-text-primary)] font-size-1 leading-[var(--line-height-1)]">
Pin
</span>
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1 rounded-md hover:bg-[var(--Container-bg-neutral-light)]"
>
<Copy className="size-4 text-[var(--Text-text-secondary)]" />
<span className="text-[var(--Text-text-primary)] font-size-1 leading-[var(--line-height-1)]">
Duplicate
</span>
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1 rounded-md hover:bg-[var(--Container-bg-neutral-light)]"
>
<Trash2 className="size-4 text-[var(--Text-text-secondary)]" />
<span className="text-[var(--Text-text-primary)] font-size-1 leading-[var(--line-height-1)]">
Delete
</span>
</button>
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
export function HistoryItemDemo() {
const [moreOpen1, setMoreOpen1] = React.useState(false);
const [moreOpen2, setMoreOpen2] = React.useState(false);
return (
<div className="flex flex-col gap-[var(--Gap-gap-md)]">
<HistoryItem
active={moreOpen1}
title="默认历史记录项"
trailing={
<div className="inline-flex items-center gap-[var(--Gap-gap-xs)]">
<HoverMorePopover open={moreOpen1} onOpenChange={setMoreOpen1}>
<span className="inline-flex items-center" aria-label="More">
<Ellipsis className="size-4" />
</span>
</HoverMorePopover>
</div>
}
/>
<HistoryItem
selected
title="选中状态历史记录项"
trailing={
<div className="inline-flex items-center gap-[var(--Gap-gap-xs)]">
<Clock className="size-4" />
</div>
}
/>
<HistoryItem
active={moreOpen2}
title="Hover 展示操作:删除 + 更多(popover)"
hoverTrailing={
<div className="inline-flex items-center gap-[var(--Gap-gap-xs)]">
<HoverMorePopover open={moreOpen2} onOpenChange={setMoreOpen2}>
<span className="inline-flex items-center" aria-label="More">
<Ellipsis className="size-4" />
</span>
</HoverMorePopover>
</div>
}
/>
</div>
);
}
History Item 组件用于展示历史记录列表中的单个条目,支持选中、激活状态管理,并提供常驻和悬停时的尾部操作区,适用于聊天历史、浏览记录、文档历史等场景。
概述
- 多状态支持:支持选中(selected)和激活(active)两种独立状态
- 双尾部插槽:常驻尾部内容 + 悬停时显示的操作按钮
- 无障碍访问:完整的 ARIA 属性支持,提升可访问性
- 交互优化:悬停、点击等交互状态清晰可见
- 灵活扩展:基于原语组件构建,支持深度定制
快速开始
import { HistoryItem } from "@/registry/wuhan/composed/history-item";
import { Trash2 } from "lucide-react";
export function Example() {
return (
<HistoryItem
title="今天的对话"
selected={true}
trailing={<span className="text-xs text-muted-foreground">10:30</span>}
hoverTrailing={
<button className="p-1 hover:bg-muted rounded">
<Trash2 className="w-4 h-4" />
</button>
}
onClick={() => console.log("clicked")}
/>
);
}特性
- 选中状态(selected):用于标识当前激活的历史记录项
- 活动状态(active):用于标识鼠标悬停或键盘焦点的项
- 常驻尾部内容:始终显示的尾部信息(如时间戳、徽章等)
- 悬停操作区:仅在悬停时显示的操作按钮(如删除、编辑等)
- ARIA 支持:自动添加 aria-selected 和 aria-current 属性
- 可点击:支持点击事件处理
安装
代码演示
基本
基础的历史记录项展示。
"use client";
import * as React from "react";
import { HistoryItem } from "@/components/composed/history-item/history-item";
import { cn } from "@/lib/utils";
import * as Popover from "@radix-ui/react-popover";
import { Clock, Pin, Trash2, Ellipsis, Copy } from "lucide-react";
function HoverMorePopover({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) {
const closeTimer = React.useRef<number | null>(null);
const clearCloseTimer = () => {
if (closeTimer.current != null) {
window.clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const scheduleClose = () => {
clearCloseTimer();
closeTimer.current = window.setTimeout(() => onOpenChange(false), 120);
};
React.useEffect(() => {
return () => clearCloseTimer();
}, []);
return (
<Popover.Root open={open} onOpenChange={onOpenChange}>
<Popover.Trigger asChild>
<span
className="inline-flex items-center"
onMouseEnter={() => {
clearCloseTimer();
onOpenChange(true);
}}
onMouseLeave={() => {
scheduleClose();
}}
>
{children}
</span>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side="bottom"
align="end"
sideOffset={8}
onMouseEnter={() => {
clearCloseTimer();
onOpenChange(true);
}}
onMouseLeave={() => {
scheduleClose();
}}
className={cn(
"z-50",
"min-w-[160px]",
"rounded-[var(--radius-lg)]",
"border border-[var(--Border-border-neutral)]",
"bg-[var(--Container-bg-container)]",
"shadow-[var(--shadow-basic)]",
"p-[var(--Padding-padding-com-sm)]",
)}
>
<div className="flex flex-col">
<button
type="button"
className="flex items-center gap-2 px-2 py-1 rounded-md hover:bg-[var(--Container-bg-neutral-light)]"
>
<Pin className="size-4 text-[var(--Text-text-secondary)]" />
<span className="text-[var(--Text-text-primary)] font-size-1 leading-[var(--line-height-1)]">
Pin
</span>
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1 rounded-md hover:bg-[var(--Container-bg-neutral-light)]"
>
<Copy className="size-4 text-[var(--Text-text-secondary)]" />
<span className="text-[var(--Text-text-primary)] font-size-1 leading-[var(--line-height-1)]">
Duplicate
</span>
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1 rounded-md hover:bg-[var(--Container-bg-neutral-light)]"
>
<Trash2 className="size-4 text-[var(--Text-text-secondary)]" />
<span className="text-[var(--Text-text-primary)] font-size-1 leading-[var(--line-height-1)]">
Delete
</span>
</button>
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
export function HistoryItemDemo() {
const [moreOpen1, setMoreOpen1] = React.useState(false);
const [moreOpen2, setMoreOpen2] = React.useState(false);
return (
<div className="flex flex-col gap-[var(--Gap-gap-md)]">
<HistoryItem
active={moreOpen1}
title="默认历史记录项"
trailing={
<div className="inline-flex items-center gap-[var(--Gap-gap-xs)]">
<HoverMorePopover open={moreOpen1} onOpenChange={setMoreOpen1}>
<span className="inline-flex items-center" aria-label="More">
<Ellipsis className="size-4" />
</span>
</HoverMorePopover>
</div>
}
/>
<HistoryItem
selected
title="选中状态历史记录项"
trailing={
<div className="inline-flex items-center gap-[var(--Gap-gap-xs)]">
<Clock className="size-4" />
</div>
}
/>
<HistoryItem
active={moreOpen2}
title="Hover 展示操作:删除 + 更多(popover)"
hoverTrailing={
<div className="inline-flex items-center gap-[var(--Gap-gap-xs)]">
<HoverMorePopover open={moreOpen2} onOpenChange={setMoreOpen2}>
<span className="inline-flex items-center" aria-label="More">
<Ellipsis className="size-4" />
</span>
</HoverMorePopover>
</div>
}
/>
</div>
);
}
选中状态
基础历史列表,带时间戳和选中状态。
"use client";
import * as React from "react";
import { HistoryItem } from "@/components/composed/history-item/history-item";
export function HistoryItemBasic() {
const [selected, setSelected] = React.useState("item-1");
return (
<div className="w-[240px] space-y-1">
<HistoryItem
title="如何学习 React?"
trailing={<span className="text-xs text-muted-foreground">10:30</span>}
selected={selected === "item-1"}
onClick={() => setSelected("item-1")}
/>
<HistoryItem
title="TypeScript 最佳实践"
trailing={<span className="text-xs text-muted-foreground">09:15</span>}
selected={selected === "item-2"}
onClick={() => setSelected("item-2")}
/>
<HistoryItem
title="CSS Grid 布局指南"
trailing={<span className="text-xs text-muted-foreground">昨天</span>}
selected={selected === "item-3"}
onClick={() => setSelected("item-3")}
/>
</div>
);
}
操作项
带悬停操作按钮的历史项,支持编辑和删除。
"use client";
import * as React from "react";
import { HistoryItem } from "@/components/composed/history-item/history-item";
import { Trash2, Edit } from "lucide-react";
export function HistoryItemWithActions() {
const [items, setItems] = React.useState([
{ id: "1", title: "项目需求分析文档", time: "14:30" },
{ id: "2", title: "UI 设计稿 v2.0", time: "11:20" },
{ id: "3", title: "API 接口文档", time: "昨天" },
]);
const [selected, setSelected] = React.useState("1");
const handleDelete = (id: string) => {
setItems((prev) => prev.filter((item) => item.id !== id));
if (selected === id) {
setSelected(items[0]?.id || "");
}
};
const handleEdit = (id: string) => {
console.log("编辑:", id);
};
return (
<div className="w-[240px] space-y-1">
{items.map((item) => (
<HistoryItem
key={item.id}
title={item.title}
trailing={
<span className="text-xs text-muted-foreground">{item.time}</span>
}
hoverTrailing={
<div className="flex gap-1">
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
handleEdit(item.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
handleEdit(item.id);
}
}}
className="p-1 hover:bg-muted rounded cursor-pointer"
>
<Edit className="w-3.5 h-3.5" />
</div>
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
handleDelete(item.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
handleDelete(item.id);
}
}}
className="p-1 hover:bg-destructive/10 rounded cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
</div>
</div>
}
selected={selected === item.id}
onClick={() => setSelected(item.id)}
/>
))}
</div>
);
}
带图标
带图标和徽章的历史项。
"use client";
import * as React from "react";
import { HistoryItem } from "@/components/composed/history-item/history-item";
import {
MessageSquare,
Code,
FileText,
Image as ImageIcon,
} from "lucide-react";
export function HistoryItemWithIcons() {
const [selected, setSelected] = React.useState("1");
const items = [
{
id: "1",
title: "聊天对话记录",
icon: <MessageSquare className="w-4 h-4" />,
badge: (
<span className="px-1.5 py-0.5 text-xs bg-blue-100 text-blue-700 rounded">
3
</span>
),
},
{
id: "2",
title: "代码片段收藏",
icon: <Code className="w-4 h-4" />,
badge: (
<span className="px-1.5 py-0.5 text-xs bg-green-100 text-green-700 rounded">
新
</span>
),
},
{
id: "3",
title: "文档草稿",
icon: <FileText className="w-4 h-4" />,
badge: null,
},
{
id: "4",
title: "设计稿",
icon: <ImageIcon className="w-4 h-4" />,
badge: (
<span className="px-1.5 py-0.5 text-xs bg-purple-100 text-purple-700 rounded">
5
</span>
),
},
];
return (
<div className="w-[240px] space-y-1">
{items.map((item) => (
<HistoryItem
key={item.id}
title={
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{item.icon}</span>
<span className="truncate">{item.title}</span>
</div>
}
trailing={item.badge}
selected={selected === item.id}
onClick={() => setSelected(item.id)}
/>
))}
</div>
);
}
状态
展示不同状态下的历史项样式。
"use client";
import * as React from "react";
import { HistoryItem } from "@/components/composed/history-item/history-item";
export function HistoryItemStates() {
const [selected, setSelected] = React.useState("item-2");
const [active, setActive] = React.useState<string | null>(null);
return (
<div className="w-[240px] space-y-1">
<HistoryItem
title="普通状态"
trailing={<span className="text-xs text-muted-foreground">10:30</span>}
onClick={() => setSelected("item-1")}
onMouseEnter={() => setActive("item-1")}
onMouseLeave={() => setActive(null)}
/>
<HistoryItem
title="选中状态"
trailing={<span className="text-xs text-muted-foreground">09:15</span>}
selected={selected === "item-2"}
onClick={() => setSelected("item-2")}
onMouseEnter={() => setActive("item-2")}
onMouseLeave={() => setActive(null)}
/>
<HistoryItem
title="活动状态"
trailing={<span className="text-xs text-muted-foreground">昨天</span>}
active={active === "item-3"}
onClick={() => setSelected("item-3")}
onMouseEnter={() => setActive("item-3")}
onMouseLeave={() => setActive(null)}
/>
<HistoryItem
title="选中且活动"
trailing={<span className="text-xs text-muted-foreground">2天前</span>}
selected={selected === "item-4"}
active={active === "item-4"}
onClick={() => setSelected("item-4")}
onMouseEnter={() => setActive("item-4")}
onMouseLeave={() => setActive(null)}
/>
</div>
);
}
列表示例
完整的历史列表示例,带收藏功能。
最近访问
"use client";
import * as React from "react";
import { HistoryItem } from "@/components/composed/history-item/history-item";
import { Star, StarOff } from "lucide-react";
export function HistoryItemList() {
const [items, setItems] = React.useState([
{ id: "1", title: "周报总结 - 第12周", time: "2小时前", starred: true },
{ id: "2", title: "产品需求评审会议纪要", time: "5小时前", starred: false },
{ id: "3", title: "技术方案设计文档 v1.0", time: "昨天", starred: true },
{ id: "4", title: "UI/UX 设计规范", time: "2天前", starred: false },
{ id: "5", title: "数据库设计方案", time: "3天前", starred: false },
]);
const [selected, setSelected] = React.useState("1");
const toggleStar = (id: string) => {
setItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, starred: !item.starred } : item,
),
);
};
return (
<div className="w-[260px] border rounded-lg p-2">
<div className="text-sm font-medium text-muted-foreground mb-2 px-3">
最近访问
</div>
<div className="space-y-0.5">
{items.map((item) => (
<HistoryItem
key={item.id}
title={item.title}
trailing={
<span className="text-xs text-muted-foreground">{item.time}</span>
}
hoverTrailing={
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
toggleStar(item.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
toggleStar(item.id);
}
}}
className="p-1 hover:bg-muted rounded cursor-pointer"
>
{item.starred ? (
<Star className="w-3.5 h-3.5 fill-yellow-400 text-yellow-400" />
) : (
<StarOff className="w-3.5 h-3.5" />
)}
</div>
}
selected={selected === item.id}
onClick={() => setSelected(item.id)}
/>
))}
</div>
</div>
);
}
API
HistoryItem
历史记录列表项组件,支持多种状态和交互。
Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | - | 历史记录项的标题文本(必填) |
trailing | ReactNode | - | 常驻的尾部内容,始终显示 |
hoverTrailing | ReactNode | - | 仅在悬停时显示的尾部内容(通常用于操作按钮) |
selected | boolean | false | 是否为选中状态 |
active | boolean | false | 是否为激活状态(悬停或焦点) |
onClick | () => void | - | 点击事件处理函数 |
className | string | - | 额外的样式类名 |
Example
import { HistoryItem } from "@/registry/wuhan/composed/history-item";
import { Clock, Trash2, Pin } from "lucide-react";
function ChatHistory() {
const [selectedId, setSelectedId] = React.useState("1");
return (
<div className="w-64 space-y-1">
{/* 基础用法 */}
<HistoryItem
title="关于 React 的讨论"
selected={selectedId === "1"}
onClick={() => setSelectedId("1")}
/>
{/* 带时间戳的历史项 */}
<HistoryItem
title="TypeScript 最佳实践"
selected={selectedId === "2"}
trailing={
<span className="text-xs text-muted-foreground">
2小时前
</span>
}
onClick={() => setSelectedId("2")}
/>
{/* 带悬停操作的历史项 */}
<HistoryItem
title="如何学习 Next.js"
selected={selectedId === "3"}
trailing={<Clock className="w-3 h-3 text-muted-foreground" />}
hoverTrailing={
<div className="flex gap-1">
<button className="p-1 hover:bg-accent rounded">
<Pin className="w-3 h-3" />
</button>
<button className="p-1 hover:bg-accent rounded text-destructive">
<Trash2 className="w-3 h-3" />
</button>
</div>
}
onClick={() => setSelectedId("3")}
/>
</div>
);
}使用场景
- 聊天历史:显示历史对话列表,支持选中当前会话、删除会话等操作
- 浏览记录:展示用户的浏览历史,支持快速访问和管理
- 文档历史:显示最近打开的文档列表
- 搜索历史:展示用户的搜索记录,支持删除和重新搜索
- 操作历史:记录用户的操作轨迹,支持回溯
- 收藏列表:展示收藏的内容,支持管理操作
最佳实践
- 状态管理:使用 selected 标识当前激活项,active 由组件自动管理悬停状态
- 操作按钮:将破坏性操作(如删除)放在 hoverTrailing 中,避免误触
- 视觉层次:使用 trailing 显示次要信息,保持标题的视觉优先级
- 交互反馈:确保点击和悬停有明确的视觉反馈
- 键盘导航:配合键盘事件实现上下键导航
- 无障碍性:组件已内置 ARIA 属性,无需手动添加
注意事项
selected和active是两个独立的状态,可以同时存在hoverTrailing会在悬停时替换trailing的显示- 点击
hoverTrailing中的按钮时需要阻止事件冒泡,避免触发onClick - 建议在父组件中管理选中状态,而非在 HistoryItem 内部
- 标题文本过长时会自动截断并显示省略号
原语组件
History Item 基于以下原语组件构建:
HistoryItemPrimitive- 历史项容器原语HistoryItemTitlePrimitive- 标题文本原语HistoryItemTrailingPrimitive- 尾部内容原语HistoryItemHoverTrailingPrimitive- 悬停尾部原语
原语组件提供了基础的样式和结构,可以在 registry/wuhan/blocks/history-item/history-item-01.tsx 中找到。
样式定制
组件使用 Tailwind CSS,可以通过以下方式定制:
// 通过 className 添加额外样式
<HistoryItem
title="自定义样式项"
className="border-l-4 border-primary"
selected={true}
/>
// 自定义 trailing 样式
<HistoryItem
title="带徽章的项"
trailing={
<span className="px-2 py-0.5 text-xs bg-primary text-primary-foreground rounded-full">
新
</span>
}
/>扩展示例
带图标的历史项
import { HistoryItem } from "@/registry/wuhan/composed/history-item";
import { MessageSquare, FileText, Code } from "lucide-react";
function HistoryWithIcons() {
return (
<div className="w-64 space-y-1">
<div className="flex items-center gap-2 px-2 py-1.5">
<MessageSquare className="w-4 h-4 text-muted-foreground" />
<HistoryItem title="聊天记录" selected={true} />
</div>
<div className="flex items-center gap-2 px-2 py-1.5">
<FileText className="w-4 h-4 text-muted-foreground" />
<HistoryItem title="文档编辑" />
</div>
</div>
);
}分组历史列表
import { HistoryItem } from "@/registry/wuhan/composed/history-item";
function GroupedHistory() {
return (
<div className="w-64">
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
今天
</div>
<div className="space-y-1">
<HistoryItem title="React 性能优化" selected={true} />
<HistoryItem title="TypeScript 类型体操" />
</div>
<div className="px-2 py-1 mt-2 text-xs font-medium text-muted-foreground">
昨天
</div>
<div className="space-y-1">
<HistoryItem title="Next.js 路由配置" />
<HistoryItem title="Tailwind CSS 技巧" />
</div>
</div>
);
}键盘导航支持
import { HistoryItem } from "@/registry/wuhan/composed/history-item";
import { useState } from "react";
function KeyboardNavigableHistory() {
const [selectedIndex, setSelectedIndex] = useState(0);
const items = ["项目 1", "项目 2", "项目 3"];
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1));
} else if (e.key === "ArrowUp") {
setSelectedIndex((prev) => Math.max(prev - 1, 0));
}
};
return (
<div className="w-64 space-y-1" onKeyDown={handleKeyDown} tabIndex={0}>
{items.map((item, index) => (
<HistoryItem
key={index}
title={item}
selected={selectedIndex === index}
onClick={() => setSelectedIndex(index)}
/>
))}
</div>
);
}