{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mention-input",
  "type": "registry:ui",
  "title": "Mention Input",
  "description": "A textarea with @mention and #tag autocomplete at the caret, highlighted tokens and whole-token delete.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/mention-input.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport type MentionTrigger = \"@\" | \"#\";\n\nexport interface MentionItem {\n  id: string;\n  label: string;\n  /** Secondary text, e.g. a handle or a count. */\n  description?: string;\n}\n\nexport interface Mention {\n  id: string;\n  label: string;\n  trigger: MentionTrigger;\n  /** Range of the token (e.g. \"@Ada Lovelace\") in `text`, end exclusive. */\n  start: number;\n  end: number;\n}\n\nexport interface MentionValue {\n  /** Plain text, with mention tokens written as trigger + label. */\n  text: string;\n  /** Mentions ordered by position. */\n  mentions: Mention[];\n}\n\nexport interface MentionInputProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\" | \"defaultValue\" | \"children\"> {\n  /** Controlled value. */\n  value?: MentionValue;\n  /** Initial value when uncontrolled. */\n  defaultValue?: MentionValue;\n  onChange?: (value: MentionValue) => void;\n  /** People offered after \"@\" (filtered locally when `onSearch` is not set). */\n  users?: MentionItem[];\n  /** Tags offered after \"#\" (filtered locally when `onSearch` is not set). */\n  tags?: MentionItem[];\n  /** Async source for suggestions; replaces local filtering. The signal aborts superseded queries. */\n  onSearch?: (query: string, trigger: MentionTrigger, signal: AbortSignal) => Promise<MentionItem[]>;\n  /** Enable \"#\" tags alongside \"@\" mentions. */\n  allowTags?: boolean;\n  /** Delay before `onSearch` runs, in ms. */\n  debounce?: number;\n  /** Visible label for the textarea. */\n  label?: React.ReactNode;\n  /** Helper text linked with aria-describedby. */\n  hint?: React.ReactNode;\n  placeholder?: string;\n  rows?: number;\n  /** Optional character limit, shown as a counter. */\n  maxLength?: number;\n  /** Mention highlight, caret and focus colour (any CSS colour). */\n  accent?: string;\n  disabled?: boolean;\n}\n\ntype ActiveTrigger = { trigger: MentionTrigger; start: number; query: string };\ntype Results = { key: string; items: MentionItem[]; error: boolean };\n\nconst EMPTY: MentionValue = { text: \"\", mentions: [] };\nconst QUERY_CHAR = /[\\p{L}\\p{N}_.\\-]/u;\nconst POPUP_WIDTH = 264;\n\n/** CSS properties copied to the mirror so it wraps text exactly like the textarea. */\nconst MIRROR_PROPS = [\n  \"direction\", \"box-sizing\", \"width\", \"height\", \"overflow-x\", \"overflow-y\", \"scrollbar-gutter\",\n  \"border-top-width\", \"border-right-width\", \"border-bottom-width\", \"border-left-width\", \"border-style\",\n  \"padding-top\", \"padding-right\", \"padding-bottom\", \"padding-left\",\n  \"font-style\", \"font-variant\", \"font-weight\", \"font-stretch\", \"font-size\", \"font-size-adjust\", \"line-height\", \"font-family\",\n  \"font-feature-settings\", \"font-variation-settings\",\n  \"text-align\", \"text-transform\", \"text-indent\", \"letter-spacing\", \"word-spacing\", \"tab-size\",\n];\n\n/**\n * Pixel position of a character inside a textarea (mirror-div technique): an invisible div copies\n * the textarea's box and type styles, holds the text up to `position`, and a marker span reveals\n * where the next character would be drawn.\n */\nfunction caretCoordinates(textarea: HTMLTextAreaElement, position: number) {\n  const mirror = document.createElement(\"div\");\n  const computed = getComputedStyle(textarea);\n  for (const prop of MIRROR_PROPS) mirror.style.setProperty(prop, computed.getPropertyValue(prop));\n  mirror.style.position = \"absolute\";\n  mirror.style.visibility = \"hidden\";\n  mirror.style.top = \"0\";\n  mirror.style.left = \"-9999px\";\n  mirror.style.whiteSpace = \"pre-wrap\";\n  mirror.style.overflowWrap = \"break-word\";\n  mirror.textContent = textarea.value.slice(0, position);\n  const marker = document.createElement(\"span\");\n  marker.textContent = textarea.value.slice(position) || \".\";\n  mirror.appendChild(marker);\n  document.body.appendChild(mirror);\n  const lineHeight = parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.4;\n  const coords = { top: marker.offsetTop, left: marker.offsetLeft, height: lineHeight };\n  mirror.remove();\n  return coords;\n}\n\n/** Finds an \"@query\" / \"#query\" being typed right before the caret. */\nfunction findTrigger(text: string, caret: number, mentions: Mention[], allowTags: boolean): ActiveTrigger | null {\n  let i = caret - 1;\n  while (i >= 0 && caret - i <= 32 && QUERY_CHAR.test(text[i])) i--;\n  if (i < 0) return null;\n  const ch = text[i];\n  if (ch !== \"@\" && !(allowTags && ch === \"#\")) return null;\n  // The trigger must start a word…\n  if (i > 0 && !/[\\s([{\"'“]/.test(text[i - 1])) return null;\n  // …and must not belong to an existing mention token.\n  if (mentions.some((m) => i >= m.start && i < m.end)) return null;\n  return { trigger: ch as MentionTrigger, start: i, query: text.slice(i + 1, caret) };\n}\n\n/** Keeps mention ranges in sync with a text edit; mentions touched by the edit are dropped. */\nfunction remapMentions(prev: string, next: string, mentions: Mention[]): Mention[] {\n  let start = 0;\n  const max = Math.min(prev.length, next.length);\n  while (start < max && prev[start] === next[start]) start++;\n  let suffix = 0;\n  while (suffix < max - start && prev[prev.length - 1 - suffix] === next[next.length - 1 - suffix]) suffix++;\n  const oldEnd = prev.length - suffix;\n  const delta = next.length - prev.length;\n  const out: Mention[] = [];\n  for (const m of mentions) {\n    if (m.end <= start) out.push(m);\n    else if (m.start >= oldEnd) out.push({ ...m, start: m.start + delta, end: m.end + delta });\n    // Otherwise the edit landed inside the token: it becomes plain text.\n  }\n  return out;\n}\n\nconst initials = (label: string) =>\n  label\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((w) => w[0]?.toUpperCase())\n    .join(\"\");\n\n/**\n * A textarea with @mention (and optional #tag) autocomplete. The textarea's own text is\n * transparent and a pixel-aligned layer behind it renders the same text with mentions\n * highlighted, so native editing, selection, IME, spellcheck and undo all keep working.\n * Suggestions open next to the caret (mirror-div coordinates), are navigated with\n * aria-activedescendant, and a mention is removed as a whole token with Backspace.\n */\nexport function MentionInput({\n  value: valueProp,\n  defaultValue,\n  onChange,\n  users = [],\n  tags = [],\n  onSearch,\n  allowTags = true,\n  debounce = 150,\n  label = \"Message\",\n  hint,\n  placeholder = \"Write a comment — type @ to mention someone\",\n  rows = 4,\n  maxLength,\n  accent = \"#FF7448\",\n  disabled = false,\n  className = \"\",\n  style,\n  ...props\n}: MentionInputProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const fieldId = `mi-${uid}`;\n  const listboxId = `${fieldId}-listbox`;\n  const hintId = `${fieldId}-hint`;\n  const optionId = (i: number) => `${fieldId}-opt-${i}`;\n\n  const [inner, setInner] = React.useState<MentionValue>(defaultValue ?? EMPTY);\n  const value = valueProp ?? inner;\n  const [trig, setTrig] = React.useState<ActiveTrigger | null>(null);\n  const [dismissedAt, setDismissedAt] = React.useState<number | null>(null);\n  const [anchor, setAnchor] = React.useState({ left: 0, top: 0 });\n  const [activeIndex, setActiveIndex] = React.useState(0);\n  const [results, setResults] = React.useState<Results | null>(null);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const textareaRef = React.useRef<HTMLTextAreaElement>(null);\n  const overlayRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const pendingCaret = React.useRef<number | null>(null);\n\n  const open = trig !== null && trig.start !== dismissedAt && !disabled;\n  const key = trig ? `${trig.trigger}${trig.query}` : \"\";\n  const isAsync = typeof onSearch === \"function\";\n\n  /* ------------------------------- suggestions ------------------------------- */\n\n  const runSearch = React.useEffectEvent((query: string, trigger: MentionTrigger, signal: AbortSignal) => onSearch?.(query, trigger, signal));\n\n  const trigger = trig?.trigger;\n  const query = trig?.query;\n  React.useEffect(() => {\n    if (!isAsync || !open || trigger === undefined || query === undefined) return;\n    const controller = new AbortController();\n    const requestKey = `${trigger}${query}`;\n    const timer = setTimeout(() => {\n      runSearch(query, trigger, controller.signal)?.then(\n        (items) => !controller.signal.aborted && setResults({ key: requestKey, items, error: false }),\n        () => !controller.signal.aborted && setResults({ key: requestKey, items: [], error: true }),\n      );\n    }, debounce);\n    return () => {\n      clearTimeout(timer);\n      controller.abort();\n    };\n  }, [isAsync, open, trigger, query, debounce]);\n\n  const localItems = (): MentionItem[] => {\n    if (!trig) return [];\n    const q = trig.query.toLowerCase();\n    const source = trig.trigger === \"@\" ? users : tags;\n    return source\n      .filter((item) => !q || item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q) || item.description?.toLowerCase().includes(q))\n      .slice(0, 8);\n  };\n  const loading = isAsync && open && results?.key !== key;\n  // While a new query loads, keep showing the previous results for the same trigger (dimmed).\n  const items = !open ? [] : isAsync ? (results && results.key[0] === trigger ? results.items : []) : localItems();\n  const failed = isAsync && !loading && results?.error === true;\n  const active = items.length ? Math.min(activeIndex, items.length - 1) : -1;\n\n  /* --------------------------------- helpers -------------------------------- */\n\n  const commit = (next: MentionValue) => {\n    if (valueProp === undefined) setInner(next);\n    onChange?.(next);\n  };\n\n  /** Re-reads the caret and decides whether a trigger is active (and where its popup goes). */\n  const syncTrigger = (text: string, mentions: Mention[]) => {\n    const el = textareaRef.current;\n    if (!el) return;\n    const caret = el.selectionStart === el.selectionEnd ? el.selectionStart : -1;\n    const next = caret >= 0 ? findTrigger(text, caret, mentions, allowTags) : null;\n    if (!next) {\n      setTrig(null);\n      setDismissedAt(null);\n      return;\n    }\n    if (!trig || trig.start !== next.start || trig.trigger !== next.trigger) {\n      setActiveIndex(0);\n      placePopup(next.start);\n    }\n    if (trig?.query !== next.query) setActiveIndex(0);\n    setTrig(next);\n  };\n\n  const placePopup = (index: number) => {\n    const el = textareaRef.current;\n    const wrap = wrapRef.current;\n    if (!el || !wrap) return;\n    const c = caretCoordinates(el, index);\n    const left = el.offsetLeft + c.left - el.scrollLeft;\n    const top = el.offsetTop + c.top - el.scrollTop + c.height + 6;\n    const maxLeft = Math.max(0, wrap.clientWidth - POPUP_WIDTH);\n    setAnchor({ left: Math.round(Math.min(Math.max(0, left - 8), maxLeft)), top: Math.round(top) });\n  };\n\n  const insert = (item: MentionItem) => {\n    const el = textareaRef.current;\n    if (!trig || !el) return;\n    const caret = el.selectionStart;\n    const token = `${trig.trigger}${item.label}`;\n    const before = value.text.slice(0, trig.start);\n    const after = value.text.slice(caret);\n    const spacer = after.startsWith(\" \") ? \"\" : \" \";\n    const text = before + token + spacer + after;\n    const delta = text.length - value.text.length;\n    const mentions = [\n      ...value.mentions.filter((m) => m.end <= trig.start),\n      { id: item.id, label: item.label, trigger: trig.trigger, start: trig.start, end: trig.start + token.length },\n      ...value.mentions.filter((m) => m.start >= caret).map((m) => ({ ...m, start: m.start + delta, end: m.end + delta })),\n    ];\n    if (maxLength && text.length > maxLength) {\n      setAnnouncement(\"Not enough room for this mention.\");\n      return;\n    }\n    commit({ text, mentions });\n    pendingCaret.current = trig.start + token.length + 1;\n    setTrig(null);\n    setAnnouncement(`${trig.trigger === \"@\" ? \"Mentioned\" : \"Tagged\"} ${item.label}.`);\n  };\n\n  const removeMention = (m: Mention) => {\n    const text = value.text.slice(0, m.start) + value.text.slice(m.end);\n    const len = m.end - m.start;\n    const mentions = value.mentions.filter((x) => x !== m).map((x) => (x.start >= m.end ? { ...x, start: x.start - len, end: x.end - len } : x));\n    commit({ text, mentions });\n    pendingCaret.current = m.start;\n    setTrig(null);\n    setAnnouncement(`Removed ${m.trigger === \"@\" ? \"mention\" : \"tag\"} ${m.label}.`);\n  };\n\n  // Restore the caret after programmatic edits (insert / remove).\n  React.useLayoutEffect(() => {\n    const pos = pendingCaret.current;\n    const el = textareaRef.current;\n    if (pos === null || !el) return;\n    pendingCaret.current = null;\n    el.focus();\n    el.setSelectionRange(pos, pos);\n    if (overlayRef.current) overlayRef.current.scrollTop = el.scrollTop;\n  });\n\n  // Keep the highlighted option visible inside the list (never scroll the page).\n  React.useEffect(() => {\n    const list = listRef.current;\n    const el = active >= 0 ? list?.querySelector<HTMLElement>(`#${CSS.escape(optionId(active))}`) : null;\n    if (!list || !el) return;\n    if (el.offsetTop < list.scrollTop) list.scrollTop = el.offsetTop - 4;\n    else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight)\n      list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight + 4;\n  });\n\n  /* --------------------------------- handlers -------------------------------- */\n\n  const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {\n    const text = event.currentTarget.value;\n    const mentions = remapMentions(value.text, text, value.mentions);\n    commit({ text, mentions });\n    setAnnouncement(\"\");\n    syncTrigger(text, mentions);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    if (event.nativeEvent.isComposing) return;\n    if (open && (items.length > 0 || event.key === \"Escape\")) {\n      switch (event.key) {\n        case \"ArrowDown\":\n          event.preventDefault();\n          setActiveIndex((active + 1) % items.length);\n          return;\n        case \"ArrowUp\":\n          event.preventDefault();\n          setActiveIndex((active - 1 + items.length) % items.length);\n          return;\n        case \"Enter\":\n        case \"Tab\":\n          if (active >= 0) {\n            event.preventDefault();\n            insert(items[active]);\n          }\n          return;\n        case \"Escape\":\n          event.preventDefault();\n          if (trig) setDismissedAt(trig.start);\n          return;\n      }\n    }\n\n    // Backspace / Delete next to a mention removes the whole token.\n    const el = event.currentTarget;\n    if (el.selectionStart !== el.selectionEnd) return;\n    const caret = el.selectionStart;\n    if (event.key === \"Backspace\") {\n      const m = value.mentions.find((x) => caret > x.start && caret <= x.end);\n      if (m) {\n        event.preventDefault();\n        removeMention(m);\n      }\n    } else if (event.key === \"Delete\") {\n      const m = value.mentions.find((x) => caret >= x.start && caret < x.end);\n      if (m) {\n        event.preventDefault();\n        removeMention(m);\n      }\n    }\n  };\n\n  /* ---------------------------------- render --------------------------------- */\n\n  const sorted = [...value.mentions].sort((a, b) => a.start - b.start);\n  const segments: React.ReactNode[] = [];\n  let cursor = 0;\n  sorted.forEach((m, i) => {\n    if (m.start > cursor) segments.push(value.text.slice(cursor, m.start));\n    segments.push(\n      <mark\n        key={`${m.start}-${i}`}\n        className=\"rounded-[4px] bg-[color-mix(in_srgb,var(--mi-accent)_18%,transparent)] text-[color-mix(in_srgb,var(--mi-accent)_62%,#000)] shadow-[0_0_0_1.5px_color-mix(in_srgb,var(--mi-accent)_18%,transparent)] dark:bg-[color-mix(in_srgb,var(--mi-accent)_22%,transparent)] dark:text-[color-mix(in_srgb,var(--mi-accent)_70%,#fff)] dark:shadow-[0_0_0_1.5px_color-mix(in_srgb,var(--mi-accent)_22%,transparent)]\"\n      >\n        {value.text.slice(m.start, m.end)}\n      </mark>,\n    );\n    cursor = m.end;\n  });\n  // A trailing newline needs a character after it to occupy a line in the overlay.\n  segments.push(value.text.slice(cursor) + (value.text.endsWith(\"\\n\") ? \" \" : \"\"));\n\n  // Shared box metrics: the overlay and the textarea must wrap identically.\n  const metrics =\n    \"w-full px-3.5 py-3 text-[15px] leading-[1.6] tracking-normal whitespace-pre-wrap break-words [overflow-wrap:break-word] [scrollbar-gutter:stable] border border-transparent\";\n\n  const describedBy = [hint ? hintId : null, props[\"aria-describedby\"]].filter(Boolean).join(\" \") || undefined;\n  const statusText = !open\n    ? \"\"\n    : loading\n      ? \"Searching…\"\n      : failed\n        ? \"Suggestions could not be loaded.\"\n        : items.length\n          ? `${items.length} ${trig?.trigger === \"#\" ? (items.length === 1 ? \"tag\" : \"tags\") : items.length === 1 ? \"person\" : \"people\"} found. Use the arrow keys and Enter to insert.`\n          : \"No matches.\";\n  const count = value.mentions.length;\n  const showPopup = open && (items.length > 0 || loading || failed || (trig?.query ?? \"\") !== \"\");\n\n  return (\n    <div\n      {...props}\n      aria-describedby={undefined}\n      className={`w-full ${className}`}\n      style={{ [\"--mi-accent\" as string]: accent, ...style }}\n    >\n      <label htmlFor={fieldId} className=\"mb-2 block font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n        {label}\n      </label>\n\n      <div\n        ref={wrapRef}\n        className={`relative rounded-xl border bg-white transition-[border-color,box-shadow] duration-150 motion-reduce:transition-none dark:bg-zinc-950 ${\n          disabled ? \"opacity-50\" : \"\"\n        } border-zinc-900/15 focus-within:border-[var(--mi-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--mi-accent)_22%,transparent)] hover:border-zinc-900/30 dark:border-white/15 dark:hover:border-white/30`}\n      >\n        <div className=\"relative\">\n          {/* Visible text with highlighted mentions, pixel-aligned behind the transparent textarea. */}\n          <div\n            ref={overlayRef}\n            aria-hidden=\"true\"\n            className={`${metrics} pointer-events-none absolute inset-0 overflow-hidden text-zinc-900 dark:text-zinc-100`}\n          >\n            {segments}\n          </div>\n\n          <textarea\n            ref={textareaRef}\n            id={fieldId}\n            rows={rows}\n            value={value.text}\n            placeholder={placeholder}\n            maxLength={maxLength}\n            disabled={disabled}\n            spellCheck\n            aria-autocomplete=\"list\"\n            aria-controls={showPopup ? listboxId : undefined}\n            aria-activedescendant={showPopup && active >= 0 ? optionId(active) : undefined}\n            aria-describedby={describedBy}\n            onChange={handleChange}\n            onKeyDown={handleKeyDown}\n            onSelect={(event) => {\n              // Caret moves (clicks, arrow keys) can enter or leave a trigger; edits are handled in onChange.\n              if (event.currentTarget.value === value.text) syncTrigger(value.text, value.mentions);\n            }}\n            onScroll={(event) => {\n              if (overlayRef.current) overlayRef.current.scrollTop = event.currentTarget.scrollTop;\n              if (trig) placePopup(trig.start);\n            }}\n            onBlur={() => setTrig(null)}\n            className={`${metrics} relative block resize-none overflow-y-auto bg-transparent text-transparent caret-zinc-900 outline-hidden placeholder:text-zinc-400 selection:bg-[color-mix(in_srgb,var(--mi-accent)_28%,transparent)] disabled:cursor-not-allowed dark:caret-zinc-100 dark:placeholder:text-zinc-500`}\n          />\n        </div>\n\n        <div className=\"flex items-center justify-between gap-3 border-t border-zinc-900/[0.06] px-3.5 py-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:border-white/[0.06] dark:text-zinc-500\">\n          <span aria-hidden=\"true\">\n            @ people{allowTags ? \" · # tags\" : \"\"}\n          </span>\n          <span className=\"tabular-nums\">\n            {count} {count === 1 ? \"mention\" : \"mentions\"}\n            {maxLength ? ` · ${value.text.length}/${maxLength}` : \"\"}\n          </span>\n        </div>\n\n        <AnimatePresence>\n          {showPopup && (\n            <motion.div\n              key=\"popup\"\n              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.98 }}\n              animate={{ opacity: 1, y: 0, scale: 1 }}\n              exit={reduceMotion ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, transition: { duration: 0.1 } }}\n              transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 600, damping: 40, mass: 0.5 }}\n              style={{ left: anchor.left, top: anchor.top, width: POPUP_WIDTH, transformOrigin: \"top left\" }}\n              className=\"absolute z-30 max-w-full overflow-hidden rounded-xl border border-zinc-900/10 bg-white shadow-[0_16px_40px_-16px_rgba(0,0,0,0.3)] dark:border-white/10 dark:bg-zinc-900\"\n              onMouseDown={(e) => e.preventDefault()}\n            >\n              <div className=\"flex items-center justify-between border-b border-zinc-900/[0.06] px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:border-white/[0.06] dark:text-zinc-500\">\n                <span>{trig?.trigger === \"#\" ? \"Tags\" : \"People\"}</span>\n                {loading && (\n                  <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-3 animate-spin text-[var(--mi-accent)] motion-reduce:animate-none\">\n                    <circle cx=\"8\" cy=\"8\" r=\"6\" fill=\"none\" stroke=\"currentColor\" strokeOpacity=\"0.25\" strokeWidth=\"1.8\" />\n                    <path d=\"M14 8a6 6 0 0 0-6-6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" />\n                  </svg>\n                )}\n              </div>\n              <div\n                ref={listRef}\n                id={listboxId}\n                role=\"listbox\"\n                aria-label={trig?.trigger === \"#\" ? \"Tag suggestions\" : \"People suggestions\"}\n                aria-busy={loading || undefined}\n                className={`max-h-56 overflow-y-auto overscroll-contain p-1 transition-opacity ${loading && items.length ? \"opacity-60\" : \"\"}`}\n              >\n                {items.map((item, i) => (\n                  <div\n                    key={item.id}\n                    id={optionId(i)}\n                    role=\"option\"\n                    aria-selected={i === active}\n                    onClick={() => insert(item)}\n                    onPointerMove={(e) => {\n                      if (e.pointerType === \"mouse\" && i !== active) setActiveIndex(i);\n                    }}\n                    className={`flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-[13px] ${\n                      i === active\n                        ? \"bg-[color-mix(in_srgb,var(--mi-accent)_12%,transparent)] text-zinc-950 dark:text-white\"\n                        : \"text-zinc-700 dark:text-zinc-300\"\n                    }`}\n                  >\n                    <span\n                      aria-hidden=\"true\"\n                      className={`grid size-6 shrink-0 place-items-center rounded-full text-[10px] font-semibold ${\n                        trig?.trigger === \"#\"\n                          ? \"rounded-md bg-zinc-900/[0.06] text-zinc-500 dark:bg-white/[0.08] dark:text-zinc-400\"\n                          : \"bg-[color-mix(in_srgb,var(--mi-accent)_22%,transparent)] text-zinc-800 dark:text-zinc-100\"\n                      }`}\n                    >\n                      {trig?.trigger === \"#\" ? \"#\" : initials(item.label)}\n                    </span>\n                    <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n                    {item.description && (\n                      <span className=\"shrink-0 truncate font-mono text-[10px] tracking-[0.04em] text-zinc-400 dark:text-zinc-500\">{item.description}</span>\n                    )}\n                  </div>\n                ))}\n              </div>\n              {items.length === 0 && (\n                <p className=\"px-3 pb-2.5 pt-1 text-[13px] text-zinc-500 dark:text-zinc-400\">\n                  {loading ? \"Searching…\" : failed ? \"Couldn’t load suggestions.\" : \"No matches\"}\n                </p>\n              )}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      {hint && (\n        <p id={hintId} className=\"mt-1.5 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400\">\n          {hint}\n        </p>\n      )}\n\n      <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement || statusText}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/mention-input"
}
