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