Scope Selector

Multi-level scope picker for nested environments, teams, and targets.

Report a bug

Preview

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

Installation

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

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client"; import { forwardRef, 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"; import { Input } from "../input"; import { Popover, PopoverContent, PopoverTrigger } from "../popover"; import { ScrollArea } from "../scroll-area"; import { Separator } from "../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 = forwardRef<HTMLButtonElement, ScopeSelectorProps>( ( { className, defaultValue, disabled, emptyMessage = "No scopes available.", nodes, onValueChange, placeholder = "Select scope", searchPlaceholder = "Search scopes...", value, }, ref, ) => { 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 };

Dependencies

  • @vllnt/ui@^0.2.1