{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mention-input",
  "title": "Mention Input",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A text field where typing @ or # opens a list of people, channels or tags: highlighted mentions that know their id, async search, arrow-key choosing, whole-mention backspace and a combobox pattern for screen readers.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json",
    "https://ui.ballmac.com/r/direction.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/mention-input.tsx",
      "content": "// Ballmac UI: Mention Input. https://ui.ballmac.com/components/mention-input\n\"use client\"\n\nimport * as React from \"react\"\nimport { Loader2 } from \"lucide-react\"\n\nimport { useDirection } from \"@/lib/ballmac/direction\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\nimport { cn } from \"@/lib/utils\"\n\ntype MentionOption = {\n  id: string\n  /** The text put in the field, without the trigger: \"Maya Chen\". */\n  label: string\n  /** A second line in the list: an email or a role. */\n  description?: string\n  /** A small picture or icon drawn before the name in the list. */\n  media?: React.ReactNode\n  disabled?: boolean\n}\n\n/** One mention in the text: where it sits (`start` to `end`, counting the trigger) and what it refers to. */\ntype Mention = { id: string; trigger: string; label: string; start: number; end: number }\n\ntype MentionValue = { text: string; mentions: Mention[] }\n\ntype MentionSource = {\n  /** The character that opens the list: \"@\" for people, \"#\" for channels or tags. */\n  trigger: string\n  /** The choices, or a function that finds them for what was typed after the trigger. It may return a promise. */\n  options: MentionOption[] | ((query: string, signal: AbortSignal) => MentionOption[] | Promise<MentionOption[]>)\n  /** What the list is called for screen readers: \"People\". */\n  label?: string\n  /** Mention styling for this trigger. */\n  className?: string\n}\n\ntype MentionInputProps = Omit<React.ComponentProps<\"textarea\">, \"value\" | \"defaultValue\" | \"onChange\" | \"onSubmit\" | \"children\"> & {\n  /** The text and its mentions, when you hold them in state. Pair with `onValueChange`. */\n  value?: MentionValue\n  defaultValue?: MentionValue\n  onValueChange?: (value: MentionValue) => void\n  sources: MentionSource[]\n  /** Rows to show at first. The field grows with its text up to `maxRows`. */\n  minRows?: number\n  maxRows?: number\n  /** One line, like a search box: Enter does not add a line. */\n  singleLine?: boolean\n  invalid?: boolean\n  /** Called when Enter is pressed with no list open, in a single-line field or with Ctrl or Cmd held. */\n  onSubmit?: (value: MentionValue) => void\n}\n\ntype Active = { source: MentionSource; sourceIndex: number; query: string; start: number }\n\nconst EMPTY: MentionValue = { text: \"\", mentions: [] }\n\n// ------------------------------------------------------------------------------------------------- helpers\n\n/** `Hi @[Maya Chen](u1), see #[docs](c9)`: the text with each mention written as `trigger[label](id)`. */\nexport function mentionsToMarkup(value: MentionValue): string {\n  let out = \"\"\n  let at = 0\n  for (const m of [...value.mentions].sort((a, b) => a.start - b.start)) {\n    out += value.text.slice(at, m.start) + `${m.trigger}[${m.label}](${m.id})`\n    at = m.end\n  }\n  return out + value.text.slice(at)\n}\n\n/** The reverse of `mentionsToMarkup`: reads `@[Maya Chen](u1)` back into text and mentions. */\nexport function parseMentionMarkup(markup: string, triggers: string[] = [\"@\", \"#\"]): MentionValue {\n  const escaped = triggers.map((t) => t.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")).join(\"|\")\n  const re = new RegExp(`(${escaped})\\\\[([^\\\\]]+)\\\\]\\\\(([^)]+)\\\\)`, \"g\")\n  let text = \"\"\n  let at = 0\n  const mentions: Mention[] = []\n  for (const m of markup.matchAll(re)) {\n    text += markup.slice(at, m.index)\n    const start = text.length\n    text += `${m[1]}${m[2]}`\n    mentions.push({ id: m[3]!, trigger: m[1]!, label: m[2]!, start, end: text.length })\n    at = m.index! + m[0].length\n  }\n  return { text: text + markup.slice(at), mentions }\n}\n\n/**\n * Keeps mentions in place as the text changes. The part that changed is found by trimming what the old and new text share at each end; a mention\n * wholly before it stays, one wholly after it moves by the change in length, and one that was touched is dropped, so it becomes plain text.\n */\nexport function shiftMentions(prev: MentionValue, nextText: string): Mention[] {\n  const a = prev.text\n  const b = nextText\n  let head = 0\n  while (head < a.length && head < b.length && a[head] === b[head]) head++\n  let tail = 0\n  while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++\n  const removedEnd = a.length - tail\n  const delta = b.length - a.length\n  const out: Mention[] = []\n  for (const m of prev.mentions) {\n    if (m.end <= head) out.push(m)\n    else if (m.start >= removedEnd) out.push({ ...m, start: m.start + delta, end: m.end + delta })\n  }\n  return out\n}\n\n/** The trigger and query the caret is in, if any: `@ma` right after a space or the start of the text. */\nfunction findActive(text: string, caret: number, sources: MentionSource[], mentions: Mention[]): Active | null {\n  const before = text.slice(0, caret)\n  let best: Active | null = null\n  sources.forEach((source, sourceIndex) => {\n    const at = before.lastIndexOf(source.trigger)\n    if (at < 0) return\n    const prev = at === 0 ? \"\" : before[at - 1]!\n    if (prev && !/[\\s([{\"'“‘]/.test(prev)) return\n    const query = before.slice(at + source.trigger.length)\n    if (/\\s{2,}|\\n/.test(query) || query.length > 40) return\n    // a space ends a one-word query unless the source's labels have spaces; allow one space so \"Maya Ch\" keeps matching\n    if ((query.match(/\\s/g) ?? []).length > 1) return\n    if (mentions.some((m) => at >= m.start && at < m.end)) return\n    if (!best || at > best.start) best = { source, sourceIndex, query, start: at }\n  })\n  return best\n}\n\n// ------------------------------------------------------------------------------------------------- component\n\n/**\n * A text field where typing @ or # opens a list of people, channels or tags. The chosen one is written into the text and highlighted; the value\n * keeps where each mention is, so you know who was mentioned. Arrow keys choose, Enter or Tab inserts, Escape closes, Backspace removes a whole mention.\n */\nfunction MentionInput({\n  value,\n  defaultValue,\n  onValueChange,\n  sources,\n  minRows = 3,\n  maxRows = 8,\n  singleLine = false,\n  invalid = false,\n  onSubmit,\n  disabled,\n  className,\n  placeholder,\n  onKeyDown,\n  onSelect,\n  onBlur,\n  ...props\n}: MentionInputProps) {\n  const msg = useMessages()\n  const dir = useDirection()\n  const id = React.useId()\n  const [inner, setInner] = React.useState<MentionValue>(defaultValue ?? EMPTY)\n  const current = value ?? inner\n  const [caret, setCaret] = React.useState(0)\n  const [open, setOpen] = React.useState(true)\n  const [index, setIndex] = React.useState(0)\n  const [results, setResults] = React.useState<MentionOption[]>([])\n  const [loading, setLoading] = React.useState(false)\n  const [pos, setPos] = React.useState<{ top: number; start: number } | null>(null)\n  const area = React.useRef<HTMLTextAreaElement>(null)\n  const layer = React.useRef<HTMLDivElement>(null)\n  const marker = React.useRef<HTMLSpanElement>(null)\n\n  const active = React.useMemo(() => (open && !disabled ? findActive(current.text, caret, sources, current.mentions) : null), [open, disabled, current.text, current.mentions, caret, sources])\n  const listId = `${id}-list`\n  // where the caret goes once the new text is in the field: set in the same commit, so typing straight after cannot land in the wrong place\n  const pendingCaret = React.useRef<number | null>(null)\n  React.useLayoutEffect(() => {\n    const at = pendingCaret.current\n    if (at === null || !area.current) return\n    pendingCaret.current = null\n    area.current.focus()\n    area.current.setSelectionRange(at, at)\n    setCaret(at)\n  }, [current.text])\n\n  const commit = (next: MentionValue) => {\n    if (value === undefined) setInner(next)\n    onValueChange?.(next)\n  }\n\n  // ----- find the options for what was typed\n  const sourcesRef = React.useRef(sources)\n  sourcesRef.current = sources\n  React.useEffect(() => {\n    if (!active) {\n      setResults([])\n      setLoading(false)\n      return\n    }\n    const source = sourcesRef.current[active.sourceIndex]!\n    const q = active.query.trim().toLowerCase()\n    if (Array.isArray(source.options)) {\n      setResults(source.options.filter((o) => !q || o.label.toLowerCase().includes(q) || o.description?.toLowerCase().includes(q)))\n      setLoading(false)\n      return\n    }\n    const controller = new AbortController()\n    setLoading(true)\n    Promise.resolve((source.options as (q: string, s: AbortSignal) => MentionOption[] | Promise<MentionOption[]>)(active.query, controller.signal))\n      .then((found) => {\n        if (controller.signal.aborted) return\n        setResults(found)\n        setLoading(false)\n      })\n      .catch(() => {\n        if (controller.signal.aborted) return\n        setResults([])\n        setLoading(false)\n      })\n    return () => controller.abort()\n  }, [active?.sourceIndex, active?.query, active?.start]) // eslint-disable-line react-hooks/exhaustive-deps\n  React.useEffect(() => setIndex(0), [active?.query, active?.start, active?.sourceIndex])\n\n  // ----- where the list goes: under the caret, read from a marker in the mirror layer\n  React.useLayoutEffect(() => {\n    if (!active || !marker.current || !layer.current) return setPos(null)\n    const top = marker.current.offsetTop - (area.current?.scrollTop ?? 0) + marker.current.offsetHeight + 6\n    setPos({ top, start: marker.current.offsetLeft })\n  }, [active, current.text, results.length])\n\n  // ----- keep the field the height of its text\n  React.useLayoutEffect(() => {\n    const el = area.current\n    if (!el || singleLine) return\n    const style = getComputedStyle(el)\n    const line = parseFloat(style.lineHeight) || 20\n    const pad = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)\n    el.style.height = \"auto\"\n    el.style.height = `${Math.min(Math.max(el.scrollHeight, line * minRows + pad), line * maxRows + pad)}px`\n    if (layer.current) layer.current.style.height = el.style.height\n  }, [current.text, minRows, maxRows, singleLine])\n\n  const choose = (option: MentionOption) => {\n    if (!active || option.disabled) return\n    const trigger = active.source.trigger\n    const inserted = `${trigger}${option.label}`\n    const after = current.text.slice(caret)\n    // a space follows the mention, unless one is already there\n    const text = current.text.slice(0, active.start) + inserted + (after.startsWith(\" \") ? \"\" : \" \") + after\n    const shifted = shiftMentions(current, text)\n    const mention: Mention = { id: option.id, trigger, label: option.label, start: active.start, end: active.start + inserted.length }\n    const next: MentionValue = { text, mentions: [...shifted.filter((m) => m.end <= mention.start || m.start >= mention.end), mention].sort((a, b) => a.start - b.start) }\n    pendingCaret.current = active.start + inserted.length + 1\n    commit(next)\n    setOpen(true)\n  }\n\n  const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n    const text = singleLine ? e.target.value.replace(/\\n/g, \" \") : e.target.value\n    commit({ text, mentions: shiftMentions(current, text) })\n    setCaret(e.target.selectionStart)\n    setOpen(true)\n  }\n\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    onKeyDown?.(e)\n    if (e.defaultPrevented) return\n    if (active && (results.length || loading)) {\n      const enabled = results.map((r, i) => (r.disabled ? -1 : i)).filter((i) => i >= 0)\n      if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n        e.preventDefault()\n        if (!enabled.length) return\n        const at = enabled.indexOf(index)\n        const next = e.key === \"ArrowDown\" ? enabled[(at + 1) % enabled.length]! : enabled[(at - 1 + enabled.length) % enabled.length]!\n        setIndex(next)\n        return\n      }\n      if ((e.key === \"Enter\" || e.key === \"Tab\") && results[index] && !results[index]!.disabled && !e.shiftKey) {\n        e.preventDefault()\n        choose(results[index]!)\n        return\n      }\n    }\n    if (active && e.key === \"Escape\") {\n      e.preventDefault()\n      setOpen(false)\n      return\n    }\n    // Backspace right after a mention removes all of it, as one unit\n    if (e.key === \"Backspace\" && !e.ctrlKey && !e.metaKey && !e.altKey) {\n      const el = e.currentTarget\n      if (el.selectionStart === el.selectionEnd) {\n        const m = current.mentions.find((x) => x.end === el.selectionStart)\n        if (m) {\n          e.preventDefault()\n          const text = current.text.slice(0, m.start) + current.text.slice(m.end)\n          pendingCaret.current = m.start\n          commit({ text, mentions: shiftMentions(current, text) })\n          return\n        }\n      }\n    }\n    if (e.key === \"Enter\" && !e.nativeEvent.isComposing) {\n      if (singleLine || e.ctrlKey || e.metaKey) {\n        if (onSubmit) {\n          e.preventDefault()\n          onSubmit(current)\n        } else if (singleLine) e.preventDefault()\n      }\n    }\n  }\n\n  // the mirror layer: the same text, invisible, with the mentions highlighted behind the real text\n  const pieces: React.ReactNode[] = []\n  {\n    let at = 0\n    const sorted = [...current.mentions].sort((a, b) => a.start - b.start)\n    const caretIn = active ? caret : -1\n    const withMarker = (from: number, to: number, key: string) => {\n      if (caretIn >= from && caretIn <= to) {\n        pieces.push(<span key={key}>{current.text.slice(from, caretIn)}</span>, <span key={`${key}-m`} ref={marker} aria-hidden=\"true\">{\"​\"}</span>, <span key={`${key}-r`}>{current.text.slice(caretIn, to)}</span>)\n      } else pieces.push(<span key={key}>{current.text.slice(from, to)}</span>)\n    }\n    for (const m of sorted) {\n      if (m.start > at) withMarker(at, m.start, `t${at}`)\n      const source = sources.find((s) => s.trigger === m.trigger)\n      pieces.push(\n        <mark key={`m${m.start}`} className={cn(\"rounded bg-primary/15 text-transparent [box-decoration-break:clone]\", source?.className)}>\n          {current.text.slice(m.start, m.end)}\n        </mark>\n      )\n      at = m.end\n    }\n    if (at <= current.text.length) withMarker(at, current.text.length, `t${at}`)\n    // a trailing newline would not take up a line in the layer without one more character\n    pieces.push(<span key=\"end\">{\"​\"}</span>)\n  }\n\n  const shown = !!active && (results.length > 0 || loading || (active.query.length > 0 && !loading))\n  const sourceLabel = active?.source.label ?? msg(\"mention-input.suggestions\", \"Suggestions\")\n\n  const text = cn(\n    \"block w-full whitespace-pre-wrap break-words px-3 py-2 text-sm leading-6 [overflow-wrap:anywhere]\",\n    singleLine && \"whitespace-pre\"\n  )\n\n  return (\n    <div data-slot=\"mention-input\" className={cn(\"relative min-w-0\", className)}>\n      <div\n        className={cn(\n          \"relative rounded-md border border-input bg-background shadow-xs transition-[border-color,box-shadow] duration-150 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 motion-reduce:transition-none\",\n          invalid && \"border-destructive focus-within:border-destructive focus-within:ring-destructive/20\",\n          disabled && \"pointer-events-none opacity-50\"\n        )}\n      >\n        <div ref={layer} aria-hidden=\"true\" data-slot=\"mention-input-layer\" dir={dir} className={cn(text, \"pointer-events-none absolute inset-0 overflow-hidden text-transparent\")}>\n          {pieces}\n        </div>\n        <textarea\n          ref={area}\n          role=\"combobox\"\n          aria-autocomplete=\"list\"\n          aria-expanded={shown}\n          aria-controls={shown ? listId : undefined}\n          aria-activedescendant={shown && results[index] ? `${id}-opt-${index}` : undefined}\n          aria-haspopup=\"listbox\"\n          aria-invalid={invalid || undefined}\n          data-slot=\"mention-input-field\"\n          dir={dir}\n          rows={singleLine ? 1 : minRows}\n          value={current.text}\n          disabled={disabled}\n          placeholder={placeholder}\n          onChange={onChange}\n          onKeyDown={handleKeyDown}\n          onSelect={(e) => {\n            setCaret(e.currentTarget.selectionStart)\n            onSelect?.(e)\n          }}\n          onBlur={(e) => {\n            setOpen(false)\n            onBlur?.(e)\n          }}\n          onFocus={() => setOpen(true)}\n          onScroll={(e) => layer.current && (layer.current.scrollTop = e.currentTarget.scrollTop)}\n          className={cn(text, \"relative z-10 resize-none bg-transparent outline-none placeholder:text-muted-foreground\", singleLine && \"overflow-x-auto\")}\n          {...props}\n        />\n      </div>\n\n      {shown ? (\n        <div\n          id={listId}\n          role=\"listbox\"\n          aria-label={sourceLabel}\n          data-slot=\"mention-input-list\"\n          style={{ top: pos?.top ?? \"100%\", insetInlineStart: Math.min(pos?.start ?? 0, 160) }}\n          className=\"absolute z-50 max-h-60 w-64 overflow-y-auto rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg\"\n          onMouseDown={(e) => e.preventDefault()}\n        >\n          {results.map((o, i) => (\n            <div\n              key={o.id}\n              id={`${id}-opt-${i}`}\n              role=\"option\"\n              aria-selected={i === index}\n              aria-disabled={o.disabled || undefined}\n              onMouseEnter={() => !o.disabled && setIndex(i)}\n              onClick={() => choose(o)}\n              className={cn(\"flex cursor-default items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm\", i === index && \"bg-accent text-accent-foreground\", o.disabled && \"opacity-50\")}\n            >\n              {o.media ? <span className=\"flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-xs font-medium\">{o.media}</span> : null}\n              <span className=\"min-w-0\">\n                <span className=\"block truncate font-medium\">{o.label}</span>\n                {o.description ? <span className=\"block truncate text-xs text-muted-foreground\">{o.description}</span> : null}\n              </span>\n            </div>\n          ))}\n          {loading ? (\n            <div role=\"status\" className=\"flex items-center gap-2 px-2 py-2 text-sm text-muted-foreground\">\n              <Loader2 className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />\n              {msg(\"mention-input.searching\", \"Searching\")}\n            </div>\n          ) : null}\n          {!loading && !results.length ? <div className=\"px-2 py-2 text-sm text-muted-foreground\">{msg(\"mention-input.noMatches\", \"No matches for “{query}”\", { query: active?.query ?? \"\" })}</div> : null}\n        </div>\n      ) : null}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {shown ? (loading ? msg(\"mention-input.searching\", \"Searching\") : results.length ? msg(\"mention-input.count\", { one: \"{count} suggestion. {name} is highlighted.\", other: \"{count} suggestions. {name} is highlighted.\" }, { count: results.length, name: results[index]?.label ?? \"\" }) : msg(\"mention-input.noMatches\", \"No matches for “{query}”\", { query: active?.query ?? \"\" })) : \"\"}\n      </span>\n    </div>\n  )\n}\n\nexport { MentionInput, type Mention, type MentionInputProps, type MentionOption, type MentionSource, type MentionValue }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/mention-input.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "mention",
      "at-mention",
      "autocomplete",
      "tags",
      "textarea",
      "comments",
      "combobox"
    ],
    "version": "1.0.0",
    "updated": "2026-10-19",
    "tests": true,
    "ai": {
      "summary": "Hold { text, mentions } in state: <MentionInput value onValueChange sources={[{ trigger: \"@\", options }]} />. options is an array or a function (query, signal) returning options or a promise. mentionsToMarkup and parseMentionMarkup convert to a string.",
      "whenToUse": [
        "Comments, chat and ticket replies where people are mentioned",
        "Tag or channel pickers inside a sentence",
        "Anywhere you need to know which entity a name in the text referred to"
      ],
      "whenNotToUse": [
        "Formatting text as well as mentions; use rich-text-editor",
        "Choosing one value from a list; use combobox",
        "Plain multi-line text; use textarea"
      ],
      "composesWith": [
        "textarea",
        "avatar",
        "rich-text-editor"
      ],
      "a11y": [
        {
          "keys": "Type @ or #",
          "action": "Opens the list under the caret; the field is a combobox with aria-activedescendant"
        },
        {
          "keys": "Arrow Up / Arrow Down",
          "action": "Choose a suggestion; disabled ones are skipped"
        },
        {
          "keys": "Enter / Tab",
          "action": "Insert the highlighted suggestion"
        },
        {
          "keys": "Escape",
          "action": "Close the list without inserting"
        },
        {
          "keys": "Backspace after a mention",
          "action": "Removes the whole mention"
        },
        {
          "keys": "Screen readers",
          "action": "The count of suggestions and the highlighted one are announced; Searching and No matches are announced too"
        }
      ],
      "customization": [
        "sources: trigger, options, label, className for each kind of mention",
        "minRows and maxRows, or singleLine",
        "invalid, disabled and the usual textarea props",
        "onSubmit for Enter in a single line or Ctrl/Cmd+Enter"
      ]
    },
    "statesNone": "Nothing loads on its own: an async source shows a Searching row in the list while it answers, and a No matches row when it finds nothing.",
    "examples": [
      "mention-input-demo",
      "mention-input-async",
      "mention-input-single",
      "mention-input-invalid"
    ],
    "url": "https://ui.ballmac.com/components/mention-input"
  },
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}