{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mention-input",
  "type": "registry:ui",
  "title": "Mention input",
  "description": "A textarea with @people and #channel mentions that act as single tokens, with suggestions at the caret.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/mention-input/mention-input.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useCallback, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { ChangeEvent, KeyboardEvent as ReactKeyboardEvent, ReactNode, UIEvent } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition } from \"motion/react\";\nimport { Hash } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport type MentionKind = \"person\" | \"channel\";\n\nexport interface MentionPerson {\n  id: string;\n  name: string;\n  /** One short line under the name, such as a job title. */\n  role?: string;\n  /** Portrait URL. Initials show when it is missing. */\n  avatar?: string;\n}\n\nexport interface MentionChannel {\n  id: string;\n  name: string;\n  description?: string;\n  members?: number;\n}\n\n/** A mention inside `text`. `start` and `end` are character offsets; the text between them is `@label` or `#label`. */\nexport interface Mention {\n  kind: MentionKind;\n  id: string;\n  label: string;\n  start: number;\n  end: number;\n}\n\nexport interface MentionValue {\n  text: string;\n  mentions: Mention[];\n}\n\nexport interface MentionInputHandle {\n  focus: () => void;\n  /** Inserts text at the caret, as if typed. */\n  insert: (text: string) => void;\n  /** Types a trigger at the caret so the suggestions open, adding a space first when needed. */\n  openSuggestions: (kind: MentionKind) => void;\n  clear: () => void;\n  textarea: HTMLTextAreaElement | null;\n}\n\nexport interface MentionInputProps {\n  value?: MentionValue;\n  defaultValue?: MentionValue;\n  onChange?: (value: MentionValue) => void;\n  /** People offered after `@`. Leave it out to turn person mentions off. */\n  people?: MentionPerson[];\n  /** Channels offered after `#`. Leave it out to turn channel mentions off. */\n  channels?: MentionChannel[];\n  /** Called when a suggestion becomes a mention. */\n  onMentionAdd?: (mention: Mention) => void;\n  /** With `submitOnEnter`, Enter submits and Shift+Enter adds a line. */\n  onSubmit?: (value: MentionValue) => void;\n  submitOnEnter?: boolean;\n  placeholder?: string;\n  minRows?: number;\n  /** The field grows to this many rows, then scrolls. */\n  maxRows?: number;\n  /** Where suggestions open. `auto` flips above when there is no room below. */\n  placement?: \"auto\" | \"top\" | \"bottom\";\n  maxSuggestions?: number;\n  disabled?: boolean;\n  name?: string;\n  id?: string;\n  \"aria-label\"?: string;\n  \"aria-describedby\"?: string;\n  className?: string;\n}\n\ntype Suggestion = { kind: \"person\"; item: MentionPerson } | { kind: \"channel\"; item: MentionChannel };\ntype Trigger = { kind: MentionKind; start: number; query: string };\n\nconst EMPTY: MentionValue = { text: \"\", mentions: [] };\nconst SYMBOL: Record<MentionKind, string> = { person: \"@\", channel: \"#\" };\nconst POPOVER_WIDTH = 272;\nconst { spring, duration, ease } = motionTokens;\nconst enter = [...ease.enter] as [number, number, number, number];\nconst standard = [...ease.standard] as [number, number, number, number];\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\nconst GLIDE = physical(0.3, 0),\n  GROW = physical(spring.smooth.visualDuration, 0);\n/** First placements. A jump made in the commit that mounts its element never reaches the DOM; an instant animation lands on the first frame. */\nconst INSTANT: Transition = { duration: 0 };\n\n/* The mirror and the textarea share every metric so tokens sit exactly under their characters. */\nconst textMetrics =\n  \"m-0 box-border w-full border-0 px-3.5 py-[11px] text-base leading-6 tracking-body [overflow-wrap:break-word] whitespace-pre-wrap [tab-size:4] [word-break:break-word]\";\nconst optionMeta = \"truncate text-(length:--text-xs) text-ink-3\";\n\nexport const mentionText = (kind: MentionKind, label: string) => `${SYMBOL[kind]}${label}`;\n\n/** Turns a value into storage friendly text, `<@id>` for people and `<#id>` for channels by default. */\nexport function serializeMentions(\n  value: MentionValue,\n  format: (mention: Mention) => string = (mention) => `<${SYMBOL[mention.kind]}${mention.id}>`,\n) {\n  let out = \"\",\n    at = 0;\n  for (const mention of [...value.mentions].sort((a, b) => a.start - b.start)) {\n    out += value.text.slice(at, mention.start) + format(mention);\n    at = mention.end;\n  }\n  return out + value.text.slice(at);\n}\n\n/** Moves mentions along with an edit and drops any the edit touched. `hint` is the caret before the edit, which settles repeated characters. */\nfunction reconcile(previous: MentionValue, text: string, hint: number): Mention[] {\n  const a = previous.text;\n  let prefix = 0;\n  const maxPrefix = Math.min(a.length, text.length, Math.max(0, hint));\n  while (prefix < maxPrefix && a[prefix] === text[prefix]) prefix++;\n  let suffix = 0;\n  while (\n    suffix < a.length - prefix &&\n    suffix < text.length - prefix &&\n    a[a.length - 1 - suffix] === text[text.length - 1 - suffix]\n  )\n    suffix++;\n  const oldEnd = a.length - suffix,\n    delta = text.length - a.length;\n  return previous.mentions\n    .flatMap((mention) => {\n      if (mention.end <= prefix) return [mention];\n      if (mention.start >= oldEnd) return [{ ...mention, start: mention.start + delta, end: mention.end + delta }];\n      return [];\n    })\n    .filter((mention) => text.slice(mention.start, mention.end) === mentionText(mention.kind, mention.label));\n}\n\nfunction findTrigger(\n  text: string,\n  caret: number,\n  mentions: Mention[],\n  kinds: Record<MentionKind, boolean>,\n): Trigger | null {\n  for (let index = caret - 1; index >= 0 && index >= caret - 48; index--) {\n    const char = text[index];\n    if (char === \"\\n\") return null;\n    if (char !== \"@\" && char !== \"#\") continue;\n    const kind: MentionKind = char === \"@\" ? \"person\" : \"channel\";\n    if (!kinds[kind]) return null;\n    if (index > 0 && !/[\\s([{\"']/.test(text[index - 1]!)) return null;\n    if (mentions.some((mention) => index >= mention.start && index < mention.end)) return null;\n    const query = text.slice(index + 1, caret);\n    const shape = kind === \"person\" ? /^[^\\s@#]*( [^\\s@#]*)?$/ : /^[^\\s@#]*$/;\n    return shape.test(query) ? { kind, start: index, query } : null;\n  }\n  return null;\n}\n\nfunction rank(label: string, extra: string, query: string) {\n  const name = label.toLowerCase();\n  if (!query) return 1;\n  if (name.startsWith(query)) return 4;\n  if (name.split(/[\\s\\-_.]+/).some((word) => word.startsWith(query))) return 3;\n  if (name.includes(query)) return 2;\n  return extra.toLowerCase().includes(query) ? 1 : 0;\n}\n\nfunction initials(name: string) {\n  return name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((part) => part[0]?.toUpperCase())\n    .join(\"\");\n}\n\n/** The matched part of a label reads in full contrast; the rest stays quiet. */\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;\n  if (at < 0) return <>{text}</>;\n  return (\n    <>\n      {text.slice(0, at)}\n      <mark className=\"bg-transparent text-ink\">{text.slice(at, at + query.length)}</mark>\n      {text.slice(at + query.length)}\n    </>\n  );\n}\n\n/**\n * A textarea with `@` people and `#` channel mentions. Mentions are atomic: the caret steps over them and one Backspace removes the whole token.\n * The value carries plain text plus a structured `mentions` array with offsets, ready to store or notify.\n */\nexport const MentionInput = forwardRef<MentionInputHandle, MentionInputProps>(function MentionInput(\n  {\n    value: valueProp,\n    defaultValue,\n    onChange,\n    people,\n    channels,\n    onMentionAdd,\n    onSubmit,\n    submitOnEnter = false,\n    placeholder,\n    minRows = 1,\n    maxRows = 8,\n    placement = \"auto\",\n    maxSuggestions = 6,\n    disabled,\n    name,\n    id,\n    \"aria-label\": ariaLabel,\n    \"aria-describedby\": describedBy,\n    className,\n  },\n  ref,\n) {\n  const reduced = !!useReducedMotion();\n  const uid = useId();\n  const listId = `${uid}-list`;\n  const [inner, setInner] = useState<MentionValue>(defaultValue ?? EMPTY);\n  const value = valueProp ?? inner;\n  const live = useRef(value);\n  useLayoutEffect(() => {\n    live.current = value;\n  });\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const areaRef = useRef<HTMLTextAreaElement>(null);\n  const backdropRef = useRef<HTMLDivElement>(null);\n  const lastCaret = useRef(0);\n  const pending = useRef<Mention | null>(null);\n\n  const [caret, setCaret] = useState<number | null>(null);\n  const [focused, setFocused] = useState(false);\n  const [dismissed, setDismissed] = useState<number | null>(null);\n  const [active, setActive] = useState(0);\n\n  const commit = useCallback(\n    (next: MentionValue) => {\n      if (valueProp === undefined) setInner(next);\n      onChange?.(next);\n    },\n    [onChange, valueProp],\n  );\n\n  const kinds = useMemo(() => ({ person: !!people?.length, channel: !!channels?.length }), [channels, people]);\n  const trigger = useMemo(\n    () => (caret === null ? null : findTrigger(value.text, caret, value.mentions, kinds)),\n    [caret, kinds, value.mentions, value.text],\n  );\n\n  const suggestions = useMemo<Suggestion[]>(() => {\n    if (!trigger) return [];\n    const query = trigger.query.toLowerCase().trim();\n    const scored: { entry: Suggestion; score: number }[] =\n      trigger.kind === \"person\"\n        ? (people ?? []).map((item) => ({\n            entry: { kind: \"person\" as const, item },\n            score: rank(item.name, item.role ?? \"\", query),\n          }))\n        : (channels ?? []).map((item) => ({\n            entry: { kind: \"channel\" as const, item },\n            score: rank(item.name, item.description ?? \"\", query),\n          }));\n    return scored\n      .filter((entry) => entry.score > 0)\n      .sort((a, b) => b.score - a.score)\n      .slice(0, maxSuggestions)\n      .map((entry) => entry.entry);\n  }, [channels, maxSuggestions, people, trigger]);\n\n  // A sentence that simply continues after an unmatched name closes the list instead of showing an empty state.\n  const open =\n    focused &&\n    !disabled &&\n    !!trigger &&\n    trigger.start !== dismissed &&\n    (suggestions.length > 0 || !trigger.query.includes(\" \"));\n  const activeIndex = Math.min(active, Math.max(0, suggestions.length - 1));\n  const queryKey = trigger ? `${trigger.start}:${trigger.query}` : \"\";\n  const [lastQueryKey, setLastQueryKey] = useState(queryKey);\n  if (lastQueryKey !== queryKey) {\n    setLastQueryKey(queryKey);\n    setActive(0);\n  }\n  if (dismissed !== null && trigger?.start !== dismissed) setDismissed(null);\n\n  /* Selection: the caret never rests inside a token. Arrow keys hop over it; a click lands on the nearest edge. */\n  const onSelect = () => {\n    const area = areaRef.current;\n    if (!area) return;\n    let start = area.selectionStart,\n      end = area.selectionEnd;\n    const collapsed = start === end;\n    for (const mention of live.current.mentions) {\n      if (collapsed && start > mention.start && start < mention.end) {\n        const stepped = Math.abs(start - lastCaret.current) === 1;\n        const forward = start > lastCaret.current;\n        start = end = stepped\n          ? forward\n            ? mention.end\n            : mention.start\n          : start - mention.start < mention.end - start\n            ? mention.start\n            : mention.end;\n      } else if (!collapsed) {\n        if (start > mention.start && start < mention.end) start = mention.start;\n        if (end > mention.start && end < mention.end) end = mention.end;\n      }\n    }\n    if (start !== area.selectionStart || end !== area.selectionEnd)\n      area.setSelectionRange(start, end, area.selectionDirection);\n    lastCaret.current = area.selectionStart;\n    setCaret(collapsed ? start : null);\n  };\n\n  const onInput = (event: ChangeEvent<HTMLTextAreaElement>) => {\n    const text = event.target.value;\n    const mentions = reconcile(live.current, text, lastCaret.current);\n    const added = pending.current;\n    pending.current = null;\n    if (added && text.slice(added.start, added.end) === mentionText(added.kind, added.label)) {\n      mentions.push(added);\n      mentions.sort((a, b) => a.start - b.start);\n    }\n    const next = { text, mentions };\n    live.current = next;\n    commit(next);\n    if (added) onMentionAdd?.(added);\n    lastCaret.current = event.target.selectionStart;\n    setCaret(event.target.selectionStart === event.target.selectionEnd ? event.target.selectionStart : null);\n  };\n\n  /** Types through the browser so the change joins the native undo stack; falls back to a direct edit. */\n  const typeText = useCallback(\n    (text: string, from?: number, to?: number) => {\n      const area = areaRef.current;\n      if (!area) return;\n      area.focus({ preventScroll: true });\n      if (from !== undefined) area.setSelectionRange(from, to ?? from);\n      lastCaret.current = area.selectionStart;\n      const typed = typeof document.execCommand === \"function\" && document.execCommand(\"insertText\", false, text);\n      if (typed) return;\n      const start = area.selectionStart,\n        end = area.selectionEnd,\n        current = live.current;\n      const nextText = current.text.slice(0, start) + text + current.text.slice(end);\n      const mentions = reconcile(current, nextText, start);\n      const added = pending.current;\n      pending.current = null;\n      if (added) mentions.push(added);\n      mentions.sort((a, b) => a.start - b.start);\n      const next = { text: nextText, mentions };\n      live.current = next;\n      commit(next);\n      if (added) onMentionAdd?.(added);\n      requestAnimationFrame(() => {\n        area.setSelectionRange(start + text.length, start + text.length);\n        setCaret(start + text.length);\n      });\n    },\n    [commit, onMentionAdd],\n  );\n\n  const choose = (suggestion: Suggestion | undefined) => {\n    if (!suggestion || !trigger || caret === null) return;\n    const label = suggestion.item.name;\n    const token = mentionText(suggestion.kind, label);\n    pending.current = {\n      kind: suggestion.kind,\n      id: suggestion.item.id,\n      label,\n      start: trigger.start,\n      end: trigger.start + token.length,\n    };\n    const after = value.text[caret];\n    typeText(after === undefined || !/\\s/.test(after) ? `${token} ` : token, trigger.start, caret);\n  };\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {\n    const area = event.currentTarget;\n    lastCaret.current = area.selectionStart;\n    if (open) {\n      if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n        event.preventDefault();\n        if (suggestions.length)\n          setActive((activeIndex + (event.key === \"ArrowDown\" ? 1 : -1) + suggestions.length) % suggestions.length);\n        return;\n      }\n      if ((event.key === \"Enter\" || event.key === \"Tab\") && suggestions.length && !event.shiftKey) {\n        event.preventDefault();\n        choose(suggestions[activeIndex]);\n        return;\n      }\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        event.stopPropagation();\n        setDismissed(trigger?.start ?? null);\n        return;\n      }\n    }\n    const start = area.selectionStart,\n      end = area.selectionEnd;\n    if (start === end && !event.metaKey && (event.key === \"Backspace\" || event.key === \"Delete\")) {\n      // Select the whole token and let the native delete run, so one keystroke removes it and undo restores it.\n      const hit = live.current.mentions.find((mention) =>\n        event.key === \"Backspace\"\n          ? start > mention.start && start <= mention.end\n          : start >= mention.start && start < mention.end,\n      );\n      if (hit) area.setSelectionRange(hit.start, hit.end);\n      return;\n    }\n    if (event.key === \"Enter\" && submitOnEnter && !event.shiftKey && !event.nativeEvent.isComposing) {\n      event.preventDefault();\n      if (live.current.text.trim()) onSubmit?.(live.current);\n    }\n  };\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      focus: () => areaRef.current?.focus(),\n      insert: (text) => typeText(text),\n      openSuggestions: (kind) => {\n        const area = areaRef.current;\n        if (!area) return;\n        const at = document.activeElement === area ? area.selectionStart : live.current.text.length;\n        const before = live.current.text[at - 1];\n        setDismissed(null);\n        typeText(\n          `${before && !/\\s/.test(before) ? \" \" : \"\"}${SYMBOL[kind]}`,\n          at,\n          document.activeElement === area ? area.selectionEnd : at,\n        );\n      },\n      clear: () => {\n        commit(EMPTY);\n        live.current = EMPTY;\n        setCaret(0);\n      },\n      get textarea() {\n        return areaRef.current;\n      },\n    }),\n    [commit, typeText],\n  );\n\n  /* Autosize: the backdrop mirrors the text, so its height is the content height. The field springs to it within the row limits. */\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  const [limits, setLimits] = useState({ min: 0, max: Infinity });\n  const measured = useRef(false);\n  useLayoutEffect(() => {\n    const backdrop = backdropRef.current;\n    if (!backdrop) return;\n    const style = getComputedStyle(backdrop);\n    const line = parseFloat(style.lineHeight) || 24;\n    const pad = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);\n    const next = { min: line * minRows + pad, max: line * Math.max(minRows, maxRows) + pad };\n    setLimits((current) => (current.min === next.min && current.max === next.max ? current : next));\n  }, [maxRows, minRows]);\n  useLayoutEffect(() => {\n    const backdrop = backdropRef.current;\n    if (!backdrop || !limits.min) return;\n    const fit = () => {\n      const target = Math.min(limits.max, Math.max(limits.min, backdrop.offsetHeight));\n      const current = height.get();\n      if (!measured.current || reduced || typeof current !== \"number\") {\n        animate(height, target, INSTANT);\n        measured.current = true;\n        return;\n      }\n      if (Math.abs(current - target) > 0.5) animate(height, target, GROW);\n    };\n    fit();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(fit);\n    observer.observe(backdrop);\n    return () => observer.disconnect();\n  }, [height, limits, reduced]);\n\n  /* The popover hangs from the trigger character, measured from a marker in the mirrored text. */\n  const [anchor, setAnchor] = useState<{ x: number; y: number; line: number; above: boolean } | null>(null);\n  const measureAnchor = useCallback(() => {\n    const marker = backdropRef.current?.querySelector<HTMLElement>(\"[data-anchor]\");\n    const root = rootRef.current,\n      field = fieldRef.current,\n      area = areaRef.current;\n    if (!marker || !root || !field || !area) {\n      setAnchor(null);\n      return;\n    }\n    const line = parseFloat(getComputedStyle(marker.parentElement ?? marker).lineHeight) || 24;\n    const x = Math.max(0, Math.min(field.offsetLeft + marker.offsetLeft - 10, root.offsetWidth - POPOVER_WIDTH));\n    const top = field.offsetTop + marker.offsetTop - area.scrollTop;\n    const box = root.getBoundingClientRect();\n    const roomBelow = window.innerHeight - (box.top + top + line);\n    const above = placement === \"top\" || (placement === \"auto\" && roomBelow < 300 && box.top + top > roomBelow);\n    const y = above ? top - 6 : top + line + 6;\n    setAnchor((current) =>\n      current && current.x === x && current.y === y && current.above === above && current.line === line\n        ? current\n        : { x, y, line, above },\n    );\n  }, [placement]);\n  useLayoutEffect(() => {\n    if (open) measureAnchor();\n  }, [measureAnchor, open, trigger?.start, value.text]);\n  // The popover mounts one commit after `open`, once the anchor is measured; the effects below wait for it.\n  const shown = open && !!anchor;\n\n  const onScroll = (event: UIEvent<HTMLTextAreaElement>) => {\n    const backdrop = backdropRef.current;\n    if (backdrop) backdrop.style.transform = `translateY(${-event.currentTarget.scrollTop}px)`;\n    measureAnchor();\n  };\n\n  /* The list's highlight glides between rows; the panel springs its height as matches come and go. */\n  const listRef = useRef<HTMLUListElement>(null);\n  const hy = useMotionValue(0),\n    hh = useMotionValue(0);\n  const placed = useRef(false);\n  const panelHeight = useMotionValue<number | \"auto\">(\"auto\");\n  useLayoutEffect(() => {\n    const row = listRef.current?.querySelector<HTMLElement>(`[data-index=\"${activeIndex}\"]`);\n    if (!row) return;\n    if (reduced || !placed.current) {\n      animate(hy, row.offsetTop, INSTANT);\n      animate(hh, row.offsetHeight, INSTANT);\n      placed.current = true;\n      return;\n    }\n    animate(hy, row.offsetTop, GLIDE);\n    animate(hh, row.offsetHeight, GLIDE);\n  }, [activeIndex, hh, hy, reduced, shown, suggestions]);\n  const bodyRef = useRef<HTMLDivElement>(null);\n  const panelSized = useRef(false);\n  useLayoutEffect(() => {\n    // The leaving list keeps its highlight while it fades; the next open places it fresh.\n    if (!shown) {\n      panelSized.current = false;\n      placed.current = false;\n      return;\n    }\n    const body = bodyRef.current;\n    if (!body) return;\n    const fit = () => {\n      const target = body.offsetHeight;\n      if (!panelSized.current || reduced) {\n        animate(panelHeight, target, INSTANT);\n        panelSized.current = true;\n        return;\n      }\n      animate(panelHeight, target, GROW);\n    };\n    fit();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(fit);\n    observer.observe(body);\n    return () => observer.disconnect();\n  }, [panelHeight, reduced, shown, trigger?.start]);\n\n  /* Mirror: the same text as the textarea, with tokens drawn in place and a marker at the open trigger. */\n  const mirror = useMemo(() => {\n    const parts: ReactNode[] = [];\n    const markAt = open && trigger ? trigger.start : -1;\n    let at = 0;\n    const pushText = (from: number, to: number) => {\n      if (markAt >= from && markAt < to) {\n        parts.push(value.text.slice(from, markAt), <span key=\"anchor\" data-anchor=\"\" />, value.text.slice(markAt, to));\n      } else parts.push(value.text.slice(from, to));\n    };\n    for (const mention of value.mentions) {\n      pushText(at, mention.start);\n      parts.push(\n        <span\n          key={`${mention.start}-${mention.id}`}\n          className={cn(\n            \"-mx-[3px] box-decoration-clone rounded-[6px] bg-accent-tint px-[3px] py-px text-accent-ink\",\n            mention.kind === \"channel\" && \"bg-[color-mix(in_oklab,var(--ink)_7%,transparent)] text-ink\",\n          )}\n          data-kind={mention.kind}\n        >\n          {value.text.slice(mention.start, mention.end)}\n        </span>,\n      );\n      at = mention.end;\n    }\n    pushText(at, value.text.length);\n    return parts;\n  }, [open, trigger, value.mentions, value.text]);\n\n  const optionId = (index: number) => `${uid}-option-${index}`;\n  const popIn = reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: anchor?.above ? 4 : -4 };\n  const countLabel = open\n    ? suggestions.length\n      ? `${suggestions.length} ${trigger?.kind === \"person\" ? (suggestions.length === 1 ? \"person\" : \"people\") : suggestions.length === 1 ? \"channel\" : \"channels\"}`\n      : \"No matches\"\n    : \"\";\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\"relative w-full font-body tracking-body text-ink data-disabled:opacity-55\", className)}\n      data-disabled={disabled || undefined}\n    >\n      {/* The field's height springs to its text. A token is tinted around its own glyphs; the negative margin keeps every character where the textarea put it. */}\n      <motion.div\n        ref={fieldRef}\n        className=\"relative box-content overflow-hidden rounded-control border border-line bg-surface [transition:border-color_var(--duration-fast)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard)] data-focused:border-line-strong\"\n        style={{ height }}\n        data-focused={focused || undefined}\n      >\n        <div\n          ref={backdropRef}\n          className={cn(textMetrics, \"pointer-events-none absolute top-0 left-0 text-ink will-change-transform\")}\n          aria-hidden=\"true\"\n        >\n          {mirror}\n          {\"\\u200b\"}\n        </div>\n        <textarea\n          ref={areaRef}\n          id={id}\n          name={name}\n          className={cn(\n            textMetrics,\n            \"absolute inset-0 block h-full resize-none overflow-y-auto bg-transparent text-transparent caret-ink outline-none [scrollbar-width:none] [-webkit-text-fill-color:transparent] selection:bg-[color-mix(in_oklab,var(--accent)_24%,transparent)] placeholder:text-ink-3 placeholder:opacity-100 placeholder:[-webkit-text-fill-color:var(--ink-3)] disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden\",\n          )}\n          value={value.text}\n          placeholder={placeholder}\n          disabled={disabled}\n          rows={minRows}\n          spellCheck\n          role=\"combobox\"\n          aria-label={ariaLabel}\n          aria-describedby={describedBy}\n          aria-autocomplete=\"list\"\n          aria-haspopup=\"listbox\"\n          aria-expanded={open}\n          aria-controls={open ? listId : undefined}\n          aria-activedescendant={open && suggestions.length ? optionId(activeIndex) : undefined}\n          onChange={onInput}\n          onSelect={onSelect}\n          onKeyDown={onKeyDown}\n          onScroll={onScroll}\n          onFocus={() => setFocused(true)}\n          onBlur={() => {\n            setFocused(false);\n            setCaret(null);\n          }}\n        />\n      </motion.div>\n      <span className=\"absolute size-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]\" aria-live=\"polite\">\n        {countLabel}\n      </span>\n      <AnimatePresence>\n        {open && anchor && (\n          <motion.div\n            key={trigger?.start}\n            className=\"absolute z-30 w-[272px] max-w-full overflow-hidden rounded-[18px] border border-line bg-surface shadow-floating data-above:-translate-y-full\"\n            data-above={anchor.above || undefined}\n            style={{\n              left: anchor.x,\n              top: anchor.y,\n              height: panelHeight,\n              transformOrigin: anchor.above ? \"14px 100%\" : \"14px 0\",\n            }}\n            initial={popIn}\n            animate={{\n              opacity: 1,\n              scale: 1,\n              y: 0,\n              transition: reduced\n                ? { duration: duration.fast }\n                : { ...spring.snappy, opacity: { duration: duration.fast, ease: enter } },\n            }}\n            exit={{ ...popIn, transition: { duration: duration.exit * 0.7, ease: standard } }}\n            onMouseDown={(event) => event.preventDefault()}\n          >\n            <div ref={bodyRef} className=\"p-1.5\">\n              {suggestions.length ? (\n                <ul\n                  ref={listRef}\n                  id={listId}\n                  // oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- the ARIA combobox pattern: the textarea owns focus and points into this listbox\n                  role=\"listbox\"\n                  aria-label={trigger?.kind === \"person\" ? \"People\" : \"Channels\"}\n                  className=\"relative m-0 grid list-none p-0\"\n                >\n                  <motion.li\n                    aria-hidden=\"true\"\n                    className=\"pointer-events-none absolute top-0 right-0 left-0 rounded-[12px] bg-[color-mix(in_oklab,var(--ink)_6.5%,transparent)]\"\n                    style={{ y: hy, height: hh }}\n                  />\n                  {suggestions.map((suggestion, index) => (\n                    // oxlint-disable-next-line jsx-a11y/click-events-have-key-events -- options are reached through aria-activedescendant; the textarea handles the keys\n                    <li\n                      key={`${suggestion.kind}-${suggestion.item.id}`}\n                      id={optionId(index)}\n                      // oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- <option> only works inside <select>\n                      role=\"option\"\n                      aria-selected={index === activeIndex}\n                      data-index={index}\n                      className=\"group/option relative flex min-w-0 cursor-pointer items-center gap-2.5 rounded-[12px] py-[7px] pr-2.5 pl-2 text-(length:--text-sm) [-webkit-tap-highlight-color:transparent]\"\n                      onPointerMove={() => {\n                        if (index !== activeIndex) setActive(index);\n                      }}\n                      onClick={() => choose(suggestion)}\n                    >\n                      {suggestion.kind === \"person\" ? (\n                        <span\n                          className=\"grid size-7 flex-none place-items-center overflow-hidden rounded-[50%] bg-hover text-(length:--text-xs) font-medium text-ink-2 [&_img]:size-full [&_img]:object-cover\"\n                          aria-hidden=\"true\"\n                        >\n                          {suggestion.item.avatar ? (\n                            // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic\n                            <img src={suggestion.item.avatar} alt=\"\" width={28} height={28} loading=\"lazy\" />\n                          ) : (\n                            initials(suggestion.item.name)\n                          )}\n                        </span>\n                      ) : (\n                        <span className=\"grid size-7 flex-none place-items-center text-ink-2\" aria-hidden=\"true\">\n                          <Hash size={16} strokeWidth={1.75} />\n                        </span>\n                      )}\n                      <span className=\"grid min-w-0 flex-1\">\n                        <span className=\"truncate font-medium text-ink-2 group-aria-selected/option:text-ink\">\n                          <Highlight text={suggestion.item.name} query={trigger?.query.trim() ?? \"\"} />\n                        </span>\n                        {suggestion.kind === \"person\"\n                          ? suggestion.item.role && <span className={optionMeta}>{suggestion.item.role}</span>\n                          : suggestion.item.description && (\n                              <span className={optionMeta}>{suggestion.item.description}</span>\n                            )}\n                      </span>\n                      {suggestion.kind === \"channel\" && suggestion.item.members !== undefined && (\n                        <span className=\"flex-none text-(length:--text-xs) text-ink-3 tabular-nums\">\n                          {suggestion.item.members}\n                        </span>\n                      )}\n                    </li>\n                  ))}\n                </ul>\n              ) : (\n                <p className=\"m-0 p-2.5 text-(length:--text-sm) text-ink-2\">\n                  No {trigger?.kind === \"person\" ? \"people\" : \"channels\"} match “{trigger?.query}”\n                </p>\n              )}\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n});\n\nexport default MentionInput;\n",
      "type": "registry:component",
      "target": "@components/iris/mention-input/mention-input.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/mention-input",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "MentionInput",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://irisui.dev/components/mention-input"
  }
}
