Mentions
Vue formTextarea with trigger-character autocomplete. Type a configured trigger (default @) to open a filtered popover; pick to insert. Static or async options.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/mentions.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/mentions.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/mentions.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/mentions.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/mentions
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
modelValue | string | — | optional |
options | O[] | — | optional |
triggers | string[] | — | optional |
prefix | string | — | optional |
rows | number | — | optional |
loading | boolean | — | optional |
loadOptions | (query: string, trigger: string) | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
CaretRect interface CaretRect {
top: number
left: number
height: number
} MentionOption interface MentionOption {
value: string
label: string
description?: string
avatar?: string
disabled?: boolean
} Dependencies
Files (3)
-
app/components/ui/mentions/Mentions.vue 7.4 kB
<script setup lang="ts" generic="O extends MentionOption"> import { computed, nextTick, onBeforeUnmount, ref } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { getCaretRect, type CaretRect } from './caret-position' import type { MentionOption } from '.' const props = withDefaults( defineProps<{ modelValue?: string options?: O[] triggers?: string[] prefix?: string rows?: number loading?: boolean loadOptions?: (query: string, trigger: string) => Promise<O[]> format?: (option: O, trigger: string) => string placeholder?: string disabled?: boolean readonly?: boolean class?: HTMLAttributes['class'] }>(), { modelValue: '', options: () => [] as any, triggers: () => ['@'], prefix: '@', rows: 4, loading: false, placeholder: '', disabled: false, readonly: false, }, ) const emits = defineEmits<{ (e: 'update:modelValue', value: string): void (e: 'select', option: O): void (e: 'search', payload: { trigger: string; query: string }): void }>() const textarea = ref<HTMLTextAreaElement | null>(null) const open = ref(false) const activeTrigger = ref('') const query = ref('') const triggerIndex = ref(-1) const highlightedIndex = ref(0) const asyncResults = ref<O[]>([]) const isAsyncLoading = ref(false) const caretRect = ref<CaretRect | null>(null) const filtered = computed((): O[] => { if (props.loadOptions) return asyncResults.value as O[] if (!query.value) return props.options as O[] const q = query.value.toLowerCase() return (props.options as O[]).filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)) }) const totalLoading = computed(() => props.loading || isAsyncLoading.value) function findActiveMention(value: string, caret: number): { trigger: string; index: number; query: string } | null { for (let i = caret - 1; i >= 0; i--) { const ch = value[i]! if (props.triggers.includes(ch)) { const before = i === 0 ? '' : value[i - 1]! if (i === 0 || /\s/.test(before)) { return { trigger: ch, index: i, query: value.substring(i + 1, caret) } } return null } if (/\s/.test(ch)) return null } return null } function updateAnchor() { if (!textarea.value) return caretRect.value = getCaretRect(textarea.value, textarea.value.selectionStart ?? 0) } let asyncToken = 0 async function runAsync(trigger: string, q: string) { if (!props.loadOptions) return const token = ++asyncToken isAsyncLoading.value = true try { const results = await props.loadOptions(q, trigger) if (token === asyncToken) asyncResults.value = results } finally { if (token === asyncToken) isAsyncLoading.value = false } } let debounceTimer: ReturnType<typeof setTimeout> | null = null function scheduleAsync(trigger: string, q: string) { if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = setTimeout(() => runAsync(trigger, q), 200) } function onInput(e: Event) { const ta = e.target as HTMLTextAreaElement emits('update:modelValue', ta.value) const match = findActiveMention(ta.value, ta.selectionStart ?? 0) if (match) { open.value = true activeTrigger.value = match.trigger triggerIndex.value = match.index query.value = match.query highlightedIndex.value = 0 emits('search', { trigger: match.trigger, query: match.query }) if (props.loadOptions) scheduleAsync(match.trigger, match.query) nextTick(updateAnchor) } else { open.value = false } } function onKeydown(e: KeyboardEvent) { if (!open.value || filtered.value.length === 0) return if (e.key === 'ArrowDown') { e.preventDefault() highlightedIndex.value = (highlightedIndex.value + 1) % filtered.value.length } else if (e.key === 'ArrowUp') { e.preventDefault() highlightedIndex.value = (highlightedIndex.value - 1 + filtered.value.length) % filtered.value.length } else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault() const opt = filtered.value[highlightedIndex.value] if (opt && !opt.disabled) insert(opt) } else if (e.key === 'Escape') { e.preventDefault() open.value = false } } function defaultFormat(option: O, _trigger: string) { return `${props.prefix}${option.value} ` } function insert(option: O) { const ta = textarea.value if (!ta) return const value = props.modelValue const caret = ta.selectionStart ?? 0 const before = value.substring(0, triggerIndex.value) const after = value.substring(caret) const token = (props.format ?? defaultFormat)(option, activeTrigger.value) const next = before + token + after emits('update:modelValue', next) emits('select', option) open.value = false nextTick(() => { const pos = before.length + token.length ta.focus() ta.setSelectionRange(pos, pos) }) } const anchorStyle = computed(() => { if (!caretRect.value) return { display: 'none' } return { position: 'fixed' as const, top: `${caretRect.value.top + caretRect.value.height}px`, left: `${caretRect.value.left}px`, width: '0px', height: '0px', pointerEvents: 'none' as const, } }) onBeforeUnmount(() => { if (debounceTimer) clearTimeout(debounceTimer) }) </script> <template> <div :class="cn('relative w-full', props.class)" data-uipkge data-slot="mentions"> <textarea ref="textarea" :value="modelValue" :rows="rows" :placeholder="placeholder" :disabled="disabled" :readonly="readonly" class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-16 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" @input="onInput" @keydown="onKeydown" @scroll="updateAnchor" /> <Popover :open="open" @update:open="open = $event"> <PopoverAnchor as-child> <div :style="anchorStyle" aria-hidden="true" /> </PopoverAnchor> <PopoverContent align="start" :side-offset="4" class="w-64 p-1" @open-auto-focus="(e: Event) => e.preventDefault()" > <div v-if="totalLoading" class="text-muted-foreground px-2 py-3 text-sm">Loading...</div> <div v-else-if="filtered.length === 0" class="text-muted-foreground px-2 py-3 text-sm">No matches</div> <ul v-else class="max-h-64 overflow-auto" role="listbox"> <li v-for="(opt, i) in filtered" :key="opt.value" role="option" :aria-selected="i === highlightedIndex" :class="[ 'flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm', i === highlightedIndex && !opt.disabled ? 'bg-accent text-accent-foreground' : '', opt.disabled ? 'cursor-not-allowed opacity-50' : '', ]" @mouseenter="highlightedIndex = i" @mousedown.prevent="!opt.disabled && insert(opt)" > <img v-if="opt.avatar" :src="opt.avatar" alt="" class="size-6 rounded-full" /> <div class="min-w-0 flex-1"> <div class="truncate">{{ opt.label }}</div> <div v-if="opt.description" class="text-muted-foreground truncate text-xs"> {{ opt.description }} </div> </div> </li> </ul> </PopoverContent> </Popover> </div> </template> -
app/components/ui/mentions/caret-position.ts 1.8 kB
const PROPERTIES_TO_COPY = [ 'direction', 'boxSizing', 'width', 'height', 'overflowX', 'overflowY', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderStyle', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', 'fontSizeAdjust', 'lineHeight', 'fontFamily', 'textAlign', 'textTransform', 'textIndent', 'textDecoration', 'letterSpacing', 'wordSpacing', 'tabSize', 'whiteSpace', 'wordBreak', 'wordWrap', ] as const export interface CaretRect { top: number left: number height: number } export function getCaretRect(textarea: HTMLTextAreaElement, position: number): CaretRect { const div = document.createElement('div') document.body.appendChild(div) const style = div.style const computed = window.getComputedStyle(textarea) style.position = 'absolute' style.visibility = 'hidden' style.whiteSpace = 'pre-wrap' style.wordWrap = 'break-word' style.top = '0' style.left = '0' for (const prop of PROPERTIES_TO_COPY) { ;(style as any)[prop] = (computed as any)[prop] } style.overflow = 'hidden' const text = textarea.value.substring(0, position) div.textContent = text const span = document.createElement('span') span.textContent = textarea.value.substring(position) || '.' div.appendChild(span) const spanRect = span.getBoundingClientRect() const divRect = div.getBoundingClientRect() const taRect = textarea.getBoundingClientRect() const result: CaretRect = { top: taRect.top + (spanRect.top - divRect.top) - textarea.scrollTop, left: taRect.left + (spanRect.left - divRect.left) - textarea.scrollLeft, height: spanRect.height, } document.body.removeChild(div) return result } -
app/components/ui/mentions/index.ts 0.2 kB
export interface MentionOption { value: string label: string description?: string avatar?: string disabled?: boolean } export { default as Mentions } from './Mentions.vue'
Raw manifest: https://uipkge.dev/r/vue/mentions.json