独家

Crumbs <mv-crumbs>

记录用户实际访问过哪些地方的轨迹,适用于人们在多条记录之间跳转的应用(CRM、客服台、管理后台、文档、代码浏览器、后台系统),而不是显示页面在层级中的位置。

文化出处

汉赛尔与格莱特,格林兄弟(1812 年,童话)。男孩在森林里迷了路,沿途撒下面包屑想找到回家的路,可鸟儿把面包屑吃掉了,于是他走过的痕迹在身后渐渐消失。在 UI 中,用户实际访问的每个地方都会在回溯工作的轨迹上留下一粒面包屑;随着时间推移和新面包屑的积累,旧的面包屑会淡出并折叠起来,除非用户固定那些值得保留的。

工作原理

记录用户实际去过的地方的轨迹,适用于用户在不同记录之间跳转的应用(CRM、客服台、管理控制台、文档、代码浏览器、后台),而不是页面在层级结构中的位置。每次访问,无论来自 visit({ id, label, href, kind, icon })、同源链接点击(track="links")还是历史记录变化(track="history",使用 Navigation API,并回退到 popstate/hashchange),都会在紧凑的水平轨迹末尾留下一粒带类型图标的面包屑;再次访问会把面包屑向前移动,而不是重复添加(也可以原地刷新或重复添加:revisit)。较旧的面包屑会随着时间推移(lifetime)和新面包屑的堆积(max)逐渐淡出、缩小,标签也被一点点截短;完全淡出的面包屑会折叠进一个 “+12 earlier” 菜单,列出每个地方及其距上次访问的时间,因此轨迹保持简短且相关;轨迹也从不滚动,因为宽度不足时最旧的面包屑会提前折叠。固定的面包屑永不淡出并排在最前,就像固定的标签页。从轨迹中打开一粒面包屑算作一步:它在原位变为当前项,轨迹绝不会在指针下重新排序。悬停在面包屑上会打开一张小卡片,显示完整标签、类型、“Visited 4 minutes ago”、Pin 和 Remove;键盘支持游走焦点(roving focus)、P / Delete,以及用 Alt+[ / Alt+] 沿轨迹前后移动。mv-crumb-open 可取消,便于 SPA 路由器接管;访问可以通过 mv-crumb 被否决或重新命名;轨迹可以按会话或按用户持久化到浏览器存储(需主动开启),也可以通过 crumbs 属性和 mv-crumbs-change 保存到你的服务器。

分类导航
类型Web Component(<mv-crumbs>)
状态稳定版
套件高级用户导航
Keywordsexclusive, culture, breadcrumb, history, recent, recently-viewed, visited, trail, navigation, back-forward, pin, crm, support-desk, admin, spa-router, persistence, fade, keyboard

When to use

  • People jump between related records (customer, deal, invoice, another customer) and lose track of how they got here
  • Support agents or admins juggle several tickets, accounts or orders and need one-click returns to the ones they just touched
  • A docs site or code browser should offer a short, self-cleaning list of pages actually read in this session
  • The browser back button is not enough because the app mixes panels, modals and routes, and users need to see the path

Avoid when

  • The trail must show where the page sits in the site structure (Home / Settings / Billing) → use Breadcrumb instead
  • Users need to catch up on what changed while they were away, not return to places they visited → use While Away instead
  • People need to jump anywhere by typing, including places they have never visited → use Command instead

安装

node scripts/add.mjs crumbs --out ./src/marvelous

使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["crumbs"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

复制的文件(含依赖):tokens/tokens.css、core/base.css、core/dismiss.js、core/dom.js、core/element.js、core/focus.js、core/motion.js、core/observe.js、core/position.js、components/crumbs/crumbs.js、components/crumbs/crumbs.css。

用法

快速开始,最小可运行标记:

<mv-crumbs track="links" persist="app"></mv-crumbs>

标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:

<div id="cr-demo" style="width:min(100%,64rem);margin-inline:auto">
  <style>
    #cr-demo { display:grid; gap:1.25rem; align-content:start }
    #cr-demo .cr-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
    #cr-demo .cr-bar { display:flex; align-items:center; gap:1rem; height:3.25rem; padding:0 1rem 0 1.125rem; border-bottom:1px solid var(--mv-border) }
    #cr-demo .cr-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em; white-space:nowrap }
    #cr-demo .cr-logo { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
    #cr-demo .cr-logo svg { width:1rem; height:1rem }
    #cr-demo .cr-search { display:flex; align-items:center; gap:.5rem; flex:0 1 18rem; height:2rem; margin-inline-start:auto; padding:0 .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle); color:var(--mv-fg-subtle); font-size:.8125rem }
    #cr-demo .cr-search svg { width:.9375rem; height:.9375rem; flex:none }
    #cr-demo .cr-me { display:grid; place-items:center; flex:none; width:2rem; height:2rem; border-radius:50%; background:var(--mv-bg-emphasis); color:var(--mv-fg); font-size:.75rem; font-weight:600 }
    #cr-demo .cr-trailbar { display:flex; align-items:center; gap:.75rem; min-height:2.875rem; padding:.5rem 1rem .5rem 1.125rem; border-bottom:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #cr-demo .cr-trailbar mv-crumbs { flex:1 1 auto }
    #cr-demo .cr-body { display:grid; grid-template-columns:11rem minmax(0,1fr); min-height:22rem }
    #cr-demo .cr-side { display:grid; align-content:start; gap:.125rem; padding:.875rem .625rem; border-right:1px solid var(--mv-border) }
    #cr-demo .cr-side a { display:flex; align-items:center; gap:.5rem; padding:.4375rem .625rem; border-radius:var(--mv-radius-md); color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500; text-decoration:none }
    #cr-demo .cr-side a:hover { background:var(--mv-bg-muted); color:var(--mv-fg) }
    #cr-demo .cr-side a[aria-current] { background:var(--mv-bg-muted); color:var(--mv-fg) }
    #cr-demo .cr-side a:focus-visible, #cr-demo .cr-rel a:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
    #cr-demo .cr-side svg { width:1rem; height:1rem; flex:none }
    #cr-demo .cr-main { min-width:0; padding:1.25rem 1.375rem }
    #cr-demo .cr-head { display:flex; align-items:flex-start; gap:.875rem }
    #cr-demo .cr-ico { display:grid; place-items:center; flex:none; width:2.5rem; height:2.5rem; border-radius:var(--mv-radius-lg); background:color-mix(in oklab, var(--tone) 13%, var(--mv-surface)); color:var(--tone) }
    #cr-demo .cr-ico svg { width:1.25rem; height:1.25rem }
    #cr-demo .cr-head h3 { margin:0; font-size:1.125rem; letter-spacing:-.015em }
    #cr-demo .cr-head p { margin:.1875rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #cr-demo .cr-head .mv-badge { margin-inline-start:auto }
    #cr-demo .cr-fields { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.625rem; margin:1.125rem 0 0 }
    #cr-demo .cr-fields div { min-width:0; padding:.625rem .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle) }
    #cr-demo .cr-fields dt { color:var(--mv-fg-muted); font-size:.6875rem }
    #cr-demo .cr-fields dd { margin:.125rem 0 0; font-size:.875rem; font-weight:600; font-variant-numeric:tabular-nums; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #cr-demo .cr-k { margin:1.25rem 0 .5rem; color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
    #cr-demo .cr-rel { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.5rem; margin:0; padding:0; list-style:none }
    #cr-demo .cr-rel a { display:flex; align-items:center; gap:.625rem; min-width:0; padding:.5rem .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-surface); color:inherit; text-decoration:none; transition:border-color var(--mv-duration-fast), background-color var(--mv-duration-fast) }
    #cr-demo .cr-rel a:hover { border-color:var(--mv-border-strong); background:var(--mv-bg-subtle) }
    #cr-demo .cr-rel .cr-ico { width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md) }
    #cr-demo .cr-rel .cr-ico svg { width:.9375rem; height:.9375rem }
    #cr-demo .cr-rel b { display:block; font-size:.8125rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #cr-demo .cr-rel small { display:block; color:var(--mv-fg-muted); font-size:.6875rem }
    #cr-demo .cr-rel span.cr-t { min-width:0 }
    #cr-demo .cr-controls { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.75rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #cr-demo .cr-actions { display:flex; align-items:center; gap:.5rem 1rem; flex-wrap:wrap }
    #cr-demo .cr-hint { margin:0; color:var(--mv-fg-muted); font-size:.75rem }
    #cr-demo .cr-log { min-height:1.25rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono) }
    #cr-demo .mv-choice { font-size:.8125rem }
    #cr-demo .cr-docs { display:grid; gap:.875rem; padding:1rem 1.125rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
    #cr-demo .cr-docs-head { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap }
    #cr-demo .cr-docs-head strong { font-size:.9375rem; letter-spacing:-.01em }
    #cr-demo .cr-docs-head span { color:var(--mv-fg-muted); font-size:.75rem }
    #cr-demo .cr-docs-links { display:flex; flex-wrap:wrap; gap:.375rem; margin:0; padding:0; list-style:none }
    #cr-demo .cr-docs-links a { display:inline-flex; align-items:center; height:1.875rem; padding:0 .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); color:var(--mv-fg); font-size:.8125rem; text-decoration:none; font-family:var(--mv-font-mono) }
    #cr-demo .cr-docs-links a:hover { background:var(--mv-bg-muted) }
    #cr-demo .cr-docs-links a:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
    @media (max-width:44rem) {
      #cr-demo .cr-body { grid-template-columns:minmax(0,1fr) }
      #cr-demo .cr-side { display:none }
      #cr-demo .cr-fields, #cr-demo .cr-rel { grid-template-columns:minmax(0,1fr) }
      #cr-demo .cr-search { display:none }
      #cr-demo .cr-me { margin-inline-start:auto }
    }
  </style>

  <!-- Main: a CRM where people hop between records. The trail follows real visits, not the hierarchy. -->
  <section class="cr-app" id="cr-app" aria-label="Ridgeline CRM">
    <header class="cr-bar">
      <span class="cr-brand">
        <span class="cr-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 18 6-8 4 5 3-4 5 7z"/></svg></span>
        Ridgeline
      </span>
      <span class="cr-search" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg>Search customers, deals, invoices…</span>
      <span class="cr-me" aria-hidden="true">LM</span>
    </header>
    <div class="cr-trailbar">
      <mv-crumbs id="cr-trail" track="links" track-root="#cr-app" max="5" lifetime="45m"></mv-crumbs>
    </div>
    <div class="cr-body">
      <nav class="cr-side" aria-label="Sections">
        <a href="#/customers" data-crumb="off"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16"/><path d="M16 9h2a2 2 0 0 1 2 2v10"/><path d="M8 7h4M8 11h4M8 15h4M3 21h18"/></svg>Customers</a>
        <a href="#/deals" data-crumb="off"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="4.5"/></svg>Deals</a>
        <a href="#/invoices" data-crumb="off" aria-current="page"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3h12v18l-3-2-3 2-3-2-3 2z"/><path d="M9 8h6M9 12h6M9 16h3"/></svg>Invoices</a>
        <a href="#/tickets" data-crumb="off"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16v3.5a2.5 2.5 0 0 0 0 5V18H4v-3.5a2.5 2.5 0 0 0 0-5z"/></svg>Tickets</a>
      </nav>
      <main class="cr-main" id="cr-view" aria-live="off"></main>
    </div>
    <div class="cr-controls">
      <span class="cr-actions">
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cr-ff">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 6 7 6-7 6zM13 6l7 6-7 6z"/></svg>
          Fast-forward 10 min
        </button>
        <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="cr-move" checked> Revisits jump to the end</label>
        <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="cr-clear">Clear trail</button>
      </span>
      <p class="cr-hint"><kbd class="mv-kbd">Alt</kbd>+<kbd class="mv-kbd">[</kbd> / <kbd class="mv-kbd">Alt</kbd>+<kbd class="mv-kbd">]</kbd> step along the trail · <kbd class="mv-kbd">P</kbd> pin · <kbd class="mv-kbd">Del</kbd> remove</p>
      <p class="cr-log" id="cr-log" aria-live="polite">Open related records to drop crumbs.</p>
    </div>
  </section>

  <!-- Plain variant: an API reference where the trail fades within two minutes. -->
  <section class="cr-docs" id="cr-docs" aria-label="API reference">
    <div class="cr-docs-head">
      <strong>API reference</strong>
      <span>Plain variant · small · 4 crumbs · fades within 2 minutes</span>
    </div>
    <mv-crumbs id="cr-doc-trail" data-variant="plain" data-size="sm" track="links" track-root="#cr-docs" max="4" lifetime="2m"></mv-crumbs>
    <ul class="cr-docs-links">
      <li><a href="#/docs/authentication" data-crumb-id="/docs/authentication" data-crumb-kind="doc">Authentication</a></li>
      <li><a href="#/docs/webhooks" data-crumb-id="/docs/webhooks" data-crumb-kind="doc">Webhooks</a></li>
      <li><a href="#/docs/rate-limits" data-crumb-id="/docs/rate-limits" data-crumb-kind="doc">Rate limits</a></li>
      <li><a href="#/docs/errors" data-crumb-id="/docs/errors" data-crumb-kind="doc">Errors</a></li>
      <li><a href="#/docs/pagination" data-crumb-id="/docs/pagination" data-crumb-kind="doc">Pagination</a></li>
      <li><a href="#/docs/idempotency" data-crumb-id="/docs/idempotency" data-crumb-kind="doc">Idempotency keys</a></li>
      <li><a href="#/docs/sdk-node" data-crumb-id="/docs/sdk-node" data-crumb-kind="code">Node.js SDK</a></li>
    </ul>
  </section>

  <script type="module">
    const trail = document.getElementById("cr-trail");
    const docTrail = document.getElementById("cr-doc-trail");
    const view = document.getElementById("cr-view");
    const log = document.getElementById("cr-log");
    const MIN = 60_000;

    const ICON = {
      customer: '<path d="M4 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16"/><path d="M16 9h2a2 2 0 0 1 2 2v10"/><path d="M8 7h4M8 11h4M8 15h4M3 21h18"/>',
      contact: '<circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/>',
      deal: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="4.5"/><circle cx="12" cy="12" r="1" fill="currentColor"/>',
      invoice: '<path d="M6 3h12v18l-3-2-3 2-3-2-3 2z"/><path d="M9 8h6M9 12h6M9 16h3"/>',
      ticket: '<path d="M4 6h16v3.5a2.5 2.5 0 0 0 0 5V18H4v-3.5a2.5 2.5 0 0 0 0-5z"/><path d="M14 6v2M14 11v2M14 16v2"/>',
    };
    const KINDS = {
      customer: { label: "Customer", icon: "building", color: "var(--mv-info)", path: "customers" },
      contact: { label: "Contact", icon: "person", color: "var(--mv-accent)", path: "contacts" },
      deal: { label: "Deal", icon: "target", color: "var(--mv-success)", path: "deals" },
      invoice: { label: "Invoice", icon: "receipt", color: "var(--mv-warning)", path: "invoices" },
      ticket: { label: "Ticket", icon: "ticket", color: "var(--mv-danger)", path: "tickets" },
    };
    trail.kinds = KINDS;

    // A tiny in-memory CRM.
    const DB = {
      "brightmoor": { kind: "customer", title: "Brightmoor Logistics", sub: "Enterprise · Columbus, US", badge: "Active", fields: [["ARR", "$184,000"], ["Owner", "Lena Moreau"], ["Health", "Good"]], rel: ["inv-2031", "tk-4471", "c-okafor"] },
      "inv-2031": { kind: "invoice", title: "INV-2031", sub: "Brightmoor Logistics · issued Aug 29, 2026", badge: "Paid", fields: [["Amount", "$46,000.00"], ["Due", "Sep 28, 2026"], ["Method", "Wire"]], rel: ["brightmoor"] },
      "tk-4471": { kind: "ticket", title: "#4471 SSO login loop", sub: "Brightmoor Logistics · priority High", badge: "Open", fields: [["Assignee", "Mateo Silva"], ["Opened", "Sep 22, 2026"], ["SLA", "3 h left"]], rel: ["brightmoor", "c-okafor"] },
      "quellan": { kind: "deal", title: "Quellan expansion", sub: "Quellan · Proposal stage", badge: "Proposal", fields: [["Value", "$96,500"], ["Close", "Nov 14, 2026"], ["Probability", "40%"]], rel: ["solace"] },
      "solace": { kind: "customer", title: "Solace Health", sub: "Mid-market · Toronto, CA", badge: "Trial", fields: [["ARR", "$0"], ["Owner", "Priya Nair"], ["Trial ends", "Oct 9, 2026"]], rel: ["quellan", "inv-2044"] },
      "inv-2044": { kind: "invoice", title: "INV-2044", sub: "Solace Health · issued Sep 18, 2026", badge: "Overdue", fields: [["Amount", "$8,250.00"], ["Due", "Sep 21, 2026"], ["Method", "Card"]], rel: ["solace"] },
      "tarnwick": { kind: "customer", title: "Tarnwick Traders", sub: "Enterprise · Seattle, US", badge: "Active", fields: [["ARR", "$212,400"], ["Owner", "Lena Moreau"], ["Health", "At risk"]], rel: ["nw-renewal", "c-tanaka", "inv-2048", "tk-4502"] },
      "nw-renewal": { kind: "deal", title: "Tarnwick renewal FY27", sub: "Tarnwick Traders · Negotiation", badge: "Negotiation", fields: [["Value", "$224,000"], ["Close", "Oct 31, 2026"], ["Probability", "75%"]], rel: ["tarnwick", "c-tanaka", "inv-2048"] },
      "c-tanaka": { kind: "contact", title: "Aiko Tanaka", sub: "VP Operations · Tarnwick Traders", badge: "Champion", fields: [["Email", "[email protected]"], ["Phone", "+1 206 555 0148"], ["Time zone", "PT"]], rel: ["tarnwick", "nw-renewal"] },
      "inv-2048": { kind: "invoice", title: "INV-2048", sub: "Tarnwick Traders · draft", badge: "Draft", fields: [["Amount", "$17,400.00"], ["Due", "Oct 15, 2026"], ["Lines", "3"]], rel: ["tarnwick", "nw-renewal", "c-tanaka", "tk-4502"] },
      "tk-4502": { kind: "ticket", title: "#4502 Export to CSV times out", sub: "Tarnwick Traders · priority Normal", badge: "Pending", fields: [["Assignee", "Mateo Silva"], ["Opened", "Sep 23, 2026"], ["SLA", "1 d left"]], rel: ["tarnwick", "inv-2048"] },
      "c-okafor": { kind: "contact", title: "Chidi Okafor", sub: "IT Director · Brightmoor Logistics", badge: "Admin", fields: [["Email", "[email protected]"], ["Phone", "+1 312 555 0190"], ["Time zone", "CT"]], rel: ["brightmoor", "tk-4471"] },
    };
    const hrefOf = (id) => `#/${KINDS[DB[id].kind].path}/${id}`;
    const crumbOf = (id) => ({ id, label: DB[id].title, href: hrefOf(id), kind: DB[id].kind });

    const el = (tag, attrs = {}, ...kids) => {
      const n = document.createElement(tag);
      for (const [k, v] of Object.entries(attrs)) if (v != null) n.setAttribute(k, v);
      n.append(...kids);
      return n;
    };
    const icon = (kind) => {
      const t = document.createElement("template");
      t.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICON[kind]}</svg>`;
      return t.content.firstElementChild;
    };
    const ico = (kind) => { const s = el("span", { class: "cr-ico" }, icon(kind)); s.style.setProperty("--tone", KINDS[kind].color); return s; };

    function show(id) {
      const r = DB[id];
      if (!r) return;
      const head = el("div", { class: "cr-head" }, ico(r.kind),
        el("div", {}, el("h3", {}, r.title), el("p", {}, `${KINDS[r.kind].label} · ${r.sub}`)),
        el("span", { class: "mv-badge", "data-variant": "secondary", "data-shape": "pill" }, r.badge));
      const fields = el("dl", { class: "cr-fields" }, ...r.fields.map(([k, v]) => el("div", {}, el("dt", {}, k), el("dd", {}, v))));
      const rel = el("ul", { class: "cr-rel" }, ...r.rel.map((rid) => {
        const o = DB[rid];
        return el("li", {}, el("a", { href: hrefOf(rid), "data-crumb-id": rid, "data-crumb-kind": o.kind, "data-crumb-label": o.title },
          ico(o.kind), el("span", { class: "cr-t" }, el("b", {}, o.title), el("small", {}, `${KINDS[o.kind].label} · ${o.badge}`))));
      }));
      view.replaceChildren(head, fields, el("p", { class: "cr-k" }, "Related"), rel);
      for (const a of document.querySelectorAll("#cr-app .cr-side a")) {
        a.toggleAttribute("aria-current", a.getAttribute("href") === `#/${KINDS[r.kind].path}`);
      }
    }

    // Seed: an afternoon of hopping between records, at realistic ages.
    const seed = [
      ["brightmoor", 52], ["inv-2031", 47], ["tk-4471", 40, true], ["quellan", 31], ["solace", 23],
      ["inv-2044", 16], ["tarnwick", 10], ["nw-renewal", 6], ["c-tanaka", 3], ["inv-2048", 0],
    ];
    trail.crumbs = seed.map(([id, min, pinned]) => ({ ...crumbOf(id), visitedAt: Date.now() - min * MIN, pinned: Boolean(pinned) }));
    show("inv-2048");

    // The demo is a single-page app: links route in place (the trail records them on its own).
    document.getElementById("cr-app").addEventListener("click", (e) => {
      const a = e.target.closest("a[href^='#/']");
      if (!a || trail.contains(a)) return;
      e.preventDefault();
      if (a.dataset.crumbId) show(a.dataset.crumbId);
    });
    // Opening a crumb: route in place, then confirm the visit (kept in place: it is a step).
    trail.addEventListener("mv-crumb-open", (e) => {
      e.preventDefault();
      const { crumb, source, direction } = e.detail;
      show(crumb.id);
      trail.visit(crumbOf(crumb.id));
      say(`Opened “${crumb.label}” (${direction ?? source}).`);
    });
    trail.addEventListener("mv-crumb", (e) => {
      const { crumb, revisit, step } = e.detail;
      if (!step) say(revisit ? `Revisited “${crumb.label}”.` : `New crumb: “${crumb.label}”.`);
    });
    trail.addEventListener("mv-crumb-pin", (e) => say(`${e.detail.pinned ? "Pinned" : "Unpinned"} “${e.detail.crumb.label}”.`));
    trail.addEventListener("mv-crumb-remove", (e) => { if (e.detail.reason === "user") say(`Removed “${e.detail.crumb.label}”.`); });

    function say(text) {
      const t = new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
      log.textContent = `${t} · ${text}`;
    }

    document.getElementById("cr-ff").addEventListener("click", () => {
      trail.crumbs = trail.crumbs.map((c) => ({ ...c, visitedAt: c.visitedAt - 10 * MIN }));
      say("Ten minutes later: older crumbs fade.");
    });
    document.getElementById("cr-move").addEventListener("change", (e) => {
      trail.revisit = e.target.checked ? "move" : "stay";
      say(e.target.checked ? "Revisits move the crumb to the end." : "Revisits refresh the crumb in place.");
    });
    document.getElementById("cr-clear").addEventListener("click", () => {
      const n = trail.clear();
      say(`Cleared ${n} unpinned crumbs.`);
    });

    // Docs: a second, quieter trail.
    docTrail.crumbs = [
      { id: "/docs/errors", label: "Errors", href: "#/docs/errors", kind: "doc", visitedAt: Date.now() - 80_000 },
      { id: "/docs/webhooks", label: "Webhooks", href: "#/docs/webhooks", kind: "doc", visitedAt: Date.now() - 45_000 },
      { id: "/docs/rate-limits", label: "Rate limits", href: "#/docs/rate-limits", kind: "doc", visitedAt: Date.now() - 12_000 },
    ];
    document.getElementById("cr-docs").addEventListener("click", (e) => {
      if (e.target.closest("a[href^='#/']")) e.preventDefault();
    });
    docTrail.addEventListener("mv-crumb-open", (e) => {
      e.preventDefault();
      docTrail.visit({ id: e.detail.crumb.id, label: e.detail.crumb.label, href: e.detail.crumb.href, kind: e.detail.crumb.kind });
    });
  </script>
</div>

API

Attributes

Name类型DefaultDescription
maxnumber6How many unpinned crumbs (the current one included) stay on the trail. Each newer visit pushes the others one step toward folding: the crumb of rank max folds into the “earlier” menu. Pinned crumbs don't count. When the trail is too narrow, fewer are shown (the oldest fold early and come back when room returns); if even the pinned and current crumbs don't fit, their labels shrink and the button shows “+N” only.
lifetimetime ("30m", "2h", "90s", ms number)30mTime after which an unpinned crumb has fully faded and folds, even if few visits followed. Freshness is the lower of the time left and the rank left, so a crumb fades with time and with newer visits.
revisitmove | stay | repeatmoveWhat a visit to a place already on the trail does: move it to the end (refreshed), refresh it in place, or add a new crumb (a strict chronological trail; the same place twice in a row still refreshes). Steps from the trail itself (click, keyboard, Alt+[ / Alt+]) always refresh in place.
track"links" | "history" | "links history"Automatic visits. links: primary same-origin clicks on a[href] outside the trail (routers calling preventDefault still count; target=_blank, download, modified clicks and plain #section anchors are skipped; hash routes like #/deals/42 count). history: the current page on first connection, then every history entry change (Navigation API, else popstate + hashchange), labeled with document.title (re-read 300 ms later for routers that set it late). Without track, call visit().
track-rootCSS selectorOnly links inside this element are tracked (default: the whole document).
persiststringStorage key (opt-in): the trail, its current crumb and a pending step are saved under mv-crumbs:<key> on every change and restored on load. Put the user id in the key for a per-user trail. Icons given as Nodes are not stored (names and URLs are).
storagesession | localsessionsessionStorage (one trail per tab session) or localStorage (survives restarts, shared by tabs on load).
limitnumber50Most crumbs kept in total (trail + earlier). Beyond it the oldest unpinned crumb is dropped for good (mv-crumb-remove, reason "limit").
shortcutsstringAlt+[, Alt+]Back and forward shortcuts, page-wide, as two comma-separated combos (Alt, Ctrl, Shift, Meta + a key; bracket keys match by physical key, so Option+[ works on macOS). Ignored while typing in a field. "none" disables them.
labelstringRecently visitedAccessible name of the nav landmark.
localeBCP 47 tagen-USLocale of relative times (“4 minutes ago”).
data-variant"plain"Text crumbs without pill backgrounds, for page headers and docs.
data-size"sm"Compact trail.
data-crumb-id / data-crumb-label / data-crumb-kind / data-crumb-iconon tracked linksOverride the id (default: normalized path, search and hash route), label (default: aria-label, title, then text), kind and icon of the crumb a link drops.
data-crumb="off" / data-crumb-ignoreon links / on containersLinks (or every link inside a container) that never drop a crumb, e.g. section tabs or the main menu.

Properties

Name类型Description
crumbsArray<{ key, id, label, href, kind, icon, pinned, current, visitedAt, firstVisitedAt, visits, data, freshness, folded }>Snapshot of the trail in chronological trail order (pinned crumbs are displayed first; freshness 0-1, folded when it sits in the earlier menu). Set it to restore a trail from your server (id, label, href, kind, icon, pinned, visitedAt, data; current: true marks the current one, else the last); no mv-crumb events are emitted, keys are kept so re-setting a modified snapshot animates in place.
currentstring | nullId of the crumb the user is on (read-only).
kindsRecord<string, { label?, icon?, color? }>Per-kind label (shown in the tooltip and descriptions), icon (built-in name, image URL or Node) and color (any CSS color, e.g. var(--mv-info)) that tints the icon while the crumb is fresh. Built-in icons: page, doc, person, building, target, receipt, box, tag, ticket, code, folder, chart, mail, settings, search, columns, calendar, book, with aliases (customer, contact, company, deal, invoice, order, product, issue, repo, report…). Declarative alternative: <template data-kind="deal" data-label="Deal" data-color="…"><svg>…</svg></template> children.
resolve({ url, href, source, element }) => Partial<crumb> | false | nullHook for tracked visits (links and history): return fields to merge (id, label, kind, icon, data) or false to skip the visit (login pages, search results…).
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (keys: label, empty, more ({count}), moreShort, moreLabel, allLabel, panelTitle, panelCount, onTrail, earlier, clear, justNow, visited ({time}), pinned, current, pin ({label}), remove ({label}), pinShort, unpinShort, removeShort, hint, tipHint, announcePin, announceUnpin, announceRemove, announceClear). English defaults.
max / lifetime / revisit / track / trackRoot / persist / storage / limit / shortcuts / label / localereflectedMirror the attributes.

Methods

NameDescription
visit({ id, label, href, kind, icon, data, pinned?, visitedAt? })Records a visit (id defaults to href). Emits the cancelable mv-crumb first. A visit to the crumb just opened from the trail is recognized as a step and refreshes it in place. Returns the crumb snapshot, or null if cancelled or invalid (javascript: and data: hrefs are dropped).
open(id)Opens a crumb as if clicked: emits the cancelable mv-crumb-open, then (not cancelled) marks it current and follows its href with location.assign.
back() / forward()Step to the previous / next crumb along the trail (what Alt+[ / Alt+] do). Return false at either end.
pin(id, force?)Toggles (or sets) the pin. An unpinned crumb starts fading from three quarters of its lifetime, not all at once. Returns the new state.
removeItem(id)Removes a crumb. Returns false if not found.
clear({ keepPinned = true })Removes every crumb except the current one (and the pinned ones unless keepPinned is false). Returns how many.
refresh()Recomputes freshness now (it is otherwise recomputed about 60 times per lifetime, and when the tab comes back).

Events

NameDescription
mv-crumbCancelable, before a visit is recorded. detail: { crumb: { id, label, href, kind, icon, data } (editable: change the label or kind before it lands), source: "api" | "link" | "history" | "menu", revisit, step }. preventDefault() skips the visit.
mv-crumb-openCancelable, when a crumb is opened from the trail or the earlier menu, or by back() / forward() / open(). detail: { crumb, source: "click" | "keyboard" | "shortcut" | "menu" | "api", direction: "back" | "forward" | null }. Call preventDefault() to route with your SPA router, then call visit() for that id when the view renders (it is recognized as a step). Not cancelled: links navigate natively, open()/shortcuts use location.assign. Modified clicks (new tab) don't emit it.
mv-crumb-pinA crumb was pinned or unpinned. detail: { crumb, pinned }.
mv-crumb-removeA crumb left the trail for good. detail: { crumb, reason: "user" | "api" | "clear" | "limit" }.
mv-crumbs-changeAfter any change (visit, step, pin, removal), batched per task: detail: { crumbs }. Save it to your server for a trail that follows the user across devices.

Content structure

NameDescription
template[data-kind]Optional <template> children defining the icon (and data-label, data-color) of a kind. Nothing else is read from the children.

CSS classes

NameDescription
mv-crumbs-nav / mv-crumbs-listThe labelled nav landmark and its <ol>. The list gets data-tight (and the host too) when even pinned and current crumbs had to shrink, and data-overflow="start | end | both" (soft edges) in the rare case it still scrolls.
mv-crumbs-itemOne crumb (<li>): data-stage 0-3 (3 = fresh), data-pinned, data-current, data-kind, --_f (freshness 0-1) and --_q (the same in 4 steps). Holds .mv-crumbs-link (a, or button without href: .mv-crumbs-icon, .mv-crumbs-label, .mv-crumbs-pinmark) and a visually hidden description.
mv-crumbs-moreThe leading “+N earlier” button (.mv-crumbs-more-text, .mv-crumbs-more-short in tight mode; data-folded when anything is folded, an icon-only “all places” button otherwise).
mv-crumbs-panelAll places popover: -panel-head, .mv-crumbs-group (On the trail / Earlier) with .mv-crumbs-row (.mv-crumbs-row-link, pin, remove), -panel-foot with .mv-crumbs-clear.
mv-crumbs-tipHover / focus card (aria-hidden: its facts are in each crumb's description): -tip-label, -tip-meta, -tip-actions with .mv-crumbs-tip-btn Pin / Remove (pointer only, never focused), -tip-hint instead of the buttons on keyboard focus (pointer-events: none then).

CSS variables

NameDefaultDescription
--mv-crumbs-label-max11remLabel width of a fresh crumb (9rem in data-size="sm").
--mv-crumbs-label-min3.25remLabel width of a crumb about to fold.
--mv-crumbs-fade-floor0.5Opacity of the pill, icon and connector of a crumb about to fold (hover and focus bring it back to 1). The label only dims to --mv-fg-subtle, so text stays readable.
--mv-crumbs-pinvar(--mv-accent)Color of the pin mark and of pinned crumbs' edge.
--mv-crumbs-surfacevar(--mv-surface)Plain variant: the background the trail sits on, so the pin / remove overlay blends in.

Accessibility

The trail is a nav landmark named “Recently visited” (label) holding an ordered list, one list item per crumb; the current place carries aria-current="page". The fade is purely visual: every link keeps its full label as text (only clipped with an ellipsis by CSS) and is described by a visually hidden line (“Invoice, visited 4 minutes ago, pinned”) plus the keyboard hint, so screen readers always get the whole name, kind and age. The hover card repeating those facts is aria-hidden and its Pin / Remove buttons are a pointer shortcut that never takes focus. Visits are never announced; only user actions are, politely (“Removed ‘INV-2044’ from the trail.”, pin and clear results). Keyboard: the trail is a single Tab stop (the current crumb by default) with roving focus: Left / Right, Home / End move between the “+N earlier” button and the crumbs, Enter opens, P pins or unpins, Delete or Backspace removes and focus moves to the neighbor; focus shows the card with the key hints. Alt+[ / Alt+] (configurable, ignored while typing in a field) step back and forth along the trail from anywhere on the page. The “+N earlier” button (aria-expanded, a name that includes its visible text) opens a labelled non-modal dialog listing every place, earlier ones first, each as a real link with real Pin (aria-pressed) and Remove buttons in the Tab order, always visible on touch screens: this is the path for screen reader browse mode, where single-letter keys belong to the reader. Focus moves to its first place, Up / Down / Home / End move between places, P and Delete work there too, Escape or an outside click closes it and Escape returns focus to the button. Hovered or focused crumbs return to full strength. The fade dims the pill, icon and connector (never below 50%, --mv-crumbs-fade-floor), while the label text only dims to the subtle text color, so it keeps a 4.5:1 contrast in both themes. Reduced motion (OS or data-motion="reduce"): the fade moves in four discrete opacity and size steps with no transition, and crumbs appear, move and fold without sliding. Forced colors: crumbs use system button colors at full opacity, faded ones get a dashed border, the current one a Highlight border.

本页面由 AI 翻译。报告翻译问题