{
	"$schema": "https://shadcn-svelte.com/schema/registry-item.json",
	"name": "mention-input",
	"title": "Mention Input",
	"type": "registry:ui",
	"description": "An input or textarea that opens a filterable list when you type a trigger character.",
	"files": [
		{
			"content": "<script lang=\"ts\" module>\r\n\texport type MentionItem = {\r\n\t\tid: string;\r\n\t\tlabel: string;\r\n\t\tdescription?: string;\r\n\t\tkeywords?: string[];\r\n\t\t[key: string]: unknown;\r\n\t};\r\n</script>\r\n\r\n<script lang=\"ts\">\r\n\timport { cn } from '$UTILS$';\r\n\timport { tick } from 'svelte';\r\n\timport { fly } from 'svelte/transition';\r\n\timport type { Snippet } from 'svelte';\r\n\r\n\tlet {\r\n\t\tvalue = $bindable(''),\r\n\t\titems = [],\r\n\t\ttrigger = '@',\r\n\t\tplaceholder = 'Type @ to mention someone...',\r\n\t\tdisabled = false,\r\n\t\tmultiline = true,\r\n\t\trows = 4,\r\n\t\tmaxItems = 8,\r\n\t\tinsertSpace = true,\r\n\t\tclass: className,\r\n\t\tfilter,\r\n\t\tonmention,\r\n\t\titemSnippet,\r\n\t\temptySnippet,\r\n\t\t...rest\r\n\t}: {\r\n\t\tvalue?: string;\r\n\t\titems?: MentionItem[];\r\n\t\ttrigger?: string;\r\n\t\tplaceholder?: string;\r\n\t\tdisabled?: boolean;\r\n\t\t/** Render a `<textarea>`; set to false for a single-line `<input>`. */\r\n\t\tmultiline?: boolean;\r\n\t\trows?: number;\r\n\t\tmaxItems?: number;\r\n\t\tinsertSpace?: boolean;\r\n\t\tclass?: string;\r\n\t\tfilter?: (items: MentionItem[], query: string) => MentionItem[];\r\n\t\tonmention?: (item: MentionItem) => void;\r\n\t\titemSnippet?: Snippet<[MentionItem, boolean]>;\r\n\t\temptySnippet?: Snippet<[string]>;\r\n\t\t[key: string]: unknown;\r\n\t} = $props();\r\n\r\n\ttype Field = HTMLTextAreaElement | HTMLInputElement;\r\n\r\n\tlet fieldRef = $state<Field | null>(null);\r\n\tlet listRef = $state<HTMLDivElement | null>(null);\r\n\r\n\tconst uid = $props.id();\r\n\tconst listId = `mention-input-list-${uid}`;\r\n\r\n\tlet isOpen = $state(false);\r\n\tlet query = $state('');\r\n\tlet activeIndex = $state(0);\r\n\t/** Index of the trigger character in `value`. */\r\n\tlet anchorIndex = $state(-1);\r\n\tlet caret = $state({ top: 0, left: 0, height: 20 });\r\n\r\n\tconst defaultFilter = (list: MentionItem[], q: string) => {\r\n\t\tif (!q) return list;\r\n\t\tconst needle = q.toLowerCase();\r\n\t\treturn list.filter((item) => {\r\n\t\t\tif (item.label.toLowerCase().includes(needle)) return true;\r\n\t\t\tif (item.description?.toLowerCase().includes(needle)) return true;\r\n\t\t\treturn item.keywords?.some((k) => k.toLowerCase().includes(needle)) ?? false;\r\n\t\t});\r\n\t};\r\n\r\n\tconst results = $derived.by(() => {\r\n\t\tconst filtered = (filter ?? defaultFilter)(items, query);\r\n\t\treturn maxItems > 0 ? filtered.slice(0, maxItems) : filtered;\r\n\t});\r\n\r\n\tfunction close() {\r\n\t\tisOpen = false;\r\n\t\tanchorIndex = -1;\r\n\t\tquery = '';\r\n\t\tactiveIndex = 0;\r\n\t}\r\n\r\n\t/**\r\n\t * Mirrors the field into a hidden div to find the pixel offset of the caret,\r\n\t * so the popover can be anchored to the trigger character instead of the field.\r\n\t */\r\n\tfunction measureCaret(el: Field, position: number) {\r\n\t\tconst style = getComputedStyle(el);\r\n\t\tconst mirror = document.createElement('div');\r\n\r\n\t\tconst copy = [\r\n\t\t\t'padding-top',\r\n\t\t\t'padding-right',\r\n\t\t\t'padding-bottom',\r\n\t\t\t'padding-left',\r\n\t\t\t'border-top-width',\r\n\t\t\t'border-right-width',\r\n\t\t\t'border-bottom-width',\r\n\t\t\t'border-left-width',\r\n\t\t\t'font-family',\r\n\t\t\t'font-size',\r\n\t\t\t'font-weight',\r\n\t\t\t'font-style',\r\n\t\t\t'letter-spacing',\r\n\t\t\t'line-height',\r\n\t\t\t'text-transform',\r\n\t\t\t'text-indent',\r\n\t\t\t'tab-size'\r\n\t\t];\r\n\r\n\t\tfor (const prop of copy) mirror.style.setProperty(prop, style.getPropertyValue(prop));\r\n\r\n\t\t// Mirror the exact content width so line breaks land in the same places.\r\n\t\tconst contentWidth =\r\n\t\t\tel.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);\r\n\r\n\t\tmirror.style.boxSizing = 'content-box';\r\n\t\tmirror.style.borderStyle = 'solid';\r\n\t\tmirror.style.borderColor = 'transparent';\r\n\t\t// A single-line input never wraps, so it must not be width constrained.\r\n\t\tmirror.style.width = multiline ? `${contentWidth}px` : 'auto';\r\n\t\tmirror.style.position = 'absolute';\r\n\t\tmirror.style.top = '0';\r\n\t\tmirror.style.left = '-9999px';\r\n\t\tmirror.style.visibility = 'hidden';\r\n\t\tmirror.style.whiteSpace = multiline ? 'pre-wrap' : 'pre';\r\n\t\tmirror.style.overflowWrap = multiline ? 'break-word' : 'normal';\r\n\t\tmirror.style.height = 'auto';\r\n\r\n\t\tmirror.textContent = el.value.slice(0, position);\r\n\r\n\t\tconst marker = document.createElement('span');\r\n\t\t// A non-empty marker keeps the span measurable at the end of the text.\r\n\t\tmarker.textContent = el.value.slice(position) || '.';\r\n\t\tmirror.appendChild(marker);\r\n\r\n\t\tdocument.body.appendChild(mirror);\r\n\t\tconst top = marker.offsetTop - el.scrollTop;\r\n\t\tconst left = marker.offsetLeft - el.scrollLeft;\r\n\t\tconst height = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;\r\n\t\tdocument.body.removeChild(mirror);\r\n\r\n\t\treturn { top, left, height };\r\n\t}\r\n\r\n\t/** Finds an open trigger sequence directly before the caret, if any. */\r\n\tfunction detect() {\r\n\t\tif (!fieldRef || disabled) return;\r\n\r\n\t\tconst caretPos = fieldRef.selectionStart ?? 0;\r\n\t\tif (caretPos !== fieldRef.selectionEnd) return close();\r\n\r\n\t\tconst before = value.slice(0, caretPos);\r\n\t\tconst index = before.lastIndexOf(trigger);\r\n\t\tif (index === -1) return close();\r\n\r\n\t\t// The trigger only counts at a word boundary.\r\n\t\tconst preceding = index === 0 ? '' : before[index - 1];\r\n\t\tif (preceding && !/[\\s(\\[{]/.test(preceding)) return close();\r\n\r\n\t\tconst candidate = before.slice(index + trigger.length);\r\n\t\tif (/[\\s]/.test(candidate)) return close();\r\n\r\n\t\t// Keep the highlighted item when the query is unchanged, otherwise moving\r\n\t\t// the cursor with the arrow keys would snap the selection back to the top.\r\n\t\tif (!isOpen || index !== anchorIndex || candidate !== query) activeIndex = 0;\r\n\r\n\t\tanchorIndex = index;\r\n\t\tquery = candidate;\r\n\t\tcaret = measureCaret(fieldRef, index);\r\n\t\tisOpen = true;\r\n\t}\r\n\r\n\tfunction select(item: MentionItem) {\r\n\t\tif (!fieldRef || anchorIndex === -1) return;\r\n\r\n\t\tconst caretPos = fieldRef.selectionStart ?? value.length;\r\n\t\tconst inserted = `${trigger}${item.label}${insertSpace ? ' ' : ''}`;\r\n\r\n\t\tvalue = value.slice(0, anchorIndex) + inserted + value.slice(caretPos);\r\n\r\n\t\tconst nextCaret = anchorIndex + inserted.length;\r\n\t\tclose();\r\n\t\tonmention?.(item);\r\n\r\n\t\ttick().then(() => {\r\n\t\t\tfieldRef?.focus();\r\n\t\t\tfieldRef?.setSelectionRange(nextCaret, nextCaret);\r\n\t\t});\r\n\t}\r\n\r\n\tfunction scrollActiveIntoView() {\r\n\t\ttick().then(() => {\r\n\t\t\tconst el = listRef?.children[activeIndex] as HTMLElement | undefined;\r\n\t\t\tel?.scrollIntoView({ block: 'nearest' });\r\n\t\t});\r\n\t}\r\n\r\n\tconst fieldClass = cn(\r\n\t\t'flex w-full rounded-md border border-input bg-background px-3 text-sm shadow-xs',\r\n\t\t'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\r\n\t\t'disabled:cursor-not-allowed disabled:opacity-50'\r\n\t);\r\n\r\n\tfunction handleKeyup(e: KeyboardEvent) {\r\n\t\t// Vertical arrows drive the list while it is open — re-detecting there would\r\n\t\t// clobber the highlighted item. Horizontal moves still re-anchor the query.\r\n\t\tif (isOpen && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) return;\r\n\t\tif (e.key.startsWith('Arrow') || e.key === 'Home' || e.key === 'End') detect();\r\n\t}\r\n\r\n\tfunction handleKeydown(e: KeyboardEvent) {\r\n\t\tif (!isOpen || results.length === 0) {\r\n\t\t\tif (isOpen && e.key === 'Escape') close();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tswitch (e.key) {\r\n\t\t\tcase 'ArrowDown':\r\n\t\t\t\te.preventDefault();\r\n\t\t\t\tactiveIndex = (activeIndex + 1) % results.length;\r\n\t\t\t\tscrollActiveIntoView();\r\n\t\t\t\tbreak;\r\n\t\t\tcase 'ArrowUp':\r\n\t\t\t\te.preventDefault();\r\n\t\t\t\tactiveIndex = (activeIndex - 1 + results.length) % results.length;\r\n\t\t\t\tscrollActiveIntoView();\r\n\t\t\t\tbreak;\r\n\t\t\tcase 'Enter':\r\n\t\t\tcase 'Tab':\r\n\t\t\t\te.preventDefault();\r\n\t\t\t\tselect(results[activeIndex]);\r\n\t\t\t\tbreak;\r\n\t\t\tcase 'Escape':\r\n\t\t\t\te.preventDefault();\r\n\t\t\t\tclose();\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\t}\r\n</script>\r\n\r\n<div class={cn('relative w-full', className)}>\r\n\t{#if multiline}\r\n\t\t<textarea\r\n\t\t\tbind:this={fieldRef}\r\n\t\t\tbind:value\r\n\t\t\t{rows}\r\n\t\t\t{placeholder}\r\n\t\t\t{disabled}\r\n\t\t\toninput={detect}\r\n\t\t\tonclick={detect}\r\n\t\t\tonkeyup={handleKeyup}\r\n\t\t\tonkeydown={handleKeydown}\r\n\t\t\tonblur={() => setTimeout(close, 120)}\r\n\t\t\trole=\"combobox\"\r\n\t\t\taria-expanded={isOpen}\r\n\t\t\taria-autocomplete=\"list\"\r\n\t\t\taria-controls={listId}\r\n\t\t\tclass={cn(fieldClass, 'resize-y py-2')}\r\n\t\t\t{...rest}\r\n\t\t></textarea>\r\n\t{:else}\r\n\t\t<input\r\n\t\t\tbind:this={fieldRef}\r\n\t\t\tbind:value\r\n\t\t\ttype=\"text\"\r\n\t\t\t{placeholder}\r\n\t\t\t{disabled}\r\n\t\t\toninput={detect}\r\n\t\t\tonclick={detect}\r\n\t\t\tonkeyup={handleKeyup}\r\n\t\t\tonkeydown={handleKeydown}\r\n\t\t\tonblur={() => setTimeout(close, 120)}\r\n\t\t\trole=\"combobox\"\r\n\t\t\taria-expanded={isOpen}\r\n\t\t\taria-autocomplete=\"list\"\r\n\t\t\taria-controls={listId}\r\n\t\t\tclass={cn(fieldClass, 'h-9 py-1')}\r\n\t\t\t{...rest}\r\n\t\t/>\r\n\t{/if}\r\n\r\n\t{#if isOpen}\r\n\t\t<div\r\n\t\t\tid={listId}\r\n\t\t\tbind:this={listRef}\r\n\t\t\trole=\"listbox\"\r\n\t\t\ttabindex=\"-1\"\r\n\t\t\tstyle:top=\"{caret.top + caret.height + 4}px\"\r\n\t\t\tstyle:left=\"{caret.left}px\"\r\n\t\t\tclass=\"absolute z-50 max-h-[240px] min-w-[220px] max-w-[320px] overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md\"\r\n\t\t\ttransition:fly={{ y: -4, duration: 120 }}\r\n\t\t>\r\n\t\t\t{#each results as item, i (item.id)}\r\n\t\t\t\t<div\r\n\t\t\t\t\trole=\"option\"\r\n\t\t\t\t\ttabindex=\"-1\"\r\n\t\t\t\t\taria-selected={i === activeIndex}\r\n\t\t\t\t\tclass={cn(\r\n\t\t\t\t\t\t'flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',\r\n\t\t\t\t\t\ti === activeIndex && 'bg-accent text-accent-foreground'\r\n\t\t\t\t\t)}\r\n\t\t\t\t\tonmouseenter={() => (activeIndex = i)}\r\n\t\t\t\t\tonmousedown={(e) => {\r\n\t\t\t\t\t\te.preventDefault();\r\n\t\t\t\t\t\tselect(item);\r\n\t\t\t\t\t}}\r\n\t\t\t\t>\r\n\t\t\t\t\t{#if itemSnippet}\r\n\t\t\t\t\t\t{@render itemSnippet(item, i === activeIndex)}\r\n\t\t\t\t\t{:else}\r\n\t\t\t\t\t\t<span class=\"truncate font-medium\">{item.label}</span>\r\n\t\t\t\t\t\t{#if item.description}\r\n\t\t\t\t\t\t\t<span class=\"ml-auto truncate text-xs text-muted-foreground\">\r\n\t\t\t\t\t\t\t\t{item.description}\r\n\t\t\t\t\t\t\t</span>\r\n\t\t\t\t\t\t{/if}\r\n\t\t\t\t\t{/if}\r\n\t\t\t\t</div>\r\n\t\t\t{:else}\r\n\t\t\t\t<div class=\"px-2 py-4 text-center text-sm text-muted-foreground\">\r\n\t\t\t\t\t{#if emptySnippet}\r\n\t\t\t\t\t\t{@render emptySnippet(query)}\r\n\t\t\t\t\t{:else}\r\n\t\t\t\t\t\tNo matches for \"{query}\"\r\n\t\t\t\t\t{/if}\r\n\t\t\t\t</div>\r\n\t\t\t{/each}\r\n\t\t</div>\r\n\t{/if}\r\n</div>\r\n",
			"type": "registry:ui",
			"target": "mention-input/mention-input.svelte"
		},
		{
			"content": "import MentionInput from './mention-input.svelte';\r\n\r\nexport { MentionInput, MentionInput as Root };\r\nexport type { MentionItem } from './mention-input.svelte';\r\n",
			"type": "registry:ui",
			"target": "mention-input/index.ts"
		}
	]
}