{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"autocomplete","type":"registry:ui","title":"Autocomplete","description":"A text field that suggests as you type, where any text is still a valid answer.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/field.json","https://dashral.com/r/input.json","https://dashral.com/r/popover.json"],"files":[{"path":"registry/dashral/autocomplete.tsx","type":"registry:file","target":"components/dashral/autocomplete.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { Loader2 } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { type FieldClassNames, type FieldOwnProps } from \"./field\"\nimport { Input } from \"./input\"\nimport { PopoverAnchor, PopoverContent, PopoverRoot } from \"./popover\"\n\ntype AutocompleteSuggestion = string | { value: string; description?: string; icon?: React.ReactNode }\n\ntype AutocompleteProps = FieldOwnProps &\n  Omit<React.ComponentProps<\"input\">, \"value\" | \"defaultValue\" | \"onChange\" | \"onSelect\" | \"prefix\" | \"list\"> & {\n    /** The text, controlled. Anything can be typed; a suggestion only fills it in. */\n    value?: string\n    defaultValue?: string\n    onValueChange?: (value: string) => void\n    /** What it suggests: strings, or { value, description, icon }. */\n    suggestions: AutocompleteSuggestion[]\n    /** Called when a suggestion is picked (not on every keystroke), e.g. to add a tag and clear the field. */\n    onSelect?: (value: string) => void\n    /** Narrows the suggestions to those containing the text, ignoring case and accents. false: they're shown as given, for a search of your own (fetch as onValueChange fires). */\n    filter?: boolean\n    /** A spinner in the list while your suggestions load. */\n    loading?: boolean\n    /** At most this many in the list. */\n    limit?: number\n    prefix?: React.ReactNode\n    suffix?: React.ReactNode\n    classNames?: FieldClassNames & { wrapper?: string; input?: string; content?: string; option?: string }\n  }\n\n/** Lowercase without accents, so \"cafe\" finds \"Café\". */\nconst fold = (text: string) => text.normalize(\"NFD\").replace(/\\p{Diacritic}/gu, \"\").toLowerCase()\n\n/** The suggestion with the typed part in bold. */\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  const at = query ? fold(text).indexOf(fold(query)) : -1\n  if (at < 0) return <>{text}</>\n  return (\n    <>\n      {text.slice(0, at)}\n      <strong className=\"font-semibold text-foreground\">{text.slice(at, at + query.length)}</strong>\n      {text.slice(at + query.length)}\n    </>\n  )\n}\n\n/**\n * A text field that suggests as you type, in one tag: `<Autocomplete label=\"City\" suggestions={cities} />`. Unlike\n * Combobox, any text is a valid answer; the suggestions only save typing. The arrow keys move through them and Enter\n * picks one, while the focus stays in the field. Takes label, description and error like Input.\n */\nfunction Autocomplete({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  suggestions,\n  onSelect,\n  filter = true,\n  loading = false,\n  limit = 8,\n  className,\n  classNames,\n  onKeyDown,\n  onFocus,\n  onBlur,\n  ...props\n}: AutocompleteProps) {\n  const { t } = useLocale()\n  const listId = React.useId()\n  const [internal, setInternal] = React.useState(defaultValue)\n  const text = value ?? internal\n  const [open, setOpen] = React.useState(false)\n  const [active, setActive] = React.useState(-1)\n  const field = React.useRef<HTMLInputElement>(null)\n  // The field's box ([data-slot=\"input\"]: the box around a prefix and the input, or the input itself without one).\n  const box = React.useRef({\n    getBoundingClientRect: () => {\n      const input = field.current\n      return (input?.closest<HTMLElement>('[data-slot=\"input\"]') ?? input)?.getBoundingClientRect() ?? new DOMRect()\n    },\n  })\n\n  const all = suggestions.map((s) => (typeof s === \"string\" ? { value: s } : s))\n  const needle = fold(text.trim())\n  const shown = (filter ? all.filter((s) => fold(s.value).includes(needle) && s.value !== text) : all).slice(0, limit)\n  const expanded = open && (shown.length > 0 || loading)\n\n  function change(next: string) {\n    if (value === undefined) setInternal(next)\n    onValueChange?.(next)\n  }\n  function choose(suggestion: string) {\n    change(suggestion)\n    onSelect?.(suggestion)\n    setOpen(false)\n    setActive(-1)\n  }\n\n  return (\n    <PopoverRoot open={expanded} onOpenChange={setOpen}>\n      {/* The list lines up with the field's box, not the bare input: with a prefix (a search icon) the input starts\n          after it, and the list would start there too, narrower than the field. */}\n      <PopoverAnchor virtualRef={box} />\n      <Input\n        {...props}\n        ref={field}\n        value={text}\n        role=\"combobox\"\n        aria-autocomplete=\"list\"\n        aria-expanded={expanded}\n        aria-controls={listId}\n        aria-activedescendant={expanded && active >= 0 ? `${listId}-${active}` : undefined}\n        autoComplete=\"off\"\n        className={className}\n        classNames={classNames}\n        onFocus={(event) => {\n          setOpen(true)\n          onFocus?.(event)\n        }}\n        onBlur={(event) => {\n          setOpen(false)\n          setActive(-1)\n          onBlur?.(event)\n        }}\n        onChange={(event) => {\n          change(event.target.value)\n          setOpen(true)\n          setActive(-1)\n        }}\n        onKeyDown={(event) => {\n          if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n            event.preventDefault()\n            setOpen(true)\n            const step = event.key === \"ArrowDown\" ? 1 : -1\n            setActive((i) => (shown.length ? (i + step + shown.length) % shown.length : -1))\n          } else if (event.key === \"Enter\" && expanded && active >= 0 && shown[active]) {\n            event.preventDefault()\n            choose(shown[active].value)\n          } else if (event.key === \"Escape\" && expanded) {\n            event.preventDefault()\n            event.stopPropagation()\n            setOpen(false)\n          }\n          onKeyDown?.(event)\n        }}\n      />\n      <PopoverContent\n        align=\"start\"\n        sideOffset={6}\n        className={cn(\"w-(--radix-popover-trigger-width) min-w-48 p-1\", classNames?.content)}\n        // The focus stays in the field: the list never takes it.\n        onOpenAutoFocus={(event) => event.preventDefault()}\n        onCloseAutoFocus={(event) => event.preventDefault()}\n        // The field anchors the list rather than triggering it, so Radix would count a press on it (its prefix\n        // included) as outside and close the list as it opens. The field closes it itself, on blur.\n        onInteractOutside={(event) => {\n          const fieldBox = field.current?.closest('[data-slot=\"input\"]') ?? field.current\n          if (event.target instanceof Node && fieldBox?.contains(event.target)) event.preventDefault()\n        }}\n      >\n        <ul id={listId} role=\"listbox\" className=\"max-h-64 overflow-y-auto\">\n          {shown.map((suggestion, i) => (\n            <li\n              key={suggestion.value}\n              id={`${listId}-${i}`}\n              role=\"option\"\n              aria-selected={i === active}\n              // mousedown, so the field doesn't blur (and close the list) before the pick lands.\n              onMouseDown={(event) => {\n                event.preventDefault()\n                choose(suggestion.value)\n              }}\n              onMouseMove={() => active !== i && setActive(i)}\n              className={cn(\n                \"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-foreground/80 transition-colors duration-(--duration-fast) [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground\",\n                i === active && \"bg-accent text-accent-foreground\",\n                classNames?.option\n              )}\n            >\n              {suggestion.icon}\n              <span className=\"grid min-w-0\">\n                <span className=\"truncate\">\n                  <Highlight text={suggestion.value} query={filter ? text.trim() : \"\"} />\n                </span>\n                {suggestion.description && <span className=\"truncate text-xs text-muted-foreground\">{suggestion.description}</span>}\n              </span>\n            </li>\n          ))}\n          {loading && (\n            <li role=\"presentation\" className=\"flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground\">\n              <Loader2 aria-hidden className=\"size-4 animate-spin motion-reduce:animate-[spin_1.5s_linear_infinite]\" />\n              {t(\"loading\")}\n            </li>\n          )}\n        </ul>\n      </PopoverContent>\n    </PopoverRoot>\n  )\n}\n\nexport { Autocomplete, type AutocompleteProps, type AutocompleteSuggestion }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}