{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"autocomplete","type":"registry:ui","title":"Autocomplete","description":"A free-text field with a portalled panel of matching suggestions — any typed value is valid; the panel only ever helps finish it faster.","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["forms"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/autocomplete/Autocomplete.tsx","content":"\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useMemo, useState } from \"react\";\nimport type { ChangeEvent, ComponentPropsWithoutRef, FocusEvent, KeyboardEvent } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport { useField } from \"../../internals/field\";\nimport { useListbox } from \"../../internals/listbox\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport { AUTOCOMPLETE_KEY, type AutocompleteContext } from \"./types\";\nimport { AutocompletePanel } from \"./AutocompletePanel\";\nimport \"./autocomplete.css\";\n\n/**\n * Every other native `<input>` attribute and listener (`aria-*`, `data-*`,\n * `autoComplete`, `inputMode`, `form`, `onFocus`, `onBlur`, `onKeyDown`,\n * `onChange`, ...) is forwarded to the text input. The managed ARIA (`role`,\n * `aria-expanded`, `aria-controls`, `aria-activedescendant`, `aria-haspopup`,\n * `aria-autocomplete`, `aria-invalid`) stays the component's own.\n */\nexport interface AutocompleteProps extends Omit<\n\tComponentPropsWithoutRef<\"input\">,\n\t| \"className\"\n\t| \"id\"\n\t| \"name\"\n\t| \"value\"\n\t| \"disabled\"\n\t| \"required\"\n\t| \"placeholder\"\n\t| \"type\"\n\t| \"role\"\n\t| \"children\"\n\t| \"onSelect\"\n\t| \"minLength\"\n> {\n\t/** Candidate strings offered as the user types. Advisory only — any text is a valid value, matched or not. */\n\tsuggestions: string[];\n\t/** The free-text value. */\n\tvalue?: string;\n\t/** Called with the new value on every keystroke. */\n\tonValueChange?: (value: string) => void;\n\t/** Called only when a suggestion is committed via click or Enter — never fires from plain typing. */\n\tonSelect?: (suggestion: string) => void;\n\t/** Shown while the field is empty. */\n\tplaceholder?: string;\n\t/** Blocks focus and typing; excluded from form submission. Overridden by a surrounding FormField. */\n\tdisabled?: boolean;\n\t/** Native `required`. Overridden by a surrounding FormField. */\n\trequired?: boolean;\n\t/** Drives the error border and `aria-invalid`. Overridden by a surrounding FormField. */\n\tinvalid?: boolean;\n\t/** Element id. Overridden by a surrounding FormField's own `controlId`. */\n\tid?: string;\n\t/** Native `name`, read on form submission — the visible value doubles as the submitted one, unlike Combobox. */\n\tname?: string;\n\t/** Accessible name — for a control with no visible Label next to it. */\n\tlabel?: string;\n\t/** Characters required before suggestions appear. */\n\tminLength?: number;\n\t/** Maximum number of suggestions shown at once. */\n\tmaxSuggestions?: number;\n\t/** Additional CSS classes. */\n\tclassName?: string;\n\t/**\n\t * Plays the matching interface cue through the sound controller. Off by\n\t * default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\nexport const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>(function Autocomplete(\n\t{\n\t\tsuggestions,\n\t\tvalue: valueProp,\n\t\tonValueChange,\n\t\tonSelect,\n\t\tplaceholder,\n\t\tdisabled = false,\n\t\trequired = false,\n\t\tinvalid = false,\n\t\tid,\n\t\tname,\n\t\tlabel,\n\t\tminLength = 1,\n\t\tmaxSuggestions = 8,\n\t\tclassName,\n\t\tsound = false,\n\t\tonChange,\n\t\tonFocus,\n\t\tonBlur,\n\t\tonKeyDown,\n\t\t...restProps\n\t},\n\tforwardedRef\n) {\n\t// Undefined outside a FormField — every derived below then falls back to\n\t// this component's own props instead of the context, so the control works\n\t// standalone exactly as it does wrapped. Same convention as Input.\n\tconst field = useField();\n\n\tconst uid = useFancyId();\n\tconst effectiveId = field?.controlId ?? id ?? `${uid}-input`;\n\tconst effectiveDisabled = field?.disabled ?? disabled;\n\tconst effectiveRequired = field?.required ?? required;\n\tconst effectiveInvalid = field?.invalid ?? invalid;\n\tconst panelId = `${uid}-listbox`;\n\n\tconst playCue = useSoundCue(sound);\n\n\t// The Svelte source's `value` is `$bindable(\"\")`: a consumer can bind it,\n\t// or hand it a plain value and let the component keep writing its own\n\t// copy. React has no such channel, so the prop SEEDS this copy and re-seeds\n\t// it whenever the consumer actually changes it — which is exactly what a\n\t// non-bound `$bindable` prop does, and is what keeps \"a plain `value` plus\n\t// `onValueChange`\" a working combination rather than a frozen field. A\n\t// consumer that does drive `value` from `onValueChange` is fully\n\t// controlled; one that never updates it still gets a typeable input.\n\tconst [value, setValue] = useState(valueProp ?? \"\");\n\tconst [seededFrom, setSeededFrom] = useState(valueProp);\n\tif (valueProp !== seededFrom) {\n\t\tsetSeededFrom(valueProp);\n\t\tsetValue(valueProp ?? \"\");\n\t}\n\n\tconst [open, setOpen] = useState(false);\n\n\t// Convention C-1: the panel anchors against, and excludes from\n\t// outside-click dismissal, the NODE — so the input is published as state,\n\t// not as a ref nobody would be re-rendered by.\n\tconst [inputNode, setInputNode] = useElementRef<HTMLInputElement>();\n\tconst inputRef = useComposedRefs(setInputNode, forwardedRef);\n\n\t// Computed as a plain function, not only through the memo below, so it can\n\t// also be called directly from handlers that just wrote `value` in the same\n\t// pass (see `handleInput`) — reading the memoized `filteredSuggestions`\n\t// there instead would mean reading this render's list in a handler that has\n\t// already superseded it.\n\tconst computeSuggestions = useCallback(\n\t\t(text: string): string[] => {\n\t\t\tif (text.length < minLength) return [];\n\t\t\tconst q = text.toLowerCase();\n\t\t\treturn suggestions.filter((s) => s.toLowerCase().includes(q)).slice(0, maxSuggestions);\n\t\t},\n\t\t[suggestions, minLength, maxSuggestions]\n\t);\n\n\tconst filteredSuggestions = useMemo(() => computeSuggestions(value), [computeSuggestions, value]);\n\n\tconst listbox = useListbox({ count: filteredSuggestions.length, loop: true });\n\n\t// If the suggestions the panel is showing become empty while it's open —\n\t// the `suggestions` prop changes out from under an open panel, or `value`\n\t// is set programmatically to something shorter than `minLength` — the\n\t// panel closes itself rather than sit open and empty. Reads the filtered\n\t// length, writes `open`: two different pieces of state, not the same one\n\t// read and written in one effect.\n\tuseEffect(() => {\n\t\tif (filteredSuggestions.length === 0) setOpen(false);\n\t}, [filteredSuggestions.length]);\n\n\tconst optionId = useCallback((index: number) => `${panelId}-option-${index}`, [panelId]);\n\n\t// The only place a suggestion becomes the value. A plain function, not an\n\t// effect, so it doesn't fight a caller's own controlled write.\n\tconst commit = useCallback(\n\t\t(suggestion: string) => {\n\t\t\t// Re-picking the suggestion already in force changes nothing — the\n\t\t\t// changed-only rule every other value-holding component follows\n\t\t\t// (Select, Tabs, DatePicker, ...), even where, as here, there is no\n\t\t\t// second cue to fall back on. Read BEFORE the write, and never at\n\t\t\t// the cost of the callbacks: both still fire on a re-pick.\n\t\t\tconst changed = value !== suggestion;\n\t\t\tsetValue(suggestion);\n\t\t\tsetOpen(false);\n\t\t\tif (!effectiveDisabled && changed) playCue(\"select\");\n\t\t\tonValueChange?.(suggestion);\n\t\t\tonSelect?.(suggestion);\n\t\t},\n\t\t[value, effectiveDisabled, playCue, onValueChange, onSelect]\n\t);\n\n\tconst close = useCallback(() => {\n\t\tsetOpen(false);\n\t}, []);\n\n\tfunction handleInput(event: ChangeEvent<HTMLInputElement>) {\n\t\tif (effectiveDisabled) return;\n\t\tconst next = event.currentTarget.value;\n\t\tsetValue(next);\n\t\tonValueChange?.(next);\n\t\tsetOpen(computeSuggestions(next).length > 0);\n\t\t// No row auto-highlights on typing — only an explicit arrow key does,\n\t\t// so Enter right after typing never silently swaps in a suggestion the\n\t\t// user never navigated to.\n\t\tlistbox.setActive(-1);\n\t}\n\n\tfunction handleFocus() {\n\t\tif (effectiveDisabled) return;\n\t\tif (computeSuggestions(value).length > 0) setOpen(true);\n\t}\n\n\tfunction handleBlur() {\n\t\t// Free text needs no resolution on the way out, unlike Combobox — every\n\t\t// value is already valid, and arrowing never wrote into the field, so\n\t\t// there is nothing to revert. Blur only has to close the panel.\n\t\tsetOpen(false);\n\t}\n\n\tfunction handleKeydown(event: KeyboardEvent<HTMLInputElement>) {\n\t\tif (effectiveDisabled) return;\n\t\tswitch (event.key) {\n\t\t\tcase \"ArrowDown\":\n\t\t\t\tif (!open) {\n\t\t\t\t\tif (computeSuggestions(value).length > 0) {\n\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\tsetOpen(true);\n\t\t\t\t\t\tlistbox.moveToEdge(\"first\");\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tevent.preventDefault();\n\t\t\t\tlistbox.move(1);\n\t\t\t\tbreak;\n\t\t\tcase \"ArrowUp\":\n\t\t\t\tif (!open) return;\n\t\t\t\tevent.preventDefault();\n\t\t\t\tlistbox.move(-1);\n\t\t\t\tbreak;\n\t\t\tcase \"Enter\": {\n\t\t\t\tif (!open) return;\n\t\t\t\tconst active = filteredSuggestions[listbox.activeIndex];\n\t\t\t\tif (active) {\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tcommit(active);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t// A plain object rebuilt when its inputs change — the rebuild is what makes\n\t// the panel re-render. The Svelte side gets the same liveness from getters.\n\tconst context = useMemo<AutocompleteContext>(\n\t\t() => ({\n\t\t\topen,\n\t\t\tpanelId,\n\t\t\tinputRef: inputNode,\n\t\t\tsuggestions: filteredSuggestions,\n\t\t\tquery: value,\n\t\t\tactiveIndex: listbox.activeIndex,\n\t\t\toptionId,\n\t\t\tisActive: (index: number) => index === listbox.activeIndex,\n\t\t\tselect: commit,\n\t\t\tclose,\n\t\t}),\n\t\t[\n\t\t\topen,\n\t\t\tpanelId,\n\t\t\tinputNode,\n\t\t\tfilteredSuggestions,\n\t\t\tvalue,\n\t\t\tlistbox.activeIndex,\n\t\t\toptionId,\n\t\t\tcommit,\n\t\t\tclose,\n\t\t]\n\t);\n\n\t// A count, not the contents — see the panel's own note on why the list\n\t// itself is never read out loud.\n\tconst resultsMessage =\n\t\tfilteredSuggestions.length === 1 ? \"1 suggestion\" : `${filteredSuggestions.length} suggestions`;\n\n\t// Internal handling first, the consumer's listener once after it, with the\n\t// same event — even when the internal handler called `preventDefault`.\n\tfunction onChangeEvent(event: ChangeEvent<HTMLInputElement>) {\n\t\thandleInput(event);\n\t\tonChange?.(event);\n\t}\n\tfunction onFocusEvent(event: FocusEvent<HTMLInputElement>) {\n\t\thandleFocus();\n\t\tonFocus?.(event);\n\t}\n\tfunction onBlurEvent(event: FocusEvent<HTMLInputElement>) {\n\t\thandleBlur();\n\t\tonBlur?.(event);\n\t}\n\tfunction onKeyDownEvent(event: KeyboardEvent<HTMLInputElement>) {\n\t\thandleKeydown(event);\n\t\tonKeyDown?.(event);\n\t}\n\n\t// A consumer `aria-describedby` is merged with the FormField's, never replaces it.\n\tconst describedBy =\n\t\t[field?.describedBy, restProps[\"aria-describedby\"]].filter(Boolean).join(\" \") || undefined;\n\n\tconst classes = cn(\n\t\t\"ft-autocomplete w-full rounded-[8px] border border-input bg-background px-[12px] py-[9px] text-[13px] text-foreground transition-colors\",\n\t\t\"placeholder:text-muted-foreground\",\n\t\t\"focus-visible:outline-none\",\n\t\t\"disabled:cursor-not-allowed disabled:opacity-50\",\n\t\teffectiveInvalid && \"border-destructive/50\",\n\t\tclassName\n\t);\n\n\t// `children` is omitted from the public props, but a caller outside the types\n\t// can still pass one — an `<input>` is a void element, so it must not reach it.\n\tconst { children: _children, ...inputProps } = restProps as typeof restProps & {\n\t\tchildren?: unknown;\n\t};\n\n\treturn (\n\t\t<AUTOCOMPLETE_KEY.Provider value={context}>\n\t\t\t<div className=\"ft-autocomplete-wrapper relative\">\n\t\t\t\t<input\n\t\t\t\t\t{...inputProps}\n\t\t\t\t\tref={inputRef}\n\t\t\t\t\ttype=\"text\"\n\t\t\t\t\trole=\"combobox\"\n\t\t\t\t\tid={effectiveId}\n\t\t\t\t\tname={name}\n\t\t\t\t\tplaceholder={placeholder}\n\t\t\t\t\tvalue={value}\n\t\t\t\t\tdisabled={effectiveDisabled}\n\t\t\t\t\trequired={effectiveRequired}\n\t\t\t\t\taria-invalid={effectiveInvalid ? \"true\" : restProps[\"aria-invalid\"]}\n\t\t\t\t\taria-describedby={describedBy}\n\t\t\t\t\taria-label={field?.labelId ? undefined : (label ?? restProps[\"aria-label\"])}\n\t\t\t\t\taria-expanded={open}\n\t\t\t\t\taria-haspopup=\"listbox\"\n\t\t\t\t\taria-autocomplete=\"list\"\n\t\t\t\t\taria-controls={open ? panelId : undefined}\n\t\t\t\t\taria-activedescendant={\n\t\t\t\t\t\topen && listbox.activeIndex >= 0 ? optionId(listbox.activeIndex) : undefined\n\t\t\t\t\t}\n\t\t\t\t\tclassName={classes}\n\t\t\t\t\tonChange={onChangeEvent}\n\t\t\t\t\tonFocus={onFocusEvent}\n\t\t\t\t\tonBlur={onBlurEvent}\n\t\t\t\t\tonKeyDown={onKeyDownEvent}\n\t\t\t\t/>\n\n\t\t\t\t{/* Always mounted, whether or not the panel is open — see Combobox's\n\t\t\t\t    identical note. Content is a count, never the suggestion text itself. */}\n\t\t\t\t<div className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n\t\t\t\t\t{open ? resultsMessage : \"\"}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/*\n\t\t\t\tRendered unconditionally where the source has `{#if open}`: the panel\n\t\t\t\towns its own `usePresence` clock, so it is what keeps the list on\n\t\t\t\tscreen for the length of the exit — the job Svelte's outro-delayed\n\t\t\t\tbranch destruction did. The gate has not disappeared, it has moved\n\t\t\t\tone level down. Hoisting it here as well would mean the panel's\n\t\t\t\t`Portal` mounting in the same commit as the surface, which resolves\n\t\t\t\tits container a layout effect too late and skips the entrance\n\t\t\t\toutright (the same reason `DialogSurface` hoists its own `Portal`).\n\t\t\t*/}\n\t\t\t<AutocompletePanel />\n\t\t</AUTOCOMPLETE_KEY.Provider>\n\t);\n});\n\nAutocomplete.displayName = \"Autocomplete\";\n","type":"registry:ui","target":"@ui/fancy/components/autocomplete/Autocomplete.tsx"},{"path":"react/src/components/autocomplete/AutocompletePanel.tsx","content":"\"use client\";\n\nimport { useCallback, useRef } from \"react\";\nimport type { MouseEvent } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport { Portal } from \"../../internals/Portal\";\nimport { useAnchorPosition } from \"../../internals/use-anchor-position\";\nimport { useDismissable } from \"../../internals/dismissable\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { anchored, originFor } from \"../../internals/motion/anchored\";\nimport { usePresence } from \"../../internals/motion/presence\";\nimport { getMatchRange } from \"./match\";\nimport { AUTOCOMPLETE_KEY } from \"./types\";\n\n/**\n * The floating suggestions list.\n *\n * `Autocomplete` renders this unconditionally and it gates itself on\n * `ctx.open`, where the source mounts it inside its own `{#if open}` — so the\n * context is always present, and the rows are never drawn before there is at\n * least one suggestion to show.\n */\nexport function AutocompletePanel() {\n\tconst ctx = AUTOCOMPLETE_KEY.useRequired();\n\n\t// Convention C-1: the NODE, not a ref. The panel is created by\n\t// `presence.mounted`, so a `useRef` + `[]`-deps effect would still be\n\t// holding `null` when the positioning and dismissable effects fire.\n\tconst [panel, setPanelNode] = useElementRef<HTMLDivElement>();\n\n\tconst presence = usePresence(ctx.open);\n\n\t// The side and alignment the panel was ACTUALLY placed on. This panel\n\t// always asks for `\"bottom\"` / `\"start\"`, but an input sitting low in the\n\t// viewport flips the side to `\"top\"`, and near a viewport edge clamping\n\t// slides the panel along the cross axis — the entrance origin has to\n\t// follow both, or it grows from the far corner. Seeded with the requested\n\t// values, so the un-flipped case never depends on a placement callback\n\t// having fired first.\n\tconst { side, align } = useAnchorPosition(panel, {\n\t\tanchor: () => ctx.inputRef,\n\t\tside: \"bottom\",\n\t\talign: \"start\",\n\t\toffset: 4,\n\t});\n\n\tconst exclude = useCallback(() => [ctx.inputRef], [ctx.inputRef]);\n\n\t// `active: ctx.open` — a plain boolean where the source needed a getter.\n\t// The layer stays ON the stack for its whole exit and stops being TOP of it\n\t// the instant `open` flips, so a second Escape during the fade falls\n\t// through to whatever is underneath instead of being swallowed by a panel\n\t// that is already leaving.\n\tuseDismissable(panel, { onDismiss: ctx.close, exclude, active: ctx.open });\n\n\t// Convention C-2: composed ABOVE the conditional below. Calling this inside\n\t// the presence gate would be a conditional hook and would throw the first\n\t// time `mounted` flips.\n\t//\n\t// ONE bidirectional transition, never a split in/out pair: a list reopened\n\t// mid-exit is handed the in-flight leg as its counterpart and continues\n\t// from where it is instead of snapping to invisible first. That matters\n\t// more here than anywhere else in the family, because a suggestion list\n\t// closes and reopens on keystrokes — a query that stops matching closes it,\n\t// and the next character that matches again reverses the exit already in\n\t// flight.\n\tconst panelRef = useComposedRefs(\n\t\tsetPanelNode,\n\t\tpresence.register(anchored, (entering) => ({ side, entering }))\n\t);\n\n\t// Svelte destroys the `{#if open}` branch that owns this component, and\n\t// marks it INERT before playing the outro — its scheduler skips inert\n\t// effects, so the rows on screen during the exit are frozen at whatever\n\t// they were the instant the close began. React re-renders an exiting\n\t// subtree normally, so the freeze is explicit here: without it a list that\n\t// closed BECAUSE its query stopped matching would empty itself to a bare\n\t// box halfway through its own fade, which is the single most common way\n\t// this particular panel closes.\n\tconst frozen = useRef({\n\t\tsuggestions: ctx.suggestions,\n\t\tquery: ctx.query,\n\t\tactiveIndex: ctx.activeIndex,\n\t});\n\tif (ctx.open) {\n\t\tfrozen.current = {\n\t\t\tsuggestions: ctx.suggestions,\n\t\t\tquery: ctx.query,\n\t\t\tactiveIndex: ctx.activeIndex,\n\t\t};\n\t}\n\tconst view = frozen.current;\n\n\t// The `Portal` stays mounted and its CHILDREN are what `presence.mounted`\n\t// gates: `usePortalTarget` resolves its container in a layout effect, so a\n\t// `Portal` mounting in the same commit as the surface renders nothing on\n\t// that pass — and the registered node would therefore not exist yet when\n\t// `usePresence` looks for legs to start, which settles the group\n\t// immediately and skips the entrance outright.\n\treturn (\n\t\t<Portal>\n\t\t\t{presence.mounted ? (\n\t\t\t\t/*\n\t\t\t\t\tA `<div role=\"listbox\">`, not a `<ul>` — see Combobox's identical\n\t\t\t\t\tpanel for why (its rows are real buttons, not a permitted child of\n\t\t\t\t\t`<ul>`).\n\n\t\t\t\t\tNo focus trap: focus never leaves the input (`aria-activedescendant`\n\t\t\t\t\ttracks the active row instead). Each row is a real button, kept out\n\t\t\t\t\tof the tab sequence with `tabindex=\"-1\"`, with `onMouseDown` calling\n\t\t\t\t\t`preventDefault()` so clicking one never blurs the input a beat\n\t\t\t\t\tbefore its own `onClick` commits the suggestion.\n\n\t\t\t\t\t`data-state` is an ordinary attribute (divergence D-2) carrying\n\t\t\t\t\t`surfaceState`'s TWO values — never `\"opening\"` (convention C-5).\n\t\t\t\t\t`inert` is not written by hand either: `usePresence` sets it on the\n\t\t\t\t\tregistered node for the whole exit, which is what stops a row taking\n\t\t\t\t\ta click on its way out.\n\t\t\t\t*/\n\t\t\t\t<div\n\t\t\t\t\tref={panelRef}\n\t\t\t\t\tid={ctx.panelId}\n\t\t\t\t\trole=\"listbox\"\n\t\t\t\t\tclassName=\"ft-autocomplete-panel border-border bg-popover text-popover-foreground flex max-h-[260px] w-max min-w-[220px] flex-col gap-[1px] overflow-auto rounded-[10px] border p-[5px] text-[13px] shadow-lg outline-none\"\n\t\t\t\t\tdata-state={presence.surfaceState}\n\t\t\t\t\tdata-side={side}\n\t\t\t\t\tdata-align=\"start\"\n\t\t\t\t\tstyle={{ transformOrigin: originFor(side, align) }}\n\t\t\t\t>\n\t\t\t\t\t{view.suggestions.map((suggestion, index) => {\n\t\t\t\t\t\tconst range = getMatchRange(suggestion, view.query);\n\t\t\t\t\t\tconst isActive = index === view.activeIndex;\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t\t`onMouseDown` below defends against a real browser's\n\t\t\t\t\t\t\t\tfocus-follows-mousedown default action stealing focus onto\n\t\t\t\t\t\t\t\tthis row before its own `onClick` commits — without it, that\n\t\t\t\t\t\t\t\tfocus shift blurs the input first, which closes the panel\n\t\t\t\t\t\t\t\t(unmounting this row) before the click already in flight can\n\t\t\t\t\t\t\t\tland. jsdom implements no such default action to suppress, so\n\t\t\t\t\t\t\t\tno test in this repo can watch this guard prevent that\n\t\t\t\t\t\t\t\toutcome — only that the call happens (the suite's \"the row's\n\t\t\t\t\t\t\t\tmousedown handler calls preventDefault\" dispatches a bare\n\t\t\t\t\t\t\t\t`mousedown` and checks `event.defaultPrevented`). Do not\n\t\t\t\t\t\t\t\tdelete this as dead code on the strength of a green suite.\n\t\t\t\t\t\t\t*/\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={suggestion}\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tid={ctx.optionId(index)}\n\t\t\t\t\t\t\t\trole=\"option\"\n\t\t\t\t\t\t\t\ttabIndex={-1}\n\t\t\t\t\t\t\t\taria-selected={isActive}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"ft-autocomplete-option flex w-full cursor-pointer items-center justify-between gap-2 rounded-[6px] px-[10px] py-[7px] text-left\",\n\t\t\t\t\t\t\t\t\tisActive && \"bg-accent text-accent-foreground\"\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\tonMouseDown={(event: MouseEvent<HTMLButtonElement>) => event.preventDefault()}\n\t\t\t\t\t\t\t\tonClick={() => ctx.select(suggestion)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t{range ? (\n\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t{suggestion.slice(0, range.start)}\n\t\t\t\t\t\t\t\t\t\t\t<strong>{suggestion.slice(range.start, range.end)}</strong>\n\t\t\t\t\t\t\t\t\t\t\t{suggestion.slice(range.end)}\n\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\tsuggestion\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t{isActive ? (\n\t\t\t\t\t\t\t\t\t<kbd aria-hidden=\"true\" className=\"text-muted-foreground font-mono text-[10px]\">\n\t\t\t\t\t\t\t\t\t\t⏎\n\t\t\t\t\t\t\t\t\t</kbd>\n\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t);\n\t\t\t\t\t})}\n\t\t\t\t</div>\n\t\t\t) : null}\n\t\t</Portal>\n\t);\n}\n\nAutocompletePanel.displayName = \"AutocompletePanel\";\n","type":"registry:ui","target":"@ui/fancy/components/autocomplete/AutocompletePanel.tsx"},{"path":"react/src/components/autocomplete/autocomplete.css","content":"/* Ported from the `<style>` block of `Autocomplete.svelte`. Svelte's compiler\n   scoped this rule to the component; plain CSS is global, so it stays anchored\n   on `.ft-autocomplete` — the class the source already carries as the first\n   token of the input's own `cn()` call, so nothing was added for the anchor's\n   sake. */\n.ft-autocomplete:focus-visible {\n\t--ft-field-accent: var(\n\t\t--ft-accent,\n\t\tlight-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))\n\t);\n\tborder-color: var(--ft-field-accent);\n\tbox-shadow: 0 0 0 3px color-mix(in oklab, var(--ft-field-accent) 25%, transparent);\n}\n","type":"registry:ui","target":"@ui/fancy/components/autocomplete/autocomplete.css"},{"path":"react/src/components/autocomplete/index.ts","content":"\"use client\";\n\nexport { Autocomplete } from \"./Autocomplete\";\nexport type { AutocompleteProps } from \"./Autocomplete\";\nexport { AUTOCOMPLETE_KEY } from \"./types\";\nexport type { AutocompleteContext } from \"./types\";\n","type":"registry:ui","target":"@ui/fancy/components/autocomplete/index.ts"},{"path":"react/src/components/autocomplete/match.ts","content":"\"use client\";\n\n// Pure text-matching helpers shared by the root and its floating panel.\n// Autocomplete has no customizable `filter` prop (unlike Combobox) — every\n// suggestion match is this same case-insensitive substring rule, so\n// `getMatchRange` never has to degrade for a custom filter's sake here. It\n// stays a separate function from the filtering predicate anyway, matching\n// Combobox's shape, since a highlight span and a yes/no match are different\n// questions even when one rule answers both.\n\nexport interface MatchRange {\n\tstart: number;\n\tend: number;\n}\n\n/** Finds the first case-insensitive occurrence of `query` inside `text`. Returns `null` when `query` is empty or not present. */\nexport function getMatchRange(text: string, query: string): MatchRange | null {\n\tif (!query) return null;\n\tconst index = text.toLowerCase().indexOf(query.toLowerCase());\n\tif (index === -1) return null;\n\treturn { start: index, end: index + query.length };\n}\n","type":"registry:ui","target":"@ui/fancy/components/autocomplete/match.ts"},{"path":"react/src/components/autocomplete/types.ts","content":"\"use client\";\n\n/**\n * The contract between the Autocomplete root and its portalled suggestions\n * panel.\n *\n * Autocomplete is an **open field**: `value` is free text and every one of\n * its characters is already valid the moment it is typed. `suggestions` are\n * advisory only — the panel is a shortcut for finishing a value the user\n * could have typed out in full, never a constraint on what `value` may be.\n * The root owns the filtering, the active row, and what a click or Enter on\n * a row does to `value`; the panel only reads this and calls back into it.\n */\n\nimport { createInternalContext } from \"../../internals/dom/context\";\n\nexport interface AutocompleteContext {\n\t/** Whether the panel is open. Read by the panel's own exit transition to\n\t *  tell an entrance from a departure — one bidirectional transition\n\t *  instance reports `direction: \"both\"` and cannot distinguish them.\n\t *  Also the panel's dismissable `active` gate: the instant this flips\n\t *  false the layer stops answering Escape, so a second Escape during the\n\t *  fade reaches whatever is underneath instead of being swallowed by a\n\t *  panel that is already leaving. */\n\treadonly open: boolean;\n\t/** The panel's own id — also what the input's `aria-controls` points at while open. */\n\treadonly panelId: string;\n\t/** The real input element, once mounted — what the panel anchors against and excludes from outside-click dismissal. */\n\treadonly inputRef: HTMLInputElement | null;\n\t/** The suggestions currently visible in the panel, already filtered and capped at `maxSuggestions`. */\n\treadonly suggestions: string[];\n\t/** The current value, for computing each row's highlighted span. */\n\treadonly query: string;\n\t/** Index of the keyboard-active row within `suggestions`, or -1 when none is active — arrowing highlights a row without writing it into the field. */\n\treadonly activeIndex: number;\n\t/** The dom id a row at this index must carry, so `aria-activedescendant` can point at it. */\n\toptionId(index: number): string;\n\t/** Whether the row at this index is the keyboard-active one. */\n\tisActive(index: number): boolean;\n\t/** Commits `suggestion` into the field and closes the panel. */\n\tselect(suggestion: string): void;\n\t/** Closes the panel without touching `value` — arrowing never wrote into the field, so there is nothing to restore. */\n\tclose(): void;\n}\n\n/**\n * The context the root publishes to its panel.\n *\n * The Svelte source keys this on a `unique symbol` passed to\n * `setContext`/`getContext`; React's own context object plays that role here,\n * under the same name, because the Svelte `index.ts` exports the key — so it\n * keeps its public identity on both sides.\n *\n * Read it with `AUTOCOMPLETE_KEY.useRequired()`: the panel is only ever\n * rendered by the root, so a missing provider is a programming error rather\n * than a degrade-gracefully case.\n */\nexport const AUTOCOMPLETE_KEY = createInternalContext<AutocompleteContext>(\"AUTOCOMPLETE_KEY\");\n","type":"registry:ui","target":"@ui/fancy/components/autocomplete/types.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/autocomplete?framework=react"}}