{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mentions",
  "title": "Mentions",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-react/components/mentions/mentions.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { cn } from '@/lib/utils'\nimport { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'\nimport { getCaretRect, type CaretRect } from './caret-position'\n\nexport interface MentionOption {\n  value: string\n  label: string\n  description?: string\n  avatar?: string\n  disabled?: boolean\n}\n\nexport interface MentionsProps<O extends MentionOption = MentionOption> {\n  /** Controlled textarea value. */\n  value?: string\n  /** Fires with the next textarea value on every input + on insert. */\n  onValueChange?: (value: string) => void\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  className?: string\n  /** Fires when an option is committed into the text. */\n  onSelect?: (option: O) => void\n  /** Fires whenever the active mention query changes. */\n  onSearch?: (payload: { trigger: string; query: string }) => void\n}\n\nfunction MentionsInner<O extends MentionOption = MentionOption>(\n  {\n    value = '',\n    onValueChange,\n    options = [] as unknown as O[],\n    triggers = ['@'],\n    prefix = '@',\n    rows = 4,\n    loading = false,\n    loadOptions,\n    format,\n    placeholder = '',\n    disabled = false,\n    readOnly = false,\n    className,\n    onSelect,\n    onSearch,\n  }: MentionsProps<O>,\n  ref: React.ForwardedRef<HTMLTextAreaElement>,\n) {\n  const innerRef = React.useRef<HTMLTextAreaElement | null>(null)\n  const setRefs = React.useCallback(\n    (node: HTMLTextAreaElement | null) => {\n      innerRef.current = node\n      if (typeof ref === 'function') ref(node)\n      else if (ref) (ref as React.MutableRefObject<HTMLTextAreaElement | null>).current = node\n    },\n    [ref],\n  )\n\n  const [open, setOpen] = React.useState(false)\n  const [activeTrigger, setActiveTrigger] = React.useState('')\n  const [query, setQuery] = React.useState('')\n  const [triggerIndex, setTriggerIndex] = React.useState(-1)\n  const [highlightedIndex, setHighlightedIndex] = React.useState(0)\n  const [asyncResults, setAsyncResults] = React.useState<O[]>([])\n  const [isAsyncLoading, setIsAsyncLoading] = React.useState(false)\n  const [caretRect, setCaretRect] = React.useState<CaretRect | null>(null)\n\n  const filtered = React.useMemo<O[]>(() => {\n    if (loadOptions) return asyncResults\n    if (!query) return options\n    const q = query.toLowerCase()\n    return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))\n  }, [loadOptions, asyncResults, query, options])\n\n  const totalLoading = loading || isAsyncLoading\n\n  function findActiveMention(\n    val: string,\n    caret: number,\n  ): { trigger: string; index: number; query: string } | null {\n    for (let i = caret - 1; i >= 0; i--) {\n      const ch = val[i]!\n      if (triggers.includes(ch)) {\n        const before = i === 0 ? '' : val[i - 1]!\n        if (i === 0 || /\\s/.test(before)) {\n          return { trigger: ch, index: i, query: val.substring(i + 1, caret) }\n        }\n        return null\n      }\n      if (/\\s/.test(ch)) return null\n    }\n    return null\n  }\n\n  const updateAnchor = React.useCallback(() => {\n    if (!innerRef.current) return\n    setCaretRect(getCaretRect(innerRef.current, innerRef.current.selectionStart ?? 0))\n  }, [])\n\n  const asyncToken = React.useRef(0)\n  const runAsync = React.useCallback(\n    async (trigger: string, q: string) => {\n      if (!loadOptions) return\n      const token = ++asyncToken.current\n      setIsAsyncLoading(true)\n      try {\n        const results = await loadOptions(q, trigger)\n        if (token === asyncToken.current) setAsyncResults(results)\n      } finally {\n        if (token === asyncToken.current) setIsAsyncLoading(false)\n      }\n    },\n    [loadOptions],\n  )\n\n  const debounceTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n  const scheduleAsync = React.useCallback(\n    (trigger: string, q: string) => {\n      if (debounceTimer.current) clearTimeout(debounceTimer.current)\n      debounceTimer.current = setTimeout(() => runAsync(trigger, q), 200)\n    },\n    [runAsync],\n  )\n\n  React.useEffect(() => {\n    return () => {\n      if (debounceTimer.current) clearTimeout(debounceTimer.current)\n    }\n  }, [])\n\n  function onInput(e: React.ChangeEvent<HTMLTextAreaElement>) {\n    const ta = e.target\n    onValueChange?.(ta.value)\n\n    const match = findActiveMention(ta.value, ta.selectionStart ?? 0)\n    if (match) {\n      setOpen(true)\n      setActiveTrigger(match.trigger)\n      setTriggerIndex(match.index)\n      setQuery(match.query)\n      setHighlightedIndex(0)\n      onSearch?.({ trigger: match.trigger, query: match.query })\n      if (loadOptions) scheduleAsync(match.trigger, match.query)\n      requestAnimationFrame(updateAnchor)\n    } else {\n      setOpen(false)\n    }\n  }\n\n  function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {\n    if (!open || filtered.length === 0) return\n    if (e.key === 'ArrowDown') {\n      e.preventDefault()\n      setHighlightedIndex((v) => (v + 1) % filtered.length)\n    } else if (e.key === 'ArrowUp') {\n      e.preventDefault()\n      setHighlightedIndex((v) => (v - 1 + filtered.length) % filtered.length)\n    } else if (e.key === 'Enter' || e.key === 'Tab') {\n      e.preventDefault()\n      const opt = filtered[highlightedIndex]\n      if (opt && !opt.disabled) insert(opt)\n    } else if (e.key === 'Escape') {\n      e.preventDefault()\n      setOpen(false)\n    }\n  }\n\n  function defaultFormat(option: O, _trigger: string) {\n    return `${prefix}${option.value} `\n  }\n\n  function insert(option: O) {\n    const ta = innerRef.current\n    if (!ta) return\n    const caret = ta.selectionStart ?? 0\n    const before = value.substring(0, triggerIndex)\n    const after = value.substring(caret)\n    const token = (format ?? defaultFormat)(option, activeTrigger)\n    const next = before + token + after\n    onValueChange?.(next)\n    onSelect?.(option)\n    setOpen(false)\n    requestAnimationFrame(() => {\n      const pos = before.length + token.length\n      ta.focus()\n      ta.setSelectionRange(pos, pos)\n    })\n  }\n\n  const anchorStyle: React.CSSProperties = caretRect\n    ? {\n        position: 'fixed',\n        top: `${caretRect.top + caretRect.height}px`,\n        left: `${caretRect.left}px`,\n        width: '0px',\n        height: '0px',\n        pointerEvents: 'none',\n      }\n    : { display: 'none' }\n\n  return (\n    <div className={cn('relative w-full', className)} data-uipkge=\"\" data-slot=\"mentions\">\n      <textarea\n        ref={setRefs}\n        value={value}\n        rows={rows}\n        placeholder={placeholder}\n        disabled={disabled}\n        readOnly={readOnly}\n        className=\"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        onChange={onInput}\n        onKeyDown={onKeyDown}\n        onScroll={updateAnchor}\n      />\n      <Popover open={open} onOpenChange={setOpen}>\n        <PopoverAnchor asChild>\n          <div style={anchorStyle} aria-hidden=\"true\" />\n        </PopoverAnchor>\n        <PopoverContent\n          align=\"start\"\n          sideOffset={4}\n          className=\"w-64 p-1\"\n          onOpenAutoFocus={(e) => e.preventDefault()}\n        >\n          {totalLoading ? (\n            <div className=\"text-muted-foreground px-2 py-3 text-sm\">Loading...</div>\n          ) : filtered.length === 0 ? (\n            <div className=\"text-muted-foreground px-2 py-3 text-sm\">No matches</div>\n          ) : (\n            <ul className=\"max-h-64 overflow-auto\" role=\"listbox\">\n              {filtered.map((opt, i) => (\n                <li\n                  key={opt.value}\n                  role=\"option\"\n                  aria-selected={i === highlightedIndex}\n                  className={cn(\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                  onMouseEnter={() => setHighlightedIndex(i)}\n                  onMouseDown={(e) => {\n                    e.preventDefault()\n                    if (!opt.disabled) insert(opt)\n                  }}\n                >\n                  {opt.avatar && <img src={opt.avatar} alt=\"\" className=\"size-6 rounded-full\" />}\n                  <div className=\"min-w-0 flex-1\">\n                    <div className=\"truncate\">{opt.label}</div>\n                    {opt.description && (\n                      <div className=\"text-muted-foreground truncate text-xs\">{opt.description}</div>\n                    )}\n                  </div>\n                </li>\n              ))}\n            </ul>\n          )}\n        </PopoverContent>\n      </Popover>\n    </div>\n  )\n}\n\n// forwardRef erases the generic; the cast restores the generic call signature so\n// consumers can still get type inference on `options`/`onSelect`.\nconst Mentions = React.forwardRef(MentionsInner) as <O extends MentionOption = MentionOption>(\n  props: MentionsProps<O> & { ref?: React.ForwardedRef<HTMLTextAreaElement> },\n) => React.ReactElement\n\nexport { Mentions }\n",
      "type": "registry:ui",
      "target": "~/components/ui/mentions/mentions.tsx"
    },
    {
      "path": "packages/registry-react/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": "~/components/ui/mentions/caret-position.ts"
    },
    {
      "path": "packages/registry-react/components/mentions/index.ts",
      "content": "export { Mentions, type MentionsProps, type MentionOption } from './mentions'\n",
      "type": "registry:ui",
      "target": "~/components/ui/mentions/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/react/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"
  ]
}