{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "async-select",
  "type": "registry:ui",
  "title": "AsyncSelect",
  "description": "App-owned async selection wrapper for single and multiple results with loading, empty, min-query, error retry, stable selected labels, and form serialization.",
  "dependencies": [
    "react-aria@^3.52.1",
    "react-aria-components@^1.21.1"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://components.dethink.co.uk/r/dethink-base.json",
    "https://components.dethink.co.uk/r/combobox.json",
    "https://components.dethink.co.uk/r/multi-select.json"
  ],
  "files": [
    {
      "path": "packages/components/src/components/async-select/async-select.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/async-select/async-select.tsx",
      "content": "import {\n  type ReactElement,\n  type ReactNode,\n  type RefAttributes,\n  forwardRef,\n  useMemo,\n  useRef,\n  useEffect,\n  useImperativeHandle,\n  type KeyboardEvent,\n  useState,\n} from \"react\";\nimport { Combobox, ComboboxItem, type ComboboxControlSize } from \"../combobox\";\nimport {\n  MultiSelect,\n  MultiSelectItem,\n  type MultiSelectControlSize,\n} from \"../multi-select\";\nimport { cn } from \"../../utils/cn\";\n\nexport type AsyncSelectValue = string;\nexport type AsyncSelectSelectionMode = \"single\" | \"multiple\";\nexport type AsyncSelectControlSize = ComboboxControlSize &\n  MultiSelectControlSize;\n\nexport type AsyncSelectItemData = {\n  label?: ReactNode;\n  textValue?: string;\n  value: AsyncSelectValue;\n};\n\nexport type AsyncSelectChangeValue<M extends AsyncSelectSelectionMode> =\n  M extends \"multiple\" ? AsyncSelectValue[] : AsyncSelectValue | null;\n\nexport interface AsyncSelectProps<\n  T extends AsyncSelectItemData = AsyncSelectItemData,\n  M extends AsyncSelectSelectionMode = \"single\",\n> {\n  \"aria-invalid\"?: boolean | \"false\" | \"true\" | \"grammar\" | \"spelling\";\n  \"data-slot\"?: string;\n  className?: string;\n  clearLabel?: string;\n  controlSize?: AsyncSelectControlSize;\n  defaultInputValue?: string;\n  defaultValue?: AsyncSelectChangeValue<M>;\n  description?: ReactNode;\n  disabled?: boolean;\n  disabledKeys?: Iterable<AsyncSelectValue>;\n  emptyMessage?: ReactNode;\n  error?: ReactNode;\n  errorMessage?: ReactNode;\n  inputValue?: string;\n  invalid?: boolean;\n  items?: Iterable<T>;\n  label?: ReactNode;\n  loading?: boolean;\n  loadingMessage?: ReactNode;\n  minQueryLength?: number;\n  minQueryMessage?: ReactNode;\n  name?: string;\n  onInputValueChange?: (value: string) => void;\n  onRetry?: () => void;\n  onValueChange?: (value: AsyncSelectChangeValue<M>) => void;\n  placeholder?: string;\n  readOnly?: boolean;\n  required?: boolean;\n  retryLabel?: string;\n  searchPlaceholder?: string;\n  selectedItems?: Iterable<T>;\n  selectionMode?: M;\n  value?: AsyncSelectChangeValue<M>;\n}\n\nconst asyncSelectRootClasses =\n  \"grid w-full min-w-0 content-start gap-[var(--dt-space-2)]\";\n\nconst asyncSelectStatusClasses =\n  \"min-h-10 rounded-md border border-border bg-muted/40 px-[var(--dt-space-3)] py-[var(--dt-space-2)] text-sm leading-5 text-muted-foreground motion-safe:animate-feedback-in\";\n\nconst asyncSelectErrorClasses =\n  \"min-h-10 rounded-md border border-destructive/30 bg-destructive/10 px-[var(--dt-space-3)] py-[var(--dt-space-2)] text-sm leading-5 text-destructive motion-safe:animate-feedback-in\";\n\nconst asyncSelectRetryClasses =\n  \"mt-[var(--dt-space-2)] inline-flex h-8 items-center justify-center rounded-md border border-border bg-background px-[var(--dt-space-2-5)] text-xs font-medium text-foreground outline-none motion-safe:transition-[background-color,border-color,color] motion-safe:duration-[var(--dt-motion-fast)] motion-safe:ease-control hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\";\n\ntype AsyncSelectComponent = (<\n  T extends AsyncSelectItemData = AsyncSelectItemData,\n  M extends AsyncSelectSelectionMode = \"single\",\n>(\n  props: AsyncSelectProps<T, M> & RefAttributes<HTMLDivElement>,\n) => ReactElement | null) & { displayName?: string };\n\nfunction itemLabel(item: AsyncSelectItemData) {\n  return item.label ?? item.textValue ?? item.value;\n}\n\nfunction itemTextValue(item: AsyncSelectItemData) {\n  if (item.textValue) {\n    return item.textValue;\n  }\n\n  return typeof item.label === \"string\" ? item.label : item.value;\n}\n\nfunction uniqueItems<T extends AsyncSelectItemData>(\n  items: Iterable<T> | undefined,\n  selectedItems: Iterable<T> | undefined,\n) {\n  const map = new Map<AsyncSelectValue, T>();\n\n  for (const item of selectedItems ?? []) {\n    map.set(item.value, item);\n  }\n\n  for (const item of items ?? []) {\n    map.set(item.value, item);\n  }\n\n  return Array.from(map.values());\n}\n\nfunction normalizeSingleValue(value: unknown): AsyncSelectValue | null {\n  return typeof value === \"string\" ? value : null;\n}\n\nfunction normalizeMultipleValue(value: unknown): AsyncSelectValue[] {\n  return Array.isArray(value) ? value : [];\n}\n\nfunction getAsyncState({\n  error,\n  inputValue,\n  itemsCount,\n  loading,\n  minQueryLength,\n}: {\n  error?: ReactNode;\n  inputValue: string;\n  itemsCount: number;\n  loading?: boolean;\n  minQueryLength: number;\n}) {\n  if (error) {\n    return \"error\";\n  }\n\n  if (loading) {\n    return \"loading\";\n  }\n\n  if (minQueryLength > 0 && inputValue.trim().length < minQueryLength) {\n    return \"min-query\";\n  }\n\n  if (itemsCount === 0) {\n    return \"empty\";\n  }\n\n  return \"ready\";\n}\n\nfunction AsyncStatus({\n  error,\n  errorMessage,\n  loadingMessage,\n  minQueryLength,\n  minQueryMessage,\n  onRetry,\n  retryLabel,\n  state,\n}: {\n  error?: ReactNode;\n  errorMessage: ReactNode;\n  loadingMessage: ReactNode;\n  minQueryLength: number;\n  minQueryMessage: ReactNode;\n  onRetry?: () => void;\n  retryLabel: string;\n  state: \"empty\" | \"error\" | \"loading\" | \"min-query\" | \"ready\";\n}) {\n  if (state === \"ready\") {\n    return null;\n  }\n\n  if (state === \"error\") {\n    return (\n      <div\n        role=\"alert\"\n        data-slot=\"async-select-status\"\n        data-state=\"error\"\n        className={asyncSelectErrorClasses}\n      >\n        <div>{error ?? errorMessage}</div>\n        {onRetry ? (\n          <button\n            type=\"button\"\n            data-slot=\"async-select-retry\"\n            onClick={onRetry}\n            className={asyncSelectRetryClasses}\n          >\n            {retryLabel}\n          </button>\n        ) : null}\n      </div>\n    );\n  }\n\n  const message =\n    state === \"loading\"\n      ? loadingMessage\n      : state === \"min-query\"\n        ? minQueryMessage || `Type at least ${minQueryLength} characters.`\n        : errorMessage;\n\n  return (\n    <div\n      role=\"status\"\n      data-slot=\"async-select-status\"\n      data-state={state}\n      className={asyncSelectStatusClasses}\n    >\n      <div className=\"flex items-center gap-2\">\n        {state === \"loading\" ? (\n          <span\n            aria-hidden=\"true\"\n            className=\"size-4 shrink-0 rounded-full border-2 border-current border-e-transparent motion-safe:animate-spin\"\n          />\n        ) : null}\n        <span>{message}</span>\n      </div>\n    </div>\n  );\n}\n\nfunction AsyncSelectRoot<\n  T extends AsyncSelectItemData = AsyncSelectItemData,\n  M extends AsyncSelectSelectionMode = \"single\",\n>(\n  {\n    \"aria-invalid\": ariaInvalid,\n    \"data-slot\": dataSlot,\n    className,\n    clearLabel,\n    controlSize = \"md\",\n    defaultInputValue,\n    defaultValue,\n    description,\n    disabled = false,\n    disabledKeys,\n    emptyMessage = \"No results found.\",\n    error,\n    errorMessage,\n    inputValue,\n    invalid = false,\n    items,\n    label,\n    loading = false,\n    loadingMessage = \"Loading options...\",\n    minQueryLength = 0,\n    minQueryMessage,\n    name,\n    onInputValueChange,\n    onRetry,\n    onValueChange,\n    placeholder = \"Search options\",\n    readOnly = false,\n    required = false,\n    retryLabel = \"Retry\",\n    searchPlaceholder,\n    selectedItems,\n    selectionMode,\n    value,\n  }: AsyncSelectProps<T, M>,\n  ref: React.ForwardedRef<HTMLDivElement>,\n) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  useImperativeHandle(ref, () => rootRef.current!, []);\n  const retryFocusPending = useRef(false);\n  const isMultiple = selectionMode === \"multiple\";\n  const valueControlled = value !== undefined;\n  const inputControlled = inputValue !== undefined;\n  const initialSingleValue = normalizeSingleValue(value ?? defaultValue);\n  const initialMultipleValue = normalizeMultipleValue(value ?? defaultValue);\n  const resultItems = useMemo(() => Array.from(items ?? []), [items]);\n  const resolvedItems = useMemo(\n    () => uniqueItems(resultItems, selectedItems),\n    [resultItems, selectedItems],\n  );\n  const [open, setOpen] = useState(false);\n  useEffect(() => {\n    if (!open && retryFocusPending.current) {\n      retryFocusPending.current = false;\n      const frame = requestAnimationFrame(() =>\n        rootRef.current\n          ?.querySelector<HTMLButtonElement>('[data-slot=\"async-select-retry\"]')\n          ?.focus(),\n      );\n      return () => cancelAnimationFrame(frame);\n    }\n  }, [open]);\n  const handleKeyDownCapture = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (\n      event.key === \"Tab\" &&\n      !event.shiftKey &&\n      open &&\n      error &&\n      onRetry &&\n      !disabled &&\n      !readOnly &&\n      (event.target as HTMLElement).getAttribute(\"role\") === \"combobox\"\n    ) {\n      // Tab leaves the popup and lands on its retry action in the field's feedback.\n      retryFocusPending.current = true;\n    }\n  };\n  const handleRetry =\n    onRetry && !disabled && !readOnly\n      ? () => {\n          onRetry();\n          requestAnimationFrame(() =>\n            rootRef.current\n              ?.querySelector<HTMLInputElement>('input[role=\"combobox\"]')\n              ?.focus(),\n          );\n        }\n      : undefined;\n  const initialSingleItem = initialSingleValue\n    ? resolvedItems.find((item) => item.value === initialSingleValue)\n    : undefined;\n  const initialInputValue =\n    defaultInputValue ??\n    (!isMultiple && initialSingleValue\n      ? itemTextValue(initialSingleItem ?? { value: initialSingleValue })\n      : \"\");\n  const [singleValue, setSingleValue] = useState<AsyncSelectValue | null>(\n    initialSingleValue,\n  );\n  const [multipleValue, setMultipleValue] =\n    useState<AsyncSelectValue[]>(initialMultipleValue);\n  const [uncontrolledInputValue, setUncontrolledInputValue] =\n    useState(initialInputValue);\n  const committedValue = valueControlled\n    ? normalizeSingleValue(value)\n    : singleValue;\n  const [previousValue, setPreviousValue] = useState(committedValue);\n  const [editing, setEditing] = useState(defaultInputValue !== undefined);\n  if (committedValue !== previousValue) {\n    setPreviousValue(committedValue);\n    setEditing(false);\n  }\n  const committedItem = resolvedItems.find(\n    (item) => item.value === committedValue,\n  );\n  const selectedLabel =\n    committedValue === null\n      ? \"\"\n      : itemTextValue(committedItem ?? { value: committedValue });\n  const resolvedInputValue =\n    inputValue ??\n    (!isMultiple && !editing ? selectedLabel : uncontrolledInputValue);\n  const asyncState = getAsyncState({\n    error,\n    inputValue: resolvedInputValue,\n    itemsCount: resultItems.length,\n    loading,\n    minQueryLength,\n  });\n  const renderedItems = asyncState === \"ready\" ? resultItems : [];\n  const showStatus =\n    asyncState !== \"empty\" ||\n    open ||\n    (!invalid &&\n      ariaInvalid !== true &&\n      ariaInvalid !== \"true\" &&\n      resolvedInputValue.trim().length > 0);\n  const status = showStatus ? (\n    <AsyncStatus\n      error={error}\n      errorMessage={emptyMessage}\n      loadingMessage={loadingMessage}\n      minQueryLength={minQueryLength}\n      minQueryMessage={minQueryMessage}\n      onRetry={handleRetry}\n      retryLabel={retryLabel}\n      state={asyncState}\n    />\n  ) : null;\n  const handleInputChange = (nextValue: string) => {\n    setEditing(true);\n    if (!inputControlled) {\n      setUncontrolledInputValue(nextValue);\n    }\n\n    onInputValueChange?.(nextValue);\n  };\n\n  if (isMultiple) {\n    const resolvedValue = valueControlled\n      ? normalizeMultipleValue(value)\n      : multipleValue;\n    const handleValueChange = (nextValue: AsyncSelectValue[]) => {\n      if (!valueControlled) {\n        setMultipleValue(nextValue);\n      }\n\n      onValueChange?.(nextValue as AsyncSelectChangeValue<M>);\n    };\n\n    return (\n      <div\n        onKeyDownCapture={handleKeyDownCapture}\n        ref={rootRef}\n        data-slot={dataSlot ?? \"async-select\"}\n        data-selection-mode=\"multiple\"\n        className={cn(asyncSelectRootClasses, className)}\n      >\n        <MultiSelect\n          aria-invalid={ariaInvalid}\n          clearLabel={clearLabel}\n          controlSize={controlSize}\n          description={description}\n          disabled={disabled}\n          disabledKeys={disabledKeys}\n          emptyMessage={open ? status : null}\n          errorMessage={errorMessage}\n          inputValue={resolvedInputValue}\n          invalid={invalid}\n          items={renderedItems}\n          label={label}\n          name={name}\n          onInputValueChange={handleInputChange}\n          onValueChange={handleValueChange}\n          placeholder={placeholder}\n          readOnly={readOnly}\n          required={required}\n          searchPlaceholder={searchPlaceholder}\n          selectedItems={resolvedItems}\n          shouldFilter={false}\n          onOpenChange={setOpen}\n          value={resolvedValue}\n        >\n          {(item) => (\n            <MultiSelectItem value={item.value} textValue={itemTextValue(item)}>\n              {itemLabel(item)}\n            </MultiSelectItem>\n          )}\n        </MultiSelect>\n        {!open ? status : null}\n      </div>\n    );\n  }\n\n  const resolvedValue = valueControlled\n    ? normalizeSingleValue(value)\n    : singleValue;\n  const handleValueChange = (nextValue: AsyncSelectValue | null) => {\n    const selected = resolvedItems.find((item) => item.value === nextValue);\n    handleInputChange(\n      nextValue === null ? \"\" : itemTextValue(selected ?? { value: nextValue }),\n    );\n    setEditing(false);\n    if (!valueControlled) {\n      setSingleValue(nextValue);\n    }\n\n    onValueChange?.(nextValue as AsyncSelectChangeValue<M>);\n  };\n\n  return (\n    <div\n      onKeyDownCapture={handleKeyDownCapture}\n      ref={rootRef}\n      data-slot={dataSlot ?? \"async-select\"}\n      data-selection-mode=\"single\"\n      className={cn(asyncSelectRootClasses, className)}\n    >\n      <Combobox\n        aria-invalid={ariaInvalid}\n        allowsEmptyCollection\n        onOpenChange={setOpen}\n        popupContent={open ? status : null}\n        controlSize={controlSize}\n        description={description}\n        disabled={disabled}\n        disabledKeys={disabledKeys}\n        errorMessage={errorMessage}\n        inputValue={resolvedInputValue}\n        invalid={invalid}\n        items={renderedItems}\n        label={label}\n        name={name}\n        onInputValueChange={handleInputChange}\n        onValueChange={handleValueChange}\n        placeholder={placeholder}\n        readOnly={readOnly}\n        required={required}\n        value={resolvedValue}\n      >\n        {(item) => (\n          <ComboboxItem value={item.value} textValue={itemTextValue(item)}>\n            {itemLabel(item)}\n          </ComboboxItem>\n        )}\n      </Combobox>\n      {!open ? status : null}\n    </div>\n  );\n}\n\nexport const AsyncSelect = forwardRef(AsyncSelectRoot) as AsyncSelectComponent;\n\nAsyncSelect.displayName = \"AsyncSelect\";\n\nexport function asyncSelectClassNames({\n  className,\n}: Pick<AsyncSelectProps, \"className\"> = {}) {\n  return cn(asyncSelectRootClasses, className);\n}\n"
    },
    {
      "path": "packages/components/src/components/async-select/index.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/async-select/index.ts",
      "content": "export {\n  AsyncSelect,\n  asyncSelectClassNames,\n  type AsyncSelectChangeValue,\n  type AsyncSelectControlSize,\n  type AsyncSelectItemData,\n  type AsyncSelectProps,\n  type AsyncSelectSelectionMode,\n  type AsyncSelectValue,\n} from \"./async-select\";\n"
    },
    {
      "path": "packages/components/src/utils/cn.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/cn.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
    }
  ]
}
