顶部工具栏
编辑器式工具栏:返回、动态面包屑、自动保存指示、实时 / 编辑 / 预览切换、主题切换与更多菜单。
已保存 · 刚刚
编辑标题或正文即可看到“同步中…→ 已保存”。拖窄窗口,面包屑中段会折叠。
'use client';
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { TopHeader, type SaveState } from '@/components/blocks/top-header';
export default function TopHeaderDemo() {
const [title, setTitle] = useState('弹簧动效参数');
const [body, setBody] = useState('SNAPPY 用于分段与高亮,GENTLE 用于弹窗与高度展开。');
const [state, setState] = useState<SaveState>('saved');
const [savedAt, setSavedAt] = useState<number>();
const [log, setLog] = useState('');
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
// 模拟自动保存:每次输入重新计时,1.5s 后完成同步
const save = (fail = false) => {
clearTimeout(timer.current);
setState('saving');
timer.current = setTimeout(() => {
if (fail) {
setState('error');
} else {
setState('saved');
setSavedAt(Date.now());
}
}, 1500);
};
return (
<div className="w-full bg-surface-0">
<TopHeader
crumbs={[{ label: '工作区' }, { label: '设计系统' }, { label: '笔记' }, { label: title || '未命名' }]}
onBack={() => setLog('返回')}
onCrumb={(c) => setLog(`打开:${c.label}`)}
saveState={state}
savedAt={savedAt}
onRetrySave={() => save(false)}
onRename={() => setLog('重命名')}
onDuplicate={() => setLog('已创建副本')}
onExport={() => setLog('已导出')}
onDelete={() => new Promise<void>((r) => setTimeout(() => (setLog('已删除'), r()), 900))}
/>
<div className="mx-auto max-w-[640px] px-6 py-8">
<input
value={title}
onChange={(e) => {
setTitle(e.target.value);
save();
}}
aria-label="标题"
className="w-full bg-transparent font-title text-[28px] leading-tight text-text-primary outline-none placeholder:text-text-muted"
placeholder="未命名"
/>
<textarea
value={body}
onChange={(e) => {
setBody(e.target.value);
save();
}}
aria-label="正文"
className="mt-3 min-h-24 w-full resize-none bg-transparent text-sm leading-relaxed text-text-secondary outline-none"
/>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-hairline pt-4 text-[12px] text-text-muted">
<Button size="xs" variant="secondary" onClick={() => save(true)}>
模拟保存失败
</Button>
<span>编辑标题或正文即可看到“同步中…→ 已保存”。拖窄窗口,面包屑中段会折叠。</span>
{log && <span className="ml-auto text-text-secondary">{log}</span>}
</div>
</div>
</div>
);
}
包含
- 左侧:返回按钮 + 动态面包屑(窄时中段折叠为 “…” 菜单,再窄只保留当前页)+ 自动保存指示
- 保存状态:同步中(转圈“同步中…”)→ 已保存(对勾“已保存 · 刚刚”,相对时间每 5 秒刷新,
tabular-nums)→ 失败(可重试),aria-live播报 - 右侧:
Segmented实时 / 编辑 / 预览(窄时只显示图标)、主题切换(next-themes,太阳 / 月亮弹簧换场)、DropdownMenu“…” - 菜单:重命名、创建副本、导出、分隔线、删除(危险)→
ConfirmDialog,异步删除期间锁定 - 全部基于容器查询,放进任何宽度的容器都能正确折叠
用法
import { TopHeader } from "@/components/blocks/top-header";
<TopHeader
crumbs={[{ label: "工作区" }, { label: "设计系统" }, { label: "笔记" }, { label: doc.title }]}
onBack={() => router.back()}
saveState={saving ? "saving" : error ? "error" : "saved"}
savedAt={lastSavedAt}
onRetrySave={save}
mode={mode}
onModeChange={setMode}
onDelete={() => api.delete(doc.id)} // 返回 Promise 时按钮转圈
/>源码
'use client';
import { AnimatePresence, motion } from 'motion/react';
import {
ArrowLeft,
ArrowsClockwise,
Broadcast,
CaretRight,
CheckCircle,
Copy,
DotsThree,
Export,
Eye,
Moon,
PencilSimple,
PencilSimpleLine,
Sun,
Trash,
WarningCircle,
} from '@phosphor-icons/react';
import { useEffect, useState } from 'react';
import { useTheme } from 'next-themes';
import { twMerge } from 'tailwind-merge';
import { Segmented } from '@/components/ui/segmented';
import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import { ConfirmDialog } from '@/components/ui/dialog';
import { Tooltip } from '@/components/ui/tooltip';
import { Spinner } from '@/components/ui/feedback';
import { SPRING_BOUNCY, SPRING_SNAPPY } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
const COPY = {
zh: {
toolbar: '页面工具栏',
back: '返回',
crumbs: '面包屑',
more: '更多路径',
saving: '同步中…',
saved: '已保存',
error: '保存失败',
retry: '重试',
justNow: '刚刚',
secAgo: (n: number) => `${n} 秒前`,
minAgo: (n: number) => `${n} 分钟前`,
hourAgo: (n: number) => `${n} 小时前`,
mode: '视图模式',
live: '实时',
edit: '编辑',
preview: '预览',
theme: '切换主题',
toLight: '切换到浅色',
toDark: '切换到深色',
actions: '更多操作',
rename: '重命名',
duplicate: '创建副本',
export: '导出',
del: '删除',
delTitle: (n: string) => `删除“${n}”?`,
delDesc: '此操作无法撤销,页面及其历史版本都会被永久移除。',
cancel: '取消',
confirm: '删除',
},
en: {
toolbar: 'Page toolbar',
back: 'Back',
crumbs: 'Breadcrumb',
more: 'More path',
saving: 'Syncing…',
saved: 'Saved',
error: 'Save failed',
retry: 'Retry',
justNow: 'just now',
secAgo: (n: number) => `${n}s ago`,
minAgo: (n: number) => `${n}m ago`,
hourAgo: (n: number) => `${n}h ago`,
mode: 'View mode',
live: 'Live',
edit: 'Edit',
preview: 'Preview',
theme: 'Toggle theme',
toLight: 'Switch to light',
toDark: 'Switch to dark',
actions: 'More actions',
rename: 'Rename',
duplicate: 'Duplicate',
export: 'Export',
del: 'Delete',
delTitle: (n: string) => `Delete “${n}”?`,
delDesc: 'This cannot be undone. The page and its version history will be permanently removed.',
cancel: 'Cancel',
confirm: 'Delete',
},
};
export type SaveState = 'saving' | 'saved' | 'error';
export type ViewMode = 'live' | 'edit' | 'preview';
export interface Crumb {
label: string;
href?: string;
}
export interface TopHeaderProps {
lang?: 'zh' | 'en';
crumbs: Crumb[];
onBack?: () => void;
onCrumb?: (c: Crumb, index: number) => void;
saveState?: SaveState;
/** 最近一次保存成功的时间戳(ms),用于“刚刚 / N 分钟前” */
savedAt?: number;
onRetrySave?: () => void;
mode?: ViewMode;
onModeChange?: (m: ViewMode) => void;
onRename?: () => void;
onDuplicate?: () => void;
onExport?: () => void;
/** 确认删除后调用;可返回 Promise(期间按钮转圈并锁定) */
onDelete?: () => Promise<void> | void;
className?: string;
}
function useNow(interval = 5000) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), interval);
return () => clearInterval(id);
}, [interval]);
return now;
}
function SaveIndicator({ state, savedAt, onRetry, t }: { state: SaveState; savedAt?: number; onRetry?: () => void; t: (typeof COPY)['zh'] }) {
const now = useNow();
const reduce = useReducedMotionPref();
const rel = (() => {
if (!savedAt) return t.justNow;
const s = Math.max(0, Math.floor((now - savedAt) / 1000));
if (s < 10) return t.justNow;
if (s < 60) return t.secAgo(s);
if (s < 3600) return t.minAgo(Math.floor(s / 60));
return t.hourAgo(Math.floor(s / 3600));
})();
const anim = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0 } }
: { initial: { opacity: 0, y: 4 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -4, transition: { duration: 0.1 } }, transition: SPRING_SNAPPY };
return (
<div aria-live="polite" className="flex h-7 min-w-0 shrink-0 items-center text-[12px]">
<AnimatePresence mode="wait" initial={false}>
{state === 'saving' && (
<motion.span key="saving" {...anim} className="inline-flex items-center gap-1.5 text-text-muted">
<Spinner size="xs" label={t.saving} />
<span className="hidden @lg/header:inline">{t.saving}</span>
</motion.span>
)}
{state === 'saved' && (
<motion.span key="saved" {...anim} className="inline-flex items-center gap-1.5 text-text-muted">
<CheckCircle size={14} weight="fill" className="text-success" aria-label={t.saved} />
<span className="hidden whitespace-nowrap tabular-nums @lg/header:inline">
{t.saved} · {rel}
</span>
</motion.span>
)}
{state === 'error' && (
<motion.span key="error" {...anim} className="inline-flex items-center gap-1.5 text-danger">
<WarningCircle size={14} weight="fill" aria-label={t.error} />
<span className="hidden whitespace-nowrap @lg/header:inline">{t.error}</span>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="cds-focus inline-flex h-6 items-center gap-1 rounded-md px-1.5 font-medium transition-colors duration-[120ms] hover:bg-danger/10"
>
<ArrowsClockwise size={12} />
{t.retry}
</button>
)}
</motion.span>
)}
</AnimatePresence>
</div>
);
}
function ThemeToggle({ t }: { t: (typeof COPY)['zh'] }) {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const reduce = useReducedMotionPref();
useEffect(() => setMounted(true), []);
const dark = mounted && resolvedTheme === 'dark';
const label = dark ? t.toLight : t.toDark;
return (
<Tooltip content={label} side="bottom">
<button
type="button"
aria-label={label}
onClick={() => setTheme(dark ? 'light' : 'dark')}
className="cds-focus relative inline-flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg text-text-secondary transition-colors duration-[120ms] hover:bg-ghost-hover hover:text-text-primary"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={dark ? 'moon' : 'sun'}
className="inline-flex"
initial={reduce ? { opacity: 0 } : { opacity: 0, rotate: -90, scale: 0.5 }}
animate={{ opacity: 1, rotate: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, rotate: 90, scale: 0.5 }}
transition={reduce ? { duration: 0 } : SPRING_BOUNCY}
>
{dark ? <Moon size={18} /> : <Sun size={18} />}
</motion.span>
</AnimatePresence>
</button>
</Tooltip>
);
}
const iconBtn =
'cds-focus inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-text-secondary transition-colors duration-[120ms] hover:bg-ghost-hover hover:text-text-primary';
/**
* 页面工具栏:返回 + 面包屑(窄时中间折叠为 …)+ 自动保存指示;
* 右侧 实时/编辑/预览 分段、主题切换、更多菜单(删除走 ConfirmDialog)。
* 用容器查询(@container/header)适配,放进任何宽度的容器都可用。
*/
export function TopHeader({
lang = 'zh',
crumbs,
onBack,
onCrumb,
saveState = 'saved',
savedAt,
onRetrySave,
mode: modeProp,
onModeChange,
onRename,
onDuplicate,
onExport,
onDelete,
className,
}: TopHeaderProps) {
const t = COPY[lang];
const [innerMode, setInnerMode] = useState<ViewMode>('edit');
const mode = modeProp ?? innerMode;
const setMode = (m: ViewMode) => {
if (modeProp === undefined) setInnerMode(m);
onModeChange?.(m);
};
const [confirm, setConfirm] = useState(false);
const current = crumbs[crumbs.length - 1];
const first = crumbs.length > 1 ? crumbs[0] : undefined;
const middle = crumbs.slice(1, -1);
const crumbLink = (c: Crumb, i: number) => (
<a
href={c.href ?? '#'}
onClick={(e) => {
if (onCrumb) {
e.preventDefault();
onCrumb(c, i);
}
}}
className="cds-focus block max-w-[160px] truncate rounded-md px-1.5 py-0.5 text-text-secondary transition-colors duration-[120ms] hover:bg-ghost-hover hover:text-text-primary"
>
{c.label}
</a>
);
const sep = <CaretRight aria-hidden size={12} className="shrink-0 text-text-muted" />;
return (
<div className={twMerge('@container/header w-full shrink-0 border-b border-hairline bg-surface-1', className)}>
<div role="toolbar" aria-label={t.toolbar} className="flex h-12 items-center gap-2 px-2 @md/header:px-3">
{/* 左:返回 + 面包屑 + 保存状态 */}
<div className="flex min-w-0 flex-1 items-center gap-1">
{onBack && (
<Tooltip content={t.back} side="bottom">
<button type="button" aria-label={t.back} onClick={onBack} className={iconBtn}>
<ArrowLeft size={18} />
</button>
</Tooltip>
)}
<nav aria-label={t.crumbs} className="min-w-0">
<ol className="flex min-w-0 items-center gap-0.5 text-[13px]">
{first && (
<>
<li className="hidden min-w-0 shrink @md/header:flex">{crumbLink(first, 0)}</li>
<li aria-hidden className="hidden @md/header:flex">{sep}</li>
</>
)}
{middle.length > 0 && (
<>
{/* 宽:完整中段 */}
{middle.map((c, i) => (
<li key={i} className="hidden min-w-0 shrink items-center gap-0.5 @2xl/header:flex">
{crumbLink(c, i + 1)}
{sep}
</li>
))}
{/* 窄:中段折叠成 … 菜单 */}
<li className="hidden items-center gap-0.5 @md/header:flex @2xl/header:hidden">
<DropdownMenu
aria-label={t.more}
trigger={
<button type="button" aria-label={t.more} className="cds-focus inline-flex h-6 items-center rounded-md px-1.5 text-text-muted transition-colors duration-[120ms] hover:bg-ghost-hover hover:text-text-primary">
<DotsThree size={16} weight="bold" />
</button>
}
>
{middle.map((c, i) => (
<DropdownMenuItem key={i} onSelect={() => onCrumb?.(c, i + 1)}>
{c.label}
</DropdownMenuItem>
))}
</DropdownMenu>
{sep}
</li>
</>
)}
{current && (
<li className="min-w-0">
<span aria-current="page" className="block truncate px-1.5 font-medium text-text-primary">
{current.label}
</span>
</li>
)}
</ol>
</nav>
<div className="ml-1 flex shrink-0 items-center">
<SaveIndicator state={saveState} savedAt={savedAt} onRetry={onRetrySave} t={t} />
</div>
</div>
{/* 右:模式 + 主题 + 更多 */}
<div className="flex shrink-0 items-center gap-1">
<Segmented<ViewMode>
ariaLabel={t.mode}
value={mode}
onChange={setMode}
options={[
{ value: 'live', icon: <Broadcast aria-hidden />, label: <span className="sr-only @3xl/header:not-sr-only">{t.live}</span> },
{ value: 'edit', icon: <PencilSimpleLine aria-hidden />, label: <span className="sr-only @3xl/header:not-sr-only">{t.edit}</span> },
{ value: 'preview', icon: <Eye aria-hidden />, label: <span className="sr-only @3xl/header:not-sr-only">{t.preview}</span> },
]}
/>
<ThemeToggle t={t} />
<DropdownMenu
align="end"
aria-label={t.actions}
className="w-52"
trigger={
<button type="button" aria-label={t.actions} className={iconBtn}>
<DotsThree size={18} weight="bold" />
</button>
}
>
<DropdownMenuItem icon={<PencilSimple />} shortcut="F2" onSelect={() => onRename?.()}>
{t.rename}
</DropdownMenuItem>
<DropdownMenuItem icon={<Copy />} shortcut="⌘D" onSelect={() => onDuplicate?.()}>
{t.duplicate}
</DropdownMenuItem>
<DropdownMenuItem icon={<Export />} shortcut="⌘E" onSelect={() => onExport?.()}>
{t.export}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem icon={<Trash />} danger onSelect={() => setConfirm(true)}>
{t.del}
</DropdownMenuItem>
</DropdownMenu>
</div>
</div>
<ConfirmDialog
open={confirm}
onOpenChange={setConfirm}
title={t.delTitle(current?.label ?? '')}
description={t.delDesc}
cancelLabel={t.cancel}
confirmLabel={t.confirm}
onConfirm={() => onDelete?.()}
/>
</div>
);
}