{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"search-input","type":"registry:ui","title":"SearchInput","description":"A pill-shaped search field with a leading icon, a clear button once there's something to clear, and optional debounced search","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/search-input/SearchInput.tsx","content":"\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useRef, useState } from \"react\";\nimport type { ChangeEvent, ComponentPropsWithoutRef, KeyboardEvent } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useField } from \"../../internals/field\";\nimport { mergeIds } from \"../../internals/merge-ids\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { preset } from \"../../internals/motion/transitions\";\nimport { prefersReducedMotion } from \"../../internals/motion/anchored\";\nimport { DURATIONS } from \"../../internals/motion/tokens\";\nimport { runTransition } from \"../../internals/motion/animate\";\nimport type { TransitionRun } from \"../../internals/motion/animate\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport \"./search-input.css\";\n\nexport interface SearchInputOwnProps {\n\t/**\n\t * Current value. Controlled when supplied: pair it with `onValueChange`,\n\t * the React counterpart of the Svelte source's `bind:value`.\n\t */\n\tvalue?: string;\n\t/** Initial value when uncontrolled (no `value` prop given). */\n\tdefaultValue?: string;\n\t/** Called with the new value on every input event. */\n\tonValueChange?: (value: string) => void;\n\t/**\n\t * Fired on Enter, and again on debounced settle whenever `debounceMs` is\n\t * set — Enter itself always cancels a pending debounce first, so a\n\t * settle never fires a second time for the value Enter already reported.\n\t */\n\tonSearch?: (value: string) => void;\n\t/** Shown while the field is empty. */\n\tplaceholder?: string;\n\t/**\n\t * Delay, in milliseconds, before a settled value fires `onSearch` on its\n\t * own. `0` (the default) disables debouncing entirely — no timer is ever\n\t * scheduled, and `onSearch` only fires from Enter.\n\t */\n\tdebounceMs?: number;\n\t/** Blocks focus and typing; excluded from form submission. Overridden by a surrounding FormField. */\n\tdisabled?: boolean;\n\t/** Blocks typing and clearing but stays focusable and is still submitted, unlike `disabled`. */\n\treadonly?: 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. */\n\tname?: string;\n\t/** Accessible name — for a control with no visible Label next to it. */\n\tlabel?: string;\n\t/** Renders a clear button once there is something to clear. Defaults to `true`. */\n\tclearable?: boolean;\n\t/** Additional CSS classes, merged onto the root field surface (not the bare `<input>`). */\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\n/**\n * Own props plus every native `<input>` attribute and listener; the rest are\n * spread onto the inner `<input>` (see the component README for the rules).\n */\nexport interface SearchInputProps\n\textends\n\t\tOmit<ComponentPropsWithoutRef<\"input\">, keyof SearchInputOwnProps | \"type\">,\n\t\tSearchInputOwnProps {}\n\n// The clear button appears mid-interaction, the instant the field stops\n// being empty — a 150ms grow-and-fade is what stops it materialising as a\n// hard pop next to the caret the user is watching.\n//\n// An intro and never an exit, deliberately: `clearValue()` calls\n// `focus()` on the input synchronously right after emptying the field, and\n// the button's own conditional goes false in the same update. An outro\n// would keep the button mounted past that focus call, reordering focus\n// against `onValueChange` for anything listening. The exit is left for a\n// later pass that can move the focus handoff first.\n//\n// `prefersReducedMotion()` is called at the instant the intro starts — the\n// ref callback below runs when the button actually attaches — never at\n// construction and never during SSR. `duration: 0` makes the runner skip\n// `element.animate()` outright instead of running a zero-length animation.\nconst pop = preset(\"scale\");\n\nconst searchIcon = (\n\t<svg\n\t\tclassName=\"size-4\"\n\t\tviewBox=\"0 0 24 24\"\n\t\tfill=\"none\"\n\t\tstroke=\"currentColor\"\n\t\tstrokeWidth=\"2\"\n\t\tstrokeLinecap=\"round\"\n\t\tstrokeLinejoin=\"round\"\n\t\taria-hidden=\"true\"\n\t>\n\t\t<circle cx=\"11\" cy=\"11\" r=\"8\" />\n\t\t<line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\" />\n\t</svg>\n);\n\n/**\n * A search field with a debounced `onSearch`, Escape-to-clear, and an\n * animated clear button.\n *\n * The element reference arrives through the ref channel (the Svelte source\n * declares `ref = $bindable(null)`), pointing at the native `<input>`.\n */\nexport const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(\n\t(\n\t\t{\n\t\t\tvalue: valueProp,\n\t\t\tdefaultValue = \"\",\n\t\t\tonValueChange,\n\t\t\tonSearch,\n\t\t\tplaceholder = \"Search\",\n\t\t\tdebounceMs = 0,\n\t\t\tdisabled = false,\n\t\t\treadonly = false,\n\t\t\trequired = false,\n\t\t\tinvalid = false,\n\t\t\tid,\n\t\t\tname,\n\t\t\tlabel,\n\t\t\tclearable = true,\n\t\t\tclassName,\n\t\t\tsound = false,\n\t\t\tonKeyDown,\n\t\t\tonChange,\n\t\t\t\"aria-describedby\": consumerDescribedBy,\n\t\t\t\"aria-label\": consumerAriaLabel,\n\t\t\t\"aria-invalid\": consumerAriaInvalid,\n\t\t\treadOnly: readOnlyAttr,\n\t\t\t// An `<input>` is a void element: a stray `children` must not reach it.\n\t\t\tchildren: _children,\n\t\t\t...rest\n\t\t},\n\t\tforwardedRef\n\t) => {\n\t\t// The Svelte side's `value = $bindable(\"\")` becomes the standard\n\t\t// controlled/uncontrolled split: uncontrolled by default, controlled\n\t\t// the moment a `value` prop is passed.\n\t\tconst isControlled = valueProp !== undefined;\n\t\tconst [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);\n\t\tconst value = isControlled ? valueProp : uncontrolledValue;\n\n\t\t// Undefined outside a FormField — every fallback below then uses this\n\t\t// component's own props instead of the context, so the control works\n\t\t// standalone exactly as it does wrapped.\n\t\tconst field = useField();\n\n\t\tconst effectiveId = field?.controlId ?? id;\n\t\tconst effectiveDisabled = field?.disabled ?? disabled;\n\t\tconst effectiveRequired = field?.required ?? required;\n\t\tconst effectiveInvalid = field?.invalid ?? invalid;\n\n\t\tconst playCue = useSoundCue(sound);\n\n\t\t// Whether the clear affordance — the button and Escape's clear-on-press —\n\t\t// is available at all right now. Kept as one value so the button's\n\t\t// presence and Escape's behaviour never disagree about when clearing is\n\t\t// actually possible.\n\t\tconst isReadonly = readonly || !!readOnlyAttr;\n\t\tconst canClear = clearable && !effectiveDisabled && !isReadonly && value !== \"\";\n\n\t\tconst inputRef = useRef<HTMLInputElement | null>(null);\n\t\tconst composedRef = useComposedRefs(forwardedRef, inputRef);\n\n\t\tconst debounceTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n\t\tfunction clearDebounce() {\n\t\t\tif (debounceTimer.current !== undefined) {\n\t\t\t\tclearTimeout(debounceTimer.current);\n\t\t\t\tdebounceTimer.current = undefined;\n\t\t\t}\n\t\t}\n\n\t\t// Runs once at mount and its cleanup is exactly the unmount teardown —\n\t\t// the same shape CopyButton uses for its own timer. A pending debounce\n\t\t// must never fire onSearch after the component backing it is gone.\n\t\t// Doubles as the \"past the initial render\" latch the intro below reads:\n\t\t// a local intro never plays on the block's first run within the initial\n\t\t// render, only when the button appears mid-interaction.\n\t\tconst hasMountedOnce = useRef(false);\n\t\tuseEffect(() => {\n\t\t\thasMountedOnce.current = true;\n\t\t\treturn () => {\n\t\t\t\thasMountedOnce.current = false;\n\t\t\t\tclearDebounce();\n\t\t\t};\n\t\t}, []);\n\n\t\t// The intro leg in flight, aborted if the button detaches before it\n\t\t// settles. The ref callback is where the button's attach is observed,\n\t\t// which is the exact moment the source starts the intro — pre-paint,\n\t\t// in the same commit that put the node in the DOM.\n\t\tconst introRun = useRef<TransitionRun | null>(null);\n\t\tconst clearButtonRef = useCallback((node: HTMLButtonElement | null) => {\n\t\t\tif (node === null) {\n\t\t\t\tintroRun.current?.abort();\n\t\t\t\tintroRun.current = null;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!hasMountedOnce.current) return;\n\t\t\tconst spec = pop(\n\t\t\t\tnode,\n\t\t\t\t{ duration: prefersReducedMotion() ? 0 : DURATIONS.fast },\n\t\t\t\t{ direction: \"in\" }\n\t\t\t);\n\t\t\t// The leg aborts itself the moment it lands, exactly as the presence\n\t\t\t// core does on an enter leg: aborting is what drops `fill: forwards`,\n\t\t\t// so the button falls back to its resting style instead of holding\n\t\t\t// `opacity` and `transform` at animation priority — over any consumer\n\t\t\t// CSS — for the rest of its life, and it releases the Animation object\n\t\t\t// the runner's own `abort()` calls a Chromium leak. The handle box is\n\t\t\t// what makes the run reachable from inside its own finish callback: a\n\t\t\t// duration-0 leg finishes synchronously inside `runTransition`, before\n\t\t\t// the return value exists, and that leg's `abort` is a no-op anyway.\n\t\t\tconst handle: { current: TransitionRun | null } = { current: null };\n\t\t\tconst run = runTransition(node, spec, 1, undefined, () => {\n\t\t\t\thandle.current?.abort();\n\t\t\t\tintroRun.current = null;\n\t\t\t});\n\t\t\thandle.current = run;\n\t\t\tintroRun.current = run;\n\t\t}, []);\n\n\t\tconst describedBy = mergeIds(field?.describedBy, consumerDescribedBy);\n\n\t\tconst classes = cn(\n\t\t\t\"ft-search-input flex w-full items-center gap-2 rounded-full border border-input bg-background px-[14px] py-[9px] text-[13px] transition-colors\",\n\t\t\teffectiveDisabled && \"cursor-not-allowed opacity-50\",\n\t\t\teffectiveInvalid && \"border-destructive/50\",\n\t\t\tclassName\n\t\t);\n\n\t\t// The single place `value` changes from typing. A native `disabled` input\n\t\t// never fires `input` from real typing, but a synthetic dispatch walks\n\t\t// straight past that guard the same way a synthetic click does on a\n\t\t// button, so the early return is repeated here rather than trusted to the\n\t\t// attribute alone.\n\t\tfunction handleInput(event: ChangeEvent<HTMLInputElement>) {\n\t\t\tif (effectiveDisabled) return;\n\t\t\tconst next = event.currentTarget.value;\n\t\t\tif (!isControlled) setUncontrolledValue(next);\n\t\t\tonValueChange?.(next);\n\n\t\t\tclearDebounce();\n\t\t\tif (debounceMs > 0) {\n\t\t\t\tdebounceTimer.current = setTimeout(() => {\n\t\t\t\t\tdebounceTimer.current = undefined;\n\t\t\t\t\tonSearch?.(next);\n\t\t\t\t}, debounceMs);\n\t\t\t}\n\t\t\tonChange?.(event);\n\t\t}\n\n\t\tfunction handleKeydown(event: KeyboardEvent<HTMLInputElement>) {\n\t\t\tif (effectiveDisabled) return;\n\t\t\thandleOwnKey(event);\n\t\t\tonKeyDown?.(event);\n\t\t}\n\n\t\tfunction handleOwnKey(event: KeyboardEvent<HTMLInputElement>) {\n\t\t\tif (event.key === \"Enter\") {\n\t\t\t\t// Enter always wins over a debounce still in flight — without this,\n\t\t\t\t// a settle firing moments later would report the same value onSearch\n\t\t\t\t// was just handed, a second time.\n\t\t\t\tclearDebounce();\n\t\t\t\tonSearch?.(value);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Escape-clears-a-search-field is the platform convention, but only\n\t\t\t// while there is something to clear — `canClear` is the same flag the\n\t\t\t// button's own presence is gated on, so the two never disagree about\n\t\t\t// when Escape does something. `stopPropagation` runs only in that same\n\t\t\t// case: this component has no dismissable layer of its own, but an\n\t\t\t// ancestor might (a popover or dialog this input sits inside), and its\n\t\t\t// Escape-to-close listener is registered on `document` — stopping the\n\t\t\t// bubble here is what keeps that ancestor closed only once this field\n\t\t\t// has nothing left of its own to clear.\n\t\t\tif (event.key === \"Escape\" && canClear) {\n\t\t\t\tevent.stopPropagation();\n\t\t\t\tclearValue();\n\t\t\t}\n\t\t}\n\n\t\tfunction clearValue() {\n\t\t\tif (effectiveDisabled || isReadonly) return;\n\t\t\tif (!isControlled) setUncontrolledValue(\"\");\n\t\t\tplayCue(\"press\");\n\t\t\tonValueChange?.(\"\");\n\t\t\tclearDebounce();\n\t\t\t// The button that triggered this is about to disappear (it only\n\t\t\t// renders while there's something to clear) — focus would otherwise be\n\t\t\t// left on a node no longer in the DOM. Escape reaching here already has\n\t\t\t// focus on the input, so this is a no-op in that path.\n\t\t\tinputRef.current?.focus();\n\t\t}\n\n\t\treturn (\n\t\t\t<div className={classes}>\n\t\t\t\t<span className=\"ft-search-input-icon text-muted-foreground flex shrink-0 items-center\">\n\t\t\t\t\t{searchIcon}\n\t\t\t\t</span>\n\t\t\t\t<input\n\t\t\t\t\t{...rest}\n\t\t\t\t\tref={composedRef}\n\t\t\t\t\ttype=\"search\"\n\t\t\t\t\tplaceholder={placeholder}\n\t\t\t\t\tname={name}\n\t\t\t\t\tid={effectiveId}\n\t\t\t\t\tvalue={value}\n\t\t\t\t\tdisabled={effectiveDisabled}\n\t\t\t\t\treadOnly={isReadonly}\n\t\t\t\t\trequired={effectiveRequired}\n\t\t\t\t\taria-invalid={effectiveInvalid ? \"true\" : consumerAriaInvalid}\n\t\t\t\t\taria-describedby={describedBy}\n\t\t\t\t\taria-label={label ?? consumerAriaLabel}\n\t\t\t\t\tclassName=\"ft-search-input-field text-foreground placeholder:text-muted-foreground min-w-0 flex-1 border-0 bg-transparent p-0 outline-none disabled:cursor-not-allowed\"\n\t\t\t\t\tonChange={handleInput}\n\t\t\t\t\tonKeyDown={handleKeydown}\n\t\t\t\t/>\n\t\t\t\t{canClear && (\n\t\t\t\t\t<button\n\t\t\t\t\t\tref={clearButtonRef}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tclassName=\"ft-search-input-clear text-muted-foreground hover:bg-accent hover:text-accent-foreground flex size-[18px] shrink-0 items-center justify-center rounded-full\"\n\t\t\t\t\t\taria-label=\"Clear search\"\n\t\t\t\t\t\tonClick={clearValue}\n\t\t\t\t\t>\n\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\tclassName=\"size-2.5\"\n\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\tstrokeWidth=\"3\"\n\t\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\" />\n\t\t\t\t\t\t\t<line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\" />\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t</button>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t);\n\t}\n);\n\nSearchInput.displayName = \"SearchInput\";\n","type":"registry:ui","target":"@ui/fancy/components/search-input/SearchInput.tsx"},{"path":"react/src/components/search-input/index.ts","content":"\"use client\";\n\nexport { SearchInput } from \"./SearchInput\";\nexport type { SearchInputProps } from \"./SearchInput\";\n","type":"registry:ui","target":"@ui/fancy/components/search-input/index.ts"},{"path":"react/src/components/search-input/search-input.css","content":"/*\n * The brand accent has no semantic token, so it is declared locally with a\n * light-dark() fallback — the same shape Input and Textarea use for their\n * own accent ring. The ring lives on the wrapper via :focus-within rather\n * than on the bare input, because the input itself has no border or\n * background of its own here — the wrapper is the visual field surface.\n */\n.ft-search-input:focus-within {\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\n/*\n * `type=\"search\"` (rather than `type=\"text\"`) is deliberate: it maps to\n * the `searchbox` accessibility role instead of a plain textbox, and on a\n * mobile keyboard it labels the return key \"search\" — both real wins Enter\n * already acts on via `onSearch`. The cost is that some browsers draw their\n * own native clear \"x\" once the field has text, which would sit right next\n * to this component's own clear button. It is suppressed here rather than\n * switching to `type=\"text\"`, so there is exactly one clear affordance,\n * not two.\n */\n.ft-search-input .ft-search-input-field::-webkit-search-cancel-button,\n.ft-search-input .ft-search-input-field::-webkit-search-decoration {\n\t-webkit-appearance: none;\n\tappearance: none;\n}\n","type":"registry:ui","target":"@ui/fancy/components/search-input/search-input.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/search-input?framework=react"}}