{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"mention-menu","type":"registry:component","title":"Mentions and commands","description":"Type @ to pull in context or / for a command. The menu grows from the caret and your text becomes a chip.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/mention-menu.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * MENTIONS AND COMMANDS: @ for context, / for commands\n *\n *   closed     a plain prompt field\n *   open       type a trigger and a menu grows from the caret\n *   filtering  it narrows as you type; its height follows\n *   loading    async search: \"Searching…\" (earlier results stay)\n *   empty      \"Searching\" morphs into \"No matches for …\"\n *   closing    the menu sinks back toward the caret, keeping what\n *              it showed, instead of vanishing\n *   chip       Enter or Tab turns the typed \"@q3-sa\" into a chip\n *              right where it was typed\n *\n * A contentEditable field that reads like a textarea. Enter sends\n * (Shift+Enter for a new line); onSubmit gets the text, the\n * mentions and the ordered segments.\n * ───────────────────────────────────────────────────────── */\n\nexport interface MentionItem {\n  id: string;\n  label: string;\n  description?: string;\n  icon?: React.ReactNode;\n}\n\nexport interface MentionTrigger {\n  /** The character that opens the menu, e.g. \"@\" or \"/\". */\n  char: string;\n  /** The menu's name, e.g. \"Context\" or \"Commands\". */\n  label: string;\n  /** The items, or a search (sync or async) that gets the query. */\n  items: MentionItem[] | ((query: string) => MentionItem[] | Promise<MentionItem[]>);\n  /** Only at the start of a line, like slash commands. */\n  startOnly?: boolean;\n  /** How the chip looks: a tinted mention or a mono command. */\n  variant?: \"mention\" | \"command\";\n}\n\nexport type MentionSegment = { type: \"text\"; text: string } | { type: \"mention\"; trigger: string; id: string; label: string };\n\nexport interface MentionValue {\n  /** Plain text, with mentions written as \"@label\". */\n  text: string;\n  mentions: { trigger: string; id: string; label: string }[];\n  segments: MentionSegment[];\n}\n\nexport interface MentionInputHandle {\n  focus: () => void;\n  clear: () => void;\n}\n\nexport interface MentionInputProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\" | \"onSubmit\" | \"defaultValue\"> {\n  triggers: MentionTrigger[];\n  defaultValue?: MentionSegment[];\n  onChange?: (value: MentionValue) => void;\n  /** Enter sends; Shift+Enter adds a line. Without it, Enter adds a line. */\n  onSubmit?: (value: MentionValue) => void;\n  placeholder?: string;\n  disabled?: boolean;\n  /** Open the menu above the caret (composers at the bottom) or below it. */\n  side?: \"top\" | \"bottom\";\n  /** Open clear of this element (e.g. your composer) instead of over it. */\n  anchorRef?: React.RefObject<HTMLElement | null>;\n  ref?: React.Ref<MentionInputHandle>;\n}\n\ntype Token = { node: Text; start: number; end: number };\ntype Menu = { trigger: MentionTrigger; query: string; left: number; top: number; bottom: number; width: number };\n\nconst EASE = \"cubic-bezier(0.23,1,0.32,1)\";\nconst MENU_WIDTH = 300;\nconst ROW = 36;\nconst MAX_ROWS = 5;\nconst CHIP =\n  \"mx-px inline-block rounded-md bg-primary/10 px-1 align-baseline font-medium text-primary [&>span]:opacity-55\";\nconst COMMAND_CHIP =\n  \"mx-px inline-block rounded-md bg-muted px-1 align-baseline font-mono text-[0.92em] text-foreground [&>span]:opacity-45\";\nconst ENTER = \"animate-[ui-chip-in_280ms_cubic-bezier(0.23,1,0.32,1)_both] motion-reduce:animate-none\";\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\n// A light that sweeps across the label. A mask, not a text clip, so it reaches letters that are mid-morph.\nconst SHEEN =\n  \"text-foreground [mask-image:linear-gradient(90deg,rgb(0_0_0/0.45)_35%,#000_50%,rgb(0_0_0/0.45)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none] motion-reduce:text-foreground/70\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction makeChip(trigger: MentionTrigger | { char: string; variant?: string }, id: string, label: string, animate: boolean) {\n  const chip = document.createElement(\"span\");\n  chip.contentEditable = \"false\";\n  chip.dataset.mention = trigger.char;\n  chip.dataset.id = id;\n  chip.dataset.label = label;\n  chip.className = `${trigger.variant === \"command\" ? COMMAND_CHIP : CHIP}${animate ? ` ${ENTER}` : \"\"}`;\n  const mark = document.createElement(\"span\");\n  mark.textContent = trigger.char;\n  chip.append(mark, label);\n  return chip;\n}\n\n/** Read the field back into ordered segments. Handles the <br> and <div> lines browsers insert. */\nfunction serialize(root: HTMLElement): MentionSegment[] {\n  const segments: MentionSegment[] = [];\n  const text = (t: string) => {\n    if (!t) return;\n    const last = segments[segments.length - 1];\n    if (last?.type === \"text\") last.text += t;\n    else segments.push({ type: \"text\", text: t });\n  };\n  const walk = (parent: Node) => {\n    parent.childNodes.forEach((node, i) => {\n      if (node.nodeType === Node.TEXT_NODE) text((node as Text).data.replace(/ /g, \" \"));\n      else if (node instanceof HTMLElement) {\n        if (node.dataset.mention) segments.push({ type: \"mention\", trigger: node.dataset.mention, id: node.dataset.id ?? \"\", label: node.dataset.label ?? \"\" });\n        else if (node.tagName === \"BR\") text(\"\\n\");\n        else {\n          if (i > 0 && (node.tagName === \"DIV\" || node.tagName === \"P\")) text(\"\\n\");\n          walk(node);\n        }\n      }\n    });\n  };\n  walk(root);\n  const last = segments[segments.length - 1];\n  if (last?.type === \"text\") last.text = last.text.replace(/\\n$/, \"\");\n  return segments.filter((s) => s.type === \"mention\" || s.text);\n}\n\nfunction toValue(segments: MentionSegment[]): MentionValue {\n  return {\n    segments,\n    text: segments.map((s) => (s.type === \"text\" ? s.text : `${s.trigger}${s.label}`)).join(\"\"),\n    mentions: segments.flatMap((s) => (s.type === \"mention\" ? [{ trigger: s.trigger, id: s.id, label: s.label }] : [])),\n  };\n}\n\n/** Best matches first: label starts with the query, then a word in it does, then it appears anywhere. */\nfunction rank(items: MentionItem[], query: string) {\n  const q = query.toLowerCase();\n  if (!q) return items;\n  const score = (item: MentionItem) => {\n    const label = item.label.toLowerCase();\n    if (label.startsWith(q)) return 0;\n    if (label.split(/[\\s\\-_./]+/).some((w) => w.startsWith(q))) return 1;\n    if (label.includes(q)) return 2;\n    if (item.description?.toLowerCase().includes(q)) return 3;\n    return -1;\n  };\n  return items\n    .map((item) => ({ item, s: score(item) }))\n    .filter((x) => x.s >= 0)\n    .sort((a, b) => a.s - b.s)\n    .map((x) => x.item);\n}\n\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  const i = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;\n  if (i < 0) return <>{text}</>;\n  return (\n    <>\n      {text.slice(0, i)}\n      <span className=\"font-semibold text-foreground\">{text.slice(i, i + query.length)}</span>\n      {text.slice(i + query.length)}\n    </>\n  );\n}\n\nexport function MentionInput({\n  triggers,\n  defaultValue,\n  onChange,\n  onSubmit,\n  placeholder = \"Ask anything\",\n  disabled = false,\n  side = \"top\",\n  anchorRef,\n  className = \"\",\n  ref,\n  ...props\n}: MentionInputProps) {\n  const id = React.useId();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const editorRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const innerRef = React.useRef<HTMLDivElement>(null);\n  const rowRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const tokenRef = React.useRef<Token | null>(null);\n  const dismissedRef = React.useRef<{ node: Text; start: number } | null>(null);\n  const composingRef = React.useRef(false);\n\n  const [menu, setMenu] = React.useState<Menu | null>(null);\n  const [results, setResults] = React.useState<MentionItem[]>([]);\n  const [loading, setLoading] = React.useState(false);\n  const [active, setActive] = React.useState(0);\n  const [height, setHeight] = React.useState<number | null>(null);\n  const [highlight, setHighlight] = React.useState<{ top: number; ready: boolean }>({ top: 0, ready: false });\n\n  const emit = React.useCallback(() => {\n    const editor = editorRef.current;\n    if (!editor) return;\n    const segments = serialize(editor);\n    // Browsers leave a stray <br> behind when everything is deleted.\n    if (!segments.length && editor.childNodes.length) editor.replaceChildren();\n    onChange?.(toValue(segments));\n  }, [onChange]);\n\n  const closeMenu = React.useCallback(() => {\n    tokenRef.current = null;\n    setMenu(null);\n  }, []);\n\n  // Write the starting value once; after that the browser owns the field.\n  React.useLayoutEffect(() => {\n    const editor = editorRef.current;\n    if (!editor || !defaultValue?.length) return;\n    editor.replaceChildren(\n      ...defaultValue.map((s) =>\n        s.type === \"text\"\n          ? document.createTextNode(s.text)\n          : makeChip(triggers.find((t) => t.char === s.trigger) ?? { char: s.trigger }, s.id, s.label, false)\n      )\n    );\n    emit();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  /* Is the caret just after a trigger and a query? Then open (or update) the menu at the trigger. */\n  const detect = React.useCallback(() => {\n    const editor = editorRef.current;\n    const root = rootRef.current;\n    const selection = window.getSelection();\n    if (!editor || !root || !selection || !selection.rangeCount || !selection.isCollapsed || composingRef.current) return closeMenu();\n    const node = selection.anchorNode;\n    if (!node || node.nodeType !== Node.TEXT_NODE || !editor.contains(node)) return closeMenu();\n    const textNode = node as Text;\n    const before = textNode.data.slice(0, selection.anchorOffset);\n\n    let found: { trigger: MentionTrigger; at: number } | null = null;\n    for (const trigger of triggers) {\n      const at = before.lastIndexOf(trigger.char);\n      if (at < 0 || (found && at < found.at)) continue;\n      const query = before.slice(at + 1);\n      if (/\\s/.test(query) || query.length > 48) continue;\n      const previous = at > 0 ? before[at - 1] : null;\n      const sibling = textNode.previousSibling;\n      const lineStart = previous === \"\\n\" || (at === 0 && (!sibling || (sibling instanceof HTMLElement && sibling.tagName === \"BR\")));\n      const boundary = lineStart || (previous !== null ? /\\s/.test(previous) : true);\n      if (trigger.startOnly ? !lineStart : !boundary) continue;\n      found = { trigger, at };\n    }\n\n    const dismissed = dismissedRef.current;\n    if (!found || (dismissed && dismissed.node === textNode && dismissed.start === found.at)) return closeMenu();\n    dismissedRef.current = null;\n\n    tokenRef.current = { node: textNode, start: found.at, end: selection.anchorOffset };\n    const range = document.createRange();\n    range.setStart(textNode, found.at);\n    range.setEnd(textNode, found.at + 1);\n    const rect = range.getBoundingClientRect();\n    const box = root.getBoundingClientRect();\n    // Never wider than the field, and kept inside it.\n    const width = Math.min(MENU_WIDTH, box.width);\n    const left = Math.max(0, Math.min(rect.left - box.left - 10, box.width - width));\n    const { trigger, at } = found;\n    const edge = anchorRef?.current?.getBoundingClientRect();\n    const query = before.slice(at + 1);\n    setMenu((m) =>\n      m && m.trigger === trigger && m.query === query && m.left === left && m.width === width\n        ? m\n        : {\n            trigger,\n            query,\n            left,\n            width,\n            // Clear of the anchor when there is one (so it never covers the composer), else of the caret's line.\n            top: (edge ? edge.bottom : rect.bottom) - box.top + 6,\n            bottom: box.bottom - (edge ? edge.top : rect.top) + 6,\n          }\n    );\n  }, [triggers, closeMenu, anchorRef]);\n\n  // Results for the current trigger and query; stale async answers are dropped.\n  const trigger = menu?.trigger;\n  const query = menu?.query ?? \"\";\n  React.useEffect(() => {\n    if (!trigger) {\n      setResults([]);\n      setLoading(false);\n      return;\n    }\n    let current = true;\n    const source = trigger.items;\n    const out = typeof source === \"function\" ? source(query) : rank(source, query);\n    if (out instanceof Promise) {\n      setLoading(true);\n      out.then(\n        (items) => current && (setResults(items), setLoading(false)),\n        () => current && (setResults([]), setLoading(false))\n      );\n    } else {\n      setResults(out);\n      setLoading(false);\n    }\n    return () => {\n      current = false;\n    };\n  }, [trigger, query]);\n\n  React.useEffect(() => setActive(0), [results]);\n\n  // The list's height follows its content.\n  React.useLayoutEffect(() => {\n    const inner = innerRef.current;\n    if (!menu || !inner) {\n      setHeight(null);\n      return;\n    }\n    const measure = () => setHeight(inner.offsetHeight);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(inner);\n    return () => observer.disconnect();\n  }, [menu !== null]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // The highlight slides to the active row, and the row stays in view.\n  React.useLayoutEffect(() => {\n    const row = rowRefs.current[active];\n    const list = listRef.current;\n    if (!row || !list) return;\n    const top = row.offsetTop;\n    setHighlight((h) => (h.top === top && h.ready ? h : { top, ready: h.ready || results.length > 0 }));\n    if (row.offsetTop < list.scrollTop) list.scrollTop = row.offsetTop - 4;\n    else if (row.offsetTop + row.offsetHeight > list.scrollTop + list.clientHeight) list.scrollTop = row.offsetTop + row.offsetHeight - list.clientHeight + 4;\n  }, [active, results]);\n\n  React.useEffect(() => {\n    if (!menu) setHighlight({ top: 0, ready: false });\n  }, [menu]);\n\n  // Follow the caret while the field has focus.\n  React.useEffect(() => {\n    const onSelection = () => {\n      if (document.activeElement === editorRef.current) detect();\n    };\n    document.addEventListener(\"selectionchange\", onSelection);\n    return () => document.removeEventListener(\"selectionchange\", onSelection);\n  }, [detect]);\n\n  const select = (item: MentionItem) => {\n    const token = tokenRef.current;\n    const editor = editorRef.current;\n    if (!token || !menu || !editor) return;\n    const { node, start } = token;\n    const range = document.createRange();\n    range.setStart(node, start);\n    range.setEnd(node, Math.min(token.end, node.length));\n    range.deleteContents();\n    const space = document.createTextNode(\" \");\n    range.insertNode(space);\n    range.insertNode(makeChip(menu.trigger, item.id, item.label, true));\n    const caret = document.createRange();\n    caret.setStart(space, 1);\n    caret.collapse(true);\n    const selection = window.getSelection();\n    selection?.removeAllRanges();\n    selection?.addRange(caret);\n    closeMenu();\n    emit();\n  };\n\n  const submit = () => {\n    const editor = editorRef.current;\n    if (!editor || !onSubmit) return;\n    const segments = serialize(editor);\n    if (!segments.length) return;\n    onSubmit(toValue(segments));\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (e.nativeEvent.isComposing || composingRef.current) return;\n    if (menu) {\n      const count = results.length;\n      if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n        e.preventDefault();\n        if (count) setActive((a) => (a + (e.key === \"ArrowDown\" ? 1 : -1) + count) % count);\n        return;\n      }\n      if ((e.key === \"Enter\" || e.key === \"Tab\") && count) {\n        e.preventDefault();\n        select(results[active]);\n        return;\n      }\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        if (tokenRef.current) dismissedRef.current = { node: tokenRef.current.node, start: tokenRef.current.start };\n        closeMenu();\n        return;\n      }\n    }\n    if (e.key === \"Enter\" && !e.shiftKey && onSubmit) {\n      e.preventDefault();\n      submit();\n    }\n  };\n\n  React.useImperativeHandle(\n    ref,\n    () => ({\n      focus: () => editorRef.current?.focus(),\n      clear: () => {\n        editorRef.current?.replaceChildren();\n        closeMenu();\n        emit();\n      },\n    }),\n    [closeMenu, emit]\n  );\n\n  const listId = `${id}-list`;\n  const optionId = (i: number) => `${id}-option-${i}`;\n  const reduced = useReducedMotion();\n  const menuRef = React.useRef<HTMLDivElement>(null);\n\n  // What the menu shows. While it closes it keeps showing the last of it, so it can sink away instead of vanishing.\n  const live = menu ? { menu, results, loading, active } : null;\n  const [last, setLast] = React.useState(live);\n  if (live && (live.menu !== last?.menu || live.results !== last.results || live.loading !== last.loading || live.active !== last.active)) setLast(live);\n  const view = live ?? last;\n  const closing = !live && last !== null;\n  React.useLayoutEffect(() => {\n    if (!closing) return;\n    const el = menuRef.current;\n    if (!el || reduced) {\n      setLast(null);\n      return;\n    }\n    const away = side === \"top\" ? 4 : -4;\n    const leave = el.animate(\n      [\n        { opacity: 1, transform: \"none\" },\n        { opacity: 0, transform: `translateY(${away}px) scale(0.97)` },\n      ],\n      { duration: 160, easing: \"ease-out\", fill: \"forwards\" }\n    );\n    leave.onfinish = () => setLast(null);\n    return () => leave.cancel();\n  }, [closing, reduced, side]);\n  const command = view?.menu.trigger.variant === \"command\";\n\n  return (\n    <div ref={rootRef} className={`relative ${className}`} {...props}>\n      <div\n        ref={editorRef}\n        role=\"combobox\"\n        aria-label={placeholder}\n        aria-expanded={menu !== null}\n        aria-controls={menu ? listId : undefined}\n        aria-activedescendant={menu && results.length ? optionId(active) : undefined}\n        aria-autocomplete=\"list\"\n        aria-disabled={disabled || undefined}\n        data-placeholder={placeholder}\n        contentEditable={!disabled}\n        suppressContentEditableWarning\n        spellCheck\n        onInput={() => {\n          emit();\n          detect();\n        }}\n        onKeyDown={onKeyDown}\n        onBlur={closeMenu}\n        onCompositionStart={() => (composingRef.current = true)}\n        onCompositionEnd={() => {\n          composingRef.current = false;\n          detect();\n        }}\n        onPaste={(e) => {\n          e.preventDefault();\n          document.execCommand(\"insertText\", false, e.clipboardData.getData(\"text/plain\"));\n        }}\n        className=\"max-h-52 min-h-6 overflow-y-auto whitespace-pre-wrap outline-none [overflow-wrap:anywhere] empty:before:pointer-events-none empty:before:float-left empty:before:h-0 empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)]\"\n      />\n\n      {view && (\n        <div\n          ref={menuRef}\n          inert={closing}\n          aria-hidden={closing || undefined}\n          className={`absolute z-50 overflow-hidden rounded-2xl bg-popover text-popover-foreground shadow-[0_0_0_1px_var(--border)] motion-reduce:animate-none ${\n            closing ? \"pointer-events-none\" : \"animate-[ui-pop-in_200ms_cubic-bezier(0.23,1,0.32,1)_backwards]\"\n          }`}\n          style={{\n            left: view.menu.left,\n            width: view.menu.width,\n            ...(side === \"top\" ? { bottom: view.menu.bottom, transformOrigin: \"bottom left\" } : { top: view.menu.top, transformOrigin: \"top left\" }),\n            height: height ?? undefined,\n            transition: reduced ? \"none\" : `height 220ms ${EASE}`,\n          }}\n          // Keep the caret in the field while clicking the menu.\n          onPointerDown={(e) => e.preventDefault()}\n        >\n          <div ref={innerRef} className=\"p-1\">\n            <div className=\"flex h-7 items-center justify-between px-2.5 text-[11.5px] text-muted-foreground\">\n              <span>{view.menu.trigger.label}</span>\n              {/* Fades in while a search runs over results you can already see. */}\n              <span\n                aria-hidden=\"true\"\n                className={`size-3 rounded-full border-[1.5px] border-border border-t-muted-foreground motion-reduce:animate-none ${view.loading && view.results.length > 0 ? \"animate-spin\" : \"\"}`}\n                style={{ opacity: view.loading && view.results.length > 0 ? 1 : 0, transition: reduced ? \"none\" : `opacity 240ms ${MORPH}` }}\n              />\n            </div>\n            <div\n              ref={listRef}\n              id={listId}\n              role=\"listbox\"\n              aria-label={view.menu.trigger.label}\n              className=\"relative overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n              style={{ maxHeight: ROW * MAX_ROWS }}\n            >\n              {view.results.length > 0 && (\n                <span\n                  aria-hidden=\"true\"\n                  className=\"absolute inset-x-0 top-0 h-9 rounded-xl bg-accent\"\n                  style={{\n                    transform: `translateY(${highlight.top}px)`,\n                    transition: highlight.ready && !reduced ? `transform 180ms ${EASE}` : \"none\",\n                  }}\n                />\n              )}\n              {view.results.map((item, i) => (\n                <div\n                  key={item.id}\n                  ref={(el) => {\n                    rowRefs.current[i] = el;\n                  }}\n                  id={optionId(i)}\n                  role=\"option\"\n                  aria-selected={i === view.active}\n                  onPointerMove={() => i !== active && setActive(i)}\n                  onClick={() => select(item)}\n                  className=\"relative flex h-9 cursor-pointer items-center gap-2.5 rounded-xl px-2.5 text-[13px]\"\n                >\n                  {item.icon && (\n                    <span aria-hidden=\"true\" className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4 [&_svg]:stroke-[1.8]\">\n                      {item.icon}\n                    </span>\n                  )}\n                  <span className={`max-w-[65%] shrink-0 truncate text-foreground/85 ${command ? \"font-mono text-[12.5px]\" : \"\"}`}>\n                    {command && <span className=\"text-muted-foreground\">{view.menu.trigger.char}</span>}\n                    <Highlight text={item.label} query={view.menu.query} />\n                  </span>\n                  {item.description && <span className=\"min-w-0 flex-1 truncate text-[12px] text-muted-foreground\">{item.description}</span>}\n                </div>\n              ))}\n              {view.results.length === 0 && (\n                // One line: \"Searching\" morphs into \"No matches for …\" as the answer comes back.\n                <div role=\"status\" className=\"flex h-9 items-center px-2.5 text-[12.5px] text-muted-foreground\">\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"flex shrink-0 items-center overflow-hidden\"\n                    style={{ width: view.loading ? 20 : 0, opacity: view.loading ? 1 : 0, transition: reduced ? \"none\" : `width 320ms ${MORPH}, opacity 240ms ${MORPH}` }}\n                  >\n                    <span className={`size-3 rounded-full border-[1.5px] border-border border-t-muted-foreground motion-reduce:animate-none ${view.loading ? \"animate-spin\" : \"\"}`} />\n                  </span>\n                  <span className={`min-w-0 truncate ${view.loading ? SHEEN : \"\"}`}>\n                    <TextMorph>{view.loading ? \"Searching\" : view.menu.query ? `No matches for “${view.menu.query}”` : \"Nothing here yet\"}</TextMorph>\n                  </span>\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-sheen":{"from":{"mask-position":"150% 0"},"to":{"mask-position":"-50% 0"}},"@keyframes ui-pop-in":{"from":{"opacity":"0","transform":"translateY(-4px) scale(0.96)"},"to":{"opacity":"1","transform":"none"}},"@keyframes ui-chip-in":{"from":{"opacity":"0.35","transform":"scale(0.9)"},"to":{"opacity":"1","transform":"none"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/mention-menu"}