Scope Selector

Multi-level scope picker for nested environments, teams, and targets. Install Scope Selector from the VLLNT UI registry with the shadcn CLI.

Report a bug

Multi-level scope picker for nested environments, teams, and targets. Part of the form family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

Add Scope Selector to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/scope-selector.json

Source

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

Stories

Explore every variant and state in the interactive Storybook:

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Dependencies

  • @vllnt/ui@^0.3.0