React/Next.js中输入#或@触发事件并显示选择对话框的实现方法
实现React/Next.js中textarea的@/#触发选择弹窗
这是社交类产品里很常见的输入交互,给你一套可直接落地的实现方案:
核心逻辑拆解
- 监听输入框的内容变化与光标位置,检测是否输入了
@或# - 根据触发字符(@/#)加载对应的用户/话题候选列表
- 将弹窗定位到输入框的光标附近,选中选项后自动插入到输入框指定位置
- 处理输入过程中的弹窗显隐逻辑(比如输入其他字符时关闭弹窗、输入关键词时实时过滤列表)
具体实现步骤&代码示例
1. 基础组件结构
用Next.js函数组件搭配useState管理状态、useRef获取DOM元素:
import { useState, useRef } from 'react'; export default function MentionTextarea() { const [inputValue, setInputValue] = useState(''); const [showPopup, setShowPopup] = useState(false); const [triggerType, setTriggerType] = useState(null); // 'user'对应@,'topic'对应# const [currentQuery, setCurrentQuery] = useState(''); const textareaRef = useRef(null); const popupRef = useRef(null); // 监听输入框内容变化 const handleInputChange = (e) => { const value = e.target.value; setInputValue(value); const cursorPos = e.target.selectionStart; // 从光标位置往前匹配最近的@或# const preText = value.substring(0, cursorPos); const match = preText.match(/(@|#)([^@#\s]*)$/); if (match) { const [, trigger, query] = match; setTriggerType(trigger === '@' ? 'user' : 'topic'); setCurrentQuery(query); setShowPopup(true); // 计算并设置弹窗位置 positionPopup(cursorPos); } else { setShowPopup(false); } }; // 把弹窗定位到光标正下方 const positionPopup = (cursorPos) => { if (!textareaRef.current || !popupRef.current) return; const textarea = textareaRef.current; const popup = popupRef.current; // 获取光标在页面中的坐标 const range = document.createRange(); const selection = window.getSelection(); range.setStart(textarea.firstChild, cursorPos); range.setEnd(textarea.firstChild, cursorPos); const rect = range.getBoundingClientRect(); popup.style.position = 'absolute'; popup.style.left = `${rect.left}px`; popup.style.top = `${rect.bottom + window.scrollY + 5}px`; }; // 将选中的内容插入输入框 const handleSelectOption = (option) => { const { value } = option; // 替换当前输入的关键词部分 const preText = inputValue.substring(0, inputValue.length - currentQuery.length); const newText = `${preText}${triggerType === 'user' ? '@' : '#'}${value} `; setInputValue(newText); setShowPopup(false); // 光标自动移到插入内容的末尾 setTimeout(() => { textareaRef.current.selectionStart = textareaRef.current.selectionEnd = newText.length; }, 0); }; // 模拟用户/话题列表(实际项目替换为接口请求) const getOptions = () => { if (triggerType === 'user') { return [ { id: 1, value: 'alice' }, { id: 2, value: 'bob' }, { id: 3, value: 'charlie' } ].filter(opt => opt.value.includes(currentQuery)); } else if (triggerType === 'topic') { return [ { id: 1, value: 'reactjs' }, { id: 2, value: 'nextjs' }, { id: 3, value: 'frontend' } ].filter(opt => opt.value.includes(currentQuery)); } return []; }; return ( <div style={{ position: 'relative', width: '500px', margin: '20px auto' }}> <textarea ref={textareaRef} value={inputValue} onChange={handleInputChange} style={{ width: '100%', height: '150px', padding: '10px', fontSize: '16px' }} placeholder="输入@提及用户,#选择话题..." /> {showPopup && ( <div ref={popupRef} style={{ background: '#fff', border: '1px solid #ccc', borderRadius: '4px', padding: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', zIndex: 1000 }}> <h4 style={{ margin: '0 0 8px 0', fontSize: '14px' }}> {triggerType === 'user' ? '选择用户' : '选择话题'} </h4> <div> {getOptions().map(option => ( <div key={option.id} style={{ padding: '6px 8px', cursor: 'pointer', borderRadius: '4px', marginBottom: '4px' }} onClick={() => handleSelectOption(option)} onMouseEnter={(e) => e.target.style.background = '#f0f0f0'} onMouseLeave={(e) => e.target.style.background = 'transparent'} > {option.value} </div> ))} </div> </div> )} </div> ); }
2. 关键细节说明
- 光标检测:通过
selectionStart获取光标位置,往前匹配最近的@或#,确保只响应当前输入的触发词 - 弹窗定位:利用
RangeAPI获取光标在页面中的精确坐标,让弹窗刚好出现在光标下方 - 内容插入:替换当前输入的关键词部分,插入内容后自动调整光标位置,避免用户手动移动光标
- 实时过滤:根据当前输入的关键词实时筛选候选列表,模拟搜索效果
3. 扩展优化建议
- 监听
keydown事件,支持按ESC键关闭弹窗 - 优化弹窗定位逻辑,避免弹窗超出视口范围
- 接入真实后端接口,替换模拟的用户/话题列表
- 支持连续输入多个@/#触发内容,处理重叠触发的情况
- 添加加载状态,请求接口时显示loading动画
内容的提问来源于stack exchange,提问作者Ansh Rathod
相关产品推荐
相关产品推荐

