Reactで、表のセルをその場で編集できるようにする方法

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

表のセルを、ふだんはボタン、編集中は入力欄として切り替えると、表の中でそのまま値を書き換えられます。Enter で保存、Escape で取り消し、入力欄の外をクリックしたときも保存、という動きにします。

ただ、入力欄を出して Enter で保存するだけの書き方では、日本語の変換を確定した Enter で編集が終わったり、保存したあとにフォーカスが行き場をなくしたりします。ここでは、そうした点を直した EditableCell コンポーネントを作り、何も手当てしない書き方と比べます。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。日本語の変換は実際の IME ではなく、Chrome DevTools Protocol(CDP)の命令で再現しています。StrictMode を付けた開発ビルドでも、出力は本番ビルドと同じで、コンソールにエラーや警告は出ませんでした。Firefox と Safari、スクリーンリーダーでの読み上げは試していません。

「ミケの年齢」のセルをクリックすると入力欄に変わって 3 が選ばれた状態になり、4 と入力して Enter を押すと 4 で保存される。次に「クロの年齢」に abc と入力して Enter を押すとエラーが出て入力欄のまま残り、Escape を押すと元の 7 に戻る
セルをクリックして編集し、Enter で保存する様子と、間違った値を Escape で取り消す様子。保存や取り消しで変わったセルを、その瞬間だけピンクにしている。エラーのときの入力欄のピンクの枠とメッセージは、表そのものの見た目。上の帯、カーソル、クリックした瞬間の輪は、GIF 用に重ねたもの

セルは「表示中」と「編集中」を切り替える

1つのセルを受け持つ EditableCell です。編集中かどうかと、入力欄の中身を、1つの state draft で持ちます。null なら表示中、文字列なら編集中です。

tsx
import { useEffect, useId, useRef, useState } from 'react';

type Props = {
  value: string;
  label: string; // 入力欄の名前(「ミケの年齢」など)
  validate?: (draft: string) => string | null; // 問題があればメッセージを返す
  onCommit: (draft: string) => void;
};

export function EditableCell({ value, label, validate, onCommit }: Props) {
  // null のときは表示中、文字列のときは編集中(入力欄の中身)
  const [draft, setDraft] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const focusCell = useRef(false);
  const errorId = useId();

  // 編集を終えたあと、Enter や Escape で終えたときだけセルにフォーカスを戻す
  useEffect(() => {
    if (draft === null && focusCell.current) {
      focusCell.current = false;
      buttonRef.current?.focus();
    }
  }, [draft]);

  const close = (refocus: boolean) => {
    focusCell.current = refocus;
    setError(null);
    setDraft(null);
  };
  const save = (refocus: boolean) => {
    if (draft === null) return;
    const message = validate?.(draft) ?? null;
    if (message) {
      setError(message);
      return;
    }
    if (draft !== value) onCommit(draft);
    close(refocus);
  };

  if (draft === null) {
    return (
      <button ref={buttonRef} type="button" className="cell" aria-label={`${label} ${value}`} onClick={() => setDraft(value)}>
        {value}
      </button>
    );
  }
  return (
    <>
      <input
        autoFocus
        value={draft}
        aria-label={label}
        aria-invalid={error !== null}
        aria-describedby={error ? errorId : undefined}
        onFocus={(e) => e.currentTarget.select()}
        onChange={(e) => {
          setDraft(e.target.value);
          setError(null);
        }}
        onKeyDown={(e) => {
          // 日本語の変換を確定する Enter では、保存しない
          if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
            e.preventDefault();
            save(true);
          } else if (e.key === 'Escape') {
            e.preventDefault();
            close(true);
          }
        }}
        onBlur={() => save(false)}
      />
      {error && <span id={errorId} className="error">{error}</span>}
    </>
  );
}

表示中のセルを <button> にしたのは、Tab キーで移動でき、Enter で押せるようにするためです。クリックでもキーボードでも、onClick で draft に今の値を入れると、編集中に切り替わります。

EditableCell は、編集中の文字列だけを持ちます。表のデータそのものは持たず、保存するときに onCommit で親に渡します。値が変わっていないときは、onCommit を呼びません。

表のデータは、親で行の配列として持つ

表を描くコンポーネントでは、行の配列を state に持ちます。onCommit で受け取った値で、map を使って該当する行だけを置き換えた新しい配列を作ります。React の公式ドキュメントの、配列の中のオブジェクトを更新する説明にある書き方です。__commits の行は、保存が呼ばれた回数を確かめるために入れたものです。

tsx
const validateName = (draft: string) => (draft.trim() === '' ? '名前を入力してください' : null);
const validateAge = (draft: string) => {
  const s = draft.normalize('NFKC').trim();
  return /^\d+$/.test(s) && Number(s) <= 30 ? null : '0〜30の整数で入力してください';
};

export function CatTable({ naive = false }: { naive?: boolean }) {
  const [cats, setCats] = useState(initialCats);
  const update = (id: number, patch: Partial<Cat>) => {
    // 確かめるために、保存が呼ばれた回数を数える
    (window as any).__commits = ((window as any).__commits ?? 0) + 1;
    setCats((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c)));
  };

1行には、名前と年齢の2つのセルを置きます。セルごとに、値をチェックする関数(validate)と、文字列を行のデータに直す処理(onCommit)を渡します。

tsx
            <tr key={cat.id}>
              <td>
                <EditableCell value={cat.name} label={`${cat.name}の名前`} validate={validateName}
                  onCommit={(v) => update(cat.id, { name: v.trim() })} />
              </td>
              <td>
                <EditableCell value={String(cat.age)} label={`${cat.name}の年齢`} validate={validateAge}
                  onCommit={(v) => update(cat.id, { age: Number(v.normalize('NFKC').trim()) })} />
              </td>
            </tr>

Cat 型(id、name、age)と、最初の3行 initialCats の定義は省いています。CatTable の naive は、このあと比べる書き方に切り替えるためのものです。

何も手当てしない書き方で起きること

比べるために、入力欄を出して Enter・Escape・フォーカスが外れたときの処理を書いただけの NaiveCell を用意しました。

tsx
import { useState } from 'react';

// 比べるための書き方:変換中の Enter の確認も、フォーカスを戻す処理もない
export function NaiveCell({ value, label, onCommit }: { value: string; label: string; onCommit: (draft: string) => void }) {
  const [draft, setDraft] = useState<string | null>(null);

  if (draft === null) {
    return (
      <button type="button" className="cell" aria-label={`${label} ${value}`} onClick={() => setDraft(value)}>
        {value}
      </button>
    );
  }
  return (
    <input
      autoFocus
      value={draft}
      aria-label={label}
      onChange={(e) => setDraft(e.target.value)}
      onKeyDown={(e) => {
        if (e.key === 'Enter') {
          onCommit(draft);
          setDraft(null);
        } else if (e.key === 'Escape') {
          setDraft(null);
        }
      }}
      onBlur={() => {
        onCommit(draft);
        setDraft(null);
      }}
    />
  );
}

2つの書き方で、同じ操作をしました。行の中身は「名前 年齢」の順で、[ ] は入力欄とその中身です。「選択 0-1」は、入力欄の中で選ばれている範囲の始まりと終わりの位置(0から数える)です。0-1 なら1文字目が選ばれていて、2-2 のように同じ数なら何も選ばれておらず、カーソルがその位置にあります。まず EditableCell です。

text
開いた直後: ミケ 3 / クロ 7 / シロ 1 | フォーカス: body | 保存 0回
「ミケの年齢」のセルをクリック: ミケ [3] / クロ 7 / シロ 1 | フォーカス: input「ミケの年齢」 選択 0-1 | 保存 0回
「4」と入力: ミケ [4] / クロ 7 / シロ 1 | フォーカス: input「ミケの年齢」 選択 1-1 | 保存 0回
Enter: ミケ 4 / クロ 7 / シロ 1 | フォーカス: button「ミケの年齢 4」 | 保存 1回
「クロの名前」をクリックして「クロスケ」と入力: ミケ 4 / [クロスケ] 7 / シロ 1 | フォーカス: input「クロの名前」 選択 4-4 | 保存 1回
Escape: ミケ 4 / クロ 7 / シロ 1 | フォーカス: button「クロの名前 クロ」 | 保存 1回
「シロの名前」に「ユキ」と入力して、表の外をクリック: ミケ 4 / クロ 7 / ユキ 1 | フォーカス: body | 保存 2回
Tab で先頭のセルに移り Enter: [ミケ] 4 / クロ 7 / ユキ 1 | フォーカス: input「ミケの名前」 選択 0-2 | 保存 2回
「とら」を「トラ」に変換して Enter で確定(CDP で再現): [トラ] 4 / クロ 7 / ユキ 1 | フォーカス: input「ミケの名前」 選択 2-2 | 保存 2回
もう一度 Enter: トラ 4 / クロ 7 / ユキ 1 | フォーカス: button「トラの名前 トラ」 | 保存 3回

次に NaiveCell です。

text
開いた直後: ミケ 3 / クロ 7 / シロ 1 | フォーカス: body | 保存 0回
「ミケの年齢」のセルをクリック: ミケ [3] / クロ 7 / シロ 1 | フォーカス: input「ミケの年齢」 選択 1-1 | 保存 0回
「4」と入力: ミケ [34] / クロ 7 / シロ 1 | フォーカス: input「ミケの年齢」 選択 2-2 | 保存 0回
Enter: ミケ 34 / クロ 7 / シロ 1 | フォーカス: body | 保存 1回
「クロの名前」をクリックして「クロスケ」と入力: ミケ 34 / [クロクロスケ] 7 / シロ 1 | フォーカス: input「クロの名前」 選択 6-6 | 保存 1回
Escape: ミケ 34 / クロ 7 / シロ 1 | フォーカス: body | 保存 1回
「シロの名前」に「ユキ」と入力して、表の外をクリック: ミケ 34 / クロ 7 / シロユキ 1 | フォーカス: body | 保存 2回
Tab で先頭のセルに移り Enter: [ミケ] 34 / クロ 7 / シロユキ 1 | フォーカス: input「ミケの名前」 選択 2-2 | 保存 2回
「とら」を「トラ」に変換して Enter で確定(CDP で再現): トラ 34 / クロ 7 / シロユキ 1 | フォーカス: body | 保存 3回
もう一度 Enter: トラ 34 / クロ 7 / シロユキ 1 | フォーカス: body | 保存 3回

違いは3つあります。

入力欄を出したら、中の文字を全部選んでおく

NaiveCell では、入力欄を出したときにカーソルが文字の後ろにあり、「4」と打つと「34」になりました。値を書き換えたい場面では、毎回いまの値を消す手間がかかります。

EditableCell は、入力欄にフォーカスが入ったときに select() を呼びます。MDN の select() の説明のとおり、入力欄の文字が全部選ばれた状態になるので、打った文字でそのまま置き換わります。

Enter と Escape のあとは、フォーカスをセルに戻す

NaiveCell では、Enter や Escape で入力欄が消えると、フォーカスが body に移りました。どのセルにもフォーカスがない状態になり、キーボードで操作している人には、いまどのセルにいるのかが画面から分からなくなります。

EditableCell では、Enter や Escape で編集を終えたとき、focusCell を true にしておき、表示中に戻ったあとの useEffect でボタンにフォーカスを移します。ボタンは入力欄と入れ替わりで新しく作られるので、編集を終える処理の中ではまだフォーカスを移せません。描画が終わってから useEffect で移します。

表の外をクリックして編集を終えたときは、フォーカスを戻しません。クリックした先にフォーカスを移したい操作なので、セルに引き戻すと邪魔になります。

変換を確定する Enter では保存しない

日本語の入力では、変換を確定するときにも Enter を押します。NaiveCell では、「とら」を「トラ」に変換して確定した Enter で、編集が終わってしまいました。「トラ」に続けて文字を打つことができません。

MDN の isComposing の説明によると、キーボードのイベントの isComposing は、変換の始まり(compositionstart)から終わり(compositionend)までの間に起きたイベントかどうかを表します。EditableCell では、React のイベントの nativeEvent(ブラウザの元のイベント)にある isComposing が true の Enter を無視するので、確定のための Enter では保存せず、もう一度 Enter を押したときに保存されました。

間違った値のときは、編集を続けさせる

validate がメッセージを返したときは、保存せずに入力欄のまま残します。入力欄には aria-invalid を付け、メッセージを aria-describedby で結びつけます。MDN の aria-invalid の説明には、入力チェックを自分で書くときは、aria-invalid を付けるだけでなく、どこが間違いかをメッセージで伝えるよう書かれています。MDN はメッセージを結びつける属性として aria-errormessage を挙げていますが、ここでは aria-describedby を使いました。

text
「ミケの年齢」に「abc」と入力して Enter: ミケ [abc](aria-invalid)「0〜30の整数で入力してください」 / クロ 7 / シロ 1 | フォーカス: input「ミケの年齢」 選択 3-3 | 保存 0回
Backspace: ミケ [ab] / クロ 7 / シロ 1 | フォーカス: input「ミケの年齢」 選択 2-2 | 保存 0回
「31」に打ち直して Enter: ミケ [31](aria-invalid)「0〜30の整数で入力してください」 / クロ 7 / シロ 1 | フォーカス: input「ミケの年齢」 選択 2-2 | 保存 0回
表の外をクリック: ミケ [31](aria-invalid)「0〜30の整数で入力してください」 / クロ 7 / シロ 1 | フォーカス: body | 保存 0回
入力欄に戻り、全角の「5」に打ち直して Enter: ミケ 5 / クロ 7 / シロ 1 | フォーカス: button「ミケの年齢 5」 | 保存 1回
「クロの名前」を空にして Enter: ミケ 5 / [](aria-invalid)「名前を入力してください」 7 / シロ 1 | フォーカス: input「クロの名前」 選択 0-0 | 保存 1回
Escape: ミケ 5 / クロ 7 / シロ 1 | フォーカス: button「クロの名前 クロ」 | 保存 1回

文字を打ち直すと、onChange でメッセージを消します。表の外をクリックしたときも、間違った値のままなら保存せず、入力欄とメッセージを残しました。取りやめたいときは、Escape で元の値に戻せます。

年齢は、normalize('NFKC') で全角の数字を半角に直してからチェックしています。全角の数字で入力されても、受け付けられるようにするためです。validateAge にいくつかの値を渡した結果は、次のとおりです。

text
"5"     → "5"    OK
"5"     → "5"    OK
" 12 "  → "12"   OK
"12"    → "12"   OK
"30"    → "30"   OK
"31"    → "31"   0〜30の整数で入力してください
"-1"    → "-1"   0〜30の整数で入力してください
"2.5"   → "2.5"  0〜30の整数で入力してください
"abc"   → "abc"  0〜30の整数で入力してください
""      → ""     0〜30の整数で入力してください

Escape で消した入力欄の onBlur は呼ばれなかった

EditableCell では、入力欄の onBlur でも保存します。そうすると、Escape で入力欄を消したときに、消える入力欄からフォーカスが外れて onBlur が呼ばれ、取り消したはずの値が保存されるのではないか、という心配があります。

そこで、Escape を押す前に、入力欄に blur と focusout のイベントを数える処理を直接付けて確かめました。1行目が EditableCell、?naive の行が NaiveCell の結果です。

text
EditableCell: 入力欄に届いたイベント ["blur","focusout"] / 入力欄の数 0 / 保存 0回
?naive: 入力欄に届いたイベント ["blur","focusout"] / 入力欄の数 0 / 保存 0回

Chromium 153 では、入力欄が消えるときに blur と focusout が起きていました。それでも、保存の回数は0回のままでした。NaiveCell の onBlur は必ず onCommit を呼ぶので、消えた入力欄の onBlur は、React 19.3.1 から呼ばれていないことになります。

EditableCell は、この動きを前提にしています。もし消えた入力欄の onBlur が呼ばれる環境があれば、Escape で取り消した値が保存されてしまいます。この環境では、Escape で取り消した値は保存されず、Enter で保存したときも保存の回数は1回ずつしか増えませんでした。

​Posted by 86