@charcuterie/ui 2.13.0 → 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 +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 +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 +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/package.json +1 -1
- package/src/QueryBuilder/QueryBuilder.mdx +11 -4
- package/src/QueryBuilder/QueryBuilder.stories.tsx +192 -6
- package/src/QueryBuilder/QueryBuilder.test.tsx +95 -23
- package/src/QueryBuilder/QueryBuilder.tsx +34 -0
- package/src/QueryBuilder/QueryBuilderCombinator.tsx +108 -0
- package/src/QueryBuilder/QueryBuilderGroup.tsx +33 -29
|
@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
|
|
|
3
3
|
export type QueryBuilderLabels = {
|
|
4
4
|
addGroup?: string;
|
|
5
5
|
addLeaf?: string;
|
|
6
|
+
/** The caption over each group's combinator picker. */
|
|
7
|
+
match?: string;
|
|
6
8
|
removeGroup?: string;
|
|
7
9
|
removeLeaf?: string;
|
|
8
10
|
};
|
|
@@ -19,6 +21,35 @@ export type QueryBuilderProps<Combinator, Leaf> = {
|
|
|
19
21
|
/** The value a fresh leaf is born with when "+ Condition" is pressed. */
|
|
20
22
|
createLeafValue: () => Leaf;
|
|
21
23
|
labels?: QueryBuilderLabels;
|
|
24
|
+
/**
|
|
25
|
+
* The group's combinator control, when one `Listbox` over a flat
|
|
26
|
+
* option list is the wrong shape for it.
|
|
27
|
+
*
|
|
28
|
+
* Optional, and the default is the right answer for most consumers:
|
|
29
|
+
* a combinator that is a plain enum reads as one picker. But a
|
|
30
|
+
* combinator can be a *composite* — mux-magic's is a quantifier
|
|
31
|
+
* (ANY/ALL/NO) crossed with a target (a nested group, a style row,
|
|
32
|
+
* a script-info block), and the legal pairs are asymmetric
|
|
33
|
+
* (`notAllScriptInfo` exists, `notAllStyle` does not). Flattened
|
|
34
|
+
* into one list that is nine options where the user is making two
|
|
35
|
+
* choices, and the illegal pairs are invisible until they are
|
|
36
|
+
* missing.
|
|
37
|
+
*
|
|
38
|
+
* So the combinator is app-ownable for the same reason `renderLeaf`
|
|
39
|
+
* is: `Combinator` is opaque here, and only the app knows whether
|
|
40
|
+
* its shape is an enum or a product. `options` and the current
|
|
41
|
+
* `value` are handed over unchanged; the app calls `onChange` with a
|
|
42
|
+
* real `Combinator`.
|
|
43
|
+
*/
|
|
44
|
+
renderCombinator?: (args: {
|
|
45
|
+
nodeId: string;
|
|
46
|
+
onChange: (combinator: Combinator) => void;
|
|
47
|
+
options: readonly {
|
|
48
|
+
label: string;
|
|
49
|
+
value: Combinator;
|
|
50
|
+
}[];
|
|
51
|
+
value: Combinator;
|
|
52
|
+
}) => ReactNode;
|
|
22
53
|
/**
|
|
23
54
|
* The leaf UI, owned by the app. `QueryBuilder` knows nothing about
|
|
24
55
|
* what a condition *is* — a field/operator/value triple, a
|
|
@@ -60,5 +91,5 @@ export type QueryBuilderProps<Combinator, Leaf> = {
|
|
|
60
91
|
* renders groups, and both are rendered inside a `.map`, which is
|
|
61
92
|
* exactly the shape the member-file rule is for.
|
|
62
93
|
*/
|
|
63
|
-
export declare const QueryBuilder: <Combinator, Leaf>({ combinatorOptions, createLeafValue, labels, renderLeaf, tree, }: QueryBuilderProps<Combinator, Leaf>) => ReactNode;
|
|
94
|
+
export declare const QueryBuilder: <Combinator, Leaf>({ combinatorOptions, createLeafValue, labels, renderCombinator, renderLeaf, tree, }: QueryBuilderProps<Combinator, Leaf>) => ReactNode;
|
|
64
95
|
//# sourceMappingURL=QueryBuilder.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"QueryBuilder.d.ts","sourceRoot":"","sources":["../../src/QueryBuilder/QueryBuilder.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAA;AAK9C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAItC,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,iBAAiB,CAAC,UAAU,EAAE,IAAI,IAAI;IAChD;;;;OAIG;IACH,iBAAiB,EAAE,SAAS;QAC1B,KAAK,EAAE,MAAM,CAAA;QACb,KAAK,EAAE,UAAU,CAAA;KAClB,EAAE,CAAA;IACH,yEAAyE;IACzE,eAAe,EAAE,MAAM,IAAI,CAAA;IAC3B,MAAM,CAAC,EAAE,kBAAkB,CAAA;IAC3B;;;;;OAKG;IACH,UAAU,EAAE,CAAC,IAAI,EAAE;QACjB,MAAM,EAAE,MAAM,CAAA;QACd,QAAQ,EAAE,CAAC,KAAK,EAAE,IAAI,KAAK,IAAI,CAAA;QAC/B,KAAK,EAAE,IAAI,CAAA;KACZ,KAAK,SAAS,CAAA;IACf;;;;;OAKG;IACH,IAAI,EAAE,IAAI,CAAC,UAAU,EAAE,IAAI,CAAC,CAAA;CAC7B,CAAA;
|
|
1
|
+
{"version":3,"file":"QueryBuilder.d.ts","sourceRoot":"","sources":["../../src/QueryBuilder/QueryBuilder.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAA;AAK9C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAItC,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,uDAAuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,iBAAiB,CAAC,UAAU,EAAE,IAAI,IAAI;IAChD;;;;OAIG;IACH,iBAAiB,EAAE,SAAS;QAC1B,KAAK,EAAE,MAAM,CAAA;QACb,KAAK,EAAE,UAAU,CAAA;KAClB,EAAE,CAAA;IACH,yEAAyE;IACzE,eAAe,EAAE,MAAM,IAAI,CAAA;IAC3B,MAAM,CAAC,EAAE,kBAAkB,CAAA;IAC3B;;;;;;;;;;;;;;;;;;;OAmBG;IACH,gBAAgB,CAAC,EAAE,CAAC,IAAI,EAAE;QACxB,MAAM,EAAE,MAAM,CAAA;QACd,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,CAAA;QAC1C,OAAO,EAAE,SAAS;YAChB,KAAK,EAAE,MAAM,CAAA;YACb,KAAK,EAAE,UAAU,CAAA;SAClB,EAAE,CAAA;QACH,KAAK,EAAE,UAAU,CAAA;KAClB,KAAK,SAAS,CAAA;IACf;;;;;OAKG;IACH,UAAU,EAAE,CAAC,IAAI,EAAE;QACjB,MAAM,EAAE,MAAM,CAAA;QACd,QAAQ,EAAE,CAAC,KAAK,EAAE,IAAI,KAAK,IAAI,CAAA;QAC/B,KAAK,EAAE,IAAI,CAAA;KACZ,KAAK,SAAS,CAAA;IACf;;;;;OAKG;IACH,IAAI,EAAE,IAAI,CAAC,UAAU,EAAE,IAAI,CAAC,CAAA;CAC7B,CAAA;AAUD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,YAAY,GAAI,UAAU,EAAE,IAAI,EAAE,qFAO5C,iBAAiB,CAAC,UAAU,EAAE,IAAI,CAAC,KAAG,SAkBxC,CAAA"}
|
|
@@ -4,6 +4,7 @@ import { QueryBuilderGroup } from "./QueryBuilderGroup.js";
|
|
|
4
4
|
const DEFAULT_LABELS = {
|
|
5
5
|
addGroup: "Add group",
|
|
6
6
|
addLeaf: "Add condition",
|
|
7
|
+
match: "Match",
|
|
7
8
|
removeGroup: "Remove group",
|
|
8
9
|
removeLeaf: "Remove condition",
|
|
9
10
|
};
|
|
@@ -29,9 +30,9 @@ const DEFAULT_LABELS = {
|
|
|
29
30
|
* renders groups, and both are rendered inside a `.map`, which is
|
|
30
31
|
* exactly the shape the member-file rule is for.
|
|
31
32
|
*/
|
|
32
|
-
export const QueryBuilder = ({ combinatorOptions, createLeafValue, labels, renderLeaf, tree, }) => {
|
|
33
|
+
export const QueryBuilder = ({ combinatorOptions, createLeafValue, labels, renderCombinator, renderLeaf, tree, }) => {
|
|
33
34
|
const state = useStoreValue(tree);
|
|
34
35
|
const resolvedLabels = { ...DEFAULT_LABELS, ...labels };
|
|
35
|
-
return (_jsx(QueryBuilderGroup, { combinatorOptions: combinatorOptions, createLeafValue: createLeafValue, depth: 0, labels: resolvedLabels, node: selectRootGroup(state), renderLeaf: renderLeaf, state: state, tree: tree }));
|
|
36
|
+
return (_jsx(QueryBuilderGroup, { combinatorOptions: combinatorOptions, createLeafValue: createLeafValue, depth: 0, labels: resolvedLabels, node: selectRootGroup(state), renderCombinator: renderCombinator, renderLeaf: renderLeaf, state: state, tree: tree }));
|
|
36
37
|
};
|
|
37
38
|
//# sourceMappingURL=QueryBuilder.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"QueryBuilder.js","sourceRoot":"","sources":["../../src/QueryBuilder/QueryBuilder.tsx"],"names":[],"mappings":";AACA,OAAO,EACL,eAAe,EACf,aAAa,GACd,MAAM,oBAAoB,CAAA;AAG3B,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAyB,CAAA;
|
|
1
|
+
{"version":3,"file":"QueryBuilder.js","sourceRoot":"","sources":["../../src/QueryBuilder/QueryBuilder.tsx"],"names":[],"mappings":";AACA,OAAO,EACL,eAAe,EACf,aAAa,GACd,MAAM,oBAAoB,CAAA;AAG3B,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAyB,CAAA;AAyE3D,MAAM,cAAc,GAAG;IACrB,QAAQ,EAAE,WAAW;IACrB,OAAO,EAAE,eAAe;IACxB,KAAK,EAAE,OAAO;IACd,WAAW,EAAE,cAAc;IAC3B,UAAU,EAAE,kBAAkB;CACQ,CAAA;AAExC;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,CAAmB,EAC7C,iBAAiB,EACjB,eAAe,EACf,MAAM,EACN,gBAAgB,EAChB,UAAU,EACV,IAAI,GACgC,EAAa,EAAE;IACnD,MAAM,KAAK,GAAG,aAAa,CAAC,IAAI,CAAC,CAAA;IAEjC,MAAM,cAAc,GAAG,EAAE,GAAG,cAAc,EAAE,GAAG,MAAM,EAAE,CAAA;IAEvD,OAAO,CACL,KAAC,iBAAiB,IAChB,iBAAiB,EAAE,iBAAiB,EACpC,eAAe,EAAE,eAAe,EAChC,KAAK,EAAE,CAAC,EACR,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,eAAe,CAAC,KAAK,CAAC,EAC5B,gBAAgB,EAAE,gBAAgB,EAClC,UAAU,EAAE,UAAU,EACtB,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,IAAI,GACV,CACH,CAAA;AACH,CAAC,CAAA"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export type QueryBuilderCombinatorProps<Combinator> = {
|
|
3
|
+
/** The visible caption above the control, e.g. "Match". */
|
|
4
|
+
label: string;
|
|
5
|
+
onChange: (combinator: Combinator) => void;
|
|
6
|
+
options: readonly {
|
|
7
|
+
label: string;
|
|
8
|
+
value: Combinator;
|
|
9
|
+
}[];
|
|
10
|
+
value: Combinator;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* One group's combinator picker.
|
|
14
|
+
*
|
|
15
|
+
* A `Listbox`, not a native `Select`, per
|
|
16
|
+
* [the 2026-08-10 demotion](../../../../docs/decisions/2026-08-10-listbox-and-combobox-are-the-default-and-select-is-demoted.md).
|
|
17
|
+
* `QueryBuilder` shipped in #84 with a `Select` the day after that
|
|
18
|
+
* record landed; `charcuterie/prefer-listbox-over-select` did not
|
|
19
|
+
* catch it because the component-choice block is scoped to app
|
|
20
|
+
* packages and exempts this one — right for a primitive, wrong for a
|
|
21
|
+
* composite an app consumes whole.
|
|
22
|
+
*
|
|
23
|
+
* ### Why this is its own file
|
|
24
|
+
*
|
|
25
|
+
* `Listbox` needs a visibility state, and a group renders one picker
|
|
26
|
+
* per group inside a recursive `.map`. A hook cannot be called there,
|
|
27
|
+
* so the picker becomes a component — the same member-file case as
|
|
28
|
+
* `QueryBuilderRow`.
|
|
29
|
+
*
|
|
30
|
+
* ### The name, and why it is not a `Field`
|
|
31
|
+
*
|
|
32
|
+
* `Field` wires `<label htmlFor>` by cloning its `id` onto the
|
|
33
|
+
* control. `Listbox`'s trigger cannot take one: `useAnchoredOverlay`
|
|
34
|
+
* overwrites the cloned trigger's `id` with the one the panel points
|
|
35
|
+
* `aria-labelledby` at, so the label would address an element that no
|
|
36
|
+
* longer carries that id — the exact defect `Field` exists to
|
|
37
|
+
* prevent. So the caption is plain text and the trigger is named with
|
|
38
|
+
* `aria-label`.
|
|
39
|
+
*
|
|
40
|
+
* That name is `"<label>: <current value>"` rather than bare
|
|
41
|
+
* `"<label>"` on purpose: the button's visible text is the current
|
|
42
|
+
* combinator, and WCAG 2.5.3 wants the visible text contained in the
|
|
43
|
+
* accessible name. "Match" alone would fail it; "Match: ALL — every
|
|
44
|
+
* condition" reads correctly and keeps each group's control findable
|
|
45
|
+
* by name.
|
|
46
|
+
*
|
|
47
|
+
* The combinator stays opaque here exactly as it is in the rest of
|
|
48
|
+
* the component: `String(value)` bridges it to the string-valued
|
|
49
|
+
* `Listbox`, and a selection is mapped back to the real `Combinator`
|
|
50
|
+
* before it leaves.
|
|
51
|
+
*/
|
|
52
|
+
export declare const QueryBuilderCombinator: <Combinator>({ label, onChange, options, value, }: QueryBuilderCombinatorProps<Combinator>) => ReactNode;
|
|
53
|
+
//# sourceMappingURL=QueryBuilderCombinator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"QueryBuilderCombinator.d.ts","sourceRoot":"","sources":["../../src/QueryBuilder/QueryBuilderCombinator.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAKtC,MAAM,MAAM,2BAA2B,CAAC,UAAU,IAAI;IACpD,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,CAAA;IAC1C,OAAO,EAAE,SAAS;QAChB,KAAK,EAAE,MAAM,CAAA;QACb,KAAK,EAAE,UAAU,CAAA;KAClB,EAAE,CAAA;IACH,KAAK,EAAE,UAAU,CAAA;CAClB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,eAAO,MAAM,sBAAsB,GAAI,UAAU,EAAG,sCAKjD,2BAA2B,CAAC,UAAU,CAAC,KAAG,SA6C5C,CAAA"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useVisibility } from "@charcuterie/logic";
|
|
3
|
+
import { Button } from "../Button/Button.js";
|
|
4
|
+
import { Listbox } from "../Listbox/Listbox.js";
|
|
5
|
+
/**
|
|
6
|
+
* One group's combinator picker.
|
|
7
|
+
*
|
|
8
|
+
* A `Listbox`, not a native `Select`, per
|
|
9
|
+
* [the 2026-08-10 demotion](../../../../docs/decisions/2026-08-10-listbox-and-combobox-are-the-default-and-select-is-demoted.md).
|
|
10
|
+
* `QueryBuilder` shipped in #84 with a `Select` the day after that
|
|
11
|
+
* record landed; `charcuterie/prefer-listbox-over-select` did not
|
|
12
|
+
* catch it because the component-choice block is scoped to app
|
|
13
|
+
* packages and exempts this one — right for a primitive, wrong for a
|
|
14
|
+
* composite an app consumes whole.
|
|
15
|
+
*
|
|
16
|
+
* ### Why this is its own file
|
|
17
|
+
*
|
|
18
|
+
* `Listbox` needs a visibility state, and a group renders one picker
|
|
19
|
+
* per group inside a recursive `.map`. A hook cannot be called there,
|
|
20
|
+
* so the picker becomes a component — the same member-file case as
|
|
21
|
+
* `QueryBuilderRow`.
|
|
22
|
+
*
|
|
23
|
+
* ### The name, and why it is not a `Field`
|
|
24
|
+
*
|
|
25
|
+
* `Field` wires `<label htmlFor>` by cloning its `id` onto the
|
|
26
|
+
* control. `Listbox`'s trigger cannot take one: `useAnchoredOverlay`
|
|
27
|
+
* overwrites the cloned trigger's `id` with the one the panel points
|
|
28
|
+
* `aria-labelledby` at, so the label would address an element that no
|
|
29
|
+
* longer carries that id — the exact defect `Field` exists to
|
|
30
|
+
* prevent. So the caption is plain text and the trigger is named with
|
|
31
|
+
* `aria-label`.
|
|
32
|
+
*
|
|
33
|
+
* That name is `"<label>: <current value>"` rather than bare
|
|
34
|
+
* `"<label>"` on purpose: the button's visible text is the current
|
|
35
|
+
* combinator, and WCAG 2.5.3 wants the visible text contained in the
|
|
36
|
+
* accessible name. "Match" alone would fail it; "Match: ALL — every
|
|
37
|
+
* condition" reads correctly and keeps each group's control findable
|
|
38
|
+
* by name.
|
|
39
|
+
*
|
|
40
|
+
* The combinator stays opaque here exactly as it is in the rest of
|
|
41
|
+
* the component: `String(value)` bridges it to the string-valued
|
|
42
|
+
* `Listbox`, and a selection is mapped back to the real `Combinator`
|
|
43
|
+
* before it leaves.
|
|
44
|
+
*/
|
|
45
|
+
export const QueryBuilderCombinator = ({ label, onChange, options, value, }) => {
|
|
46
|
+
const { hide, isVisible, toggle } = useVisibility();
|
|
47
|
+
const currentOption = options.find((option) => String(option.value) === String(value));
|
|
48
|
+
const currentLabel = currentOption?.label ?? "";
|
|
49
|
+
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("span", { className: "font-medium text-content-primary text-sm", children: label }), _jsx(Listbox, { isVisible: isVisible, onDismiss: hide, onSelect: (nextValue) => {
|
|
50
|
+
const match = options.find((option) => String(option.value) === nextValue);
|
|
51
|
+
if (match) {
|
|
52
|
+
onChange(match.value);
|
|
53
|
+
}
|
|
54
|
+
}, options: options.map((option) => ({
|
|
55
|
+
label: option.label,
|
|
56
|
+
value: String(option.value),
|
|
57
|
+
})), selectedValue: String(value), trigger: _jsx(Button, { appearance: "outline", "aria-label": `${label}: ${currentLabel}`, intent: "neutral", onClick: toggle, children: currentLabel }) })] }));
|
|
58
|
+
};
|
|
59
|
+
//# sourceMappingURL=QueryBuilderCombinator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"QueryBuilderCombinator.js","sourceRoot":"","sources":["../../src/QueryBuilder/QueryBuilderCombinator.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAGlD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAsB,CAAA;AAC7C,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAwB,CAAA;AAahD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAc,EAClD,KAAK,EACL,QAAQ,EACR,OAAO,EACP,KAAK,GACmC,EAAa,EAAE;IACvD,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,aAAa,EAAE,CAAA;IAEnD,MAAM,aAAa,GAAG,OAAO,CAAC,IAAI,CAChC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,MAAM,CAAC,KAAK,CAAC,CACnD,CAAA;IAED,MAAM,YAAY,GAAG,aAAa,EAAE,KAAK,IAAI,EAAE,CAAA;IAE/C,OAAO,CACL,eAAK,SAAS,EAAC,uBAAuB,aACpC,eAAM,SAAS,EAAC,0CAA0C,YACvD,KAAK,GACD,EAEP,KAAC,OAAO,IACN,SAAS,EAAE,SAAS,EACpB,SAAS,EAAE,IAAI,EACf,QAAQ,EAAE,CAAC,SAAS,EAAE,EAAE;oBACtB,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CACxB,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,SAAS,CAC/C,CAAA;oBAED,IAAI,KAAK,EAAE,CAAC;wBACV,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;oBACvB,CAAC;gBACH,CAAC,EACD,OAAO,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;oBAChC,KAAK,EAAE,MAAM,CAAC,KAAK;oBACnB,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC;iBAC5B,CAAC,CAAC,EACH,aAAa,EAAE,MAAM,CAAC,KAAK,CAAC,EAC5B,OAAO,EACL,KAAC,MAAM,IACL,UAAU,EAAC,SAAS,gBACR,GAAG,KAAK,KAAK,YAAY,EAAE,EACvC,MAAM,EAAC,SAAS,EAChB,OAAO,EAAE,MAAM,YAEd,YAAY,GACN,GAEX,IACE,CACP,CAAA;AACH,CAAC,CAAA"}
|
|
@@ -11,6 +11,15 @@ export type QueryBuilderGroupProps<Combinator, Leaf> = {
|
|
|
11
11
|
depth: number;
|
|
12
12
|
labels: Required<QueryBuilderLabels>;
|
|
13
13
|
node: TreeGroupNode<Combinator>;
|
|
14
|
+
renderCombinator?: (args: {
|
|
15
|
+
nodeId: string;
|
|
16
|
+
onChange: (combinator: Combinator) => void;
|
|
17
|
+
options: readonly {
|
|
18
|
+
label: string;
|
|
19
|
+
value: Combinator;
|
|
20
|
+
}[];
|
|
21
|
+
value: Combinator;
|
|
22
|
+
}) => ReactNode;
|
|
14
23
|
renderLeaf: (args: {
|
|
15
24
|
nodeId: string;
|
|
16
25
|
onChange: (value: Leaf) => void;
|
|
@@ -20,18 +29,14 @@ export type QueryBuilderGroupProps<Combinator, Leaf> = {
|
|
|
20
29
|
tree: Tree<Combinator, Leaf>;
|
|
21
30
|
};
|
|
22
31
|
/**
|
|
23
|
-
* One match group: a "Match" combinator
|
|
32
|
+
* One match group: a "Match" combinator picker, its children (leaf
|
|
24
33
|
* rows and nested groups), and a toolbar to add either. Its own file
|
|
25
34
|
* because it renders itself recursively for nested groups and is
|
|
26
35
|
* itself rendered inside the parent's `.map` — the member-file case.
|
|
27
36
|
*
|
|
28
|
-
* The
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* `Combinator` before it reaches `setCombinator`. That keeps
|
|
32
|
-
* `Combinator` fully generic while still using the platform control
|
|
33
|
-
* that brings type-ahead, the mobile wheel, and form semantics for
|
|
34
|
-
* free.
|
|
37
|
+
* The picker is `QueryBuilderCombinator`, a `Listbox` rather than the
|
|
38
|
+
* native `Select` this shipped with — see that file for why it is a
|
|
39
|
+
* component and how it stays generic over an opaque `Combinator`.
|
|
35
40
|
*/
|
|
36
|
-
export declare const QueryBuilderGroup: <Combinator, Leaf>({ combinatorOptions, createLeafValue, depth, labels, node, renderLeaf, state, tree, }: QueryBuilderGroupProps<Combinator, Leaf>) => ReactNode;
|
|
41
|
+
export declare const QueryBuilderGroup: <Combinator, Leaf>({ combinatorOptions, createLeafValue, depth, labels, node, renderCombinator, renderLeaf, state, tree, }: QueryBuilderGroupProps<Combinator, Leaf>) => ReactNode;
|
|
37
42
|
//# sourceMappingURL=QueryBuilderGroup.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"QueryBuilderGroup.d.ts","sourceRoot":"","sources":["../../src/QueryBuilder/QueryBuilderGroup.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,IAAI,EACJ,aAAa,EACb,SAAS,EACV,MAAM,oBAAoB,CAAA;AAE3B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"QueryBuilderGroup.d.ts","sourceRoot":"","sources":["../../src/QueryBuilder/QueryBuilderGroup.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,IAAI,EACJ,aAAa,EACb,SAAS,EACV,MAAM,oBAAoB,CAAA;AAE3B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAMtC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAI5D,MAAM,MAAM,sBAAsB,CAAC,UAAU,EAAE,IAAI,IAAI;IACrD,iBAAiB,EAAE,SAAS;QAC1B,KAAK,EAAE,MAAM,CAAA;QACb,KAAK,EAAE,UAAU,CAAA;KAClB,EAAE,CAAA;IACH,eAAe,EAAE,MAAM,IAAI,CAAA;IAC3B,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,QAAQ,CAAC,kBAAkB,CAAC,CAAA;IACpC,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,CAAA;IAC/B,gBAAgB,CAAC,EAAE,CAAC,IAAI,EAAE;QACxB,MAAM,EAAE,MAAM,CAAA;QACd,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,CAAA;QAC1C,OAAO,EAAE,SAAS;YAChB,KAAK,EAAE,MAAM,CAAA;YACb,KAAK,EAAE,UAAU,CAAA;SAClB,EAAE,CAAA;QACH,KAAK,EAAE,UAAU,CAAA;KAClB,KAAK,SAAS,CAAA;IACf,UAAU,EAAE,CAAC,IAAI,EAAE;QACjB,MAAM,EAAE,MAAM,CAAA;QACd,QAAQ,EAAE,CAAC,KAAK,EAAE,IAAI,KAAK,IAAI,CAAA;QAC/B,KAAK,EAAE,IAAI,CAAA;KACZ,KAAK,SAAS,CAAA;IACf,KAAK,EAAE,SAAS,CAAC,UAAU,EAAE,IAAI,CAAC,CAAA;IAClC,IAAI,EAAE,IAAI,CAAC,UAAU,EAAE,IAAI,CAAC,CAAA;CAC7B,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,EAAE,IAAI,EAAE,yGAUjD,sBAAsB,CAAC,UAAU,EAAE,IAAI,CAAC,KAAG,SA4H7C,CAAA"}
|
|
@@ -2,45 +2,39 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { selectChildNodes } from "@charcuterie/logic";
|
|
3
3
|
import { Button } from "../Button/Button.js";
|
|
4
4
|
import { Card } from "../Card/Card.js";
|
|
5
|
-
import { Field } from "../Field/Field.js";
|
|
6
5
|
import { IconButton } from "../IconButton/IconButton.js";
|
|
7
|
-
import { Select } from "../Select/Select.js";
|
|
8
6
|
import { toClassName } from "../toClassName.js";
|
|
7
|
+
import { QueryBuilderCombinator } from "./QueryBuilderCombinator.js";
|
|
9
8
|
import { QueryBuilderRow } from "./QueryBuilderRow.js";
|
|
10
9
|
/**
|
|
11
|
-
* One match group: a "Match" combinator
|
|
10
|
+
* One match group: a "Match" combinator picker, its children (leaf
|
|
12
11
|
* rows and nested groups), and a toolbar to add either. Its own file
|
|
13
12
|
* because it renders itself recursively for nested groups and is
|
|
14
13
|
* itself rendered inside the parent's `.map` — the member-file case.
|
|
15
14
|
*
|
|
16
|
-
* The
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* `Combinator` before it reaches `setCombinator`. That keeps
|
|
20
|
-
* `Combinator` fully generic while still using the platform control
|
|
21
|
-
* that brings type-ahead, the mobile wheel, and form semantics for
|
|
22
|
-
* free.
|
|
15
|
+
* The picker is `QueryBuilderCombinator`, a `Listbox` rather than the
|
|
16
|
+
* native `Select` this shipped with — see that file for why it is a
|
|
17
|
+
* component and how it stays generic over an opaque `Combinator`.
|
|
23
18
|
*/
|
|
24
|
-
export const QueryBuilderGroup = ({ combinatorOptions, createLeafValue, depth, labels, node, renderLeaf, state, tree, }) => {
|
|
19
|
+
export const QueryBuilderGroup = ({ combinatorOptions, createLeafValue, depth, labels, node, renderCombinator, renderLeaf, state, tree, }) => {
|
|
25
20
|
const children = selectChildNodes(state, node.id);
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}));
|
|
21
|
+
const setCombinator = (nextCombinator) => {
|
|
22
|
+
tree.setCombinator(node.id, nextCombinator);
|
|
23
|
+
};
|
|
30
24
|
const card = (_jsx(Card
|
|
31
25
|
// Nested groups sit on the sunken surface so the depth reads as
|
|
32
26
|
// recession, not another raised card floating at the same level.
|
|
33
27
|
, {
|
|
34
28
|
// Nested groups sit on the sunken surface so the depth reads as
|
|
35
29
|
// recession, not another raised card floating at the same level.
|
|
36
|
-
surface: depth === 0 ? "raised" : "sunken", children: _jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx("div", { className: "min-w-52", children:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
surface: depth === 0 ? "raised" : "sunken", children: _jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx("div", { className: "min-w-52", children: renderCombinator ? (renderCombinator({
|
|
31
|
+
nodeId: node.id,
|
|
32
|
+
onChange: setCombinator,
|
|
33
|
+
options: combinatorOptions,
|
|
34
|
+
value: node.combinator,
|
|
35
|
+
})) : (_jsx(QueryBuilderCombinator, { label: labels.match, onChange: setCombinator, options: combinatorOptions, value: node.combinator })) }), depth === 0 ? null : (_jsx(IconButton, { appearance: "ghost", intent: "danger", label: labels.removeGroup, onClick: () => {
|
|
42
36
|
tree.removeNode(node.id);
|
|
43
|
-
}, size: "sm", children: "\u2715" }))] }), children.length === 0 ? null : (_jsx("div", { className: "flex flex-col gap-2", children: children.map((child) => child.kind === "group" ? (_jsx(QueryBuilderGroup, { combinatorOptions: combinatorOptions, createLeafValue: createLeafValue, depth: depth + 1, labels: labels, node: child, renderLeaf: renderLeaf, state: state, tree: tree }, child.id)) : (_jsx(QueryBuilderRow, { label: labels.removeLeaf, nodeId: child.id, onChange: (value) => {
|
|
37
|
+
}, size: "sm", children: "\u2715" }))] }), children.length === 0 ? null : (_jsx("div", { className: "flex flex-col gap-2", children: children.map((child) => child.kind === "group" ? (_jsx(QueryBuilderGroup, { combinatorOptions: combinatorOptions, createLeafValue: createLeafValue, depth: depth + 1, labels: labels, node: child, renderCombinator: renderCombinator, renderLeaf: renderLeaf, state: state, tree: tree }, child.id)) : (_jsx(QueryBuilderRow, { label: labels.removeLeaf, nodeId: child.id, onChange: (value) => {
|
|
44
38
|
tree.patchLeaf(child.id, value);
|
|
45
39
|
}, onRemove: () => {
|
|
46
40
|
tree.removeNode(child.id);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"QueryBuilderGroup.js","sourceRoot":"","sources":["../../src/QueryBuilder/QueryBuilderGroup.tsx"],"names":[],"mappings":";AAKA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAGrD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAsB,CAAA;AAC7C,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAkB,CAAA;AACvC,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"QueryBuilderGroup.js","sourceRoot":"","sources":["../../src/QueryBuilder/QueryBuilderGroup.tsx"],"names":[],"mappings":";AAKA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAGrD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAsB,CAAA;AAC7C,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAkB,CAAA;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,6BAA8B,CAAA;AACzD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAE/C,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA8B,CAAA;AACrE,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAuB,CAAA;AA8BvD;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAmB,EAClD,iBAAiB,EACjB,eAAe,EACf,KAAK,EACL,MAAM,EACN,IAAI,EACJ,gBAAgB,EAChB,UAAU,EACV,KAAK,EACL,IAAI,GACqC,EAAa,EAAE;IACxD,MAAM,QAAQ,GAAG,gBAAgB,CAAC,KAAK,EAAE,IAAI,CAAC,EAAE,CAAC,CAAA;IAEjD,MAAM,aAAa,GAAG,CAAC,cAA0B,EAAE,EAAE;QACnD,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,EAAE,cAAc,CAAC,CAAA;IAC7C,CAAC,CAAA;IAED,MAAM,IAAI,GAAG,CACX,KAAC,IAAI;IACH,gEAAgE;IAChE,iEAAiE;;QADjE,gEAAgE;QAChE,iEAAiE;QACjE,OAAO,EAAE,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,YAE1C,eAAK,SAAS,EAAC,qBAAqB,aAClC,eAAK,SAAS,EAAC,wCAAwC,aACrD,cAAK,SAAS,EAAC,UAAU,YACtB,gBAAgB,CAAC,CAAC,CAAC,CAClB,gBAAgB,CAAC;gCACf,MAAM,EAAE,IAAI,CAAC,EAAE;gCACf,QAAQ,EAAE,aAAa;gCACvB,OAAO,EAAE,iBAAiB;gCAC1B,KAAK,EAAE,IAAI,CAAC,UAAU;6BACvB,CAAC,CACH,CAAC,CAAC,CAAC,CACF,KAAC,sBAAsB,IACrB,KAAK,EAAE,MAAM,CAAC,KAAK,EACnB,QAAQ,EAAE,aAAa,EACvB,OAAO,EAAE,iBAAiB,EAC1B,KAAK,EAAE,IAAI,CAAC,UAAU,GACtB,CACH,GACG,EAEL,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CACpB,KAAC,UAAU,IACT,UAAU,EAAC,OAAO,EAClB,MAAM,EAAC,QAAQ,EACf,KAAK,EAAE,MAAM,CAAC,WAAW,EACzB,OAAO,EAAE,GAAG,EAAE;gCACZ,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;4BAC1B,CAAC,EACD,IAAI,EAAC,IAAI,uBAGE,CACd,IACG,EAEL,QAAQ,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAC9B,cAAK,SAAS,EAAC,qBAAqB,YACjC,QAAQ,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACtB,KAAK,CAAC,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,CACvB,KAAC,iBAAiB,IAChB,iBAAiB,EAAE,iBAAiB,EACpC,eAAe,EAAE,eAAe,EAChC,KAAK,EAAE,KAAK,GAAG,CAAC,EAEhB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,KAAK,EACX,gBAAgB,EAAE,gBAAgB,EAClC,UAAU,EAAE,UAAU,EACtB,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,IAAI,IANL,KAAK,CAAC,EAAE,CAOb,CACH,CAAC,CAAC,CAAC,CACF,KAAC,eAAe,IAEd,KAAK,EAAE,MAAM,CAAC,UAAU,EACxB,MAAM,EAAE,KAAK,CAAC,EAAE,EAChB,QAAQ,EAAE,CAAC,KAAK,EAAE,EAAE;4BAClB,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,EAAE,KAAK,CAAC,CAAA;wBACjC,CAAC,EACD,QAAQ,EAAE,GAAG,EAAE;4BACb,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;wBAC3B,CAAC,EACD,UAAU,EAAE,UAAU,EACtB,KAAK,EAAE,KAAK,CAAC,KAAK,IAVb,KAAK,CAAC,EAAE,CAWb,CACH,CACF,GACG,CACP,EAED,eAAK,SAAS,EAAC,sBAAsB,aACnC,KAAC,MAAM,IACL,UAAU,EAAC,SAAS,EACpB,MAAM,EAAC,SAAS,EAChB,OAAO,EAAE,GAAG,EAAE;gCACZ,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,eAAe,EAAE,CAAC,CAAA;4BAC1C,CAAC,EACD,IAAI,EAAC,IAAI,YAER,KAAK,MAAM,CAAC,OAAO,EAAE,GACf,EAET,KAAC,MAAM,IACL,UAAU,EAAC,SAAS,EACpB,MAAM,EAAC,SAAS,EAChB,OAAO,EAAE,GAAG,EAAE;gCACZ,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;4BACxB,CAAC,EACD,IAAI,EAAC,IAAI,YAER,KAAK,MAAM,CAAC,QAAQ,EAAE,GAChB,IACL,IACF,GACD,CACR,CAAA;IAED,qEAAqE;IACrE,oEAAoE;IACpE,gEAAgE;IAChE,OAAO,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CACnB,IAAI,CACL,CAAC,CAAC,CAAC,CACF,cACE,SAAS,EAAE,WAAW,CACpB,6CAA6C,CAC9C,YAEA,IAAI,GACD,CACP,CAAA;AACH,CAAC,CAAA"}
|
package/package.json
CHANGED
|
@@ -55,9 +55,16 @@ the sunken surface so it recedes rather than floating at the same level as its p
|
|
|
55
55
|
|
|
56
56
|
## Driving it
|
|
57
57
|
|
|
58
|
-
Every control is nameable, not a `data-testid`: the combinator
|
|
59
|
-
|
|
60
|
-
required `label` makes it findable. Add a condition, nest a
|
|
61
|
-
row — all reachable by role and name.
|
|
58
|
+
Every control is nameable, not a `data-testid`: the combinator picker is a `Listbox` whose
|
|
59
|
+
trigger is named `Match: <current combinator>`, the leaf controls by their own labels, and each
|
|
60
|
+
delete is an `IconButton` whose required `label` makes it findable. Add a condition, nest a
|
|
61
|
+
group, flip a combinator, delete a row — all reachable by role and name.
|
|
62
|
+
|
|
63
|
+
The picker is a `Listbox` rather than a native `Select`, per
|
|
64
|
+
[the 2026-08-10 demotion](../../../../docs/decisions/2026-08-10-listbox-and-combobox-are-the-default-and-select-is-demoted.md).
|
|
65
|
+
Its name carries the current value because the trigger's visible text *is* that value, and
|
|
66
|
+
WCAG 2.5.3 wants the visible text contained in the accessible name — so query it with
|
|
67
|
+
`getByRole("button", { name: /^Match: / })`, not by the bare caption. Rename the caption with
|
|
68
|
+
`labels.match`.
|
|
62
69
|
|
|
63
70
|
<Canvas of={QueryBuilderStories.Interactive} />
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import type { SerializedTree } from "@charcuterie/logic"
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createTree,
|
|
4
|
+
useTree,
|
|
5
|
+
useVisibility,
|
|
6
|
+
} from "@charcuterie/logic"
|
|
3
7
|
import type { Meta, StoryObj } from "@storybook/react"
|
|
4
8
|
import type { ReactNode } from "react"
|
|
9
|
+
import { useState } from "react"
|
|
10
|
+
import { Button } from "../Button/Button.tsx"
|
|
5
11
|
import { StorySection } from "../board.storyHelpers.tsx"
|
|
6
|
-
import {
|
|
12
|
+
import { Listbox } from "../Listbox/Listbox.tsx"
|
|
7
13
|
import { QueryBuilder } from "./QueryBuilder.tsx"
|
|
8
14
|
|
|
9
15
|
/**
|
|
@@ -46,6 +52,58 @@ const createLeafValue = (): DemoLeaf => ({
|
|
|
46
52
|
value: "",
|
|
47
53
|
})
|
|
48
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
|
+
|
|
49
107
|
const renderLeaf = ({
|
|
50
108
|
onChange,
|
|
51
109
|
value,
|
|
@@ -55,23 +113,21 @@ const renderLeaf = ({
|
|
|
55
113
|
value: DemoLeaf
|
|
56
114
|
}): ReactNode => (
|
|
57
115
|
<div className="flex flex-wrap items-center gap-2">
|
|
58
|
-
<
|
|
116
|
+
<LeafPicker
|
|
59
117
|
label="Field"
|
|
60
118
|
onChange={(field) => {
|
|
61
119
|
onChange({ ...value, field })
|
|
62
120
|
}}
|
|
63
121
|
options={FIELD_OPTIONS}
|
|
64
|
-
size="sm"
|
|
65
122
|
value={value.field}
|
|
66
123
|
/>
|
|
67
124
|
|
|
68
|
-
<
|
|
125
|
+
<LeafPicker
|
|
69
126
|
label="Operator"
|
|
70
127
|
onChange={(operator) => {
|
|
71
128
|
onChange({ ...value, operator })
|
|
72
129
|
}}
|
|
73
130
|
options={OPERATOR_OPTIONS}
|
|
74
|
-
size="sm"
|
|
75
131
|
value={value.operator}
|
|
76
132
|
/>
|
|
77
133
|
|
|
@@ -241,3 +297,133 @@ export const Interactive: Story = {
|
|
|
241
297
|
/>
|
|
242
298
|
),
|
|
243
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
|
+
}
|
|
@@ -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,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
|
+
}
|
|
@@ -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}
|