エクスクルーシブ

Combo <mv-combo>

パワーユーザー向けアプリのためのキーボードシーケンスショートカットエンジン。入力バッファーをライブ表示します。

文化的リファレンス

ストリートファイターII、Capcom(岡本吉起、西谷亮)(1991年、ゲーム)。必殺技は受付時間内に入力しなければならない入力シーケンスで、トレーニングモードでは直近の入力のバッファーがリアルタイムに表示され、ゲームは多少雑な入力も許容してくれます。UI では、ショートカットがステップごとに受付時間を持つキーシーケンスになり、入力バッファーのライブ表示、迷ったときに現れる続きの候補リスト、そして Caps Lock、キーボード配列、余計なキー入力に対する意図的な許容を備えます。

仕組み

パワーユーザー向けアプリのためのキーボードシーケンスショートカットエンジン。入力バッファーをライブ表示します。ムーブはステップの連続(「g i」「g p a」)で、各ステップは単一のキーまたは同時押しのコードです(「Mod+k」「Alt+ArrowDown」。Mod は Apple では ⌘、それ以外では Ctrl)。<mv-combo-move keys href|for label group>、任意の要素への data-combo(ムーブがその要素をクリックするか、フィールドにフォーカスします)、または register({ keys, label, run }) で宣言します。各ステップは前のステップから受付時間(既定では 1.5 秒)以内に入力する必要があり、その残り時間は、これまでに入力したキーを表示する小さな aria-hidden のバッファー内で細い線が減っていくことで示されます。シーケンスの途中で少し止まると、続けて入力できるものを一覧表示する続きパネルが現れ、ライブで絞り込まれます。このときタイマーも止まるので、入力がゆっくりな人でもシーケンスが途切れません。組み込みの許容機能:Caps Lock は無視され、非ラテン文字の配列や macOS の Option は物理キーで照合され、余計なキーはそのキーからシーケンスをやり直し、Backspace は最後のステップを取り消します。別のムーブの接頭辞でもあるムーブ(「g p」と「g p a」)は受付時間が過ぎるまで待つか、Enter で即座に実行されます。mv-combo はキャンセル可能です(SPA のルーティング向け)。? を押すと、現在のムーブから生成されたチートシートの <dialog> が開き、各ショートカットを新しいキーを記録して割り当て直したり(競合を検出し、persist で保存)、文字キーのショートカットを無効にしたりできます。キーはプラットフォームに合わせて表示され(⌘⇧P と Ctrl+Shift+P)、[data-combo-hint] 要素には現在のキーが描画されます。フィールド、メニュー、グリッドでの入力が横取りされることはありません。

カテゴリーユーティリティ
タイプWeb Component(<mv-combo>)
ステータス安定版
キットパワーユーザー向けナビゲーション
同時にインストールされるものbutton, input, kbd, checkbox, switch
Keywordsexclusive, culture, keyboard, shortcuts, hotkeys, keybindings, sequence, chord, which-key, cheat-sheet, remap, power-user, accessibility, wcag-2.1.4, navigation, input-display

When to use

  • A dense web app (tracker, inbox, admin, editor) needs multi-key shortcuts like g then i to jump between sections
  • Power users want shortcuts they can discover by pausing mid-sequence instead of memorizing a cheat sheet first
  • Shortcuts must satisfy WCAG 2.1.4: users can turn character-key shortcuts off or remap them, saved per user
  • One declarative list of shortcuts should drive the key hints in menus, the cheat sheet and the handlers at once

Avoid when

  • Users search a large set of actions by name rather than remembering keys → use Command instead
  • The only need is to display a shortcut next to a label, with no behavior attached → use Kbd instead
  • The page is mostly text entry (a document editor, a chat): single keys belong to typing and chords to the editor

インストール

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

Marvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["combo"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

コピーされるファイル(依存関係を含む):tokens/tokens.css, core/base.css, components/button/button.css, components/input/input.css, components/kbd/kbd.css, components/checkbox/checkbox.css, components/switch/switch.css, core/dom.js, core/element.js, core/motion.js, components/combo/combo.js, components/combo/combo.css。

使い方

クイックスタート、動作する最小限のマークアップ:

<mv-combo>
  <mv-combo-move keys="g i" label="Go to inbox" href="/inbox"></mv-combo-move>
  <mv-combo-move keys="g s" label="Go to settings" href="/settings"></mv-combo-move>
</mv-combo>

基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:

<div id="cb-demo" style="width:min(100%,60rem);margin-inline:auto">
  <style>
    #cb-demo { display:grid; gap:1rem }
    #cb-demo .cb-app { display:grid; grid-template-columns:13.5rem minmax(0,1fr); min-height:30rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden; transition:grid-template-columns var(--mv-duration-normal) var(--mv-ease-out) }
    #cb-demo .cb-app[data-collapsed] { grid-template-columns:3.75rem minmax(0,1fr) }
    #cb-demo .cb-side { display:flex; flex-direction:column; gap:1rem; min-width:0; padding:1rem .75rem; border-inline-end:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #cb-demo .cb-brand { display:flex; align-items:center; gap:.5rem; padding:0 .375rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em; white-space:nowrap }
    #cb-demo .cb-logo { display:grid; place-items:center; flex:none; width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
    #cb-demo .cb-logo svg { width:1rem; height:1rem }
    #cb-demo .cb-nav { display:grid; gap:.125rem }
    #cb-demo .cb-nav a { display:flex; align-items:center; gap:.625rem; height:2rem; padding:0 .5rem; border-radius:var(--mv-radius-md); color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500; text-decoration:none; white-space:nowrap; transition:background-color var(--mv-duration-fast), color var(--mv-duration-fast) }
    #cb-demo .cb-nav a:hover { background:var(--mv-bg-muted); color:var(--mv-fg) }
    #cb-demo .cb-nav a:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
    #cb-demo .cb-nav a[aria-current="page"] { background:var(--mv-surface); color:var(--mv-fg); box-shadow:var(--mv-shadow-xs) }
    #cb-demo .cb-nav a[aria-current="page"]:focus-visible { box-shadow:var(--mv-focus-ring) }
    #cb-demo .cb-nav svg { flex:none; width:1rem; height:1rem }
    #cb-demo .cb-nav .cb-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis }
    #cb-demo .cb-count { color:var(--mv-fg-subtle); font-size:.75rem; font-variant-numeric:tabular-nums }
    #cb-demo .cb-nav [data-combo-hint] { opacity:.75 }
    #cb-demo .cb-nav a:hover [data-combo-hint], #cb-demo .cb-nav a[aria-current] [data-combo-hint] { opacity:1 }
    #cb-demo .cb-app[data-collapsed] :is(.cb-name, .cb-count, .cb-brand-name, [data-combo-hint], .cb-tip) { display:none }
    #cb-demo .cb-tip { margin:auto .375rem 0; color:var(--mv-fg-subtle); font-size:.75rem; line-height:1.5 }
    #cb-demo .cb-main { position:relative; display:flex; flex-direction:column; min-width:0 }
    #cb-demo .cb-top { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap; padding:1rem 1.25rem; border-bottom:1px solid var(--mv-border) }
    #cb-demo .cb-heading { flex:1 1 12rem; min-width:0 }
    #cb-demo .cb-heading h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
    #cb-demo .cb-heading p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.75rem }
    #cb-demo .cb-search { position:relative; flex:0 1 14rem }
    #cb-demo .cb-search .mv-input { width:100%; box-sizing:border-box; padding-inline-end:3rem }
    #cb-demo .cb-search [data-combo-hint] { position:absolute; inset-inline-end:.5rem; top:50%; translate:0 -50%; pointer-events:none }
    #cb-demo .cb-list { margin:0; padding:.5rem; list-style:none; display:grid; gap:.125rem }
    #cb-demo .cb-issue { display:grid; grid-template-columns:auto 4.25rem minmax(0,1fr) auto auto; align-items:center; gap:.75rem; padding:.625rem .75rem; border-radius:var(--mv-radius-md); color:inherit; text-decoration:none; font-size:.8125rem; transition:padding var(--mv-duration-normal) var(--mv-ease-out), background-color var(--mv-duration-fast) }
    #cb-demo .cb-app[data-compact] .cb-issue { padding-block:.3125rem }
    #cb-demo .cb-issue:hover { background:var(--mv-bg-subtle) }
    #cb-demo .cb-issue:focus-visible { outline:none; box-shadow:var(--mv-focus-ring); background:var(--mv-bg-subtle) }
    #cb-demo .cb-dot { width:.625rem; height:.625rem; border-radius:50%; border:1.5px solid var(--mv-fg-subtle) }
    #cb-demo .cb-dot[data-s="progress"] { border-color:var(--mv-warning); background:linear-gradient(90deg, var(--mv-warning) 50%, transparent 0) }
    #cb-demo .cb-dot[data-s="review"] { border-color:var(--mv-accent); background:var(--mv-accent-subtle) }
    #cb-demo .cb-dot[data-s="done"] { border-color:var(--mv-success); background:var(--mv-success) }
    #cb-demo .cb-id { color:var(--mv-fg-subtle); font-variant-numeric:tabular-nums }
    #cb-demo .cb-t { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #cb-demo .cb-date { color:var(--mv-fg-subtle); font-size:.75rem; white-space:nowrap }
    #cb-demo .cb-who { display:grid; place-items:center; width:1.5rem; height:1.5rem; border-radius:50%; background:var(--mv-bg-emphasis); font-size:.625rem; font-weight:600 }
    #cb-demo .cb-controls { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.875rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #cb-demo .cb-group { display:flex; align-items:center; gap:.625rem 1rem; flex-wrap:wrap }
    #cb-demo .cb-field { display:flex; align-items:center; gap:.5rem; color:var(--mv-fg-muted); font-size:.75rem }
    #cb-demo .mv-choice { font-size:.8125rem }
    #cb-demo .cb-log { margin:0; min-height:1.25rem; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); text-align:center }
    @media (max-width:44rem) {
      #cb-demo .cb-app { grid-template-columns:minmax(0,1fr) }
      #cb-demo .cb-side { display:none }
      #cb-demo .cb-issue { grid-template-columns:auto minmax(0,1fr) auto }
      #cb-demo .cb-id, #cb-demo .cb-date { display:none }
    }
  </style>

  <section class="cb-app" id="cb-app" aria-label="Issue tracker">
    <aside class="cb-side">
      <span class="cb-brand">
        <span class="cb-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19V5l14 14V5"/></svg></span>
        <span class="cb-brand-name">Tarnwick</span>
      </span>
      <nav class="cb-nav" aria-label="Workspace" data-combo-group="Navigation">
        <a href="#inbox" data-view="inbox" data-combo="g i" data-combo-label="Go to inbox" aria-current="page">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.5 5h13L22 12v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6z"/></svg>
          <span class="cb-name">Inbox</span><span class="cb-count">3</span><kbd data-combo-hint></kbd>
        </a>
        <a href="#my-issues" data-view="my-issues" data-combo="g m" data-combo-label="Go to my issues">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/></svg>
          <span class="cb-name">My issues</span><kbd data-combo-hint></kbd>
        </a>
        <a href="#projects" data-view="projects" data-combo="g p" data-combo-label="Go to projects">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
          <span class="cb-name">Projects</span><kbd data-combo-hint></kbd>
        </a>
        <a href="#cycles" data-view="cycles" data-combo="g c" data-combo-label="Go to cycles">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>
          <span class="cb-name">Cycles</span><kbd data-combo-hint></kbd>
        </a>
        <a href="#settings" data-view="settings" data-combo="g s" data-combo-label="Go to settings">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></svg>
          <span class="cb-name">Settings</span><kbd data-combo-hint></kbd>
        </a>
      </nav>
      <p class="cb-tip">Press <kbd class="mv-kbd" data-variant="flat">?</kbd> for every shortcut. Pause after the first key to see what comes next.</p>
    </aside>

    <div class="cb-main">
      <header class="cb-top">
        <div class="cb-heading">
          <h3 id="cb-title">Inbox</h3>
          <p id="cb-sub">3 unread · updated 2 minutes ago</p>
        </div>
        <div class="cb-search">
          <input id="cb-search" class="mv-input" data-size="sm" type="search" placeholder="Search issues" aria-label="Search issues">
          <kbd data-combo-hint="search-issues"></kbd>
        </div>
        <button type="button" class="mv-button" data-size="sm" id="cb-new" data-combo="n i" data-combo-label="New issue" data-combo-group="Issues">
          New issue <kbd data-combo-hint></kbd>
        </button>
      </header>

      <ul class="cb-list" aria-label="Issues">
        <li><a class="cb-issue" href="#NW-214"><span class="cb-dot" data-s="progress" aria-label="In progress"></span><span class="cb-id">NW-214</span><span class="cb-t">Checkout fails when the coupon field is left empty</span><span class="cb-date">Sep 22</span><span class="cb-who" aria-label="Assigned to Amara Okafor">AO</span></a></li>
        <li><a class="cb-issue" href="#NW-209"><span class="cb-dot" data-s="review" aria-label="In review"></span><span class="cb-id">NW-209</span><span class="cb-t">Localize invoice PDFs for Japanese and German customers</span><span class="cb-date">Sep 21</span><span class="cb-who" aria-label="Assigned to Kenji Watanabe">KW</span></a></li>
        <li><a class="cb-issue" href="#NW-203"><span class="cb-dot" aria-label="To do"></span><span class="cb-id">NW-203</span><span class="cb-t">Add SAML single sign-on for the Enterprise plan</span><span class="cb-date">Sep 19</span><span class="cb-who" aria-label="Assigned to Lucía Fernández">LF</span></a></li>
        <li><a class="cb-issue" href="#NW-198"><span class="cb-dot" data-s="done" aria-label="Done"></span><span class="cb-id">NW-198</span><span class="cb-t">Dashboard charts flicker on Safari 18 when resizing</span><span class="cb-date">Sep 17</span><span class="cb-who" aria-label="Assigned to Noah Schmidt">NS</span></a></li>
        <li><a class="cb-issue" href="#NW-191"><span class="cb-dot" aria-label="To do"></span><span class="cb-id">NW-191</span><span class="cb-t">Export monthly usage to CSV from the billing page</span><span class="cb-date">Sep 15</span><span class="cb-who" aria-label="Assigned to Priya Raman">PR</span></a></li>
      </ul>

      <mv-combo id="cb-combo" contained persist="cb-demo">
        <mv-combo-move keys="g p a" name="archived-projects" label="Go to archived projects" group="Navigation"></mv-combo-move>
        <mv-combo-move keys="Mod+k" for="#cb-search" name="search-issues" label="Search issues" group="Issues" in-fields></mv-combo-move>
      </mv-combo>
    </div>
  </section>

  <div class="cb-controls">
    <span class="cb-group">
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="cb-on" checked> Shortcuts</label>
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="cb-chars" checked> Character keys</label>
      <span class="cb-field"><span id="cb-display-l">Display</span>
        <mv-segmented id="cb-display" value="buffer" aria-labelledby="cb-display-l">
          <button value="buffer">Buffer</button><button value="history">History</button><button value="none">Off</button>
        </mv-segmented>
      </span>
      <span class="cb-field"><span id="cb-platform-l">Keys</span>
        <mv-segmented id="cb-platform" value="auto" aria-labelledby="cb-platform-l">
          <button value="auto">Auto</button><button value="mac">macOS</button><button value="windows">Windows</button>
        </mv-segmented>
      </span>
    </span>
    <span class="cb-group">
      <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cb-replay">Replay G P A</button>
      <button type="button" class="mv-button" data-size="sm" id="cb-sheet">All shortcuts <kbd class="mv-kbd">?</kbd></button>
    </span>
  </div>
  <p class="cb-log" id="cb-log" aria-live="polite">Type G, then pause: the next keys appear.</p>

  <script type="module">
    const combo = document.getElementById("cb-combo");
    const app = document.getElementById("cb-app");
    const log = document.getElementById("cb-log");
    const title = document.getElementById("cb-title");
    const sub = document.getElementById("cb-sub");
    const nav = [...app.querySelectorAll(".cb-nav a")];
    const VIEWS = {
      inbox: ["Inbox", "3 unread · updated 2 minutes ago", "inbox"],
      "my-issues": ["My issues", "5 open · 2 due this week", "my-issues"],
      projects: ["Projects", "8 active · Q4 roadmap", "projects"],
      archived: ["Archived projects", "14 archived · last on Aug 29, 2026", "projects"],
      cycles: ["Cycles", "Cycle 38 · Sep 15 to Sep 28", "cycles"],
      settings: ["Settings", "Workspace, members and billing", "settings"],
    };
    const say = (text) => { log.textContent = text; };
    const show = (view) => {
      const [t, s, navKey] = VIEWS[view];
      title.textContent = t;
      sub.textContent = s;
      for (const a of nav) {
        if (a.dataset.view === navKey) a.setAttribute("aria-current", "page");
        else a.removeAttribute("aria-current");
      }
    };
    for (const a of nav) a.addEventListener("click", (e) => { e.preventDefault(); show(a.dataset.view); });
    for (const a of app.querySelectorAll(".cb-issue")) a.addEventListener("click", (e) => e.preventDefault());
    document.getElementById("cb-new").addEventListener("click", () => say("New issue draft opened in Tarnwick."));

    // Moves registered from JS: behavior lives in run().
    const rows = () => [...app.querySelectorAll(".cb-issue")];
    const step = (d) => {
      const list = rows();
      const i = list.indexOf(document.activeElement);
      list[(i + d + list.length) % list.length].focus();
    };
    combo.register({ id: "next-issue", keys: "Alt+ArrowDown", label: "Next issue", group: "Issues", run: () => step(1) });
    combo.register({ id: "previous-issue", keys: "Alt+ArrowUp", label: "Previous issue", group: "Issues", run: () => step(-1) });
    combo.register({ id: "toggle-sidebar", keys: "t s", label: "Toggle sidebar", group: "View", run: () => app.toggleAttribute("data-collapsed") });
    combo.register({ id: "toggle-compact", keys: "t d", label: "Toggle compact rows", group: "View", run: () => app.toggleAttribute("data-compact") });

    combo.addEventListener("mv-combo", (e) => {
      const { id, label, keys } = e.detail;
      if (id === "archived-projects") show("archived");
      say(`mv-combo · ${combo.format(keys)} → ${label}`);
    });
    combo.addEventListener("mv-combo-miss", (e) => say(`mv-combo-miss · ${combo.format(e.detail.keys)} (${e.detail.reason})`));
    combo.addEventListener("mv-combo-remap", (e) => say(e.detail.reset ? "mv-combo-remap · default restored" : `mv-combo-remap · ${e.detail.label} → ${combo.format(e.detail.keys)}`));

    const on = document.getElementById("cb-on");
    const chars = document.getElementById("cb-chars");
    chars.checked = combo.characterKeys;
    on.addEventListener("change", () => { combo.disabled = !on.checked; });
    chars.addEventListener("change", () => { combo.characterKeys = chars.checked; });
    combo.addEventListener("mv-combo-change", (e) => { chars.checked = e.detail.characterKeys; });
    document.getElementById("cb-display").addEventListener("mv-change", (e) => { combo.display = e.detail.value; });
    document.getElementById("cb-platform").addEventListener("mv-change", (e) => { combo.platform = e.detail.value; });
    document.getElementById("cb-sheet").addEventListener("click", () => combo.openSheet());

    const wait = (ms) => new Promise((r) => setTimeout(r, ms));
    document.getElementById("cb-replay").addEventListener("click", async () => {
      combo.cancel();
      combo.press("g");
      await wait(900);   // pause: the continuation panel appears and the clock stops
      combo.press("p");
      await wait(500);
      combo.press("a");
    });

    // Initial state: a sequence already started, waiting for its next key.
    setTimeout(() => { if (combo.characterKeys && !combo.disabled) combo.press("g"); }, 150);
  </script>
</div>

API

Attributes

NameタイプDefaultDescription
timeouttime (ms number, "1.5s", "900ms")1500Timing window between two steps. It restarts on each key, so it is per step, not for the whole sequence. Stops entirely while the continuation panel is shown (unless the buffer is already a complete move waiting for a longer one).
hint-delaytime | "none"700Pause mid-sequence after which the continuation panel appears. "none" never shows it (the window then always applies).
helpkeys | "none"?Keys that open the cheat sheet. It is the only single character key allowed without single-keys; it obeys the character-key switch and can be remapped like any move. "none" removes it (call openSheet() from a button).
displaybuffer | history | nonebufferbuffer: the keys of the current sequence, then what they did. history: the same plus the last four moves stacked for a few seconds (screencasts, onboarding). none: no buffer (the continuation panel still follows hint-delay).
placementbottom | bottom-start | bottom-end | top | top-start | top-endbottomWhere the buffer and panel sit (viewport, or the positioned parent with contained).
containedbooleanThe buffer is positioned inside the nearest positioned ancestor (an app frame, a demo) instead of the viewport.
platformauto | mac | windows | linuxautoHow keys are displayed (⌘ ⌥ ⇧ ⌃ vs Ctrl Alt Shift Win). Display only: Mod always matches the real platform’s key.
scopeCSS selectorOnly keys pressed while focus is inside a matching element count, and [data-combo] elements outside it are ignored. Default: the whole document.
single-keysbooleanAllows one-step character shortcuts (“c”, “/”). Off by default: they are ignored with a console warning (WCAG 2.1.4). Even when allowed they obey the character-key switch.
in-fieldsbooleanMoves may fire while typing in fields. Per move: in-fields on <mv-combo-move>, data-combo-in-fields, register({ inFields }). Even then, only sequences starting with a real modifier chord, Escape or a function key fire in a field, never plain letters.
character-keyson | offonInitial state of the character-key switch (shortcuts made only of letters, digits or symbols). The user’s choice in the cheat sheet, or the characterKeys property, overrides it.
persiststringSaves remaps and the character-key choice in localStorage under mv-combo:<value> ("default" when empty). Omit it and use the remaps / characterKeys properties to store them server-side.
disabledbooleanGlobal off switch: no key is consumed, nothing shows.
quietbooleanDon’t announce the label of executed moves in the polite live region.
<mv-combo-move keys label group name href target for in-fields disabled>child elementDeclarative move. keys: sequence, alternatives separated by commas ("g i, Mod+Shift+i"). label: shown in the panel and sheet. name: stable id for remaps (default: label slug). href (+ target): navigates. for: selector of an element to focus (fields) or click. Neither: handle it in mv-combo.
data-combo / data-combo-label / data-combo-group / data-combo-name / data-combo-action / data-combo-in-fieldson any elementMakes the element a move: it is clicked (or focused for fields; data-combo-action="focus | click" forces it). Active only while it is rendered, enabled and not inert, so contextual shortcuts follow the UI. Label: data-combo-label, aria-label, then its text. data-combo-group also works on a container. Single-step chords get aria-keyshortcuts.
data-combo-hinton any element (usually <kbd>)Filled with the current keycaps of the move it belongs to: its closest [data-combo] element, or the move id given as value. Follows remaps and platform; hidden when the move is switched off. data-variant, data-size, data-then (show “then” between steps) are passed to the keycaps.
data-combo-ignoreon any elementKeys pressed inside it are never taken (like fields, [role=menu|listbox|grid|tree|textbox|combobox|slider|spinbutton|application]).

Properties

NameタイプDescription
characterKeysbooleanThe WCAG 2.1.4 off switch for character-key shortcuts. Persisted with persist.
remapsRecord<moveId, keys>User remaps. Set it from your own storage; read it after mv-combo-change.
movesArray<{ id, label, group, keys, defaultKeys, display, custom, source, element, active }>Snapshot of every declared move (read-only). source: "move" | "element" | "register" | "help".
conflictsArray<{ keys, moves: [id, id] }>Identical sequences declared twice (the first one wins; a console warning is printed once). Prefixes are not conflicts.
bufferstringKeys typed so far in the current sequence ("g p"), "" when idle.
sheetOpenbooleanTrue while the cheat sheet is open.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (sheetTitle, sheetText, filter, filterPlaceholder, close, empty, then, or, general, help, change, reset, custom, off, record, recordEmpty, recordHint, recordHintSpoken, recordNeedKeys, recordSingle, recordConflict, recordSaved, recordRestored, characterKeys, characterKeysHint, resetAll, restoredAll, hintsTitle, hintsCount, hintsCountOne, more, cancel, runNow, allShortcuts, miss). English defaults.
timeout / hintDelay / help / display / placement / platform / scope / persist / singleKeys / inFields / disabled / contained / quietreflectedMirror the attributes.

Methods

NameDescription
register({ keys, label, id?, group?, run?, href?, inFields?, when? })Adds a move from JS; run(detail) is called unless mv-combo is canceled, when() returning false makes it inactive. Returns an unregister function.
unregister(id)Removes a registered move.
remap(id, keys) / resetRemaps()Changes a move’s keys (null or "" restores the default); returns false for invalid keys. resetRemaps() restores every default.
press(keys)Feeds keys as if typed, one step or a whole sequence ("g", later "p"): onboarding tours, demos, tests. Returns true if consumed.
trigger(id)Runs a move by id without typing it (from a menu or a command palette); emits mv-combo with source "api".
cancel()Clears the current sequence.
format(keys)Display string on the current platform: "Mod+k" → "⌘K" or "Ctrl+K", "g i" → "G then I".
openSheet() / closeSheet()Opens or closes the cheat sheet (also reachable with the help keys).

Events

NameDescription
mv-comboA move matched. Cancelable: preventDefault() skips the default action (navigation, click, focus, run()), e.g. to route in a SPA. detail: { id, label, group, keys (the matched sequence), source: "keyboard" | "api", element, href }.
mv-combo-missA started sequence led nowhere. detail: { keys, reason: "mismatch" | "timeout" }. Useful to spot shortcuts users expect.
mv-combo-remapThe user is about to save a new shortcut or restore defaults in the sheet (cancelable). detail: { id, label, keys, previous, reset }.
mv-combo-changeUser settings changed in the sheet. detail: { characterKeys, remaps }: store them if you don’t use persist.
mv-combo-sheetThe cheat sheet opened or closed. detail: { open }.

Content structure

NameDescription
(children)<mv-combo-move> elements (display: none). The buffer, a live region and the sheet <dialog> are appended by the component.

CSS classes

NameDescription
mv-combo-hudFixed (or contained) overlay, aria-hidden, pointer-events: none. data-state="idle | typing | match | miss", data-held while the clock is stopped, data-placement, data-display.
mv-combo-bufferThe input display: .mv-combo-strokes (keycaps, .mv-combo-stroke each), .mv-combo-result (-icon, -text), .mv-combo-window (draining timing line).
mv-combo-hintsContinuation panel: -hints-head (-hints-count), -hints-list > .mv-combo-hint (-hint-label; data-more), -hints-foot.
mv-combo-historyRecent moves in display="history": .mv-combo-history-item (data-leaving while fading).
mv-combo-sheetThe cheat-sheet <dialog>: -sheet-head, -sheet-title, -sheet-text, -close, -sheet-tools (.mv-combo-filter), -sheet-body, .mv-combo-group (-group-title), .mv-combo-row (data-custom, data-off, data-recording; -row-label, -tag, -row-keys, -row-actions, -row-btn), -sheet-foot (.mv-combo-chars, -reset-all).
mv-combo-recorderShortcut recorder shown in a row while remapping: -recorder-input (readonly, carries the spoken value), -recorder-keys, -recorder-msg (data-tone="error").
mv-combo-keys / mv-combo-chord / mv-combo-thenRendered sequences: .mv-kbd keycaps grouped per chord, “then” between steps (also inside [data-combo-hint]).

CSS variables

NameDefaultDescription
--mv-combo-accentvar(--mv-accent)Caret, timing line, match tint and check.
--mv-combo-offset1.25remDistance of the buffer from the edge.
--mv-combo-sheet-width48remMaximum width of the cheat sheet.
--mv-combo-recordvar(--mv-danger)Recording dot in the recorder.

Accessibility

Designed not to steal keys: one-step character shortcuts are refused unless single-keys is set, keys are ignored while composing (IME), in text fields, contenteditable and widgets that own letters or arrows (menu, listbox, grid, tree, combobox, textbox, slider, spinbutton, application, [data-combo-ignore]), and even with in-fields a field only receives modifier chords, Escape or function keys. WCAG 2.1.4 is met twice: the cheat sheet has a “Character-key shortcuts” switch (also characterKeys, character-keys="off") and every shortcut can be remapped, both saved per user; disabled is a global off switch. Timing (WCAG 2.2.1): the window applies per step and stops as soon as the continuation panel appears, so a sequence waits for the user indefinitely; Escape, a click or leaving the tab cancels it. The buffer and panel are aria-hidden and pointer-events: none (purely visual echo); the label of each executed move is announced once in a polite live region (quiet turns it off, and a canceled mv-combo is not announced). The cheat sheet is a native modal <dialog> labelled and described by its heading: focus goes to the filter field, Escape or the close button closes it and focus returns to where it was. Each row reads its label and a spoken version of the keys (“Command K”, “G then I”; symbols named) while the keycaps are aria-hidden; edit and restore buttons have explicit names (“Change shortcut for Go to inbox”). Remapping uses a readonly text field (screen readers switch to focus mode, so keys reach it) whose value is the spoken sequence, described by the instructions, with each key and any conflict announced politely; Enter saves, Escape cancels without closing the dialog, Backspace erases, and Tab always leaves (never a keyboard trap). [data-combo] elements get aria-keyshortcuts for single-step chords (sequences cannot be expressed there), and [data-combo-hint] keycaps carry the same spoken text. Reduced motion (OS or data-motion="reduce"): no key pop, shake, blinking caret or draining line. Forced colors: system colors for borders, the caret and the match state.

このページは AI で翻訳されています。翻訳の問題を報告