UIPackage

Mentions

Vue form
Edit on GitHub

Textarea 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

$ npx 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