Reactで、タグを入力して並べる入力欄を作る方法

​​React​IME,Playwright,TypeScript,フォーム

タグの入力欄は、タグの配列と、入力中の文字列を別々の state で持つと素直に書けます。Enter か「,」を押したら入力中の文字列をタグの配列に足し、×ボタンか、入力欄が空のときの Backspace で配列から消します。

そのうえで気をつけるところが3つあります。日本語の変換を確定する Enter でタグを作らないこと、Enter でフォームが送信されないようにすること、タグを消したあとにフォーカスを入力欄へ戻すことです。どれも、処理を抜いた書き方と比べて、何が起きるかを確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 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 を操作して、タグの並びや入力欄の値、フォーカスの位置を調べました。日本語の変換は実際の IME ではなく、Chrome DevTools Protocol の命令で再現しています。Firefox と Safari、スマホのブラウザ、スクリーンリーダーでの読み上げは試していません。

タグの入力欄に「react」と入力して Enter を押すとタグになり、「vite」と「,」、「hooks」と Enter で3つ並ぶ。vite の×をクリックすると vite が消え、入力欄が空のまま Backspace を押すと hooks が消える
タグの追加と削除を5回続けたときの入力欄。タグの数の表示は、数が変わった瞬間だけピンクになる(カーソル、押したキーとクリックの表示、操作の回数の表示は GIF 用に重ねたもの)

タグの配列と、入力中の文字列を分けて持つ

コンポーネントの全体は次のとおりです。タグの配列 tags は親から受け取り、変えるときは onChange で親に新しい配列を渡します。入力中の文字列 text は、このコンポーネントの中だけで持ちます。

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

type Props = {
  label: string;
  tags: string[];
  onChange: (tags: string[]) => void;
};

// 「,」「、」や改行で区切って、前後の空白を取り、空のものを除く
export function splitTags(text: string) {
  return text
    .split(/[,、\n]/)
    .map((t) => t.trim())
    .filter(Boolean);
}

export function TagInput({ label, tags, onChange }: Props) {
  const [text, setText] = useState('');
  const inputRef = useRef<HTMLInputElement>(null);
  const id = useId();

  function addTags(candidates: string[]) {
    const next = [...tags];
    for (const tag of candidates) {
      // 大文字と小文字だけが違うものも、同じタグとして扱う
      if (!next.some((t) => t.toLowerCase() === tag.toLowerCase())) next.push(tag);
    }
    if (next.length !== tags.length) onChange(next);
    setText('');
  }

  function removeTag(index: number) {
    onChange(tags.filter((_, i) => i !== index));
    // 押した削除ボタンは消えるので、入力欄にフォーカスを戻す
    inputRef.current?.focus();
  }

  function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
    // 日本語の変換中のキーは、変換の操作として IME に任せる
    if (e.nativeEvent.isComposing || e.keyCode === 229) return;
    if (e.key === 'Enter' || e.key === ',') {
      // フォームの送信と、「,」が入力欄に入るのを止める
      e.preventDefault();
      addTags(splitTags(text));
    } else if (e.key === 'Backspace' && text === '' && tags.length > 0) {
      removeTag(tags.length - 1);
    }
  }

  function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
    const pasted = e.clipboardData.getData('text');
    // 区切りを含む文字列を貼り付けたときだけ、まとめてタグにする
    if (!/[,、\n]/.test(pasted)) return;
    e.preventDefault();
    addTags(splitTags(text + pasted));
  }

  return (
    <div className="field">
      <label htmlFor={id}>{label}</label>
      <div className="tag-input" onClick={() => inputRef.current?.focus()}>
        <ul className="tags">
          {tags.map((tag, i) => (
            <li key={tag} className="tag">
              {tag}
              <button type="button" aria-label={`${tag} を削除`} onClick={() => removeTag(i)}>
                ×
              </button>
            </li>
          ))}
        </ul>
        <input
          id={id}
          ref={inputRef}
          value={text}
          onChange={(e) => setText(e.target.value)}
          onKeyDown={handleKeyDown}
          onPaste={handlePaste}
          placeholder={tags.length === 0 ? 'Enter か , で追加' : ''}
        />
      </div>
    </div>
  );
}

addTags で、大文字と小文字だけが違うものも含めて、同じタグを足さないようにしています。そのため、タグの文字列は配列の中で重複せず、そのまま key に使えます。React の公式ドキュメントにある key の説明では、key は兄弟の要素の中で重複しないこととされています。また、配列の添字を key にすると、要素の追加や削除で並びが変わったときに分かりにくい不具合のもとになる、と書かれています。タグは途中のものを消すので、添字ではなくタグの文字列を使いました。

入力欄の <label> と <input> は、useId で作った ID で結びつけています。useId のページによると、同じコンポーネントを画面に何度置いても、作られる ID は重複しません。

見た目は、タグと入力欄を横に並べて折り返す CSS にしました。主な指定は次のとおりです。枠は .tag-input に付け、:focus-within で、中の入力欄にフォーカスがあるときだけ枠の色を変えます。.tag-input には、クリックされたら入力欄にフォーカスを移す onClick を付けています。検証環境で、枠の外をクリックしてフォーカスを外したあと、.tag-input の左上の、タグも入力欄もない余白をクリックすると、フォーカスは入力欄に移りました。

css
.tag-input {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  padding: 6px;
  border: 1px solid #9aa5ab;
  border-radius: 6px;
}
.tag-input:focus-within {
  border-color: #00c8e8;
  outline: 2px solid #b8eef7;
}
.tags {
  display: contents;
  list-style: none;
}
.tag-input input {
  flex: 1;
  min-width: 8em;
  border: 0;
  outline: none;
}

.tags に display: contents を付けると、<ul> は自分の箱を作らず、中の <li> が .tag-input の直接の子のように並びます。そのため、タグと入力欄が同じ行に並び、幅が足りなくなったところで折り返します。冒頭の GIF でも、タグが3つになったところで、入力欄が2行目に回っています。ただし、MDN の display の説明には、display: contents を付けた要素を、アクセシビリティツリーから外してしまうブラウザがあると書かれています(中の要素は残り、仕様のうえでは誤った動きとされています)。その場合、<ul> がリストとして読み上げられなくなることがあります。

Enter と「,」で入力をタグにする

検証環境で、入力欄にいくつか打ち込んで確かめた結果は次のとおりです。

text
「react」と入力して Enter: タグ ["react"] / 入力欄「」
「typescript」と入力して ,: タグ ["react","typescript"] / 入力欄「」
「React」と入力して Enter: タグ ["react","typescript"] / 入力欄「」
空白だけを入力して Enter: タグ ["react","typescript"] / 入力欄「」
「  vite  」と入力して Enter: タグ ["react","typescript","vite"] / 入力欄「」

「React」は、すでにある「react」と大文字・小文字だけが違うので、足されませんでした。空白だけの入力はタグにならず、前後に空白のある「 vite 」は、空白を取った「vite」になりました。どの場合も、Enter や「,」を押したあとの入力欄は空になります。

「,」のキーで preventDefault() を呼んでいるのは、「,」そのものが入力欄に入らないようにするためです。呼ばないとどうなるかは、後の節で確かめています。

日本語の変換を確定する Enter で、タグを作らない

日本語を入力するときは、変換を確定するために Enter を押します。この Enter にも keydown が届くので、何もしないと、変換を確定したつもりの Enter でタグができてしまいます。

「ねこ」と入力して「猫」に変換し、Enter で確定する操作を再現して、handleKeyDown の最初の isComposing の判定がある書き方と、ない書き方を比べました。

text
isComposing を見る書き方(この記事の書き方)
届いた keydown: Enter(isComposing=true, keyCode=13)
「ねこ」を「猫」に変換して Enter で確定: タグ [] / 入力欄「猫」
もう一度 Enter: タグ ["猫"] / 入力欄「」

isComposing を見ない書き方
届いた keydown: Enter(isComposing=true, keyCode=13)
「ねこ」を「猫」に変換して Enter で確定: タグ ["猫"] / 入力欄「猫」

この再現では、isComposing を見ない書き方だと、変換を確定する Enter で「猫」のタグができました。そのうえ、確定した「猫」が入力欄にも残りました。この記事の書き方では、確定の Enter ではタグができず、入力欄に「猫」が入りました。もう一度 Enter を押すと、「猫」がタグになります。

React の公式リファレンスにある KeyboardEvent の説明では、React のイベントから読めるプロパティに isComposing が挙がっていません。そこで、ブラウザの元のイベントを指す e.nativeEvent から読んでいます。MDN の keydown イベントのページにある IME の説明では、変換中の keydown を無視するために、isComposing に加えて、keyCode が 229 かどうかも見る書き方が示されています。229 は、IME が処理したキーのイベントに付く特別な値です。MDN の同じ箇所には、compositionend が keydown より先に起きると、isComposing が false なのに keyCode が 229 の keydown が届く、という注記もあります。

補足

検証環境では実際の IME を動かせず、Chrome DevTools Protocol の Input.imeSetComposition と Input.insertText で変換と確定を再現しました。この再現では、確定の Enter の keyCode は 13 だったので、keyCode === 229 の判定は通っていません。タグを作らずに済んだのは、isComposing の判定によるものです。実際の IME で確定したときの keyCode の値や、入力欄に文字が残るかどうかは確かめていません。

入力欄が空のときの Backspace で、最後のタグを消す

handleKeyDown では、入力欄が空のときに Backspace が押されたら、最後のタグを消します。入力欄に文字があるうちは、ふつうに1文字消えるだけです。

text
「ab」と入力して Backspace: タグ ["react","typescript","vite"] / 入力欄「a」
もう一度 Backspace(入力欄が空になる): タグ ["react","typescript","vite"] / 入力欄「」
入力欄が空のときに Backspace: タグ ["react","typescript"] / 入力欄「」

「a」を消して入力欄が空になった Backspace では、タグは消えません。その次の Backspace で、最後の「vite」が消えました。text === '' の判定は、キーを押した時点の入力欄の値で行われるためです。

削除ボタンに、どのタグを消すかが分かる名前を付ける

タグの横の×ボタンには、aria-label で「react を削除」のような名前を付けています。ボタンの中身は「×」の1文字だけで、どのタグを消すボタンなのかを表す文字がありません。MDN の aria-label の説明では、文字のないアイコンだけのボタンに aria-label で名前を付ける例が示されています。検証では、Playwright の getByRole('button', { name: 'react を削除' }) で、このボタンを見つけてクリックできました。

ボタンを押すと、そのタグと一緒にボタンも画面から消えます。removeTag で inputRef.current?.focus() を呼んでいるのは、このためです。フォーカスを戻さない書き方と比べました。

text
フォーカスを戻さない書き方
「vite を削除」をクリック: タグ ["react"] / 入力欄「」 / フォーカス BODY
続けて「hooks」とキーを打つ: タグ ["react"] / 入力欄「」 / フォーカス BODY

この記事の書き方
「vite を削除」をクリック: タグ ["react"] / 入力欄「」 / フォーカス INPUT
続けて「hooks」とキーを打つ: タグ ["react"] / 入力欄「hooks」 / フォーカス INPUT

フォーカスを戻さないと、フォーカスは <body> に移り、続けて打ったキーはどこにも入りませんでした。キーボードで操作している人は、入力欄までフォーカスを移し直すことになります。

区切りを含む文字列を貼り付けたら、まとめてタグにする

「猫, 写真、 TypeScript」のように、区切りを含む文字列を貼り付けたときは、onPaste でまとめてタグにします。MDN の paste イベントの説明にあるとおり、貼り付ける文字列は clipboardData.getData() で読めます。また、preventDefault() を呼ぶと、入力欄に文字列が入る、いつもの動きを止められます。

検証環境で、Backspace の節の続きで「react を削除」を押し、タグが「typescript」だけになった状態から、次の文字列を貼り付けました。

text
「猫, 写真、 TypeScript\nhooks,,」を貼り付け: タグ ["typescript","猫","写真","hooks"] / 入力欄「」
区切りのない「useState」を貼り付け: タグ ["typescript","猫","写真","hooks"] / 入力欄「useState」
続けて Enter: タグ ["typescript","猫","写真","hooks","useState"] / 入力欄「」

「,」「、」と改行で分かれ、すでにある「typescript」と重複する「TypeScript」と、「,,」の間の空の文字列は除かれました。区切りのない「useState」は、ふつうの貼り付けと同じく入力欄に入り、Enter を押すとタグになりました。

この handlePaste は、カーソルの位置を見ずに、入力欄の文字列の後ろに貼り付けた文字列をつなげてから分けています。入力欄に「vtest」と打ち、「v」の後ろにカーソルを置いて「ite, hooks」を貼り付けると、タグは「vtestite」と「hooks」になりました。カーソルの位置に入るなら「vite」と「hookstest」になるところです。

なお、splitTags は「、」でも分けますが、キーを押してタグにするのは Enter と「,」だけです。「react」のあとに日本語入力で「、」を打って確定する操作を再現すると、「、」はそのまま入力欄に入り、タグにはなりませんでした。続けて「vite」と打ち、入力欄が「react、vite」の状態で Enter を押すと、「react」と「vite」の2つのタグになりました。

フォームで送るときは、タグを hidden の input に入れる

タグの入力欄を、ほかの入力欄と一緒にフォームで送るときの書き方です。タグは <input> の値ではなく state の配列にあるので、そのままではフォームの値に入りません。親のコンポーネントで、タグを1つずつ type="hidden" の <input> にしておくと、FormData で読めるようになります。

tsx
import { useState } from 'react';
import { TagInput } from './TagInput';

export function App() {
  const [tags, setTags] = useState<string[]>([]);
  const [sent, setSent] = useState('');

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    setSent(JSON.stringify(data.getAll('tags')));
  }

  return (
    <form onSubmit={handleSubmit}>
      <TagInput label="タグ" tags={tags} onChange={setTags} />
      {/* フォームの値として送るために、タグを1つずつ hidden の input に入れる */}
      {tags.map((tag) => (
        <input key={tag} type="hidden" name="tags" value={tag} />
      ))}
      <p className="count">{tags.length}個のタグ</p>
      <button type="submit">保存</button>
      <output>{sent}</output>
    </form>
  );
}

前の節の5つのタグが並んだ状態で「保存」を押すと、data.getAll('tags') は ["typescript","猫","写真","hooks","useState"] を返しました。同じ name の値が、タグの並びの順で取り出せます。

preventDefault を呼ばないと、Enter でフォームが送信される

handleKeyDown から e.preventDefault() を抜いた書き方でも試しました。

text
「react」と入力して Enter: タグ ["react"] / 入力欄「」 / 送信 1回
「vite」と入力して ,: タグ ["react","vite"] / 入力欄「,」 / 送信 1回

Enter を押すと、タグが足されると同時に、フォームの submit イベントが起きました。「,」を押すと、タグは足されたものの、空になったはずの入力欄に「,」が残りました。この記事の書き方では、Enter を押しても submit イベントは0回でした。

フォームの中のテキストの入力欄で Enter を押すとフォームが送信される動きは、HTML の仕様にある、暗黙の送信(implicit submission)の説明 に書かれています。タグを確定するための Enter で、書きかけのフォームが送られてしまうので、Enter の keydown では preventDefault() を呼んでおきます。

​Posted by 86