Mention 提及组件
一个基于 Vue 3 Vben Admin 框架的提及组件,支持输入特定字符(默认 @)时显示选项面板,用于快速选择并插入提及内容。
特性
✅ 支持自定义触发字符(默认 @)
✅ 动态过滤选项
✅ 键盘导航支持(上下箭头)
✅ 多种选择方式(鼠标点击、Enter/Tab/空格键)
✅ 面板宽度与输入框相同,支持响应式
✅ 点击外部区域关闭面板
✅ 获取焦点时检查光标前是否有触发字符
✅ 默认选中第一个选项
✅ 失去焦点自动关闭面板
index.ts
typescript
复制代码
//packages\@core\ui-kit\shadcn-ui\src\ui\mention\index.ts
export { default as Mention } from './Mention.vue';
export type { MentionOption } from './Mention.vue';
Mention.vue
typescript
复制代码
//packages\@core\ui-kit\shadcn-ui\src\ui\mention\Mention.vue
<script setup lang="ts">
import { cn } from '@vben-core/shared/utils';
import { useVModel } from '@vueuse/core';
import { computed, ref, onMounted, onUnmounted, nextTick } from 'vue';
export interface MentionOption {
label: string;
value: string;
}
const props = defineProps<{
class?: any;
defaultValue?: string;
modelValue?: string;
placeholder?: string;
options?: MentionOption[];
trigger?: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', payload: string): void;
(e: 'select', payload: MentionOption): void;
}>();
const modelValue = useVModel(props, 'modelValue', emit, {
defaultValue: props.defaultValue || '',
passive: true,
});
// 内部状态
const inputRef = ref<HTMLInputElement | null>(null);
const dropdownRef = ref<HTMLDivElement | null>(null);
const resizeObserver = ref<ResizeObserver | null>(null);
const isOpen = ref(false);
const selectedIndex = ref(-1);
const mentionText = ref('');
const mentionStartIndex = ref(-1);
// 默认值
const trigger = computed(() => props.trigger || '@');
const options = computed(() => props.options || []);
// 过滤后的选项
const filteredOptions = computed(() => {
if (!mentionText.value) return options.value;
return options.value.filter(option =>
option.label.toLowerCase().includes(mentionText.value.toLowerCase()) ||
option.value.toLowerCase().includes(mentionText.value.toLowerCase())
);
});
// 检测是否在输入框或选项面板内点击
const handleClickOutside = (event: MouseEvent) => {
const dropdownEl = document.querySelector('.mention-dropdown');
if (inputRef.value && !inputRef.value.contains(event.target as Node) &&
(!dropdownEl || !dropdownEl.contains(event.target as Node))) {
closeMention();
}
};
// 设置提及面板宽度和位置
const setDropdownWidth = () => {
if (inputRef.value && dropdownRef.value) {
// 获取输入框的宽度
const inputWidth = inputRef.value.offsetWidth;
// 设置面板宽度与输入框相同
dropdownRef.value.style.width = `${inputWidth}px`;
}
};
// 打开提及面板
const openMention = (index: number) => {
mentionStartIndex.value = index;
isOpen.value = true;
// 默认选中第一个选项
selectedIndex.value = 0;
// 设置面板宽度
nextTick(() => {
setDropdownWidth();
});
};
// 关闭提及面板
const closeMention = () => {
isOpen.value = false;
mentionText.value = '';
mentionStartIndex.value = -1;
selectedIndex.value = -1;
};
// 选择选项
const selectOption = (option: MentionOption) => {
if (mentionStartIndex.value === -1) return;
const beforeText = (modelValue.value || '').slice(0, mentionStartIndex.value);
const afterText = (modelValue.value || '').slice(mentionStartIndex.value + mentionText.value.length + 1);
modelValue.value = `${beforeText}${option.value}${afterText}`;
emit('select', option);
closeMention();
// 聚焦输入框并将光标移到选择项后面
setTimeout(() => {
if (inputRef.value) {
inputRef.value.focus();
const cursorPosition = beforeText.length + option.value.length;
inputRef.value.setSelectionRange(cursorPosition, cursorPosition);
}
}, 0);
};
// 处理输入事件
const handleInput = (event: Event) => {
const input = event.target as HTMLInputElement;
const value = input.value;
const cursorPosition = input.selectionStart || 0;
// 更新modelValue
modelValue.value = value;
// 查找最后一个@符号的位置
const lastAtIndex = value.lastIndexOf(trigger.value, cursorPosition - 1);
// 如果找到@符号,并且后面没有空格
if (lastAtIndex !== -1) {
// 检查@符号后面是否有空格
const nextSpaceIndex = value.indexOf(' ', lastAtIndex);
const endIndex = nextSpaceIndex !== -1 ? nextSpaceIndex : cursorPosition;
// 如果@符号在光标之前,并且后面没有空格
if (endIndex === cursorPosition) {
const textAfterAt = value.slice(lastAtIndex + 1, endIndex);
mentionText.value = textAfterAt;
openMention(lastAtIndex);
return;
}
}
// 如果没有找到@符号或者@符号后面有空格,关闭提及面板
closeMention();
};
// 处理失去焦点事件
const handleBlur = () => {
// 延迟关闭,以便点击选项时能先触发点击事件
setTimeout(() => {
closeMention();
}, 200);
};
// 处理获取焦点事件
const handleClickFocus = () => {
if (!inputRef.value) return;
const cursorPosition = inputRef.value.selectionStart || 0;
const value = modelValue.value || '';
// 检查光标前是否有@符号
const textBeforeCursor = value.slice(0, cursorPosition);
const lastAtIndex = textBeforeCursor.lastIndexOf(trigger.value);
if (lastAtIndex !== -1) {
// 检查@符号后面是否有空格
const nextSpaceIndex = textBeforeCursor.indexOf(' ', lastAtIndex);
// 如果@符号后面没有空格(即光标在@符号或其后面的文本处)
if (nextSpaceIndex === -1) {
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
mentionText.value = textAfterAt;
openMention(lastAtIndex);
}
}else{
handleBlur()
}
};
// 处理键盘事件
const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpen.value) return;
switch (event.key) {
case 'ArrowUp':
event.preventDefault();
selectedIndex.value = selectedIndex.value > 0 ? selectedIndex.value - 1 : filteredOptions.value.length - 1;
break;
case 'ArrowDown':
event.preventDefault();
selectedIndex.value = selectedIndex.value < filteredOptions.value.length - 1 ? selectedIndex.value + 1 : 0;
break;
case 'Enter':
case 'Tab':
case ' ':
// 如果没有选择任何选项,默认选择第一个
if (selectedIndex.value >= 0) {
if (filteredOptions.value[selectedIndex.value]) {
event.preventDefault();
selectOption(filteredOptions.value[selectedIndex.value]!);
}
} else if (filteredOptions.value.length > 0) {
event.preventDefault();
// 默认选择第一个选项
selectOption(filteredOptions.value[0]!);
}
break;
case 'Escape':
event.preventDefault();
closeMention();
break;
}
};
// 组件挂载时添加点击外部事件监听和ResizeObserver
onMounted(() => {
document.addEventListener('click', handleClickOutside);
// 添加ResizeObserver监听输入框大小变化
if (inputRef.value) {
resizeObserver.value = new ResizeObserver(() => {
// 只有当面板打开时才调整宽度
if (isOpen.value) {
setDropdownWidth();
}
});
resizeObserver.value.observe(inputRef.value);
}
});
// 组件卸载时移除点击外部事件监听和ResizeObserver
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
// 销毁ResizeObserver
if (resizeObserver.value) {
resizeObserver.value.disconnect();
}
});
</script>
<template>
<div class="relative w-full">
<input
ref="inputRef"
v-model="modelValue"
:placeholder="props.placeholder"
:class="
cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@input="handleInput"
@keydown="handleKeyDown"
@blur="handleBlur"
@click="handleClickFocus"
/>
<!-- 提及选项面板 -->
<div
ref="dropdownRef"
v-if="isOpen && filteredOptions.length > 0"
class="mention-dropdown fixed z-10 mt-1 rounded-md border border-input bg-background shadow-md overflow-hidden"
>
<div
v-for="(option, index) in filteredOptions"
:key="option.value"
class="px-3 py-2 text-sm cursor-pointer hover:bg-muted focus:bg-muted focus:outline-none"
:class="{ 'bg-muted': index === selectedIndex }"
@click="selectOption(option)"
:title="option.label"
>
{{ option.label }}
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
input {
--ring: var(--primary);
}
</style>
导入
ts
复制代码
import { Mention } from '@vben-core/shadcn-ui';
import type { MentionOption } from '@vben-core/shadcn-ui';
基本使用
ts
复制代码
<template>
<Mention
v-model="value"
:options="options"
placeholder="输入 @ 选择邮箱后缀"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Mention } from '@vben-core/shadcn-ui';
import type { MentionOption } from '@vben-core/shadcn-ui';
const value = ref('');
const options: MentionOption[] = [
{ label: '@hotmail.com', value: '@hotmail.com' },
{ label: '@bright.com.cn', value: '@bright.com.cn' },
{ label: '@bright.com', value: '@bright.com' },
];
</script>
ts
复制代码
<template>
<VbenForm :form-schema="formSchema" @submit="handleSubmit" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { VbenForm } from '@vben/common-ui';
import type { VbenFormSchema } from '@vben/common-ui';
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenMention',
componentProps: {
placeholder: '输入 @ 选择邮箱后缀',
options: [
{ label: '@hotmail.com', value: '@hotmail.com' },
{ label: '@bright.com.cn', value: '@bright.com.cn' },
{ label: '@bright.com', value: '@bright.com' },
],
},
fieldName: 'email',
label: '邮箱',
rules: z.string().min(1, { message: '请输入邮箱' }),
},
];
});
function handleSubmit(value: Recordable<any>) {
console.log('表单提交:', value);
}
</script>
API
Props
属性名
类型
默认值
说明
class
any
-
自定义类名
defaultValue
string
-
默认值
modelValue
string
-
绑定值(v-model)
placeholder
string
-
占位符
options
MentionOption[]
[]
选项列表
trigger
string
@
触发字符
Emits
事件名
类型
说明
update:modelValue
(value: string) => void
绑定值更新事件
select
(option: MentionOption) => void
选择选项事件
接口
MentionOption
ts
复制代码
export interface MentionOption {
label: string; // 选项显示文本
value: string; // 选项实际值
}
键盘快捷键
按键
说明
↑
向上移动选择
↓
向下移动选择
Enter
选择当前选项
Tab
选择当前选项
Space
选择当前选项
Escape
关闭面板
浏览器兼容性
Chrome (最新)
Firefox (最新)
Safari (最新)
Edge (最新)
注意事项
组件使用 ResizeObserver API 来监听输入框大小变化,确保面板宽度与输入框一致。
失去焦点时会延迟 200ms 关闭面板,以便点击选项时能先触发点击事件。
获取焦点时会检查光标前是否有触发字符,如果有则自动打开面板。
默认选中第一个选项,提高选择效率。
相关链接