"use client"; import { useId, useRef, type CSSProperties, type ReactNode } from "react"; import { AnimatePresence, motion } from "motion/react"; import { useReducedMotion } from "../motion/Preferences.tsx"; import { EASE_EXPO } from "../motion/easings.ts"; import { SegmentedControl } from "../forms/Selection.tsx"; import { MultiSelect } from "../interaction/MultiSelect.tsx"; import { useAnnouncer, useControllable } from "./internal.ts"; import { OPERATORS, describeQuery, isComplete, isGroup, operatorOf, type QueryField, type QueryGroup, type QueryRule, type QueryValue, } from "./query.ts"; export type { QueryField, QueryGroup, QueryRule, QueryValue } from "./query.ts"; export { describeQuery, matchesQuery } from "./query.ts"; type Node = QueryRule | QueryGroup; const makeId = () => `q${Math.random().toString(36).slice(2, 9)}`; function update( group: QueryGroup, id: string, change: (node: Node) => Node | null, ): QueryGroup { return { ...group, rules: group.rules.flatMap((node) => { if (node.id === id) { const next = change(node); return next ? [next] : []; } return isGroup(node) ? [update(node, id, change)] : [node]; }), }; } /** * Conditions that read as a sentence: typed operators per field, nested * groups, a plain-language summary and an evaluator for local filtering. */ export function QueryBuilder({ label, fields, value, defaultValue, onValueChange, maxDepth = 3, createId = makeId, footer, className = "", style, }: { label: string; fields: readonly QueryField[]; value?: QueryGroup; defaultValue?: QueryGroup; onValueChange?: (value: QueryGroup) => void; /** Nesting limit for groups; 1 allows no subgroups. */ maxDepth?: number; createId?: () => string; /** Beside the summary, e.g. a live count of matches. */ footer?: ReactNode; className?: string; style?: CSSProperties; }) { const [query, setQuery] = useControllable( value, defaultValue ?? { id: "root", combinator: "and", rules: [] }, onValueChange, ); const [message, announce] = useAnnouncer(); const focusRef = useRef(null); const change = (id: string, fn: (node: Node) => Node | null) => setQuery( id === query.id ? (fn(query) as QueryGroup) : update(query, id, fn), ); const newRule = (): QueryRule => { const field = fields[0]!; return { id: createId(), field: field.id, operator: OPERATORS[field.type][0]!.id, }; }; const add = (groupId: string, node: Node) => { focusRef.current = isGroup(node) ? (node.rules[0]?.id ?? null) : node.id; change(groupId, (g) => ({ ...(g as QueryGroup), rules: [...(g as QueryGroup).rules, node], })); announce(isGroup(node) ? "Group added." : "Condition added."); }; const summary = describeQuery(query, fields); return (

Reads as {summary}

{footer &&
{footer}
}
{message}
); } type GroupProps = { group: QueryGroup; depth: number; maxDepth: number; fields: readonly QueryField[]; onChange: (id: string, fn: (node: Node) => Node | null) => void; onAdd: (groupId: string, node: Node) => void; newRule: () => QueryRule; createId: () => string; focusRef: React.RefObject; announce: (message: string) => void; onRemove?: () => void; }; function Group({ group, depth, maxDepth, fields, onChange, onAdd, newRule, createId, focusRef, announce, onRemove, }: GroupProps) { const id = useId(); const reduce = useReducedMotion(); const transition = reduce ? { duration: 0 } : { duration: 0.3, ease: EASE_EXPO }; const word = group.combinator === "and" ? "and" : "or"; return (
{depth === 1 ? "Conditions" : "Group"}: match{" "} {group.combinator === "and" ? "all" : "any"}
{ onChange(group.id, (g) => ({ ...(g as QueryGroup), combinator: v as "and" | "or", })); announce( v === "and" ? "Every condition must match." : "Any condition may match.", ); }} options={[ { value: "and", label: "All" }, { value: "or", label: "Any" }, ]} /> {onRemove && ( )}
    {group.rules.map((node, i) => ( {i > 0 && ( )} {isGroup(node) ? ( { onChange(node.id, () => null); announce("Group removed."); }} /> ) : ( onChange(node.id, () => next)} onRemove={() => { onChange(node.id, () => null); announce("Condition removed."); }} /> )} ))}
{!group.rules.length && (

No conditions — everything matches.

)}
{depth < maxDepth && ( )}
); } function Rule({ rule, index, fields, focusRef, onChange, onRemove, }: { rule: QueryRule; index: number; fields: readonly QueryField[]; focusRef: React.RefObject; onChange: (rule: QueryRule) => void; onRemove: () => void; }) { const id = useId(); const field = fields.find((f) => f.id === rule.field) ?? fields[0]!; const op = operatorOf(field, rule.operator) ?? OPERATORS[field.type][0]!; const complete = isComplete(rule, fields); const set = (value: QueryValue) => onChange({ ...rule, value }); const name = `condition ${index}`; const numeric = (v: string) => (v === "" ? undefined : Number(v)); const options = field.options ?? []; let input: ReactNode = null; if (op.arity === 2) { const pair = (Array.isArray(rule.value) ? rule.value : []) as number[]; input = ( set([numeric(e.target.value) ?? NaN, pair[1] ?? NaN] as [ number, number, ]) } /> set([pair[0] ?? NaN, numeric(e.target.value) ?? NaN] as [ number, number, ]) } /> {field.unit && {field.unit}} ); } else if (op.arity === "many") { const chosen = (Array.isArray(rule.value) ? rule.value : []) as string[]; input = options.length <= 8 ? ( {options.map((o) => ( ))} ) : ( ); } else if (op.arity === 1) { const text = typeof rule.value === "string" ? rule.value : typeof rule.value === "number" && Number.isFinite(rule.value) ? String(rule.value) : ""; input = field.type === "select" ? ( ) : field.type === "date" && op.id !== "last" ? ( set(e.target.value || undefined)} /> ) : field.type === "number" || op.id === "last" ? ( set(numeric(e.target.value))} /> {(field.unit || op.id === "last") && ( {op.id === "last" ? "days" : field.unit} )} ) : ( set(e.target.value)} /> ); } return (
{ if (el && focusRef.current === rule.id) { focusRef.current = null; el.querySelector("select")?.focus(); } }} > {input} {!complete && ( Needs a value — ignored until complete )}
); }