-

Variable Selector

PreviousNext

A grouped variable picker for ZGI workflow inputs, node outputs, and template insertion.

Installation

pnpm
pnpm dlx shadcn@latest add https://ui.zgi.ai/r/variable-selector.json
npm
pnpm dlx shadcn@latest add https://ui.zgi.ai/r/variable-selector.json
yarn
yarn dlx shadcn@latest add https://ui.zgi.ai/r/variable-selector.json

Component

components/ui/variable-selector.tsx
"use client"

import * as React from "react"
import { CheckIcon, ChevronsUpDownIcon, SearchIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"

export interface VariableSelectorItem {
  id: string
  label: string
  path?: string
  type?: string
  group?: string
  description?: string
}

export interface VariableSelectorProps {
  variables: VariableSelectorItem[]
  value?: string
  onValueChange?: (id: string, item: VariableSelectorItem) => void
  placeholder?: string
  searchPlaceholder?: string
  emptyText?: string
  disabled?: boolean
  className?: string
  contentClassName?: string
}

export function VariableSelector({
  variables,
  value,
  onValueChange,
  placeholder = "Select variable",
  searchPlaceholder = "Search variables...",
  emptyText = "No variables found.",
  disabled = false,
  className,
  contentClassName,
}: VariableSelectorProps) {
  const [open, setOpen] = React.useState(false)
  const selected = variables.find((item) => item.id === value)
  const groups = React.useMemo(() => {
    const grouped = new Map<string, VariableSelectorItem[]>()

    for (const variable of variables) {
      const group = variable.group ?? "Variables"
      grouped.set(group, [...(grouped.get(group) ?? []), variable])
    }

    return Array.from(grouped.entries())
  }, [variables])

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button
          type="button"
          variant="outline"
          role="combobox"
          aria-expanded={open}
          disabled={disabled}
          className={cn(
            "h-10 w-full justify-between rounded-md px-3 font-normal",
            !selected && "text-muted-foreground",
            className
          )}
        >
          <span className="min-w-0 truncate text-left">
            {selected ? (selected.path ?? selected.label) : placeholder}
          </span>
          <ChevronsUpDownIcon className="text-muted-foreground size-4 shrink-0" />
        </Button>
      </PopoverTrigger>
      <PopoverContent
        align="start"
        className={cn("w-[min(420px,calc(100vw-2rem))] p-0", contentClassName)}
      >
        <Command>
          <div className="border-border flex items-center border-b px-3">
            <SearchIcon className="text-muted-foreground mr-2 size-4 shrink-0" />
            <CommandInput
              placeholder={searchPlaceholder}
              className="h-10 border-0 px-0 focus:ring-0"
            />
          </div>
          <CommandList className="max-h-80">
            <CommandEmpty>{emptyText}</CommandEmpty>
            {groups.map(([group, items]) => (
              <CommandGroup key={group} heading={group}>
                {items.map((item) => {
                  const isSelected = item.id === value

                  return (
                    <CommandItem
                      key={item.id}
                      value={`${item.label} ${item.path ?? ""} ${item.type ?? ""} ${
                        item.description ?? ""
                      }`}
                      onSelect={() => {
                        onValueChange?.(item.id, item)
                        setOpen(false)
                      }}
                      className="items-start gap-3 py-3"
                    >
                      <CheckIcon
                        className={cn(
                          "mt-0.5 size-4 shrink-0",
                          isSelected ? "opacity-100" : "opacity-0"
                        )}
                      />
                      <span className="min-w-0 flex-1">
                        <span className="flex min-w-0 items-center gap-2">
                          <span className="truncate text-sm font-medium">
                            {item.label}
                          </span>
                          {item.type ? (
                            <Badge
                              variant="secondary"
                              className="rounded-sm px-1.5 py-0 text-[11px]"
                            >
                              {item.type}
                            </Badge>
                          ) : null}
                        </span>
                        {item.path ? (
                          <code className="text-muted-foreground mt-1 block truncate font-mono text-xs">
                            {item.path}
                          </code>
                        ) : null}
                        {item.description ? (
                          <span className="text-muted-foreground mt-1 block text-xs leading-5">
                            {item.description}
                          </span>
                        ) : null}
                      </span>
                    </CommandItem>
                  )
                })}
              </CommandGroup>
            ))}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

Usage

import { VariableSelector } from "@/components/ui/variable-selector"
 
export function Example() {
  return (
    <VariableSelector
      variables={[{ id: "start.query", label: "User query", path: "{{start.query}}" }]}
      onValueChange={console.log}
    />
  )
}

Props

PropTypeDefault
variablesVariableSelectorItem[]required
valuestring-
onValueChange(id, item) => void-
placeholderstring"Select variable"
disabledbooleanfalse