Reactで、絵文字を選んで入力欄に入れるピッカーを作る方法

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

ピッカーで選んだ絵文字をテキストエリアに入れるとき、state の文字列を組み立て直して入れると、検証環境の Chromium では、入れたあとに Ctrl+Z を押しても元に戻せなくなりました。document.execCommand('insertText') でブラウザの編集として入れれば、元に戻す履歴が残り、React の onChange もふつうの入力と同じように呼ばれます。

ピッカーの検索欄にフォーカスが移っても、テキストエリアのキャレットの位置は残っています。選んだときにテキストエリアへフォーカスを戻せば、絵文字はキャレットがあった場所に入ります。ここでは、検索、矢印キーでの移動、最近使った絵文字の保存までを含めたピッカーを作ります。

補足

検証環境は 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 で変換し、ヘッドレスの Chromium を Playwright で操作して確かめました。StrictMode を付けた開発ビルドでも結果は本番ビルドと同じで、どちらもコンソールにエラーや警告は出ませんでした。Firefox と Safari、スマートフォンのブラウザ、日本語の IME での入力、スクリーンリーダーでの読み上げは試していません。

「ねこ です」のキャレットを「ねこ」の後ろに置いたまま絵文字のボタンを押すとピッカーが開き、検索欄に「ねこ」と打つと猫の絵文字が3つに絞られる。黒ねこを押すと「ねこ」の後ろに入ってピッカーが閉じ、もう一度開くと最近使った絵文字の行に黒ねこが出る。クラッカーを押すと黒ねこの後ろに入る
ピッカーを開き、「ねこ」で検索して黒ねこを入れ、もう一度開いてクラッカーを入れる様子。操作で変わった表示を、その瞬間だけピンクの枠で囲んでいる(カーソル、押した瞬間の輪、画面上部に出した操作の回数は GIF 用に重ねたもの)

絵文字のデータは、検索に使う言葉と一緒に持つ

絵文字の一覧は、文字そのものと、検索に使う名前とキーワードを1つのオブジェクトにまとめた配列にしました。検証では40個の絵文字を並べています。先頭の部分だけを抜き出すと、次のようになります。

ts
export type Emoji = { char: string; name: string; keywords: string[] };

// 検索に使う名前とキーワードは、ひらがなと漢字で持つ
export const EMOJIS: Emoji[] = [
  { char: '😀', name: 'にっこり', keywords: ['笑顔', 'えがお'] },
  { char: '😂', name: 'うれし泣き', keywords: ['笑う', 'わらう', '涙'] },
  { char: '😍', name: 'ハートの目', keywords: ['好き', 'すき'] },

name は、ボタンの aria-label にも使います。絵文字だけが書かれたボタンにも名前が付くので、検証で使った Playwright のテストでは、getByRole で「黒ねこ」という名前からボタンを探せました(スクリーンリーダーでの読み上げは試していません)。

入れる処理は、ブラウザの編集として行う

テキストエリアに絵文字を入れる関数です。テキストエリアにフォーカスを戻してから、選ばれている範囲(何も選んでいなければキャレットの位置)を絵文字で置き換えます。

ts
// テキストエリアの選択範囲を text で置き換え、キャレットを text の直後に置く
export function insertAtCaret(el: HTMLTextAreaElement, text: string) {
  el.focus();
  // ブラウザの編集として入れると、Ctrl+Z(元に戻す)の履歴に残り、input イベントも起きる
  if (document.execCommand('insertText', false, text)) return;
  // execCommand が使えなかったとき
  el.setRangeText(text, el.selectionStart, el.selectionEnd, 'end');
  el.dispatchEvent(new Event('input', { bubbles: true }));
}

React らしく書くなら、value.slice(0, start) + text + value.slice(end) を state に入れ、useLayoutEffect でキャレットを戻す方法もあります。以前の記事「Reactで、入力欄で「@」を打つと名前の候補を出す方法」でも、この書き方で名前を入れました。検証環境の Chromium で、2つの書き方を比べました。どちらも、「ねこ です」の「ねこ」の後ろに黒ねこ(🐈‍⬛)を入れ、続けて Ctrl+Z を3回押しました。

text
--- state を書き換えて入れる ---
入れた直後: "ねこ🐈‍⬛ です" キャレット 6 フォーカス テキストエリア
Ctrl+Z 1回目: "ねこ🐈‍⬛ です"
Ctrl+Z 2回目: "ねこ🐈‍⬛ です"
Ctrl+Z 3回目: "ねこ🐈‍⬛ です"
--- insertAtCaret で入れる ---
入れた直後: "ねこ🐈‍⬛ です" キャレット 6 フォーカス テキストエリア
Ctrl+Z 1回目: "ねこ です"
Ctrl+Z 2回目: "ねこ で"
Ctrl+Z 3回目: "ねこ "
insertAtCaret 側の state: "ねこ "

state を書き換えた場合は、Ctrl+Z を何回押しても値が変わりませんでした。絵文字を入れる前に打った「です」も戻せません。insertAtCaret で入れた場合は、1回目で絵文字が消え、そのあとは打った「です」が後ろから1文字ずつ消えました。Ctrl+Z で戻したときも input イベントが起きて onChange が呼ばれるので、React の state も DOM の値と同じ「ねこ 」になっています。

MDN の execCommand() の説明では、このメソッドは非推奨(deprecated)とされています。そのうえで、DOM を直接書き換える方法と違って編集の履歴(元に戻す履歴)が残るので、ほかに代わりがない使い方では使ってよい、ただし複数のブラウザで動きを確かめること、と書かれています。

注意

同じ MDN の説明によると、execCommand() で input イベントが起きるかどうかは、ブラウザや設定によって違います。また、execCommand() が true を返すのは、ユーザーの操作の中で呼んだときだけです。await のあとなど、クリックやキー操作の処理から外れたところで呼んで false が返った場合は、下の setRangeText() の側で入るので、Ctrl+Z では戻せないと考えられます。この場合は試しておらず、筆者が確かめたのは、クリックと Enter の処理の中から呼んだ Chromium の場合だけです。

execCommand() が false を返したときは、setRangeText() で選択範囲を置き換えます。4つ目の引数の 'end' で、キャレットを入れた文字の直後に置きます。MDN の setRangeText() の説明で、起きるイベントとして挙がっているのは select と selectionchange だけです。そこで、自分で input イベントを送って onChange を呼ばせます。

execCommand が必ず false を返すようにして試したところ、絵文字は「ねこ」の後ろに入り、キャレットも絵文字の直後(位置 6)に来ました。そのあとピッカーを開いて閉じ、コンポーネントを描き直させても、値は「ねこ🐈‍⬛ です」のままでした。input イベントで state が更新されているためです。ただし、この場合は Ctrl+Z を押しても値は変わりませんでした。

1つの絵文字でも、length は1とは限らない

上の結果で、「ねこ」(2文字)の後ろに絵文字を1つ入れたのに、キャレットの位置は 3 ではなく 6 でした。selectionStart などの位置は、文字列の length と同じ数え方をするからです。length は UTF-16 のコード単位の数で(MDN の String の length の説明)、絵文字のように1文字に見えても、2以上になることがあります。このことは、以前の記事「Reactで、入力した文字数を数えて上限を知らせる方法」でもくわしく扱いました。

text
👍  length 2  U+1F44D
👍🏽  length 4  U+1F44D U+1F3FD
🐈‍⬛  length 4  U+1F408 U+200D U+2B1B
❤️  length 2  U+2764 U+FE0F
🇯🇵  length 4  U+1F1EF U+1F1F5

黒ねこは、ねこ(U+1F408)と黒い四角(U+2B1B)を、ゼロ幅接合子(ZWJ、U+200D)でつないだもので、length は 4 です。肌の色を付けた絵文字や国旗も 4 になりました。state を書き換えて入れる書き方では、キャレットを戻す位置を start + text.length と計算します。start + 1 にすると、絵文字の途中にキャレットを置くことになります。insertAtCaret では、キャレットの位置をブラウザが決めるので、この計算はいりません。

入れた絵文字の直後で Backspace を1回押すと、Chromium ではどの絵文字も丸ごと消え、値は空になりました。

text
いいね: 入れた値 👍(length 2)→ Backspace 1回で (空)
いいね(中間の肌の色): 入れた値 👍🏽(length 4)→ Backspace 1回で (空)
黒ねこ: 入れた値 🐈‍⬛(length 4)→ Backspace 1回で (空)
ハート: 入れた値 ❤️(length 2)→ Backspace 1回で (空)
日本の旗: 入れた値 🇯🇵(length 4)→ Backspace 1回で (空)

ピッカーのコンポーネント

ピッカーを開くと、検索欄にフォーカスが移ります。検索欄に打った言葉が、名前かキーワードのどれかに含まれる絵文字だけを並べます。検索欄で Enter を押すと先頭の絵文字を選び、↓を押すと一覧に移ります。

一覧の中は矢印キーで動けるようにしました。WAI-ARIA Authoring Practices の、roving tabindex の説明にあるやり方で、Tab でフォーカスが入るのは今いるボタン(tabIndex が 0)だけにし、ほかのボタンは tabIndex を -1 にします。40個のボタンが全部 Tab の順番に入ると、一覧を抜けるのに Tab を何十回も押すことになるためです。

tsx
import { useRef, useState, type KeyboardEvent } from 'react';
import { EMOJIS, type Emoji } from './emojis';

const COLS = 8; // CSS の grid-template-columns と同じ列数にする
const NAMES = new Map(EMOJIS.map((e) => [e.char, e.name]));

export function filterEmojis(query: string): Emoji[] {
  const q = query.trim().toLowerCase();
  if (!q) return EMOJIS;
  return EMOJIS.filter((e) => [e.name, ...e.keywords].some((w) => w.toLowerCase().includes(q)));
}

type Props = {
  recent: string[];
  onPick: (char: string) => void;
  onClose: () => void;
};

export function EmojiPicker({ recent, onPick, onClose }: Props) {
  const [query, setQuery] = useState('');
  const [active, setActive] = useState(0); // 一覧の中で Tab で入れる位置
  const gridRef = useRef<HTMLDivElement>(null);
  const items = filterEmojis(query);

  const focusItem = (i: number) => {
    setActive(i);
    gridRef.current?.querySelectorAll<HTMLButtonElement>('button')[i]?.focus();
  };

  const onSearchKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'ArrowDown' && items.length > 0) {
      e.preventDefault();
      focusItem(0);
    } else if (e.key === 'Enter' && items.length > 0) {
      e.preventDefault();
      onPick(items[0].char);
    }
  };

  const onGridKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    const steps: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: COLS, ArrowUp: -COLS };
    const step = steps[e.key];
    if (step === undefined) return;
    e.preventDefault();
    const next = active + step;
    if (next >= 0 && next < items.length) focusItem(next);
  };

  return (
    <div
      className="picker"
      role="dialog"
      aria-label="絵文字を選ぶ"
      onKeyDown={(e) => {
        if (e.key === 'Escape') {
          e.preventDefault();
          onClose();
        }
      }}
    >
      <input
        type="search"
        aria-label="絵文字を検索"
        placeholder="検索(例:ねこ)"
        autoFocus
        value={query}
        onChange={(e) => {
          setQuery(e.target.value);
          setActive(0);
        }}
        onKeyDown={onSearchKeyDown}
      />
      {query === '' && recent.length > 0 && (
        <div className="recent" role="group" aria-label="最近使った絵文字">
          {recent.map((c) => (
            <button key={c} type="button" aria-label={NAMES.get(c) ?? c} onClick={() => onPick(c)}>
              {c}
            </button>
          ))}
        </div>
      )}
      {items.length === 0 ? (
        <p className="empty">見つかりませんでした</p>
      ) : (
        <div className="grid" role="group" aria-label="絵文字の一覧" ref={gridRef} onKeyDown={onGridKeyDown}>
          {items.map((e, i) => (
            <button
              key={e.char}
              type="button"
              tabIndex={i === active ? 0 : -1}
              aria-label={e.name}
              title={e.name}
              onFocus={() => setActive(i)}
              onClick={() => onPick(e.char)}
            >
              {e.char}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

↑と↓では、列の数(COLS)だけ前後に動きます。一覧は CSS で grid-template-columns: repeat(8, 1fr); にしているので、8つ先のボタンが1段下のボタンです。CSS の列数を変えたら、COLS も合わせて変えます。検索欄の文字が変わるたびに、active を 0 に戻しています。絞り込みで一覧が短くなったときに、active が一覧の数以上のままだと、Tab でフォーカスが入るボタンがなくなるためです。

最近使った絵文字を localStorage に残す

最近使った絵文字は、新しいものから8個まで localStorage に保存します。保存した値が壊れているとき(JSON として読めない、配列でないなど)は、空の一覧として扱います。

ts
const KEY = 'emoji-recent';
const MAX = 8;

// 保存した値が壊れていたり、localStorage が使えなかったりしたら空にする
export function loadRecent(): string[] {
  try {
    const v: unknown = JSON.parse(localStorage.getItem(KEY) ?? '[]');
    return Array.isArray(v) ? v.filter((c): c is string => typeof c === 'string').slice(0, MAX) : [];
  } catch {
    return [];
  }
}

// 使った絵文字を先頭に移し、MAX 個までにして保存する
export function pushRecent(list: string[], char: string): string[] {
  const next = [char, ...list.filter((c) => c !== char)].slice(0, MAX);
  try {
    localStorage.setItem(KEY, JSON.stringify(next));
  } catch {
    // 保存できなくても、開いている間の一覧は使える
  }
  return next;
}

try で囲んでいるのは、localStorage が例外を投げることがあるためです。MDN によると、localStorage の読み出しは、オリジンが file: や data: の場合などに SecurityError を投げます。setItem() は、保存できる容量を使い切ったときなどに QuotaExceededError を投げます。検証では、例外が起きる場合は試していません。

テキストエリアとピッカーをつなぐ

テキストエリア、ピッカーを開くボタン、ピッカーをまとめたコンポーネントです。絵文字が選ばれたら、ピッカーを閉じて insertAtCaret で入れ、最近使った絵文字の一覧を更新します。

tsx
import { useEffect, useRef, useState } from 'react';
import { EmojiPicker } from './EmojiPicker';
import { insertAtCaret } from './insertAtCaret';
import { loadRecent, pushRecent } from './recent';

export function EmojiTextarea() {
  const [value, setValue] = useState('');
  const [open, setOpen] = useState(false);
  const [recent, setRecent] = useState(loadRecent);
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const popupRef = useRef<HTMLDivElement>(null);

  // ピッカーの外を押したら閉じる
  useEffect(() => {
    if (!open) return;
    const onPointerDown = (e: PointerEvent) => {
      if (!popupRef.current?.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener('pointerdown', onPointerDown);
    return () => document.removeEventListener('pointerdown', onPointerDown);
  }, [open]);

  const pick = (char: string) => {
    setOpen(false);
    insertAtCaret(textareaRef.current!, char);
    setRecent((list) => pushRecent(list, char));
  };

  const close = () => {
    setOpen(false);
    textareaRef.current?.focus();
  };

  return (
    <div className="emoji-field">
      <textarea
        ref={textareaRef}
        aria-label="メッセージ"
        rows={3}
        value={value}
        onChange={(e) => setValue(e.target.value)}
      />
      <div className="popup" ref={popupRef}>
        <button
          type="button"
          className="toggle"
          aria-label="絵文字を入れる"
          aria-haspopup="dialog"
          aria-expanded={open}
          onClick={() => setOpen((o) => !o)}
        >
          😀
        </button>
        {open && <EmojiPicker recent={recent} onPick={pick} onClose={close} />}
      </div>
    </div>
  );
}

外側を押したかどうかは、ボタンとピッカーを囲んだ popupRef の中かどうかで決めます。ボタンもこの中に入れているので、開いているときにボタンを押すと、pointerdown の処理では閉じず、onClick で open が反転して閉じます。

動かして確かめた結果

ページを Chromium で開き、Playwright でキーボードとマウスを操作しました。各行は、操作のあとのテキストエリアの値、選択範囲(selectionStart–selectionEnd)、フォーカスのある場所、ピッカーが開いているかどうか、ボタンの aria-expanded です。まず、「ねこ です」のキャレットを「ねこ」の後ろに置いてからピッカーを開き、キーボードだけで黒ねこを入れました。一覧の項目の後ろの * は、tabIndex が 0 のボタンです。

text
キャレットを「ねこ」の後ろへ: 値 "ねこ です" 選択 2-2 フォーカス テキストエリア ピッカー 閉 aria-expanded=false
ボタンを押した: 値 "ねこ です" 選択 2-2 フォーカス 絵文字を検索 ピッカー 開 aria-expanded=true
一覧の数: 40
「ねこ」で検索: 🐱ねこの顔* 🐈ねこ 🐈‍⬛黒ねこ
検索欄に打った: 値 "ねこ です" 選択 2-2 フォーカス 絵文字を検索 ピッカー 開 aria-expanded=true
↓: 値 "ねこ です" 選択 2-2 フォーカス 絵文字の一覧のねこの顔 ピッカー 開 aria-expanded=true
→ を2回: 値 "ねこ です" 選択 2-2 フォーカス 絵文字の一覧の黒ねこ ピッカー 開 aria-expanded=true
Tab で入る位置(*): 🐱ねこの顔 🐈ねこ 🐈‍⬛黒ねこ*
Enter: 値 "ねこ🐈‍⬛ です" 選択 6-6 フォーカス テキストエリア ピッカー 閉 aria-expanded=false
続けて「がいる」と打った: 値 "ねこ🐈‍⬛がいる です" 選択 9-9 フォーカス テキストエリア ピッカー 閉 aria-expanded=false

フォーカスが検索欄や一覧のボタンにある間も、テキストエリアの選択範囲は 2-2 のまま残っていました。Enter で選ぶと、絵文字は「ねこ」の後ろに入り、フォーカスはテキストエリアに戻りました。続けて打った「がいる」も、絵文字の後ろに入っています。

文字を選んでからピッカーで絵文字を選ぶと、選んだ文字が絵文字に置き換わります。「です」を選んでから、マウスで「いいね」を押しました。

text
「です」を選んだ: 値 "ねこ🐈‍⬛がいる です" 選択 10-12 フォーカス テキストエリア ピッカー 閉 aria-expanded=false
「いいね」をクリック: 値 "ねこ🐈‍⬛がいる 👍" 選択 12-12 フォーカス テキストエリア ピッカー 閉 aria-expanded=false

次に、検索欄に何も打たずに↓で一覧に入り、矢印キーで動きました。いちばん上の段で↑を押しても、フォーカスは動きません。一覧から Shift+Tab を押すと、最近使った絵文字の行に移りました。

text
↓(検索欄から一覧へ): 値 "ねこ🐈‍⬛がいる 👍" 選択 12-12 フォーカス 絵文字の一覧のにっこり ピッカー 開 aria-expanded=true
↓: 値 "ねこ🐈‍⬛がいる 👍" 選択 12-12 フォーカス 絵文字の一覧のいいね ピッカー 開 aria-expanded=true
←: 値 "ねこ🐈‍⬛がいる 👍" 選択 12-12 フォーカス 絵文字の一覧のこらえる ピッカー 開 aria-expanded=true
↑ を2回(1段目より上はない): 値 "ねこ🐈‍⬛がいる 👍" 選択 12-12 フォーカス 絵文字の一覧のこらえる ピッカー 開 aria-expanded=true
Shift+Tab: 値 "ねこ🐈‍⬛がいる 👍" 選択 12-12 フォーカス 最近使った絵文字の黒ねこ ピッカー 開 aria-expanded=true
Shift+Tab: 値 "ねこ🐈‍⬛がいる 👍" 選択 12-12 フォーカス 最近使った絵文字のいいね ピッカー 開 aria-expanded=true

一覧の「にっこり」で↓を押すと、8つ先の「いいね」に移りました。そこから←を押すと、1つ前の「こらえる」(1段目の右端)に移ります。

閉じ方も確かめました。Esc ではピッカーが閉じてフォーカスがテキストエリアに戻り、選択範囲もそのままでした。ピッカーの外をクリックしたときと、開いているときにボタンをもう一度押したときも、ピッカーは閉じました。

text
Esc: 値 "ねこ🐈‍⬛がいる 👍" 選択 12-12 フォーカス テキストエリア ピッカー 閉 aria-expanded=false
ピッカーの外をクリック: 値 "ねこ🐈‍⬛がいる 👍" 選択 12-12 フォーカス BODY ピッカー 閉 aria-expanded=false
ボタンをもう一度押した: 値 "ねこ🐈‍⬛がいる 👍" 選択 12-12 フォーカス 絵文字を入れる ピッカー 閉 aria-expanded=false

検索に当てはまる絵文字がないときは「見つかりませんでした」と出し、Enter を押しても何も入りません。最近使った絵文字は、新しく使ったものから順に並び、ページを読み込み直しても残りました。

text
最近使った: 👍いいね 🐈‍⬛黒ねこ
localStorage: ["👍","🐈‍⬛"]
黒ねこを使った後: 🐈‍⬛黒ねこ 👍いいね
検索欄に打つと、最近使ったの行: なし
読み込み直した後: 🐈‍⬛黒ねこ 👍いいね
壊れた値を入れて読み込み直した後: 最近使ったの行 なし

localStorage に JSON として読めない値を入れてから読み込み直すと、最近使った絵文字の行は出ず、エラーも起きませんでした。

最後に、ページを読み込み直して空になったテキストエリアでピッカーを開き、検索欄に「ねこ」と打って Enter を押しました。一覧の先頭の「ねこの顔」が入り、フォーカスはテキストエリアに戻りました。

text
「ねこ」と打って Enter: 値 "🐱" 選択 2-2 フォーカス テキストエリア ピッカー 閉 aria-expanded=false

​Posted by 86