エクスクルーシブ
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 |
| Keywords | exclusive, 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/marvelousMarvelous 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 | タイプ | Default | Description |
|---|---|---|---|
timeout | time (ms number, "1.5s", "900ms") | 1500 | Timing 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-delay | time | "none" | 700 | Pause mid-sequence after which the continuation panel appears. "none" never shows it (the window then always applies). |
help | keys | "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). |
display | buffer | history | none | buffer | buffer: 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). |
placement | bottom | bottom-start | bottom-end | top | top-start | top-end | bottom | Where the buffer and panel sit (viewport, or the positioned parent with contained). |
contained | boolean | The buffer is positioned inside the nearest positioned ancestor (an app frame, a demo) instead of the viewport. | |
platform | auto | mac | windows | linux | auto | How keys are displayed (⌘ ⌥ ⇧ ⌃ vs Ctrl Alt Shift Win). Display only: Mod always matches the real platform’s key. |
scope | CSS selector | Only keys pressed while focus is inside a matching element count, and [data-combo] elements outside it are ignored. Default: the whole document. | |
single-keys | boolean | Allows 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-fields | boolean | Moves 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-keys | on | off | on | Initial 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. |
persist | string | Saves 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. | |
disabled | boolean | Global off switch: no key is consumed, nothing shows. | |
quiet | boolean | Don’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 element | Declarative 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-fields | on any element | Makes 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-hint | on 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-ignore | on any element | Keys pressed inside it are never taken (like fields, [role=menu|listbox|grid|tree|textbox|combobox|slider|spinbutton|application]). |
Properties
| Name | タイプ | Description |
|---|---|---|
characterKeys | boolean | The WCAG 2.1.4 off switch for character-key shortcuts. Persisted with persist. |
remaps | Record<moveId, keys> | User remaps. Set it from your own storage; read it after mv-combo-change. |
moves | Array<{ id, label, group, keys, defaultKeys, display, custom, source, element, active }> | Snapshot of every declared move (read-only). source: "move" | "element" | "register" | "help". |
conflicts | Array<{ keys, moves: [id, id] }> | Identical sequences declared twice (the first one wins; a console warning is printed once). Prefixes are not conflicts. |
buffer | string | Keys typed so far in the current sequence ("g p"), "" when idle. |
sheetOpen | boolean | True while the cheat sheet is open. |
strings | Partial<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 / quiet | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-combo | A 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-miss | A started sequence led nowhere. detail: { keys, reason: "mismatch" | "timeout" }. Useful to spot shortcuts users expect. |
mv-combo-remap | The user is about to save a new shortcut or restore defaults in the sheet (cancelable). detail: { id, label, keys, previous, reset }. |
mv-combo-change | User settings changed in the sheet. detail: { characterKeys, remaps }: store them if you don’t use persist. |
mv-combo-sheet | The cheat sheet opened or closed. detail: { open }. |
Content structure
| Name | Description |
|---|---|
(children) | <mv-combo-move> elements (display: none). The buffer, a live region and the sheet <dialog> are appended by the component. |
CSS classes
| Name | Description |
|---|---|
mv-combo-hud | Fixed (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-buffer | The input display: .mv-combo-strokes (keycaps, .mv-combo-stroke each), .mv-combo-result (-icon, -text), .mv-combo-window (draining timing line). |
mv-combo-hints | Continuation panel: -hints-head (-hints-count), -hints-list > .mv-combo-hint (-hint-label; data-more), -hints-foot. |
mv-combo-history | Recent moves in display="history": .mv-combo-history-item (data-leaving while fading). |
mv-combo-sheet | The 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-recorder | Shortcut 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-then | Rendered sequences: .mv-kbd keycaps grouped per chord, “then” between steps (also inside [data-combo-hint]). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-combo-accent | var(--mv-accent) | Caret, timing line, match tint and check. |
--mv-combo-offset | 1.25rem | Distance of the buffer from the edge. |
--mv-combo-sheet-width | 48rem | Maximum width of the cheat sheet. |
--mv-combo-record | var(--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.