@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.
@@ -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;AASD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,YAAY,GAAI,UAAU,EAAE,IAAI,EAAE,mEAM5C,iBAAiB,CAAC,UAAU,EAAE,IAAI,CAAC,KAAG,SAiBxC,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;AA0C3D,MAAM,cAAc,GAAG;IACrB,QAAQ,EAAE,WAAW;IACrB,OAAO,EAAE,eAAe;IACxB,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,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,UAAU,EAAE,UAAU,EACtB,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,IAAI,GACV,CACH,CAAA;AACH,CAAC,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 select, its children (leaf
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 combinator is opaque, so the native `Select` (which speaks
29
- * strings) is bridged by `String(value)`: options carry the
30
- * stringified combinator, and a change is mapped back to the real
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;AAQtC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAG5D,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,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;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,EAAE,IAAI,EAAE,uFASjD,sBAAsB,CAAC,UAAU,EAAE,IAAI,CAAC,KAAG,SA6H7C,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 select, its children (leaf
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 combinator is opaque, so the native `Select` (which speaks
17
- * strings) is bridged by `String(value)`: options carry the
18
- * stringified combinator, and a change is mapped back to the real
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 selectOptions = combinatorOptions.map((option) => ({
27
- label: option.label,
28
- value: String(option.value),
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: _jsx(Field, { label: "Match", children: _jsx(Select, { onChange: (nextValue) => {
37
- const match = combinatorOptions.find((option) => String(option.value) === nextValue);
38
- if (match) {
39
- tree.setCombinator(node.id, match.value);
40
- }
41
- }, options: selectOptions, value: String(node.combinator) }) }) }), depth === 0 ? null : (_jsx(IconButton, { appearance: "ghost", intent: "danger", label: labels.removeGroup, onClick: () => {
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,KAAK,EAAE,MAAM,mBAAoB,CAAA;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,6BAA8B,CAAA;AACzD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAsB,CAAA;AAC7C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAE/C,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAuB,CAAA;AAqBvD;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAmB,EAClD,iBAAiB,EACjB,eAAe,EACf,KAAK,EACL,MAAM,EACN,IAAI,EACJ,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,iBAAiB,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QACvD,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC;KAC5B,CAAC,CAAC,CAAA;IAEH,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,YACvB,KAAC,KAAK,IAAC,KAAK,EAAC,OAAO,YAClB,KAAC,MAAM,IACL,QAAQ,EAAE,CAAC,SAAS,EAAE,EAAE;wCACtB,MAAM,KAAK,GAAG,iBAAiB,CAAC,IAAI,CAClC,CAAC,MAAM,EAAE,EAAE,CACT,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,SAAS,CACrC,CAAA;wCAED,IAAI,KAAK,EAAE,CAAC;4CACV,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,EAAE,KAAK,CAAC,KAAK,CAAC,CAAA;wCAC1C,CAAC;oCACH,CAAC,EACD,OAAO,EAAE,aAAa,EACtB,KAAK,EAAE,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,GAC9B,GACI,GACJ,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,UAAU,EAAE,UAAU,EACtB,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,IAAI,IALL,KAAK,CAAC,EAAE,CAMb,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"}
1
+ {"version":3,"file":"QueryBuilderGroup.js","sourceRoot":"","sources":["../../src/QueryBuilder/QueryBuilderGroup.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 select is named `Match` by the
59
- field label, the leaf controls by their own labels, and each delete is an `IconButton` whose
60
- required `label` makes it findable. Add a condition, nest a group, flip a combinator, delete 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 { createTree, useTree } from "@charcuterie/logic"
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 { Select } from "../Select/Select.tsx"
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
- <Select
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
- <Select
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 { AllStates, Default, Interactive } =
11
- composeStories(stories)
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 select — findable by
18
- // the label `Field` wires, not a testid.
19
- expectAgentDrivable(canvas, {
20
- name: "Match",
21
- role: "combobox",
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("combobox", { name: "Match" }),
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("combobox", { name: "Match" }),
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("changing the combinator select updates its value", async () => {
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
- await expect(combinator).toHaveValue("and")
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.selectOptions(
94
- combinator,
95
- "ANY — one condition",
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
- expect(combinator).toHaveValue("or")
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 selects, which is the nesting rendering.
144
+ // three combinator pickers, which is the nesting rendering.
126
145
  await expect(
127
- canvas.getAllByRole("combobox", { name: "Match" })
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 select, its children (leaf
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 combinator is opaque, so the native `Select` (which speaks
44
- * strings) is bridged by `String(value)`: options carry the
45
- * stringified combinator, and a change is mapped back to the real
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 selectOptions = combinatorOptions.map((option) => ({
64
- label: option.label,
65
- value: String(option.value),
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
- <Field label="Match">
78
- <Select
79
- onChange={(nextValue) => {
80
- const match = combinatorOptions.find(
81
- (option) =>
82
- String(option.value) === nextValue,
83
- )
84
-
85
- if (match) {
86
- tree.setCombinator(node.id, match.value)
87
- }
88
- }}
89
- options={selectOptions}
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
- </Field>
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}