@charcuterie/ui 2.14.0 → 2.15.1

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.
@@ -1,8 +1,6 @@
1
- import { useVisibility } from "@charcuterie/logic"
2
1
  import type { ReactNode } from "react"
3
2
 
4
- import { Button } from "../Button/Button.tsx"
5
- import { Listbox } from "../Listbox/Listbox.tsx"
3
+ import { Picker } from "../Picker/Picker.tsx"
6
4
 
7
5
  export type QueryBuilderCombinatorProps<Combinator> = {
8
6
  /** The visible caption above the control, e.g. "Match". */
@@ -18,91 +16,54 @@ export type QueryBuilderCombinatorProps<Combinator> = {
18
16
  /**
19
17
  * One group's combinator picker.
20
18
  *
21
- * A `Listbox`, not a native `Select`, per
19
+ * A `Picker` — a `Listbox` with its trigger attached — rather than a
20
+ * native `Select`, per
22
21
  * [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.
22
+ * This file used to assemble the trigger by hand; `Picker` exists
23
+ * because it was the fourth place in the fleet doing so.
28
24
  *
29
- * ### Why this is its own file
25
+ * ### What is left here, and why it is still a component
30
26
  *
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.
27
+ * Only the bridging. `Combinator` is opaque to `QueryBuilder`, so it
28
+ * cannot reach `Picker`'s string-valued API directly: `String(value)`
29
+ * carries it out and the matching option carries it back, so the
30
+ * generic never leaks. The caption is plain text rather than a
31
+ * `Field`, because `useAnchoredOverlay` overwrites the trigger's `id`
32
+ * and a `<label htmlFor>` would then address an element that no longer
33
+ * carries it — the exact defect `Field` exists to prevent.
57
34
  */
58
35
  export const QueryBuilderCombinator = <Combinator,>({
59
36
  label,
60
37
  onChange,
61
38
  options,
62
39
  value,
63
- }: QueryBuilderCombinatorProps<Combinator>): ReactNode => {
64
- const { hide, isVisible, toggle } = useVisibility()
40
+ }: QueryBuilderCombinatorProps<Combinator>): ReactNode => (
41
+ <div className="flex flex-col gap-1.5">
42
+ <span className="font-medium text-content-primary text-sm">
43
+ {label}
44
+ </span>
65
45
 
66
- const currentOption = options.find(
67
- (option) => String(option.value) === String(value),
68
- )
46
+ <Picker
47
+ // No chevron: this is a refactor onto the shared component, and
48
+ // the trigger that shipped in 2.14.0 had none. Adding the
49
+ // affordance here is a visual change and belongs in its own PR,
50
+ // not smuggled in under an extraction.
51
+ iconEnd={null}
52
+ label={label}
53
+ onChange={(nextValue) => {
54
+ const match = options.find(
55
+ (option) => String(option.value) === nextValue,
56
+ )
69
57
 
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>
58
+ if (match) {
59
+ onChange(match.value)
104
60
  }
105
- />
106
- </div>
107
- )
108
- }
61
+ }}
62
+ options={options.map((option) => ({
63
+ label: option.label,
64
+ value: String(option.value),
65
+ }))}
66
+ value={String(value)}
67
+ />
68
+ </div>
69
+ )
package/src/index.ts CHANGED
@@ -160,6 +160,11 @@ export type {
160
160
  export { Menu } from "./Menu/Menu.tsx"
161
161
  export type { ModalProps } from "./Modal/Modal.tsx"
162
162
  export { Modal } from "./Modal/Modal.tsx"
163
+ export type {
164
+ PickerOption,
165
+ PickerProps,
166
+ } from "./Picker/Picker.tsx"
167
+ export { Picker } from "./Picker/Picker.tsx"
163
168
  export type { PopoverProps } from "./Popover/Popover.tsx"
164
169
  export { Popover } from "./Popover/Popover.tsx"
165
170
  export type {
@@ -373,7 +373,13 @@ test("the barrel is the only place components are re-exported", async () => {
373
373
  // both are rendered inside a `.map` — and stay out of this count and
374
374
  // the barrel by the `<Name>/<Name>.tsx` rule, exactly as
375
375
  // `SegmentedOption` and `ListboxOption` do.
376
- expect(componentNames.length).toBe(44)
376
+ //
377
+ // `Picker` — a `Listbox` with its trigger already attached — is +1 ->
378
+ // 45. It is a component rather than a recipe in the docs because the
379
+ // fleet wrote it four separate times (plex-channels' `SelectListbox`,
380
+ // board-games' `SelectMenu`, mux-magic's `ListboxPicker`, and twice
381
+ // inside this package), each with its own hand-rolled chevron.
382
+ expect(componentNames.length).toBe(45)
377
383
 
378
384
  for (const name of componentNames) {
379
385
  expect(barrel).toContain(`export { ${name} }`)
@@ -457,17 +463,22 @@ const ENTRY_POINT_RUNTIMES: Record<
457
463
  "./core": [],
458
464
  "./jotai": ["jotai"],
459
465
  "./preact": ["preact", "preact/hooks"],
460
- // The request/response data layer. It reaches react-query, the
461
- // two openapi-* primitives it re-exports, and `react` (the
462
- // provider's `useState`) — all optional peers, so an app that
463
- // never imports `./query` resolves none of them. `react/jsx-runtime`
464
- // is added only at emit, so it is not a source specifier here.
465
- "./query": [
466
- "@tanstack/react-query",
467
- "openapi-fetch",
468
- "openapi-react-query",
469
- "react",
470
- ],
466
+ // The typed OpenAPI seam, split out of `./query` so that only an
467
+ // app whose backend publishes a spec resolves these two. This
468
+ // table is the thing that makes that split legible: what the row
469
+ // costs is the whole argument for it being its own entry point.
470
+ // See the [split
471
+ // decision](../../../docs/decisions/2026-08-13-the-openapi-seam-is-its-own-subpath-not-part-of-query.md).
472
+ "./openapi": ["openapi-fetch", "openapi-react-query"],
473
+ // The request/response data layer: react-query and `react` (the
474
+ // provider's `useState`), both optional peers, so an app that
475
+ // never imports `./query` resolves neither. It deliberately does
476
+ // NOT reach the openapi-* packages — when it did (via the
477
+ // re-export this row used to list), the type-only edge above was
478
+ // enough to force every non-OpenAPI consumer to install both.
479
+ // `react/jsx-runtime` is added only at emit, so it is not a
480
+ // source specifier here.
481
+ "./query": ["@tanstack/react-query", "react"],
471
482
  "./signals": ["@preact/signals-core"],
472
483
  },
473
484
  tokens: {