{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mention-input",
  "type": "registry:ui",
  "title": "Mention Input",
  "description": "A textarea where @ suggests people at the caret and names become tokens.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "@floating-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/mention-input.tsx",
      "content": "\"use client\";\nimport { autoUpdate, flip, FloatingPortal, offset, shift, size, useFloating } from \"@floating-ui/react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\nexport type MentionUser = {\n  id: string;\n  name: string;\n  /** A second line: a role, a team, an email. */\n  detail?: string;\n  avatar?: string;\n};\n\nexport type Mention = { id: string; label: string; start: number; end: number };\n\nexport type MentionInputProps = Omit<React.ComponentProps<\"textarea\">, \"value\" | \"defaultValue\" | \"onChange\" | \"children\"> & {\n  /** People who can be mentioned. Filtered by name and detail as you type. */\n  users: MentionUser[];\n  value?: string;\n  defaultValue?: string;\n  /** Mentions that already exist in defaultValue, by character range. */\n  defaultMentions?: Mention[];\n  /** The plain text and every mention in it, with ranges that stay correct as the text is edited. */\n  onValueChange?: (text: string, mentions: Mention[]) => void;\n  /** Most suggestions shown at once. */\n  limit?: number;\n  /** Rows before the field starts scrolling. */\n  maxRows?: number;\n  label?: React.ReactNode;\n  /** Where the suggestion list portals to. Defaults to document.body. */\n  portalRoot?: HTMLElement | null;\n};\n\n/** Turn text + mentions into your storage format, e.g. \"Thanks <@u_42>\". */\nexport function mentionsToMarkup(text: string, mentions: Mention[], format: (m: Mention) => string = (m) => `<@${m.id}>`) {\n  let out = \"\";\n  let at = 0;\n  for (const m of [...mentions].sort((a, b) => a.start - b.start)) {\n    out += text.slice(at, m.start) + format(m);\n    at = m.end;\n  }\n  return out + text.slice(at);\n}\n\nconst initials = (name: string) =>\n  name\n    .split(/\\s+/)\n    .slice(0, 2)\n    .map((w) => w[0]?.toUpperCase() ?? \"\")\n    .join(\"\");\n\n/** Finds an \"@query\" that ends at the caret and isn't inside an existing mention. */\nfunction findTrigger(text: string, caret: number, mentions: Mention[]) {\n  const before = text.slice(0, caret);\n  const at = before.lastIndexOf(\"@\");\n  if (at < 0) return null;\n  if (at > 0 && !/[\\s([{\"'“]/.test(before[at - 1])) return null;\n  const query = before.slice(at + 1);\n  // One space is allowed so \"@maya ch\" still narrows to Maya Chen.\n  if (!/^[^\\s@]*( [^\\s@]*)?$/.test(query)) return null;\n  if (mentions.some((m) => at >= m.start && at < m.end)) return null;\n  return { at, query };\n}\n\nexport function MentionInput({\n  users,\n  value: valueProp,\n  defaultValue = \"\",\n  defaultMentions = [],\n  onValueChange,\n  limit = 6,\n  maxRows = 8,\n  label,\n  portalRoot,\n  className,\n  placeholder,\n  disabled,\n  readOnly,\n  id: idProp,\n  onKeyDown,\n  onBlur,\n  rows = 3,\n  ...rest\n}: MentionInputProps) {\n  const reduce = useReducedMotion();\n  const autoId = useId();\n  const id = idProp ?? `mention-${autoId}`;\n  const listId = `${id}-list`;\n  const [inner, setInner] = useState(defaultValue);\n  const text = valueProp ?? inner;\n  const [mentions, setMentions] = useState<Mention[]>(defaultMentions);\n  const [trigger, setTrigger] = useState<{ at: number; query: string } | null>(null);\n  const [active, setActive] = useState(0);\n  const [dismissedAt, setDismissedAt] = useState<number | null>(null);\n  const [fresh, setFresh] = useState<string | null>(null);\n  const areaRef = useRef<HTMLTextAreaElement | null>(null);\n  const mirrorRef = useRef<HTMLDivElement | null>(null);\n  const pendingCaret = useRef<number | null>(null);\n\n  const matches = useMemo(() => {\n    if (!trigger) return [];\n    const q = trigger.query.toLocaleLowerCase();\n    const scored = users\n      .map((u) => {\n        const name = u.name.toLocaleLowerCase();\n        const detail = (u.detail ?? \"\").toLocaleLowerCase();\n        const words = name.split(/\\s+/);\n        const score = !q ? 1 : name.startsWith(q) ? 4 : words.some((w) => w.startsWith(q)) ? 3 : name.includes(q) ? 2 : detail.includes(q) ? 1 : 0;\n        return { u, score };\n      })\n      .filter((x) => x.score > 0)\n      .sort((a, b) => b.score - a.score);\n    return scored.slice(0, limit).map((x) => x.u);\n  }, [trigger, users, limit]);\n\n  const open = Boolean(trigger) && dismissedAt !== trigger?.at && !disabled && !readOnly && (matches.length > 0 || (trigger?.query.length ?? 0) > 0);\n  const activeIndex = Math.min(active, Math.max(0, matches.length - 1));\n\n  const {\n    refs: { setFloating, setPositionReference },\n    floatingStyles,\n  } = useFloating({\n    open,\n    placement: \"bottom-start\",\n    strategy: \"fixed\",\n    whileElementsMounted: autoUpdate,\n    middleware: [\n      offset({ mainAxis: 6, crossAxis: -8 }),\n      flip({ padding: 8 }),\n      shift({ padding: 8 }),\n      size({ padding: 8, apply: ({ availableHeight, elements }) => elements.floating.style.setProperty(\"--avail\", `${Math.min(280, availableHeight)}px`) }),\n    ],\n  });\n\n  const emit = (nextText: string, nextMentions: Mention[]) => {\n    if (valueProp === undefined) setInner(nextText);\n    setMentions(nextMentions);\n    onValueChange?.(nextText, nextMentions);\n  };\n\n  const refreshTrigger = (t: string, caret: number, ms: Mention[]) => {\n    const found = findTrigger(t, caret, ms);\n    setTrigger(found);\n    if (!found || found.at !== trigger?.at) setActive(0);\n    if (!found) setDismissedAt(null);\n  };\n\n  const insert = (user: MentionUser) => {\n    if (!trigger) return;\n    const area = areaRef.current;\n    const caret = area?.selectionStart ?? trigger.at + trigger.query.length + 1;\n    const token = `@${user.name}`;\n    const after = text.slice(caret);\n    const spacer = after.startsWith(\" \") ? \"\" : \" \";\n    const nextText = text.slice(0, trigger.at) + token + spacer + after;\n    const delta = token.length + spacer.length - (caret - trigger.at);\n    const nextMentions = [\n      ...mentions.map((m) => (m.start >= caret ? { ...m, start: m.start + delta, end: m.end + delta } : m)),\n      { id: user.id, label: token, start: trigger.at, end: trigger.at + token.length },\n    ].sort((a, b) => a.start - b.start);\n    pendingCaret.current = trigger.at + token.length + 1;\n    emit(nextText, nextMentions);\n    setTrigger(null);\n    setFresh(`${user.id}:${trigger.at}`);\n  };\n\n  // The token that just landed glows for a moment, then settles into the text.\n  useEffect(() => {\n    if (!fresh) return;\n    const t = window.setTimeout(() => setFresh(null), 700);\n    return () => window.clearTimeout(t);\n  }, [fresh]);\n\n  // Grow with the text up to maxRows; restore the caret after programmatic edits.\n  useLayoutEffect(() => {\n    const area = areaRef.current;\n    if (!area) return;\n    const lh = parseFloat(getComputedStyle(area).lineHeight) || 20;\n    const pad = parseFloat(getComputedStyle(area).paddingTop) * 2;\n    area.style.height = \"auto\";\n    area.style.height = `${Math.min(Math.max(area.scrollHeight, lh * rows + pad), lh * maxRows + pad)}px`;\n    if (mirrorRef.current) mirrorRef.current.scrollTop = area.scrollTop;\n    const caret = pendingCaret.current;\n    if (caret !== null && document.activeElement === area) {\n      pendingCaret.current = null;\n      area.setSelectionRange(caret, caret);\n    }\n  }, [text, rows, maxRows]);\n\n  const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n    const next = e.target.value;\n    const caret = e.target.selectionStart ?? next.length;\n    // Diff old and new to find the edited span, then carry every mention across it:\n    // before it stays, after it shifts, touching it turns back into plain text.\n    let p = 0;\n    while (p < text.length && p < next.length && text[p] === next[p]) p++;\n    let s = 0;\n    while (s < text.length - p && s < next.length - p && text[text.length - 1 - s] === next[next.length - 1 - s]) s++;\n    const oldEnd = text.length - s;\n    const delta = next.length - text.length;\n    const nextMentions = mentions.flatMap((m) =>\n      m.end <= p ? [m] : m.start >= oldEnd ? [{ ...m, start: m.start + delta, end: m.end + delta }] : [],\n    );\n    emit(next, nextMentions);\n    refreshTrigger(next, caret, nextMentions);\n  };\n\n  const segments = useMemo(() => {\n    const out: { text: string; mention?: Mention; anchor?: boolean }[] = [];\n    let at = 0;\n    const anchorAt = open && trigger ? trigger.at : -1;\n    const push = (t: string) => {\n      if (anchorAt >= at && anchorAt < at + t.length) {\n        const i = anchorAt - at;\n        if (i > 0) out.push({ text: t.slice(0, i) });\n        out.push({ text: \"@\", anchor: true });\n        if (t.length > i + 1) out.push({ text: t.slice(i + 1) });\n      } else if (t) out.push({ text: t });\n      at += t.length;\n    };\n    for (const m of mentions) {\n      push(text.slice(at, m.start));\n      out.push({ text: text.slice(m.start, m.end), mention: m });\n      at = m.end;\n    }\n    push(text.slice(at));\n    return out;\n  }, [text, mentions, open, trigger]);\n\n  const setAnchor = useCallback((el: HTMLSpanElement | null) => setPositionReference(el), [setPositionReference]);\n  const shared = \"whitespace-pre-wrap break-words [scrollbar-gutter:stable] px-3 py-2 text-base leading-6 sm:text-[13px] sm:leading-5\";\n\n  return (\n    <div className={cn(\"flex w-full min-w-0 flex-col gap-1.5\", className)} data-disabled={disabled || undefined}>\n      {label && (\n        <label htmlFor={id} className=\"w-fit text-[12.5px] font-medium leading-4 text-fg\">\n          {label}\n        </label>\n      )}\n      <div\n        className={cn(\n          \"relative rounded-lg border border-line-2 bg-raised\",\n          \"transition-[border-color,box-shadow] duration-150 ease-out\",\n          \"hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 focus-within:hover:border-fg-3\",\n          disabled && \"pointer-events-none opacity-50\",\n          readOnly && \"bg-hover\",\n        )}\n      >\n        {/* The visible text, drawn behind a transparent textarea so mentions can be styled as tokens. */}\n        <div ref={mirrorRef} aria-hidden className={cn(\"pointer-events-none absolute inset-0 overflow-hidden text-fg\", shared)}>\n          {segments.map((seg, i) =>\n            seg.mention ? (\n              <span\n                key={i}\n                data-fresh={fresh === `${seg.mention.id}:${seg.mention.start}` || undefined}\n                className={cn(\n                  // Background and a same-color ring give the token room without changing its width.\n                  \"rounded-[4px] bg-fg/10 text-fg shadow-[0_0_0_2px_color-mix(in_oklab,var(--fg)_10%,transparent)] [box-decoration-break:clone]\",\n                  \"transition-[background-color,box-shadow] duration-500 ease-out\",\n                  \"data-[fresh]:bg-fg/25 data-[fresh]:shadow-[0_0_0_2px_color-mix(in_oklab,var(--fg)_25%,transparent)] data-[fresh]:duration-75\",\n                )}\n              >\n                {seg.text}\n              </span>\n            ) : seg.anchor ? (\n              <span key={i} ref={setAnchor}>\n                @\n              </span>\n            ) : (\n              <span key={i}>{seg.text}</span>\n            ),\n          )}\n          {/* A trailing newline needs a character after it to take up a line. */}\n          {\"​\"}\n        </div>\n\n        <textarea\n          {...rest}\n          ref={areaRef}\n          id={id}\n          rows={rows}\n          value={text}\n          placeholder={placeholder}\n          disabled={disabled}\n          readOnly={readOnly}\n          role=\"combobox\"\n          aria-autocomplete=\"list\"\n          aria-expanded={open}\n          aria-controls={open ? listId : undefined}\n          aria-activedescendant={open && matches[activeIndex] ? `${listId}-${matches[activeIndex].id}` : undefined}\n          onChange={onChange}\n          onSelect={(e) => {\n            const el = e.currentTarget;\n            if (el.selectionStart === el.selectionEnd) refreshTrigger(text, el.selectionStart, mentions);\n            else setTrigger(null);\n          }}\n          onScroll={(e) => {\n            if (mirrorRef.current) mirrorRef.current.scrollTop = e.currentTarget.scrollTop;\n          }}\n          onBlur={(e) => {\n            setTrigger(null);\n            onBlur?.(e);\n          }}\n          onKeyDown={(e) => {\n            onKeyDown?.(e);\n            if (e.defaultPrevented || e.nativeEvent.isComposing) return;\n            if (open && matches.length) {\n              if (e.key === \"ArrowDown\" || (e.key === \"n\" && e.ctrlKey)) {\n                e.preventDefault();\n                setActive((activeIndex + 1) % matches.length);\n                return;\n              }\n              if (e.key === \"ArrowUp\" || (e.key === \"p\" && e.ctrlKey)) {\n                e.preventDefault();\n                setActive((activeIndex - 1 + matches.length) % matches.length);\n                return;\n              }\n              if (e.key === \"Enter\" || e.key === \"Tab\") {\n                e.preventDefault();\n                insert(matches[activeIndex]);\n                return;\n              }\n            }\n            if (open && e.key === \"Escape\") {\n              e.preventDefault();\n              setDismissedAt(trigger?.at ?? null);\n              return;\n            }\n            const el = e.currentTarget;\n            if (el.selectionStart !== el.selectionEnd) return;\n            const caret = el.selectionStart;\n            // A mention goes as one piece: Backspace at its end or Delete at its start removes all of it.\n            const hit =\n              e.key === \"Backspace\"\n                ? mentions.find((m) => caret > m.start && caret <= m.end)\n                : e.key === \"Delete\"\n                  ? mentions.find((m) => caret >= m.start && caret < m.end)\n                  : undefined;\n            if (hit) {\n              e.preventDefault();\n              // Take one neighboring space with it, so removing a name doesn't leave a double gap.\n              const end = text[hit.end] === \" \" && (hit.start === 0 || /\\s/.test(text[hit.start - 1])) ? hit.end + 1 : hit.end;\n              const nextText = text.slice(0, hit.start) + text.slice(end);\n              const len = end - hit.start;\n              const nextMentions = mentions.filter((m) => m !== hit).map((m) => (m.start >= end ? { ...m, start: m.start - len, end: m.end - len } : m));\n              pendingCaret.current = hit.start;\n              emit(nextText, nextMentions);\n            }\n          }}\n          className={cn(\n            \"relative block w-full resize-none bg-transparent text-transparent caret-fg outline-none placeholder:text-fg-4\",\n            \"selection:bg-fg/20! selection:text-transparent!\",\n            \"[scrollbar-width:thin]\",\n            shared,\n          )}\n        />\n      </div>\n\n      {open && (\n        <FloatingPortal root={portalRoot ?? undefined}>\n          <div\n            ref={setFloating}\n            style={floatingStyles}\n            className=\"z-(--z-popover) w-[min(18rem,calc(100vw-16px))]\"\n          >\n            {/* It opens from typing, dozens of times a day, so it only fades in: 100ms, no travel to wait for. */}\n            <motion.div\n              id={listId}\n              role=\"listbox\"\n              aria-label=\"People\"\n              initial={{ opacity: 0, y: reduce ? 0 : -2 }}\n              animate={{ opacity: 1, y: 0 }}\n              transition={{ duration: 0.1, ease: ease.out }}\n              // Keeps focus (and the caret) in the textarea while choosing with the pointer.\n              onMouseDown={(e) => e.preventDefault()}\n              className=\"max-h-(--avail) overflow-y-auto overscroll-contain rounded-xl border border-line-2 bg-raised p-1 shadow-pop\"\n            >\n              {matches.length === 0 ? (\n                <p className=\"px-2.5 py-2 text-[12.5px] text-fg-3\">\n                  No one matches “{trigger?.query}”\n                </p>\n              ) : (\n                matches.map((u, i) => (\n                  <div\n                    key={u.id}\n                    id={`${listId}-${u.id}`}\n                    role=\"option\"\n                    aria-selected={i === activeIndex}\n                    onMouseMove={() => i !== activeIndex && setActive(i)}\n                    onClick={() => insert(u)}\n                    className=\"flex h-10 cursor-default select-none items-center gap-2.5 rounded-lg px-2 aria-selected:bg-fg/8\"\n                  >\n                    <span aria-hidden className=\"grid size-6 shrink-0 place-items-center overflow-hidden rounded-full bg-fg/10 text-[10px] font-medium text-fg-2\">\n                      {u.avatar ? <span className=\"size-full bg-cover bg-center\" style={{ backgroundImage: `url(${u.avatar})` }} /> : initials(u.name)}\n                    </span>\n                    <span className=\"flex min-w-0 flex-col\">\n                      <span className=\"truncate text-[13px] leading-4 text-fg\">\n                        <Highlight text={u.name} query={trigger?.query ?? \"\"} />\n                      </span>\n                      {u.detail && <span className=\"truncate text-[11.5px] leading-4 text-fg-3\">{u.detail}</span>}\n                    </span>\n                  </div>\n                ))\n              )}\n            </motion.div>\n          </div>\n        </FloatingPortal>\n      )}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {open ? (matches.length ? `${matches.length} ${matches.length === 1 ? \"person\" : \"people\"} found` : \"No matches\") : \"\"}\n      </span>\n    </div>\n  );\n}\n\n/** The part of the name that matched reads a step brighter. */\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  const i = query ? text.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) : -1;\n  if (i < 0) return <>{text}</>;\n  return (\n    <>\n      {text.slice(0, i)}\n      <span className=\"font-medium\">{text.slice(i, i + query.length)}</span>\n      {text.slice(i + query.length)}\n    </>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/mention-input"
}
