背景
チャット・コメント欄で「@ユーザー名」や「#場所名」のような chip(メンション) を、本文中の好きな位置に複数挿入したいことがあります。
contenteditable を使えばブラウザのネイティブなカーソル制御をそのまま使えますが、値の取得・IME対応・アクセシビリティ対応が複雑になりがちです。
今回は contenteditable を使わず、通常の <input> を複数並べる方式で実装したので、その設計と、キーボード操作でchipをまたいでカーソルを移動させる部分の実装を紹介します。
実際の画面はこんな感じです(開発中のダミーデータです)。
環境
- React 18 + TypeScript
- 状態管理はReactの
useState/useRefのみ(追加ライブラリなし)
設計: テキストとchipが交互に並ぶ「セグメント配列」
入力内容を1つの文字列ではなく、以下のような配列(セグメント列)として持ちます。
type ComposeSegment =
| { id: number; type: 'text'; text: string }
| { id: number; type: 'place'; place: PickedPlace }
常に「先頭・末尾は必ず text セグメント」というルールを保つと、chipの前後どちらにも文字を打てる状態を維持できます。
chipを挿入するときは、対象の text セグメントをカーソル位置(offset)で分割し、間に place セグメントを挟みます。
function handlePlaceSelect(place: PickedPlace) {
const idx = segments.findIndex((s) => s.id === targetId)
const seg = segments[idx]
if (!seg || seg.type !== 'text') return
const beforeSeg = { id: nextSegId(), type: 'text', text: seg.text.slice(0, offset) }
const placeSeg = { id: nextSegId(), type: 'place', place }
const afterSeg = { id: nextSegId(), type: 'text', text: seg.text.slice(offset) }
const next = [...segments]
next.splice(idx, 1, beforeSeg, placeSeg, afterSeg)
setSegments(next)
}
描画側は、textセグメントを <input>、placeセグメントを chip の <span> として並べるだけです。
つまずいた点1: chipの前に空欄が常に見えてしまう
textセグメントが空文字でも常に <input> を描画すると、chipの前後に「使われていない小さな空欄」が視覚的に目立ってしまいます。
かといって空文字のセグメントを常に非表示にすると、今度は「chipの左側にカーソルを置く場所自体が無くなる」という逆の問題が起きます。
解決策は、「フォーカス中のセグメントだけ例外的に表示する」 という条件にすることでした。
// 空文字かつ末尾でもフォーカス中でもない場合は、空の入力欄を作らないため描画しない
if (seg.text === '' && index !== lastSegmentIndex && seg.id !== focusedSegmentId) return null
これにより、普段は空欄が見えず、キー操作でそこにカーソルが移動した瞬間だけ入力欄が現れるようになります。
つまずいた点2: state更新だけではDOMのカーソルは動かない
chipの手前で ← キーを押したとき、「1つ前のテキスト欄の末尾」にカーソルを移動させたい場合があります。
ここで、Reactの state(どのセグメントが論理的にフォーカス中か)を更新しただけでは、実際のDOM要素のフォーカスもカーソル位置も変わりません。別々の <input> 要素にまたがる操作なので、明示的に .focus() と setSelectionRange() を呼ぶ必要があります。
さらに厄介なのは、移動先の <input> が「つまずいた点1」の条件でまだ非表示の場合、.focus() を呼びたくてもDOM要素自体がまだ存在しないことです。
そこで、「次にどのセグメントの、どちら側にカーソルを置きたいか」を ref に一時保存しておき、再描画が終わった後の useEffect で実際にフォーカスを当てる、という二段構えにしました。
const pendingFocusRef = useRef<{ segmentId: number; caret: 'start' | 'end' } | null>(null)
useEffect(() => {
const pending = pendingFocusRef.current
if (!pending || pending.segmentId !== focusedSegmentId) return
const el = inputRefs.current.get(focusedSegmentId)
if (el) {
el.focus()
const pos = pending.caret === 'end' ? el.value.length : 0
el.setSelectionRange(pos, pos)
}
pendingFocusRef.current = null
}, [focusedSegmentId])
function handleSegmentKeyDown(event: React.KeyboardEvent<HTMLInputElement>, segmentIndex: number) {
const el = event.currentTarget
const atStart = el.selectionStart === 0 && el.selectionEnd === 0
if (event.key === 'ArrowLeft' && atStart) {
for (let i = segmentIndex - 1; i >= 0; i -= 1) {
const prev = segments[i]
if (prev.type === 'text') {
event.preventDefault()
pendingFocusRef.current = { segmentId: prev.id, caret: 'end' }
setFocusedSegmentId(prev.id) // ①state更新→再描画→②useEffectでDOMフォーカス
break
}
}
}
}
→ キーでの逆方向の移動も、対称的なロジックで同様に実装できます。
つまずいた点3: 誤操作でchipが消えてしまう
chipの隣で Backspace を押したときにそのままchipが消える実装だと、文章を打っている途中の誤操作で場所情報が消えてしまいやすい問題がありました。
そこで「1回目のBackspaceではchipを赤くハイライトするだけ(削除の予告)」「同じchipに対して2回目のBackspaceで初めて削除を確定する」という2段階の確認を入れました。
const [armedRemovalId, setArmedRemovalId] = useState<number | null>(null)
if (event.key === 'Backspace' && atStart) {
const prev = segments[segmentIndex - 1]
if (prev && prev.type === 'place') {
event.preventDefault()
if (armedRemovalId === prev.id) {
handleRemovePlace(prev.id)
setArmedRemovalId(null)
} else {
setArmedRemovalId(prev.id) // 1回目は予告のみ
}
}
}
文字入力や他の場所へのフォーカス移動があった場合は、この「予告」状態を解除するようにしています。
まとめ
- 複数chipを任意位置に挿入したい入力は、
contenteditableを使わず「text/chipが交互に並ぶセグメント配列」で表現すると、通常の<input>の組み合わせだけで実装できる - 空セグメントの表示は「フォーカス中だけ表示」にすると、余分な空白を出さずにカーソル移動先を確保できる
- 複数の
<input>をまたぐキーボードナビゲーションは、state更新(論理的なフォーカス先)とDOMの実フォーカス(.focus()+setSelectionRange())を分けて考え、refで「保留中のフォーカス要求」を橋渡しする - 破壊的な操作(chip削除)は2段階確認にすると、既存のデザイントークンを流用するだけで誤操作対策になる

