{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rule-builder",
  "type": "registry:component",
  "title": "Rule Builder",
  "description": "A nested query builder that reads like a sentence: Where, and, or, groups inside groups, drag or arrow-key rules into place, undo anything, and watch the parameterised SQL, JSON and plain English rewrite themselves as you type.",
  "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",
    "https://coss.com/ui/r/tabs.json",
    "https://coss.com/ui/r/tooltip.json"
  ],
  "files": [
    {
      "path": "registry/devl/rule-builder.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  type AnyFieldDef,\n  compile,\n  type EditorSpec,\n  encode,\n  type Fields,\n  fieldLabel,\n  formatInput,\n  formatList,\n  type Issue,\n  type PathIssue,\n  parseInput,\n  parseList,\n  type Query,\n  type QueryNode,\n  type QueryOf,\n  validateQuery,\n} from \"@qnot/core\";\nimport { sqlFormat } from \"@qnot/core/formats/sql\";\nimport { textFormat } from \"@qnot/core/formats/text\";\nimport {\n  QueryProvider,\n  useDragHandle,\n  useDropTarget,\n  useGroup,\n  useGroupDropZone,\n  useHistory,\n  useNode,\n  useQuery,\n  useQueryStore,\n  useRule,\n  useStore,\n} from \"@qnot/react\";\nimport {\n  CalendarIcon,\n  CheckIcon,\n  ChevronsUpDownIcon,\n  CopyIcon,\n  CopyPlusIcon,\n  EllipsisIcon,\n  FolderPlusIcon,\n  GripVerticalIcon,\n  HashIcon,\n  ListIcon,\n  PlusIcon,\n  Redo2Icon,\n  TagsIcon,\n  ToggleLeftIcon,\n  Trash2Icon,\n  TypeIcon,\n  Undo2Icon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, memo, type ReactNode, useEffect, useMemo, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  Menu,\n  MenuCheckboxItem,\n  MenuItem,\n  MenuPopup,\n  MenuSeparator,\n  MenuTrigger,\n} from \"@/components/ui/menu\";\nimport { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { Tabs, TabsList, TabsTab } from \"@/components/ui/tabs\";\nimport { Tooltip, TooltipPopup, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface RuleBuilderProps {\n  fields: Fields;\n  defaultQuery?: Query;\n  rows?: readonly Record<string, unknown>[];\n  table?: string;\n  noun?: { one: string; many: string };\n  maxDepth?: number;\n  onChange?: (query: Query) => void;\n  className?: string;\n}\n\ntype OutputTab = \"sql\" | \"json\" | \"text\";\n\nconst postgres = sqlFormat({ dialect: \"postgres\", params: \"numbered\" });\nconst english = textFormat();\nconst easeOut = [0.23, 1, 0.32, 1] as const;\nconst glide = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\nconst emptyQuery: Query = { kind: \"group\", combinator: \"and\", children: [] };\n\nconst kindIcons: Record<string, typeof TypeIcon> = {\n  text: TypeIcon,\n  number: HashIcon,\n  enum: ListIcon,\n  multiEnum: TagsIcon,\n  boolean: ToggleLeftIcon,\n  date: CalendarIcon,\n};\n\nexport function RuleBuilder({\n  fields,\n  defaultQuery = emptyQuery,\n  rows,\n  table = \"accounts\",\n  noun = { one: \"account\", many: \"accounts\" },\n  maxDepth = 2,\n  onChange,\n  className,\n}: RuleBuilderProps) {\n  const store = useQueryStore({\n    fields,\n    initial: defaultQuery as QueryOf<Fields>,\n    settings: useMemo(() => ({ maxDepth }), [maxDepth]),\n    onChange: onChange as ((q: QueryOf<Fields>) => void) | undefined,\n  });\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const target = e.target as HTMLElement;\n    if (target.closest(\"input, textarea\")) return;\n    if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== \"z\") return;\n    e.preventDefault();\n    if (e.shiftKey) store.redo();\n    else store.undo();\n  };\n\n  return (\n    <QueryProvider store={store}>\n      <div\n        className={cn(\n          \"flex w-full min-w-0 max-w-[640px] flex-col overflow-hidden rounded-2xl border bg-card text-sm shadow-xs/5\",\n          className,\n        )}\n        onKeyDown={onKeyDown}\n      >\n        <Header noun={noun} rows={rows} />\n        <div className=\"px-3 pt-1 pb-3 sm:px-4\">\n          <GroupView depth={0} id={store.rootId} />\n        </div>\n        <OutputPanel fields={fields} table={table} />\n      </div>\n    </QueryProvider>\n  );\n}\n\nfunction Header({ rows, noun }: { rows?: readonly Record<string, unknown>[]; noun: { one: string; many: string } }) {\n  const store = useStore();\n  const query = useQuery();\n  const { canUndo, canRedo, undo, redo } = useHistory();\n  const count = useMemo(() => {\n    if (!rows) return null;\n    const issues = validateQuery(store.fields, query);\n    if (issues.some((i) => i.severity === \"error\" && i.code !== \"required\")) return null;\n    const test = compile(store.fields, query);\n    return rows.filter((row) => test(row as never)).length;\n  }, [rows, store, query]);\n\n  return (\n    <div className=\"flex items-center gap-3 px-4 pt-3.5 pb-2\">\n      <div className=\"min-w-0 flex-1\">\n        <h3 className=\"font-medium\">Filter {noun.many}</h3>\n        <p className=\"text-muted-foreground text-xs tabular-nums\">\n          {rows\n            ? count === null\n              ? \"Fix the highlighted rule to see matches\"\n              : `${count} of ${rows.length} ${rows.length === 1 ? noun.one : noun.many} match`\n            : `${store.ruleCount} ${store.ruleCount === 1 ? \"rule\" : \"rules\"}`}\n        </p>\n      </div>\n      <div className=\"flex items-center gap-0.5\">\n        <IconAction disabled={!canUndo} label=\"Undo\" onClick={undo} shortcut=\"⌘Z\">\n          <Undo2Icon />\n        </IconAction>\n        <IconAction disabled={!canRedo} label=\"Redo\" onClick={redo} shortcut=\"⇧⌘Z\">\n          <Redo2Icon />\n        </IconAction>\n      </div>\n    </div>\n  );\n}\n\nfunction IconAction({\n  label,\n  shortcut,\n  disabled,\n  onClick,\n  children,\n}: {\n  label: string;\n  shortcut?: string;\n  disabled?: boolean;\n  onClick: () => void;\n  children: ReactNode;\n}) {\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        render={<Button aria-label={label} disabled={disabled} onClick={onClick} size=\"icon-sm\" variant=\"ghost\" />}\n      >\n        {children}\n      </TooltipTrigger>\n      <TooltipPopup>\n        {label}\n        {shortcut && <span className=\"ms-1.5 text-muted-foreground\">{shortcut}</span>}\n      </TooltipPopup>\n    </Tooltip>\n  );\n}\n\nconst GroupView = memo(function GroupView({ id, depth }: { id: string; depth: number }) {\n  const reduce = useReducedMotion();\n  const { group, isRoot, can, setCombinator, setNot, addRule, addGroup, clone, remove } = useGroup(id);\n  const { isOver, zoneProps } = useGroupDropZone(id);\n  const { isDragging, handleProps } = useDragHandle(id);\n  const { position, dropProps } = useDropTarget(id);\n  const other = group.combinator === \"and\" ? \"or\" : \"and\";\n  const summary = group.not ? \"None of these\" : group.combinator === \"and\" ? \"All of these\" : \"Any of these\";\n\n  const list = (\n    <div className=\"@container relative flex flex-col gap-3 @sm:gap-1.5\" {...zoneProps}>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {group.children.map((child, index) => (\n          <motion.div\n            animate={{ opacity: 1, scale: 1 }}\n            className=\"flex min-w-0 items-start gap-1.5\"\n            exit={{ opacity: 0, scale: 0.98, transition: { duration: reduce ? 0 : 0.15, ease: easeOut } }}\n            initial={{ opacity: 0, scale: reduce ? 1 : 0.98 }}\n            key={child}\n            layout={reduce ? false : \"position\"}\n            layoutDependency={index}\n            transition={reduce ? { duration: 0 } : { ...glide, opacity: { duration: 0.18, ease: easeOut } }}\n          >\n            <Connector\n              id={child}\n              index={index}\n              onToggle={can.editGroup ? () => setCombinator(other) : undefined}\n              value={group.combinator}\n            />\n            <div className=\"min-w-0 flex-1\">\n              <NodeView depth={depth} id={child} />\n            </div>\n          </motion.div>\n        ))}\n      </AnimatePresence>\n      {group.children.length === 0 && (\n        <div\n          className={cn(\n            \"rounded-xl border border-dashed px-3 py-5 text-center text-muted-foreground text-xs transition-colors\",\n            isOver && \"border-primary/48 bg-primary/4 text-foreground\",\n          )}\n        >\n          {isRoot ? \"No rules yet, so every row matches.\" : \"Empty group. Add a rule or drop one here.\"}\n        </div>\n      )}\n    </div>\n  );\n\n  if (isRoot) {\n    return (\n      <div className=\"@container flex flex-col gap-2.5\">\n        {list}\n        <div className={cn(\"flex items-center gap-1.5\", group.children.length > 0 && \"ps-[2.875rem] @lg:ps-[3.375rem]\")}>\n          <Button disabled={!can.addRule} onClick={() => addRule()} size=\"sm\" variant=\"outline\">\n            <PlusIcon />\n            Add rule\n          </Button>\n          <Button disabled={!can.addGroup} onClick={() => addGroup()} size=\"sm\" variant=\"ghost\">\n            <FolderPlusIcon />\n            Add group\n          </Button>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={cn(\n        \"relative rounded-xl border bg-muted/40 p-1.5 transition-opacity dark:bg-muted/24\",\n        isDragging && \"opacity-48\",\n      )}\n      {...dropProps}\n    >\n      <DropLine position={position} />\n      <div className=\"flex h-8 items-center gap-1 ps-0.5 pe-0.5 sm:h-7\">\n        <DragHandle handleProps={handleProps} id={id} label=\"group\" />\n        <span className=\"min-w-0 flex-1 truncate font-medium text-muted-foreground text-xs\">{summary}</span>\n        <Tooltip>\n          <TooltipTrigger\n            render={\n              <Button\n                aria-label=\"Add rule to group\"\n                disabled={!can.addRule}\n                onClick={() => addRule()}\n                size=\"icon-sm\"\n                variant=\"ghost\"\n              />\n            }\n          >\n            <PlusIcon />\n          </TooltipTrigger>\n          <TooltipPopup>Add rule</TooltipPopup>\n        </Tooltip>\n        <Menu>\n          <MenuTrigger render={<Button aria-label=\"Group actions\" size=\"icon-sm\" variant=\"ghost\" />}>\n            <EllipsisIcon />\n          </MenuTrigger>\n          <MenuPopup align=\"end\" className=\"w-48\">\n            <MenuItem disabled={!can.addGroup} onClick={() => addGroup()}>\n              <FolderPlusIcon />\n              Add nested group\n            </MenuItem>\n            <MenuItem disabled={!can.clone} onClick={() => clone()}>\n              <CopyPlusIcon />\n              Duplicate group\n            </MenuItem>\n            <MenuCheckboxItem checked={group.not} disabled={!can.editGroup} onCheckedChange={(v) => setNot(v)}>\n              Match none of these\n            </MenuCheckboxItem>\n            <MenuSeparator />\n            <MenuItem disabled={!can.remove} onClick={remove} variant=\"destructive\">\n              <Trash2Icon />\n              Delete group\n            </MenuItem>\n          </MenuPopup>\n        </Menu>\n      </div>\n      <div className=\"px-0.5 pb-0.5\">{list}</div>\n    </div>\n  );\n});\n\nfunction NodeView({ id, depth }: { id: string; depth: number }) {\n  const node = useNode(id);\n  if (!node) return null;\n  return node.kind === \"group\" ? <GroupView depth={depth + 1} id={id} /> : <RuleRow id={id} />;\n}\n\nfunction Connector({\n  id,\n  index,\n  value,\n  onToggle,\n}: {\n  id: string;\n  index: number;\n  value: \"and\" | \"or\";\n  onToggle?: () => void;\n}) {\n  const isGroup = useNode(id)?.kind === \"group\";\n  const base = cn(\"flex h-8 w-10 shrink-0 items-center text-xs sm:h-7 @lg:w-12\", isGroup && \"mt-[0.4375rem]\");\n  if (index === 0) return <span className={cn(base, \"text-muted-foreground\")}>Where</span>;\n  if (index === 1 && onToggle) {\n    return (\n      <button\n        aria-label={`Combine with ${value === \"and\" ? \"or\" : \"and\"} instead`}\n        className={cn(\n          base,\n          \"justify-center rounded-lg border border-input bg-background px-1.5 font-medium @lg:justify-between text-foreground 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        )}\n        onClick={onToggle}\n        type=\"button\"\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: -4 }}\n            initial={{ opacity: 0, y: 4 }}\n            key={value}\n            transition={{ duration: 0.15, ease: easeOut }}\n          >\n            {value}\n          </motion.span>\n        </AnimatePresence>\n        <ChevronsUpDownIcon className=\"hidden size-3 opacity-64 @lg:block\" />\n      </button>\n    );\n  }\n  return <span className={cn(base, \"ps-1 text-muted-foreground @lg:ps-1.5\")}>{value}</span>;\n}\n\nfunction DropLine({ position }: { position: \"before\" | \"after\" | \"inside\" | null }) {\n  if (position !== \"before\" && position !== \"after\") return null;\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"pointer-events-none absolute inset-x-0 z-10 h-0.5 rounded-full bg-primary\",\n        position === \"before\" ? \"-top-1\" : \"-bottom-1\",\n      )}\n    />\n  );\n}\n\nfunction DragHandle({\n  id,\n  label,\n  handleProps,\n}: {\n  id: string;\n  label: string;\n  handleProps: ReturnType<typeof useDragHandle>[\"handleProps\"];\n}) {\n  const store = useStore();\n  const nudge = (delta: -1 | 1) => {\n    const node = store.get(id);\n    if (!node?.parent) return;\n    const siblings = store.getGroup(node.parent).children;\n    const index = siblings.indexOf(id);\n    const target = delta < 0 ? index - 1 : index + 2;\n    if (target < 0 || target > siblings.length) return;\n    store.move(id, node.parent, target);\n  };\n  return (\n    <button\n      aria-label={`Move ${label}. Drag, or use the arrow keys.`}\n      className=\"col-start-1 row-start-1 flex h-8 w-5 shrink-0 sm:h-7 cursor-grab 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\" || e.key === \"ArrowDown\") {\n          e.preventDefault();\n          nudge(e.key === \"ArrowUp\" ? -1 : 1);\n        }\n      }}\n      type=\"button\"\n      {...handleProps}\n    >\n      <GripVerticalIcon className=\"size-3.5\" />\n    </button>\n  );\n}\n\nconst RuleRow = memo(function RuleRow({ id }: { id: string }) {\n  const reduce = useReducedMotion();\n  const { rule, def, fields, editor, operators, fieldOptions, issues, can, setField, setOperator, setValue, clone, remove } =\n    useRule(id);\n  const { isDragging, handleProps } = useDragHandle(id);\n  const { position, dropProps } = useDropTarget(id);\n  const [unreadable, setUnreadable] = useState(false);\n  const messages = unreadable\n    ? [\"Enter a number\"]\n    : issues.filter((i) => i.severity === \"error\" && i.code !== \"required\").map((i) => i.message);\n  const field = typeof rule.field === \"string\" ? rule.field : \"\";\n  const label = def ? fieldLabel(field, def) : field;\n  const FieldIcon = kindIcons[def?.kind ?? \"text\"] ?? TypeIcon;\n  const fieldItems = fieldOptions.map((o) => ({ value: o.value, label: o.label }));\n\n  return (\n    <div className={cn(\"relative transition-opacity\", isDragging && \"opacity-48\")} {...dropProps}>\n      <DropLine position={position} />\n      <div className=\"grid grid-cols-[1.25rem_minmax(0,1fr)_2rem] items-start gap-1.5 @sm:grid-cols-[1.25rem_minmax(0,1fr)_minmax(0,1fr)_2rem] @lg:grid-cols-[1.25rem_8.5rem_8.5rem_minmax(0,1fr)_2rem]\">\n        <DragHandle handleProps={handleProps} id={id} label={`rule on ${label}`} />\n        <Select\n          disabled={!can.editField}\n          items={fieldItems}\n          onValueChange={(v) => v && setField(v)}\n          value={field}\n        >\n          <SelectTrigger aria-label=\"Field\" className=\"col-start-2 row-start-1 min-w-0\" size=\"sm\">\n            <span className=\"flex min-w-0 flex-1 items-center gap-1.5\">\n              <FieldIcon className=\"hidden size-3.5 shrink-0 opacity-64 @lg:block\" />\n              <SelectValue />\n            </span>\n          </SelectTrigger>\n          <SelectPopup>\n            {fieldOptions.map((o) => {\n              const Icon = kindIcons[fields[o.value]?.kind ?? \"text\"] ?? TypeIcon;\n              return (\n                <SelectItem key={o.value} value={o.value}>\n                  <span className=\"flex items-center gap-2\">\n                    <Icon className=\"size-3.5 opacity-64\" />\n                    {o.label}\n                  </span>\n                </SelectItem>\n              );\n            })}\n          </SelectPopup>\n        </Select>\n        <Select\n          disabled={!can.editOperator}\n          items={operators}\n          onValueChange={(v) => v && setOperator(v)}\n          value={rule.operator}\n        >\n          <SelectTrigger\n            aria-label=\"Operator\"\n            className=\"col-start-2 row-start-2 min-w-0 @sm:col-start-3 @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-2 row-start-3 min-w-0 @sm:col-span-2 @sm:row-start-2 @lg:col-span-1 @lg:col-start-4 @lg:row-start-1\",\n            editor.arity === \"none\" && \"hidden\",\n          )}\n        >\n          <ValueEditor\n            def={def}\n            disabled={!can.editValue}\n            editor={editor}\n            issues={issues}\n            label={label}\n            onChange={setValue}\n            onUnreadable={setUnreadable}\n            value={rule.value}\n          />\n        </div>\n        <Menu>\n          <MenuTrigger\n            render={\n              <Button\n                aria-label={`Actions for rule on ${label}`}\n                className=\"col-start-3 row-start-1 @sm:col-start-4 @lg:col-start-5\"\n                size=\"icon-sm\"\n                variant=\"ghost\"\n              />\n            }\n          >\n            <EllipsisIcon />\n          </MenuTrigger>\n          <MenuPopup align=\"end\" className=\"w-44\">\n            <MenuItem disabled={!can.clone} onClick={() => clone()}>\n              <CopyPlusIcon />\n              Duplicate\n            </MenuItem>\n            <MenuSeparator />\n            <MenuItem disabled={!can.remove} onClick={remove} variant=\"destructive\">\n              <Trash2Icon />\n              Delete rule\n            </MenuItem>\n          </MenuPopup>\n        </Menu>\n      </div>\n      <AnimatePresence initial={false}>\n        {messages.length > 0 && (\n          <motion.p\n            animate={{ opacity: 1, height: \"auto\" }}\n            className=\"overflow-hidden ps-[1.625rem] text-destructive-foreground text-xs\"\n            exit={{ opacity: 0, height: 0 }}\n            initial={{ opacity: 0, height: 0 }}\n            transition={{ duration: reduce ? 0 : 0.18, ease: easeOut }}\n          >\n            <span className=\"block pt-1\">{messages.join(\" · \")}</span>\n          </motion.p>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n});\n\nfunction ValueEditor({\n  editor,\n  def,\n  value,\n  issues,\n  label,\n  disabled,\n  onChange,\n  onUnreadable,\n}: {\n  editor: EditorSpec;\n  def: AnyFieldDef | undefined;\n  value: unknown;\n  issues: readonly Issue[];\n  label: string;\n  disabled?: boolean;\n  onChange: (value: unknown) => void;\n  onUnreadable: (unreadable: boolean) => 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\n  if (editor.arity === \"none\") return null;\n\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\n          def={def}\n          disabled={disabled}\n          editor={editor}\n          invalid={invalidAt(0)}\n          label={`${label} from`}\n          onChange={(v) => onChange([v, pair[1] ?? null])}\n          onUnreadable={onUnreadable}\n          value={pair[0]}\n        />\n        <span className=\"shrink-0 text-muted-foreground text-xs\">and</span>\n        <Scalar\n          def={def}\n          disabled={disabled}\n          editor={editor}\n          invalid={invalidAt(1)}\n          label={`${label} to`}\n          onChange={(v) => onChange([pair[0] ?? null, v])}\n          onUnreadable={onUnreadable}\n          value={pair[1]}\n        />\n      </div>\n    );\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        disabled={disabled}\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 placeholder=\"Choose…\">\n            {(vals: string[]) =>\n              vals.length === 0\n                ? \"Choose…\"\n                : vals.map((v) => options.find((o) => String(o.value) === v)?.label ?? v).join(\", \")\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\n  if (editor.type === \"list\") {\n    return (\n      <DraftInput\n        disabled={disabled}\n        format={formatList}\n        invalid={invalidAt()}\n        label={`${label} values`}\n        onChange={onChange}\n        onUnreadable={onUnreadable}\n        parse={(raw) => parseList(def, raw)}\n        placeholder=\"Comma, separated\"\n        value={value}\n      />\n    );\n  }\n\n  return (\n    <Scalar\n      def={def}\n      disabled={disabled}\n      editor={editor}\n      invalid={invalidAt()}\n      label={label}\n      onChange={onChange}\n      onUnreadable={onUnreadable}\n      value={value}\n    />\n  );\n}\n\nfunction Scalar({\n  editor,\n  def,\n  value,\n  invalid,\n  label,\n  disabled,\n  onChange,\n  onUnreadable,\n}: {\n  editor: EditorSpec;\n  def: AnyFieldDef | undefined;\n  value: unknown;\n  invalid: boolean;\n  label: string;\n  disabled?: boolean;\n  onChange: (value: unknown) => void;\n  onUnreadable: (unreadable: boolean) => 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        disabled={disabled}\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\n  if (editor.type === \"date\") {\n    return (\n      <Input\n        aria-invalid={invalid || undefined}\n        aria-label={label}\n        className=\"[color-scheme:light] dark:[color-scheme:dark]\"\n        disabled={disabled}\n        onChange={(e) => onChange(parseInput(def, e.target.value))}\n        size=\"sm\"\n        type=\"date\"\n        value={formatInput(value)}\n      />\n    );\n  }\n\n  return (\n    <DraftInput\n      disabled={disabled}\n      format={formatInput}\n      inputMode={editor.type === \"number\" ? \"decimal\" : undefined}\n      invalid={invalid}\n      label={label}\n      onChange={onChange}\n      onUnreadable={onUnreadable}\n      parse={(raw) => parseInput(def, raw)}\n      placeholder={editor.type === \"number\" ? \"0\" : \"Value\"}\n      value={value}\n    />\n  );\n}\n\nfunction DraftInput({\n  value,\n  parse,\n  format,\n  invalid,\n  label,\n  placeholder,\n  inputMode,\n  disabled,\n  onChange,\n  onUnreadable,\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  disabled?: boolean;\n  onChange: (value: unknown) => void;\n  onUnreadable: (unreadable: boolean) => void;\n}) {\n  const [draft, setDraft] = useState<string | null>(null);\n  const unreadable = draft !== null && draft.trim() !== \"\" && parse(draft) === null;\n  useEffect(() => {\n    onUnreadable(unreadable);\n    return () => onUnreadable(false);\n  }, [unreadable, onUnreadable]);\n  return (\n    <Input\n      aria-invalid={invalid || unreadable || undefined}\n      aria-label={label}\n      disabled={disabled}\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      value={draft ?? format(value)}\n    />\n  );\n}\n\nfunction OutputPanel({ fields, table }: { fields: Fields; table: string }) {\n  const query = useQuery();\n  const [tab, setTab] = useState<OutputTab>(\"sql\");\n  const issues = useMemo(() => validateQuery(fields, query), [fields, query]);\n  const incomplete = issues.filter((i) => i.code === \"required\").length;\n  const blocking = issues.filter((i) => i.severity === \"error\" && i.code !== \"required\");\n\n  const output = useMemo(() => {\n    if (blocking.length) return null;\n    try {\n      const { sql, params } = encode(postgres, query, fields);\n      return {\n        sql: sql === \"1 = 1\" ? `SELECT * FROM ${table}` : `SELECT * FROM ${table}\\nWHERE ${prettyWhere(sql)}`,\n        params,\n        json: JSON.stringify(query, null, 2),\n        text: encode(english, query, fields) || \"Every row\",\n      };\n    } catch {\n      return null;\n    }\n  }, [blocking.length, query, fields, table]);\n\n  const copyText = output ? (tab === \"sql\" ? output.sql : tab === \"json\" ? output.json : output.text) : \"\";\n\n  return (\n    <div className=\"border-t bg-muted/32 dark:bg-muted/16\">\n      <div className=\"flex items-center gap-2 px-3 pt-2.5 sm:px-4\">\n        <Tabs onValueChange={(v) => setTab(v as OutputTab)} value={tab}>\n          <TabsList className=\"h-8 text-muted-foreground\">\n            <TabsTab className=\"text-xs sm:text-xs\" value=\"sql\">\n              SQL\n            </TabsTab>\n            <TabsTab className=\"text-xs sm:text-xs\" value=\"json\">\n              JSON\n            </TabsTab>\n            <TabsTab className=\"text-xs sm:text-xs\" value=\"text\">\n              Plain English\n            </TabsTab>\n          </TabsList>\n        </Tabs>\n        <span className=\"flex-1\" />\n        <CopyButton disabled={!output} text={copyText} />\n      </div>\n      <div className=\"h-44 overflow-auto px-3 pt-2 pb-3 sm:px-4\">\n        {output ? (\n          tab === \"sql\" ? (\n            <div className=\"flex flex-col gap-2.5\">\n              <pre className=\"whitespace-pre-wrap break-words font-mono text-[12px] leading-5\">{highlightSql(output.sql)}</pre>\n              {incomplete > 0 && (\n                <p className=\"text-muted-foreground text-xs\">\n                  Skipping {incomplete} incomplete {incomplete === 1 ? \"rule\" : \"rules\"} until {incomplete === 1 ? \"it has\" : \"they have\"} a value.\n                </p>\n              )}\n              {output.params.length > 0 && (\n                <div className=\"flex flex-wrap gap-1\">\n                  {output.params.map((p, i) => (\n                    <span\n                      className=\"inline-flex items-center gap-1 rounded-md border bg-background px-1.5 py-0.5 font-mono text-[11px] tabular-nums dark:bg-input/32\"\n                      key={`${i}-${String(p)}`}\n                    >\n                      <span className=\"text-amber-700 dark:text-amber-400\">${i + 1}</span>\n                      <span className=\"max-w-40 truncate text-muted-foreground\">{JSON.stringify(p)}</span>\n                    </span>\n                  ))}\n                </div>\n              )}\n            </div>\n          ) : tab === \"json\" ? (\n            <pre className=\"font-mono text-[12px] leading-5\">{highlightJson(output.json)}</pre>\n          ) : (\n            <p className=\"text-pretty leading-6\">{output.text}</p>\n          )\n        ) : (\n          <BlockingIssues fields={fields} issues={blocking} query={query} />\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction BlockingIssues({ issues, query, fields }: { issues: PathIssue[]; query: Query; fields: Fields }) {\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <p className=\"font-medium text-destructive-foreground text-xs\">\n        Fix {issues.length} {issues.length === 1 ? \"rule\" : \"rules\"} to export this filter\n      </p>\n      <ul className=\"flex flex-col gap-1\">\n        {issues.map((issue) => (\n          <li className=\"flex items-baseline gap-2 text-xs\" key={`${issue.path.join(\".\")}-${issue.code}-${issue.index ?? \"\"}`}>\n            <span className=\"size-1.5 shrink-0 translate-y-[-1px] rounded-full bg-destructive\" />\n            <span className=\"font-medium\">{nodeLabel(fields, query, issue.path)}</span>\n            <span className=\"text-muted-foreground\">{issue.message}</span>\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n\nfunction nodeLabel(fields: Fields, query: Query, path: readonly number[]) {\n  let node: QueryNode | undefined = query;\n  for (const index of path) node = node && node.kind === \"group\" ? node.children[index] : undefined;\n  if (!node) return \"Rule\";\n  if (node.kind === \"group\") return \"Group\";\n  const name = typeof node.field === \"string\" ? node.field : \"Function\";\n  return fields[name] ? fieldLabel(name, fields[name]) : name;\n}\n\nfunction CopyButton({ text, disabled }: { text: string; disabled?: boolean }) {\n  const [copied, setCopied] = useState(false);\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  useEffect(() => () => {\n    if (timer.current) clearTimeout(timer.current);\n  }, []);\n  return (\n    <Button\n      aria-label={copied ? \"Copied\" : \"Copy\"}\n      disabled={disabled}\n      onClick={() => {\n        navigator.clipboard?.writeText(text).catch(() => {});\n        setCopied(true);\n        if (timer.current) clearTimeout(timer.current);\n        timer.current = setTimeout(() => setCopied(false), 1400);\n      }}\n      size=\"icon-sm\"\n      variant=\"ghost\"\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          animate={{ opacity: 1, scale: 1 }}\n          className=\"flex\"\n          exit={{ opacity: 0, scale: 0.8 }}\n          initial={{ opacity: 0, scale: 0.8 }}\n          key={copied ? \"done\" : \"copy\"}\n          transition={{ duration: 0.15, ease: easeOut }}\n        >\n          {copied ? <CheckIcon className=\"text-success-foreground\" /> : <CopyIcon />}\n        </motion.span>\n      </AnimatePresence>\n    </Button>\n  );\n}\n\nfunction prettyWhere(sql: string) {\n  let depth = 0;\n  let quoted = false;\n  let out = \"\";\n  for (let i = 0; i < sql.length; i++) {\n    const ch = sql[i];\n    if (ch === \"'\") quoted = !quoted;\n    if (!quoted && ch === \"(\") depth++;\n    if (!quoted && ch === \")\") depth--;\n    if (!quoted && depth === 0) {\n      const rest = sql.slice(i);\n      const joiner = rest.startsWith(\" AND \") ? \"AND\" : rest.startsWith(\" OR \") ? \"OR\" : null;\n      if (joiner) {\n        out += `\\n  ${joiner} `;\n        i += joiner.length + 1;\n        continue;\n      }\n    }\n    out += ch;\n  }\n  return out;\n}\n\nconst sqlKeywords = /^(SELECT|FROM|WHERE|AND|OR|NOT|IN|LIKE|ILIKE|ESCAPE|BETWEEN|IS|NULL|TRUE|FALSE)$/;\n\nfunction highlightSql(sql: string) {\n  return sql.split(/(\\$\\d+|'(?:[^']|'')*'|\\b[A-Z]+\\b)/g).map((part, i) => {\n    if (/^\\$\\d+$/.test(part))\n      return (\n        <span className=\"text-amber-700 dark:text-amber-400\" key={i}>\n          {part}\n        </span>\n      );\n    if (part.startsWith(\"'\"))\n      return (\n        <span className=\"text-emerald-700 dark:text-emerald-400\" key={i}>\n          {part}\n        </span>\n      );\n    if (sqlKeywords.test(part))\n      return (\n        <span className=\"text-violet-700 dark:text-violet-400\" key={i}>\n          {part}\n        </span>\n      );\n    return part;\n  });\n}\n\nfunction highlightJson(json: string) {\n  return json.split(/(\"(?:[^\"\\\\]|\\\\.)*\"(?:\\s*:)?|\\b-?\\d+(?:\\.\\d+)?\\b|\\btrue\\b|\\bfalse\\b|\\bnull\\b)/g).map((part, i) => {\n    if (/^\".*:$/.test(part))\n      return (\n        <span className=\"text-muted-foreground\" key={i}>\n          {part}\n        </span>\n      );\n    if (part.startsWith('\"'))\n      return (\n        <span className=\"text-emerald-700 dark:text-emerald-400\" key={i}>\n          {part}\n        </span>\n      );\n    if (/^(-?\\d|true|false|null)/.test(part))\n      return (\n        <span className=\"text-amber-700 dark:text-amber-400\" key={i}>\n          {part}\n        </span>\n      );\n    return part;\n  });\n}\n"
    }
  ]
}