{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "routing-rules",
  "type": "registry:component",
  "title": "Routing Rules",
  "description": "Routing for a booking form: if company size is over 50 and the region is Europe, send it to Sales EU. Drag routes into order, see what share of last month each one caught, test a response to watch it land, and export the lot as one CASE WHEN.",
  "categories": [
    "component",
    "Filters"
  ],
  "dependencies": [
    "@qnot/core",
    "@qnot/react",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json",
    "https://coss.com/ui/r/input.json",
    "https://coss.com/ui/r/menu.json",
    "https://coss.com/ui/r/select.json"
  ],
  "files": [
    {
      "path": "registry/devl/routing-rules.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  type AnyFieldDef,\n  compileCases,\n  type EditorSpec,\n  encodeCases,\n  type Fields,\n  fieldLabel,\n  formatInput,\n  formatList,\n  type Issue,\n  makeRule,\n  parseInput,\n  parseList,\n  type Query,\n  type QueryOf,\n  validateQuery,\n} from \"@qnot/core\";\nimport { sqlFormat } from \"@qnot/core/formats/sql\";\nimport { QueryProvider, useGroup, useQueryStore, useRule } from \"@qnot/react\";\nimport {\n  ArrowDownIcon,\n  ArrowRightIcon,\n  ArrowUpIcon,\n  CodeIcon,\n  CopyPlusIcon,\n  EllipsisIcon,\n  GripVerticalIcon,\n  PlusIcon,\n  Trash2Icon,\n  XIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, Reorder, useDragControls, useReducedMotion } from \"motion/react\";\nimport { memo, type ReactNode, useMemo, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from \"@/components/ui/menu\";\nimport { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface RouteDestination {\n  id: string;\n  name: string;\n  detail: string;\n}\n\nexport interface Route {\n  id: string;\n  when: Query;\n  to: string;\n}\n\nexport interface RoutingRulesProps {\n  title?: string;\n  description?: string;\n  fields: Fields;\n  destinations: RouteDestination[];\n  routes: Route[];\n  fallback: string;\n  history?: readonly Record<string, unknown>[];\n  sampleResponse: Record<string, unknown>;\n  onChange?: (routes: Route[], fallback: string) => void;\n  className?: string;\n}\n\nconst easeOut = [0.23, 1, 0.32, 1] as const;\nconst postgres = sqlFormat({ dialect: \"postgres\" });\nlet counter = 0;\n\nexport function RoutingRules({\n  title = \"Request routing\",\n  description = \"Checked top to bottom. The first route that matches wins.\",\n  fields,\n  destinations,\n  routes: initialRoutes,\n  fallback: initialFallback,\n  history = [],\n  sampleResponse,\n  onChange,\n  className,\n}: RoutingRulesProps) {\n  const reduce = useReducedMotion();\n  const [routes, setRoutes] = useState(initialRoutes);\n  const [fallback, setFallback] = useState(initialFallback);\n  const [response, setResponse] = useState(sampleResponse);\n  const [showSql, setShowSql] = useState(false);\n\n  const commit = (next: Route[], nextFallback = fallback) => {\n    setRoutes(next);\n    setFallback(nextFallback);\n    onChange?.(next, nextFallback);\n  };\n\n  const patch = (id: string, p: Partial<Route>) => commit(routes.map((r) => (r.id === id ? { ...r, ...p } : r)));\n  const move = (index: number, delta: -1 | 1) => {\n    const next = [...routes];\n    const [route] = next.splice(index, 1);\n    next.splice(index + delta, 0, route);\n    commit(next);\n  };\n\n  const cases = useMemo(\n    () => ({ cases: routes.map((r) => ({ when: r.when as QueryOf<Fields>, then: r.id })), otherwise: \"fallback\" }),\n    [routes],\n  );\n  const route = useMemo(() => compileCases(fields, cases, { incompleteRules: false }), [fields, cases]);\n  const hit = route(response as never);\n  const shares = useMemo(() => {\n    const counts: Record<string, number> = {};\n    for (const row of history) {\n      const { value } = route(row as never);\n      counts[value as string] = (counts[value as string] ?? 0) + 1;\n    }\n    return counts;\n  }, [history, route]);\n\n  const sql = useMemo(() => {\n    try {\n      const names = Object.fromEntries(destinations.map((d) => [d.id, d.name]));\n      return String(\n        encodeCases(\n          postgres,\n          { cases: routes.map((r) => ({ when: r.when, then: names[r.to] })), otherwise: names[fallback] },\n          fields,\n        ),\n      );\n    } catch (e) {\n      return e instanceof Error ? e.message.replace(/^qnot\\/sql: /, \"Can't export yet: \") : \"Can't export yet\";\n    }\n  }, [routes, fallback, destinations, fields]);\n\n  const destination = (id: string) => destinations.find((d) => d.id === id) ?? destinations[0];\n  const servedBy = hit.index >= 0 ? destination(routes[hit.index].to) : destination(fallback);\n\n  return (\n    <div\n      className={cn(\n        \"flex w-full min-w-0 max-w-[600px] flex-col overflow-hidden rounded-2xl border bg-card text-sm shadow-xs/5\",\n        className,\n      )}\n    >\n      <div className=\"flex items-start gap-3 px-4 pt-3.5 pb-3\">\n        <div className=\"min-w-0 flex-1\">\n          <h3 className=\"font-medium\">{title}</h3>\n          <p className=\"text-pretty text-muted-foreground text-xs\">{description}</p>\n        </div>\n        <Button aria-expanded={showSql} onClick={() => setShowSql((s) => !s)} size=\"sm\" variant={showSql ? \"secondary\" : \"ghost\"}>\n          <CodeIcon />\n          SQL\n        </Button>\n      </div>\n\n      <AnimatePresence initial={false}>\n        {showSql && (\n          <motion.div\n            animate={{ height: \"auto\", opacity: 1 }}\n            className=\"overflow-hidden\"\n            exit={{ height: 0, opacity: 0 }}\n            initial={{ height: 0, opacity: 0 }}\n            transition={{ duration: reduce ? 0 : 0.22, ease: easeOut }}\n          >\n            <pre className=\"mx-4 mb-3 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-xl border bg-muted/40 p-3 font-mono text-[11.5px] leading-5 dark:bg-muted/24\">\n              {sql.replace(/ (WHEN|ELSE|END)/g, \"\\n  $1\").replace(/\\n {2}END/, \"\\nEND\")}\n            </pre>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      <Reorder.Group axis=\"y\" className=\"flex flex-col gap-2 px-4\" onReorder={(next) => commit(next)} values={routes}>\n        {routes.map((r, index) => (\n          <RouteCard\n            destinations={destinations}\n            fields={fields}\n            index={index}\n            isLast={index === routes.length - 1}\n            key={r.id}\n            matched={hit.index === index}\n            onDuplicate={() => {\n              const copy = { ...r, id: `route-${++counter}` };\n              commit([...routes.slice(0, index + 1), copy, ...routes.slice(index + 1)]);\n            }}\n            onMove={(delta) => move(index, delta)}\n            onRemove={() => commit(routes.filter((x) => x.id !== r.id))}\n            onTo={(to) => patch(r.id, { to })}\n            onWhen={(when) => patch(r.id, { when })}\n            route={r}\n            share={history.length ? (shares[r.id] ?? 0) / history.length : null}\n          />\n        ))}\n      </Reorder.Group>\n\n      <div className=\"flex flex-col gap-2 px-4 pt-2 pb-3\">\n        <Button\n          className=\"self-start\"\n          onClick={() =>\n            commit([\n              ...routes,\n              {\n                id: `route-${++counter}`,\n                when: { kind: \"group\", combinator: \"and\", children: [makeRule(fields, { field: Object.keys(fields)[0] })] },\n                to: destinations[0].id,\n              },\n            ])\n          }\n          size=\"sm\"\n          variant=\"outline\"\n        >\n          <PlusIcon />\n          Add route\n        </Button>\n        <div\n          className={cn(\n            \"flex items-center gap-2 rounded-xl border border-dashed px-3 py-2 transition-[background-color,border-color] duration-200\",\n            hit.index === -1 && \"border-success/40 border-solid bg-success/6 dark:bg-success/10\",\n          )}\n        >\n          <span className=\"whitespace-nowrap text-muted-foreground text-xs\">\n            {routes.length ? \"Otherwise send to\" : \"Send everyone to\"}\n          </span>\n          <DestinationSelect destinations={destinations} label=\"Fallback destination\" onChange={(id) => commit(routes, id)} value={fallback} />\n          <span className=\"flex-1\" />\n          {history.length > 0 && (\n            <span className=\"text-muted-foreground text-xs tabular-nums max-sm:hidden\">\n              {Math.round(((shares.fallback ?? 0) / history.length) * 100)}% of last month\n            </span>\n          )}\n        </div>\n      </div>\n\n      <TestPanel fields={fields} onChange={setResponse} response={response} servedBy={servedBy} via={hit.index >= 0 ? `Route ${hit.index + 1}` : \"the fallback\"} />\n    </div>\n  );\n}\n\nfunction DestinationSelect({\n  value,\n  destinations,\n  label,\n  onChange,\n}: {\n  value: string;\n  destinations: RouteDestination[];\n  label: string;\n  onChange: (id: string) => void;\n}) {\n  return (\n    <Select items={destinations.map((d) => ({ value: d.id, label: d.name }))} onValueChange={(v) => v && onChange(v)} value={value}>\n      <SelectTrigger aria-label={label} className=\"w-auto min-w-0\" size=\"sm\">\n        <SelectValue />\n      </SelectTrigger>\n      <SelectPopup>\n        {destinations.map((d) => (\n          <SelectItem key={d.id} value={d.id}>\n            <span className=\"flex flex-col\">\n              <span>{d.name}</span>\n              <span className=\"text-muted-foreground text-xs\">{d.detail}</span>\n            </span>\n          </SelectItem>\n        ))}\n      </SelectPopup>\n    </Select>\n  );\n}\n\nfunction RouteCard({\n  route,\n  index,\n  fields,\n  destinations,\n  matched,\n  share,\n  isLast,\n  onWhen,\n  onTo,\n  onMove,\n  onDuplicate,\n  onRemove,\n}: {\n  route: Route;\n  index: number;\n  fields: Fields;\n  destinations: RouteDestination[];\n  matched: boolean;\n  share: number | null;\n  isLast: boolean;\n  onWhen: (when: Query) => void;\n  onTo: (to: string) => void;\n  onMove: (delta: -1 | 1) => void;\n  onDuplicate: () => void;\n  onRemove: () => void;\n}) {\n  const reduce = useReducedMotion();\n  const controls = useDragControls();\n  const store = useQueryStore({ fields, initial: route.when as QueryOf<Fields>, onChange: (q) => onWhen(q as Query) });\n  const issues = useMemo(() => validateQuery(fields, route.when), [fields, route.when]);\n  const incomplete = issues.some((i) => i.code === \"required\");\n\n  return (\n    <Reorder.Item\n      className=\"relative\"\n      dragControls={controls}\n      dragListener={false}\n      layout=\"position\"\n      layoutDependency={index}\n      transition={reduce ? { duration: 0 } : { type: \"spring\", duration: 0.3, bounce: 0 }}\n      value={route}\n      whileDrag={{ scale: 1.01, boxShadow: \"0 8px 24px -12px rgb(0 0 0 / 0.25)\" }}\n    >\n      <QueryProvider store={store}>\n        <div\n          className={cn(\n            \"flex flex-col gap-2 rounded-xl border bg-background p-2.5 transition-[border-color,box-shadow] duration-200 dark:bg-input/16\",\n            matched && \"border-success/48 ring-3 ring-success/12\",\n          )}\n        >\n          <div className=\"flex items-center gap-1.5\">\n            <button\n              aria-label={`Reorder route ${index + 1}. Drag, or use the arrow keys.`}\n              className=\"flex h-7 w-5 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground/64 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing\"\n              onKeyDown={(e) => {\n                if (e.key === \"ArrowUp\" && index > 0) {\n                  e.preventDefault();\n                  onMove(-1);\n                }\n                if (e.key === \"ArrowDown\" && !isLast) {\n                  e.preventDefault();\n                  onMove(1);\n                }\n              }}\n              onPointerDown={(e) => controls.start(e)}\n              type=\"button\"\n            >\n              <GripVerticalIcon className=\"size-3.5\" />\n            </button>\n            <span className=\"font-medium text-xs tabular-nums\">Route {index + 1}</span>\n            <ArrowRightIcon className=\"size-3.5 text-muted-foreground\" />\n            <DestinationSelect destinations={destinations} label={`Destination for route ${index + 1}`} onChange={onTo} value={route.to} />\n            <span className=\"flex-1\" />\n            {matched && (\n              <span className=\"rounded-full bg-success/10 px-2 py-0.5 font-medium text-[11px] text-success-foreground max-[400px]:hidden\">\n                Matches test\n              </span>\n            )}\n            <Menu>\n              <MenuTrigger render={<Button aria-label={`Route ${index + 1} actions`} size=\"icon-sm\" variant=\"ghost\" />}>\n                <EllipsisIcon />\n              </MenuTrigger>\n              <MenuPopup align=\"end\" className=\"w-40\">\n                <MenuItem disabled={index === 0} onClick={() => onMove(-1)}>\n                  <ArrowUpIcon />\n                  Move up\n                </MenuItem>\n                <MenuItem disabled={isLast} onClick={() => onMove(1)}>\n                  <ArrowDownIcon />\n                  Move down\n                </MenuItem>\n                <MenuItem onClick={onDuplicate}>\n                  <CopyPlusIcon />\n                  Duplicate\n                </MenuItem>\n                <MenuSeparator />\n                <MenuItem onClick={onRemove} variant=\"destructive\">\n                  <Trash2Icon />\n                  Delete route\n                </MenuItem>\n              </MenuPopup>\n            </Menu>\n          </div>\n          <Conditions\n            id={store.rootId}\n            status={\n              incomplete ? (\n                <span className=\"text-warning-foreground\">Incomplete, skipped</span>\n              ) : share === null ? null : (\n                `${Math.round(share * 100)}% of last month`\n              )\n            }\n          />\n        </div>\n      </QueryProvider>\n    </Reorder.Item>\n  );\n}\n\nfunction AnswerInput({\n  def,\n  label,\n  value,\n  onChange,\n}: {\n  def: AnyFieldDef;\n  label: string;\n  value: unknown;\n  onChange: (value: unknown) => void;\n}) {\n  const [draft, setDraft] = useState<string | null>(null);\n  const unreadable = draft !== null && draft.trim() !== \"\" && parseInput(def, draft) === null;\n  return (\n    <Input\n      aria-invalid={unreadable || undefined}\n      aria-label={label}\n      inputMode={def.kind === \"number\" ? \"numeric\" : undefined}\n      onBlur={() => setDraft(null)}\n      onChange={(e) => {\n        setDraft(e.target.value);\n        onChange(parseInput(def, e.target.value));\n      }}\n      onFocus={() => setDraft(formatInput(value))}\n      size=\"sm\"\n      value={draft ?? formatInput(value)}\n    />\n  );\n}\n\nfunction TestPanel({\n  fields,\n  response,\n  servedBy,\n  via,\n  onChange,\n}: {\n  fields: Fields;\n  response: Record<string, unknown>;\n  servedBy: RouteDestination;\n  via: string;\n  onChange: (response: Record<string, unknown>) => void;\n}) {\n  const reduce = useReducedMotion();\n  const set = (key: string, value: unknown) => onChange({ ...response, [key]: value });\n  return (\n    <div className=\"border-t bg-muted/32 px-4 pt-3 pb-3.5 dark:bg-muted/16\">\n      <div className=\"mb-2.5 flex items-baseline justify-between gap-3\">\n        <h4 className=\"font-medium text-xs\">Test a response</h4>\n        <span className=\"text-muted-foreground text-xs max-sm:hidden\">Edit the answers to see where it lands</span>\n      </div>\n      <div className=\"grid grid-cols-2 gap-2 sm:grid-cols-3\">\n        {Object.entries(fields).map(([key, def]) => (\n          <label className=\"flex min-w-0 flex-col gap-1\" key={key}>\n            <span className=\"truncate text-muted-foreground text-xs\">{fieldLabel(key, def)}</span>\n            {def.options ? (\n              <Select\n                items={(def.options as readonly { value: string; label: string }[]).map((o) => ({ value: o.value, label: o.label }))}\n                onValueChange={(v) => v && set(key, v)}\n                value={String(response[key] ?? \"\")}\n              >\n                <SelectTrigger aria-label={fieldLabel(key, def)} className=\"min-w-0\" size=\"sm\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectPopup>\n                  {(def.options as readonly { value: string; label: string }[]).map((o) => (\n                    <SelectItem key={o.value} value={o.value}>\n                      {o.label}\n                    </SelectItem>\n                  ))}\n                </SelectPopup>\n              </Select>\n            ) : (\n              <AnswerInput def={def} label={fieldLabel(key, def)} onChange={(v) => set(key, v)} value={response[key]} />\n            )}\n          </label>\n        ))}\n      </div>\n      <div className=\"mt-3 flex items-center gap-2 rounded-lg border bg-background px-3 py-2 dark:bg-input/32\">\n        <ArrowRightIcon className=\"size-3.5 shrink-0 text-success-foreground\" />\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            animate={{ opacity: 1, y: 0 }}\n            className=\"min-w-0 truncate\"\n            exit={{ opacity: 0, y: reduce ? 0 : -4 }}\n            initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n            key={`${servedBy.id}-${via}`}\n            transition={{ duration: 0.18, ease: easeOut }}\n          >\n            <span className=\"font-medium\">{servedBy.name}</span>\n            <span className=\"text-muted-foreground\"> via {via} · {servedBy.detail}</span>\n          </motion.span>\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n\nfunction Conditions({ id, status }: { id: string; status: ReactNode }) {\n  const { group, addRule, setCombinator } = useGroup(id);\n  return (\n    <div className=\"@container flex flex-col gap-2.5 @sm:gap-1.5\">\n      {group.children.map((child, i) => (\n        <div className=\"flex items-start gap-1.5\" key={child}>\n          {i === 0 ? (\n            <span className=\"flex h-8 w-9 shrink-0 sm:h-7 items-center text-muted-foreground text-xs\">If</span>\n          ) : i === 1 ? (\n            <button\n              aria-label={`Match ${group.combinator === \"and\" ? \"any\" : \"all\"} conditions instead`}\n              className=\"flex h-8 w-9 shrink-0 sm:h-7 items-center justify-center rounded-lg border border-input bg-background font-medium text-xs shadow-xs/5 outline-none transition-[background-color,scale] duration-150 hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] dark:bg-input/32\"\n              onClick={() => setCombinator(group.combinator === \"and\" ? \"or\" : \"and\")}\n              type=\"button\"\n            >\n              {group.combinator}\n            </button>\n          ) : (\n            <span className=\"flex h-8 w-9 shrink-0 sm:h-7 items-center ps-1 text-muted-foreground text-xs\">{group.combinator}</span>\n          )}\n          <div className=\"min-w-0 flex-1\">\n            <Condition id={child} />\n          </div>\n        </div>\n      ))}\n      <div className=\"flex items-center justify-between gap-2 ps-10.5\">\n        <button\n          className=\"-ms-1.5 flex h-7 items-center gap-1 rounded-md px-1.5 text-muted-foreground text-xs outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n          onClick={() => addRule()}\n          type=\"button\"\n        >\n          <PlusIcon className=\"size-3.5\" />\n          Condition\n        </button>\n        <span className=\"min-w-0 truncate text-muted-foreground text-xs tabular-nums\">{status}</span>\n      </div>\n    </div>\n  );\n}\n\nconst Condition = memo(function Condition({ id }: { id: string }) {\n  const { rule, def, editor, operators, fieldOptions, issues, setField, setOperator, setValue, remove } = useRule(id);\n  const field = typeof rule.field === \"string\" ? rule.field : \"\";\n  const label = fieldOptions.find((o) => o.value === field)?.label ?? field;\n  const errors = issues.filter((i) => i.severity === \"error\" && i.code !== \"required\");\n  return (\n    <div>\n      <div className=\"grid grid-cols-[minmax(0,1fr)_2rem] items-start gap-1.5 @sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2rem] @lg:grid-cols-[8.5rem_8rem_minmax(0,1fr)_2rem]\">\n        <Select items={fieldOptions} onValueChange={(v) => v && setField(v)} value={field}>\n          <SelectTrigger aria-label=\"Attribute\" className=\"col-start-1 row-start-1 min-w-0\" size=\"sm\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectPopup>\n            {fieldOptions.map((o) => (\n              <SelectItem key={o.value} value={o.value}>\n                {o.label}\n              </SelectItem>\n            ))}\n          </SelectPopup>\n        </Select>\n        <Select items={operators} onValueChange={(v) => v && setOperator(v)} value={rule.operator}>\n          <SelectTrigger\n            aria-label=\"Operator\"\n            className=\"col-start-1 row-start-2 min-w-0 @sm:col-start-2 @sm:row-start-1\"\n            size=\"sm\"\n          >\n            <SelectValue />\n          </SelectTrigger>\n          <SelectPopup>\n            {operators.map((o) => (\n              <SelectItem key={o.value} value={o.value}>\n                {o.label}\n              </SelectItem>\n            ))}\n          </SelectPopup>\n        </Select>\n        <div\n          className={cn(\n            \"col-start-1 row-start-3 min-w-0 @sm:col-span-2 @sm:row-start-2 @lg:col-span-1 @lg:col-start-3 @lg:row-start-1\",\n            editor.arity === \"none\" && \"hidden\",\n          )}\n        >\n          <ValueEditor def={def} editor={editor} issues={issues} label={label} onChange={setValue} value={rule.value} />\n        </div>\n        <Button\n          aria-label={`Remove condition on ${label}`}\n          className=\"col-start-2 row-start-1 @sm:col-start-3 @lg:col-start-4\"\n          onClick={remove}\n          size=\"icon-sm\"\n          variant=\"ghost\"\n        >\n          <XIcon />\n        </Button>\n      </div>\n      {errors.length > 0 && <p className=\"pt-1 text-destructive-foreground text-xs\">{errors.map((e) => e.message).join(\" · \")}</p>}\n    </div>\n  );\n});\n\nfunction ValueEditor({\n  editor,\n  def,\n  value,\n  issues,\n  label,\n  onChange,\n}: {\n  editor: EditorSpec;\n  def: AnyFieldDef | undefined;\n  value: unknown;\n  issues: readonly Issue[];\n  label: string;\n  onChange: (value: unknown) => void;\n}) {\n  const invalidAt = (index?: number) =>\n    issues.some((i) => i.severity === \"error\" && i.code !== \"required\" && (index === undefined || i.index === undefined || i.index === index));\n  if (editor.arity === \"none\") return null;\n  if (editor.arity === \"two\") {\n    const pair = Array.isArray(value) ? value : [null, null];\n    return (\n      <div className=\"flex items-center gap-1.5\">\n        <Scalar def={def} editor={editor} invalid={invalidAt(0)} label={`${label} from`} onChange={(v) => onChange([v, pair[1] ?? null])} value={pair[0]} />\n        <span className=\"shrink-0 text-muted-foreground text-xs\">and</span>\n        <Scalar def={def} editor={editor} invalid={invalidAt(1)} label={`${label} to`} onChange={(v) => onChange([pair[0] ?? null, v])} value={pair[1]} />\n      </div>\n    );\n  }\n  if (editor.type === \"multiselect\") {\n    const options = editor.options ?? [];\n    const selected = Array.isArray(value) ? value.map(String) : [];\n    return (\n      <Select\n        items={options.map((o) => ({ value: String(o.value), label: o.label }))}\n        multiple\n        onValueChange={(next) => onChange((next as string[]).map((raw) => parseInput(def, raw)))}\n        value={selected}\n      >\n        <SelectTrigger aria-invalid={invalidAt() || undefined} aria-label={`${label} values`} className=\"min-w-0\" size=\"sm\">\n          <SelectValue>\n            {(vals: string[]) =>\n              vals.length === 0 ? (\n                <span className=\"text-muted-foreground\">Choose…</span>\n              ) : (\n                vals.map((v) => options.find((o) => String(o.value) === v)?.label ?? v).join(\", \")\n              )\n            }\n          </SelectValue>\n        </SelectTrigger>\n        <SelectPopup>\n          {options.map((o) => (\n            <SelectItem key={String(o.value)} value={String(o.value)}>\n              {o.label}\n            </SelectItem>\n          ))}\n        </SelectPopup>\n      </Select>\n    );\n  }\n  if (editor.type === \"list\") {\n    return (\n      <DraftInput\n        format={formatList}\n        invalid={invalidAt()}\n        label={`${label} values`}\n        onChange={onChange}\n        parse={(raw) => parseList(def, raw)}\n        placeholder=\"Comma, separated\"\n        value={value}\n      />\n    );\n  }\n  return <Scalar def={def} editor={editor} invalid={invalidAt()} label={label} onChange={onChange} value={value} />;\n}\n\nfunction Scalar({\n  editor,\n  def,\n  value,\n  invalid,\n  label,\n  onChange,\n}: {\n  editor: EditorSpec;\n  def: AnyFieldDef | undefined;\n  value: unknown;\n  invalid: boolean;\n  label: string;\n  onChange: (value: unknown) => void;\n}) {\n  if (editor.type === \"select\" || editor.type === \"boolean\") {\n    const items =\n      editor.type === \"boolean\"\n        ? [\n            { value: \"true\", label: \"Yes\" },\n            { value: \"false\", label: \"No\" },\n          ]\n        : (editor.options ?? []).map((o) => ({ value: String(o.value), label: o.label }));\n    return (\n      <Select\n        items={items}\n        onValueChange={(raw) => raw !== null && onChange(parseInput(def, String(raw)))}\n        value={value === null || value === undefined ? null : String(value)}\n      >\n        <SelectTrigger aria-invalid={invalid || undefined} aria-label={label} className=\"min-w-0\" size=\"sm\">\n          <SelectValue placeholder=\"Choose…\" />\n        </SelectTrigger>\n        <SelectPopup>\n          {items.map((o) => (\n            <SelectItem key={o.value} value={o.value}>\n              {o.label}\n            </SelectItem>\n          ))}\n        </SelectPopup>\n      </Select>\n    );\n  }\n  return (\n    <DraftInput\n      format={formatInput}\n      inputMode={editor.type === \"number\" ? \"decimal\" : undefined}\n      invalid={invalid}\n      label={label}\n      onChange={onChange}\n      parse={(raw) => parseInput(def, raw)}\n      placeholder={editor.type === \"number\" ? \"0\" : \"Value\"}\n      type={editor.type === \"date\" ? \"date\" : \"text\"}\n      value={value}\n    />\n  );\n}\n\nfunction DraftInput({\n  value,\n  parse,\n  format,\n  invalid,\n  label,\n  placeholder,\n  inputMode,\n  type = \"text\",\n  onChange,\n}: {\n  value: unknown;\n  parse: (raw: string) => unknown;\n  format: (value: unknown) => string;\n  invalid: boolean;\n  label: string;\n  placeholder?: string;\n  inputMode?: \"decimal\";\n  type?: \"text\" | \"date\";\n  onChange: (value: unknown) => void;\n}) {\n  const [draft, setDraft] = useState<string | null>(null);\n  const unreadable = draft !== null && draft.trim() !== \"\" && parse(draft) === null;\n  return (\n    <div className=\"flex min-w-0 flex-col gap-1\">\n      <Input\n        aria-invalid={invalid || unreadable || undefined}\n        aria-label={label}\n        className={type === \"date\" ? \"[color-scheme:light] dark:[color-scheme:dark]\" : undefined}\n        inputMode={inputMode}\n        onBlur={() => setDraft(null)}\n        onChange={(e) => {\n          setDraft(e.target.value);\n          onChange(parse(e.target.value));\n        }}\n        onFocus={() => setDraft(format(value))}\n        placeholder={placeholder}\n        size=\"sm\"\n        type={type}\n        value={draft ?? format(value)}\n      />\n      {unreadable && <p className=\"text-destructive-foreground text-xs\">Enter a number</p>}\n    </div>\n  );\n}\n\n"
    }
  ]
}