@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.
- package/dist/QueryBuilder/QueryBuilder.d.ts +95 -0
- package/dist/QueryBuilder/QueryBuilder.d.ts.map +1 -0
- package/dist/QueryBuilder/QueryBuilder.js +38 -0
- package/dist/QueryBuilder/QueryBuilder.js.map +1 -0
- package/dist/QueryBuilder/QueryBuilderCombinator.d.ts +53 -0
- package/dist/QueryBuilder/QueryBuilderCombinator.d.ts.map +1 -0
- package/dist/QueryBuilder/QueryBuilderCombinator.js +59 -0
- package/dist/QueryBuilder/QueryBuilderCombinator.js.map +1 -0
- package/dist/QueryBuilder/QueryBuilderGroup.d.ts +42 -0
- package/dist/QueryBuilder/QueryBuilderGroup.d.ts.map +1 -0
- package/dist/QueryBuilder/QueryBuilderGroup.js +51 -0
- package/dist/QueryBuilder/QueryBuilderGroup.js.map +1 -0
- package/dist/QueryBuilder/QueryBuilderRow.d.ts +28 -0
- package/dist/QueryBuilder/QueryBuilderRow.d.ts.map +1 -0
- package/dist/QueryBuilder/QueryBuilderRow.js +16 -0
- package/dist/QueryBuilder/QueryBuilderRow.js.map +1 -0
- 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 +2 -2
- package/src/QueryBuilder/QueryBuilder.mdx +70 -0
- package/src/QueryBuilder/QueryBuilder.stories.tsx +429 -0
- package/src/QueryBuilder/QueryBuilder.test.tsx +204 -0
- package/src/QueryBuilder/QueryBuilder.tsx +136 -0
- package/src/QueryBuilder/QueryBuilderCombinator.tsx +108 -0
- package/src/QueryBuilder/QueryBuilderGroup.tsx +189 -0
- package/src/QueryBuilder/QueryBuilderRow.tsx +54 -0
- package/src/index.ts +5 -0
- package/src/sourceRules.test.ts +10 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Tree } from "@charcuterie/logic"
|
|
2
|
+
import {
|
|
3
|
+
selectRootGroup,
|
|
4
|
+
useStoreValue,
|
|
5
|
+
} from "@charcuterie/logic"
|
|
6
|
+
import type { ReactNode } from "react"
|
|
7
|
+
|
|
8
|
+
import { QueryBuilderGroup } from "./QueryBuilderGroup.tsx"
|
|
9
|
+
|
|
10
|
+
export type QueryBuilderLabels = {
|
|
11
|
+
addGroup?: string
|
|
12
|
+
addLeaf?: string
|
|
13
|
+
/** The caption over each group's combinator picker. */
|
|
14
|
+
match?: string
|
|
15
|
+
removeGroup?: string
|
|
16
|
+
removeLeaf?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type QueryBuilderProps<Combinator, Leaf> = {
|
|
20
|
+
/**
|
|
21
|
+
* The combinator choices offered in every group's "Match" select.
|
|
22
|
+
* The `value` is opaque — `and`/`or` for a mail rule, `any`/`all`/
|
|
23
|
+
* `none` for a DSL — and the `label` is what a user reads.
|
|
24
|
+
*/
|
|
25
|
+
combinatorOptions: readonly {
|
|
26
|
+
label: string
|
|
27
|
+
value: Combinator
|
|
28
|
+
}[]
|
|
29
|
+
/** The value a fresh leaf is born with when "+ Condition" is pressed. */
|
|
30
|
+
createLeafValue: () => Leaf
|
|
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
|
|
61
|
+
/**
|
|
62
|
+
* The leaf UI, owned by the app. `QueryBuilder` knows nothing about
|
|
63
|
+
* what a condition *is* — a field/operator/value triple, a
|
|
64
|
+
* non-uniform per-kind shape — only that it can be rendered and
|
|
65
|
+
* patched. The delete control around each leaf is the builder's.
|
|
66
|
+
*/
|
|
67
|
+
renderLeaf: (args: {
|
|
68
|
+
nodeId: string
|
|
69
|
+
onChange: (value: Leaf) => void
|
|
70
|
+
value: Leaf
|
|
71
|
+
}) => ReactNode
|
|
72
|
+
/**
|
|
73
|
+
* The `Tree` core from `useTree`, owned by the consumer — the same
|
|
74
|
+
* arrangement as a `Listbox`'s parent owning `useVisibility`.
|
|
75
|
+
* `QueryBuilder` subscribes to it and drives it; it never creates
|
|
76
|
+
* one, so a consumer keeps `serialize()` and the initial tree.
|
|
77
|
+
*/
|
|
78
|
+
tree: Tree<Combinator, Leaf>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DEFAULT_LABELS = {
|
|
82
|
+
addGroup: "Add group",
|
|
83
|
+
addLeaf: "Add condition",
|
|
84
|
+
match: "Match",
|
|
85
|
+
removeGroup: "Remove group",
|
|
86
|
+
removeLeaf: "Remove condition",
|
|
87
|
+
} satisfies Required<QueryBuilderLabels>
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The generic, nestable AND/OR — and any-combinator — group editor.
|
|
91
|
+
*
|
|
92
|
+
* Two apps in the fleet grow the same widget: Mail Sifter's nested
|
|
93
|
+
* mail rules (leaf = field/operator/value, combinators AND/OR) and
|
|
94
|
+
* mux-magic's job DSL (non-uniform leaves, combinators any/all/none).
|
|
95
|
+
* Both are a recursive group of conditions with a combinator, so the
|
|
96
|
+
* value **and** the combinator are opaque here — `Combinator` and
|
|
97
|
+
* `Leaf` never appear except passed through to the consumer's
|
|
98
|
+
* `renderLeaf` and its `combinatorOptions`.
|
|
99
|
+
*
|
|
100
|
+
* State lives entirely in the `Tree` core from `@charcuterie/logic`;
|
|
101
|
+
* this component holds none of its own. It subscribes with
|
|
102
|
+
* `useStoreValue`, so a `patchLeaf` deep in the tree re-renders only
|
|
103
|
+
* the rows whose node identity changed — the same identity discipline
|
|
104
|
+
* the state layer is built on.
|
|
105
|
+
*
|
|
106
|
+
* The recursion and the per-leaf row each live in their own file
|
|
107
|
+
* (`QueryBuilderGroup`, `QueryBuilderRow`), not exported: a group
|
|
108
|
+
* renders groups, and both are rendered inside a `.map`, which is
|
|
109
|
+
* exactly the shape the member-file rule is for.
|
|
110
|
+
*/
|
|
111
|
+
export const QueryBuilder = <Combinator, Leaf>({
|
|
112
|
+
combinatorOptions,
|
|
113
|
+
createLeafValue,
|
|
114
|
+
labels,
|
|
115
|
+
renderCombinator,
|
|
116
|
+
renderLeaf,
|
|
117
|
+
tree,
|
|
118
|
+
}: QueryBuilderProps<Combinator, Leaf>): ReactNode => {
|
|
119
|
+
const state = useStoreValue(tree)
|
|
120
|
+
|
|
121
|
+
const resolvedLabels = { ...DEFAULT_LABELS, ...labels }
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<QueryBuilderGroup
|
|
125
|
+
combinatorOptions={combinatorOptions}
|
|
126
|
+
createLeafValue={createLeafValue}
|
|
127
|
+
depth={0}
|
|
128
|
+
labels={resolvedLabels}
|
|
129
|
+
node={selectRootGroup(state)}
|
|
130
|
+
renderCombinator={renderCombinator}
|
|
131
|
+
renderLeaf={renderLeaf}
|
|
132
|
+
state={state}
|
|
133
|
+
tree={tree}
|
|
134
|
+
/>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useVisibility } from "@charcuterie/logic"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import { Button } from "../Button/Button.tsx"
|
|
5
|
+
import { Listbox } from "../Listbox/Listbox.tsx"
|
|
6
|
+
|
|
7
|
+
export type QueryBuilderCombinatorProps<Combinator> = {
|
|
8
|
+
/** The visible caption above the control, e.g. "Match". */
|
|
9
|
+
label: string
|
|
10
|
+
onChange: (combinator: Combinator) => void
|
|
11
|
+
options: readonly {
|
|
12
|
+
label: string
|
|
13
|
+
value: Combinator
|
|
14
|
+
}[]
|
|
15
|
+
value: Combinator
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One group's combinator picker.
|
|
20
|
+
*
|
|
21
|
+
* A `Listbox`, not a native `Select`, per
|
|
22
|
+
* [the 2026-08-10 demotion](../../../../docs/decisions/2026-08-10-listbox-and-combobox-are-the-default-and-select-is-demoted.md).
|
|
23
|
+
* `QueryBuilder` shipped in #84 with a `Select` the day after that
|
|
24
|
+
* record landed; `charcuterie/prefer-listbox-over-select` did not
|
|
25
|
+
* catch it because the component-choice block is scoped to app
|
|
26
|
+
* packages and exempts this one — right for a primitive, wrong for a
|
|
27
|
+
* composite an app consumes whole.
|
|
28
|
+
*
|
|
29
|
+
* ### Why this is its own file
|
|
30
|
+
*
|
|
31
|
+
* `Listbox` needs a visibility state, and a group renders one picker
|
|
32
|
+
* per group inside a recursive `.map`. A hook cannot be called there,
|
|
33
|
+
* so the picker becomes a component — the same member-file case as
|
|
34
|
+
* `QueryBuilderRow`.
|
|
35
|
+
*
|
|
36
|
+
* ### The name, and why it is not a `Field`
|
|
37
|
+
*
|
|
38
|
+
* `Field` wires `<label htmlFor>` by cloning its `id` onto the
|
|
39
|
+
* control. `Listbox`'s trigger cannot take one: `useAnchoredOverlay`
|
|
40
|
+
* overwrites the cloned trigger's `id` with the one the panel points
|
|
41
|
+
* `aria-labelledby` at, so the label would address an element that no
|
|
42
|
+
* longer carries that id — the exact defect `Field` exists to
|
|
43
|
+
* prevent. So the caption is plain text and the trigger is named with
|
|
44
|
+
* `aria-label`.
|
|
45
|
+
*
|
|
46
|
+
* That name is `"<label>: <current value>"` rather than bare
|
|
47
|
+
* `"<label>"` on purpose: the button's visible text is the current
|
|
48
|
+
* combinator, and WCAG 2.5.3 wants the visible text contained in the
|
|
49
|
+
* accessible name. "Match" alone would fail it; "Match: ALL — every
|
|
50
|
+
* condition" reads correctly and keeps each group's control findable
|
|
51
|
+
* by name.
|
|
52
|
+
*
|
|
53
|
+
* The combinator stays opaque here exactly as it is in the rest of
|
|
54
|
+
* the component: `String(value)` bridges it to the string-valued
|
|
55
|
+
* `Listbox`, and a selection is mapped back to the real `Combinator`
|
|
56
|
+
* before it leaves.
|
|
57
|
+
*/
|
|
58
|
+
export const QueryBuilderCombinator = <Combinator,>({
|
|
59
|
+
label,
|
|
60
|
+
onChange,
|
|
61
|
+
options,
|
|
62
|
+
value,
|
|
63
|
+
}: QueryBuilderCombinatorProps<Combinator>): ReactNode => {
|
|
64
|
+
const { hide, isVisible, toggle } = useVisibility()
|
|
65
|
+
|
|
66
|
+
const currentOption = options.find(
|
|
67
|
+
(option) => String(option.value) === String(value),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const currentLabel = currentOption?.label ?? ""
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="flex flex-col gap-1.5">
|
|
74
|
+
<span className="font-medium text-content-primary text-sm">
|
|
75
|
+
{label}
|
|
76
|
+
</span>
|
|
77
|
+
|
|
78
|
+
<Listbox
|
|
79
|
+
isVisible={isVisible}
|
|
80
|
+
onDismiss={hide}
|
|
81
|
+
onSelect={(nextValue) => {
|
|
82
|
+
const match = options.find(
|
|
83
|
+
(option) => String(option.value) === nextValue,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if (match) {
|
|
87
|
+
onChange(match.value)
|
|
88
|
+
}
|
|
89
|
+
}}
|
|
90
|
+
options={options.map((option) => ({
|
|
91
|
+
label: option.label,
|
|
92
|
+
value: String(option.value),
|
|
93
|
+
}))}
|
|
94
|
+
selectedValue={String(value)}
|
|
95
|
+
trigger={
|
|
96
|
+
<Button
|
|
97
|
+
appearance="outline"
|
|
98
|
+
aria-label={`${label}: ${currentLabel}`}
|
|
99
|
+
intent="neutral"
|
|
100
|
+
onClick={toggle}
|
|
101
|
+
>
|
|
102
|
+
{currentLabel}
|
|
103
|
+
</Button>
|
|
104
|
+
}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Tree,
|
|
3
|
+
TreeGroupNode,
|
|
4
|
+
TreeState,
|
|
5
|
+
} from "@charcuterie/logic"
|
|
6
|
+
import { selectChildNodes } from "@charcuterie/logic"
|
|
7
|
+
import type { ReactNode } from "react"
|
|
8
|
+
|
|
9
|
+
import { Button } from "../Button/Button.tsx"
|
|
10
|
+
import { Card } from "../Card/Card.tsx"
|
|
11
|
+
import { IconButton } from "../IconButton/IconButton.tsx"
|
|
12
|
+
import { toClassName } from "../toClassName.ts"
|
|
13
|
+
import type { QueryBuilderLabels } from "./QueryBuilder.tsx"
|
|
14
|
+
import { QueryBuilderCombinator } from "./QueryBuilderCombinator.tsx"
|
|
15
|
+
import { QueryBuilderRow } from "./QueryBuilderRow.tsx"
|
|
16
|
+
|
|
17
|
+
export type QueryBuilderGroupProps<Combinator, Leaf> = {
|
|
18
|
+
combinatorOptions: readonly {
|
|
19
|
+
label: string
|
|
20
|
+
value: Combinator
|
|
21
|
+
}[]
|
|
22
|
+
createLeafValue: () => Leaf
|
|
23
|
+
/** Root is `0`; nested groups deepen and gain the accent rail. */
|
|
24
|
+
depth: number
|
|
25
|
+
labels: Required<QueryBuilderLabels>
|
|
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
|
|
36
|
+
renderLeaf: (args: {
|
|
37
|
+
nodeId: string
|
|
38
|
+
onChange: (value: Leaf) => void
|
|
39
|
+
value: Leaf
|
|
40
|
+
}) => ReactNode
|
|
41
|
+
state: TreeState<Combinator, Leaf>
|
|
42
|
+
tree: Tree<Combinator, Leaf>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One match group: a "Match" combinator picker, its children (leaf
|
|
47
|
+
* rows and nested groups), and a toolbar to add either. Its own file
|
|
48
|
+
* because it renders itself recursively for nested groups and is
|
|
49
|
+
* itself rendered inside the parent's `.map` — the member-file case.
|
|
50
|
+
*
|
|
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`.
|
|
54
|
+
*/
|
|
55
|
+
export const QueryBuilderGroup = <Combinator, Leaf>({
|
|
56
|
+
combinatorOptions,
|
|
57
|
+
createLeafValue,
|
|
58
|
+
depth,
|
|
59
|
+
labels,
|
|
60
|
+
node,
|
|
61
|
+
renderCombinator,
|
|
62
|
+
renderLeaf,
|
|
63
|
+
state,
|
|
64
|
+
tree,
|
|
65
|
+
}: QueryBuilderGroupProps<Combinator, Leaf>): ReactNode => {
|
|
66
|
+
const children = selectChildNodes(state, node.id)
|
|
67
|
+
|
|
68
|
+
const setCombinator = (nextCombinator: Combinator) => {
|
|
69
|
+
tree.setCombinator(node.id, nextCombinator)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const card = (
|
|
73
|
+
<Card
|
|
74
|
+
// Nested groups sit on the sunken surface so the depth reads as
|
|
75
|
+
// recession, not another raised card floating at the same level.
|
|
76
|
+
surface={depth === 0 ? "raised" : "sunken"}
|
|
77
|
+
>
|
|
78
|
+
<div className="flex flex-col gap-3">
|
|
79
|
+
<div className="flex items-start justify-between gap-2">
|
|
80
|
+
<div className="min-w-52">
|
|
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}
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{depth === 0 ? null : (
|
|
99
|
+
<IconButton
|
|
100
|
+
appearance="ghost"
|
|
101
|
+
intent="danger"
|
|
102
|
+
label={labels.removeGroup}
|
|
103
|
+
onClick={() => {
|
|
104
|
+
tree.removeNode(node.id)
|
|
105
|
+
}}
|
|
106
|
+
size="sm"
|
|
107
|
+
>
|
|
108
|
+
✕
|
|
109
|
+
</IconButton>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{children.length === 0 ? null : (
|
|
114
|
+
<div className="flex flex-col gap-2">
|
|
115
|
+
{children.map((child) =>
|
|
116
|
+
child.kind === "group" ? (
|
|
117
|
+
<QueryBuilderGroup
|
|
118
|
+
combinatorOptions={combinatorOptions}
|
|
119
|
+
createLeafValue={createLeafValue}
|
|
120
|
+
depth={depth + 1}
|
|
121
|
+
key={child.id}
|
|
122
|
+
labels={labels}
|
|
123
|
+
node={child}
|
|
124
|
+
renderCombinator={renderCombinator}
|
|
125
|
+
renderLeaf={renderLeaf}
|
|
126
|
+
state={state}
|
|
127
|
+
tree={tree}
|
|
128
|
+
/>
|
|
129
|
+
) : (
|
|
130
|
+
<QueryBuilderRow
|
|
131
|
+
key={child.id}
|
|
132
|
+
label={labels.removeLeaf}
|
|
133
|
+
nodeId={child.id}
|
|
134
|
+
onChange={(value) => {
|
|
135
|
+
tree.patchLeaf(child.id, value)
|
|
136
|
+
}}
|
|
137
|
+
onRemove={() => {
|
|
138
|
+
tree.removeNode(child.id)
|
|
139
|
+
}}
|
|
140
|
+
renderLeaf={renderLeaf}
|
|
141
|
+
value={child.value}
|
|
142
|
+
/>
|
|
143
|
+
),
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
<div className="flex flex-wrap gap-2">
|
|
149
|
+
<Button
|
|
150
|
+
appearance="outline"
|
|
151
|
+
intent="neutral"
|
|
152
|
+
onClick={() => {
|
|
153
|
+
tree.addLeaf(node.id, createLeafValue())
|
|
154
|
+
}}
|
|
155
|
+
size="sm"
|
|
156
|
+
>
|
|
157
|
+
{`+ ${labels.addLeaf}`}
|
|
158
|
+
</Button>
|
|
159
|
+
|
|
160
|
+
<Button
|
|
161
|
+
appearance="outline"
|
|
162
|
+
intent="neutral"
|
|
163
|
+
onClick={() => {
|
|
164
|
+
tree.addGroup(node.id)
|
|
165
|
+
}}
|
|
166
|
+
size="sm"
|
|
167
|
+
>
|
|
168
|
+
{`+ ${labels.addGroup}`}
|
|
169
|
+
</Button>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</Card>
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
// Depth is shown with a semantic-token inline-start rail. The colour
|
|
176
|
+
// utility paints all four sides, but only the inline-start edge has
|
|
177
|
+
// a width, so only it renders — no raw palette colour anywhere.
|
|
178
|
+
return depth === 0 ? (
|
|
179
|
+
card
|
|
180
|
+
) : (
|
|
181
|
+
<div
|
|
182
|
+
className={toClassName(
|
|
183
|
+
"border-intent-accent-border border-s-2 ps-3",
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
{card}
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import { IconButton } from "../IconButton/IconButton.tsx"
|
|
4
|
+
|
|
5
|
+
export type QueryBuilderRowProps<Leaf> = {
|
|
6
|
+
/** The accessible name of the row's delete control. */
|
|
7
|
+
label: string
|
|
8
|
+
nodeId: string
|
|
9
|
+
onChange: (value: Leaf) => void
|
|
10
|
+
onRemove: () => void
|
|
11
|
+
renderLeaf: (args: {
|
|
12
|
+
nodeId: string
|
|
13
|
+
onChange: (value: Leaf) => void
|
|
14
|
+
value: Leaf
|
|
15
|
+
}) => ReactNode
|
|
16
|
+
value: Leaf
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A single leaf row: the app's own leaf UI, plus the builder's delete
|
|
21
|
+
* control. Its own file for the same reason `ListboxOption` is —
|
|
22
|
+
* rendered inside the group's `.map`, so it cannot be an inline
|
|
23
|
+
* component — and it is a member file (`QueryBuilder*.tsx`, not
|
|
24
|
+
* `QueryBuilder/QueryBuilder.tsx`), so it stays out of the barrel and
|
|
25
|
+
* the component count by rule.
|
|
26
|
+
*
|
|
27
|
+
* The delete control is an `IconButton` because a bare glyph is not an
|
|
28
|
+
* accessible name; its required `label` is what makes each row's
|
|
29
|
+
* remove button findable and unique per condition.
|
|
30
|
+
*/
|
|
31
|
+
export const QueryBuilderRow = <Leaf,>({
|
|
32
|
+
label,
|
|
33
|
+
nodeId,
|
|
34
|
+
onChange,
|
|
35
|
+
onRemove,
|
|
36
|
+
renderLeaf,
|
|
37
|
+
value,
|
|
38
|
+
}: QueryBuilderRowProps<Leaf>): ReactNode => (
|
|
39
|
+
<div className="flex items-start gap-2">
|
|
40
|
+
<div className="grow">
|
|
41
|
+
{renderLeaf({ nodeId, onChange, value })}
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<IconButton
|
|
45
|
+
appearance="ghost"
|
|
46
|
+
intent="danger"
|
|
47
|
+
label={label}
|
|
48
|
+
onClick={onRemove}
|
|
49
|
+
size="sm"
|
|
50
|
+
>
|
|
51
|
+
✕
|
|
52
|
+
</IconButton>
|
|
53
|
+
</div>
|
|
54
|
+
)
|
package/src/index.ts
CHANGED
|
@@ -173,6 +173,11 @@ export {
|
|
|
173
173
|
toProgressPercent,
|
|
174
174
|
toProgressValue,
|
|
175
175
|
} from "./ProgressBar/progressValue.ts"
|
|
176
|
+
export type {
|
|
177
|
+
QueryBuilderLabels,
|
|
178
|
+
QueryBuilderProps,
|
|
179
|
+
} from "./QueryBuilder/QueryBuilder.tsx"
|
|
180
|
+
export { QueryBuilder } from "./QueryBuilder/QueryBuilder.tsx"
|
|
176
181
|
export type {
|
|
177
182
|
RadioGroupProps,
|
|
178
183
|
RadioItem,
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -364,7 +364,16 @@ test("the barrel is the only place components are re-exported", async () => {
|
|
|
364
364
|
// `useToolbarOverflow.ts` and `chooseVisibleCount.ts` are its
|
|
365
365
|
// members and stay out of this count by the `<Name>/<Name>.tsx`
|
|
366
366
|
// rule.
|
|
367
|
-
|
|
367
|
+
//
|
|
368
|
+
// `QueryBuilder` — the generic nestable AND/OR (any-combinator) group
|
|
369
|
+
// editor Mail Sifter's nested rules and mux-magic's job DSL both need
|
|
370
|
+
// — is the library's first recursive component and its first with a
|
|
371
|
+
// fully opaque value *and* combinator: +1 -> 44. `QueryBuilderGroup`
|
|
372
|
+
// and `QueryBuilderRow` are its members — a group renders groups, and
|
|
373
|
+
// both are rendered inside a `.map` — and stay out of this count and
|
|
374
|
+
// the barrel by the `<Name>/<Name>.tsx` rule, exactly as
|
|
375
|
+
// `SegmentedOption` and `ListboxOption` do.
|
|
376
|
+
expect(componentNames.length).toBe(44)
|
|
368
377
|
|
|
369
378
|
for (const name of componentNames) {
|
|
370
379
|
expect(barrel).toContain(`export { ${name} }`)
|