Reactのテキストエリアで、Tabキーを押して字下げする方法

​​React​Playwright,textarea,TypeScript,アクセシビリティ

テキストエリアで Tab キーを押すと、ふつうは字下げされず、フォーカスが次の項目に移ります。onKeyDown で Tab を受け取って preventDefault() し、document.execCommand('insertText') で字下げの空白を入れます。こうすると、エディターのように字下げでき、Ctrl+Z で戻すこともできます。そのかわり Tab で外に出られなくなるので、Esc を押してから Tab を押すと次の項目へ移れるようにしておきます。

複数行をまとめて字下げする場合や、Shift+Tab で字下げを戻す場合も含めて、部品を作って確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、@types/react 19.3.0、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright でヘッドレスの Chromium を操作しました。Firefox と Safari、スマートフォンのソフトウェアキーボード、日本語入力(IME)の変換中に Tab を押した場合、スクリーンリーダーでの読み上げ、Esc で閉じるダイアログの中にテキストエリアを置いた場合は試していません。

2行目の先頭で Tab を押すと、2行目が字下げされる。全体を選んで Tab を押すと全行が字下げされ、Shift+Tab で戻る。Esc を押してから Tab を押すと、フォーカスが「保存」ボタンに移る。右下の枠は押したキー、上の帯は何回目の操作か、丸はマウスの位置で、どれも GIF 用に重ねたもの
2行目で Tab、全体を選んで Tab、Shift+Tab、Esc、Tab の順に押す様子。変わった箇所はその瞬間だけピンクになる

Tab は keydown で止めないと、フォーカスが移る

テキストエリアに Tab の処理を何も書かない版で、2行目の先頭にキャレットを置いて Tab を押しました。テキストエリアの値は変わらず、フォーカスがテキストエリアの次にある「保存」ボタンに移りました。検証の出力では、テキストエリアを「本文」と呼んでいます。

text
2行目の先頭で Tab
  値 "function meow() {\nconsole.log(\"にゃー\");\n}" | 選択 18〜18 | フォーカス: ボタン「保存」

W3C の ARIA Authoring Practices Guide のキーボード操作の説明にあるとおり、Tab と Shift+Tab でフォーカスを部品から部品へ移すのは、どの環境でも共通の操作です。Tab を字下げに使うには、onKeyDown で Tab を受け取り、e.preventDefault() でこの動きを止めます。

止めるのは、Tab と Shift+Tab だけにしています。ctrlKey、altKey、metaKey のどれかが付いているときは何もしません。Tab キーの keydown で defaultPrevented を調べ、押した組み合わせごとに並べました。止めたのは Tab と Shift+Tab のときだけで、Ctrl+Tab と Alt+Tab では止めていませんでした。

text
Tab        止めた
Shift+Tab  止めた
Ctrl+Tab   止めていない
Alt+Tab    止めていない

字下げで変える範囲を、先に計算する

どこをどう書き換えるかは、React に関係のない関数 computeIndent にまとめました。テキストエリアの値と選択範囲を受け取り、置き換える範囲(from〜to)、代わりに入れる文字(insert)、置き換えたあとの選択範囲を返します。

ts
export const INDENT = '  '; // 字下げ1つ分(半角スペース2つ)

export type IndentEdit = {
  from: number; // 置き換える範囲の始まり
  to: number; // 置き換える範囲の終わり
  insert: string; // 代わりに入れる文字
  selStart: number; // 置き換えたあとの選択範囲
  selEnd: number;
};

// Tab(outdent = false)か Shift+Tab(outdent = true)を押したときの変更を求める。変更がなければ null
export function computeIndent(value: string, start: number, end: number, outdent: boolean): IndentEdit | null {
  const multiLine = value.slice(start, end).includes('\n');
  // 1行の中だけなら、キャレットの位置(選んでいればその範囲)に字下げを入れる
  if (!outdent && !multiLine) {
    const caret = start + INDENT.length;
    return { from: start, to: end, insert: INDENT, selStart: caret, selEnd: caret };
  }
  // 選択範囲にかかる行を、最初の行の先頭から最後の行の末尾まで取り出す
  const from = value.lastIndexOf('\n', start - 1) + 1;
  // 選択の終わりが行の先頭にあるときは、その行は含めない
  const last = end > start && value[end - 1] === '\n' ? end - 1 : end;
  const lineEnd = value.indexOf('\n', last);
  const to = lineEnd === -1 ? value.length : lineEnd;
  const lines = value.slice(from, to).split('\n');

  if (!outdent) {
    return {
      from, to,
      insert: lines.map((line) => INDENT + line).join('\n'),
      selStart: start + INDENT.length,
      selEnd: end + INDENT.length * lines.length,
    };
  }
  // 行頭のタブ1つか、スペースを INDENT の長さまで取り除く
  const removed = lines.map((line) => (line.startsWith('\t') ? 1 : line.match(/^ */)![0].slice(0, INDENT.length).length));
  if (removed.every((n) => n === 0)) return null;
  const total = removed.reduce((a, b) => a + b, 0);
  return {
    from, to,
    insert: lines.map((line, i) => line.slice(removed[i])).join('\n'),
    selStart: Math.max(from, start - removed[0]),
    selEnd: Math.max(from, end - total),
  };
}

選択が1行の中に収まっているときの Tab は、キャレットの位置に空白を2つ入れるだけです。文字を選んでいれば、文字を打ったときと同じく、選んだ文字が空白に置き換わります。選択が複数行にまたがるときと Shift+Tab のときは、かかっている行をまるごと取り出し、行ごとに空白を足したり削ったりします。

Node.js で、いくつかの場合を試した結果です。上の4つは "a\nb\nc"(a、b、c の3行)、続く3つは " a\n b\n\tc\nd"(スペース4つの a、スペース2つの b、タブの c、字下げのない d の4行)、最後の1つは " "(半角スペース2つだけの1行)から始めました。

「前」は操作する前の値と選択範囲、「後」は操作したあとの値と選択範囲です。[ と ] のあいだが選択範囲で、[] はキャレットの位置です。かっこ内の数字は、computeIndent に start〜end として渡した位置です(先頭を0とし、改行 \n とタブ \t も1文字に数えます)。

text
キャレットが b の前で Tab(選択 2〜2)
  前 "a\n[]b\nc"
  後 "a\n  []b\nc"
「b」を選んで Tab(選択 2〜3)
  前 "a\n[b]\nc"
  後 "a\n  []\nc"
a〜b を選んで Tab(選択 0〜3)
  前 "[a\nb]\nc"
  後 "  [a\n  b]\nc"
a〜b の改行まで選んで Tab(選択 0〜4)
  前 "[a\nb\n]c"
  後 "  [a\n  b\n]c"
全体を選んで Shift+Tab(選択 0〜14)
  前 "[    a\n  b\n\tc\nd]"
  後 "[  a\nb\nc\nd]"
キャレットが b の後で Shift+Tab(選択 9〜9)
  前 "    a\n  b[]\n\tc\nd"
  後 "    a\nb[]\n\tc\nd"
d の行で Shift+Tab(選択 14〜14)
  前 "    a\n  b\n\tc\nd[]"
  後 null(変更なし)
「  」だけの行で Shift+Tab(選択 2〜2)
  前 "  []"
  後 "[]"

「a〜b の改行まで選んで Tab」では、選択の終わりが c の行の先頭にあります。c の行の文字は1つも選んでいないので、この行は字下げしないようにしています。Shift+Tab では、スペース4つの行から2つ、スペース2つの行から2つ、タブで始まる行からタブ1つを取り除いています。

ブラウザの編集として入れて、Ctrl+Z で戻せるようにする

計算した変更は、テキストエリアの選択範囲を from〜to に合わせてから、document.execCommand('insertText') で入れます。部品の全体は次のとおりです。

tsx
import { useState, type KeyboardEvent } from 'react';
import { computeIndent, type IndentEdit } from './indent';

const INITIAL = 'function meow() {\nconsole.log("にゃー");\n}';

// ブラウザの編集として置き換える(Ctrl+Z で戻せて、input イベントも起きる)
function applyEdit(el: HTMLTextAreaElement, edit: IndentEdit) {
  el.setSelectionRange(edit.from, edit.to);
  const ok = edit.insert === ''
    ? document.execCommand('delete')
    : document.execCommand('insertText', false, edit.insert);
  if (!ok) {
    // execCommand が使えなかったとき(Ctrl+Z では戻せない)
    el.setRangeText(edit.insert, edit.from, edit.to);
    el.dispatchEvent(new Event('input', { bubbles: true }));
  }
  el.setSelectionRange(edit.selStart, edit.selEnd);
}

export function TabTextarea() {
  const [text, setText] = useState(INITIAL);
  // Esc を押した直後だけ、Tab でフォーカスを移せるようにする
  const [tabMovesFocus, setTabMovesFocus] = useState(false);

  function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
    if (e.key === 'Escape') {
      setTabMovesFocus(true);
      return;
    }
    if (e.key !== 'Tab' || e.ctrlKey || e.altKey || e.metaKey || tabMovesFocus) {
      // Tab 以外のキーか、Esc の直後の Tab は、ブラウザに任せる
      setTabMovesFocus(false);
      return;
    }
    e.preventDefault(); // フォーカスを次の要素に移さない
    const el = e.currentTarget;
    const edit = computeIndent(el.value, el.selectionStart, el.selectionEnd, e.shiftKey);
    if (edit) applyEdit(el, edit);
  }

  return (
    <div>
      <label htmlFor="code">コード</label>
      <textarea
        id="code"
        rows={6}
        value={text}
        onChange={(e) => setText(e.target.value)}
        onKeyDown={handleKeyDown}
        onFocus={() => setTabMovesFocus(false)}
        aria-describedby="code-help"
        spellCheck={false}
      />
      <p id="code-help" className="help">
        {tabMovesFocus
          ? '次に Tab を押すと、次の項目へ移ります。'
          : 'Tab で字下げ、Shift+Tab で字下げを戻します。Esc を押してから Tab を押すと、次の項目へ移ります。'}
      </p>
    </div>
  );
}

2行目の先頭で Tab を2回、Shift+Tab を1回押してから、Ctrl+Z を3回押しました。Ctrl+Z を押すたびに、1つ前の状態に戻っています。

text
Tab 1回目      値 "function meow() {\n  console.log(\"にゃー\");\n}"   | 選択 20〜20
Tab 2回目      値 "function meow() {\n    console.log(\"にゃー\");\n}" | 選択 22〜22
Shift+Tab      値 "function meow() {\n  console.log(\"にゃー\");\n}"   | 選択 20〜20
Ctrl+Z 1回目   値 "function meow() {\n    console.log(\"にゃー\");\n}" | 選択 18〜41
Ctrl+Z 2回目   値 "function meow() {\n  console.log(\"にゃー\");\n}"   | 選択 20〜20
Ctrl+Z 3回目   値 "function meow() {\nconsole.log(\"にゃー\");\n}"     | 選択 18〜18

全体を選んで Tab を押したときも、Ctrl+Z を1回押すだけで3行とも元に戻りました。この部品は、かかっている行をまとめて1回の insertText で置き換えています。

MDN の execCommand() の説明では、このメソッドは非推奨とされていますが、編集の履歴が残る点で代わりがないことにも触れられています。insertText は選択範囲を消して文字を入れ、delete は選択範囲を消すコマンドです。

Shift+Tab で空白だけの行が空になるときは、入れる文字がないので delete を使いました。半角スペース2つだけの行で Shift+Tab を押すと空になり、Ctrl+Z で元の2つのスペースに戻りました。

insertText で入れたときは、テキストエリアの input イベントが起き(inputType は insertText)、React の onChange が呼ばれます。字下げしたあとに Esc を押して説明の文を切り替え、部品を描き直させても、値は字下げしたままでした。描き直しで値が戻らないので、state の text も DOM の値と同じに更新されていると分かります。

比べるために、computeIndent の結果から state の文字列を組み立て直し、flushSync で描いてから選択範囲を戻す版も作りました。字下げはできましたが、input イベントは1回も起きず、Ctrl+Z を押しても値は変わりませんでした。

text
全体を選んで Tab   値 "  function meow() {\n  console.log(\"にゃー\");\n  }" | 選択 2〜45
  input イベントの inputType: []
Ctrl+Z 1回目       値 "  function meow() {\n  console.log(\"にゃー\");\n  }" | 選択 2〜45

state を書き換えて入れると Ctrl+Z で戻せなくなることは、以前の記事「Reactで、絵文字を選んで入力欄に入れるピッカーを作る方法」で、絵文字を入れる場合について詳しく比べました。execCommand() を使うときの注意も、そちらに書いています。

execCommand() が false を返したときは、setRangeText() で置き換え、input イベントを自分で送って onChange を呼ばせます。execCommand が必ず false を返すようにして試すと、字下げはでき、描き直させても値は保たれました。ただし、この場合は Ctrl+Z を押しても値は変わりませんでした。

Tab で外に出られなくなるので、抜け方を書いておく

Tab を字下げに使うと、キーボードだけで操作している人は、Tab でテキストエリアから出られなくなります。WCAG 2.2 の「キーボードトラップなし」の解説では、矢印キーや Tab のようなふつうの方法で外に出られない部品でも、抜け出し方を利用者に知らせていれば、この基準を満たすとされています。物理キーボードのある多くの環境では、Esc がよく使われる抜け方だとも書かれています。同じ解説の例には、Tab と Shift+Tab で字下げするリッチテキストエディターが、抜け方を案内したうえで載っています。

そこで、Esc を押した直後の Tab だけは止めずに、ブラウザに任せるようにしました。抜け方はテキストエリアの下に書き、aria-describedby でテキストエリアと結びつけています。Esc を押すと、この文が「次に Tab を押すと、次の項目へ移ります。」に変わります。

text
本文で Tab を3回             選択 45〜45 | フォーカス: 本文
Esc のあと Tab              選択 45〜45 | フォーカス: ボタン「保存」
ボタンから Shift+Tab で本文に戻る  選択 45〜45 | フォーカス: 本文
戻ってから Tab              選択 47〜47 | フォーカス: 本文
Esc、「a」と打ってから Tab     選択 50〜50 | フォーカス: 本文

Tab を3回押してもフォーカスはテキストエリアにあり、末尾に空白が6つ入りました。Esc のあとの Tab では、値は変わらず「保存」ボタンに移りました。Shift+Tab でテキストエリアに戻ると、onFocus で tabMovesFocus が false に戻り、Tab はまた字下げになります。Esc のあとに「a」のようなほかのキーを押したときも false に戻るので、続く Tab は字下げになりました。

​Posted by 86