{"$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://ui.teamofsilicons.com/r/silicon-motion-tokens.json"],"files":[{"path":"registry/components/mention-input/mention-input.tsx","type":"registry:component","target":"@components/silicon-ui/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 { useNearestItem } from \"../lib/fluid-hover\";\nimport styles from \"./mention-input.module.css\";\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(.3, .1), 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\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(value: MentionValue, format: (mention: Mention) => string = mention => `<${SYMBOL[mention.kind]}${mention.id}>`) {\n  let out = \"\", 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 (suffix < a.length - prefix && suffix < text.length - prefix && a[a.length - 1 - suffix] === text[text.length - 1 - suffix]) suffix++;\n  const oldEnd = a.length - suffix, delta = text.length - a.length;\n  return previous.mentions.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  }).filter(mention => text.slice(mention.start, mention.end) === mentionText(mention.kind, mention.label));\n}\n\nfunction findTrigger(text: string, caret: number, mentions: Mention[], kinds: Record<MentionKind, boolean>): 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.split(/\\s+/).filter(Boolean).slice(0, 2).map(part => part[0]?.toUpperCase()).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 <>{text.slice(0, at)}<mark className={styles.match}>{text.slice(at, at + query.length)}</mark>{text.slice(at + query.length)}</>;\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  value: valueProp, defaultValue, onChange, people, channels, onMentionAdd, onSubmit, submitOnEnter = false,\n  placeholder, minRows = 1, maxRows = 8, placement = \"auto\", maxSuggestions = 6, disabled, name, id,\n  \"aria-label\": ariaLabel, \"aria-describedby\": describedBy, className,\n}, ref) {\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(() => { live.current = value; });\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  // Fluid hover: the mouse makes the nearest option active, gaps and padding included, and a click there picks it.\n  const nearest = useNearestItem<HTMLDivElement>({ items: '[role=\"option\"]', onPick: item => setActive(Number(item.dataset.index)) });\n\n  const commit = useCallback((next: MentionValue) => {\n    if (valueProp === undefined) setInner(next);\n    onChange?.(next);\n  }, [onChange, valueProp]);\n\n  const kinds = useMemo(() => ({ person: !!people?.length, channel: !!channels?.length }), [channels, people]);\n  const trigger = useMemo(() => caret === null ? null : findTrigger(value.text, caret, value.mentions, kinds), [caret, kinds, value.mentions, value.text]);\n\n  const suggestions = useMemo<Suggestion[]>(() => {\n    if (!trigger) return [];\n    const query = trigger.query.toLowerCase().trim();\n    const scored: { entry: Suggestion; score: number }[] = trigger.kind === \"person\"\n      ? (people ?? []).map(item => ({ entry: { kind: \"person\" as const, item }, score: rank(item.name, item.role ?? \"\", query) }))\n      : (channels ?? []).map(item => ({ entry: { kind: \"channel\" as const, item }, score: rank(item.name, item.description ?? \"\", query) }));\n    return scored.filter(entry => entry.score > 0).sort((a, b) => b.score - a.score).slice(0, maxSuggestions).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 = focused && !disabled && !!trigger && trigger.start !== dismissed && (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) { setLastQueryKey(queryKey); setActive(0); }\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, 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 ? (forward ? mention.end : mention.start) : (start - mention.start < mention.end - start ? mention.start : 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) 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((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, end = area.selectionEnd, 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(() => { area.setSelectionRange(start + text.length, start + text.length); setCaret(start + text.length); });\n  }, [commit, onMentionAdd]);\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 = { kind: suggestion.kind, id: suggestion.item.id, label, start: trigger.start, end: trigger.start + token.length };\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) 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, 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 => event.key === \"Backspace\" ? start > mention.start && start <= mention.end : start >= mention.start && start < mention.end);\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(ref, () => ({\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(`${before && !/\\s/.test(before) ? \" \" : \"\"}${SYMBOL[kind]}`, at, document.activeElement === area ? area.selectionEnd : at);\n    },\n    clear: () => {\n      commit(EMPTY);\n      live.current = EMPTY;\n      setCaret(0);\n    },\n    get textarea() { return areaRef.current; },\n  }), [commit, typeText]);\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\") { animate(height, target, INSTANT); measured.current = true; return; }\n      if (Math.abs(current - target) > .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, field = fieldRef.current, area = areaRef.current;\n    if (!marker || !root || !field || !area) { setAnchor(null); return; }\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 => current && current.x === x && current.y === y && current.above === above && current.line === line ? current : { x, y, line, above });\n  }, [placement]);\n  useLayoutEffect(() => { if (open) measureAnchor(); }, [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), 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) { animate(hy, row.offsetTop, INSTANT); animate(hh, row.offsetHeight, INSTANT); placed.current = true; return; }\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) { panelSized.current = false; placed.current = false; return; }\n    const body = bodyRef.current;\n    if (!body) return;\n    const fit = () => {\n      const target = body.offsetHeight;\n      if (!panelSized.current || reduced) { animate(panelHeight, target, INSTANT); panelSized.current = true; return; }\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(<span key={`${mention.start}-${mention.id}`} className={styles.token} data-kind={mention.kind}>{value.text.slice(mention.start, mention.end)}</span>);\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: .96, y: anchor?.above ? 4 : -4 };\n  const countLabel = open ? (suggestions.length ? `${suggestions.length} ${trigger?.kind === \"person\" ? (suggestions.length === 1 ? \"person\" : \"people\") : (suggestions.length === 1 ? \"channel\" : \"channels\")}` : \"No matches\") : \"\";\n\n  return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(\" \")} data-disabled={disabled || undefined}>\n    <motion.div ref={fieldRef} className={styles.field} style={{ height }} data-focused={focused || undefined}>\n      <div ref={backdropRef} className={styles.backdrop} aria-hidden=\"true\">{mirror}{\"\\u200b\"}</div>\n      <textarea\n        ref={areaRef}\n        id={id}\n        name={name}\n        className={styles.textarea}\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={() => { setFocused(false); setCaret(null); }}\n      />\n    </motion.div>\n    <span className={styles.srOnly} aria-live=\"polite\">{countLabel}</span>\n    <AnimatePresence>\n      {open && anchor && <motion.div\n        key={trigger?.start}\n        className={styles.popover}\n        data-above={anchor.above || undefined}\n        style={{ left: anchor.x, top: anchor.y, height: panelHeight, transformOrigin: anchor.above ? \"14px 100%\" : \"14px 0\" }}\n        initial={popIn}\n        animate={{ opacity: 1, scale: 1, y: 0, transition: reduced ? { duration: duration.fast } : { ...spring.snappy, opacity: { duration: duration.fast, ease: enter } } }}\n        exit={{ ...popIn, transition: { duration: duration.exit * .7, ease: standard } }}\n        onMouseDown={event => event.preventDefault()}\n        {...nearest}\n      >\n        <div ref={bodyRef} className={styles.popoverBody}>\n          {suggestions.length ? <ul ref={listRef} id={listId} role=\"listbox\" aria-label={trigger?.kind === \"person\" ? \"People\" : \"Channels\"} className={styles.list}>\n            <motion.li aria-hidden=\"true\" className={styles.highlight} style={{ y: hy, height: hh }} />\n            {suggestions.map((suggestion, index) => <li\n              key={`${suggestion.kind}-${suggestion.item.id}`}\n              id={optionId(index)}\n              role=\"option\"\n              aria-selected={index === activeIndex}\n              data-index={index}\n              className={styles.option}\n              onClick={() => choose(suggestion)}\n            >\n              {suggestion.kind === \"person\"\n                ? <span className={styles.avatar} aria-hidden=\"true\">{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                  : initials(suggestion.item.name)}</span>\n                : <span className={styles.glyph} aria-hidden=\"true\"><Hash size={16} strokeWidth={1.75} /></span>}\n              <span className={styles.optionText}>\n                <span className={styles.optionLabel}><Highlight text={suggestion.item.name} query={trigger?.query.trim() ?? \"\"} /></span>\n                {suggestion.kind === \"person\"\n                  ? suggestion.item.role && <span className={styles.optionMeta}>{suggestion.item.role}</span>\n                  : suggestion.item.description && <span className={styles.optionMeta}>{suggestion.item.description}</span>}\n              </span>\n              {suggestion.kind === \"channel\" && suggestion.item.members !== undefined && <span className={styles.count}>{suggestion.item.members}</span>}\n            </li>)}\n          </ul> : <p className={styles.empty}>No {trigger?.kind === \"person\" ? \"people\" : \"channels\"} match “{trigger?.query}”</p>}\n        </div>\n      </motion.div>}\n    </AnimatePresence>\n  </div>;\n});\n\nexport default MentionInput;\n"},{"path":"registry/components/mention-input/mention-input.module.css","type":"registry:component","target":"@components/silicon-ui/mention-input/mention-input.module.css","content":".root {\n  position: relative;\n  width: 100%;\n  color: var(--foreground);\n  font-family: var(--font-body);\n  letter-spacing: var(--tracking-body);\n}\n.root[data-disabled] { opacity: .55; }\n\n/* The field's height springs to its text. The mirror and the textarea share every metric so tokens sit exactly under their characters. */\n.field {\n  position: relative;\n  box-sizing: content-box;\n  overflow: hidden;\n  border: 1px solid var(--border);\n  border-radius: var(--radius-control); corner-shape: var(--corner-shape);\n  background: var(--surface);\n  transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);\n}\n.field[data-focused] { border-color: var(--border-strong); }\n\n.backdrop,\n.textarea {\n  box-sizing: border-box;\n  width: 100%;\n  margin: 0;\n  padding: var(--space-group) var(--space-inset);\n  border: 0;\n  font: inherit;\n  font-size: var(--text-base);\n  letter-spacing: var(--tracking-body);\n  line-height: 24px;\n  overflow-wrap: break-word;\n  white-space: pre-wrap;\n  word-break: break-word;\n  tab-size: 4;\n}\n\n.backdrop {\n  position: absolute;\n  top: 0;\n  left: 0;\n  color: var(--foreground);\n  pointer-events: none;\n  will-change: transform;\n}\n\n.textarea {\n  position: absolute;\n  inset: 0;\n  height: 100%;\n  display: block;\n  resize: none;\n  overflow-y: auto;\n  background: transparent;\n  color: transparent;\n  caret-color: var(--foreground);\n  outline: none;\n  scrollbar-width: none;\n  -webkit-text-fill-color: transparent;\n}\n.textarea::-webkit-scrollbar { display: none; }\n.textarea::placeholder { color: var(--text-muted); -webkit-text-fill-color: var(--text-muted); opacity: 1; }\n.textarea::selection { background: color-mix(in oklab, var(--accent) 24%, transparent); }\n.textarea:disabled { cursor: not-allowed; }\n\n/* A token is tinted around its own glyphs; the negative margin keeps every character where the textarea put it. */\n.token {\n  margin: 0 calc(var(--space-tight) * -1);\n  padding: 1px var(--space-tight);\n  border-radius: var(--radius-detail); corner-shape: var(--corner-shape);\n  background: var(--accent-subtle);\n  color: var(--accent-strong);\n  box-decoration-break: clone;\n  -webkit-box-decoration-break: clone;\n}\n.token[data-kind=\"channel\"] { background: color-mix(in oklab, var(--foreground) 7%, transparent); color: var(--foreground); }\n\n.popover {\n  position: absolute;\n  z-index: var(--layer-dropdown);\n  width: 272px;\n  max-width: 100%;\n  overflow: hidden;\n  border: 1px solid var(--border);\n  border-radius: var(--radius-control); corner-shape: var(--corner-shape);\n  background: var(--surface-raised);\n  box-shadow: var(--shadow-floating);\n}\n.popover[data-above] { translate: 0 -100%; }\n.popoverBody { padding: var(--space-related); }\n\n.list { position: relative; display: grid; margin: 0; padding: 0; list-style: none; }\n.highlight { position: absolute; top: 0; right: 0; left: 0; border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: color-mix(in oklab, var(--foreground) 6.5%, transparent); pointer-events: none; }\n\n.option {\n  position: relative;\n  display: flex;\n  min-width: 0;\n  align-items: center;\n  gap: var(--space-group);\n  padding: var(--space-related) var(--space-group) var(--space-related) var(--space-related);\n  border-radius: var(--radius-control); corner-shape: var(--corner-shape);\n  cursor: pointer;\n  font-size: var(--text-sm);\n  -webkit-tap-highlight-color: transparent;\n}\n\n.avatar {\n  display: grid;\n  width: 28px;\n  height: 28px;\n  flex: none;\n  overflow: hidden;\n  place-items: center;\n  border-radius: 50%; corner-shape: round;\n  background: var(--surface-muted);\n  color: var(--text-secondary);\n  font-size: var(--text-xs);\n  font-weight: 500;\n}\n.avatar img { width: 100%; height: 100%; object-fit: cover; }\n.glyph { display: grid; width: 28px; height: 28px; flex: none; place-items: center; color: var(--text-secondary); }\n\n.optionText { display: grid; min-width: 0; flex: 1; }\n.optionLabel { overflow: hidden; color: var(--text-secondary); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }\n.option[aria-selected=\"true\"] .optionLabel { color: var(--foreground); }\n.match { background: none; color: var(--foreground); }\n.optionMeta { overflow: hidden; color: var(--text-muted); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }\n.count { flex: none; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n\n.empty { margin: 0; padding: var(--space-group) var(--space-group); color: var(--text-secondary); font-size: var(--text-sm); }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n"},{"path":"lib/fluid-hover.tsx","type":"registry:lib","target":"@components/silicon-ui/lib/fluid-hover.tsx","content":"\"use client\";\n\n/**\n * Fluid hover: in a set of items (a menu, a list, tabs, a grid), the item nearest the pointer is lit, even while the\n * pointer is in a gap, the padding or past the last item, and one highlight springs between items. A click in a gap goes\n * to the lit item, so what is lit is what a click hits. A new pointer entry fades the highlight in where it lands instead\n * of sliding it over from the last item. The keyboard moves it without travel, and reduced motion keeps the fade but\n * drops the travel.\n *\n *   const list = useRef<HTMLUListElement>(null);\n *   const hover = useFluidHover(list, { items: \"[data-item]\" });\n *   <ul ref={list} className={styles.list} {...hover.handlers}>      // .list { position: relative }\n *     <FluidHoverHighlight hover={hover} className={styles.highlight} />\n *     …items…\n *   </ul>\n *\n * Adapted from Fluid Functionalism's use-fluid-hover and fluid-hover-highlight by Micka Touillaud (MIT),\n * https://github.com/mickadesign/fluid-functionalism. Items are found by a selector instead of registering themselves,\n * so a component adopts it without changing its items.\n */\nimport { useEffect, useEffectEvent, useRef, useState } from \"react\";\nimport type { FocusEvent, MouseEvent, PointerEvent, RefObject } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"./motion-tokens\";\n\n/** An item's box in the container's own layout space (unaffected by transforms or scrolling). */\nexport interface FluidRect { top: number; left: number; width: number; height: number }\n\nexport interface FluidHoverState {\n  /** Where the highlight sits, or null when nothing is lit. */\n  rect: FluidRect | null;\n  /** The lit item. */\n  item: HTMLElement | null;\n  /** False when the highlight should jump (keyboard, a resize) rather than travel. */\n  glide: boolean;\n  /** Steps on each new pointer entry, so the highlight fades in where it lands. */\n  session: number;\n}\n\nexport interface FluidHoverOptions {\n  /** Selector for the items inside the container. */\n  items: string;\n  /** \"y\" for lists (default), \"x\" for a row of tabs, \"xy\" for grids. */\n  axis?: \"x\" | \"y\" | \"xy\";\n  /** Items the highlight skips (and a gap click never activates). Defaults to disabled or aria-disabled items. */\n  isItemDisabled?: (item: HTMLElement) => boolean;\n  /** \"skip\" (default) lights the nearest enabled item even over a disabled one; \"clear\" lights nothing over a disabled item (a calendar's unavailable days). */\n  disabled?: \"skip\" | \"clear\";\n  /** Route a click in a gap to the lit item. On by default; turn it off where empty space should stay inert. */\n  gapClick?: boolean;\n  /** Light the item that receives keyboard focus. On by default. */\n  followFocus?: boolean;\n}\n\nconst defaultDisabled = (item: HTMLElement) =>\n  item.matches(\":disabled, [aria-disabled='true'], [data-disabled]\");\n\n/** Controls that sit between items keep their own clicks. */\nconst CONTROL = \"input, textarea, select, button, a, summary, [contenteditable], [role='textbox'], [role='searchbox'], [role='button']\";\n\n/** The item's box relative to the container, summing offsets through positioned ancestors in between. */\nexport function rectOf(item: HTMLElement, container: HTMLElement): FluidRect {\n  let top = item.offsetTop, left = item.offsetLeft;\n  let parent = item.offsetParent as HTMLElement | null;\n  while (parent && parent !== container && container.contains(parent)) {\n    top += parent.offsetTop + parent.clientTop;\n    left += parent.offsetLeft + parent.clientLeft;\n    parent = parent.offsetParent as HTMLElement | null;\n  }\n  return { top, left, width: item.offsetWidth, height: item.offsetHeight };\n}\n\n/**\n * The item the pointer is inside, or else the one whose edge is nearest along the axis (both axes for a grid). Edges, not\n * centres, so a gap next to a wide item still lights the item the pointer is closer to.\n */\nexport function pickNearest(items: HTMLElement[], x: number, y: number, axis: \"x\" | \"y\" | \"xy\"): HTMLElement | null {\n  let nearest: HTMLElement | null = null, best = Infinity;\n  for (const item of items) {\n    const r = item.getBoundingClientRect();\n    if (!r.width && !r.height) continue;\n    if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return item;\n    const dx = Math.max(r.left - x, 0, x - r.right), dy = Math.max(r.top - y, 0, y - r.bottom);\n    const distance = axis === \"x\" ? dx : axis === \"y\" ? dy : Math.hypot(dx, dy);\n    if (distance < best) { best = distance; nearest = item; }\n  }\n  return nearest;\n}\n\n/** Hands a gap click to the item as a real mouse click (detail, modifiers and coordinates kept), so the item reads it as a pointer press, not a keyboard one. */\nfunction forwardClick(item: HTMLElement, event: MouseEvent<HTMLElement>) {\n  const { detail, clientX, clientY, screenX, screenY, ctrlKey, shiftKey, altKey, metaKey, button, buttons } = event;\n  item.dispatchEvent(new globalThis.MouseEvent(\"click\", { bubbles: true, cancelable: true, composed: true, view: window, detail, clientX, clientY, screenX, screenY, ctrlKey, shiftKey, altKey, metaKey, button, buttons }));\n}\n\n/** Whether an event happened inside the element whose handler hears it, not in a portalled child (a submenu, a tooltip). */\nconst isOwn = (event: { currentTarget: EventTarget; target: EventTarget }) =>\n  !(event.currentTarget instanceof Node) || !(event.target instanceof Node) || event.currentTarget.contains(event.target);\n\nexport function useFluidHover<T extends HTMLElement>(container: RefObject<T | null>, options: FluidHoverOptions) {\n  const { items: selector, axis = \"y\", isItemDisabled = defaultDisabled, disabled = \"skip\", gapClick = true, followFocus = true } = options;\n  const [state, setState] = useState<FluidHoverState>({ rect: null, item: null, glide: false, session: 0 });\n  const current = useRef(state);\n  const pointer = useRef(false);\n  const entering = useRef(false);\n  const frame = useRef(0);\n\n  function commit(next: FluidHoverState) {\n    const prev = current.current;\n    if (prev.item === next.item && prev.session === next.session && sameRect(prev.rect, next.rect)) return;\n    current.current = next;\n    setState(next);\n  }\n\n  function light(item: HTMLElement | null, glide: boolean) {\n    const root = container.current;\n    if (!root || !item) return commit({ ...current.current, rect: null, item: null, glide });\n    let session = current.current.session;\n    if (entering.current) { entering.current = false; session += 1; }\n    commit({ rect: rectOf(item, root), item, glide: glide && session === current.current.session, session });\n  }\n\n  const enabledItems = () => {\n    const root = container.current;\n    return root ? [...root.querySelectorAll<HTMLElement>(selector)].filter(item => !isItemDisabled(item)) : [];\n  };\n\n  // The lit item can move under a still highlight (a resize, content above it changing): follow it without travel.\n  const follow = useEffectEvent(() => { const { item } = current.current; if (item?.isConnected) light(item, false); });\n  useEffect(() => {\n    const root = container.current;\n    if (!root || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => follow());\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, [container]);\n  useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n  const handlers = {\n    onMouseEnter: () => { entering.current = true; },\n    onMouseMove: (event: MouseEvent<T>) => {\n      if (!isOwn(event)) return;\n      pointer.current = true;\n      const { clientX, clientY } = event;\n      cancelAnimationFrame(frame.current);\n      frame.current = requestAnimationFrame(() => {\n        const over = disabled === \"clear\" ? document.elementFromPoint(clientX, clientY)?.closest<HTMLElement>(selector) : null;\n        light(over && isItemDisabled(over) ? null : pickNearest(enabledItems(), clientX, clientY, axis), true);\n      });\n    },\n    onMouseLeave: () => { cancelAnimationFrame(frame.current); entering.current = false; light(null, false); },\n    onKeyDownCapture: () => { pointer.current = false; },\n    onFocus: (event: FocusEvent<T>) => {\n      if (!followFocus || pointer.current || !isOwn(event)) return;\n      const item = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>(selector) : null;\n      if (item && container.current?.contains(item) && !isItemDisabled(item)) light(item, false);\n    },\n    onBlur: (event: FocusEvent<T>) => {\n      if (!pointer.current && !event.currentTarget.contains(event.relatedTarget as Node | null)) light(null, false);\n    },\n    onClick: (event: MouseEvent<T>) => {\n      if (!gapClick || !isOwn(event)) return;\n      const target = event.target as Element;\n      if (!target.isConnected || target.closest(selector) || target.closest(CONTROL)) return;\n      const { item } = current.current;\n      if (item?.isConnected && !isItemDisabled(item)) forwardClick(item, event);\n    },\n  };\n\n  /** Clears the highlight at once, such as when a menu opens again. */\n  const reset = () => { cancelAnimationFrame(frame.current); entering.current = true; light(null, false); };\n\n  return { ...state, handlers, reset, light };\n}\n\nexport interface NearestItemOptions extends Omit<FluidHoverOptions, \"followFocus\"> {\n  /** Called with the nearest enabled item as the mouse moves over the container. */\n  onPick: (item: HTMLElement) => void;\n}\n\n/**\n * Fluid hover's picking without its highlight, for a list that already keeps its own active item and highlight (a listbox\n * with aria-activedescendant, a roving list): the mouse picks the nearest enabled item, gaps and padding included, and a\n * click in a gap goes to that item. Spread the result on the container.\n */\n// A hook only so callbacks that touch refs can be passed during render; it keeps no state.\nexport function useNearestItem<T extends HTMLElement>({ items: selector, axis = \"y\", isItemDisabled = defaultDisabled, gapClick = true, onPick }: NearestItemOptions) {\n  const nearest = (event: MouseEvent<T>) => {\n    if (!isOwn(event)) return null;\n    const items = [...event.currentTarget.querySelectorAll<HTMLElement>(selector)].filter(item => !isItemDisabled(item));\n    return pickNearest(items, event.clientX, event.clientY, axis);\n  };\n  return {\n    onPointerMove: (event: PointerEvent<T>) => { if (event.pointerType !== \"mouse\") return; const item = nearest(event); if (item) onPick(item); },\n    onClick: (event: MouseEvent<T>) => {\n      if (!gapClick) return;\n      const target = event.target as Element;\n      if (!target.isConnected || target.closest(selector) || target.closest(CONTROL)) return;\n      const item = nearest(event);\n      if (item) forwardClick(item, event);\n    },\n  };\n}\n\nconst sameRect = (a: FluidRect | null, b: FluidRect | null) =>\n  a === b || (!!a && !!b && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height);\n\nexport interface FluidHoverHighlightProps {\n  /** The state from useFluidHover (or any { rect, glide, session }). */\n  hover: Pick<FluidHoverState, \"rect\" | \"glide\" | \"session\">;\n  /** Radius, fill, z-index. The container must be position: relative; the highlight is placed at its top-left corner. */\n  className?: string;\n  /** Extra attributes for styling, such as data-tone. */\n  tone?: string;\n}\n\n/** The one highlight a fluid hover set renders: it springs between items on transforms, and fades in and out. */\nexport function FluidHoverHighlight({ hover, className, tone }: FluidHoverHighlightProps) {\n  const reduced = useReducedMotion() ?? false;\n  const { rect, glide, session } = hover;\n  const place = rect ? { x: rect.left, y: rect.top, width: rect.width, height: rect.height } : undefined;\n  return (\n    <AnimatePresence initial={false}>\n      {place ? (\n        <motion.span key={session} className={className} data-tone={tone} aria-hidden=\"true\"\n          style={{ position: \"absolute\", top: 0, left: 0, pointerEvents: \"none\" }}\n          initial={{ opacity: 0, ...place }}\n          animate={{ opacity: 1, ...place }}\n          exit={{ opacity: 0, transition: motionTokens.spring.fast.exit }}\n          transition={{ default: glide && !reduced ? motionTokens.spring.fast : { duration: 0 }, opacity: { duration: motionTokens.duration.fast } }} />\n      ) : null}\n    </AnimatePresence>\n  );\n}\n"}],"docs":"Docs and live preview: https://ui.teamofsilicons.com/mention-input","meta":{"tier":"free","kind":"component","docs":"https://ui.teamofsilicons.com/mention-input","markdown":"https://ui.teamofsilicons.com/mention-input.md","tags":["inputs","new"],"status":"stable"}}