Reactで、Ctrl+Kで開くコマンドパレットを作る方法

​​React​dialog,Playwright,TypeScript,アクセシビリティ,キーボード操作

コマンドパレットは、HTML の dialog 要素を showModal() で開き、中に入力欄とコマンドの一覧を置いて作ります。Esc で閉じる、後ろの画面を操作できなくする、閉じたら元の場所へフォーカスを戻す、といった処理はブラウザが受け持つので、React 側で書くのは「Ctrl+K で開け閉めする」「打った言葉で絞り込む」「↑↓ で選んで Enter で実行する」の3つです。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium にキー入力やクリックを送って確かめました。Firefox と Safari、Mac の実機での ⌘K、実際の日本語入力(IME)、スクリーンリーダーでは試していません。

アプリ全体を StrictMode で囲んでも、開く、「けんさく」で Enter を押して検索欄へ移る、メモの欄から開いて Esc で戻る、のどれも StrictMode で囲まないときと同じ結果になりました。useEffect のリファレンスにあるとおり、StrictMode で effect のセットアップとクリーンアップが余分に実行されるのは、最初のセットアップの前の1回だけです。CommandPalette が最初に描画されたとき(マウントのとき)に渡す open は false なので、余分に実行されても showModal() も close() も呼ばれません。

上の帯に「操作 1回目/3回」などの回数と押したキーが出る。Ctrl+K でパレットが開き、「てーま」と1文字ずつ打つと候補が12件から1件に減り、Enter でパレットが閉じて画面が暗いテーマに変わる
Ctrl+K で開き、「てーま」で絞り込んで Enter で実行する動き(上の帯と、変わった瞬間のピンクの強調は GIF 用に足したもの)

コマンドは「名前」と「実行する関数」の組にする

パレットに並べるコマンドは、次の形のオブジェクトの配列で渡します。

ts
export type Command = {
  id: string;
  label: string; // 一覧に出す名前
  group: string; // 右に小さく出す分類
  keywords?: string[]; // 名前にない言葉でも見つかるようにする
  run: () => void;
};

keywords には、名前には出てこないけれど、利用者が打ちそうな言葉を入れます。たとえば「テーマを切り替える」に dark を入れておくと、英語で打っても見つかります。

検証に使った画面では、次のように12個のコマンドを用意しました。make は、実行したら画面の下に「実行:〇〇」と出すための小さな関数です。

tsx
const commands = useMemo<Command[]>(() => {
  // 実行したら、画面の下に「実行:〇〇」と出す。act を省いたコマンドは、表示だけを変える
  const make = (id: string, label: string, group: string, keywords: string[], act = () => {}): Command => ({
    id,
    label,
    group,
    keywords,
    run: () => {
      act();
      setLastRun(label);
    },
  });
  return [
    make('theme', 'テーマを切り替える', '表示', ['dark', 'ダーク', 'ライト'], () => setDark((d) => !d)),
    make('bigger', '文字を大きくする', '表示', ['font', 'zoom'], () => setFontSize((s) => s + 2)),
    make('smaller', '文字を小さくする', '表示', ['font', 'zoom'], () => setFontSize((s) => s - 2)),
    make('search', '検索欄へ移動', '移動', ['search', 'けんさく'], () => searchRef.current?.focus()),
    make('memo', 'メモ欄へ移動', '移動', ['note'], () => memoRef.current?.focus()),
    make('top', 'ページの先頭へ移動', '移動', ['top', 'scroll']),
    make('new-memo', '新しいメモを作る', '作成', ['new', 'note']),
    make('memo-list', 'メモの一覧を開く', 'メモ', ['note', 'list']),
    make('memo-export', '書き出す(メモ)', 'メモ', ['export', 'download']),
    make('settings', '設定を開く', '全般', ['settings', 'preferences']),
    make('shortcuts', 'ショートカットの一覧', '全般', ['keyboard', 'help']),
    make('help', 'ヘルプを表示', '全般', ['help', '使い方']),
  ];
}, []);

Ctrl+K で開け閉めするフック

Ctrl+K は、window の keydown を useEffect で登録して受け取ります。

ts
import { useEffect } from 'react';

// Ctrl+K(Mac では ⌘K)でパレットを開け閉めする。入力欄の中でも反応させる
export function useCommandPaletteShortcut(toggle: () => void) {
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key.toLowerCase() !== 'k' || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;
      if (e.isComposing) return; // 変換中のキーは IME に任せる
      e.preventDefault(); // 押しっぱなしの2回目以降も、ブラウザの既定の動きは止める
      if (e.repeat) return; // 押しっぱなしでは、開け閉めをくり返さない
      toggle();
    }
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [toggle]);
}

呼び出す側では、開いているかどうかを open という state に持ち、反転させる関数を useCallback で包んで渡します。

tsx
const toggle = useCallback(() => setOpen((o) => !o), []);
useCommandPaletteShortcut(toggle);

toggle を useCallback で包むのは、レンダーのたびに別の関数にならないようにするためです。別の関数になると、effect が依存する値が変わったことになり、リスナーの登録と解除がレンダーのたびにくり返されます。

修飾キーは ctrlKey || metaKey で比べています。MDN の metaKey のページによると、Mac のキーボードでは ⌘ Command キーが Meta キーにあたります。検証では、Linux の Chromium で Meta+K を押したときも、Ctrl+K と同じようにパレットが開きました。

「/」のように文字を打つキーのショートカットとは違い、入力欄の中で押されたかどうかは見ていません。コマンドパレットはどこからでも呼び出せるほうが便利なので、入力欄の中でも開けるようにしました。検証でも、メモの欄に文字を打っている途中で Ctrl+K を押すとパレットが開き、パレットの入力欄の中でもう一度押すと閉じました。

preventDefault を呼んで、このキー入力に対するブラウザの既定の動きを止めます。ブラウザ本体のショートカットにどんな動きが割り当てられていて、それが実際に止まるかどうかは、ヘッドレスの Chromium では確かめていません。

押しっぱなしにしたときの keydown は、repeat が true になります。このときは開け閉めをせずに返しますが、preventDefault はその前に呼んでいます。Ctrl を押したまま K の keydown を3回送ったところ、パレットは1回目で開いたままで、3回とも defaultPrevented は true でした。

dialog の showModal で開く

パレットの本体です。open が変わったら、effect の中で showModal() と close() を呼びます。

tsx
import { useEffect, useId, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import type { Command } from './commands';
import { filterCommands } from './filterCommands';

type Props = {
  open: boolean;
  onClose: () => void;
  commands: Command[];
};

export function CommandPalette({ open, onClose, commands }: Props) {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
    if (open && !dialog.open) dialog.showModal();
    if (!open && dialog.open) dialog.close();
  }, [open]);

  function run(command: Command) {
    // 先に閉じてフォーカスを戻してから、コマンドを実行する
    dialogRef.current?.close();
    command.run();
  }

  return (
    <dialog
      ref={dialogRef}
      className="palette"
      aria-label="コマンドパレット"
      onClose={onClose}
      onClick={(e) => {
        if (e.target === e.currentTarget) onClose(); // 幕の部分をクリックしたら閉じる
      }}
    >
      {open && <PaletteBody commands={commands} onRun={run} />}
    </dialog>
  );
}

showModal() で開いた dialog は、MDN の dialog 要素のページにあるとおり、Esc キーで閉じることができ、後ろの画面は操作できなくなります。Esc で閉じたときも dialog に close イベントが届くので、onClose で open を false に戻し、React の state と dialog の状態をそろえます。検証でも、Esc で閉じた後に Ctrl+K を1回押すと、パレットが開きました。onClose で open が false に戻っていたので、Ctrl+K の反転で true になったということです。

MDN によると、showModal() で開くと、dialog の中で最初にフォーカスできる要素にフォーカスが移ります。このパレットでは入力欄が先頭にあるので、autoFocus を付けなくても、開いた直後から文字を打てました。閉じたときは、開く前にフォーカスがあった要素へ戻ります。検証では、「コマンド」ボタンから開いて Esc で閉じるとボタンに、メモの欄から開いて閉じるとメモの欄に戻りました。

幕(::backdrop)の部分をクリックしたときは、クリックの target が dialog 自身になるので、e.target === e.currentTarget のときだけ閉じます。dialog は CSS で padding: 0 にしてあり、中身の部分をクリックしたときは中の要素が target になります。

開くたびに入力を空に戻す

中身の PaletteBody は、open が true のときだけ描画しています。閉じると PaletteBody が取り除かれ、その中の state も消えます。React のドキュメントにある、state の保持とリセットの説明のとおり、React はコンポーネントを取り除くと、その state を破棄します。

検証では、「dark」と打ってから Ctrl+K で閉じ、もう一度 Ctrl+K で開くと、入力欄は空になり、候補は12件すべてに戻りました。前に打った言葉が残っていると、開くたびに消す手間がかかります。

打った言葉で絞り込む

絞り込みは、コマンドの配列と入力欄の文字を受け取って、合うものだけを返す関数にしました。

ts
import type { Command } from './commands';

// 全角・半角と大文字・小文字をそろえ、カタカナをひらがなにする
function normalize(s: string): string {
  return s
    .normalize('NFKC')
    .toLowerCase()
    .replace(/[ァ-ヶ]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x60));
}

export function filterCommands(commands: Command[], query: string): Command[] {
  const q = normalize(query.trim());
  if (q === '') return commands;
  const hits: { command: Command; rank: number }[] = [];
  for (const command of commands) {
    const label = normalize(command.label);
    const keywords = (command.keywords ?? []).map(normalize);
    // 名前の先頭に一致 → 名前の途中に一致 → キーワードに一致、の順に並べる
    const rank = label.startsWith(q) ? 0 : label.includes(q) ? 1 : keywords.some((k) => k.includes(q)) ? 2 : -1;
    if (rank >= 0) hits.push({ command, rank });
  }
  // sort は安定なので、同じ順位の中では元の並びが保たれる
  return hits.sort((a, b) => a.rank - b.rank).map((h) => h.command);
}

比べる前に、名前と入力の両方を同じ形にそろえます。normalize('NFKC') で全角の英数字を半角にし、toLowerCase() で小文字にします。さらにカタカナの「ァ」〜「ヶ」をひらがなに置き換えて、「めも」と打っても「メモ」が見つかるようにしました。長音の「ー」はカタカナとひらがなで同じ文字なので、そのままで合います。

並べ方は、名前の先頭に一致したもの、名前の途中に一致したもの、キーワードだけに一致したもの、の順です。同じ順位の中では、sort が安定であることを利用して、元の配列の並びを残しています。

いくつかの言葉で試した結果です。

text
「めも」: open=true modal=true focus=palette-input query="めも" 候補=[メモ欄へ移動/メモの一覧を開く/新しいメモを作る/書き出す(メモ)] 選択=メモ欄へ移動 activedescendant一致=true
「メモ」: open=true modal=true focus=palette-input query="メモ" 候補=[メモ欄へ移動/メモの一覧を開く/新しいメモを作る/書き出す(メモ)] 選択=メモ欄へ移動 activedescendant一致=true
「note」: open=true modal=true focus=palette-input query="note" 候補=[メモ欄へ移動/新しいメモを作る/メモの一覧を開く] 選択=メモ欄へ移動 activedescendant一致=true
「DARK」: open=true modal=true focus=palette-input query="DARK" 候補=[テーマを切り替える] 選択=テーマを切り替える activedescendant一致=true
「てーま」: open=true modal=true focus=palette-input query="てーま" 候補=[テーマを切り替える] 選択=テーマを切り替える activedescendant一致=true
「けんさく」: open=true modal=true focus=palette-input query="けんさく" 候補=[検索欄へ移動] 選択=検索欄へ移動 activedescendant一致=true
「xyz」: open=true modal=true focus=palette-input query="xyz" 候補=[] 選択=null activedescendant一致=true 表示="一致するコマンドはありません"

「めも」と「メモ」は同じ結果になりました。名前が「メモ」で始まる2つが先に並び、途中に「メモ」を含む「新しいメモを作る」と「書き出す(メモ)」が後に続きます。「note」はキーワードだけに一致するので、元の配列の順に並びました。全角の「DARK」も、キーワードの dark に一致しています。

ただし、漢字の読みまでは分かりません。「けんさく」で「検索欄へ移動」が見つかるのは、キーワードに「けんさく」を入れてあるからです。漢字の名前のコマンドで読みでも探せるようにしたいときは、読みをキーワードに足しておきます。

↑↓ で選び、Enter で実行する

入力欄と一覧の部分です。フォーカスは入力欄に置いたまま、いま選んでいる行の番号を active に持ちます。

tsx
export function PaletteBody({ commands, onRun }: { commands: Command[]; onRun: (c: Command) => void }) {
  const [query, setQuery] = useState('');
  const [active, setActive] = useState(0);
  const results = filterCommands(commands, query);
  const listId = useId();
  const optionId = (i: number) => `${listId}-${i}`;
  const listRef = useRef<HTMLUListElement>(null);

  // 選んでいる行が一覧の外に出たら、見える位置までスクロールする
  useEffect(() => {
    listRef.current?.children[active]?.scrollIntoView({ block: 'nearest' });
  }, [active, query]);

  function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    if (e.nativeEvent.isComposing) return; // 日本語の変換中のキーは IME に任せる
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault(); // 入力欄のカーソルを動かさない
      if (results.length === 0) return;
      const step = e.key === 'ArrowDown' ? 1 : -1;
      setActive((i) => (i + step + results.length) % results.length);
    } else if (e.key === 'Enter' && results[active]) {
      e.preventDefault();
      onRun(results[active]);
    }
  }

  return (
    <>
      <input
        className="palette-input"
        role="combobox"
        aria-expanded="true"
        aria-controls={listId}
        aria-autocomplete="list"
        aria-activedescendant={results[active] ? optionId(active) : undefined}
        placeholder="コマンドを検索"
        value={query}
        onChange={(e) => {
          setQuery(e.target.value);
          setActive(0); // 絞り込みが変わったら、先頭を選び直す
        }}
        onKeyDown={handleKeyDown}
      />
      <ul id={listId} ref={listRef} role="listbox" aria-label="コマンド" className="palette-list">
        {results.map((command, i) => (
          <li
            key={command.id}
            id={optionId(i)}
            role="option"
            aria-selected={i === active}
            onMouseMove={() => setActive(i)}
            onMouseDown={(e) => e.preventDefault()} // 入力欄からフォーカスを外さない
            onClick={() => onRun(command)}
          >
            {command.label}
            <span className="palette-group">{command.group}</span>
          </li>
        ))}
      </ul>
      {results.length === 0 && <p className="palette-empty">一致するコマンドはありません</p>}
    </>
  );
}

選んでいる行は、入力欄の aria-activedescendant に、その行の id を入れて伝えます。MDN の combobox ロールのページによると、フォーカスを入力欄に残したまま、一覧の中のいま選ばれている要素を示すのに使う属性です。検証では、どの操作の後も、aria-activedescendant の値と aria-selected="true" の行の id が一致していました。入力欄に role="combobox"、一覧に role="listbox" を付ける作りは、入力候補を出すコンボボックスと同じです。

↑↓ は端で反対側へ回ります。12件の一覧の先頭で ↑ を押すと最後の「ヘルプを表示」が選ばれ、そこで ↓ を押すと先頭に戻りました。一覧は高さの上限を 240px にしてスクロールさせているので、active が変わるたびに scrollIntoView({ block: 'nearest' }) を呼び、選んだ行が見える位置まで最小限だけスクロールします。検証では、最後の行を選んだときに一覧が 140px スクロールし、行の下端が一覧の下端にそろいました。

打つ言葉が変わったら、active を 0 に戻します。「めも」で ↓ を2回押して「新しいメモを作る」を選んでから1文字消すと、選んでいる行は先頭の「メモ欄へ移動」に戻りました。戻さないと、候補の数が減ったときに、存在しない番号を指したままになります。一致するコマンドがないときは「一致するコマンドはありません」と出し、↓ も Enter も何もしません。

日本語の変換を確定する Enter でコマンドが実行されないように、isComposing が true のキーは無視します。スクリプトから isComposing: true の Enter を送ると、コマンドは実行されませんでした(実際の IME では試していません)。

マウスでは、行の上に載せると onMouseMove でその行が選ばれ、クリックで実行します。onMouseDown では preventDefault を呼び、押した瞬間に入力欄からフォーカスが外れないようにしています。検証でも、行の上でマウスのボタンを押して、まだ離していないときのフォーカスは入力欄のままでした。

先に閉じてから、コマンドを実行する

run では、dialogRef.current?.close() を呼んでから command.run() を呼んでいます。この順番は、フォーカスを動かすコマンドで効いてきます。

HTML の仕様にある dialog を閉じる手順では、モーダルの dialog を閉じると、開く前にフォーカスがあった要素へフォーカスを戻します。close イベントは、その後でタスクとして発生します。つまり、close() を呼んだ時点でフォーカスは元の要素に戻っています。

比べるために、コマンドを先に実行して、閉じるのは onClose で state を変えて effect に任せる版も作りました。

tsx
function run(command: Command) {
  command.run();
  onClose(); // dialog が閉じるのは、この後の effect の中
}

どちらの版でも、「コマンド」ボタンにフォーカスを置いて Ctrl+K で開き、「けんさく」と打って Enter で「検索欄へ移動」を実行しました。出力の focus は、そのときフォーカスのある要素の id です。search は検索欄、opener は「コマンド」ボタンを指します。

text
先に閉じてから実行する版: open=false modal=false focus=search 「実行:検索欄へ移動」 dark=false
先に実行して、閉じるのは state に任せる版: open=false modal=false focus=opener 「実行:検索欄へ移動」 dark=false

先に閉じる版では、検索欄にフォーカスが移りました。いっぽう、先に実行する版では、コマンドは実行されたのに、フォーカスは「コマンド」ボタンに戻っていました。コマンドで検索欄へ移した後に、effect の中の close() が、開く前の要素へフォーカスを戻したためです。

フォーカスを動かさないコマンドなら、フォーカスは開く前の場所に戻ります。「テーマを切り替える」を Enter で実行すると、パレットが閉じて画面が暗いテーマになり、フォーカスは「コマンド」ボタンに戻りました。close() を直接呼んでも close イベントは発生するので、onClose が呼ばれて open の state も false に戻ります。検証でも、実行した後に Ctrl+K を1回押すと、パレットが開きました。

ポイント

close() を先に呼べば、フォーカスが元の場所に戻ってからコマンドが動くので、コマンドの中で動かしたフォーカスが上書きされません。

​Posted by 86