익스클루시브

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가 담긴 작은 카드가 열립니다. 키보드에서는 로빙 포커스, 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로 번역되었습니다. 번역 문제 신고