-

Option Editor

PreviousNext

An editable option list for workflow choices, enum values, form states, and routing rules.

Installation

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

Component

components/ui/option-editor.tsx
"use client"

import * as React from "react"
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export interface OptionEditorItem {
  id: string
  label: string
  value?: string
}

export interface OptionEditorProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "title"> {
  value: OptionEditorItem[]
  onValueChange?: (value: OptionEditorItem[]) => void
  title?: React.ReactNode
  addText?: string
  labelPlaceholder?: string
  valuePlaceholder?: string
  showValue?: boolean
  disabled?: boolean
}

function createOption(): OptionEditorItem {
  return {
    id: crypto.randomUUID(),
    label: "",
    value: "",
  }
}

function moveItem<T>(items: T[], from: number, to: number) {
  const next = [...items]
  const [item] = next.splice(from, 1)
  next.splice(to, 0, item)
  return next
}

export function OptionEditor({
  value,
  onValueChange,
  title = "Options",
  addText = "Add option",
  labelPlaceholder = "Label",
  valuePlaceholder = "Value",
  showValue = true,
  disabled = false,
  className,
  ...props
}: OptionEditorProps) {
  function updateItem(index: number, patch: Partial<OptionEditorItem>) {
    onValueChange?.(
      value.map((item, itemIndex) =>
        itemIndex === index ? { ...item, ...patch } : item
      )
    )
  }

  return (
    <div className={cn("space-y-3", className)} {...props}>
      <div className="flex items-center justify-between gap-3">
        <Label className="text-sm font-medium">{title}</Label>
        <Button
          type="button"
          variant="outline"
          size="sm"
          className="h-8 rounded-md"
          disabled={disabled}
          onClick={() => onValueChange?.([...value, createOption()])}
        >
          <PlusIcon className="size-4" />
          {addText}
        </Button>
      </div>
      <div className="space-y-2">
        {value.map((item, index) => (
          <div
            key={item.id}
            className="border-border bg-card grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-lg border p-2"
          >
            <div className="text-muted-foreground flex flex-col">
              <Button
                type="button"
                variant="ghost"
                size="icon"
                className="size-7 rounded-md"
                disabled={disabled || index === 0}
                onClick={() =>
                  onValueChange?.(moveItem(value, index, index - 1))
                }
                aria-label="Move option up"
              >
                <ArrowUpIcon className="size-3.5" />
              </Button>
              <Button
                type="button"
                variant="ghost"
                size="icon"
                className="size-7 rounded-md"
                disabled={disabled || index === value.length - 1}
                onClick={() =>
                  onValueChange?.(moveItem(value, index, index + 1))
                }
                aria-label="Move option down"
              >
                <ArrowDownIcon className="size-3.5" />
              </Button>
            </div>
            <div className={cn("grid gap-2", showValue && "sm:grid-cols-2")}>
              <Input
                value={item.label}
                onChange={(event) =>
                  updateItem(index, { label: event.target.value })
                }
                placeholder={labelPlaceholder}
                disabled={disabled}
                className="h-9"
              />
              {showValue ? (
                <Input
                  value={item.value ?? ""}
                  onChange={(event) =>
                    updateItem(index, { value: event.target.value })
                  }
                  placeholder={valuePlaceholder}
                  disabled={disabled}
                  className="h-9 font-mono text-sm"
                />
              ) : null}
            </div>
            <Button
              type="button"
              variant="ghost"
              size="icon"
              className="size-8 rounded-md"
              disabled={disabled}
              onClick={() =>
                onValueChange?.(
                  value.filter((_, itemIndex) => itemIndex !== index)
                )
              }
              aria-label="Remove option"
            >
              <Trash2Icon className="size-4" />
            </Button>
          </div>
        ))}
      </div>
    </div>
  )
}

Usage

import { OptionEditor } from "@/components/ui/option-editor"
 
export function Example() {
  return <OptionEditor value={[]} onValueChange={console.log} />
}

Props

PropTypeDefault
valueOptionEditorItem[]required
onValueChange(value: OptionEditorItem[]) => void-
titleReactNode"Options"
showValuebooleantrue
disabledbooleanfalse