0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Reactのtextareaでメンション部分だけ色を変える(透明textarea+オーバーレイ技法)と、その過程でハマった2つの罠

0
Posted at

背景

コメント入力欄に「@ユーザー」や「#場所名」のようなメンションを埋め込み、
その部分だけ色を変えてリンクっぽく見せたい、という要件はよくある。

しかし<textarea>はプレーンテキストしか扱えず、文字列の一部だけ色を変えることができない。
今回、実際にこれを実装する過程で「オーバーレイ技法」というテクニックにたどり着き、あわせて
2つの罠を踏んだので、まとめておく。

環境

  • React 18 + TypeScript
  • Tailwind CSS v4

1. オーバーレイ技法でメンション部分だけ色を変える

<textarea>自体はモノクロのテキストしか描画できない。そこで、以下の2層構成にする。

  • 層1(表示専用): 通常の<div>。メンション部分だけ<span>で色付けして描画する
  • 層2(入力受け付け): 実物の<textarea>。文字色を透明にし、キャレット(カーソル)の色だけ指定する
<div className="relative">
  {/* 層1: 表示専用。メンション部分だけ色付け */}
  <div
    aria-hidden="true"
    className="pointer-events-none absolute inset-0 whitespace-pre-wrap break-words px-3 py-1.5 text-[16px] leading-6"
  >
    {renderHighlighted(text, mentions)}
  </div>

  {/* 層2: 実際の入力欄。文字は透明、キャレットだけ見せる */}
  <textarea
    value={text}
    onChange={(e) => setText(e.target.value)}
    className="relative z-10 block w-full whitespace-pre-wrap break-words border-none bg-transparent px-3 py-1.5 text-[16px] leading-6 text-transparent outline-none"
    style={{ caretColor: 'black' }}
  />
</div>

ポイントは以下の3つ。

  • 層1はpointer-events-noneにして、クリックやキー入力を層2(実際のtextarea)に透過させる
  • 層2はrelative z-10で層1より手前に置く。こうしないと、ネイティブのキャレット(点滅カーソル)が
    層1に隠れて見えなくなる
  • 両層のpadding・font-size・line-height・white-spaceを完全に一致させる。 1pxでもズレると、
    層1の色付き文字と、層2の(見えない)実際の文字位置がズレてしまい、クリックした場所と違う位置に
    カーソルが飛んでいるように見える

フォントサイズは要注意で、text-baseのようなrem相対のユーティリティクラスだと、親要素のフォント
サイズ設定(例えばモバイル対応でルートのフォントサイズが変わる設計の場合)によって両層で微妙に
ズレることがある。両層ともtext-[16px]のように絶対値で明示するのが安全。

横スクロールが発生する場合は、層2のonScrollで層1のscrollLeft/scrollTopを同期する必要もある。

onScroll={(e) => {
  overlayRef.current!.scrollLeft = e.currentTarget.scrollLeft
  overlayRef.current!.scrollTop = e.currentTarget.scrollTop
}}

2. event.isComposingだけで判定するとIME変換確定を誤検知する

日本語入力(IME)では、変換を確定するために押すEnterキーと、本当に改行・送信したいときのEnterキーを
区別する必要がある。よくあるのが「Shift+Enterで送信、変換確定中のEnterでは送信しない」という実装で、
KeyboardEvent.isComposingを使って判定するのが定番とされている。

// 一見正しそうだが、環境によって取りこぼす
if (event.key === 'Enter' && event.shiftKey && !event.isComposing) {
  submit()
}

これをVitest + Testing Libraryでテストしたところ、fireEvent.keyDown(el, { isComposing: true })で
明示的にisComposingを渡しても、Reactの合成イベント側に正しく伝播せず、変換確定中のはずのキー操作が
送信扱いになってしまう不具合が発生した。

対策: isComposingプロパティを直接読むのではなく、compositionstart/compositionendイベントで
自前のフラグを持ち、そちらを判定に使う。

const isComposingRef = useRef(false)

<textarea
  onCompositionStart={() => { isComposingRef.current = true }}
  onCompositionEnd={() => { isComposingRef.current = false }}
  onKeyDown={(event) => {
    if (event.key === 'Enter' && event.shiftKey && !isComposingRef.current) {
      submit()
    }
  }}
/>

isComposingはブラウザ間でも挙動差があることが知られているため、テスト環境に限らず本番コードとしても
自前追跡の方が確実。

3. rounded-fullな入力欄は、複数行になった瞬間に丸くなる

1行のピル型(カプセル型)入力欄によくrounded-full(border-radius: 9999px)を使う。横長・低い高さの
ボックスに対しては綺麗な半円の両端を持つピルに見える。

<div className="rounded-full border ...">
  <textarea ... />
</div>

しかし、このボックスの中身が複数行に伸びて高さが増えると、border-radius: 9999pxは「実際にとりうる
最大の丸み」を意味するため、正方形に近づくにつれて見た目がほぼ円になってしまう。長文を入力しただけで
入力欄全体が丸いシルエットに包まれ、レイアウトが崩壊しているように見える不具合として現れた。

対策: 高さが可変(複数行に伸びる可能性がある)な要素にはrounded-fullを使わず、rounded-2xlのような
固定の角丸を使う。あわせて、伸びた分だけ下に隣接するボタン等がある場合は、items-centerではなく
items-endにして常に下揃えにすると、送信ボタンなどが浮いて見えなくなる。

<div className="flex items-end gap-2">
  <div className="relative flex-1 rounded-2xl border ...">
    {/* textarea + overlay */}
  </div>
  <button>送信</button>
</div>

まとめ

  • textareaで部分的な色分けが必要なら、「透明textarea + 表示専用div」の二層構成(オーバーレイ技法)が使える
  • IME変換確定の判定はevent.isComposingだけに頼らず、compositionstart/compositionendで自前追跡する
  • 高さが可変な要素にrounded-fullは使わない。伸びると円になる

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?