@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,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,
@@ -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
- expect(componentNames.length).toBe(43)
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} }`)