{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scope-selector",
  "type": "registry:component",
  "title": "Scope Selector",
  "description": "Multi-level scope picker for nested environments, teams, and targets.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/scope-selector/scope-selector.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useMemo, useState } from \"react\";\n\nimport { Check, ChevronRight, Search } from \"lucide-react\";\n\nimport { cn } from \"@vllnt/ui\";\nimport { Badge } from \"@vllnt/ui\";\nimport { Button } from \"@vllnt/ui\";\nimport { Input } from \"@vllnt/ui\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@vllnt/ui\";\nimport { ScrollArea } from \"@vllnt/ui\";\nimport { Separator } from \"@vllnt/ui\";\n\nexport type ScopeSelectorNode = {\n  badge?: string;\n  children?: ScopeSelectorNode[];\n  description?: string;\n  disabled?: boolean;\n  id: string;\n  label: string;\n  selectable?: boolean;\n};\n\nexport type ScopeSelectorSelection = {\n  node: ScopeSelectorNode;\n  path: ScopeSelectorNode[];\n};\n\nexport type ScopeSelectorProps = {\n  className?: string;\n  defaultValue?: string;\n  disabled?: boolean;\n  emptyMessage?: string;\n  nodes: ScopeSelectorNode[];\n  onValueChange?: (selection: ScopeSelectorSelection) => void;\n  placeholder?: string;\n  searchPlaceholder?: string;\n  value?: string;\n};\n\ntype FlattenedScopeNode = {\n  node: ScopeSelectorNode;\n  path: ScopeSelectorNode[];\n};\n\ntype ScopeOptionButtonProps = {\n  node: ScopeSelectorNode;\n  onBrowse: (node: ScopeSelectorNode) => void;\n  onSelect: (selection: ScopeSelectorSelection) => void;\n  path: ScopeSelectorNode[];\n  selectedValue?: string;\n  showPathLabel?: boolean;\n};\n\ntype ScopePanelProps = {\n  currentPath: ScopeSelectorNode[];\n  emptyMessage: string;\n  nodes: ScopeSelectorNode[];\n  onBrowse: (node: ScopeSelectorNode) => void;\n  onSelect: (selection: ScopeSelectorSelection) => void;\n  query: string;\n  searchResults: FlattenedScopeNode[];\n  selectedValue?: string;\n};\n\ntype ScopeSelectorState = {\n  currentLevelNodes: ScopeSelectorNode[];\n  currentPath: ScopeSelectorNode[];\n  currentPathLabel: string;\n  handleBrowse: (node: ScopeSelectorNode) => void;\n  handleOpenChange: (nextOpen: boolean) => void;\n  handleSelect: (selection: ScopeSelectorSelection) => void;\n  open: boolean;\n  query: string;\n  searchResults: FlattenedScopeNode[];\n  selectedPathLabel?: string;\n  selectedSelection?: ScopeSelectorSelection;\n  selectedValue?: string;\n  setCurrentPath: (path: ScopeSelectorNode[]) => void;\n  setQuery: (value: string) => void;\n};\n\ntype ScopeSelectorPopoverBodyProps = {\n  emptyMessage: string;\n  searchPlaceholder: string;\n  state: ScopeSelectorState;\n};\n\nfunction flattenNodes(\n  nodes: ScopeSelectorNode[],\n  parentPath: ScopeSelectorNode[] = [],\n): FlattenedScopeNode[] {\n  return nodes.flatMap((node) => {\n    const path = [...parentPath, node];\n    const current = [{ node, path }];\n    const descendants = node.children ? flattenNodes(node.children, path) : [];\n    return [...current, ...descendants];\n  });\n}\n\nfunction findSelection(\n  nodes: ScopeSelectorNode[],\n  value?: string,\n): ScopeSelectorSelection | undefined {\n  if (!value) return undefined;\n\n  const flattened = flattenNodes(nodes);\n  const match = flattened.find((entry) => entry.node.id === value);\n  return match ? { node: match.node, path: match.path } : undefined;\n}\n\nfunction getVisibleNodes(\n  tree: ScopeSelectorNode[],\n  currentPath: ScopeSelectorNode[],\n): ScopeSelectorNode[] {\n  const currentNode = currentPath.at(-1);\n  return currentNode?.children ?? tree;\n}\n\nfunction isSelectableNode(node: ScopeSelectorNode): boolean {\n  if (node.disabled) return false;\n  if (node.selectable !== undefined) return node.selectable;\n  return !node.children || node.children.length === 0;\n}\n\nfunction getPathLabel(path: ScopeSelectorNode[]): string {\n  return path.map((node) => node.label).join(\" / \");\n}\n\nfunction filterScopeResults(\n  flattenedNodes: FlattenedScopeNode[],\n  query: string,\n): FlattenedScopeNode[] {\n  const normalizedQuery = query.trim().toLowerCase();\n  if (!normalizedQuery) return [];\n\n  return flattenedNodes.filter(({ node, path }) => {\n    const pathLabel = getPathLabel(path).toLowerCase();\n    const description = node.description?.toLowerCase() ?? \"\";\n    return (\n      node.label.toLowerCase().includes(normalizedQuery) ||\n      description.includes(normalizedQuery) ||\n      pathLabel.includes(normalizedQuery)\n    );\n  });\n}\n\nfunction ScopeOptionButton({\n  node,\n  onBrowse,\n  onSelect,\n  path,\n  selectedValue,\n  showPathLabel,\n}: ScopeOptionButtonProps) {\n  const selectable = isSelectableNode(node);\n\n  return (\n    <button\n      className={cn(\n        \"flex w-full items-start justify-between rounded-md border p-3 text-left transition-colors hover:bg-accent hover:text-accent-foreground\",\n        selectedValue === node.id && \"border-primary bg-accent\",\n        node.disabled && \"cursor-not-allowed opacity-50\",\n      )}\n      disabled={node.disabled}\n      key={node.id}\n      onClick={() => {\n        if (selectable) {\n          onSelect({ node, path });\n          return;\n        }\n        onBrowse(node);\n      }}\n      type=\"button\"\n    >\n      <div className=\"min-w-0 space-y-1\">\n        <div className=\"flex flex-wrap items-center gap-2\">\n          <span className=\"font-medium\">{node.label}</span>\n          {node.badge ? <Badge variant=\"outline\">{node.badge}</Badge> : null}\n        </div>\n        {showPathLabel ? (\n          <div className=\"text-xs text-muted-foreground\">\n            {getPathLabel(path)}\n          </div>\n        ) : null}\n        {node.description ? (\n          <p className=\"text-sm text-muted-foreground\">{node.description}</p>\n        ) : null}\n      </div>\n      {selectedValue === node.id ? (\n        <Check className=\"mt-0.5 size-4 shrink-0 text-primary\" />\n      ) : node.children && node.children.length > 0 ? (\n        <ChevronRight className=\"mt-0.5 size-4 shrink-0 text-muted-foreground\" />\n      ) : null}\n    </button>\n  );\n}\n\nfunction ScopeSearchResults({\n  onBrowse,\n  onSelect,\n  results,\n  selectedValue,\n}: {\n  onBrowse: (node: ScopeSelectorNode) => void;\n  onSelect: (selection: ScopeSelectorSelection) => void;\n  results: FlattenedScopeNode[];\n  selectedValue?: string;\n}) {\n  return (\n    <div className=\"space-y-2 p-1\">\n      {results.map(({ node, path }) => (\n        <ScopeOptionButton\n          key={getPathLabel(path)}\n          node={node}\n          onBrowse={onBrowse}\n          onSelect={onSelect}\n          path={path}\n          selectedValue={selectedValue}\n          showPathLabel\n        />\n      ))}\n    </div>\n  );\n}\n\nfunction ScopeCurrentLevel({\n  currentPath,\n  nodes,\n  onBrowse,\n  onSelect,\n  selectedValue,\n}: {\n  currentPath: ScopeSelectorNode[];\n  nodes: ScopeSelectorNode[];\n  onBrowse: (node: ScopeSelectorNode) => void;\n  onSelect: (selection: ScopeSelectorSelection) => void;\n  selectedValue?: string;\n}) {\n  return (\n    <div className=\"space-y-2 p-1\">\n      {nodes.map((node) => (\n        <ScopeOptionButton\n          key={node.id}\n          node={node}\n          onBrowse={onBrowse}\n          onSelect={onSelect}\n          path={[...currentPath, node]}\n          selectedValue={selectedValue}\n        />\n      ))}\n    </div>\n  );\n}\n\nfunction ScopePanel({\n  currentPath,\n  emptyMessage,\n  nodes,\n  onBrowse,\n  onSelect,\n  query,\n  searchResults,\n  selectedValue,\n}: ScopePanelProps) {\n  const normalizedQuery = query.trim().toLowerCase();\n\n  if (normalizedQuery) {\n    if (searchResults.length === 0) {\n      return (\n        <div className=\"px-3 py-8 text-center text-sm text-muted-foreground\">\n          No scopes match your search.\n        </div>\n      );\n    }\n\n    return (\n      <ScopeSearchResults\n        onBrowse={onBrowse}\n        onSelect={onSelect}\n        results={searchResults}\n        selectedValue={selectedValue}\n      />\n    );\n  }\n\n  if (nodes.length === 0) {\n    return (\n      <div className=\"px-3 py-8 text-center text-sm text-muted-foreground\">\n        {emptyMessage}\n      </div>\n    );\n  }\n\n  return (\n    <ScopeCurrentLevel\n      currentPath={currentPath}\n      nodes={nodes}\n      onBrowse={onBrowse}\n      onSelect={onSelect}\n      selectedValue={selectedValue}\n    />\n  );\n}\n\nfunction ScopeSelectorBreadcrumb({\n  currentPath,\n  onBack,\n}: {\n  currentPath: ScopeSelectorNode[];\n  onBack: () => void;\n}) {\n  if (currentPath.length === 0) return null;\n\n  return (\n    <div className=\"mt-3 flex items-center gap-2 text-xs text-muted-foreground\">\n      <Button className=\"h-7 px-2\" onClick={onBack} size=\"sm\" variant=\"ghost\">\n        <ChevronRight className=\"size-3.5 rotate-180\" />\n        Back\n      </Button>\n      <span className=\"truncate\">{getPathLabel(currentPath)}</span>\n    </div>\n  );\n}\n\nfunction ScopeSelectorPopoverBody({\n  emptyMessage,\n  searchPlaceholder,\n  state,\n}: ScopeSelectorPopoverBodyProps) {\n  return (\n    <PopoverContent align=\"start\" className=\"w-[380px] p-0\" sideOffset={8}>\n      <div className=\"border-b p-3\">\n        <div className=\"relative\">\n          <Search className=\"pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground\" />\n          <Input\n            className=\"pl-9\"\n            onChange={(event) => {\n              state.setQuery(event.target.value);\n            }}\n            placeholder={searchPlaceholder}\n            value={state.query}\n          />\n        </div>\n        {state.query.trim() ? null : (\n          <ScopeSelectorBreadcrumb\n            currentPath={state.currentPath}\n            onBack={() => {\n              state.setCurrentPath(state.currentPath.slice(0, -1));\n            }}\n          />\n        )}\n      </div>\n      <ScrollArea className=\"max-h-[320px]\">\n        <ScopePanel\n          currentPath={state.currentPath}\n          emptyMessage={emptyMessage}\n          nodes={state.currentLevelNodes}\n          onBrowse={state.handleBrowse}\n          onSelect={state.handleSelect}\n          query={state.query}\n          searchResults={state.searchResults}\n          selectedValue={state.selectedValue}\n        />\n      </ScrollArea>\n      {state.selectedSelection ? (\n        <>\n          <Separator />\n          <div className=\"px-3 py-2 text-xs text-muted-foreground\">\n            Selected: {state.selectedPathLabel}\n          </div>\n        </>\n      ) : null}\n    </PopoverContent>\n  );\n}\n\nfunction useScopeSelectorState({\n  defaultValue,\n  nodes,\n  onValueChange,\n  value,\n}: Pick<\n  ScopeSelectorProps,\n  \"defaultValue\" | \"nodes\" | \"onValueChange\" | \"value\"\n>): ScopeSelectorState {\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);\n  const [currentPath, setCurrentPath] = useState<ScopeSelectorNode[]>([]);\n  const selectedValue = value ?? uncontrolledValue;\n  const selectedSelection = useMemo(\n    () => findSelection(nodes, selectedValue),\n    [nodes, selectedValue],\n  );\n  const flattenedNodes = useMemo(() => flattenNodes(nodes), [nodes]);\n  const searchResults = useMemo(\n    () => filterScopeResults(flattenedNodes, query),\n    [flattenedNodes, query],\n  );\n  const currentLevelNodes = getVisibleNodes(nodes, currentPath);\n\n  function handleSelect(selection: ScopeSelectorSelection) {\n    if (value === undefined) {\n      setUncontrolledValue(selection.node.id);\n    }\n    setCurrentPath(selection.path.slice(0, -1));\n    setQuery(\"\");\n    setOpen(false);\n    onValueChange?.(selection);\n  }\n\n  function handleBrowse(node: ScopeSelectorNode) {\n    const nextPath = findSelection(nodes, node.id)?.path ?? [];\n    setCurrentPath(nextPath);\n  }\n\n  function handleOpenChange(nextOpen: boolean) {\n    setOpen(nextOpen);\n    if (nextOpen) {\n      setCurrentPath(selectedSelection?.path.slice(0, -1) ?? []);\n      return;\n    }\n    setQuery(\"\");\n  }\n\n  return {\n    currentLevelNodes,\n    currentPath,\n    currentPathLabel: getPathLabel(currentPath),\n    handleBrowse,\n    handleOpenChange,\n    handleSelect,\n    open,\n    query,\n    searchResults,\n    selectedPathLabel: selectedSelection\n      ? getPathLabel(selectedSelection.path)\n      : undefined,\n    selectedSelection,\n    selectedValue,\n    setCurrentPath,\n    setQuery,\n  };\n}\n\nconst ScopeSelector = forwardRef<HTMLButtonElement, ScopeSelectorProps>(\n  (\n    {\n      className,\n      defaultValue,\n      disabled,\n      emptyMessage = \"No scopes available.\",\n      nodes,\n      onValueChange,\n      placeholder = \"Select scope\",\n      searchPlaceholder = \"Search scopes...\",\n      value,\n    },\n    ref,\n  ) => {\n    const state = useScopeSelectorState({\n      defaultValue,\n      nodes,\n      onValueChange,\n      value,\n    });\n\n    return (\n      <Popover onOpenChange={state.handleOpenChange} open={state.open}>\n        <PopoverTrigger asChild>\n          <Button\n            className={cn(\"w-full justify-between\", className)}\n            disabled={disabled}\n            ref={ref}\n            variant=\"outline\"\n          >\n            <span className=\"truncate text-left\">\n              {state.selectedPathLabel ?? placeholder}\n            </span>\n            <ChevronRight className=\"size-4 shrink-0 rotate-90 text-muted-foreground\" />\n          </Button>\n        </PopoverTrigger>\n        <ScopeSelectorPopoverBody\n          emptyMessage={emptyMessage}\n          searchPlaceholder={searchPlaceholder}\n          state={state}\n        />\n      </Popover>\n    );\n  },\n);\n\nScopeSelector.displayName = \"ScopeSelector\";\n\nexport { ScopeSelector };\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
