Vue 3 Vben Admin 框架的Mention提及组件

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>

在 VbenForm 中使用

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 (最新)

注意事项

  1. 组件使用 ResizeObserver API 来监听输入框大小变化,确保面板宽度与输入框一致。
  2. 失去焦点时会延迟 200ms 关闭面板,以便点击选项时能先触发点击事件。
  3. 获取焦点时会检查光标前是否有触发字符,如果有则自动打开面板。
  4. 默认选中第一个选项,提高选择效率。

相关链接

相关推荐
用户69371750013842 小时前
Google 正在“收紧侧加载”:陌生 APK 安装或需等待 24 小时
android·前端
蓝帆傲亦2 小时前
Web 前端搜索文字高亮实现方法汇总
前端
用户69371750013842 小时前
Room 3.0:这次不是升级,是重来
android·前端·google
漫随流水3 小时前
旅游推荐系统(view.py)
前端·数据库·python·旅游
踩着两条虫4 小时前
VTJ.PRO 核心架构全公开!从设计稿到代码,揭秘AI智能体如何“听懂人话”
前端·vue.js·ai编程
jzlhll1235 小时前
kotlin Flow first() last()总结
开发语言·前端·kotlin
用头发抵命6 小时前
Vue 3 中优雅地集成 Video.js 播放器:从组件封装到功能定制
开发语言·javascript·ecmascript
蓝冰凌6 小时前
Vue 3 中 defineExpose 的行为【defineExpose暴露ref变量】详解:自动解包、响应性与实际使用
前端·javascript·vue.js
奔跑的呱呱牛6 小时前
generate-route-vue基于文件系统的 Vue Router 动态路由生成工具
前端·javascript·vue.js