{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mention-picker",
  "type": "registry:component",
  "title": "Mention Picker",
  "description": "Type @ in a message and a picker opens right at the caret with people, files and channels; what you pick lands as a token that moves and deletes as one piece.",
  "categories": [
    "component",
    "Conversation"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/mention-picker.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ArrowUpIcon, AtSignIcon, FileTextIcon, HashIcon, ImageIcon, PresentationIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nfunction useSafeReducedMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nexport type MentionKind = \"person\" | \"file\" | \"channel\";\n\nexport interface MentionPerson {\n  id: string;\n  name: string;\n  initials: string;\n  role?: string;\n  status?: \"online\" | \"away\" | \"offline\";\n  avatarClassName?: string;\n}\n\nexport interface MentionFile {\n  id: string;\n  name: string;\n  location?: string;\n  type?: \"doc\" | \"image\" | \"slides\";\n}\n\nexport interface MentionChannel {\n  id: string;\n  name: string;\n  members?: number;\n}\n\nexport type MessageSegment =\n  | { type: \"text\"; text: string }\n  | { type: \"mention\"; kind: MentionKind; id: string; label: string };\n\nexport interface MentionMessage {\n  id: string;\n  author: MentionPerson;\n  time: string;\n  segments: MessageSegment[];\n}\n\nexport interface MentionPickerProps {\n  channel?: string;\n  people?: MentionPerson[];\n  files?: MentionFile[];\n  channels?: MentionChannel[];\n  messages?: MentionMessage[];\n  currentUser?: MentionPerson;\n  onSend?: (segments: MessageSegment[]) => void;\n  className?: string;\n}\n\ninterface Option {\n  kind: MentionKind;\n  id: string;\n  label: string;\n  sub?: string;\n  person?: MentionPerson;\n  file?: MentionFile;\n}\n\ninterface Trigger {\n  node: Text;\n  start: number;\n  query: string;\n  x: number;\n  bottom: number;\n  maxHeight: number;\n}\n\nconst tokenStyles: Record<MentionKind, string> = {\n  person: \"bg-info/12 text-info-foreground\",\n  file: \"bg-muted text-foreground ring-1 ring-border ring-inset\",\n  channel: \"bg-muted text-foreground\",\n};\n\nconst TOKEN_BASE =\n  \"mx-px inline-flex select-all items-center gap-1 whitespace-nowrap rounded-md px-1 align-baseline font-medium leading-[1.35]\";\n\nconst fileSvg =\n  '<svg aria-hidden=\"true\" width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" style=\"opacity:.7;flex-shrink:0\"><path d=\"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z\"/><path d=\"M14 2v4a2 2 0 0 0 2 2h4\"/></svg>';\n\nfunction tokenText(kind: MentionKind, label: string) {\n  return kind === \"person\" ? `@${label}` : kind === \"channel\" ? `#${label}` : label;\n}\n\nconst statusDot: Record<NonNullable<MentionPerson[\"status\"]>, string> = {\n  online: \"bg-success\",\n  away: \"bg-warning\",\n  offline: \"bg-muted-foreground/40\",\n};\n\nfunction Avatar({ person, size = 7 }: { person: MentionPerson; size?: 6 | 7 | 8 }) {\n  return (\n    <span className=\"relative shrink-0\">\n      <span\n        className={cn(\n          \"flex select-none items-center justify-center rounded-full font-medium\",\n          size === 8 ? \"size-8 text-xs\" : size === 7 ? \"size-7 text-[10px]\" : \"size-6 text-[10px]\",\n          person.avatarClassName ?? \"bg-muted text-foreground\",\n        )}\n      >\n        {person.initials}\n      </span>\n      {person.status && (\n        <span\n          className={cn(\"absolute -end-px -bottom-px size-2.5 rounded-full ring-2 ring-popover\", statusDot[person.status])}\n        />\n      )}\n    </span>\n  );\n}\n\nfunction FileGlyph({ type }: { type?: MentionFile[\"type\"] }) {\n  const Icon = type === \"image\" ? ImageIcon : type === \"slides\" ? PresentationIcon : FileTextIcon;\n  const tone =\n    type === \"image\"\n      ? \"bg-violet-100 text-violet-700 dark:bg-violet-950 dark:text-violet-300\"\n      : type === \"slides\"\n        ? \"bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300\"\n        : \"bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-300\";\n  return (\n    <span className={cn(\"flex size-7 shrink-0 items-center justify-center rounded-lg\", tone)}>\n      <Icon className=\"size-3.5\" />\n    </span>\n  );\n}\n\nexport function MentionToken({ kind, label }: { kind: MentionKind; label: string }) {\n  return (\n    <span className={cn(TOKEN_BASE, tokenStyles[kind])}>\n      {kind === \"file\" && <FileTextIcon aria-hidden className=\"size-3 shrink-0 opacity-70\" />}\n      {tokenText(kind, label)}\n    </span>\n  );\n}\n\nfunction serialize(root: HTMLElement): MessageSegment[] {\n  const out: MessageSegment[] = [];\n  const pushText = (t: string) => {\n    if (!t) return;\n    const last = out[out.length - 1];\n    if (last?.type === \"text\") last.text += t;\n    else out.push({ type: \"text\", text: t });\n  };\n  const walk = (node: Node, top: boolean) => {\n    if (node.nodeType === Node.TEXT_NODE) return pushText((node.textContent ?? \"\").replace(/ /g, \" \"));\n    if (!(node instanceof HTMLElement)) return;\n    if (node.dataset.kind) {\n      out.push({ type: \"mention\", kind: node.dataset.kind as MentionKind, id: node.dataset.id ?? \"\", label: node.dataset.label ?? \"\" });\n      return;\n    }\n    if (node.tagName === \"BR\") return pushText(\"\\n\");\n    if (!top && node.tagName === \"DIV\") pushText(\"\\n\");\n    for (const child of node.childNodes) walk(child, false);\n  };\n  for (const child of root.childNodes) walk(child, false);\n  const first = out[0];\n  if (first?.type === \"text\") first.text = first.text.replace(/^\\s+/, \"\");\n  const last = out[out.length - 1];\n  if (last?.type === \"text\") last.text = last.text.replace(/\\s+$/, \"\");\n  return out.filter((s) => s.type !== \"text\" || s.text);\n}\n\nexport function MentionPicker({\n  channel = \"launch\",\n  people = [],\n  files = [],\n  channels = [],\n  messages: initialMessages = [],\n  currentUser = { id: \"me\", name: \"You\", initials: \"YO\" },\n  onSend,\n  className,\n}: MentionPickerProps) {\n  const reduce = useSafeReducedMotion();\n  const listId = useId();\n  const editorRef = useRef<HTMLDivElement>(null);\n  const cardRef = useRef<HTMLElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const [trigger, setTrigger] = useState<Trigger | null>(null);\n  const [active, setActive] = useState(0);\n  const [empty, setEmpty] = useState(true);\n  const [messages, setMessages] = useState(initialMessages);\n\n  useEffect(() => {\n    const el = scrollRef.current;\n    if (el) el.scrollTo({ top: el.scrollHeight, behavior: reduce ? \"auto\" : \"smooth\" });\n  }, [messages, reduce]);\n\n  const groups = useMemo(() => {\n    if (!trigger) return [];\n    const q = trigger.query.toLowerCase();\n    const hit = (s: string) => !q || s.toLowerCase().split(/[\\s._-]+/).some((w) => w.startsWith(q)) || s.toLowerCase().startsWith(q);\n    const ppl: Option[] = people\n      .filter((p) => hit(p.name))\n      .slice(0, q ? 5 : 4)\n      .map((p) => ({ kind: \"person\", id: p.id, label: p.name, sub: p.role, person: p }));\n    const fls: Option[] = files\n      .filter((f) => hit(f.name))\n      .slice(0, 3)\n      .map((f) => ({ kind: \"file\", id: f.id, label: f.name, sub: f.location, file: f }));\n    const chs: Option[] = channels\n      .filter((c) => hit(c.name))\n      .slice(0, 3)\n      .map((c) => ({ kind: \"channel\", id: c.id, label: c.name, sub: c.members ? `${c.members} members` : undefined }));\n    return [\n      { name: \"People\", items: ppl },\n      { name: \"Files\", items: fls },\n      { name: \"Channels\", items: chs },\n    ].filter((g) => g.items.length);\n  }, [trigger, people, files, channels]);\n  const flat = groups.flatMap((g) => g.items);\n  const activeOptionId = trigger && flat[active] ? `${listId}-${flat[active].kind}-${flat[active].id}` : undefined;\n\n  useEffect(() => {\n    if (activeOptionId) document.getElementById(activeOptionId)?.scrollIntoView({ block: \"nearest\" });\n  }, [activeOptionId]);\n\n  const readTrigger = () => {\n    const editor = editorRef.current;\n    const card = cardRef.current;\n    const sel = window.getSelection();\n    if (!editor || !card || !sel || !sel.rangeCount || !sel.isCollapsed) return setTrigger(null);\n    const node = sel.anchorNode;\n    if (!node || node.nodeType !== Node.TEXT_NODE || !editor.contains(node)) return setTrigger(null);\n    const before = (node.textContent ?? \"\").slice(0, sel.anchorOffset);\n    const m = /(^|[\\s ])@([\\p{L}\\d._-]{0,24})$/u.exec(before);\n    if (!m) return setTrigger(null);\n    const start = sel.anchorOffset - m[2].length - 1;\n    const r = document.createRange();\n    r.setStart(node, start);\n    r.setEnd(node, start + 1);\n    const rect = r.getBoundingClientRect();\n    const box = card.getBoundingClientRect();\n    const x = Math.max(8, Math.min(rect.left - box.left - 10, box.width - 280 - 8));\n    setTrigger((prev) => {\n      if (prev?.query !== m[2]) setActive(0);\n      return {\n        node: node as Text,\n        start,\n        query: m[2],\n        x,\n        bottom: box.bottom - rect.top + 8,\n        maxHeight: Math.max(150, Math.min(300, rect.top - box.top - 4)),\n      };\n    });\n  };\n\n  const syncEmpty = () => {\n    const editor = editorRef.current;\n    if (!editor) return;\n    const isEmpty = !editor.textContent?.trim() && !editor.querySelector(\"[data-kind]\");\n    setEmpty(isEmpty);\n    if (isEmpty && editor.innerHTML !== \"\") editor.innerHTML = \"\";\n  };\n\n  const insert = (opt: Option) => {\n    const editor = editorRef.current;\n    if (!editor || !trigger) return;\n    const { node, start } = trigger;\n    const end = Math.min(node.length, start + 1 + trigger.query.length);\n    const range = document.createRange();\n    range.setStart(node, start);\n    range.setEnd(node, end);\n    range.deleteContents();\n\n    const token = document.createElement(\"span\");\n    token.contentEditable = \"false\";\n    token.dataset.kind = opt.kind;\n    token.dataset.id = opt.id;\n    token.dataset.label = opt.label;\n    token.className = cn(TOKEN_BASE, tokenStyles[opt.kind]);\n    token.innerHTML = opt.kind === \"file\" ? fileSvg : \"\";\n    token.append(tokenText(opt.kind, opt.label));\n    const space = document.createTextNode(\" \");\n    range.insertNode(space);\n    range.insertNode(token);\n\n    const sel = window.getSelection();\n    const caret = document.createRange();\n    caret.setStart(space, 1);\n    caret.collapse(true);\n    sel?.removeAllRanges();\n    sel?.addRange(caret);\n    editor.focus();\n    if (!reduce) {\n      token.animate(\n        [\n          { transform: \"scale(0.9)\", opacity: 0.4 },\n          { transform: \"scale(1)\", opacity: 1 },\n        ],\n        { duration: 200, easing: \"cubic-bezier(0.23, 1, 0.32, 1)\" },\n      );\n    }\n    setTrigger(null);\n    syncEmpty();\n  };\n\n  const send = () => {\n    const editor = editorRef.current;\n    if (!editor) return;\n    const segments = serialize(editor);\n    if (!segments.length) return;\n    onSend?.(segments);\n    setMessages((m) => [...m, { id: `m-${Date.now()}`, author: currentUser, time: \"now\", segments }]);\n    editor.innerHTML = \"\";\n    setEmpty(true);\n    setTrigger(null);\n    editor.focus();\n  };\n\n  const openPicker = () => {\n    const editor = editorRef.current;\n    if (!editor) return;\n    editor.focus();\n    const sel = window.getSelection();\n    if (!sel) return;\n    if (!sel.rangeCount || !editor.contains(sel.anchorNode)) {\n      const r = document.createRange();\n      r.selectNodeContents(editor);\n      r.collapse(false);\n      sel.removeAllRanges();\n      sel.addRange(r);\n    }\n    const before = sel.anchorNode?.nodeType === Node.TEXT_NODE ? (sel.anchorNode.textContent ?? \"\").slice(0, sel.anchorOffset) : \"\";\n    document.execCommand(\"insertText\", false, before && !/\\s$/.test(before) ? \" @\" : \"@\");\n    syncEmpty();\n    readTrigger();\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (trigger) {\n      if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n        e.preventDefault();\n        if (flat.length) setActive((a) => (a + (e.key === \"ArrowDown\" ? 1 : -1) + flat.length) % flat.length);\n        return;\n      }\n      if ((e.key === \"Enter\" || e.key === \"Tab\") && flat.length) {\n        e.preventDefault();\n        insert(flat[active] ?? flat[0]);\n        return;\n      }\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        setTrigger(null);\n        return;\n      }\n    }\n    if (e.key === \"Enter\" && !e.shiftKey) {\n      e.preventDefault();\n      send();\n    }\n  };\n\n  return (\n    <section\n      aria-label={`#${channel}`}\n      className={cn(\"relative flex w-full max-w-[440px] flex-col rounded-2xl border bg-card text-card-foreground shadow-xs/5\", className)}\n      ref={cardRef}\n    >\n      <header className=\"flex items-center gap-1.5 border-b px-4 py-3\">\n        <HashIcon className=\"size-4 text-muted-foreground\" />\n        <h3 className=\"font-medium text-sm\">{channel}</h3>\n        <span className=\"ms-auto text-muted-foreground text-xs tabular-nums\">{people.length + 1} members</span>\n      </header>\n\n      <div className=\"flex h-64 flex-col gap-4 overflow-y-auto px-4 py-4\" ref={scrollRef}>\n        <AnimatePresence initial={false}>\n          {messages.map((m) => (\n            <motion.div\n              animate={{ opacity: 1, y: 0 }}\n              className=\"flex gap-2.5\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}\n              key={m.id}\n              transition={{ type: \"spring\", stiffness: 480, damping: 36, opacity: { duration: 0.16 } }}\n            >\n              <Avatar person={{ ...m.author, status: undefined }} size={8} />\n              <div className=\"min-w-0 flex-1\">\n                <div className=\"flex items-baseline gap-2\">\n                  <span className=\"font-medium text-sm\">{m.author.name}</span>\n                  <span className=\"text-muted-foreground text-xs\">{m.time}</span>\n                </div>\n                <p className=\"mt-0.5 whitespace-pre-wrap text-pretty text-sm leading-relaxed\">\n                  {m.segments.map((s, i) =>\n                    s.type === \"text\" ? (\n                      <span key={i}>{s.text}</span>\n                    ) : (\n                      <MentionToken key={i} kind={s.kind} label={s.label} />\n                    ),\n                  )}\n                </p>\n              </div>\n            </motion.div>\n          ))}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"p-3 pt-0\">\n        <div className=\"rounded-xl border bg-background transition-shadow focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/24 dark:bg-input/32\">\n          <div className=\"relative\">\n            {empty && (\n              <span aria-hidden className=\"pointer-events-none absolute top-2.5 left-3 text-muted-foreground text-sm\">\n                Message #{channel}, type @ to mention\n              </span>\n            )}\n            <div\n              aria-activedescendant={trigger && flat[active] ? `${listId}-${flat[active].kind}-${flat[active].id}` : undefined}\n              aria-autocomplete=\"list\"\n              aria-controls={trigger ? listId : undefined}\n              aria-label={`Message #${channel}`}\n              aria-multiline=\"true\"\n              className=\"max-h-32 min-h-11 overflow-y-auto whitespace-pre-wrap break-words px-3 py-2.5 text-sm leading-relaxed outline-none\"\n              contentEditable\n              onBlur={() => setTrigger(null)}\n              onInput={() => {\n                syncEmpty();\n                readTrigger();\n              }}\n              onKeyDown={onKeyDown}\n              onKeyUp={(e) => (e.key.startsWith(\"Arrow\") || e.key === \"Home\" || e.key === \"End\") && !trigger && readTrigger()}\n              onMouseUp={readTrigger}\n              onPaste={(e) => {\n                e.preventDefault();\n                document.execCommand(\"insertText\", false, e.clipboardData.getData(\"text/plain\"));\n              }}\n              ref={editorRef}\n              role=\"textbox\"\n              spellCheck\n              suppressContentEditableWarning\n              tabIndex={0}\n            />\n          </div>\n          <div className=\"flex items-center justify-between px-1.5 pb-1.5\">\n            <button\n              aria-label=\"Mention someone\"\n              className=\"relative flex size-7 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors before:absolute before:-inset-0.5 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n              onClick={openPicker}\n              onMouseDown={(e) => e.preventDefault()}\n              type=\"button\"\n            >\n              <AtSignIcon className=\"size-4\" />\n            </button>\n            <button\n              aria-label=\"Send\"\n              className=\"flex size-7 cursor-pointer items-center justify-center rounded-lg bg-primary text-primary-foreground outline-none transition-[opacity,scale] duration-150 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] disabled:cursor-default disabled:opacity-40\"\n              disabled={empty}\n              onClick={send}\n              onMouseDown={(e) => e.preventDefault()}\n              type=\"button\"\n            >\n              <ArrowUpIcon className=\"size-4\" />\n            </button>\n          </div>\n        </div>\n      </div>\n\n      <AnimatePresence>\n        {trigger && (\n          <motion.div\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            className=\"absolute z-20 w-[280px] overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg/5\"\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: 4 }}\n            onMouseDown={(e) => e.preventDefault()}\n            style={{ left: trigger.x, bottom: trigger.bottom, transformOrigin: \"bottom left\" }}\n            transition={{ duration: 0.14, ease: [0.23, 1, 0.32, 1] }}\n          >\n            <div\n              aria-label=\"Mention\"\n              className=\"overflow-y-auto overscroll-contain p-1\"\n              id={listId}\n              role=\"listbox\"\n              style={{ maxHeight: trigger.maxHeight }}\n            >\n              {flat.length === 0 ? (\n                <div className=\"px-3 py-6 text-center\">\n                  <p className=\"text-sm\">Nothing matches “{trigger.query}”</p>\n                  <p className=\"mt-0.5 text-muted-foreground text-xs\">Try a first name, a file or a channel</p>\n                </div>\n              ) : (\n                groups.map((g) => (\n                  <div key={g.name} role=\"presentation\">\n                    <div className=\"px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs\">{g.name}</div>\n                    {g.items.map((o) => {\n                      const idx = flat.indexOf(o);\n                      const on = idx === active;\n                      return (\n                        <div\n                          aria-selected={on}\n                          className={cn(\"flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5\", on && \"bg-accent\")}\n                          id={`${listId}-${o.kind}-${o.id}`}\n                          key={`${o.kind}-${o.id}`}\n                          onClick={() => insert(o)}\n                          onMouseMove={() => idx !== active && setActive(idx)}\n                          role=\"option\"\n                          tabIndex={-1}\n                        >\n                          {o.person ? (\n                            <Avatar person={o.person} />\n                          ) : o.kind === \"file\" ? (\n                            <FileGlyph type={o.file?.type} />\n                          ) : (\n                            <span className=\"flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground\">\n                              <HashIcon className=\"size-3.5\" />\n                            </span>\n                          )}\n                          <span className=\"min-w-0 flex-1 truncate text-sm\">{o.label}</span>\n                          {o.sub && <span className=\"max-w-[45%] shrink-0 truncate text-muted-foreground text-xs\">{o.sub}</span>}\n                        </div>\n                      );\n                    })}\n                  </div>\n                ))\n              )}\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </section>\n  );\n}\n"
    }
  ]
}