@multicomma/arkcn 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/bin/arkcn.mjs +78 -0
- package/cli/commands.mjs +251 -0
- package/cli/project.mjs +168 -0
- package/cli/registry.mjs +50 -0
- package/cli/ui.mjs +46 -0
- package/mcp/server.mjs +149 -0
- package/package.json +201 -0
- package/registry/base.css +680 -0
- package/registry/index.json +1833 -0
- package/registry/items/accordion.json +18 -0
- package/registry/items/activity-feed.json +18 -0
- package/registry/items/alert-dialog.json +18 -0
- package/registry/items/alert.json +17 -0
- package/registry/items/angle-slider.json +17 -0
- package/registry/items/app-shell.json +28 -0
- package/registry/items/aspect-ratio.json +13 -0
- package/registry/items/attachment.json +19 -0
- package/registry/items/avatar.json +17 -0
- package/registry/items/badge.json +18 -0
- package/registry/items/breadcrumb.json +18 -0
- package/registry/items/bubble.json +18 -0
- package/registry/items/button-group.json +19 -0
- package/registry/items/button.json +18 -0
- package/registry/items/calendar.json +19 -0
- package/registry/items/canvas.json +24 -0
- package/registry/items/card.json +15 -0
- package/registry/items/carousel.json +19 -0
- package/registry/items/cascader.json +23 -0
- package/registry/items/chart.json +17 -0
- package/registry/items/chat.json +23 -0
- package/registry/items/checkbox.json +18 -0
- package/registry/items/clipboard.json +19 -0
- package/registry/items/collapsible.json +15 -0
- package/registry/items/color-picker.json +19 -0
- package/registry/items/combobox.json +20 -0
- package/registry/items/command.json +21 -0
- package/registry/items/comment-thread.json +25 -0
- package/registry/items/context-menu.json +18 -0
- package/registry/items/controllable.json +14 -0
- package/registry/items/data-grid.json +24 -0
- package/registry/items/data-table-adapter.json +14 -0
- package/registry/items/data-table.json +32 -0
- package/registry/items/date-input.json +17 -0
- package/registry/items/dialog.json +19 -0
- package/registry/items/direction.json +13 -0
- package/registry/items/drag-preview.json +16 -0
- package/registry/items/drawer.json +18 -0
- package/registry/items/dropdown-menu.json +18 -0
- package/registry/items/editable.json +17 -0
- package/registry/items/empty.json +17 -0
- package/registry/items/field.json +19 -0
- package/registry/items/file-upload.json +19 -0
- package/registry/items/floating-panel.json +19 -0
- package/registry/items/floating-toolbar.json +16 -0
- package/registry/items/form.json +14 -0
- package/registry/items/format.json +15 -0
- package/registry/items/gantt.json +22 -0
- package/registry/items/history.json +14 -0
- package/registry/items/hotkeys.json +18 -0
- package/registry/items/hover-card.json +17 -0
- package/registry/items/image-cropper.json +17 -0
- package/registry/items/input-group.json +20 -0
- package/registry/items/input-otp.json +18 -0
- package/registry/items/input.json +15 -0
- package/registry/items/item.json +19 -0
- package/registry/items/json-tree-view.json +17 -0
- package/registry/items/kanban.json +26 -0
- package/registry/items/kbd.json +15 -0
- package/registry/items/label.json +15 -0
- package/registry/items/live-region.json +16 -0
- package/registry/items/marker.json +18 -0
- package/registry/items/marquee.json +17 -0
- package/registry/items/menubar.json +19 -0
- package/registry/items/message.json +15 -0
- package/registry/items/navigation-menu.json +19 -0
- package/registry/items/node-graph.json +22 -0
- package/registry/items/number-input.json +18 -0
- package/registry/items/pagination.json +18 -0
- package/registry/items/password-input.json +18 -0
- package/registry/items/popconfirm.json +20 -0
- package/registry/items/popover.json +18 -0
- package/registry/items/progress.json +17 -0
- package/registry/items/qr-code.json +17 -0
- package/registry/items/query-builder.json +23 -0
- package/registry/items/radio-group.json +17 -0
- package/registry/items/rating-group.json +18 -0
- package/registry/items/resizable.json +17 -0
- package/registry/items/rich-text-editor.json +37 -0
- package/registry/items/row-layout.json +15 -0
- package/registry/items/scheduler.json +23 -0
- package/registry/items/scroll-area.json +17 -0
- package/registry/items/segment-group.json +17 -0
- package/registry/items/select.json +19 -0
- package/registry/items/separator.json +15 -0
- package/registry/items/sheet.json +19 -0
- package/registry/items/sidebar.json +27 -0
- package/registry/items/signature-pad.json +19 -0
- package/registry/items/skeleton.json +15 -0
- package/registry/items/slider.json +17 -0
- package/registry/items/sonner.json +17 -0
- package/registry/items/spinner.json +17 -0
- package/registry/items/steps.json +19 -0
- package/registry/items/swap.json +17 -0
- package/registry/items/switch.json +17 -0
- package/registry/items/table.json +16 -0
- package/registry/items/tabs.json +18 -0
- package/registry/items/tags-input.json +18 -0
- package/registry/items/textarea.json +15 -0
- package/registry/items/time.json +14 -0
- package/registry/items/timer.json +18 -0
- package/registry/items/toc.json +17 -0
- package/registry/items/toggle-group.json +19 -0
- package/registry/items/toggle.json +18 -0
- package/registry/items/tooltip.json +17 -0
- package/registry/items/tour.json +19 -0
- package/registry/items/transfer-list.json +24 -0
- package/registry/items/tree-select.json +24 -0
- package/registry/items/tree-view.json +18 -0
- package/registry/items/use-mobile.json +13 -0
- package/registry/items/utils.json +17 -0
- package/registry/items/virtual-list.json +16 -0
- package/src/components/ui/accordion.tsx +111 -0
- package/src/components/ui/activity-feed.tsx +228 -0
- package/src/components/ui/alert-dialog.tsx +204 -0
- package/src/components/ui/alert.tsx +55 -0
- package/src/components/ui/angle-slider.tsx +117 -0
- package/src/components/ui/app-shell.tsx +985 -0
- package/src/components/ui/aspect-ratio.tsx +35 -0
- package/src/components/ui/attachment.tsx +193 -0
- package/src/components/ui/avatar.tsx +94 -0
- package/src/components/ui/badge.tsx +45 -0
- package/src/components/ui/breadcrumb.tsx +93 -0
- package/src/components/ui/bubble.tsx +119 -0
- package/src/components/ui/button-group.tsx +79 -0
- package/src/components/ui/button.tsx +65 -0
- package/src/components/ui/calendar.tsx +172 -0
- package/src/components/ui/canvas.tsx +723 -0
- package/src/components/ui/card.tsx +69 -0
- package/src/components/ui/carousel.tsx +228 -0
- package/src/components/ui/cascader.tsx +754 -0
- package/src/components/ui/chart.tsx +303 -0
- package/src/components/ui/chat.tsx +544 -0
- package/src/components/ui/checkbox.tsx +94 -0
- package/src/components/ui/clipboard.tsx +94 -0
- package/src/components/ui/collapsible.tsx +32 -0
- package/src/components/ui/color-picker.tsx +441 -0
- package/src/components/ui/combobox.tsx +400 -0
- package/src/components/ui/command.tsx +292 -0
- package/src/components/ui/comment-thread.tsx +923 -0
- package/src/components/ui/context-menu.tsx +331 -0
- package/src/components/ui/data-grid.tsx +1227 -0
- package/src/components/ui/data-table.tsx +993 -0
- package/src/components/ui/date-input.tsx +103 -0
- package/src/components/ui/dialog.tsx +159 -0
- package/src/components/ui/direction.tsx +32 -0
- package/src/components/ui/drawer.tsx +200 -0
- package/src/components/ui/dropdown-menu.tsx +329 -0
- package/src/components/ui/editable.tsx +90 -0
- package/src/components/ui/empty.tsx +86 -0
- package/src/components/ui/field.tsx +218 -0
- package/src/components/ui/file-upload.tsx +204 -0
- package/src/components/ui/floating-panel.tsx +199 -0
- package/src/components/ui/floating-toolbar.tsx +79 -0
- package/src/components/ui/format.tsx +22 -0
- package/src/components/ui/gantt.tsx +950 -0
- package/src/components/ui/hotkeys.tsx +340 -0
- package/src/components/ui/hover-card.tsx +83 -0
- package/src/components/ui/image-cropper.tsx +100 -0
- package/src/components/ui/input-group.tsx +133 -0
- package/src/components/ui/input-otp.tsx +82 -0
- package/src/components/ui/input.tsx +18 -0
- package/src/components/ui/item.tsx +174 -0
- package/src/components/ui/json-tree-view.tsx +37 -0
- package/src/components/ui/kanban.tsx +884 -0
- package/src/components/ui/kbd.tsx +20 -0
- package/src/components/ui/label.tsx +30 -0
- package/src/components/ui/live-region.tsx +54 -0
- package/src/components/ui/marker.tsx +66 -0
- package/src/components/ui/marquee.tsx +61 -0
- package/src/components/ui/menubar.tsx +456 -0
- package/src/components/ui/message.tsx +74 -0
- package/src/components/ui/navigation-menu.tsx +202 -0
- package/src/components/ui/node-graph.tsx +1723 -0
- package/src/components/ui/number-input.tsx +148 -0
- package/src/components/ui/pagination.tsx +88 -0
- package/src/components/ui/password-input.tsx +104 -0
- package/src/components/ui/popconfirm.tsx +149 -0
- package/src/components/ui/popover.tsx +126 -0
- package/src/components/ui/progress.tsx +114 -0
- package/src/components/ui/qr-code.tsx +51 -0
- package/src/components/ui/query-builder.tsx +938 -0
- package/src/components/ui/radio-group.tsx +108 -0
- package/src/components/ui/rating-group.tsx +91 -0
- package/src/components/ui/resizable.tsx +41 -0
- package/src/components/ui/rich-text-editor.tsx +845 -0
- package/src/components/ui/scheduler.tsx +1164 -0
- package/src/components/ui/scroll-area.tsx +82 -0
- package/src/components/ui/segment-group.tsx +95 -0
- package/src/components/ui/select.tsx +354 -0
- package/src/components/ui/separator.tsx +32 -0
- package/src/components/ui/sheet.tsx +130 -0
- package/src/components/ui/sidebar.tsx +665 -0
- package/src/components/ui/signature-pad.tsx +112 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/slider.tsx +183 -0
- package/src/components/ui/sonner.tsx +37 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/steps.tsx +152 -0
- package/src/components/ui/swap.tsx +33 -0
- package/src/components/ui/switch.tsx +83 -0
- package/src/components/ui/table.tsx +72 -0
- package/src/components/ui/tabs.tsx +100 -0
- package/src/components/ui/tags-input.tsx +153 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/timer.tsx +67 -0
- package/src/components/ui/toc.tsx +64 -0
- package/src/components/ui/toggle-group.tsx +89 -0
- package/src/components/ui/toggle.tsx +46 -0
- package/src/components/ui/tooltip.tsx +113 -0
- package/src/components/ui/tour.tsx +168 -0
- package/src/components/ui/transfer-list.tsx +700 -0
- package/src/components/ui/tree-select.tsx +638 -0
- package/src/components/ui/tree-view.tsx +217 -0
- package/src/components/ui/virtual-list.tsx +343 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/lib/controllable.ts +31 -0
- package/src/lib/data-table-adapter.ts +67 -0
- package/src/lib/drag-preview.ts +39 -0
- package/src/lib/form.ts +39 -0
- package/src/lib/history.ts +55 -0
- package/src/lib/row-layout.ts +81 -0
- package/src/lib/table.ts +483 -0
- package/src/lib/time.ts +37 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/arkcn.css +764 -0
- package/src/styles/index.css +3 -0
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { ark } from "@ark-ui/react"
|
|
3
|
+
import { createListCollection } from "@ark-ui/react/collection"
|
|
4
|
+
import { PlusIcon, XIcon } from "lucide-react"
|
|
5
|
+
import { Button } from "@/components/ui/button"
|
|
6
|
+
import { Input } from "@/components/ui/input"
|
|
7
|
+
import {
|
|
8
|
+
Select,
|
|
9
|
+
SelectContent,
|
|
10
|
+
SelectControl,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectItemIndicator,
|
|
13
|
+
SelectItemText,
|
|
14
|
+
SelectTrigger,
|
|
15
|
+
SelectValue,
|
|
16
|
+
} from "@/components/ui/select"
|
|
17
|
+
import { cn } from "@/lib/utils"
|
|
18
|
+
import { useControllable } from "@/lib/controllable"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* QueryBuilder — a compositional filter builder that reads like a sentence.
|
|
22
|
+
*
|
|
23
|
+
* Every group opens with "Match all / any of the following" and each condition
|
|
24
|
+
* reads field → plain-language operator → value. Incomplete conditions (no
|
|
25
|
+
* value yet) are flagged with `data-incomplete` and dropped by `pruneQuery`
|
|
26
|
+
* instead of erroring, negation lives in the operator wording ("is not",
|
|
27
|
+
* "does not contain") and order is irrelevant, so there is no NOT toggle and
|
|
28
|
+
* no drag-reorder to learn.
|
|
29
|
+
*
|
|
30
|
+
* The consumer owns the query tree (`value`/`onValueChange`) and renders the
|
|
31
|
+
* parts recursively; the root applies all edits (add, remove, update). The
|
|
32
|
+
* primitive never interprets the tree: turning it into SQL, an API filter, or
|
|
33
|
+
* an in-memory predicate is the consumer's job (see `src/demo/query-builder`).
|
|
34
|
+
*
|
|
35
|
+
* Anatomy:
|
|
36
|
+
* QueryBuilder
|
|
37
|
+
* QueryBuilderGroup { group }
|
|
38
|
+
* QueryBuilderGroupHeader
|
|
39
|
+
* QueryBuilderMatch "all" | "any" select
|
|
40
|
+
* QueryBuilderRemoveTrigger (nested groups)
|
|
41
|
+
* QueryBuilderGroupBody
|
|
42
|
+
* QueryBuilderRule { rule } → FieldSelect + OperatorSelect + ValueEditor + RuleActions
|
|
43
|
+
* QueryBuilderGroup (nested)
|
|
44
|
+
* QueryBuilderGroupFooter
|
|
45
|
+
* QueryBuilderAddRuleTrigger / QueryBuilderAddGroupTrigger
|
|
46
|
+
* QueryBuilderSummary natural-language sentence
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/* ---------------------------------------------------------------------------
|
|
50
|
+
* Model
|
|
51
|
+
* ------------------------------------------------------------------------- */
|
|
52
|
+
|
|
53
|
+
type QueryFieldType = "text" | "number" | "date" | "boolean" | "select"
|
|
54
|
+
type QueryOption = { value: string; label: string }
|
|
55
|
+
type QueryField = {
|
|
56
|
+
name: string
|
|
57
|
+
label: string
|
|
58
|
+
type?: QueryFieldType
|
|
59
|
+
/** For `select` fields. */
|
|
60
|
+
options?: QueryOption[]
|
|
61
|
+
/** Restrict the operators offered (names from `operators`). */
|
|
62
|
+
operators?: string[]
|
|
63
|
+
/** Placeholder for the value editor. */
|
|
64
|
+
placeholder?: string
|
|
65
|
+
}
|
|
66
|
+
/** How many values an operator takes. */
|
|
67
|
+
type QueryArity = "none" | "one" | "two" | "many"
|
|
68
|
+
type QueryOperator = { name: string; label: string; arity: QueryArity; types: QueryFieldType[] }
|
|
69
|
+
|
|
70
|
+
type QueryRule = { id: string; field: string; operator: string; value?: unknown }
|
|
71
|
+
type QueryGroup = { id: string; match: "all" | "any"; rules: QueryNode[] }
|
|
72
|
+
type QueryNode = QueryRule | QueryGroup
|
|
73
|
+
|
|
74
|
+
const isGroup = (node: QueryNode): node is QueryGroup => "rules" in node
|
|
75
|
+
|
|
76
|
+
let seq = 0
|
|
77
|
+
const uid = (prefix: string) => `${prefix}-${(seq++).toString(36)}${Date.now().toString(36).slice(-3)}`
|
|
78
|
+
|
|
79
|
+
const op = (name: string, label: string, arity: QueryArity, types: QueryFieldType[]): QueryOperator => ({
|
|
80
|
+
name,
|
|
81
|
+
label,
|
|
82
|
+
arity,
|
|
83
|
+
types,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
/** Plain-language operators; negation is part of the wording. */
|
|
87
|
+
const defaultOperators: QueryOperator[] = [
|
|
88
|
+
op("is", "is", "one", ["text", "number", "date", "select"]),
|
|
89
|
+
op("isNot", "is not", "one", ["text", "number", "date", "select"]),
|
|
90
|
+
op("contains", "contains", "one", ["text"]),
|
|
91
|
+
op("doesNotContain", "does not contain", "one", ["text"]),
|
|
92
|
+
op("startsWith", "starts with", "one", ["text"]),
|
|
93
|
+
op("endsWith", "ends with", "one", ["text"]),
|
|
94
|
+
op("greaterThan", "is greater than", "one", ["number"]),
|
|
95
|
+
op("lessThan", "is less than", "one", ["number"]),
|
|
96
|
+
op("atLeast", "is at least", "one", ["number"]),
|
|
97
|
+
op("atMost", "is at most", "one", ["number"]),
|
|
98
|
+
op("before", "is before", "one", ["date"]),
|
|
99
|
+
op("after", "is after", "one", ["date"]),
|
|
100
|
+
op("between", "is between", "two", ["number", "date"]),
|
|
101
|
+
op("anyOf", "is any of", "many", ["select", "text"]),
|
|
102
|
+
op("noneOf", "is none of", "many", ["select", "text"]),
|
|
103
|
+
op("isTrue", "is true", "none", ["boolean"]),
|
|
104
|
+
op("isFalse", "is false", "none", ["boolean"]),
|
|
105
|
+
op("isEmpty", "is empty", "none", ["text", "number", "date", "select"]),
|
|
106
|
+
op("isNotEmpty", "is not empty", "none", ["text", "number", "date", "select"]),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
function operatorsFor(field: QueryField | undefined, operators: QueryOperator[]) {
|
|
110
|
+
if (!field) return []
|
|
111
|
+
const type = field.type ?? "text"
|
|
112
|
+
const allowed = operators.filter((o) => o.types.includes(type))
|
|
113
|
+
return field.operators
|
|
114
|
+
? (field.operators.map((n) => allowed.find((o) => o.name === n)).filter(Boolean) as QueryOperator[])
|
|
115
|
+
: allowed
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function createRule(fields: QueryField[], operators = defaultOperators, patch?: Partial<QueryRule>): QueryRule {
|
|
119
|
+
const field = fields.find((f) => f.name === patch?.field) ?? fields[0]
|
|
120
|
+
const operator = patch?.operator ?? operatorsFor(field, operators)[0]?.name ?? ""
|
|
121
|
+
return { id: uid("rule"), field: field?.name ?? "", operator, value: undefined, ...patch }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function createGroup(match: QueryGroup["match"] = "all", rules: QueryNode[] = []): QueryGroup {
|
|
125
|
+
return { id: uid("group"), match, rules }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const hasValue = (value: unknown) =>
|
|
129
|
+
value !== undefined && value !== null && value !== "" && !(Array.isArray(value) && value.length === 0)
|
|
130
|
+
|
|
131
|
+
/** A rule counts once it has everything its operator needs. */
|
|
132
|
+
function isComplete(rule: QueryRule, operators = defaultOperators) {
|
|
133
|
+
const operator = operators.find((o) => o.name === rule.operator)
|
|
134
|
+
if (!operator || !rule.field) return false
|
|
135
|
+
if (operator.arity === "none") return true
|
|
136
|
+
if (operator.arity === "two") return Array.isArray(rule.value) && hasValue(rule.value[0]) && hasValue(rule.value[1])
|
|
137
|
+
return hasValue(rule.value)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* Tree edits (pure) */
|
|
141
|
+
|
|
142
|
+
function mapTree(node: QueryGroup, fn: (node: QueryNode) => QueryNode | null): QueryGroup {
|
|
143
|
+
const rules = node.rules
|
|
144
|
+
.map((child) => {
|
|
145
|
+
const next = fn(child)
|
|
146
|
+
if (next === null) return null
|
|
147
|
+
return isGroup(next) ? mapTree(next, fn) : next
|
|
148
|
+
})
|
|
149
|
+
.filter((n): n is QueryNode => n !== null)
|
|
150
|
+
return { ...node, rules }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function updateNode(root: QueryGroup, id: string, patch: Partial<QueryRule> | Partial<QueryGroup>): QueryGroup {
|
|
154
|
+
if (root.id === id) return { ...root, ...(patch as Partial<QueryGroup>) }
|
|
155
|
+
return mapTree(root, (n) => (n.id === id ? ({ ...n, ...patch } as QueryNode) : n))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function removeNode(root: QueryGroup, id: string): QueryGroup {
|
|
159
|
+
return mapTree(root, (n) => (n.id === id ? null : n))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function insertNode(root: QueryGroup, groupId: string, node: QueryNode): QueryGroup {
|
|
163
|
+
if (root.id === groupId) return { ...root, rules: [...root.rules, node] }
|
|
164
|
+
return mapTree(root, (n) => (isGroup(n) && n.id === groupId ? { ...n, rules: [...n.rules, node] } : n))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ---------------------------------------------------------------------------
|
|
168
|
+
* Describing the tree (presentation only; interpreting it is the consumer's job)
|
|
169
|
+
* ------------------------------------------------------------------------- */
|
|
170
|
+
|
|
171
|
+
type DescribeOptions = { fields: QueryField[]; operators?: QueryOperator[] }
|
|
172
|
+
|
|
173
|
+
function valueLabel(rule: QueryRule, field: QueryField | undefined): string {
|
|
174
|
+
const label = (v: unknown) => field?.options?.find((o) => o.value === String(v))?.label ?? String(v)
|
|
175
|
+
if (Array.isArray(rule.value)) {
|
|
176
|
+
const values = rule.value.map(label)
|
|
177
|
+
if (rule.operator === "between") return `${values[0]} and ${values[1]}`
|
|
178
|
+
if (values.length <= 3) return values.join(", ")
|
|
179
|
+
return `${values.slice(0, 2).join(", ")} and ${values.length - 2} more`
|
|
180
|
+
}
|
|
181
|
+
return label(rule.value)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Natural-language sentence, e.g. "Status is Active and Created is after 2026-01-01". */
|
|
185
|
+
function describeQuery(
|
|
186
|
+
group: QueryGroup,
|
|
187
|
+
{ fields, operators = defaultOperators }: DescribeOptions,
|
|
188
|
+
depth = 0
|
|
189
|
+
): string {
|
|
190
|
+
const parts = group.rules
|
|
191
|
+
.map((n) => {
|
|
192
|
+
if (isGroup(n)) return describeQuery(n, { fields, operators }, depth + 1)
|
|
193
|
+
if (!isComplete(n, operators)) return ""
|
|
194
|
+
const field = fields.find((f) => f.name === n.field)
|
|
195
|
+
const operator = operators.find((o) => o.name === n.operator)
|
|
196
|
+
const value = operator?.arity === "none" ? "" : ` ${valueLabel(n, field)}`
|
|
197
|
+
return `${field?.label ?? n.field} ${operator?.label ?? n.operator}${value}`
|
|
198
|
+
})
|
|
199
|
+
.filter(Boolean)
|
|
200
|
+
if (!parts.length) return ""
|
|
201
|
+
const joined = parts.join(group.match === "all" ? " and " : " or ")
|
|
202
|
+
return depth > 0 && parts.length > 1 ? `(${joined})` : joined
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The tree without incomplete rules and empty groups: what an interpreter should consume. */
|
|
206
|
+
function pruneQuery(group: QueryGroup, operators = defaultOperators): QueryGroup {
|
|
207
|
+
const rules = group.rules
|
|
208
|
+
.map((n) => (isGroup(n) ? pruneQuery(n, operators) : isComplete(n, operators) ? n : null))
|
|
209
|
+
.filter((n): n is QueryNode => n !== null && (!isGroup(n) || n.rules.length > 0))
|
|
210
|
+
return { ...group, rules }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* ---------------------------------------------------------------------------
|
|
214
|
+
* Context
|
|
215
|
+
* ------------------------------------------------------------------------- */
|
|
216
|
+
|
|
217
|
+
type QueryBuilderContextValue = {
|
|
218
|
+
fields: QueryField[]
|
|
219
|
+
operators: QueryOperator[]
|
|
220
|
+
value: QueryGroup
|
|
221
|
+
disabled: boolean
|
|
222
|
+
maxDepth: number
|
|
223
|
+
addRule: (groupId: string, patch?: Partial<QueryRule>) => void
|
|
224
|
+
addGroup: (groupId: string) => void
|
|
225
|
+
remove: (id: string) => void
|
|
226
|
+
updateRule: (id: string, patch: Partial<QueryRule>) => void
|
|
227
|
+
setMatch: (groupId: string, match: QueryGroup["match"]) => void
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const QueryBuilderContext = React.createContext<QueryBuilderContextValue | null>(null)
|
|
231
|
+
const GroupContext = React.createContext<{ group: QueryGroup; depth: number } | null>(null)
|
|
232
|
+
const RuleContext = React.createContext<{ rule: QueryRule } | null>(null)
|
|
233
|
+
|
|
234
|
+
function useQueryBuilder() {
|
|
235
|
+
const ctx = React.useContext(QueryBuilderContext)
|
|
236
|
+
if (!ctx) throw new Error("QueryBuilder parts must be used within <QueryBuilder>")
|
|
237
|
+
return ctx
|
|
238
|
+
}
|
|
239
|
+
function useQueryBuilderGroup() {
|
|
240
|
+
const ctx = React.useContext(GroupContext)
|
|
241
|
+
if (!ctx) throw new Error("Group parts must be used within <QueryBuilderGroup>")
|
|
242
|
+
return ctx
|
|
243
|
+
}
|
|
244
|
+
function useQueryBuilderRule() {
|
|
245
|
+
const ctx = React.useContext(RuleContext)
|
|
246
|
+
if (!ctx) throw new Error("Rule parts must be used within <QueryBuilderRule>")
|
|
247
|
+
return ctx
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* ---------------------------------------------------------------------------
|
|
251
|
+
* Root
|
|
252
|
+
* ------------------------------------------------------------------------- */
|
|
253
|
+
|
|
254
|
+
function QueryBuilder({
|
|
255
|
+
fields,
|
|
256
|
+
operators = defaultOperators,
|
|
257
|
+
value: valueProp,
|
|
258
|
+
defaultValue,
|
|
259
|
+
onValueChange,
|
|
260
|
+
disabled = false,
|
|
261
|
+
maxDepth = 3,
|
|
262
|
+
className,
|
|
263
|
+
children,
|
|
264
|
+
...props
|
|
265
|
+
}: Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
|
|
266
|
+
fields: QueryField[]
|
|
267
|
+
operators?: QueryOperator[]
|
|
268
|
+
value?: QueryGroup
|
|
269
|
+
defaultValue?: QueryGroup
|
|
270
|
+
onValueChange?: (value: QueryGroup) => void
|
|
271
|
+
disabled?: boolean
|
|
272
|
+
/** Nesting limit for "Add group"; the trigger hides past it. */
|
|
273
|
+
maxDepth?: number
|
|
274
|
+
}) {
|
|
275
|
+
const [initial] = React.useState<QueryGroup>(
|
|
276
|
+
() => defaultValue ?? createGroup("all", [createRule(fields, operators)])
|
|
277
|
+
)
|
|
278
|
+
const [value, setValue] = useControllable<QueryGroup>(valueProp, initial, onValueChange)
|
|
279
|
+
const update = React.useCallback((fn: (prev: QueryGroup) => QueryGroup) => setValue(fn), [setValue])
|
|
280
|
+
|
|
281
|
+
const ctx = React.useMemo<QueryBuilderContextValue>(
|
|
282
|
+
() => ({
|
|
283
|
+
fields,
|
|
284
|
+
operators,
|
|
285
|
+
value,
|
|
286
|
+
disabled,
|
|
287
|
+
maxDepth,
|
|
288
|
+
addRule: (groupId, patch) => update((prev) => insertNode(prev, groupId, createRule(fields, operators, patch))),
|
|
289
|
+
addGroup: (groupId) =>
|
|
290
|
+
update((prev) => {
|
|
291
|
+
// A nested group starts with the opposite match so it adds meaning ("…and any of these").
|
|
292
|
+
const match = groupIn(prev, groupId)?.match === "any" ? "all" : "any"
|
|
293
|
+
return insertNode(prev, groupId, createGroup(match, [createRule(fields, operators)]))
|
|
294
|
+
}),
|
|
295
|
+
remove: (id) => update((prev) => removeNode(prev, id)),
|
|
296
|
+
updateRule: (id, patch) =>
|
|
297
|
+
update((prev) => {
|
|
298
|
+
if (patch.field !== undefined) {
|
|
299
|
+
// Changing the field resets the operator to the first valid one and clears the value.
|
|
300
|
+
const field = fields.find((f) => f.name === patch.field)
|
|
301
|
+
const current = operators.find((o) => o.name === patch.operator)
|
|
302
|
+
const options = operatorsFor(field, operators)
|
|
303
|
+
const operator = current && options.includes(current) ? current.name : (options[0]?.name ?? "")
|
|
304
|
+
return updateNode(prev, id, { field: patch.field, operator, value: undefined })
|
|
305
|
+
}
|
|
306
|
+
if (patch.operator !== undefined && patch.value === undefined) {
|
|
307
|
+
const prevRule = findRule(prev, id)
|
|
308
|
+
const before = operators.find((o) => o.name === prevRule?.operator)?.arity
|
|
309
|
+
const after = operators.find((o) => o.name === patch.operator)?.arity
|
|
310
|
+
return updateNode(prev, id, {
|
|
311
|
+
operator: patch.operator,
|
|
312
|
+
value: before === after ? prevRule?.value : undefined,
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
return updateNode(prev, id, patch)
|
|
316
|
+
}),
|
|
317
|
+
setMatch: (groupId, match) => update((prev) => updateNode(prev, groupId, { match })),
|
|
318
|
+
}),
|
|
319
|
+
[fields, operators, value, disabled, maxDepth, update]
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<QueryBuilderContext.Provider value={ctx}>
|
|
324
|
+
<div
|
|
325
|
+
data-slot="query-builder"
|
|
326
|
+
data-disabled={disabled ? "" : undefined}
|
|
327
|
+
className={cn("flex flex-col gap-3 text-sm", className)}
|
|
328
|
+
{...props}
|
|
329
|
+
>
|
|
330
|
+
{children}
|
|
331
|
+
</div>
|
|
332
|
+
</QueryBuilderContext.Provider>
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function groupIn(root: QueryGroup, id: string): QueryGroup | null {
|
|
337
|
+
if (root.id === id) return root
|
|
338
|
+
for (const child of root.rules) {
|
|
339
|
+
if (isGroup(child)) {
|
|
340
|
+
const found = groupIn(child, id)
|
|
341
|
+
if (found) return found
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return null
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function findRule(root: QueryGroup, id: string): QueryRule | null {
|
|
348
|
+
for (const child of root.rules) {
|
|
349
|
+
if (isGroup(child)) {
|
|
350
|
+
const found = findRule(child, id)
|
|
351
|
+
if (found) return found
|
|
352
|
+
} else if (child.id === id) return child
|
|
353
|
+
}
|
|
354
|
+
return null
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* ---------------------------------------------------------------------------
|
|
358
|
+
* Group parts
|
|
359
|
+
* ------------------------------------------------------------------------- */
|
|
360
|
+
|
|
361
|
+
function QueryBuilderGroup({
|
|
362
|
+
group,
|
|
363
|
+
className,
|
|
364
|
+
children,
|
|
365
|
+
...props
|
|
366
|
+
}: React.ComponentProps<"div"> & { group: QueryGroup }) {
|
|
367
|
+
const parent = React.useContext(GroupContext)
|
|
368
|
+
const depth = parent ? parent.depth + 1 : 0
|
|
369
|
+
const ctx = React.useMemo(() => ({ group, depth }), [group, depth])
|
|
370
|
+
return (
|
|
371
|
+
<GroupContext.Provider value={ctx}>
|
|
372
|
+
<div
|
|
373
|
+
data-slot="query-builder-group"
|
|
374
|
+
data-match={group.match}
|
|
375
|
+
data-depth={depth}
|
|
376
|
+
data-empty={group.rules.length === 0 ? "" : undefined}
|
|
377
|
+
className={cn(
|
|
378
|
+
"group/query-group flex flex-col gap-2",
|
|
379
|
+
depth > 0 && "rounded-lg border border-dashed bg-muted/30 p-3",
|
|
380
|
+
className
|
|
381
|
+
)}
|
|
382
|
+
{...props}
|
|
383
|
+
>
|
|
384
|
+
{children}
|
|
385
|
+
</div>
|
|
386
|
+
</GroupContext.Provider>
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function QueryBuilderGroupHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
391
|
+
return (
|
|
392
|
+
<div
|
|
393
|
+
data-slot="query-builder-group-header"
|
|
394
|
+
className={cn("flex flex-wrap items-center gap-2", className)}
|
|
395
|
+
{...props}
|
|
396
|
+
/>
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const matchOptions: QueryOption[] = [
|
|
401
|
+
{ value: "all", label: "all" },
|
|
402
|
+
{ value: "any", label: "any" },
|
|
403
|
+
]
|
|
404
|
+
const matchCollection = createListCollection({ items: matchOptions, itemToValue: (o) => o.value })
|
|
405
|
+
|
|
406
|
+
/** "Match [all ▾] of the following" — the only combinator control, once per group. */
|
|
407
|
+
function QueryBuilderMatch({
|
|
408
|
+
className,
|
|
409
|
+
before,
|
|
410
|
+
after = "of the following",
|
|
411
|
+
...props
|
|
412
|
+
}: Omit<React.ComponentProps<typeof Select>, "collection" | "value" | "onValueChange"> & {
|
|
413
|
+
/** Text before the select. Defaults to "Match" at the top level and "Where" in nested groups. */
|
|
414
|
+
before?: React.ReactNode
|
|
415
|
+
after?: React.ReactNode
|
|
416
|
+
}) {
|
|
417
|
+
const { setMatch, disabled } = useQueryBuilder()
|
|
418
|
+
const { group, depth } = useQueryBuilderGroup()
|
|
419
|
+
return (
|
|
420
|
+
<span data-slot="query-builder-match" className="inline-flex flex-wrap items-center gap-1.5">
|
|
421
|
+
<span className="text-muted-foreground">{before ?? (depth === 0 ? "Match" : "Where")}</span>
|
|
422
|
+
<Select
|
|
423
|
+
collection={matchCollection}
|
|
424
|
+
value={[group.match]}
|
|
425
|
+
onValueChange={({ value }) => value[0] && setMatch(group.id, value[0] as QueryGroup["match"])}
|
|
426
|
+
disabled={disabled}
|
|
427
|
+
positioning={{ sameWidth: false }}
|
|
428
|
+
{...props}
|
|
429
|
+
>
|
|
430
|
+
<SelectControl>
|
|
431
|
+
<SelectTrigger size="sm" className={cn("font-medium", className)} aria-label="Match all or any">
|
|
432
|
+
<SelectValue>{group.match}</SelectValue>
|
|
433
|
+
</SelectTrigger>
|
|
434
|
+
</SelectControl>
|
|
435
|
+
<SelectContent className="min-w-24">
|
|
436
|
+
{matchOptions.map((o) => (
|
|
437
|
+
<SelectItem key={o.value} item={o}>
|
|
438
|
+
<SelectItemText>{o.label}</SelectItemText>
|
|
439
|
+
<SelectItemIndicator />
|
|
440
|
+
</SelectItem>
|
|
441
|
+
))}
|
|
442
|
+
</SelectContent>
|
|
443
|
+
</Select>
|
|
444
|
+
<span className="text-muted-foreground">{after}</span>
|
|
445
|
+
</span>
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Indented list of conditions with a guide line. */
|
|
450
|
+
function QueryBuilderGroupBody({ className, ...props }: React.ComponentProps<"div">) {
|
|
451
|
+
const { group } = useQueryBuilderGroup()
|
|
452
|
+
return (
|
|
453
|
+
<div
|
|
454
|
+
data-slot="query-builder-group-body"
|
|
455
|
+
data-empty={group.rules.length === 0 ? "" : undefined}
|
|
456
|
+
className={cn(
|
|
457
|
+
"relative flex flex-col gap-2 ps-4 before:absolute before:inset-y-1 before:inset-s-1 before:w-px before:bg-border",
|
|
458
|
+
className
|
|
459
|
+
)}
|
|
460
|
+
{...props}
|
|
461
|
+
/>
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function QueryBuilderGroupFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
466
|
+
return (
|
|
467
|
+
<div
|
|
468
|
+
data-slot="query-builder-group-footer"
|
|
469
|
+
className={cn("flex flex-wrap items-center gap-1 ps-4", className)}
|
|
470
|
+
{...props}
|
|
471
|
+
/>
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/* ---------------------------------------------------------------------------
|
|
476
|
+
* Rule parts
|
|
477
|
+
* ------------------------------------------------------------------------- */
|
|
478
|
+
|
|
479
|
+
function QueryBuilderRule({ rule, className, children, ...props }: React.ComponentProps<"div"> & { rule: QueryRule }) {
|
|
480
|
+
const { operators } = useQueryBuilder()
|
|
481
|
+
const complete = isComplete(rule, operators)
|
|
482
|
+
const ctx = React.useMemo(() => ({ rule }), [rule])
|
|
483
|
+
return (
|
|
484
|
+
<RuleContext.Provider value={ctx}>
|
|
485
|
+
<div
|
|
486
|
+
data-slot="query-builder-rule"
|
|
487
|
+
data-incomplete={complete ? undefined : ""}
|
|
488
|
+
className={cn(
|
|
489
|
+
"group/query-rule -ms-1 flex flex-wrap items-center gap-1.5 rounded-lg py-0.5 ps-1 pe-1 hover:bg-muted/40 has-focus-visible:bg-muted/40",
|
|
490
|
+
className
|
|
491
|
+
)}
|
|
492
|
+
{...props}
|
|
493
|
+
>
|
|
494
|
+
{children}
|
|
495
|
+
</div>
|
|
496
|
+
</RuleContext.Provider>
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
type PickerProps = Omit<React.ComponentProps<typeof Select>, "collection" | "value" | "onValueChange">
|
|
501
|
+
|
|
502
|
+
function QueryBuilderFieldSelect({ className, ...props }: PickerProps & { className?: string }) {
|
|
503
|
+
const { fields, updateRule, disabled } = useQueryBuilder()
|
|
504
|
+
const { rule } = useQueryBuilderRule()
|
|
505
|
+
const collection = React.useMemo(
|
|
506
|
+
() => createListCollection({ items: fields, itemToValue: (f) => f.name, itemToString: (f) => f.label }),
|
|
507
|
+
[fields]
|
|
508
|
+
)
|
|
509
|
+
const field = fields.find((f) => f.name === rule.field)
|
|
510
|
+
return (
|
|
511
|
+
<Select
|
|
512
|
+
collection={collection}
|
|
513
|
+
value={[rule.field]}
|
|
514
|
+
onValueChange={({ value }) => value[0] && updateRule(rule.id, { field: value[0] })}
|
|
515
|
+
disabled={disabled}
|
|
516
|
+
positioning={{ sameWidth: false }}
|
|
517
|
+
{...props}
|
|
518
|
+
>
|
|
519
|
+
<SelectControl>
|
|
520
|
+
<SelectTrigger
|
|
521
|
+
size="sm"
|
|
522
|
+
className={cn("font-medium", className)}
|
|
523
|
+
data-slot="query-builder-field-select"
|
|
524
|
+
aria-label="Field"
|
|
525
|
+
>
|
|
526
|
+
<SelectValue placeholder="Field">{field?.label}</SelectValue>
|
|
527
|
+
</SelectTrigger>
|
|
528
|
+
</SelectControl>
|
|
529
|
+
<SelectContent>
|
|
530
|
+
{fields.map((f) => (
|
|
531
|
+
<SelectItem key={f.name} item={f}>
|
|
532
|
+
<SelectItemText>{f.label}</SelectItemText>
|
|
533
|
+
<SelectItemIndicator />
|
|
534
|
+
</SelectItem>
|
|
535
|
+
))}
|
|
536
|
+
</SelectContent>
|
|
537
|
+
</Select>
|
|
538
|
+
)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function QueryBuilderOperatorSelect({ className, ...props }: PickerProps & { className?: string }) {
|
|
542
|
+
const { fields, operators, updateRule, disabled } = useQueryBuilder()
|
|
543
|
+
const { rule } = useQueryBuilderRule()
|
|
544
|
+
const field = fields.find((f) => f.name === rule.field)
|
|
545
|
+
const options = React.useMemo(() => operatorsFor(field, operators), [field, operators])
|
|
546
|
+
const collection = React.useMemo(
|
|
547
|
+
() => createListCollection({ items: options, itemToValue: (o) => o.name, itemToString: (o) => o.label }),
|
|
548
|
+
[options]
|
|
549
|
+
)
|
|
550
|
+
const current = options.find((o) => o.name === rule.operator)
|
|
551
|
+
return (
|
|
552
|
+
<Select
|
|
553
|
+
collection={collection}
|
|
554
|
+
value={[rule.operator]}
|
|
555
|
+
onValueChange={({ value }) => value[0] && updateRule(rule.id, { operator: value[0] })}
|
|
556
|
+
disabled={disabled}
|
|
557
|
+
positioning={{ sameWidth: false }}
|
|
558
|
+
{...props}
|
|
559
|
+
>
|
|
560
|
+
<SelectControl>
|
|
561
|
+
<SelectTrigger
|
|
562
|
+
size="sm"
|
|
563
|
+
className={cn("text-muted-foreground", className)}
|
|
564
|
+
data-slot="query-builder-operator-select"
|
|
565
|
+
aria-label="Operator"
|
|
566
|
+
>
|
|
567
|
+
<SelectValue placeholder="Operator">{current?.label}</SelectValue>
|
|
568
|
+
</SelectTrigger>
|
|
569
|
+
</SelectControl>
|
|
570
|
+
<SelectContent>
|
|
571
|
+
{options.map((o) => (
|
|
572
|
+
<SelectItem key={o.name} item={o}>
|
|
573
|
+
<SelectItemText>{o.label}</SelectItemText>
|
|
574
|
+
<SelectItemIndicator />
|
|
575
|
+
</SelectItem>
|
|
576
|
+
))}
|
|
577
|
+
</SelectContent>
|
|
578
|
+
</Select>
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
type ValueEditorRenderProps = {
|
|
583
|
+
rule: QueryRule
|
|
584
|
+
field: QueryField | undefined
|
|
585
|
+
operator: QueryOperator | undefined
|
|
586
|
+
value: unknown
|
|
587
|
+
setValue: (value: unknown) => void
|
|
588
|
+
disabled: boolean
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Renders an editor for the rule's value based on the field type and operator
|
|
593
|
+
* arity. Pass a render-prop child to take over for specific cases and fall back
|
|
594
|
+
* to the default by returning `undefined`.
|
|
595
|
+
*/
|
|
596
|
+
function QueryBuilderValueEditor({
|
|
597
|
+
className,
|
|
598
|
+
children,
|
|
599
|
+
...props
|
|
600
|
+
}: Omit<React.ComponentProps<"div">, "children"> & {
|
|
601
|
+
children?: (props: ValueEditorRenderProps) => React.ReactNode | undefined
|
|
602
|
+
}) {
|
|
603
|
+
const { fields, operators, updateRule, disabled } = useQueryBuilder()
|
|
604
|
+
const { rule } = useQueryBuilderRule()
|
|
605
|
+
const field = fields.find((f) => f.name === rule.field)
|
|
606
|
+
const operator = operators.find((o) => o.name === rule.operator)
|
|
607
|
+
const setValue = React.useCallback((value: unknown) => updateRule(rule.id, { value }), [updateRule, rule.id])
|
|
608
|
+
const custom = children?.({ rule, field, operator, value: rule.value, setValue, disabled })
|
|
609
|
+
if (custom !== undefined) return <>{custom}</>
|
|
610
|
+
if (!operator || operator.arity === "none") return null
|
|
611
|
+
const type = field?.type ?? "text"
|
|
612
|
+
const inputType = type === "number" ? "number" : type === "date" ? "date" : "text"
|
|
613
|
+
const inputClass = cn("h-7 w-40 text-sm md:text-sm", type === "number" && "w-24", type === "date" && "w-36")
|
|
614
|
+
|
|
615
|
+
if (operator.arity === "two") {
|
|
616
|
+
const pair = Array.isArray(rule.value) ? (rule.value as unknown[]) : [undefined, undefined]
|
|
617
|
+
const set = (index: 0 | 1, v: string) => {
|
|
618
|
+
const next = [...pair]
|
|
619
|
+
next[index] = v
|
|
620
|
+
setValue(next)
|
|
621
|
+
}
|
|
622
|
+
return (
|
|
623
|
+
<div
|
|
624
|
+
data-slot="query-builder-value-editor"
|
|
625
|
+
className={cn("inline-flex items-center gap-1.5", className)}
|
|
626
|
+
{...props}
|
|
627
|
+
>
|
|
628
|
+
<Input
|
|
629
|
+
type={inputType}
|
|
630
|
+
value={String(pair[0] ?? "")}
|
|
631
|
+
onChange={(e) => set(0, e.target.value)}
|
|
632
|
+
disabled={disabled}
|
|
633
|
+
aria-label="From"
|
|
634
|
+
className={inputClass}
|
|
635
|
+
/>
|
|
636
|
+
<span className="text-muted-foreground">and</span>
|
|
637
|
+
<Input
|
|
638
|
+
type={inputType}
|
|
639
|
+
value={String(pair[1] ?? "")}
|
|
640
|
+
onChange={(e) => set(1, e.target.value)}
|
|
641
|
+
disabled={disabled}
|
|
642
|
+
aria-label="To"
|
|
643
|
+
className={inputClass}
|
|
644
|
+
/>
|
|
645
|
+
</div>
|
|
646
|
+
)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (type === "select" && field?.options) {
|
|
650
|
+
const many = operator.arity === "many"
|
|
651
|
+
const selected = many ? ((rule.value as string[] | undefined) ?? []) : rule.value ? [String(rule.value)] : []
|
|
652
|
+
return (
|
|
653
|
+
<div data-slot="query-builder-value-editor" className={cn("inline-flex", className)} {...props}>
|
|
654
|
+
<OptionPicker
|
|
655
|
+
options={field.options}
|
|
656
|
+
value={selected}
|
|
657
|
+
multiple={many}
|
|
658
|
+
disabled={disabled}
|
|
659
|
+
placeholder="Choose…"
|
|
660
|
+
onChange={(values) => setValue(many ? values : values[0])}
|
|
661
|
+
/>
|
|
662
|
+
</div>
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (operator.arity === "many") {
|
|
667
|
+
// Free-text lists: comma separated, shown as-is.
|
|
668
|
+
const list = Array.isArray(rule.value) ? (rule.value as string[]) : []
|
|
669
|
+
return (
|
|
670
|
+
<div data-slot="query-builder-value-editor" className={cn("inline-flex", className)} {...props}>
|
|
671
|
+
<Input
|
|
672
|
+
value={list.join(", ")}
|
|
673
|
+
onChange={(e) =>
|
|
674
|
+
setValue(
|
|
675
|
+
e.target.value
|
|
676
|
+
.split(",")
|
|
677
|
+
.map((s) => s.trim())
|
|
678
|
+
.filter(Boolean)
|
|
679
|
+
)
|
|
680
|
+
}
|
|
681
|
+
placeholder={field?.placeholder ?? "Comma separated"}
|
|
682
|
+
disabled={disabled}
|
|
683
|
+
aria-label="Values"
|
|
684
|
+
className={cn(inputClass, "w-56")}
|
|
685
|
+
/>
|
|
686
|
+
</div>
|
|
687
|
+
)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return (
|
|
691
|
+
<div data-slot="query-builder-value-editor" className={cn("inline-flex", className)} {...props}>
|
|
692
|
+
<Input
|
|
693
|
+
type={inputType}
|
|
694
|
+
value={String(rule.value ?? "")}
|
|
695
|
+
onChange={(e) => setValue(e.target.value)}
|
|
696
|
+
placeholder={field?.placeholder ?? (type === "text" ? "Value" : undefined)}
|
|
697
|
+
disabled={disabled}
|
|
698
|
+
aria-label="Value"
|
|
699
|
+
className={inputClass}
|
|
700
|
+
/>
|
|
701
|
+
</div>
|
|
702
|
+
)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function OptionPicker({
|
|
706
|
+
options,
|
|
707
|
+
value,
|
|
708
|
+
multiple,
|
|
709
|
+
disabled,
|
|
710
|
+
placeholder,
|
|
711
|
+
onChange,
|
|
712
|
+
}: {
|
|
713
|
+
options: QueryOption[]
|
|
714
|
+
value: string[]
|
|
715
|
+
multiple: boolean
|
|
716
|
+
disabled: boolean
|
|
717
|
+
placeholder: string
|
|
718
|
+
onChange: (values: string[]) => void
|
|
719
|
+
}) {
|
|
720
|
+
const collection = React.useMemo(
|
|
721
|
+
() => createListCollection({ items: options, itemToValue: (o) => o.value, itemToString: (o) => o.label }),
|
|
722
|
+
[options]
|
|
723
|
+
)
|
|
724
|
+
const labels = value.map((v) => options.find((o) => o.value === v)?.label ?? v)
|
|
725
|
+
return (
|
|
726
|
+
<Select
|
|
727
|
+
collection={collection}
|
|
728
|
+
value={value}
|
|
729
|
+
multiple={multiple}
|
|
730
|
+
closeOnSelect={!multiple}
|
|
731
|
+
onValueChange={({ value }) => onChange(value)}
|
|
732
|
+
disabled={disabled}
|
|
733
|
+
positioning={{ sameWidth: false }}
|
|
734
|
+
>
|
|
735
|
+
<SelectControl>
|
|
736
|
+
<SelectTrigger size="sm" className="max-w-64" aria-label="Value">
|
|
737
|
+
<SelectValue placeholder={placeholder}>
|
|
738
|
+
{labels.length
|
|
739
|
+
? labels.length > 3
|
|
740
|
+
? `${labels.slice(0, 3).join(", ")} +${labels.length - 3}`
|
|
741
|
+
: labels.join(", ")
|
|
742
|
+
: undefined}
|
|
743
|
+
</SelectValue>
|
|
744
|
+
</SelectTrigger>
|
|
745
|
+
</SelectControl>
|
|
746
|
+
<SelectContent>
|
|
747
|
+
{options.map((o) => (
|
|
748
|
+
<SelectItem key={o.value} item={o}>
|
|
749
|
+
<SelectItemText>{o.label}</SelectItemText>
|
|
750
|
+
<SelectItemIndicator />
|
|
751
|
+
</SelectItem>
|
|
752
|
+
))}
|
|
753
|
+
</SelectContent>
|
|
754
|
+
</Select>
|
|
755
|
+
)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Hover-revealed slot at the end of a rule row (remove trigger, extras). */
|
|
759
|
+
function QueryBuilderRuleActions({ className, ...props }: React.ComponentProps<"div">) {
|
|
760
|
+
return (
|
|
761
|
+
<div
|
|
762
|
+
data-slot="query-builder-rule-actions"
|
|
763
|
+
className={cn(
|
|
764
|
+
"ms-auto inline-flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within/query-rule:opacity-100 group-hover/query-rule:opacity-100",
|
|
765
|
+
className
|
|
766
|
+
)}
|
|
767
|
+
{...props}
|
|
768
|
+
/>
|
|
769
|
+
)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/* ---------------------------------------------------------------------------
|
|
773
|
+
* Triggers (polymorphic via asChild)
|
|
774
|
+
* ------------------------------------------------------------------------- */
|
|
775
|
+
|
|
776
|
+
function QueryBuilderAddRuleTrigger({ asChild, children, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
|
777
|
+
const { addRule, disabled } = useQueryBuilder()
|
|
778
|
+
const { group } = useQueryBuilderGroup()
|
|
779
|
+
return (
|
|
780
|
+
<Button
|
|
781
|
+
data-slot="query-builder-add-rule-trigger"
|
|
782
|
+
variant="ghost"
|
|
783
|
+
size="sm"
|
|
784
|
+
asChild={asChild}
|
|
785
|
+
disabled={disabled}
|
|
786
|
+
onClick={(event) => {
|
|
787
|
+
onClick?.(event)
|
|
788
|
+
if (!event.defaultPrevented) addRule(group.id)
|
|
789
|
+
}}
|
|
790
|
+
{...props}
|
|
791
|
+
>
|
|
792
|
+
{asChild
|
|
793
|
+
? children
|
|
794
|
+
: (children ?? (
|
|
795
|
+
<>
|
|
796
|
+
<PlusIcon /> Add condition
|
|
797
|
+
</>
|
|
798
|
+
))}
|
|
799
|
+
</Button>
|
|
800
|
+
)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function QueryBuilderAddGroupTrigger({ asChild, children, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
|
804
|
+
const { addGroup, disabled, maxDepth } = useQueryBuilder()
|
|
805
|
+
const { group, depth } = useQueryBuilderGroup()
|
|
806
|
+
if (depth >= maxDepth) return null
|
|
807
|
+
return (
|
|
808
|
+
<Button
|
|
809
|
+
data-slot="query-builder-add-group-trigger"
|
|
810
|
+
variant="ghost"
|
|
811
|
+
size="sm"
|
|
812
|
+
asChild={asChild}
|
|
813
|
+
disabled={disabled}
|
|
814
|
+
className="text-muted-foreground"
|
|
815
|
+
onClick={(event) => {
|
|
816
|
+
onClick?.(event)
|
|
817
|
+
if (!event.defaultPrevented) addGroup(group.id)
|
|
818
|
+
}}
|
|
819
|
+
{...props}
|
|
820
|
+
>
|
|
821
|
+
{asChild ? children : (children ?? <>Add a nested group</>)}
|
|
822
|
+
</Button>
|
|
823
|
+
)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** Removes the enclosing rule, or the enclosing group when used in a group header. */
|
|
827
|
+
function QueryBuilderRemoveTrigger({
|
|
828
|
+
asChild,
|
|
829
|
+
children,
|
|
830
|
+
onClick,
|
|
831
|
+
className,
|
|
832
|
+
...props
|
|
833
|
+
}: React.ComponentProps<typeof Button>) {
|
|
834
|
+
const { remove, disabled } = useQueryBuilder()
|
|
835
|
+
const rule = React.useContext(RuleContext)
|
|
836
|
+
const group = React.useContext(GroupContext)
|
|
837
|
+
const target = rule?.rule.id ?? group?.group.id
|
|
838
|
+
const label = rule ? "Remove condition" : "Remove group"
|
|
839
|
+
return (
|
|
840
|
+
<Button
|
|
841
|
+
data-slot="query-builder-remove-trigger"
|
|
842
|
+
variant="ghost"
|
|
843
|
+
size="icon-xs"
|
|
844
|
+
asChild={asChild}
|
|
845
|
+
disabled={disabled}
|
|
846
|
+
aria-label={label}
|
|
847
|
+
className={cn("text-muted-foreground hover:text-destructive", className)}
|
|
848
|
+
onClick={(event) => {
|
|
849
|
+
onClick?.(event)
|
|
850
|
+
if (!event.defaultPrevented && target) remove(target)
|
|
851
|
+
}}
|
|
852
|
+
{...props}
|
|
853
|
+
>
|
|
854
|
+
{asChild ? children : (children ?? <XIcon />)}
|
|
855
|
+
</Button>
|
|
856
|
+
)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/* ---------------------------------------------------------------------------
|
|
860
|
+
* Summary and empty state
|
|
861
|
+
* ------------------------------------------------------------------------- */
|
|
862
|
+
|
|
863
|
+
/** The query as a sentence; `children` overrides the fallback when nothing is set. */
|
|
864
|
+
function QueryBuilderSummary({
|
|
865
|
+
className,
|
|
866
|
+
children,
|
|
867
|
+
prefix = "Showing results where ",
|
|
868
|
+
...props
|
|
869
|
+
}: React.ComponentProps<"p"> & { prefix?: React.ReactNode }) {
|
|
870
|
+
const { value, fields, operators } = useQueryBuilder()
|
|
871
|
+
const text = describeQuery(value, { fields, operators })
|
|
872
|
+
return (
|
|
873
|
+
<p
|
|
874
|
+
data-slot="query-builder-summary"
|
|
875
|
+
data-empty={text ? undefined : ""}
|
|
876
|
+
className={cn("text-sm", className)}
|
|
877
|
+
{...props}
|
|
878
|
+
>
|
|
879
|
+
{text ? (
|
|
880
|
+
<>
|
|
881
|
+
<span className="text-muted-foreground">{prefix}</span>
|
|
882
|
+
<span className="font-medium">{text}</span>
|
|
883
|
+
</>
|
|
884
|
+
) : (
|
|
885
|
+
(children ?? (
|
|
886
|
+
<span className="text-muted-foreground">Showing everything. Add a condition to narrow it down.</span>
|
|
887
|
+
))
|
|
888
|
+
)}
|
|
889
|
+
</p>
|
|
890
|
+
)
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function QueryBuilderEmpty({ className, ...props }: React.ComponentProps<typeof ark.div>) {
|
|
894
|
+
return (
|
|
895
|
+
<ark.div
|
|
896
|
+
data-slot="query-builder-empty"
|
|
897
|
+
className={cn("rounded-lg border border-dashed px-3 py-2 text-sm text-muted-foreground", className)}
|
|
898
|
+
{...props}
|
|
899
|
+
/>
|
|
900
|
+
)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
export {
|
|
904
|
+
QueryBuilder,
|
|
905
|
+
QueryBuilderGroup,
|
|
906
|
+
QueryBuilderGroupHeader,
|
|
907
|
+
QueryBuilderMatch,
|
|
908
|
+
QueryBuilderGroupBody,
|
|
909
|
+
QueryBuilderGroupFooter,
|
|
910
|
+
QueryBuilderRule,
|
|
911
|
+
QueryBuilderFieldSelect,
|
|
912
|
+
QueryBuilderOperatorSelect,
|
|
913
|
+
QueryBuilderValueEditor,
|
|
914
|
+
QueryBuilderRuleActions,
|
|
915
|
+
QueryBuilderAddRuleTrigger,
|
|
916
|
+
QueryBuilderAddGroupTrigger,
|
|
917
|
+
QueryBuilderRemoveTrigger,
|
|
918
|
+
QueryBuilderSummary,
|
|
919
|
+
QueryBuilderEmpty,
|
|
920
|
+
useQueryBuilder,
|
|
921
|
+
useQueryBuilderGroup,
|
|
922
|
+
useQueryBuilderRule,
|
|
923
|
+
createRule,
|
|
924
|
+
createGroup,
|
|
925
|
+
isGroup,
|
|
926
|
+
isComplete,
|
|
927
|
+
operatorsFor,
|
|
928
|
+
defaultOperators,
|
|
929
|
+
describeQuery,
|
|
930
|
+
pruneQuery,
|
|
931
|
+
type QueryField,
|
|
932
|
+
type QueryFieldType,
|
|
933
|
+
type QueryOption,
|
|
934
|
+
type QueryOperator,
|
|
935
|
+
type QueryRule,
|
|
936
|
+
type QueryGroup,
|
|
937
|
+
type QueryNode,
|
|
938
|
+
}
|