Building a custom UI

Your own builder from the headless hooks, with coss ui as the example kit.

The default builder is deliberately plain. For a product, build the builder from the hooks with the components you already have. This is how the playground's coss ui builder is made, and the same shape works for shadcn/ui, MUI or your own design system.

The pieces

You need three components: a group, a rule, and a value editor. The hooks hand each one everything it needs.

import { fieldOptions } from '@qnot/core';
import { QueryProvider, useGroup, useNode, useRule } from '@qnot/react';
import { memo } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';

export function CossQueryBuilder({ store }: { store: QueryStore }) {
  return (
    <QueryProvider store={store}>
      <Group id={store.rootId} />
    </QueryProvider>
  );
}

const Group = memo(function Group({ id }: { id: string }) {
  const { group, can, setCombinator, addRule, addGroup } = useGroup(id);
  return (
    <div className="flex flex-col gap-3 rounded-xl border p-3">
      <div className="flex items-center gap-2">
        <ToggleGroup value={[group.combinator]} onValueChange={(v) => v[0] && setCombinator(v[0])} disabled={!can.editGroup}>
          <ToggleGroupItem value="and">all</ToggleGroupItem>
          <ToggleGroupItem value="or">any</ToggleGroupItem>
        </ToggleGroup>
        <Button size="sm" variant="outline" disabled={!can.addRule} onClick={() => addRule()}>Add rule</Button>
        <Button size="sm" variant="ghost" disabled={!can.addGroup} onClick={() => addGroup()}>Add group</Button>
      </div>
      {group.children.map((child) => <Node id={child} key={child} />)}
    </div>
  );
});

function Node({ id }: { id: string }) {
  return useNode(id)?.kind === 'group' ? <Group id={id} /> : <Rule id={id} />;
}

The rule picks a field, an operator, and renders the editor the field and operator call for:

const Rule = memo(function Rule({ id }: { id: string }) {
  const { rule, def, editor, operators, fieldOptions, issues, can, setField, setOperator, setValue, remove } = useRule(id);
  const invalid = issues.some((i) => i.severity === 'error');
  return (
    <div className="flex flex-wrap items-center gap-2">
      <Pick items={fieldOptions} value={rule.field as string} onChange={setField} disabled={!can.editField} />
      <Pick items={operators} value={rule.operator} onChange={setOperator} disabled={!can.editOperator} />
      {editor.type === 'text' && (
        <Input size="sm" value={(rule.value as string) ?? ''} aria-invalid={invalid} onChange={(e) => setValue(e.target.value)} />
      )}
      {editor.type === 'select' && (
        <Pick items={editor.options!.map((o) => ({ value: String(o.value), label: o.label }))} value={String(rule.value ?? '')} onChange={setValue} />
      )}
      {/* number, date, multiselect, boolean, between pairs … */}
      <Button size="icon-sm" variant="ghost" disabled={!can.remove} onClick={remove}>×</Button>
    </div>
  );
});

The editor spec

editor describes what to render, so your UI never has to know the operator table:

editor.arity says how many: 'one', 'two' (render two editors for between), 'many', or 'none'. editor.async means options come from def.search(query, signal): use a combobox that calls it as the user types. parseInput, formatInput, parseList and formatList from @qnot/core convert between input strings and typed values.

Field refs and functions

When editor.allowsRef is true the right side can be a field or a function instead of a literal. useRule gives you source ('value' | 'field' | 'function'), refOptions, functionOptions, setRef and setCall. defaultCall(fields, name) builds a call with each argument at its default.

Keep it fast

  • Pass ids, not nodes, and wrap components in memo.
  • Don't subscribe rows to the whole query: useQuery re-renders on every change.
  • Long trees: the default stylesheet puts content-visibility: auto on nested groups so off-screen ones skip layout. Do the same on your group container.

The playground's coss builder is a complete version with tree selects, async comboboxes, function editors, array groups, locks and tooltips. Install it into your project with shadcn and change whatever you like.