{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mention-input",
  "title": "Mention Input",
  "description": "The comment box where typing `@` opens a list of people and picking one types their handle in — the completion that lives inside the prose, anchored under the caret rather than under the field. Reach for it wherever text is addressed to somebody: a code-review or pull-request comment, an issue or ticket description, a chat, DM or channel composer, a task assignment note, a document or design comment thread, a support reply, a release note that credits people, and an AI prompt box where `@` should pull in a file, a table or a teammate. The trigger is a prop, so the same field does `#` for issues, milestones or channels, `/` for commands and `:` for emoji. Common asks it answers: \"mention input react\", \"@ mention textarea\", \"react mentions component\", \"autocomplete inside a textarea\", \"tag someone in a comment box\", \"@mention dropdown react\", \"slack style mention input\", \"github comment @ autocomplete\", \"shadcn mention input\", \"shadcn textarea autocomplete\", \"react-mentions alternative\", \"tribute.js alternative\", \"textarea caret position javascript\", \"get caret coordinates in textarea\", \"position dropdown at cursor react\", \"how to know where the cursor is in a textarea\", \"mirror div caret\", \"insert text at cursor react\", \"insertText execCommand react\", \"keep undo history when inserting text\", \"cmd+z broken after setState textarea\", \"mention autocomplete ignores email addresses\", \"detect @ but not in email\", \"IME enter selects autocomplete item\", \"日本語入力 変換確定 enter 誤爆\", \"メンション入力 react\", \"テキストエリア キャレット 座標\". Official shadcn/ui has none of this, and the measurement is not close. Fetching all sixty-three registry entries today (sixty-two are fetchable; questionnaire is listed and 404s on both style tracks) and grepping 245 KB of source: mention, selectionStart, selectionEnd, setSelectionRange, execCommand, insertText, contentEditable, autocomplete and aria-activedescendant are every one of them zero hits. The single caret match is input-otp drawing a fake blinking one. Its textarea is twenty-three lines of styled element, and its combobox is a @base-ui/react popover hung off its own input group — a field whose whole value is the thing you picked, which is the opposite shape from a paragraph with three names in it. So an agent asked for a mention box builds it out of a plain textarea and a div, and the four things that make it hard are exactly the four it will get wrong. The first is that the menu has to appear at the caret, and the browser will not say where the caret is. There is no API for it: selectionStart is an index into a string and nothing converts one to pixels. The only way is to lay the text out a second time in a hidden mirror wearing the field's font, padding, border, width and wrapping, put a marker where the caret would be, and measure that — which is why the version that skips it pins the menu to a corner of the field, and offers suggestions next to line one for an `@` typed on line three. Details that decide whether the mirror is right: a copied border-width lays out as nothing without a border-style, getComputedStyle resolves width to the content box whichever box-sizing is in force (so copying box-sizing shrinks the column and rewraps every line), a trailing newline needs something after it or the last line never exists, and the field's own scroll has to be subtracted because the text moves under a caret that does not. The second is that finding the trigger is a word-boundary problem, not a search. Scan backwards for an `@` and every email address in the box opens the menu — and it is not an edge case, it is the first thing anyone pastes into a comment. A trigger welded to the end of a word is not a trigger. The other end matters as much: the query has to stop at the first space, or one stray `@` turns the rest of the paragraph into a search term and the list quietly goes empty. Both rules live in an exported pure function, findMentionQuery, along with a bound on how far back it scans, so a 40 KB comment costs the same as a short one. The third is the keyboard, and it is where this component makes its strongest claim. While the menu is open, Up, Down and Enter belong to the list; while it is closed they belong to the textarea, and Shift+Enter is a new line either way. But there is a third owner nobody accounts for: an open IME conversion. Enter commits the reading, the arrows walk the candidate window, and a mention box that takes those keys leaves a Japanese, Chinese or Korean writer unable to finish a word — they press Enter to accept 山田 and a name they never chose lands in the text instead. Every key is checked against isComposing and against keyCode 229, which is what the browsers that clear isComposing early report instead, and compositionstart is tracked on top of both. The menu itself keeps updating during the conversion, because suppressing it would leave that same writer typing blind; it is the keys that are borrowed, not the list. The fourth is undo, and it is invisible until someone hits Cmd+Z. Writing the new value with setState looks identical on screen and empties the browser's undo stack, so one undo after picking a name wipes the entire comment rather than stepping back over the insert — because as far as the browser is concerned, nobody typed anything. The insert goes through execCommand(\"insertText\") instead, deprecated and still the only way to put text into a field as though a person had, so the undo entry exists and the input event fires like any keystroke. Where that is unavailable the fallback writes through the prototype's value setter rather than the element, because React installs its own value property on the node to track changes: assign to the element and the tracker updates as a side effect, the input event that follows is discarded as \"no change\", and a controlled field ends up showing text its owner never received. Accessibility is the ARIA 1.2 editable-combobox pattern, complete rather than approximated: the textarea carries role=\"combobox\" with aria-expanded present whether the menu is open or shut, aria-controls and aria-autocomplete=\"list\", and the highlighted row is tracked with aria-activedescendant so DOM focus never leaves the text — moving it into the list would take the caret with it and there would be nothing left to insert into. The cost is stated plainly: a field with that role is announced as a combobox even while no menu is open, which is the price of the menu being reachable at all. The live region follows char-counter's discipline rather than the reflex — announcing the count on every keystroke makes a screen reader read numbers over the letters being typed, so the count is spoken once when the menu opens, and again only when the matches run out. That last one matters more than it looks: no matches usually closes the menu, and a writer who cannot see it vanish is otherwise told nothing at all about the name they just typed. The API: items of { id, label, value?, description?, disabled? } — value is the text typed in when the label has a space in it, since `@Ada Lovelace` is not a token anything can find again and `@ada` is. Controlled with value and onChange or uncontrolled with defaultValue, on a real <textarea> that forwards its ref, so labels, react-hook-form and native validation keep working. filter takes your own matcher or false for a server-side one, and onQueryChange reports the query as it changes (and null when it closes) for the async lookup, with loading and showEmpty for the states that lookup goes through. Also trigger, maxItems, maxQueryLength, allowSpaces for names with spaces, toInsertText, renderItem, and labels for every string. The default matcher folds accents so \"jose\" finds José, and ranks a match at the start of a word above one buried inside it, so \"@love\" puts Ada Lovelace above Clover. useMentionInput returns the whole behaviour as props to spread onto a field you lay out yourself — it takes onInput rather than onChange precisely so it does not collide with the value plumbing you already have — and findMentionQuery, defaultMentionFilter, insertMentionText and measureCaretPosition are exported for the times you want one part of it. Within pulld it is the third layer on the comment box: autosize-textarea is the field that grows, char-counter is the count underneath it, and this is what happens inside it — spread the hook onto AutosizeTextarea and all three compose. It is distinct from multi-select and tag-input, which are fields whose value is a list you assembled; here the value is prose that happens to have names in it. Distinct from command-palette too, which owns the whole screen to run a command rather than living in one field. One file, zero dependencies — not even an icon — and every colour is a shadcn token, so it follows light and dark.",
  "files": [
    {
      "path": "registry/ui/mention-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface MentionItem {\n  /** Stable identifier handed back to `onMentionSelect` — the thing you actually store. */\n  id: string\n  /** What the row reads as, and what gets typed in unless `value` says otherwise. */\n  label: string\n  /**\n   * The text inserted after the trigger, when it differs from the label. Reach for it whenever the\n   * label has a space in it: `{ label: \"Ada Lovelace\", value: \"ada\" }` types `@ada`, which is a\n   * token the parser can find again — `@Ada Lovelace` is not.\n   */\n  value?: string\n  /** Second line on the row: a handle, an email, a team. Matched last when filtering. */\n  description?: string\n  disabled?: boolean\n}\n\nexport interface MentionQuery {\n  trigger: string\n  /** What has been typed after the trigger, without it. */\n  query: string\n  /** Index of the trigger character in the field's value. */\n  start: number\n  /** The caret — the end of the range a chosen item replaces. */\n  end: number\n}\n\nexport interface FindMentionQueryOptions {\n  /** The character that opens the menu. `\"@\"` for people, `\"#\"` for issues, `\":\"` for emoji. */\n  trigger?: string\n  /** How far back the search runs, which is also the longest query it will report. */\n  maxQueryLength?: number\n  /** Let a query span spaces (for full names). A newline still ends it. */\n  allowSpaces?: boolean\n}\n\ntype EditableField = HTMLTextAreaElement | HTMLInputElement\n\n// Letters, digits and underscore — the characters a trigger glued to the end of one must not follow.\nconst WORDISH = /[\\p{L}\\p{N}_]/u\n\nconst NO_ITEMS: never[] = []\n\n// Distinguishes \"no query has been reported yet\" from \"the query is gone\"; a real key always holds\n// two separators, and the empty string is the closed menu.\nconst UNREPORTED = \"\\u0000unreported\"\n\n/**\n * Reads the mention being typed at `caret`, or null if there isn't one.\n *\n * Pure, so the two rules that decide whether a menu should be open at all can be tested without a\n * browser: a trigger only counts at a word boundary (otherwise every email address in the box opens\n * the menu), and the query ends at the first space (otherwise one stray `@` turns the rest of the\n * paragraph into a search term).\n */\nexport function findMentionQuery(\n  value: string,\n  caret: number,\n  { trigger = \"@\", maxQueryLength = 32, allowSpaces = false }: FindMentionQueryOptions = {}\n): MentionQuery | null {\n  if (!trigger || caret < 0 || caret > value.length) return null\n\n  // Bounded so a 40 KB comment costs the same as a short one, and so the bound is also the answer to\n  // \"how long may a query get\".\n  const floor = Math.max(0, caret - maxQueryLength - trigger.length)\n  for (let i = caret - trigger.length; i >= floor; i--) {\n    if (value.startsWith(trigger, i)) {\n      const before = i > 0 ? value[i - 1] : \"\"\n      // The email test. `name@example.com` has a trigger in it and must never open a menu, so a\n      // trigger welded to the end of a word is not a trigger. Neither is the second `@` of `@@`.\n      if (before && (WORDISH.test(before) || trigger.includes(before))) return null\n      return { trigger, query: value.slice(i + trigger.length, caret), start: i, end: caret }\n    }\n    const ch = value[i + trigger.length - 1]\n    if (ch === undefined) return null\n    // A line break always ends the query; a space does unless the caller allows one.\n    if (ch === \"\\n\" || ch === \"\\r\") return null\n    if (!allowSpaces && /\\s/.test(ch)) return null\n  }\n  return null\n}\n\n/** Lower-cases and drops accents, so \"jose\" finds \"José\" and \"Ångström\" answers to \"angstrom\". */\nfunction fold(text: string): string {\n  // NFD splits a letter from its accent; \\p{M} is the accent. Nothing downstream removes marks, so\n  // this line is doing the work rather than repeating it.\n  return text.normalize(\"NFD\").replace(/\\p{M}/gu, \"\").toLowerCase()\n}\n\n/**\n * The matcher used when no `filter` is given: accent-insensitive, and ranked so a match at the start\n * of a word beats one buried inside it — \"@love\" puts Ada Lovelace above Clover. The description is\n * searched too, but last, so an email or a team name still finds someone without swamping the list.\n */\nexport function defaultMentionFilter<T extends MentionItem>(items: T[], query: string): T[] {\n  const q = fold(query.trim())\n  if (!q) return items\n  const ranked: Array<{ item: T; rank: number; index: number }> = []\n  items.forEach((item, index) => {\n    const name = fold(`${item.label} ${item.value ?? \"\"}`)\n    const detail = fold(item.description ?? \"\")\n    const rank =\n      name.startsWith(q) || name.includes(` ${q}`)\n        ? 0\n        : name.includes(q)\n          ? 1\n          : detail.includes(q)\n            ? 2\n            : -1\n    if (rank >= 0) ranked.push({ item, rank, index })\n  })\n  // Stable within a rank: the order you passed the items in is the order equals come back in.\n  ranked.sort((a, b) => a.rank - b.rank || a.index - b.index)\n  return ranked.map((entry) => entry.item)\n}\n\n/**\n * Replaces `[start, end)` with `text` through the browser's own editing pipeline, and returns\n * whether that pipeline was available.\n *\n * This is the part every hand-rolled mention box gets wrong. Writing the new string with `setState`\n * looks identical on screen and quietly empties the undo stack: one Cmd+Z after picking a name wipes\n * the whole comment instead of stepping back over the insert, because as far as the browser is\n * concerned nobody typed anything. `execCommand(\"insertText\")` is deprecated and still the only way\n * to put text into a field as though a person had, so the undo entry exists — and it fires `input`,\n * so React hears about it like any keystroke.\n *\n * The fallback writes through the prototype's value setter rather than the element, because React\n * installs its own `value` property on the node to track changes; assigning to the element updates\n * that tracker as a side effect and the `input` event that follows is discarded as \"no change\",\n * leaving a controlled field showing text its owner never received.\n */\nexport function insertMentionText(\n  el: EditableField,\n  start: number,\n  end: number,\n  text: string\n): boolean {\n  el.focus()\n  el.setSelectionRange(start, end)\n  const doc = el.ownerDocument\n  let inserted = false\n  try {\n    inserted = typeof doc?.execCommand === \"function\" && doc.execCommand(\"insertText\", false, text)\n  } catch {\n    inserted = false\n  }\n  if (inserted) return true\n\n  const next = `${el.value.slice(0, start)}${text}${el.value.slice(end)}`\n  const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), \"value\")?.set\n  if (setter) setter.call(el, next)\n  else el.value = next\n  const caret = start + text.length\n  el.setSelectionRange(caret, caret)\n  el.dispatchEvent(new Event(\"input\", { bubbles: true }))\n  return false\n}\n\nexport interface CaretPosition {\n  /** Top of the caret's line, in the field's own border-box coordinates. */\n  top: number\n  left: number\n  /** Height of one line — how far below `top` the menu has to start to clear the text. */\n  height: number\n}\n\n// Everything that moves text around inside the box. Copied onto the mirror so it wraps identically;\n// miss one and the measured caret drifts a little further with every line.\nconst MIRROR_PROPS = [\n  \"font-style\",\n  \"font-variant\",\n  \"font-weight\",\n  \"font-stretch\",\n  \"font-size\",\n  \"font-size-adjust\",\n  \"font-family\",\n  \"line-height\",\n  \"letter-spacing\",\n  \"word-spacing\",\n  \"text-transform\",\n  \"text-indent\",\n  \"text-align\",\n  \"text-rendering\",\n  \"tab-size\",\n  \"direction\",\n  \"overflow-wrap\",\n  \"word-break\",\n  \"padding-top\",\n  \"padding-right\",\n  \"padding-bottom\",\n  \"padding-left\",\n  \"border-top-width\",\n  \"border-right-width\",\n  \"border-bottom-width\",\n  \"border-left-width\",\n]\n\n/**\n * Where the caret is inside a textarea or input, relative to that element's own top-left corner.\n *\n * The browser will not tell you. There is no API for the caret's pixel position in a form field —\n * `selectionStart` is an index into a string, and nothing converts one to the other. So the text up\n * to that index is laid out a second time, in a hidden element wearing the field's font, padding,\n * border, width and wrapping, with a marker span where the caret would be; the marker's offset is\n * the answer. Skip this and the menu has to be pinned to a corner of the field, which is wrong the\n * moment somebody types an `@` on the third line and the suggestions appear next to the first.\n *\n * Returns null off the browser, and wherever the element is not a real node — the server render and\n * the first client pass both have to survive it.\n */\nexport function measureCaretPosition(el: EditableField, index: number): CaretPosition | null {\n  if (typeof window === \"undefined\" || typeof el?.getBoundingClientRect !== \"function\") return null\n  const doc = el.ownerDocument\n  if (!doc?.body) return null\n\n  const style = window.getComputedStyle(el)\n  const mirror = doc.createElement(\"div\")\n  for (const prop of MIRROR_PROPS) mirror.style.setProperty(prop, style.getPropertyValue(prop))\n  // A copied border-width lays out as nothing on its own: a border with no style is not there.\n  mirror.style.borderStyle = \"solid\"\n  mirror.style.borderColor = \"transparent\"\n  // getComputedStyle resolves `width` to the content box whichever box-sizing is in force, so the\n  // mirror is declared content-box and handed exactly that width, plus the same padding and border.\n  // Copying box-sizing instead would shrink the text's column by the padding and rewrap every line.\n  mirror.style.boxSizing = \"content-box\"\n  mirror.style.width = style.width\n  mirror.style.height = \"auto\"\n  mirror.style.position = \"absolute\"\n  mirror.style.top = \"0\"\n  mirror.style.left = \"-9999px\"\n  mirror.style.visibility = \"hidden\"\n  mirror.style.pointerEvents = \"none\"\n  // A textarea wraps and keeps its spaces; a single-line input does neither.\n  mirror.style.whiteSpace = el.tagName === \"TEXTAREA\" ? \"pre-wrap\" : \"pre\"\n\n  try {\n    mirror.textContent = el.value.slice(0, index)\n    const marker = doc.createElement(\"span\")\n    // Something has to sit after a trailing newline or that last line box never exists and the\n    // caret is measured one line high. The rest of the text goes in so the marker wraps where the\n    // real caret does.\n    marker.textContent = el.value.slice(index) || \".\"\n    mirror.appendChild(marker)\n    doc.body.appendChild(mirror)\n\n    const fontSize = parseFloat(style.fontSize) || 16\n    return {\n      // offsetTop and offsetLeft are measured from the padding edge, so the border is added back to\n      // land in the field's border-box coordinates — the ones the menu is positioned in. Scroll is\n      // subtracted because the field's text moves under a caret that stays where it is.\n      top: marker.offsetTop + (parseFloat(style.borderTopWidth) || 0) - el.scrollTop,\n      left: marker.offsetLeft + (parseFloat(style.borderLeftWidth) || 0) - el.scrollLeft,\n      height: parseFloat(style.lineHeight) || fontSize * 1.5,\n    }\n  } finally {\n    mirror.remove()\n  }\n}\n\nexport interface MentionLabels {\n  /** Accessible name for the suggestion list. */\n  listbox: string\n  loading: string\n  empty: string\n  /** Spoken once when the menu opens with results — not on every keystroke. */\n  results: (count: number) => string\n}\n\nexport const defaultMentionLabels: MentionLabels = {\n  listbox: \"Mention suggestions\",\n  loading: \"Loading…\",\n  empty: \"No matches\",\n  results: (count) => `${count} ${count === 1 ? \"suggestion\" : \"suggestions\"}`,\n}\n\nexport interface UseMentionInputOptions<T extends MentionItem> {\n  items: T[]\n  trigger?: string\n  /** `false` to keep `items` exactly as given (you are filtering on the server). */\n  filter?: ((items: T[], query: string) => T[]) | false\n  maxItems?: number\n  maxQueryLength?: number\n  allowSpaces?: boolean\n  /** Keeps the menu open with a loading row while your fetch is in flight. */\n  loading?: boolean\n  /** Keep the menu open on no matches instead of closing it. */\n  showEmpty?: boolean\n  labels?: Partial<MentionLabels>\n  /** Called with the query as it changes, and with null when the menu closes — where async lookups go. */\n  onQueryChange?: (query: MentionQuery | null) => void\n  onMentionSelect?: (item: T, query: MentionQuery) => void\n  /** The text typed in. Defaults to the trigger, the item's value or label, and a trailing space. */\n  toInsertText?: (item: T, trigger: string) => string\n}\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\nfunction sameQuery(a: MentionQuery | null, b: MentionQuery | null): boolean {\n  if (a === b) return true\n  if (!a || !b) return false\n  return a.trigger === b.trigger && a.start === b.start && a.end === b.end && a.query === b.query\n}\n\n/**\n * The whole mention behaviour, without any markup — spread `fieldProps` onto a `<textarea>` you own\n * (pulld's autosize-textarea, a react-hook-form field, an `<input>`) and render the list yourself.\n *\n * `fieldProps` takes `onInput` rather than `onChange` precisely so it does not collide with the\n * value plumbing you already have: React fires both for the same keystroke, so your `onChange` stays\n * yours.\n */\nexport function useMentionInput<T extends MentionItem>({\n  items,\n  trigger = \"@\",\n  filter,\n  maxItems = 8,\n  maxQueryLength = 32,\n  allowSpaces = false,\n  loading = false,\n  showEmpty = false,\n  labels,\n  onQueryChange,\n  onMentionSelect,\n  toInsertText,\n}: UseMentionInputOptions<T>) {\n  const fieldRef = React.useRef<EditableField | null>(null)\n  const panelRef = React.useRef<HTMLDivElement | null>(null)\n  // The trigger position the writer dismissed with Escape, so typing on does not reopen it. -1 is\n  // \"nothing dismissed\": a real position is an index, and never negative.\n  const dismissedRef = React.useRef(-1)\n  const composingRef = React.useRef(false)\n\n  const [query, setQuery] = React.useState<MentionQuery | null>(null)\n  const [active, setActive] = React.useState<{ key: string; index: number }>({ key: \"\", index: 0 })\n  const [position, setPosition] = React.useState<{ top: number; left: number } | null>(null)\n  const [announcement, setAnnouncement] = React.useState(\"\")\n\n  const text = { ...defaultMentionLabels, ...labels }\n  const reactId = React.useId()\n  const listboxId = `${reactId}-mentions`\n  const optionId = React.useCallback((index: number) => `${listboxId}-opt-${index}`, [listboxId])\n\n  const setField = React.useCallback((node: EditableField | null) => {\n    fieldRef.current = node\n  }, [])\n\n  const refresh = React.useCallback(\n    (from?: EditableField | null) => {\n      const el = from ?? fieldRef.current\n      const caret = el?.selectionStart\n      let next =\n        el && typeof caret === \"number\"\n          ? findMentionQuery(el.value, caret, { trigger, maxQueryLength, allowSpaces })\n          : null\n      if (!next) dismissedRef.current = -1\n      else if (dismissedRef.current === next.start) next = null\n      setQuery((prev) => (sameQuery(prev, next) ? prev : next))\n    },\n    [trigger, maxQueryLength, allowSpaces]\n  )\n\n  const shown = React.useMemo(() => {\n    if (!query) return NO_ITEMS\n    const list = filter === false ? items : (filter ?? defaultMentionFilter)(items, query.query)\n    return maxItems > 0 ? list.slice(0, maxItems) : list\n  }, [query, items, filter, maxItems])\n\n  const open = query !== null && (shown.length > 0 || loading || showEmpty)\n\n  // Latest-callback refs: the effects below fire on state, not on how often the parent re-renders.\n  const queryChangeRef = React.useRef(onQueryChange)\n  const selectRef = React.useRef(onMentionSelect)\n  React.useEffect(() => {\n    queryChangeRef.current = onQueryChange\n    selectRef.current = onMentionSelect\n  })\n\n  // One string identifies the mention being typed, which is all three of the things that have to be\n  // compared: two different `@`s in the same box are different queries even when the text matches.\n  const queryKey = query ? `${query.trigger}\\u0000${query.start}\\u0000${query.query}` : \"\"\n\n  // Report the query as it changes, but not the emptiness it starts life with: an async consumer\n  // would otherwise answer a question nobody asked, on every mount.\n  const reportedRef = React.useRef(UNREPORTED)\n  React.useEffect(() => {\n    if (reportedRef.current === queryKey) return\n    const first = reportedRef.current === UNREPORTED\n    reportedRef.current = queryKey\n    if (!first) queryChangeRef.current?.(query)\n  }, [queryKey, query])\n\n  // The highlight belongs to one query rather than to the component, so it is derived rather than\n  // reset: a new mention starts at the top instead of at whatever row the last one reached, and a\n  // list that shrinks under it pulls it back inside without a second render to correct itself.\n  const activeIndex =\n    active.key === queryKey ? Math.min(active.index, Math.max(0, shown.length - 1)) : 0\n  const setActiveIndex = React.useCallback(\n    (index: number) => setActive({ key: queryKey, index }),\n    [queryKey]\n  )\n\n  const measure = React.useCallback(() => {\n    const el = fieldRef.current\n    if (!el || !query) return null\n    const caret = measureCaretPosition(el, query.start)\n    if (!caret) return null\n    const panel = panelRef.current\n    const panelHeight = panel?.offsetHeight ?? 0\n    const panelWidth = panel?.offsetWidth ?? 0\n    // A caret scrolled out of a tall field would otherwise drag the menu out of the box with it.\n    const caretTop = Math.min(Math.max(caret.top, 0), el.clientHeight)\n    const rect = el.getBoundingClientRect()\n    const below = window.innerHeight - (rect.top + caretTop + caret.height)\n    const above = rect.top + caretTop\n    // Flip above the line only when the menu genuinely does not fit below and does fit above —\n    // otherwise a field near the bottom of a short page trades one clipped menu for another.\n    const flip = panelHeight > 0 && below < panelHeight && above > panelHeight\n    const maxLeft = panelWidth > 0 ? Math.max(0, el.clientWidth - panelWidth) : Number.POSITIVE_INFINITY\n    return {\n      top: el.offsetTop + (flip ? caretTop - panelHeight : caretTop + caret.height),\n      left: el.offsetLeft + Math.min(Math.max(caret.left, 0), maxLeft),\n    }\n  }, [query])\n\n  // Measured before paint, so the menu is never seen at the last mention's position.\n  useIsomorphicLayoutEffect(() => {\n    const next = open ? measure() : null\n    setPosition((prev) =>\n      prev === next || (prev && next && prev.top === next.top && prev.left === next.left)\n        ? prev\n        : next\n    )\n  }, [open, measure])\n\n  // Scrolling the field moves the text under the caret, and resizing rewraps it. Both move the\n  // anchor without changing a thing this component holds in state.\n  React.useEffect(() => {\n    const el = fieldRef.current\n    if (!open || !el || typeof window === \"undefined\") return\n    let frame = 0\n    const remeasure = () => {\n      window.cancelAnimationFrame(frame)\n      frame = window.requestAnimationFrame(() => {\n        const next = measure()\n        setPosition((prev) =>\n          prev === next || (prev && next && prev.top === next.top && prev.left === next.left)\n            ? prev\n            : next\n        )\n      })\n    }\n    el.addEventListener(\"scroll\", remeasure)\n    window.addEventListener(\"resize\", remeasure)\n    return () => {\n      window.cancelAnimationFrame(frame)\n      el.removeEventListener(\"scroll\", remeasure)\n      window.removeEventListener(\"resize\", remeasure)\n    }\n  }, [open, measure])\n\n  // Announced on the way in and on the way to empty, never on every keystroke: a live region tied to\n  // a count that changes as fast as typing does reads the count out instead of the letters.\n  const bandRef = React.useRef(\"closed\")\n  const labelsRef = React.useRef(text)\n  labelsRef.current = text\n  React.useEffect(() => {\n    // Keyed to the query rather than to the panel: running out of matches usually closes the menu,\n    // and a writer who cannot see it vanish is otherwise told nothing at all about the name they\n    // just typed. That silence is the one moment they most need a word.\n    const band = query === null\n      ? \"closed\"\n      : loading && shown.length === 0\n        ? \"loading\"\n        : shown.length === 0\n          ? \"empty\"\n          : \"results\"\n    if (band === bandRef.current) return\n    bandRef.current = band\n    const spoken = labelsRef.current\n    setAnnouncement(\n      band === \"closed\"\n        ? \"\"\n        : band === \"loading\"\n          ? spoken.loading\n          : band === \"empty\"\n            ? spoken.empty\n            : spoken.results(shown.length)\n    )\n  }, [query, loading, shown.length])\n\n  const select = React.useCallback(\n    (item: T) => {\n      const el = fieldRef.current\n      if (!el || !query || item.disabled) return\n      const inserted = toInsertText\n        ? toInsertText(item, query.trigger)\n        : `${query.trigger}${item.value ?? item.label} `\n      insertMentionText(el, query.start, query.end, inserted)\n      // The default insertion ends in a space, which closes the menu on its own. A custom one might\n      // not, and reopening the menu on the name somebody just picked is the wrong answer to that.\n      dismissedRef.current = query.start\n      setQuery(null)\n      selectRef.current?.(item, query)\n    },\n    [query, toInsertText]\n  )\n\n  const dismiss = React.useCallback(() => {\n    if (query) dismissedRef.current = query.start\n    setQuery(null)\n  }, [query])\n\n  const move = React.useCallback(\n    (delta: number) => {\n      let next = activeIndex\n      // Wraps, and steps over rows that cannot be chosen rather than parking the highlight on one.\n      for (let step = 0; step < shown.length; step++) {\n        next = (next + delta + shown.length) % shown.length\n        if (!shown[next]?.disabled) {\n          setActiveIndex(next)\n          return\n        }\n      }\n    },\n    [shown, activeIndex, setActiveIndex]\n  )\n\n  const handleKeyDown = React.useCallback(\n    (event: React.KeyboardEvent<HTMLElement>) => {\n      if (event.defaultPrevented) return\n      // The IME owns these keys while a conversion is open: Enter commits the reading, the arrows\n      // walk the candidate window. Taking either turns a Japanese, Chinese or Korean writer's field\n      // into one that cannot finish a word — and `keyCode === 229` is the browsers that say so\n      // without setting isComposing on the keydown.\n      if (composingRef.current || event.nativeEvent.isComposing || event.keyCode === 229) return\n      if (!open) return\n      const item = shown[activeIndex]\n      if (event.key === \"ArrowDown\") {\n        event.preventDefault()\n        move(1)\n      } else if (event.key === \"ArrowUp\") {\n        event.preventDefault()\n        move(-1)\n      } else if (event.key === \"Escape\") {\n        event.preventDefault()\n        dismiss()\n      } else if ((event.key === \"Enter\" && !event.shiftKey) || event.key === \"Tab\") {\n        // Shift+Enter is still a new line, and Tab still leaves the field when nothing is highlighted.\n        if (!item || item.disabled) return\n        event.preventDefault()\n        select(item)\n      }\n    },\n    [open, shown, activeIndex, move, dismiss, select]\n  )\n\n  const fieldProps = {\n    ref: setField,\n    role: \"combobox\" as const,\n    \"aria-expanded\": open,\n    \"aria-controls\": open ? listboxId : undefined,\n    \"aria-autocomplete\": \"list\" as const,\n    \"aria-haspopup\": \"listbox\" as const,\n    \"aria-activedescendant\": open && shown.length > 0 ? optionId(activeIndex) : undefined,\n    onInput: (event: React.FormEvent<EditableField>) => refresh(event.currentTarget),\n    onSelect: (event: React.SyntheticEvent<EditableField>) => refresh(event.currentTarget),\n    onClick: (event: React.MouseEvent<EditableField>) => refresh(event.currentTarget),\n    onBlur: () => setQuery(null),\n    onKeyDown: handleKeyDown,\n    onCompositionStart: () => {\n      composingRef.current = true\n    },\n    onCompositionEnd: (event: React.CompositionEvent<EditableField>) => {\n      composingRef.current = false\n      // The committed reading is only in the value now, so the query is only right now.\n      refresh(event.currentTarget)\n    },\n  }\n\n  const listboxProps = {\n    ref: panelRef,\n    id: listboxId,\n    role: \"listbox\" as const,\n    \"aria-label\": text.listbox,\n    // Pressing an option must not blur the field: the caret is the anchor for the insert.\n    onMouseDown: (event: React.MouseEvent) => event.preventDefault(),\n  }\n\n  return {\n    fieldRef,\n    panelRef,\n    query,\n    open,\n    items: shown,\n    activeIndex,\n    setActiveIndex,\n    select,\n    dismiss,\n    refresh,\n    position,\n    announcement,\n    labels: text,\n    listboxId,\n    optionId,\n    fieldProps,\n    listboxProps,\n  }\n}\n\nexport interface MentionInputProps\n  extends Omit<React.ComponentPropsWithoutRef<\"textarea\">, \"value\" | \"defaultValue\"> {\n  /** The people, issues or emoji that can be mentioned. */\n  items: MentionItem[]\n  trigger?: string\n  filter?: ((items: MentionItem[], query: string) => MentionItem[]) | false\n  maxItems?: number\n  maxQueryLength?: number\n  allowSpaces?: boolean\n  loading?: boolean\n  showEmpty?: boolean\n  labels?: Partial<MentionLabels>\n  onQueryChange?: (query: MentionQuery | null) => void\n  onMentionSelect?: (item: MentionItem, query: MentionQuery) => void\n  toInsertText?: (item: MentionItem, trigger: string) => string\n  renderItem?: (item: MentionItem, state: { active: boolean; index: number }) => React.ReactNode\n  value?: string\n  defaultValue?: string\n  /** Class for the wrapper. The field and the menu have their own. */\n  className?: string\n  textareaClassName?: string\n  panelClassName?: string\n}\n\n/**\n * A textarea where typing `@` opens a list of people to mention, anchored under the caret rather\n * than under the field. Use it for comment and review boxes, chat and DM composers, issue and task\n * descriptions, AI prompt boxes that can reference a document, and anywhere `#` should pull up\n * issues or `:` emoji instead — the trigger is a prop.\n *\n * Controlled with `value` and `onChange`, or uncontrolled with `defaultValue`; either way the native\n * textarea is what gets rendered, so labels, form libraries and validation keep working. For a field\n * you lay out yourself, `useMentionInput` returns the same behaviour as props to spread.\n */\nexport const MentionInput = React.forwardRef<HTMLTextAreaElement, MentionInputProps>(\n  function MentionInput(\n    {\n      items,\n      trigger = \"@\",\n      filter,\n      maxItems,\n      maxQueryLength,\n      allowSpaces,\n      loading,\n      showEmpty,\n      labels,\n      onQueryChange,\n      onMentionSelect,\n      toInsertText,\n      renderItem,\n      value,\n      defaultValue,\n      onChange,\n      onInput,\n      onSelect,\n      onClick,\n      onBlur,\n      onKeyDown,\n      onCompositionStart,\n      onCompositionEnd,\n      className,\n      textareaClassName,\n      panelClassName,\n      ...props\n    },\n    forwardedRef\n  ) {\n    const isControlled = value !== undefined\n    const [internal, setInternal] = React.useState(defaultValue ?? \"\")\n    const text = isControlled ? value : internal\n\n    const mention = useMentionInput({\n      items,\n      trigger,\n      filter,\n      maxItems,\n      maxQueryLength,\n      allowSpaces,\n      loading,\n      showEmpty,\n      labels,\n      onQueryChange,\n      onMentionSelect,\n      toInsertText,\n    })\n    const { fieldProps, listboxProps, open, activeIndex, optionId } = mention\n    const { ref: setField, ...fieldAria } = fieldProps\n    const { ref: setPanel, ...listboxAria } = listboxProps\n\n    const setRefs = React.useCallback(\n      (node: HTMLTextAreaElement | null) => {\n        setField(node)\n        if (typeof forwardedRef === \"function\") forwardedRef(node)\n        else if (forwardedRef) forwardedRef.current = node\n      },\n      [setField, forwardedRef]\n    )\n\n    // Keep the highlighted row in view while arrowing through a menu taller than its box.\n    React.useEffect(() => {\n      if (!open) return\n      const row = mention.panelRef.current?.children?.[activeIndex] as HTMLElement | undefined\n      row?.scrollIntoView?.({ block: \"nearest\" })\n    }, [open, activeIndex, mention.panelRef])\n\n    function handleChange(event: React.ChangeEvent<HTMLTextAreaElement>) {\n      if (!isControlled) setInternal(event.target.value)\n      onChange?.(event)\n    }\n\n    return (\n      <div className={cn(\"relative\", className)}>\n        <textarea\n          {...props}\n          {...fieldAria}\n          ref={setRefs}\n          value={text}\n          onChange={handleChange}\n          onInput={(event) => {\n            onInput?.(event)\n            fieldAria.onInput(event)\n          }}\n          onSelect={(event) => {\n            onSelect?.(event)\n            fieldAria.onSelect(event)\n          }}\n          onClick={(event) => {\n            onClick?.(event)\n            fieldAria.onClick(event)\n          }}\n          onBlur={(event) => {\n            onBlur?.(event)\n            fieldAria.onBlur()\n          }}\n          onKeyDown={(event) => {\n            onKeyDown?.(event)\n            fieldAria.onKeyDown(event)\n          }}\n          onCompositionStart={(event) => {\n            onCompositionStart?.(event)\n            fieldAria.onCompositionStart()\n          }}\n          onCompositionEnd={(event) => {\n            onCompositionEnd?.(event)\n            fieldAria.onCompositionEnd(event)\n          }}\n          className={cn(\n            \"flex min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\",\n            textareaClassName\n          )}\n        />\n\n        {open && (\n          <div\n            {...listboxAria}\n            ref={setPanel}\n            style={mention.position ? { top: mention.position.top, left: mention.position.left } : undefined}\n            className={cn(\n              \"absolute z-50 max-h-56 min-w-[12rem] max-w-[18rem] overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md\",\n              // Until the caret has been measured — the server pass, the first paint — the menu sits\n              // under the field rather than nowhere.\n              !mention.position && \"left-0 top-full mt-1\",\n              panelClassName\n            )}\n          >\n            {mention.items.length === 0 ? (\n              <div className=\"px-2 py-1.5 text-sm text-muted-foreground\">\n                {loading ? mention.labels.loading : mention.labels.empty}\n              </div>\n            ) : (\n              mention.items.map((item, index) => (\n                <div\n                  key={item.id}\n                  id={optionId(index)}\n                  role=\"option\"\n                  aria-selected={index === activeIndex}\n                  aria-disabled={item.disabled || undefined}\n                  onClick={() => mention.select(item)}\n                  onMouseEnter={() => !item.disabled && mention.setActiveIndex(index)}\n                  className={cn(\n                    \"flex cursor-pointer flex-col rounded-sm px-2 py-1.5 text-sm\",\n                    index === activeIndex && \"bg-accent text-accent-foreground\",\n                    item.disabled && \"pointer-events-none opacity-50\"\n                  )}\n                >\n                  {renderItem ? (\n                    renderItem(item, { active: index === activeIndex, index })\n                  ) : (\n                    <>\n                      <span className=\"truncate\">{item.label}</span>\n                      {item.description && (\n                        <span className=\"truncate text-xs text-muted-foreground\">\n                          {item.description}\n                        </span>\n                      )}\n                    </>\n                  )}\n                </div>\n              ))\n            )}\n          </div>\n        )}\n\n        <span aria-live=\"polite\" className=\"sr-only\">\n          {mention.announcement}\n        </span>\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 103 components: https://pulld.pages.dev/?utm_source=cli"
}
