MioUI

Top Header

An editor-style toolbar with back, dynamic breadcrumbs, an autosave indicator, Live / Edit / Preview, a theme toggle and a more menu.

Edit the title or body to see “Syncing… → Saved”. Narrow the window and the middle crumbs collapse.

Includes

  • Left: back button + dynamic breadcrumbs (middle crumbs collapse into a “…” menu when narrow, then only the current page remains) + autosave indicator
  • Save states: saving (spinner “Syncing…”) → saved (check “Saved · just now”, relative time refreshes every 5s, tabular-nums) → error (with retry), announced via aria-live
  • Right: Segmented Live / Edit / Preview (icons only when narrow), theme toggle (next-themes, sun / moon spring swap), DropdownMenu “…”
  • Menu: rename, duplicate, export, separator, delete (danger) → ConfirmDialog, locked while an async delete runs
  • Driven by container queries, so it collapses correctly in any container width

Usage

import { TopHeader } from "@/components/blocks/top-header";

<TopHeader
  lang="en"
  crumbs={[{ label: "Workspace" }, { label: "Design system" }, { label: "Notes" }, { 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)}   // a Promise shows a spinner
/>

Source

components/blocks/top-header.tsx
'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>
  );
}