12k
All articles

WebアプリにキーボードショートカットをÂ追加する方法

Webアプリにキーボードショートカットを追加する方法。documentのkeydown、入力中の除外、MacとWindowsの修飾キー、2キーの連続入力、Reactでのクリーンアップを解説します。

OpenReplay Team
OpenReplay Team
WebアプリにキーボードショートカットをÂ追加する方法

Webアプリにキーボードショートカットを追加するには、documentkeydown リスナーを1つ登録し、event.key と修飾キーのブール値でマッチングを行い、イベントのターゲットが編集可能な要素である場合はイベントをスキップし、リスナーを所有するコンポーネントがアンマウントされる際に同じ関数参照でリスナーを削除します。

最初のショートカットは、たいてい手早く書けます。問題が現れるのは、その後です。ユーザーが検索ボックスに「k」と入力したらコマンドパレットが開いてしまう、あるいはMacを使っている同僚がショートカットがまったく動作しないことに気づく、といった具合です。

この記事では、そうした素朴なリスナーを出発点として、入力中の発火、Mac対Windowsの修飾キー、2キーのシーケンス、Reactにおけるリスナーのリーク、そしてショートカット特有のアクセシビリティ規則を、順に一つずつ修正していきます。修正の大半は、既存のハンドラーにそのまま落とし込める数行のTypeScriptです。

要点

  • document に登録した keydown リスナーはページ上のすべてのキーストロークを受け取るため、ハンドラーは event.targetinputtextareaselect、または isContentEditable が true である任意の要素の場合、早期にreturnしなければなりません。
  • event.ctrlKey のみをテストするショートカットはMacでは決して発火しません。CommandキーがセットするのはA event.metaKey だからです。event.metaKey || event.ctrlKey をテストすれば、1つのバインディングで両プラットフォームをカバーできます。
  • マッチングにプラットフォーム判定は不要です。プラットフォームの解決は表示のためだけに行いましょう。これはMDNが navigator.platform について文書化している唯一の用途です。
  • g の次に i のようなシーケンスには、バッファ、有効期限のタイムアウト、プレフィックスでないキーが押されたときのリセット、そしてそのキーを新しいシーケンスの開始として再チェックする処理が必要です。
  • Reactでは、useEffect 内でリスナーを登録し、クリーンアップで同じ参照を削除します。ハンドラーがpropsやstateを読み取る場合は useCallback でメモ化します。

素朴なJavaScriptキーボードショートカットリスナー

最も単純に動作するショートカットは、event.key を文字と比較し、一致した場合に preventDefault() を呼ぶ keydown リスナーです。使うのは event.key であり、廃止された keyCode は決して使わないでください。

document.addEventListener('keydown', (event) => {
  if (event.ctrlKey && event.key.toLowerCase() === 'k') {
    event.preventDefault();
    openCommandPalette();
  }
});

event.key を小文字化することで、Caps LockやShiftの影響を受けずにマッチングできます。このリスナーのそれ以外の部分は、すべてユーザーを待ち構えているバグです。

ユーザーが入力している間、ショートカットの発火をどう止めるか?

ショートカットハンドラーは、何かを実行する前に event.target をチェックしなければなりません。ドキュメントレベルのリスナーは、ユーザーが検索フィールドに入力するキーストロークも受け取るからです。よくあるフィルターは3つのタグ名をチェックしますが、その考え方こそが穴になります。contenteditable の領域は自身のタグ(通常は div)を保つため、リッチテキストエディタはこのチェックを通過し、文章の途中でショートカットが発火してしまいます。グローバルショートカットを備えたアプリのセッションリプレイには、まさにこれが現れます。ユーザーがフィールドに入力していて、たまたまバインドされていた文字でページが遷移してしまうのです。

代わりに isContentEditable を使いましょう。これは、祖先要素から編集可能性を継承している要素を含め、ユーザーが編集できるあらゆる要素で true になります。

function isTyping(target: EventTarget | null): boolean {
  if (!(target instanceof HTMLElement)) return false;
  const tag = target.tagName;
  return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
    || target.isContentEditable;
}

document.addEventListener('keydown', (event) => {
  if (isTyping(event.target)) return;
  // matching below
});

ハンドラーの先頭でreturnすることで、後続の処理、つまり後のセクションで扱うシーケンスバッファも含めて、入力されたキーストロークを一切目にしないようにします。

MacとWindowsの修飾キーをどう扱うか?

event.ctrlKey のみをテストするショートカットはMacでは機能しません。Commandキーがセットするのは event.metaKey だからです。マッチング時にはどちらの修飾キーも受け付けましょう。

const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 'k') { /* ... */ }

これはわずかに過剰マッチします(MacでもCtrl+Kが動作する)が、無害です。これによって避けられるのは、マッチング経路でのプラットフォーム判定です。navigator.platform は判定用途には信頼できないと文書化されており、MDNが認めている唯一の用途は、ユーザーにショートカットを表示する際に ⌘ と Ctrl のどちらを選ぶかを決めることです。用途はそこに留めておきましょう。

物理キーイベントプロパティ表示
Command (macOS)metaKey
Control (Windows/Linux)ctrlKeyCtrl
WindowsキーmetaKeyバインドしない
const isMac = navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone';
const formatKeys = (keys: string[]) =>
  keys.map((k) => (k === 'mod' ? (isMac ? '' : 'Ctrl') : k)).join(isMac ? '' : '+');

g の次に i のようなキーシーケンスをどうサポートするか?

2キーのシーケンスには、バッファ、それをクリアするタイムアウト、バッファが有効なプレフィックスでなくなったときのリセット、そして問題となったキーを新しいシーケンスの開始として再チェックする処理が必要です。そのキーを捨ててしまうと、ユーザーは2回押す必要が生じます。さらに2つのルールがあります。キーを押し続けてバッファが溢れないよう event.repeat が true の keydown は無視すること、そして修飾キーのみのkeydownShiftControlMetaAltAltGraph)も無視することです。そうしなければ、コード(同時押し)の前にShiftを押すだけで進行中のシーケンスがキャンセルされてしまいます。

バッファキーを追加した後の結果動作
任意バインディングと完全一致実行し、バッファをクリア
任意バインディングのプレフィックスバッファを保持し、タイムアウトを再開
長さ > 1何にも一致しないバッファをクリアし、そのキーを単独で再投入
長さ 1何にも一致しないバッファをクリア
任意タイムアウト発火バッファをクリア
type Binding = { keys: string[]; description: string; run: () => void };
const bindings: Binding[] = [
  { keys: ['g', 'i'], description: 'Go to inbox', run: () => navigate('/inbox') },
  { keys: ['g', 'p'], description: 'Go to projects', run: () => navigate('/projects') },
];
const MODIFIERS = new Set(['Control', 'Meta', 'Shift', 'Alt', 'AltGraph']);
let buffer: string[] = [];
let timer: ReturnType<typeof setTimeout> | undefined;

function reset() { buffer = []; clearTimeout(timer); }

function feed(key: string) {
  buffer.push(key);
  const exact = bindings.find(
    (b) => b.keys.length === buffer.length && b.keys.every((k, i) => k === buffer[i]),
  );
  if (exact) { exact.run(); reset(); return; }
  if (bindings.some((b) => buffer.every((k, i) => b.keys[i] === k))) {
    clearTimeout(timer);
    timer = setTimeout(reset, 800);
    return;
  }
  const retry = buffer.length > 1;
  reset();
  if (retry) feed(key);
}

document.addEventListener('keydown', (event) => {
  if (isTyping(event.target) || event.repeat || MODIFIERS.has(event.key)) return;
  if (event.metaKey || event.ctrlKey || event.altKey) return; // chords go elsewhere
  feed(event.key.toLowerCase());
});

800ミリ秒というウィンドウは計測に基づくものではなく、一つの選択です。数百ミリ秒程度が一般的です。

Reactでショートカットリスナーをどう登録し、後片付けするか?

Reactでは、useEffect の中でリスナーを追加し、そのクリーンアップで同じ関数参照を削除します。ハンドラーがpropsやstateを読み取る場合は、useCallback でメモ化し、effectの依存配列に含めます。クリーンアップがないと、再マウントごとにリスナーが積み重なり、1回のキー押下でアクションが2回実行されてしまいます。

function useShortcuts(bindings: Binding[]) {
  const handleKeyDown = useCallback((event: KeyboardEvent) => {
    if (isTyping(event.target)) return;
    // match against bindings here
  }, [bindings]);

  useEffect(() => {
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [handleKeyDown]);
}

このフックの bindings 依存には注意してください。呼び出し側が配列リテラルをインラインで渡すと、レンダーごとに別の配列になるため、handleKeyDown の同一性が変わり、effectは毎回リスナーを削除して再追加します。壊れるわけではありませんが、この入れ替わりは無駄な処理です。配列はモジュールレベルで宣言するか、呼び出し側コンポーネントで useMemo でラップしましょう。

React 18以降、Strict Mode は開発時にすべてのEffectをセットアップとティアダウンの追加ラウンドに通します。そのため、追加した参照とは異なる参照を削除するクリーンアップは、ハンドラーの二重登録としてすぐに露呈します。React外でもルールは同じです。addEventListener 1回に対し、それに対応する removeEventListener 1回、同じ関数で。

ショートカットをアクセシブルかつ発見しやすく保つ

ショートカット特有のルールが3つあります。ブラウザが予約している組み合わせ(Cmd/Ctrl+W、Cmd/Ctrl+N、Cmd/Ctrl+T、Tabなど)をバインドしないこと、そしてCmd/Ctrl+Cのようなネイティブの編集コードに対して preventDefault() を呼ばないこと。ショートカットを機能への唯一の経路にしないこと。同じアクションに対するメニュー項目やボタンが存在しなければなりません。そして単一文字のバインディングについては、WCAG 2.1 達成基準 2.1.4 文字キーのショートカット(レベルA) が次の3つのうち1つを求めています。ショートカットをオフにできる手段、CtrlやAltなどのキーを含むように再割り当てできる手段、あるいはそのコンポーネント自身がフォーカスを持っている間だけ発火するよう十分に狭いスコープに限定すること。

発見しやすさのためには、? をヘルプダイアログにバインドし、マッチャーが使うのと同じ bindings 配列をレンダリングしましょう。Shiftとスラッシュキーの組み合わせではなく event.key === '?' でマッチさせることで、? が異なる物理キーに配置されているレイアウトでも動作します。

if (event.key === '?' && !isTyping(event.target)) {
  event.preventDefault();
  helpDialog.showModal();
}

// inside the dialog
{bindings.map((b) => (
  <li key={b.keys.join(' ')}><kbd>{formatKeys(b.keys)}</kbd> {b.description}</li>
))}

ネイティブの <dialog> に対する showModal() は、Escapeキーの処理を無償で提供してくれます。ダイアログ内部のフォーカス管理については、モーダルにおける一般的なアクセシビリティの問題のガイドを参照してください。

ショートカットライブラリを使うべきタイミングは?

バインディングが数個を超えたら、スコープ管理、競合検出、シーケンス処理は委譲する価値があります。TanStack Hotkeys はその選択肢の一つです。バインディング内の Mod キーはMacではCommandに、それ以外の環境ではControlに解決され、フォーカスされた入力要素に向けられたキーストロークは自動的にスキップされます。ただし概要ページでは依然としてこのライブラリをアルファ版と位置づけ、APIが変更される可能性があると警告しているため、バージョンを固定し、変更に備えてください。

まとめ

ショートカットは決まった場所で壊れます。イベントターゲット、修飾キー、シーケンスバッファ、そしてリスナーのライフサイクルです。まずは今あるハンドラーに isTyping ガードと metaKey || ctrlKey のマッチングを入れ、次にバインディングを単一の配列にまとめて、マッチャーと ? のヘルプダイアログが同じソースを参照するようにしましょう。

FAQ

キーボードショートカットにおける event.key と event.code の違いは何ですか?

event.key は、キーボードレイアウトと押されている修飾キーを考慮した上でそのキーが生成する文字を返します。一方 event.code は物理的なキー位置に名前を付けたもので、レイアウトが何であっても変わりません。ショートカットは event.key でマッチさせましょう。そうすれば 'k' のバインディングは、あらゆるキーボードでキーキャップに印字された文字を意味します。event.code は、ゲームのWASDのような位置ベースの入力のために取っておきましょう。TanStack Hotkeys が event.code にフォールバックするのは、文字キーと数字キーに限り、かつ macOS の Option プラス文字のように event.key が代わりに特殊文字を返す場合だけです。

キーボードショートカットには keydown、keyup、keypress のどれを使うべきですか?

keydown を使いましょう。MDN は keypress を非推奨としており、また文字を生成するキーに対してのみ発火するため、Escape、矢印キー、単独で押された修飾キーを報告することはありません。keydown はすべてのキーで発火し、event.key と修飾キーのブール値を公開し、preventDefault がブラウザ自身の動作を止められるイベントです。keyup は、ブラウザが既に keydown に対して動作した後に到着するため、ネイティブのショートカットや挿入された文字を抑制することはできません。

日本語や中国語入力などのIMEで入力している間、キーボードショートカットは発火しますか?

はい。ドキュメントレベルの keydown リスナーは、IMEが変換中であってもキーストロークを受け取ります。したがって event.isComposing が true の場合は早期にreturnしましょう。このフラグは、IMEが変換セッションを開始した瞬間から終了する瞬間までのすべてのキーイベントで true のままであり、まさにショートカットが邪魔をしてはならない期間全体に相当します。変換は編集可能な要素内で起こるため isTyping ガードがほとんどのケースを捕捉しますが、isComposing はカスタムのテキスト入力面に対する第二のチェックを追加してくれます。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.