Reactで、入力欄で「@」を打つと名前の候補を出す方法

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

テキストエリアで「@」を打ったときに名前の候補を出すには、入力やキャレットの移動のたびに、キャレットより前の文字を調べます。キャレットの直前が「@」とそれに続く文字で終わっていれば、その文字で候補を絞り込んで一覧を出します。候補を選んだら「@」から打ちかけの文字までを「@tama 」のような名前に置き換え、キャレットを名前の直後に戻します。

一覧を「@」の真下に出すには、テキストエリアと同じ見た目の div を画面の外に作り、そこで「@」の位置を測ります。キャレットの前を調べる処理、名前を入れてキャレットを戻す処理、一覧の位置を決める処理を、MentionTextarea という1つのコンポーネントにまとめました。

補足

検証環境は 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 を操作して確かめました。日本語の変換中の入力は、Chrome DevTools Protocol の Input.imeSetComposition で変換中の文字を入れて再現しました。実際の IME、Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。

「こんにちは @」と打つと、5匹の名前の候補の一覧が「@」の下に出る。「t」を打つと「たま」と「とら」の2件に絞られ、↓で「とら」を選んで Enter を押すと「@tora 」が入る。続けて「さん、@」と打ち、一覧の「くろ」をマウスでクリックすると「@kuro 」が入る。送信すると「メンション:tora、kuro」と表示される
「@」で出た候補を、キーボードとマウスで選んで送信する様子

GIF の中の、左上の操作の説明、右上の操作の回数、右下のキーの表示、カーソルと押した位置の輪、入力欄の下の「候補 5件」などの表示は、撮影のために重ねたものです。キーボードで操作しているあいだは、カーソルを隠しています。操作で変わった表示は、その瞬間だけピンクにしています。

キャレットの前を調べて、候補を出すかを決める

候補を出すかどうかは、テキストエリアの値のうち、キャレットより前の部分だけで決めます。キャレットの位置は selectionStart で分かります。MDN の selectionStart の説明にあるとおり、文字を選んでいないときは、キャレットの位置を返します。

「@」を調べる関数、候補を絞り込む関数、送信するときにメンションを取り出す関数を、mention.ts にまとめました。最後の caretPosition() は、一覧の位置を決めるときに使います(後の節で説明します)。

ts
export type User = { id: string; name: string };
export type Mention = { start: number; query: string };

// キャレットの直前が「@」と、それに続く空白以外の文字になっているかを調べる
// 「@」の前が英数字のときは開かない(cat@example.com のようなメールアドレスを除く)
// 日本語の文の中では「さん、@」のように、空白を入れずに打たれることがある
export function findMention(text: string, caret: number): Mention | null {
  const m = /(?:^|[^A-Za-z0-9])@([^\s@]*)$/.exec(text.slice(0, caret));
  if (!m) return null;
  return { start: caret - m[1].length - 1, query: m[1] };
}

// id(ローマ字)か名前(ひらがな)の先頭が一致するものを、最大5件まで
export function filterUsers(users: User[], query: string): User[] {
  const q = query.toLowerCase();
  return users.filter((u) => u.id.startsWith(q) || u.name.startsWith(q)).slice(0, 5);
}

// 送信するときに、本文に書かれたメンションの id を取り出す
export function extractMentions(text: string, users: User[]): string[] {
  const ids = new Set(users.map((u) => u.id));
  const found = [...text.matchAll(/(?:^|[^A-Za-z0-9])@([a-z0-9_]+)/g)].map((m) => m[1]);
  return [...new Set(found)].filter((id) => ids.has(id));
}

// textarea と同じ見た目の div を作り、指定した文字の位置(textarea の左上からの距離)を測る
const COPY = [
  'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'letterSpacing', 'wordSpacing',
  'lineHeight', 'textTransform', 'textIndent', 'tabSize',
  'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
] as const;

export function caretPosition(el: HTMLTextAreaElement, index: number) {
  const style = getComputedStyle(el);
  const mirror = document.createElement('div');
  for (const p of COPY) mirror.style[p] = style[p];
  Object.assign(mirror.style, {
    position: 'absolute',
    visibility: 'hidden',
    top: '0',
    left: '0',
    boxSizing: 'border-box',
    width: `${el.clientWidth}px`, // padding を含み、枠線とスクロールバーを含まない幅
    whiteSpace: 'pre-wrap',
    overflowWrap: 'break-word',
  });
  mirror.textContent = el.value.slice(0, index);
  const marker = document.createElement('span');
  marker.textContent = el.value.slice(index) || '.';
  mirror.append(marker);
  document.body.append(mirror);
  const top = el.clientTop + marker.offsetTop - el.scrollTop;
  const left = el.clientLeft + marker.offsetLeft - el.scrollLeft;
  const lineHeight = parseFloat(style.lineHeight);
  mirror.remove();
  return { top, left, lineHeight };
}

findMention() の正規表現は、キャレットより前の文字列が、「@」と、それに続く空白・「@」以外の文字(0文字以上)で終わっているかを調べます。合っていれば、「@」の位置(start)と、その後ろの打ちかけの文字(query)を返します。検証では、「こんにちは @」と打った時点で5件すべての候補が出ました。続けて「t」を打つと「たま」「とら」の2件、「to」で「とら」の1件に絞られ、「tox」で一致するものがなくなると一覧は閉じました。

読点の直後の「@」でも候補を出す

「@」の前の文字の条件は、日本語の文で使うときに効いてきます。「@」の前を空白か文の先頭に限ると、「さん、@」のように読点の直後に打った「@」では、一覧が開きません。日本語では単語の間に空白を入れないので、ここで開かないのは不便です。

そこで、「@」の前が英数字のときだけ開かないようにしました。2つの条件で、開くかどうかを比べた結果です。

キャレットの前の文字 前が空白か文の先頭なら開く 前が英数字以外なら開く
@ 開く 開く
こんにちは @ 開く 開く
さん、@ 開かない 開く
ねこ@ 開かない 開く
(@ 開かない 開く
cat@ 開かない 開かない
cat@example.com 開かない 開かない
@tama (名前の後ろに空白) 開かない 開かない

英数字の後ろの「@」で開かないので、cat@example.com のようなメールアドレスを打っている途中では、一覧が出ません。「@」の後ろに空白を打つと、そこで候補を探すのをやめます。

候補の絞り込みは、id(ローマ字)か名前(ひらがな)の先頭が一致するものにしています。日本語の変換中も、テキストエリアの値には変換中の文字が入ります。Chrome DevTools Protocol で「@」の後ろに変換中の「み」を入れたところ、値は「@み」になり、候補は「みけ」の1件に絞られました。

入力とキャレットの移動のたびに調べ直す

候補を出すかどうかは、文字を打ったときだけでなく、キャレットを動かしたときにも変わります。そこで、テキストエリアの onChange と onSelect の両方で、同じ update() を呼んでいます。React の共通コンポーネントのリファレンスにある onSelect の説明によると、React の onSelect は、文字を選んでいないとき(キャレットの移動)や、編集したときにも呼ばれます。

検証では、「@sh だよ」と打ってキャレットが末尾にあるときは、一覧は閉じていました。← を3回押してキャレットを「@sh」の直後に戻すと「しろ」の候補が出て、End を押すとまた閉じました。

update() の中では、Esc で閉じた「@」の位置を dismissedAt に覚えておき、同じ「@」では開き直さないようにしています。Esc で閉じたのに次の1文字でまた開くと、候補を使わずに「@」を書きたいときに邪魔になるためです。検証では「@m」で Esc を押すと閉じ、続けて「i」を打っても閉じたままでした。「@」を消して打ち直すと、また開きました。

選んだ名前を入れて、キャレットを名前の直後に戻す

候補を選んだら、「@」から打ちかけの文字の終わりまでを @tora のような文字列に置き換えます。最後に空白を付けるので、続けて文を打てます。置き換えた部分の後ろが空白で始まるときは、空白が2つ並ばないように1つ省きます。

置き換えたあとは、キャレットの位置に気をつけます。テキストエリアの value に新しい文字列を代入すると、キャレットは末尾に動きます。検証した Chromium で、キャレットを位置 3(先頭から3文字の後ろ)に置いたまま value に10文字の文字列を代入したところ、selectionStart は 10 になりました。戻す処理がないと、文の途中で名前を選んだときに、キャレットが文の終わりに飛んでしまいます。

そこで、置き換えたあとに置きたいキャレットの位置を nextCaret に入れておき、値が DOM に反映されたあと(画面に描かれる前に)、setSelectionRange() でその位置に戻します。戻す処理は useLayoutEffect に書きます。React の useLayoutEffect のリファレンスにあるとおり、ブラウザが画面を描き直す前に動くので、キャレットが末尾にある状態は画面に出ません。

検証では、「@ku と遊んだ」のキャレットを「@ku」の直後に戻して Tab で選ぶと、値は「@kuro と遊んだ」になり、キャレットは「@kuro 」の直後(位置 6)にありました。「こんにちは @t」で↓と Enter を押した場合も、「こんにちは @tora 」の直後にキャレットがあり、続けて打った「さん」はその後ろに入りました。

キャレットを戻したときに onSelect が呼ばれても、キャレットの直前は空白なので findMention() は null を返します。検証でも、名前を入れたあとの一覧は閉じたままでした。

候補の一覧を「@」の位置に出す

テキストエリアの中の文字の位置は、テキストエリアの要素からは直接分かりません。そこで caretPosition() では、テキストエリアと同じフォント・行の高さ・padding・幅の div を作って、見えない状態で画面に置きます。div には「@」より前の文字を入れ、その後ろに span を置いて、span の位置を測ります。span が始まる位置が、「@」の位置です。

幅は clientWidth に合わせます。MDN の説明にあるとおり、clientWidth は padding を含み、枠線とスクロールバーを含まない幅なので、div を border-box にしてこの幅を指定すれば、折り返す位置がテキストエリアと同じになります。white-space: pre-wrap と overflow-wrap: break-word も、テキストエリアの文字の並べ方に合わせるための指定です。

測った値には、テキストエリアの上の枠線の太さ(clientTop)を足し、テキストエリアの中のスクロール量(scrollTop)を引きます。一覧はその1行下に出します。一覧の position: absolute は、position: relative にした外側の div を基準にします。MDN の offsetTop の説明にあるとおり、span の offsetTop も、いちばん近い位置指定された祖先(ここでは測るための div)からの距離です。

幅 368px、padding 8px、枠線 1px、文字 16px、行の高さ 24px、4行のテキストエリアで、一覧の位置を測りました。一覧の幅は 200px です。

キャレットの前の文字 一覧の上端 一覧の左端
@ 33px 9px
「あいうえお」と、空白と「@」 33px 93px
「あいうえお…そ」の15文字と、空白と「@」 33px 168px(右端で抑えた値)
25文字と、空白と「@」(2行目に折り返す) 57px 77px
改行して3行目の @ 81px 9px
改行して7行目の @(中が 64px スクロール) 113px 9px

「あいうえお」の後ろの「@」の 93px は、枠線と padding の 9px に、5文字分の 80px と空白の幅を足した位置です。折り返した場合は、2行目の「にぬねの @」の「@」の位置に出ました。7行目の場合は、スクロールした 64px が引かれ、見えている4行目の下に出ています。

右端に近い「@」では、一覧がテキストエリアからはみ出さないように、左端を「テキストエリアの幅 − 一覧の幅」(ここでは 168px)までに抑えています。表の3行目は、本来の位置ではなく、この抑えた値です。

キーボードの操作と、変換中の Enter

一覧が開いているあいだは、↓・↑で候補を選び、Enter か Tab で決め、Esc で閉じます。フォーカスはテキストエリアに置いたままにして、どの候補を選んでいるかは aria-activedescendant で伝えます。キーボードで候補を選ぶ仕組みは、以前の記事「Reactで、入力候補を出すコンボボックスをキーボードで操作する方法」と同じ考え方です。

ただし、テキストエリアには role="combobox" を付けられません。W3C の ARIA in HTML では、textarea に付けてよいロールは textbox だけで、それも勧めないとされています。一方、WAI-ARIA の textbox ロールは、aria-activedescendant と aria-autocomplete に対応しています。そこでロールは付けずに、aria-autocomplete="list"、aria-controls、aria-activedescendant の3つを付けました。MDN の aria-activedescendant の説明にも、フォーカスのある要素が textbox のときは、aria-controls で一覧を指すよう書かれています。スクリーンリーダーでどう読み上げられるかは、試していません。

Enter は、日本語の変換を確定するときにも押します。変換中の Enter で候補を決めてしまわないように、handleKeyDown() の最初で isComposing が true のときは何もしません。検証では、変換中の「み」がある状態で isComposing: true の keydown(スクリプトで作ったイベント)を送ると、preventDefault() は呼ばれず、候補は選ばれませんでした。変換を確定したあとに Enter を押すと、「@mike 」が入りました。なお、以前の記事でも触れたとおり、MDN の keydown イベントのページにある IME の説明では、変換の最初や最後のキーで isComposing が false のまま届く場合があるとされています。この記事のコードは isComposing だけを見ていて、実際の IME での動きは試していません。

マウスで選ぶときは、候補の onMouseDown で preventDefault() を呼び、押した時点でテキストエリアのフォーカスが外れないようにしています。テキストエリアの onBlur で一覧を閉じているためです。以前の記事「Reactで、入力候補を出すコンボボックスをキーボードで操作する方法」の検証では、この1行を消すと、クリックしても候補は選ばれず、フォーカスが body 要素に移りました。この記事の検証では、「くろ」をクリックすると「@kuro 」が入り、フォーカスはテキストエリアに残っていました。

MentionTextarea のコード

ここまでの処理をまとめたコンポーネントです。

tsx
import { useId, useLayoutEffect, useRef, useState, type ComponentProps, type KeyboardEvent } from 'react';
import { caretPosition, filterUsers, findMention, type Mention, type User } from './mention';

type Props = Omit<ComponentProps<'textarea'>, 'value' | 'onChange'> & {
  users: User[];
  value: string;
  onChange: (value: string) => void;
};

const LIST_WIDTH = 200;

export function MentionTextarea({ users, value, onChange, ...rest }: Props) {
  const ref = useRef<HTMLTextAreaElement>(null);
  const listId = useId();
  const [mention, setMention] = useState<Mention | null>(null);
  const [active, setActive] = useState(0);
  const [pos, setPos] = useState({ top: 0, left: 0 });
  const lastKey = useRef(''); // 前回の「@の位置:検索文字列」
  const dismissedAt = useRef<number | null>(null); // Esc で閉じた「@」の位置
  const nextCaret = useRef<number | null>(null); // 候補を入れたあとに置くキャレットの位置

  const candidates = mention ? filterUsers(users, mention.query) : [];
  const open = candidates.length > 0;

  // 入力やキャレットの移動のたびに、キャレットの前を調べ直す
  function update(el: HTMLTextAreaElement) {
    const caret = el.selectionStart;
    const found = caret === el.selectionEnd ? findMention(el.value, caret) : null;
    if (found?.start !== dismissedAt.current) dismissedAt.current = null;
    const m = found && found.start !== dismissedAt.current ? found : null;
    const key = m ? `${m.start}:${m.query}` : '';
    if (key !== lastKey.current) {
      lastKey.current = key;
      setActive(0); // 検索文字列が変わったら、先頭の候補を選び直す
    }
    setMention(m);
    if (m) {
      const p = caretPosition(el, m.start);
      setPos({ top: p.top + p.lineHeight, left: Math.min(p.left, el.offsetWidth - LIST_WIDTH) });
    }
  }

  function select(user: User) {
    if (!mention) return;
    const end = mention.start + 1 + mention.query.length; // 「@」と検索文字列の終わり
    const insert = `@${user.id} `;
    const rest = value.slice(end);
    nextCaret.current = mention.start + insert.length;
    onChange(value.slice(0, mention.start) + insert + (rest.startsWith(' ') ? rest.slice(1) : rest));
    setMention(null);
  }

  // 値を書き換えるとキャレットが末尾に動くので、入れた名前の直後に戻す
  useLayoutEffect(() => {
    if (nextCaret.current === null) return;
    ref.current!.setSelectionRange(nextCaret.current, nextCaret.current);
    nextCaret.current = null;
  }, [value]);

  function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
    if (!open || e.nativeEvent.isComposing) return; // 変換中のキーは IME に任せる
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActive((i) => (i + 1) % candidates.length);
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActive((i) => (i - 1 + candidates.length) % candidates.length);
        break;
      case 'Enter':
      case 'Tab':
        e.preventDefault();
        select(candidates[active]);
        break;
      case 'Escape':
        e.preventDefault();
        dismissedAt.current = mention!.start;
        setMention(null);
        break;
    }
  }

  return (
    <div style={{ position: 'relative' }}>
      <textarea
        {...rest}
        ref={ref}
        value={value}
        onChange={(e) => {
          onChange(e.target.value);
          update(e.target);
        }}
        onSelect={(e) => update(e.currentTarget)}
        onKeyDown={handleKeyDown}
        onBlur={() => setMention(null)}
        aria-autocomplete="list"
        aria-controls={open ? listId : undefined}
        aria-activedescendant={open ? `${listId}-${active}` : undefined}
      />
      {open && (
        <ul
          id={listId}
          role="listbox"
          aria-label="メンションの候補"
          style={{ position: 'absolute', top: pos.top, left: pos.left, width: LIST_WIDTH }}
          className="mention-list"
        >
          {candidates.map((u, i) => (
            <li
              key={u.id}
              id={`${listId}-${i}`}
              role="option"
              aria-selected={i === active}
              onMouseDown={(e) => e.preventDefault()} // 押した時点で textarea のフォーカスを外さない
              onClick={() => select(u)}
              onMouseEnter={() => setActive(i)}
            >
              {u.name} <span className="mention-id">@{u.id}</span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

使う側は、候補にする名前の一覧と、値と onChange を渡します。送信するときに extractMentions() で本文から id を取り出せば、通知を送る相手を決めるときなどに使えます。

tsx
import { useState, type FormEvent } from 'react';
import { MentionTextarea } from './MentionTextarea';
import { extractMentions, type User } from './mention';

const USERS: User[] = [
  { id: 'tama', name: 'たま' },
  { id: 'tora', name: 'とら' },
  { id: 'mike', name: 'みけ' },
  { id: 'kuro', name: 'くろ' },
  { id: 'shiro', name: 'しろ' },
];

export function App() {
  const [text, setText] = useState('');
  const [sent, setSent] = useState<string[] | null>(null);

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setSent(extractMentions(text, USERS));
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="comment">コメント</label>
      <MentionTextarea id="comment" users={USERS} value={text} onChange={setText} rows={4} placeholder="@ で名前の候補が出ます" />
      <button type="submit">送信</button>
      <p role="status">{sent && `メンション:${sent.length ? sent.join('、') : 'なし'}`}</p>
    </form>
  );
}

extractMentions() は、候補の一覧にある id だけを、重複を除いて返します。「@tama と @kuro と @nobody へ。cat@example.com @tama、@mike」を送信すると、結果は「tama、kuro、mike」でした。一覧にない @nobody と、メールアドレスの中の「@」は入らず、2回書いた @tama は1回になっています。

​Posted by 86