@メンション入力
: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: 注意事項
- 依存パッケージを忘れず追加