@charcuterie/ui 2.12.1 → 2.14.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.
Files changed (30) hide show
  1. package/dist/QueryBuilder/QueryBuilder.d.ts +95 -0
  2. package/dist/QueryBuilder/QueryBuilder.d.ts.map +1 -0
  3. package/dist/QueryBuilder/QueryBuilder.js +38 -0
  4. package/dist/QueryBuilder/QueryBuilder.js.map +1 -0
  5. package/dist/QueryBuilder/QueryBuilderCombinator.d.ts +53 -0
  6. package/dist/QueryBuilder/QueryBuilderCombinator.d.ts.map +1 -0
  7. package/dist/QueryBuilder/QueryBuilderCombinator.js +59 -0
  8. package/dist/QueryBuilder/QueryBuilderCombinator.js.map +1 -0
  9. package/dist/QueryBuilder/QueryBuilderGroup.d.ts +42 -0
  10. package/dist/QueryBuilder/QueryBuilderGroup.d.ts.map +1 -0
  11. package/dist/QueryBuilder/QueryBuilderGroup.js +51 -0
  12. package/dist/QueryBuilder/QueryBuilderGroup.js.map +1 -0
  13. package/dist/QueryBuilder/QueryBuilderRow.d.ts +28 -0
  14. package/dist/QueryBuilder/QueryBuilderRow.d.ts.map +1 -0
  15. package/dist/QueryBuilder/QueryBuilderRow.js +16 -0
  16. package/dist/QueryBuilder/QueryBuilderRow.js.map +1 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/package.json +2 -2
  22. package/src/QueryBuilder/QueryBuilder.mdx +70 -0
  23. package/src/QueryBuilder/QueryBuilder.stories.tsx +429 -0
  24. package/src/QueryBuilder/QueryBuilder.test.tsx +204 -0
  25. package/src/QueryBuilder/QueryBuilder.tsx +136 -0
  26. package/src/QueryBuilder/QueryBuilderCombinator.tsx +108 -0
  27. package/src/QueryBuilder/QueryBuilderGroup.tsx +189 -0
  28. package/src/QueryBuilder/QueryBuilderRow.tsx +54 -0
  29. package/src/index.ts +5 -0
  30. package/src/sourceRules.test.ts +10 -1
@@ -0,0 +1,429 @@
1
+ import type { SerializedTree } from "@charcuterie/logic"
2
+ import {
3
+ createTree,
4
+ useTree,
5
+ useVisibility,
6
+ } from "@charcuterie/logic"
7
+ import type { Meta, StoryObj } from "@storybook/react"
8
+ import type { ReactNode } from "react"
9
+ import { useState } from "react"
10
+ import { Button } from "../Button/Button.tsx"
11
+ import { StorySection } from "../board.storyHelpers.tsx"
12
+ import { Listbox } from "../Listbox/Listbox.tsx"
13
+ import { QueryBuilder } from "./QueryBuilder.tsx"
14
+
15
+ /**
16
+ * The concrete demo leaf — a field/operator/value triple, Mail
17
+ * Sifter's shape. `QueryBuilder` never sees this type; it flows
18
+ * through `renderLeaf` and `createLeafValue` untouched, which is what
19
+ * lets mux-magic pass a non-uniform per-kind shape instead.
20
+ */
21
+ type DemoLeaf = {
22
+ field: string
23
+ operator: string
24
+ value: string
25
+ }
26
+
27
+ type Combinator = "and" | "or"
28
+
29
+ const COMBINATOR_OPTIONS: readonly {
30
+ label: string
31
+ value: Combinator
32
+ }[] = [
33
+ { label: "ALL — every condition", value: "and" },
34
+ { label: "ANY — one condition", value: "or" },
35
+ ]
36
+
37
+ const FIELD_OPTIONS = [
38
+ { label: "Subject", value: "subject" },
39
+ { label: "From", value: "from" },
40
+ { label: "Body", value: "body" },
41
+ ] as const
42
+
43
+ const OPERATOR_OPTIONS = [
44
+ { label: "contains", value: "contains" },
45
+ { label: "is", value: "is" },
46
+ { label: "starts with", value: "startsWith" },
47
+ ] as const
48
+
49
+ const createLeafValue = (): DemoLeaf => ({
50
+ field: "subject",
51
+ operator: "contains",
52
+ value: "",
53
+ })
54
+
55
+ /**
56
+ * A leaf's own picker — a `Listbox`, like the combinator above it,
57
+ * because [the 2026-08-10 demotion](../../../../docs/decisions/2026-08-10-listbox-and-combobox-are-the-default-and-select-is-demoted.md)
58
+ * applies to an app's leaf UI exactly as it does to this component's
59
+ * own controls. This story is the example apps copy, so it must not
60
+ * teach the native `Select` the record demoted.
61
+ *
62
+ * It is a component rather than inline JSX for the same reason
63
+ * `QueryBuilderCombinator` is: `Listbox` needs a visibility state, and
64
+ * a leaf is rendered inside a `.map` where a hook cannot be called.
65
+ * An app adopting `QueryBuilder` writes this same small wrapper — see
66
+ * the note in `QueryBuilder.mdx`.
67
+ */
68
+ const LeafPicker = ({
69
+ label,
70
+ onChange,
71
+ options,
72
+ value,
73
+ }: {
74
+ label: string
75
+ onChange: (value: string) => void
76
+ options: readonly { label: string; value: string }[]
77
+ value: string
78
+ }): ReactNode => {
79
+ const { hide, isVisible, toggle } = useVisibility()
80
+
81
+ const currentLabel =
82
+ options.find((option) => option.value === value)
83
+ ?.label ?? ""
84
+
85
+ return (
86
+ <Listbox
87
+ isVisible={isVisible}
88
+ onDismiss={hide}
89
+ onSelect={onChange}
90
+ options={options}
91
+ selectedValue={value}
92
+ trigger={
93
+ <Button
94
+ appearance="outline"
95
+ aria-label={`${label}: ${currentLabel}`}
96
+ intent="neutral"
97
+ onClick={toggle}
98
+ size="sm"
99
+ >
100
+ {currentLabel}
101
+ </Button>
102
+ }
103
+ />
104
+ )
105
+ }
106
+
107
+ const renderLeaf = ({
108
+ onChange,
109
+ value,
110
+ }: {
111
+ nodeId: string
112
+ onChange: (value: DemoLeaf) => void
113
+ value: DemoLeaf
114
+ }): ReactNode => (
115
+ <div className="flex flex-wrap items-center gap-2">
116
+ <LeafPicker
117
+ label="Field"
118
+ onChange={(field) => {
119
+ onChange({ ...value, field })
120
+ }}
121
+ options={FIELD_OPTIONS}
122
+ value={value.field}
123
+ />
124
+
125
+ <LeafPicker
126
+ label="Operator"
127
+ onChange={(operator) => {
128
+ onChange({ ...value, operator })
129
+ }}
130
+ options={OPERATOR_OPTIONS}
131
+ value={value.operator}
132
+ />
133
+
134
+ <input
135
+ aria-label="Value"
136
+ className="min-w-40 grow rounded-md border border-border-default bg-surface-raised px-3 py-1.5 text-content-primary text-sm"
137
+ onChange={(changeEvent) => {
138
+ onChange({
139
+ ...value,
140
+ value: changeEvent.currentTarget.value,
141
+ })
142
+ }}
143
+ value={value.value}
144
+ />
145
+ </div>
146
+ )
147
+
148
+ const FLAT_TREE: SerializedTree<Combinator, DemoLeaf> = {
149
+ children: [
150
+ {
151
+ kind: "leaf",
152
+ value: {
153
+ field: "from",
154
+ operator: "contains",
155
+ value: "@newsletter",
156
+ },
157
+ },
158
+ {
159
+ kind: "leaf",
160
+ value: {
161
+ field: "subject",
162
+ operator: "contains",
163
+ value: "sale",
164
+ },
165
+ },
166
+ ],
167
+ combinator: "and",
168
+ kind: "group",
169
+ }
170
+
171
+ const NESTED_TREE: SerializedTree<Combinator, DemoLeaf> = {
172
+ children: [
173
+ {
174
+ kind: "leaf",
175
+ value: {
176
+ field: "from",
177
+ operator: "is",
178
+ value: "billing@acme.test",
179
+ },
180
+ },
181
+ {
182
+ children: [
183
+ {
184
+ kind: "leaf",
185
+ value: {
186
+ field: "subject",
187
+ operator: "contains",
188
+ value: "invoice",
189
+ },
190
+ },
191
+ {
192
+ kind: "leaf",
193
+ value: {
194
+ field: "subject",
195
+ operator: "contains",
196
+ value: "receipt",
197
+ },
198
+ },
199
+ ],
200
+ combinator: "or",
201
+ kind: "group",
202
+ },
203
+ ],
204
+ combinator: "and",
205
+ kind: "group",
206
+ }
207
+
208
+ const QueryBuilderHarness = ({
209
+ initialTree,
210
+ }: {
211
+ initialTree?: SerializedTree<Combinator, DemoLeaf>
212
+ }): ReactNode => {
213
+ const tree = useTree<Combinator, DemoLeaf>({
214
+ defaultCombinator: "and",
215
+ initialTree,
216
+ })
217
+
218
+ return (
219
+ <QueryBuilder
220
+ combinatorOptions={COMBINATOR_OPTIONS}
221
+ createLeafValue={createLeafValue}
222
+ renderLeaf={renderLeaf}
223
+ tree={tree}
224
+ />
225
+ )
226
+ }
227
+
228
+ const meta = {
229
+ title: "Components/QueryBuilder",
230
+ component: QueryBuilder,
231
+ parameters: { layout: "padded" },
232
+ args: {
233
+ combinatorOptions: COMBINATOR_OPTIONS,
234
+ createLeafValue,
235
+ renderLeaf,
236
+ tree: createTree<Combinator, DemoLeaf>({
237
+ defaultCombinator: "and",
238
+ }),
239
+ },
240
+ } satisfies Meta<typeof QueryBuilder<Combinator, DemoLeaf>>
241
+
242
+ export default meta
243
+
244
+ type Story = StoryObj<typeof meta>
245
+
246
+ /**
247
+ * A flat group of two conditions joined by ALL. Change the combinator,
248
+ * edit a condition, or add another with "+ Add condition".
249
+ */
250
+ export const Default: Story = {
251
+ render: () => (
252
+ <QueryBuilderHarness initialTree={FLAT_TREE} />
253
+ ),
254
+ }
255
+
256
+ /**
257
+ * Two shapes side by side: a flat rule, and a nested one where a
258
+ * sub-group joined by ANY sits inside a group joined by ALL. The
259
+ * nested group wears the inline-start accent rail that marks depth.
260
+ */
261
+ export const AllStates: Story = {
262
+ render: () => (
263
+ <div className="flex flex-col gap-6">
264
+ <StorySection title="Flat — ALL of two conditions">
265
+ <QueryBuilderHarness initialTree={FLAT_TREE} />
266
+ </StorySection>
267
+
268
+ <StorySection title="Nested — ALL, with an ANY sub-group">
269
+ <QueryBuilderHarness initialTree={NESTED_TREE} />
270
+ </StorySection>
271
+ </div>
272
+ ),
273
+ }
274
+
275
+ /**
276
+ * Drive it: add a condition, add a nested group, flip a combinator,
277
+ * delete a row. Starts from a single condition so every control is
278
+ * unique and nameable.
279
+ */
280
+ export const Interactive: Story = {
281
+ render: () => (
282
+ <QueryBuilderHarness
283
+ initialTree={{
284
+ children: [
285
+ {
286
+ kind: "leaf",
287
+ value: {
288
+ field: "subject",
289
+ operator: "contains",
290
+ value: "urgent",
291
+ },
292
+ },
293
+ ],
294
+ combinator: "and",
295
+ kind: "group",
296
+ }}
297
+ />
298
+ ),
299
+ }
300
+
301
+ /**
302
+ * `renderCombinator` — the group's combinator control, owned by the app.
303
+ *
304
+ * The default single picker is right when a combinator is a plain enum.
305
+ * mux-magic's is not: it is a **quantifier** (ANY/ALL/NO) crossed with a
306
+ * **target** (these groups, style rows, script-info blocks), and the legal
307
+ * pairs are asymmetric — the DSL has `notAllScriptInfo` and no
308
+ * `notAllStyle`. Flattened to one list, that asymmetry is invisible; split
309
+ * in two, the second picker filters and the illegal pair cannot be built.
310
+ *
311
+ * This story is that shape in miniature: pick `NOT ALL` and the target
312
+ * list collapses to the one target it can legally take.
313
+ */
314
+ const PAIR_COMBINATORS = {
315
+ "all:group": "and",
316
+ "all:row": "and",
317
+ "any:group": "or",
318
+ "any:row": "or",
319
+ "notAll:group": "and",
320
+ } as const
321
+
322
+ const QUANTIFIER_OPTIONS = [
323
+ { label: "ALL", value: "all" },
324
+ { label: "ANY", value: "any" },
325
+ { label: "NOT ALL", value: "notAll" },
326
+ ] as const
327
+
328
+ const CombinatorPair = ({
329
+ onChange,
330
+ value,
331
+ }: {
332
+ onChange: (combinator: Combinator) => void
333
+ value: Combinator
334
+ }): ReactNode => {
335
+ const [quantifier, setQuantifier] =
336
+ useState<string>("all")
337
+
338
+ // "NOT ALL" is legal over groups only here — the same filtered
339
+ // second picker mux-magic needs, in one line.
340
+ const targetOptions =
341
+ quantifier === "notAll"
342
+ ? [{ label: "of these groups", value: "group" }]
343
+ : [
344
+ { label: "of these groups", value: "group" },
345
+ { label: "matching rows", value: "row" },
346
+ ]
347
+
348
+ const [target, setTarget] = useState<string>("group")
349
+
350
+ const resolvedTarget =
351
+ quantifier === "notAll" ? "group" : target
352
+
353
+ return (
354
+ <div className="flex flex-col gap-1.5">
355
+ <span className="font-medium text-content-primary text-sm">
356
+ Match
357
+ </span>
358
+
359
+ <div className="flex items-center gap-1.5">
360
+ <LeafPicker
361
+ label="Quantifier"
362
+ onChange={(nextQuantifier) => {
363
+ setQuantifier(nextQuantifier)
364
+
365
+ const nextTarget =
366
+ nextQuantifier === "notAll"
367
+ ? "group"
368
+ : resolvedTarget
369
+
370
+ onChange(
371
+ PAIR_COMBINATORS[
372
+ `${nextQuantifier}:${nextTarget}` as keyof typeof PAIR_COMBINATORS
373
+ ],
374
+ )
375
+ }}
376
+ options={QUANTIFIER_OPTIONS}
377
+ value={quantifier}
378
+ />
379
+
380
+ <LeafPicker
381
+ label="Target"
382
+ onChange={(nextTarget) => {
383
+ setTarget(nextTarget)
384
+
385
+ onChange(
386
+ PAIR_COMBINATORS[
387
+ `${quantifier}:${nextTarget}` as keyof typeof PAIR_COMBINATORS
388
+ ],
389
+ )
390
+ }}
391
+ options={targetOptions}
392
+ value={resolvedTarget}
393
+ />
394
+ </div>
395
+
396
+ <span className="text-content-muted text-xs">
397
+ {`Combinator: ${String(value)}`}
398
+ </span>
399
+ </div>
400
+ )
401
+ }
402
+
403
+ export const CustomCombinator: Story = {
404
+ render: () => {
405
+ const CustomCombinatorHarness = (): ReactNode => {
406
+ const tree = useTree<Combinator, DemoLeaf>({
407
+ defaultCombinator: "and",
408
+ initialTree: FLAT_TREE,
409
+ })
410
+
411
+ return (
412
+ <QueryBuilder
413
+ combinatorOptions={COMBINATOR_OPTIONS}
414
+ createLeafValue={createLeafValue}
415
+ renderCombinator={({ onChange, value }) => (
416
+ <CombinatorPair
417
+ onChange={onChange}
418
+ value={value}
419
+ />
420
+ )}
421
+ renderLeaf={renderLeaf}
422
+ tree={tree}
423
+ />
424
+ )
425
+ }
426
+
427
+ return <CustomCombinatorHarness />
428
+ },
429
+ }
@@ -0,0 +1,204 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./QueryBuilder.stories.tsx"
9
+
10
+ const {
11
+ AllStates,
12
+ CustomCombinator,
13
+ Default,
14
+ Interactive,
15
+ } = composeStories(stories)
16
+
17
+ test("the root group exposes its combinator as a named 'Match' control", async () => {
18
+ const { canvas, canvasElement } =
19
+ await mountStory(Default)
20
+
21
+ // One root group, so exactly one combinator picker. It is a
22
+ // `Listbox` trigger, so the control is a button that opens a
23
+ // listbox — named "Match: <current>", which keeps the button's
24
+ // visible text (the combinator) inside its accessible name.
25
+ const combinator = expectAgentDrivable(canvas, {
26
+ name: /^Match: /,
27
+ role: "button",
28
+ })
29
+
30
+ await expect(combinator).toHaveAttribute(
31
+ "aria-haspopup",
32
+ "listbox",
33
+ )
34
+
35
+ await expectNoAxeViolations(canvasElement)
36
+ })
37
+
38
+ test("'+ Add condition' appends a new leaf row", async () => {
39
+ const { canvas, canvasElement } =
40
+ await mountStory(Interactive)
41
+
42
+ // Interactive starts from a single condition, so every control is
43
+ // unique and nameable.
44
+ await expect(
45
+ canvas.getAllByRole("textbox", { name: "Value" }),
46
+ ).toHaveLength(1)
47
+
48
+ await userEvent.click(
49
+ expectAgentDrivable(canvas, {
50
+ name: "+ Add condition",
51
+ role: "button",
52
+ }),
53
+ )
54
+
55
+ await waitFor(() => {
56
+ expect(
57
+ canvas.getAllByRole("textbox", { name: "Value" }),
58
+ ).toHaveLength(2)
59
+ })
60
+
61
+ await expectNoAxeViolations(canvasElement)
62
+ })
63
+
64
+ test("'+ Add group' nests a second group with its own combinator", async () => {
65
+ const { canvas, canvasElement } =
66
+ await mountStory(Interactive)
67
+
68
+ await expect(
69
+ canvas.getAllByRole("button", { name: /^Match: / }),
70
+ ).toHaveLength(1)
71
+
72
+ await userEvent.click(
73
+ expectAgentDrivable(canvas, {
74
+ name: "+ Add group",
75
+ role: "button",
76
+ }),
77
+ )
78
+
79
+ await waitFor(() => {
80
+ expect(
81
+ canvas.getAllByRole("button", { name: /^Match: / }),
82
+ ).toHaveLength(2)
83
+ })
84
+
85
+ // Only the nested group carries a remove control, so it is unique.
86
+ expectAgentDrivable(canvas, {
87
+ name: "Remove group",
88
+ role: "button",
89
+ })
90
+
91
+ await expectNoAxeViolations(canvasElement)
92
+ })
93
+
94
+ test("choosing a combinator from the listbox updates the group", async () => {
95
+ const { body, canvas } = await mountStory(Interactive)
96
+
97
+ // The trigger's label IS the current combinator, so the change is
98
+ // asserted on its accessible name rather than a form value — a
99
+ // `Listbox` trigger is a button and holds none.
100
+ await userEvent.click(
101
+ expectAgentDrivable(canvas, {
102
+ name: "Match: ALL — every condition",
103
+ role: "button",
104
+ }),
105
+ )
106
+
107
+ await userEvent.click(
108
+ expectAgentDrivable(body, {
109
+ name: "ANY — one condition",
110
+ role: "option",
111
+ }),
112
+ )
113
+
114
+ await waitFor(() => {
115
+ expectAgentDrivable(canvas, {
116
+ name: "Match: ANY — one condition",
117
+ role: "button",
118
+ })
119
+ })
120
+ })
121
+
122
+ test("removing a condition takes its row away", async () => {
123
+ const { canvas } = await mountStory(Interactive)
124
+
125
+ await userEvent.click(
126
+ expectAgentDrivable(canvas, {
127
+ name: "Remove condition",
128
+ role: "button",
129
+ }),
130
+ )
131
+
132
+ await waitFor(() => {
133
+ expect(
134
+ canvas.queryAllByRole("textbox", { name: "Value" }),
135
+ ).toHaveLength(0)
136
+ })
137
+ })
138
+
139
+ test("a nested tree renders every group's combinator and passes axe", async () => {
140
+ const { canvas, canvasElement } =
141
+ await mountStory(AllStates)
142
+
143
+ // Flat harness (1 group) + nested harness (root + one sub-group) =
144
+ // three combinator pickers, which is the nesting rendering.
145
+ await expect(
146
+ canvas.getAllByRole("button", { name: /^Match: / })
147
+ .length,
148
+ ).toBeGreaterThanOrEqual(3)
149
+
150
+ await expectNoAxeViolations(canvasElement)
151
+ })
152
+
153
+ test("renderCombinator replaces the built-in picker, and its second control filters", async () => {
154
+ const { body, canvas } = await mountStory(
155
+ CustomCombinator,
156
+ )
157
+
158
+ // The app's control is rendered instead of the default one — no
159
+ // "Match: <combinator>" trigger anywhere.
160
+ await expect(
161
+ canvas.queryAllByRole("button", { name: /^Match: / }),
162
+ ).toHaveLength(0)
163
+
164
+ const target = expectAgentDrivable(canvas, {
165
+ name: /^Target: /,
166
+ role: "button",
167
+ })
168
+
169
+ // Both targets are offered while the quantifier is ALL.
170
+ await userEvent.click(target)
171
+
172
+ await expect(body.getAllByRole("option")).toHaveLength(2)
173
+
174
+ await userEvent.keyboard("{Escape}")
175
+
176
+ // Switching to NOT ALL collapses the target list to the one legal
177
+ // pair — the asymmetry this prop exists to express.
178
+ await userEvent.click(
179
+ expectAgentDrivable(canvas, {
180
+ name: /^Quantifier: /,
181
+ role: "button",
182
+ }),
183
+ )
184
+
185
+ await userEvent.click(
186
+ expectAgentDrivable(body, {
187
+ name: "NOT ALL",
188
+ role: "option",
189
+ }),
190
+ )
191
+
192
+ await userEvent.click(
193
+ expectAgentDrivable(canvas, {
194
+ name: /^Target: /,
195
+ role: "button",
196
+ }),
197
+ )
198
+
199
+ await waitFor(async () => {
200
+ await expect(body.getAllByRole("option")).toHaveLength(
201
+ 1,
202
+ )
203
+ })
204
+ })