@charcuterie/ui 2.13.0 → 2.15.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/dist/Picker/Picker.d.ts +83 -0
- package/dist/Picker/Picker.d.ts.map +1 -0
- package/dist/Picker/Picker.js +85 -0
- package/dist/Picker/Picker.js.map +1 -0
- package/dist/QueryBuilder/QueryBuilder.d.ts +32 -1
- package/dist/QueryBuilder/QueryBuilder.d.ts.map +1 -1
- package/dist/QueryBuilder/QueryBuilder.js +3 -2
- package/dist/QueryBuilder/QueryBuilder.js.map +1 -1
- package/dist/QueryBuilder/QueryBuilderCombinator.d.ts +32 -0
- package/dist/QueryBuilder/QueryBuilderCombinator.d.ts.map +1 -0
- package/dist/QueryBuilder/QueryBuilderCombinator.js +41 -0
- package/dist/QueryBuilder/QueryBuilderCombinator.js.map +1 -0
- package/dist/QueryBuilder/QueryBuilderGroup.d.ts +14 -9
- package/dist/QueryBuilder/QueryBuilderGroup.d.ts.map +1 -1
- package/dist/QueryBuilder/QueryBuilderGroup.js +16 -22
- package/dist/QueryBuilder/QueryBuilderGroup.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Picker/Picker.mdx +63 -0
- package/src/Picker/Picker.stories.tsx +184 -0
- package/src/Picker/Picker.test.tsx +98 -0
- package/src/Picker/Picker.tsx +186 -0
- package/src/QueryBuilder/QueryBuilder.mdx +11 -4
- package/src/QueryBuilder/QueryBuilder.stories.tsx +140 -3
- package/src/QueryBuilder/QueryBuilder.test.tsx +95 -23
- package/src/QueryBuilder/QueryBuilder.tsx +34 -0
- package/src/QueryBuilder/QueryBuilderCombinator.tsx +69 -0
- package/src/QueryBuilder/QueryBuilderGroup.tsx +33 -29
- package/src/index.ts +5 -0
- package/src/sourceRules.test.ts +7 -1
|
@@ -2,8 +2,9 @@ import type { SerializedTree } from "@charcuterie/logic"
|
|
|
2
2
|
import { createTree, useTree } from "@charcuterie/logic"
|
|
3
3
|
import type { Meta, StoryObj } from "@storybook/react"
|
|
4
4
|
import type { ReactNode } from "react"
|
|
5
|
+
import { useState } from "react"
|
|
5
6
|
import { StorySection } from "../board.storyHelpers.tsx"
|
|
6
|
-
import {
|
|
7
|
+
import { Picker } from "../Picker/Picker.tsx"
|
|
7
8
|
import { QueryBuilder } from "./QueryBuilder.tsx"
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -55,7 +56,8 @@ const renderLeaf = ({
|
|
|
55
56
|
value: DemoLeaf
|
|
56
57
|
}): ReactNode => (
|
|
57
58
|
<div className="flex flex-wrap items-center gap-2">
|
|
58
|
-
<
|
|
59
|
+
<Picker
|
|
60
|
+
iconEnd={null}
|
|
59
61
|
label="Field"
|
|
60
62
|
onChange={(field) => {
|
|
61
63
|
onChange({ ...value, field })
|
|
@@ -65,7 +67,8 @@ const renderLeaf = ({
|
|
|
65
67
|
value={value.field}
|
|
66
68
|
/>
|
|
67
69
|
|
|
68
|
-
<
|
|
70
|
+
<Picker
|
|
71
|
+
iconEnd={null}
|
|
69
72
|
label="Operator"
|
|
70
73
|
onChange={(operator) => {
|
|
71
74
|
onChange({ ...value, operator })
|
|
@@ -241,3 +244,137 @@ export const Interactive: Story = {
|
|
|
241
244
|
/>
|
|
242
245
|
),
|
|
243
246
|
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* `renderCombinator` — the group's combinator control, owned by the app.
|
|
250
|
+
*
|
|
251
|
+
* The default single picker is right when a combinator is a plain enum.
|
|
252
|
+
* mux-magic's is not: it is a **quantifier** (ANY/ALL/NO) crossed with a
|
|
253
|
+
* **target** (these groups, style rows, script-info blocks), and the legal
|
|
254
|
+
* pairs are asymmetric — the DSL has `notAllScriptInfo` and no
|
|
255
|
+
* `notAllStyle`. Flattened to one list, that asymmetry is invisible; split
|
|
256
|
+
* in two, the second picker filters and the illegal pair cannot be built.
|
|
257
|
+
*
|
|
258
|
+
* This story is that shape in miniature: pick `NOT ALL` and the target
|
|
259
|
+
* list collapses to the one target it can legally take.
|
|
260
|
+
*/
|
|
261
|
+
const PAIR_COMBINATORS = {
|
|
262
|
+
"all:group": "and",
|
|
263
|
+
"all:row": "and",
|
|
264
|
+
"any:group": "or",
|
|
265
|
+
"any:row": "or",
|
|
266
|
+
"notAll:group": "and",
|
|
267
|
+
} as const
|
|
268
|
+
|
|
269
|
+
const QUANTIFIER_OPTIONS = [
|
|
270
|
+
{ label: "ALL", value: "all" },
|
|
271
|
+
{ label: "ANY", value: "any" },
|
|
272
|
+
{ label: "NOT ALL", value: "notAll" },
|
|
273
|
+
] as const
|
|
274
|
+
|
|
275
|
+
const CombinatorPair = ({
|
|
276
|
+
onChange,
|
|
277
|
+
value,
|
|
278
|
+
}: {
|
|
279
|
+
onChange: (combinator: Combinator) => void
|
|
280
|
+
value: Combinator
|
|
281
|
+
}): ReactNode => {
|
|
282
|
+
const [quantifier, setQuantifier] =
|
|
283
|
+
useState<string>("all")
|
|
284
|
+
|
|
285
|
+
// "NOT ALL" is legal over groups only here — the same filtered
|
|
286
|
+
// second picker mux-magic needs, in one line.
|
|
287
|
+
const targetOptions =
|
|
288
|
+
quantifier === "notAll"
|
|
289
|
+
? [{ label: "of these groups", value: "group" }]
|
|
290
|
+
: [
|
|
291
|
+
{ label: "of these groups", value: "group" },
|
|
292
|
+
{ label: "matching rows", value: "row" },
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
const [target, setTarget] = useState<string>("group")
|
|
296
|
+
|
|
297
|
+
const resolvedTarget =
|
|
298
|
+
quantifier === "notAll" ? "group" : target
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<div className="flex flex-col gap-1.5">
|
|
302
|
+
<span className="font-medium text-content-primary text-sm">
|
|
303
|
+
Match
|
|
304
|
+
</span>
|
|
305
|
+
|
|
306
|
+
<div className="flex items-center gap-1.5">
|
|
307
|
+
<Picker
|
|
308
|
+
iconEnd={null}
|
|
309
|
+
label="Quantifier"
|
|
310
|
+
onChange={(nextQuantifier) => {
|
|
311
|
+
setQuantifier(nextQuantifier)
|
|
312
|
+
|
|
313
|
+
const nextTarget =
|
|
314
|
+
nextQuantifier === "notAll"
|
|
315
|
+
? "group"
|
|
316
|
+
: resolvedTarget
|
|
317
|
+
|
|
318
|
+
onChange(
|
|
319
|
+
PAIR_COMBINATORS[
|
|
320
|
+
`${nextQuantifier}:${nextTarget}` as keyof typeof PAIR_COMBINATORS
|
|
321
|
+
],
|
|
322
|
+
)
|
|
323
|
+
}}
|
|
324
|
+
options={QUANTIFIER_OPTIONS}
|
|
325
|
+
size="sm"
|
|
326
|
+
value={quantifier}
|
|
327
|
+
/>
|
|
328
|
+
|
|
329
|
+
<Picker
|
|
330
|
+
iconEnd={null}
|
|
331
|
+
label="Target"
|
|
332
|
+
onChange={(nextTarget) => {
|
|
333
|
+
setTarget(nextTarget)
|
|
334
|
+
|
|
335
|
+
onChange(
|
|
336
|
+
PAIR_COMBINATORS[
|
|
337
|
+
`${quantifier}:${nextTarget}` as keyof typeof PAIR_COMBINATORS
|
|
338
|
+
],
|
|
339
|
+
)
|
|
340
|
+
}}
|
|
341
|
+
options={targetOptions}
|
|
342
|
+
size="sm"
|
|
343
|
+
value={resolvedTarget}
|
|
344
|
+
/>
|
|
345
|
+
</div>
|
|
346
|
+
|
|
347
|
+
<span className="text-content-muted text-xs">
|
|
348
|
+
{`Combinator: ${String(value)}`}
|
|
349
|
+
</span>
|
|
350
|
+
</div>
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export const CustomCombinator: Story = {
|
|
355
|
+
render: () => {
|
|
356
|
+
const CustomCombinatorHarness = (): ReactNode => {
|
|
357
|
+
const tree = useTree<Combinator, DemoLeaf>({
|
|
358
|
+
defaultCombinator: "and",
|
|
359
|
+
initialTree: FLAT_TREE,
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<QueryBuilder
|
|
364
|
+
combinatorOptions={COMBINATOR_OPTIONS}
|
|
365
|
+
createLeafValue={createLeafValue}
|
|
366
|
+
renderCombinator={({ onChange, value }) => (
|
|
367
|
+
<CombinatorPair
|
|
368
|
+
onChange={onChange}
|
|
369
|
+
value={value}
|
|
370
|
+
/>
|
|
371
|
+
)}
|
|
372
|
+
renderLeaf={renderLeaf}
|
|
373
|
+
tree={tree}
|
|
374
|
+
/>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return <CustomCombinatorHarness />
|
|
379
|
+
},
|
|
380
|
+
}
|
|
@@ -7,20 +7,31 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./QueryBuilder.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const {
|
|
11
|
-
|
|
10
|
+
const {
|
|
11
|
+
AllStates,
|
|
12
|
+
CustomCombinator,
|
|
13
|
+
Default,
|
|
14
|
+
Interactive,
|
|
15
|
+
} = composeStories(stories)
|
|
12
16
|
|
|
13
17
|
test("the root group exposes its combinator as a named 'Match' control", async () => {
|
|
14
18
|
const { canvas, canvasElement } =
|
|
15
19
|
await mountStory(Default)
|
|
16
20
|
|
|
17
|
-
// One root group, so exactly one combinator
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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",
|
|
22
28
|
})
|
|
23
29
|
|
|
30
|
+
await expect(combinator).toHaveAttribute(
|
|
31
|
+
"aria-haspopup",
|
|
32
|
+
"listbox",
|
|
33
|
+
)
|
|
34
|
+
|
|
24
35
|
await expectNoAxeViolations(canvasElement)
|
|
25
36
|
})
|
|
26
37
|
|
|
@@ -55,7 +66,7 @@ test("'+ Add group' nests a second group with its own combinator", async () => {
|
|
|
55
66
|
await mountStory(Interactive)
|
|
56
67
|
|
|
57
68
|
await expect(
|
|
58
|
-
canvas.getAllByRole("
|
|
69
|
+
canvas.getAllByRole("button", { name: /^Match: / }),
|
|
59
70
|
).toHaveLength(1)
|
|
60
71
|
|
|
61
72
|
await userEvent.click(
|
|
@@ -67,7 +78,7 @@ test("'+ Add group' nests a second group with its own combinator", async () => {
|
|
|
67
78
|
|
|
68
79
|
await waitFor(() => {
|
|
69
80
|
expect(
|
|
70
|
-
canvas.getAllByRole("
|
|
81
|
+
canvas.getAllByRole("button", { name: /^Match: / }),
|
|
71
82
|
).toHaveLength(2)
|
|
72
83
|
})
|
|
73
84
|
|
|
@@ -80,23 +91,31 @@ test("'+ Add group' nests a second group with its own combinator", async () => {
|
|
|
80
91
|
await expectNoAxeViolations(canvasElement)
|
|
81
92
|
})
|
|
82
93
|
|
|
83
|
-
test("
|
|
84
|
-
const { canvas } = await mountStory(Interactive)
|
|
85
|
-
|
|
86
|
-
const combinator = expectAgentDrivable(canvas, {
|
|
87
|
-
name: "Match",
|
|
88
|
-
role: "combobox",
|
|
89
|
-
})
|
|
94
|
+
test("choosing a combinator from the listbox updates the group", async () => {
|
|
95
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
90
96
|
|
|
91
|
-
|
|
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
|
+
)
|
|
92
106
|
|
|
93
|
-
await userEvent.
|
|
94
|
-
|
|
95
|
-
|
|
107
|
+
await userEvent.click(
|
|
108
|
+
expectAgentDrivable(body, {
|
|
109
|
+
name: "ANY — one condition",
|
|
110
|
+
role: "option",
|
|
111
|
+
}),
|
|
96
112
|
)
|
|
97
113
|
|
|
98
114
|
await waitFor(() => {
|
|
99
|
-
|
|
115
|
+
expectAgentDrivable(canvas, {
|
|
116
|
+
name: "Match: ANY — one condition",
|
|
117
|
+
role: "button",
|
|
118
|
+
})
|
|
100
119
|
})
|
|
101
120
|
})
|
|
102
121
|
|
|
@@ -122,11 +141,64 @@ test("a nested tree renders every group's combinator and passes axe", async () =
|
|
|
122
141
|
await mountStory(AllStates)
|
|
123
142
|
|
|
124
143
|
// Flat harness (1 group) + nested harness (root + one sub-group) =
|
|
125
|
-
// three combinator
|
|
144
|
+
// three combinator pickers, which is the nesting rendering.
|
|
126
145
|
await expect(
|
|
127
|
-
canvas.getAllByRole("
|
|
146
|
+
canvas.getAllByRole("button", { name: /^Match: / })
|
|
128
147
|
.length,
|
|
129
148
|
).toBeGreaterThanOrEqual(3)
|
|
130
149
|
|
|
131
150
|
await expectNoAxeViolations(canvasElement)
|
|
132
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
|
+
})
|
|
@@ -10,6 +10,8 @@ import { QueryBuilderGroup } from "./QueryBuilderGroup.tsx"
|
|
|
10
10
|
export type QueryBuilderLabels = {
|
|
11
11
|
addGroup?: string
|
|
12
12
|
addLeaf?: string
|
|
13
|
+
/** The caption over each group's combinator picker. */
|
|
14
|
+
match?: string
|
|
13
15
|
removeGroup?: string
|
|
14
16
|
removeLeaf?: string
|
|
15
17
|
}
|
|
@@ -27,6 +29,35 @@ export type QueryBuilderProps<Combinator, Leaf> = {
|
|
|
27
29
|
/** The value a fresh leaf is born with when "+ Condition" is pressed. */
|
|
28
30
|
createLeafValue: () => Leaf
|
|
29
31
|
labels?: QueryBuilderLabels
|
|
32
|
+
/**
|
|
33
|
+
* The group's combinator control, when one `Listbox` over a flat
|
|
34
|
+
* option list is the wrong shape for it.
|
|
35
|
+
*
|
|
36
|
+
* Optional, and the default is the right answer for most consumers:
|
|
37
|
+
* a combinator that is a plain enum reads as one picker. But a
|
|
38
|
+
* combinator can be a *composite* — mux-magic's is a quantifier
|
|
39
|
+
* (ANY/ALL/NO) crossed with a target (a nested group, a style row,
|
|
40
|
+
* a script-info block), and the legal pairs are asymmetric
|
|
41
|
+
* (`notAllScriptInfo` exists, `notAllStyle` does not). Flattened
|
|
42
|
+
* into one list that is nine options where the user is making two
|
|
43
|
+
* choices, and the illegal pairs are invisible until they are
|
|
44
|
+
* missing.
|
|
45
|
+
*
|
|
46
|
+
* So the combinator is app-ownable for the same reason `renderLeaf`
|
|
47
|
+
* is: `Combinator` is opaque here, and only the app knows whether
|
|
48
|
+
* its shape is an enum or a product. `options` and the current
|
|
49
|
+
* `value` are handed over unchanged; the app calls `onChange` with a
|
|
50
|
+
* real `Combinator`.
|
|
51
|
+
*/
|
|
52
|
+
renderCombinator?: (args: {
|
|
53
|
+
nodeId: string
|
|
54
|
+
onChange: (combinator: Combinator) => void
|
|
55
|
+
options: readonly {
|
|
56
|
+
label: string
|
|
57
|
+
value: Combinator
|
|
58
|
+
}[]
|
|
59
|
+
value: Combinator
|
|
60
|
+
}) => ReactNode
|
|
30
61
|
/**
|
|
31
62
|
* The leaf UI, owned by the app. `QueryBuilder` knows nothing about
|
|
32
63
|
* what a condition *is* — a field/operator/value triple, a
|
|
@@ -50,6 +81,7 @@ export type QueryBuilderProps<Combinator, Leaf> = {
|
|
|
50
81
|
const DEFAULT_LABELS = {
|
|
51
82
|
addGroup: "Add group",
|
|
52
83
|
addLeaf: "Add condition",
|
|
84
|
+
match: "Match",
|
|
53
85
|
removeGroup: "Remove group",
|
|
54
86
|
removeLeaf: "Remove condition",
|
|
55
87
|
} satisfies Required<QueryBuilderLabels>
|
|
@@ -80,6 +112,7 @@ export const QueryBuilder = <Combinator, Leaf>({
|
|
|
80
112
|
combinatorOptions,
|
|
81
113
|
createLeafValue,
|
|
82
114
|
labels,
|
|
115
|
+
renderCombinator,
|
|
83
116
|
renderLeaf,
|
|
84
117
|
tree,
|
|
85
118
|
}: QueryBuilderProps<Combinator, Leaf>): ReactNode => {
|
|
@@ -94,6 +127,7 @@ export const QueryBuilder = <Combinator, Leaf>({
|
|
|
94
127
|
depth={0}
|
|
95
128
|
labels={resolvedLabels}
|
|
96
129
|
node={selectRootGroup(state)}
|
|
130
|
+
renderCombinator={renderCombinator}
|
|
97
131
|
renderLeaf={renderLeaf}
|
|
98
132
|
state={state}
|
|
99
133
|
tree={tree}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import { Picker } from "../Picker/Picker.tsx"
|
|
4
|
+
|
|
5
|
+
export type QueryBuilderCombinatorProps<Combinator> = {
|
|
6
|
+
/** The visible caption above the control, e.g. "Match". */
|
|
7
|
+
label: string
|
|
8
|
+
onChange: (combinator: Combinator) => void
|
|
9
|
+
options: readonly {
|
|
10
|
+
label: string
|
|
11
|
+
value: Combinator
|
|
12
|
+
}[]
|
|
13
|
+
value: Combinator
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One group's combinator picker.
|
|
18
|
+
*
|
|
19
|
+
* A `Picker` — a `Listbox` with its trigger attached — rather than a
|
|
20
|
+
* native `Select`, per
|
|
21
|
+
* [the 2026-08-10 demotion](../../../../docs/decisions/2026-08-10-listbox-and-combobox-are-the-default-and-select-is-demoted.md).
|
|
22
|
+
* This file used to assemble the trigger by hand; `Picker` exists
|
|
23
|
+
* because it was the fourth place in the fleet doing so.
|
|
24
|
+
*
|
|
25
|
+
* ### What is left here, and why it is still a component
|
|
26
|
+
*
|
|
27
|
+
* Only the bridging. `Combinator` is opaque to `QueryBuilder`, so it
|
|
28
|
+
* cannot reach `Picker`'s string-valued API directly: `String(value)`
|
|
29
|
+
* carries it out and the matching option carries it back, so the
|
|
30
|
+
* generic never leaks. The caption is plain text rather than a
|
|
31
|
+
* `Field`, because `useAnchoredOverlay` overwrites the trigger's `id`
|
|
32
|
+
* and a `<label htmlFor>` would then address an element that no longer
|
|
33
|
+
* carries it — the exact defect `Field` exists to prevent.
|
|
34
|
+
*/
|
|
35
|
+
export const QueryBuilderCombinator = <Combinator,>({
|
|
36
|
+
label,
|
|
37
|
+
onChange,
|
|
38
|
+
options,
|
|
39
|
+
value,
|
|
40
|
+
}: QueryBuilderCombinatorProps<Combinator>): ReactNode => (
|
|
41
|
+
<div className="flex flex-col gap-1.5">
|
|
42
|
+
<span className="font-medium text-content-primary text-sm">
|
|
43
|
+
{label}
|
|
44
|
+
</span>
|
|
45
|
+
|
|
46
|
+
<Picker
|
|
47
|
+
// No chevron: this is a refactor onto the shared component, and
|
|
48
|
+
// the trigger that shipped in 2.14.0 had none. Adding the
|
|
49
|
+
// affordance here is a visual change and belongs in its own PR,
|
|
50
|
+
// not smuggled in under an extraction.
|
|
51
|
+
iconEnd={null}
|
|
52
|
+
label={label}
|
|
53
|
+
onChange={(nextValue) => {
|
|
54
|
+
const match = options.find(
|
|
55
|
+
(option) => String(option.value) === nextValue,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if (match) {
|
|
59
|
+
onChange(match.value)
|
|
60
|
+
}
|
|
61
|
+
}}
|
|
62
|
+
options={options.map((option) => ({
|
|
63
|
+
label: option.label,
|
|
64
|
+
value: String(option.value),
|
|
65
|
+
}))}
|
|
66
|
+
value={String(value)}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
@@ -8,11 +8,10 @@ import type { ReactNode } from "react"
|
|
|
8
8
|
|
|
9
9
|
import { Button } from "../Button/Button.tsx"
|
|
10
10
|
import { Card } from "../Card/Card.tsx"
|
|
11
|
-
import { Field } from "../Field/Field.tsx"
|
|
12
11
|
import { IconButton } from "../IconButton/IconButton.tsx"
|
|
13
|
-
import { Select } from "../Select/Select.tsx"
|
|
14
12
|
import { toClassName } from "../toClassName.ts"
|
|
15
13
|
import type { QueryBuilderLabels } from "./QueryBuilder.tsx"
|
|
14
|
+
import { QueryBuilderCombinator } from "./QueryBuilderCombinator.tsx"
|
|
16
15
|
import { QueryBuilderRow } from "./QueryBuilderRow.tsx"
|
|
17
16
|
|
|
18
17
|
export type QueryBuilderGroupProps<Combinator, Leaf> = {
|
|
@@ -25,6 +24,15 @@ export type QueryBuilderGroupProps<Combinator, Leaf> = {
|
|
|
25
24
|
depth: number
|
|
26
25
|
labels: Required<QueryBuilderLabels>
|
|
27
26
|
node: TreeGroupNode<Combinator>
|
|
27
|
+
renderCombinator?: (args: {
|
|
28
|
+
nodeId: string
|
|
29
|
+
onChange: (combinator: Combinator) => void
|
|
30
|
+
options: readonly {
|
|
31
|
+
label: string
|
|
32
|
+
value: Combinator
|
|
33
|
+
}[]
|
|
34
|
+
value: Combinator
|
|
35
|
+
}) => ReactNode
|
|
28
36
|
renderLeaf: (args: {
|
|
29
37
|
nodeId: string
|
|
30
38
|
onChange: (value: Leaf) => void
|
|
@@ -35,18 +43,14 @@ export type QueryBuilderGroupProps<Combinator, Leaf> = {
|
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
/**
|
|
38
|
-
* One match group: a "Match" combinator
|
|
46
|
+
* One match group: a "Match" combinator picker, its children (leaf
|
|
39
47
|
* rows and nested groups), and a toolbar to add either. Its own file
|
|
40
48
|
* because it renders itself recursively for nested groups and is
|
|
41
49
|
* itself rendered inside the parent's `.map` — the member-file case.
|
|
42
50
|
*
|
|
43
|
-
* The
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* `Combinator` before it reaches `setCombinator`. That keeps
|
|
47
|
-
* `Combinator` fully generic while still using the platform control
|
|
48
|
-
* that brings type-ahead, the mobile wheel, and form semantics for
|
|
49
|
-
* free.
|
|
51
|
+
* The picker is `QueryBuilderCombinator`, a `Listbox` rather than the
|
|
52
|
+
* native `Select` this shipped with — see that file for why it is a
|
|
53
|
+
* component and how it stays generic over an opaque `Combinator`.
|
|
50
54
|
*/
|
|
51
55
|
export const QueryBuilderGroup = <Combinator, Leaf>({
|
|
52
56
|
combinatorOptions,
|
|
@@ -54,16 +58,16 @@ export const QueryBuilderGroup = <Combinator, Leaf>({
|
|
|
54
58
|
depth,
|
|
55
59
|
labels,
|
|
56
60
|
node,
|
|
61
|
+
renderCombinator,
|
|
57
62
|
renderLeaf,
|
|
58
63
|
state,
|
|
59
64
|
tree,
|
|
60
65
|
}: QueryBuilderGroupProps<Combinator, Leaf>): ReactNode => {
|
|
61
66
|
const children = selectChildNodes(state, node.id)
|
|
62
67
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}))
|
|
68
|
+
const setCombinator = (nextCombinator: Combinator) => {
|
|
69
|
+
tree.setCombinator(node.id, nextCombinator)
|
|
70
|
+
}
|
|
67
71
|
|
|
68
72
|
const card = (
|
|
69
73
|
<Card
|
|
@@ -74,22 +78,21 @@ export const QueryBuilderGroup = <Combinator, Leaf>({
|
|
|
74
78
|
<div className="flex flex-col gap-3">
|
|
75
79
|
<div className="flex items-start justify-between gap-2">
|
|
76
80
|
<div className="min-w-52">
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
value={String(node.combinator)}
|
|
81
|
+
{renderCombinator ? (
|
|
82
|
+
renderCombinator({
|
|
83
|
+
nodeId: node.id,
|
|
84
|
+
onChange: setCombinator,
|
|
85
|
+
options: combinatorOptions,
|
|
86
|
+
value: node.combinator,
|
|
87
|
+
})
|
|
88
|
+
) : (
|
|
89
|
+
<QueryBuilderCombinator
|
|
90
|
+
label={labels.match}
|
|
91
|
+
onChange={setCombinator}
|
|
92
|
+
options={combinatorOptions}
|
|
93
|
+
value={node.combinator}
|
|
91
94
|
/>
|
|
92
|
-
|
|
95
|
+
)}
|
|
93
96
|
</div>
|
|
94
97
|
|
|
95
98
|
{depth === 0 ? null : (
|
|
@@ -118,6 +121,7 @@ export const QueryBuilderGroup = <Combinator, Leaf>({
|
|
|
118
121
|
key={child.id}
|
|
119
122
|
labels={labels}
|
|
120
123
|
node={child}
|
|
124
|
+
renderCombinator={renderCombinator}
|
|
121
125
|
renderLeaf={renderLeaf}
|
|
122
126
|
state={state}
|
|
123
127
|
tree={tree}
|
package/src/index.ts
CHANGED
|
@@ -160,6 +160,11 @@ export type {
|
|
|
160
160
|
export { Menu } from "./Menu/Menu.tsx"
|
|
161
161
|
export type { ModalProps } from "./Modal/Modal.tsx"
|
|
162
162
|
export { Modal } from "./Modal/Modal.tsx"
|
|
163
|
+
export type {
|
|
164
|
+
PickerOption,
|
|
165
|
+
PickerProps,
|
|
166
|
+
} from "./Picker/Picker.tsx"
|
|
167
|
+
export { Picker } from "./Picker/Picker.tsx"
|
|
163
168
|
export type { PopoverProps } from "./Popover/Popover.tsx"
|
|
164
169
|
export { Popover } from "./Popover/Popover.tsx"
|
|
165
170
|
export type {
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -373,7 +373,13 @@ test("the barrel is the only place components are re-exported", async () => {
|
|
|
373
373
|
// both are rendered inside a `.map` — and stay out of this count and
|
|
374
374
|
// the barrel by the `<Name>/<Name>.tsx` rule, exactly as
|
|
375
375
|
// `SegmentedOption` and `ListboxOption` do.
|
|
376
|
-
|
|
376
|
+
//
|
|
377
|
+
// `Picker` — a `Listbox` with its trigger already attached — is +1 ->
|
|
378
|
+
// 45. It is a component rather than a recipe in the docs because the
|
|
379
|
+
// fleet wrote it four separate times (plex-channels' `SelectListbox`,
|
|
380
|
+
// board-games' `SelectMenu`, mux-magic's `ListboxPicker`, and twice
|
|
381
|
+
// inside this package), each with its own hand-rolled chevron.
|
|
382
|
+
expect(componentNames.length).toBe(45)
|
|
377
383
|
|
|
378
384
|
for (const name of componentNames) {
|
|
379
385
|
expect(barrel).toContain(`export { ${name} }`)
|