SCALE — Build Lab
UI部品 · REACT COMPONENT

@メンション入力

CATEGORYUI部品 TYPEReact Component EFFORT120〜240分 DIFFICULTY
PRIMARY CODE
tsx
'use client';
import { useState, useRef } from 'react';

export function MentionInput({ users, onChange }: {
  users: { id: string; name: string }[];
  onChange: (text: string) => void;
}) {
  const [text, setText] = useState('');
  const [pop, setPop] = useState<{ at: number; q: string } | null>(null);
  const ref = useRef<HTMLTextAreaElement>(null);

  const onInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    const v = e.target.value;
    setText(v); onChange(v);
    const cursor = e.target.selectionStart;
    const before = v.slice(0, cursor);
    const m = before.match(/@(\w*)$/);
    setPop(m ? { at: cursor - m[1].length - 1, q: m[1] } : null);
  };

  const insert = (u: { id: string; name: string }) => {
    if (!pop) return;
    const next = text.slice(0, pop.at) + '@' + u.name + ' ' + text.slice(pop.at + pop.q.length + 1);
    setText(next); onChange(next); setPop(null);
    setTimeout(() => ref.current?.focus(), 0);
  };

  const candidates = pop ? users.filter(u => u.name.toLowerCase().includes(pop.q.toLowerCase())).slice(0, 5) : [];

  return (
    <div style={{ position: 'relative' }}>
      <textarea ref={ref} value={text} onChange={onInput}
        style={{ width: '100%', minHeight: 80, padding: '.65rem', background: 'transparent', border: '1px solid rgba(255,255,255,.07)', color: '#fff', fontFamily: 'inherit' }} />
      {candidates.length > 0 && (
        <div style={{ position: 'absolute', top: '100%', left: 0, background: '#0a0a0c', border: '1px solid rgba(255,255,255,.07)', minWidth: 200, zIndex: 10 }}>
          {candidates.map(u => (
            <div key={u.id} onClick={() => insert(u)}
              style={{ padding: '.5rem 1rem', cursor: 'pointer', fontSize: '.8rem' }}>@{u.name}</div>
          ))}
        </div>
      )}
    </div>
  );
}
前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • 任意のダッシュボードに組み込み

@メンション入力

:LiTarget: 用途

@ をタイプするとメンバー候補ポップアップ。Slack/Notion 風。

:LiSparkle: 特徴

  • @ で起動
  • 候補絞込
  • 上下キー選択
  • Enter で挿入

:LiCode: コード(コピペ用)

'use client';
import { useState, useRef } from 'react';

export function MentionInput({ users, onChange }: {
  users: { id: string; name: string }[];
  onChange: (text: string) => void;
}) {
  const [text, setText] = useState('');
  const [pop, setPop] = useState<{ at: number; q: string } | null>(null);
  const ref = useRef<HTMLTextAreaElement>(null);

  const onInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    const v = e.target.value;
    setText(v); onChange(v);
    const cursor = e.target.selectionStart;
    const before = v.slice(0, cursor);
    const m = before.match(/@(\w*)$/);
    setPop(m ? { at: cursor - m[1].length - 1, q: m[1] } : null);
  };

  const insert = (u: { id: string; name: string }) => {
    if (!pop) return;
    const next = text.slice(0, pop.at) + '@' + u.name + ' ' + text.slice(pop.at + pop.q.length + 1);
    setText(next); onChange(next); setPop(null);
    setTimeout(() => ref.current?.focus(), 0);
  };

  const candidates = pop ? users.filter(u => u.name.toLowerCase().includes(pop.q.toLowerCase())).slice(0, 5) : [];

  return (
    <div style={{ position: 'relative' }}>
      <textarea ref={ref} value={text} onChange={onInput}
        style={{ width: '100%', minHeight: 80, padding: '.65rem', background: 'transparent', border: '1px solid rgba(255,255,255,.07)', color: '#fff', fontFamily: 'inherit' }} />
      {candidates.length > 0 && (
        <div style={{ position: 'absolute', top: '100%', left: 0, background: '#0a0a0c', border: '1px solid rgba(255,255,255,.07)', minWidth: 200, zIndex: 10 }}>
          {candidates.map(u => (
            <div key={u.id} onClick={() => insert(u)}
              style={{ padding: '.5rem 1rem', cursor: 'pointer', fontSize: '.8rem' }}>@{u.name}</div>
          ))}
        </div>
      )}
    </div>
  );
}

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加