{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink/multi-select",
  "title": "Multi Select",
  "description": "Multi-select list with checkboxes and select-all",
  "dependencies": [
    "ink"
  ],
  "registryDependencies": [
    "https://termcn.dev/r/ink/interaction.json",
    "https://termcn.dev/r/ink/terminal-symbols.json",
    "https://termcn.dev/r/ink/use-theme.json",
    "https://termcn.dev/r/ink/use-unicode.json"
  ],
  "files": [
    {
      "path": "registry/ui/multi-select.tsx",
      "content": "import { Box, Text } from \"ink\";\nimport React, { useState } from \"react\";\n\nimport { useInteraction } from \"@/hooks/use-interaction\";\nimport { useTheme } from \"@/hooks/use-theme\";\nimport { useUnicode } from \"@/hooks/use-unicode\";\nimport { resolveTerminalSymbol } from \"@/lib/terminal-symbols\";\n\nexport interface MultiSelectOption<T = string> {\n  value: T;\n  label: string;\n  hint?: string;\n  disabled?: boolean;\n}\n\nexport interface MultiSelectProps<T = string> {\n  options: MultiSelectOption<T>[];\n  value?: T[];\n  defaultValue?: T[];\n  onValueChange?: (values: T[]) => void;\n  onChange?: (values: T[]) => void;\n  onSubmit?: (values: T[]) => void;\n  cursor?: string;\n  checkmark?: string;\n  height?: number;\n  label?: string;\n  id?: string;\n  autoFocus?: boolean;\n  isActive?: boolean;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n}\n\nexport const MultiSelect = <T = string,>({\n  options,\n  value: controlledValue,\n  defaultValue = [],\n  onValueChange,\n  onChange,\n  onSubmit,\n  cursor,\n  checkmark,\n  height,\n  label,\n  id,\n  autoFocus = false,\n  isActive = true,\n  disabled = false,\n  \"aria-label\": ariaLabel,\n}: MultiSelectProps<T>) => {\n  const theme = useTheme();\n  const unicode = useUnicode();\n  const resolvedCursor = cursor ?? resolveTerminalSymbol(unicode, \"›\", \">\");\n  const resolvedCheckmark =\n    checkmark ?? resolveTerminalSymbol(unicode, \"◉\", \"[x]\");\n  const [activeIndex, setActiveIndex] = useState(0);\n  const [internalSelected, setInternalSelected] = useState<T[]>(defaultValue);\n\n  const selected = controlledValue ?? internalSelected;\n\n  const scrollOffset = (() => {\n    if (!height) {\n      return 0;\n    }\n    const half = Math.floor(height / 2);\n    const maxOffset = options.length - height;\n    const offset = activeIndex - half;\n    if (offset < 0) {\n      return 0;\n    }\n    if (offset > maxOffset) {\n      return Math.max(0, maxOffset);\n    }\n    return offset;\n  })();\n\n  const visibleOptions = height\n    ? options.slice(scrollOffset, scrollOffset + height)\n    : options;\n\n  const { isFocused } = useInteraction(\n    (input, key) => {\n      if (key.upArrow) {\n        setActiveIndex((i) => {\n          let next = i - 1;\n          while (next >= 0 && options[next]?.disabled) {\n            next -= 1;\n          }\n          return next < 0 ? i : next;\n        });\n      } else if (key.downArrow) {\n        setActiveIndex((i) => {\n          let next = i + 1;\n          while (next < options.length && options[next]?.disabled) {\n            next += 1;\n          }\n          return next >= options.length ? i : next;\n        });\n      } else if (input === \" \") {\n        const opt = options[activeIndex];\n        if (!opt || opt.disabled) {\n          return;\n        }\n        const isSelected = selected.includes(opt.value);\n        const next = isSelected\n          ? selected.filter((v) => v !== opt.value)\n          : [...selected, opt.value];\n        if (controlledValue === undefined) {\n          setInternalSelected(next);\n        }\n        (onValueChange ?? onChange)?.(next);\n      } else if (key.return) {\n        onSubmit?.(selected);\n      } else if (key.home) {\n        const firstEnabled = options.findIndex((option) => !option.disabled);\n        if (firstEnabled !== -1) {\n          setActiveIndex(firstEnabled);\n        }\n      } else if (key.end) {\n        const lastEnabled = options.findLastIndex((option) => !option.disabled);\n        if (lastEnabled !== -1) {\n          setActiveIndex(lastEnabled);\n        }\n      }\n    },\n    { autoFocus, disabled, id, isActive }\n  );\n\n  return (\n    <Box\n      aria-role=\"listbox\"\n      aria-state={{ disabled, multiselectable: true }}\n      flexDirection=\"column\"\n    >\n      <Text\n        aria-label={ariaLabel ?? label ?? \"Multi select\"}\n        bold={Boolean(label)}\n      >\n        {label ?? \"\"}\n      </Text>\n      {visibleOptions.map((opt, visibleIdx) => {\n        const idx = scrollOffset + visibleIdx;\n        const isActive = idx === activeIndex;\n        const isSelected = selected.includes(opt.value);\n        const icon = isSelected\n          ? resolvedCheckmark\n          : resolveTerminalSymbol(unicode, \"○\", \"[ ]\");\n\n        let iconColor: string;\n        if (opt.disabled) {\n          iconColor = theme.colors.mutedForeground;\n        } else if (isSelected) {\n          iconColor = theme.colors.primary;\n        } else {\n          iconColor = theme.colors.foreground;\n        }\n\n        let labelColor: string;\n        if (opt.disabled) {\n          labelColor = theme.colors.mutedForeground;\n        } else if (isActive) {\n          labelColor = theme.colors.primary;\n        } else {\n          labelColor = theme.colors.foreground;\n        }\n\n        return (\n          <Box\n            key={idx}\n            aria-label={opt.hint ? `${opt.label}: ${opt.hint}` : opt.label}\n            aria-role=\"option\"\n            aria-state={{ disabled: opt.disabled, selected: isSelected }}\n            gap={1}\n          >\n            <Text\n              aria-hidden\n              color={isActive ? theme.colors.primary : undefined}\n            >\n              {isActive ? resolvedCursor : \" \"}\n            </Text>\n            <Text aria-hidden color={iconColor} dimColor={opt.disabled}>\n              {icon}\n            </Text>\n            <Text\n              aria-hidden\n              color={labelColor}\n              bold={isActive}\n              dimColor={opt.disabled}\n            >\n              {opt.label}\n            </Text>\n            {opt.hint && (\n              <Text aria-hidden color={theme.colors.mutedForeground} dimColor>\n                {opt.hint}\n              </Text>\n            )}\n          </Box>\n        );\n      })}\n    </Box>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "selection"
  ],
  "type": "registry:ui"
}