{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "elements-option-list",
  "type": "registry:component",
  "title": "Option list",
  "description": "The agent asks a question with a few answers; the pick returns to it and stays as a receipt.",
  "registryDependencies": [
    "https://r.assistant-ui.com/elements-surfaces.json",
    "https://r.assistant-ui.com/elements-range.json"
  ],
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "type": "registry:component",
      "path": "components/assistant-ui/elements/option-list.tsx",
      "sourcePath": "packages/ui/src/components/react/assistant-ui/elements/option-list.tsx",
      "content": "\"use client\";\n\nimport { useState, type ComponentProps } from \"react\";\nimport { CheckIcon, Loader2Icon } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { inkButton, mono, paper } from \"./surfaces\";\nimport { clamp } from \"../utils/range\";\n\nexport interface OptionListOption {\n  id: string;\n  label: string;\n  description?: string | undefined;\n  disabled?: boolean | undefined;\n}\n\nexport interface OptionListProps extends Omit<\n  ComponentProps<\"div\">,\n  \"children\" | \"defaultValue\" | \"onChange\"\n> {\n  options: readonly OptionListOption[];\n  /** One option answers the question, or several do. */\n  selectionMode?: \"single\" | \"multiple\" | undefined;\n  /**\n   * Options the list starts selected: checked in a multiple selection, and\n   * marked as the current answer in a single one.\n   */\n  defaultValue?: readonly string[] | undefined;\n  /** The fewest options a multiple selection confirms with. */\n  minSelections?: number | undefined;\n  /** The most options a multiple selection may hold. */\n  maxSelections?: number | undefined;\n  /**\n   * Commits the answer: a single selection commits on pick, a multiple one on\n   * the confirm button. Without it the list only displays its options. A\n   * rejected promise reopens the list, because the answer never landed.\n   */\n  onConfirm?: ((ids: string[]) => void | Promise<void>) | undefined;\n  confirmLabel?: string | undefined;\n  /**\n   * The committed answer. Once set, the list is a receipt of just these\n   * options and takes no further input.\n   */\n  choice?: readonly string[] | undefined;\n}\n\nconst row =\n  \"flex w-full items-start gap-2.5 rounded-xl px-2 py-2 text-start transition-colors outline-none focus-visible:ring-1 focus-visible:ring-foreground/20\";\n\nfunction OptionText({ option }: { option: OptionListOption }) {\n  return (\n    <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n      <span className=\"text-[13.5px] leading-5 break-words\">\n        {option.label}\n      </span>\n      {option.description ? (\n        <span className=\"text-foreground/45 text-xs leading-4 break-words\">\n          {option.description}\n        </span>\n      ) : null}\n    </span>\n  );\n}\n\nexport function OptionList({\n  options,\n  selectionMode = \"single\",\n  defaultValue,\n  minSelections = 1,\n  maxSelections,\n  onConfirm,\n  confirmLabel = \"Confirm\",\n  choice,\n  className,\n  ...props\n}: OptionListProps) {\n  const multiple = selectionMode === \"multiple\";\n  const [selected, setSelected] = useState<readonly string[]>(() =>\n    (defaultValue ?? []).filter((id) => options.some((o) => o.id === id)),\n  );\n  const [pending, setPending] = useState<readonly string[] | null>(null);\n  const [confirmed, setConfirmed] = useState<readonly string[] | undefined>();\n  const [error, setError] = useState<string | null>(null);\n\n  const root = cn(\n    paper,\n    \"flex w-full max-w-sm flex-col gap-1 rounded-2xl p-2\",\n    className,\n  );\n\n  const confirmedChoice = choice ?? confirmed;\n\n  if (confirmedChoice !== undefined) {\n    const chosen = options.filter((option) =>\n      confirmedChoice.includes(option.id),\n    );\n    return (\n      <div\n        data-slot=\"option-list\"\n        data-state=\"receipt\"\n        className={root}\n        {...props}\n      >\n        {chosen.length === 0 ? (\n          <span className={cn(mono, \"text-foreground/35 px-2 py-2\")}>\n            Nothing selected\n          </span>\n        ) : (\n          chosen.map((option) => (\n            <div key={option.id} className={cn(row, \"text-foreground/70\")}>\n              <span className=\"flex h-5 w-3.5 shrink-0 items-center justify-center\">\n                <CheckIcon\n                  aria-hidden\n                  className=\"text-foreground/45 size-3.5\"\n                />\n                <span className=\"sr-only\">Selected:</span>\n              </span>\n              <OptionText option={option} />\n            </div>\n          ))\n        )}\n      </div>\n    );\n  }\n\n  if (!onConfirm) {\n    return (\n      <div data-slot=\"option-list\" className={root} {...props}>\n        {options.map((option) => (\n          <div\n            key={option.id}\n            className={cn(row, option.disabled && \"text-foreground/35\")}\n          >\n            <OptionText option={option} />\n          </div>\n        ))}\n      </div>\n    );\n  }\n\n  const locked = pending !== null;\n  const max = Math.floor(\n    clamp(maxSelections ?? options.length, 0, options.length),\n  );\n  const min = Math.floor(clamp(minSelections, 0, max));\n  const optionIds = new Set(options.map((option) => option.id));\n  const selectedIds = selected.filter((id) => optionIds.has(id));\n\n  const commit = (ids: string[]) => {\n    if (locked) return;\n    setPending(ids);\n    setError(null);\n    void (async () => {\n      try {\n        await onConfirm(ids);\n        setConfirmed(ids);\n      } catch (commitError) {\n        setPending(null);\n        setError(\n          commitError instanceof Error\n            ? commitError.message\n            : String(commitError),\n        );\n      }\n    })();\n  };\n\n  const toggle = (id: string) => {\n    if (locked) return;\n    setSelected((current) => {\n      const available = current.filter((value) => optionIds.has(value));\n      return available.includes(id)\n        ? available.filter((value) => value !== id)\n        : available.length < max\n          ? [...available, id]\n          : available;\n    });\n  };\n\n  const count = selectedIds.length;\n  const canConfirm = !locked && count >= min && count <= max;\n\n  return (\n    <div\n      role=\"group\"\n      data-slot=\"option-list\"\n      data-state={locked ? \"pending\" : \"open\"}\n      aria-busy={locked || undefined}\n      className={root}\n      {...props}\n    >\n      {options.map((option) => {\n        const isSelected = multiple\n          ? selectedIds.includes(option.id)\n          : (pending ?? selected).includes(option.id);\n        const unavailable =\n          option.disabled === true || (multiple && !isSelected && count >= max);\n        return (\n          <button\n            key={option.id}\n            type=\"button\"\n            {...(multiple\n              ? { role: \"checkbox\", \"aria-checked\": isSelected }\n              : {})}\n            aria-disabled={unavailable || locked || undefined}\n            disabled={option.disabled}\n            onClick={() => {\n              if (unavailable) return;\n              if (multiple) toggle(option.id);\n              else commit([option.id]);\n            }}\n            className={cn(\n              row,\n              isSelected\n                ? \"bg-foreground/[0.06]\"\n                : !unavailable && !locked && \"hover:bg-foreground/[0.035]\",\n              unavailable ? \"text-foreground/35\" : \"text-foreground/90\",\n              (unavailable || locked) && \"cursor-default\",\n            )}\n          >\n            {multiple ? (\n              <span\n                aria-hidden\n                className=\"flex h-5 w-3.5 shrink-0 items-center justify-center\"\n              >\n                <span\n                  className={cn(\n                    \"flex size-3.5 items-center justify-center rounded-[5px] border transition-colors\",\n                    isSelected\n                      ? \"border-foreground bg-foreground\"\n                      : \"border-foreground/15\",\n                  )}\n                >\n                  {isSelected ? (\n                    <CheckIcon className=\"fade-in zoom-in-90 animate-in text-background size-2.5 duration-200 motion-reduce:animate-none\" />\n                  ) : null}\n                </span>\n              </span>\n            ) : pending?.includes(option.id) ? (\n              <span\n                aria-hidden\n                className=\"flex h-5 w-3.5 shrink-0 items-center justify-center\"\n              >\n                <Loader2Icon className=\"text-foreground/45 size-3.5 animate-spin motion-reduce:animate-none\" />\n              </span>\n            ) : null}\n            <OptionText option={option} />\n          </button>\n        );\n      })}\n      {error ? (\n        <p\n          role=\"alert\"\n          className=\"px-2 pb-1 text-xs break-words text-red-600 dark:text-red-400\"\n        >\n          {error}\n        </p>\n      ) : null}\n      {multiple ? (\n        <div className=\"flex items-center justify-between gap-3 px-2 pt-1.5 pb-1\">\n          <span className={cn(mono, \"text-foreground/35 tabular-nums\")}>\n            {count} of {max}\n          </span>\n          <button\n            type=\"button\"\n            aria-disabled={!canConfirm || undefined}\n            onClick={() => {\n              if (!canConfirm) return;\n              commit(\n                options\n                  .filter((option) => selectedIds.includes(option.id))\n                  .map((option) => option.id),\n              );\n            }}\n            className={cn(\n              inkButton,\n              \"flex h-8 items-center gap-1.5 rounded-full px-3.5 text-xs font-medium\",\n              !canConfirm && \"cursor-default opacity-40 hover:opacity-40\",\n            )}\n          >\n            {locked ? (\n              <Loader2Icon\n                aria-hidden\n                className=\"size-3.5 animate-spin motion-reduce:animate-none\"\n              />\n            ) : null}\n            {confirmLabel}\n          </button>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"
    }
  ]
}