@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.
@@ -0,0 +1,184 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import type { ReactNode } from "react"
3
+ import { useState } from "react"
4
+
5
+ import {
6
+ controlSizeArgType,
7
+ intentArgType,
8
+ placementArgType,
9
+ } from "../argTypes.storyHelpers.ts"
10
+ import { Badge } from "../Badge/Badge.tsx"
11
+ import {
12
+ StoryRow,
13
+ StorySection,
14
+ } from "../board.storyHelpers.tsx"
15
+ import type { PickerOption } from "./Picker.tsx"
16
+ import { Picker } from "./Picker.tsx"
17
+
18
+ const LANGUAGES: PickerOption[] = [
19
+ { label: "English", value: "eng" },
20
+ { label: "Spanish", value: "spa" },
21
+ { label: "French", value: "fra" },
22
+ { isDisabled: true, label: "German", value: "deu" },
23
+ { label: "Japanese", value: "jpn" },
24
+ ]
25
+
26
+ /**
27
+ * The rich options a native `<option>` cannot render — a trailing
28
+ * intent-coloured badge. No emoji: the VRT runner has no emoji font, so
29
+ * a flag would snapshot as a blank box and make the shot font-dependent.
30
+ */
31
+ const TRACKS: PickerOption[] = [
32
+ {
33
+ label: (
34
+ <span className="flex items-center gap-2">
35
+ <span>English</span>
36
+ <Badge intent="neutral" size="sm">
37
+ 5.1
38
+ </Badge>
39
+ </span>
40
+ ),
41
+ textValue: "English",
42
+ value: "eng",
43
+ },
44
+ {
45
+ label: (
46
+ <span className="flex items-center gap-2">
47
+ <span>Japanese</span>
48
+ <Badge intent="warning" size="sm">
49
+ forced
50
+ </Badge>
51
+ </span>
52
+ ),
53
+ textValue: "Japanese",
54
+ value: "jpn",
55
+ },
56
+ ]
57
+
58
+ const meta = {
59
+ title: "Components/Picker",
60
+ component: Picker,
61
+ parameters: { layout: "padded" },
62
+ argTypes: {
63
+ intent: intentArgType,
64
+ placement: placementArgType,
65
+ size: controlSizeArgType,
66
+ },
67
+ args: {
68
+ label: "Language",
69
+ onChange: () => {},
70
+ options: LANGUAGES,
71
+ },
72
+ } satisfies Meta<typeof Picker>
73
+
74
+ export default meta
75
+
76
+ type Story = StoryObj<typeof meta>
77
+
78
+ const PickerHarness = ({
79
+ initialValue,
80
+ label,
81
+ options,
82
+ placeholder,
83
+ }: {
84
+ initialValue?: string
85
+ label: string
86
+ options: PickerOption[]
87
+ placeholder?: string
88
+ }): ReactNode => {
89
+ const [value, setValue] = useState<string | undefined>(
90
+ initialValue,
91
+ )
92
+
93
+ return (
94
+ <Picker
95
+ label={label}
96
+ onChange={setValue}
97
+ options={options}
98
+ placeholder={placeholder}
99
+ value={value}
100
+ />
101
+ )
102
+ }
103
+
104
+ /**
105
+ * The assembled default: a button showing the current value, a
106
+ * chevron, and the `Listbox` panel already wired to it.
107
+ */
108
+ export const Default: Story = {
109
+ render: () => (
110
+ <PickerHarness
111
+ initialValue="eng"
112
+ label="Language"
113
+ options={LANGUAGES}
114
+ />
115
+ ),
116
+ }
117
+
118
+ /**
119
+ * Nothing chosen yet, a disabled option, and rich option labels —
120
+ * each with a `textValue` so type-ahead and the trigger's text stay
121
+ * plain strings.
122
+ */
123
+ export const AllStates: Story = {
124
+ render: () => (
125
+ <div className="flex flex-col gap-6">
126
+ <StorySection title="Seeded">
127
+ <StoryRow>
128
+ <PickerHarness
129
+ initialValue="spa"
130
+ label="Language"
131
+ options={LANGUAGES}
132
+ />
133
+ </StoryRow>
134
+ </StorySection>
135
+
136
+ <StorySection title="Empty — reads its placeholder">
137
+ <StoryRow>
138
+ <PickerHarness
139
+ label="Language"
140
+ options={LANGUAGES}
141
+ placeholder="Choose a language…"
142
+ />
143
+ </StoryRow>
144
+ </StorySection>
145
+
146
+ <StorySection title="Rich options">
147
+ <StoryRow>
148
+ <PickerHarness
149
+ initialValue="eng"
150
+ label="Audio track"
151
+ options={TRACKS}
152
+ />
153
+ </StoryRow>
154
+ </StorySection>
155
+
156
+ <StorySection title="Disabled">
157
+ <StoryRow>
158
+ <Picker
159
+ isDisabled
160
+ label="Language"
161
+ onChange={() => {}}
162
+ options={LANGUAGES}
163
+ value="eng"
164
+ />
165
+ </StoryRow>
166
+ </StorySection>
167
+ </div>
168
+ ),
169
+ }
170
+
171
+ /**
172
+ * Drive it: open, choose, and watch the trigger's accessible name
173
+ * follow the value — `"Language: English"` becomes
174
+ * `"Language: French"`.
175
+ */
176
+ export const Interactive: Story = {
177
+ render: () => (
178
+ <PickerHarness
179
+ initialValue="eng"
180
+ label="Language"
181
+ options={LANGUAGES}
182
+ />
183
+ ),
184
+ }
@@ -0,0 +1,98 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./Picker.stories.tsx"
9
+
10
+ const { AllStates, Default, Interactive } =
11
+ composeStories(stories)
12
+
13
+ test("the trigger's accessible name carries the current value", async () => {
14
+ const { canvas, canvasElement } =
15
+ await mountStory(Default)
16
+
17
+ // Not a bare "Language": the button's VISIBLE text is the value, and
18
+ // WCAG 2.5.3 wants the visible text inside the accessible name.
19
+ const trigger = expectAgentDrivable(canvas, {
20
+ name: "Language: English",
21
+ role: "button",
22
+ })
23
+
24
+ await expect(trigger).toHaveAttribute(
25
+ "aria-haspopup",
26
+ "listbox",
27
+ )
28
+
29
+ await expectNoAxeViolations(canvasElement)
30
+ })
31
+
32
+ test("choosing an option updates the trigger", async () => {
33
+ const { body, canvas } = await mountStory(Interactive)
34
+
35
+ await userEvent.click(
36
+ expectAgentDrivable(canvas, {
37
+ name: "Language: English",
38
+ role: "button",
39
+ }),
40
+ )
41
+
42
+ await userEvent.click(
43
+ expectAgentDrivable(body, {
44
+ name: "French",
45
+ role: "option",
46
+ }),
47
+ )
48
+
49
+ await waitFor(() => {
50
+ expectAgentDrivable(canvas, {
51
+ name: "Language: French",
52
+ role: "button",
53
+ })
54
+ })
55
+ })
56
+
57
+ test("a picker with no value reads its placeholder", async () => {
58
+ const { canvas } = await mountStory(AllStates)
59
+
60
+ expectAgentDrivable(canvas, {
61
+ name: "Language: Choose a language…",
62
+ role: "button",
63
+ })
64
+ })
65
+
66
+ test("a disabled picker does not open", async () => {
67
+ const { body, canvas } = await mountStory(AllStates)
68
+
69
+ const disabledTrigger = canvas
70
+ .getAllByRole("button", { name: "Language: English" })
71
+ .find((button: HTMLElement) =>
72
+ button.hasAttribute("disabled"),
73
+ )
74
+
75
+ await expect(disabledTrigger).toBeDefined()
76
+
77
+ await userEvent.click(disabledTrigger as HTMLElement, {
78
+ pointerEventsCheck: 0,
79
+ })
80
+
81
+ await expect(body.queryAllByRole("listbox")).toHaveLength(
82
+ 0,
83
+ )
84
+ })
85
+
86
+ test("a rich option label keeps a plain-string trigger via textValue", async () => {
87
+ const { canvas, canvasElement } =
88
+ await mountStory(AllStates)
89
+
90
+ // The label is JSX (a flag, a badge); `textValue` is what the name
91
+ // and the type-ahead use, so the trigger does not read "🇬🇧 English 5.1".
92
+ expectAgentDrivable(canvas, {
93
+ name: "Audio track: English",
94
+ role: "button",
95
+ })
96
+
97
+ await expectNoAxeViolations(canvasElement)
98
+ })
@@ -0,0 +1,186 @@
1
+ import { useVisibility } from "@charcuterie/logic"
2
+ import type {
3
+ ControlSize,
4
+ IntentName,
5
+ } from "@charcuterie/tokens"
6
+ import type { Placement } from "@floating-ui/react"
7
+ import type {
8
+ ComponentPropsWithRef,
9
+ ReactNode,
10
+ } from "react"
11
+
12
+ import { Button } from "../Button/Button.tsx"
13
+ import type { IntentAppearance } from "../intentStyles.ts"
14
+ import type { ListboxItem } from "../Listbox/Listbox.tsx"
15
+ import { Listbox } from "../Listbox/Listbox.tsx"
16
+
17
+ export type PickerOption = ListboxItem
18
+
19
+ /**
20
+ * A constant element rather than a component, for the same reason
21
+ * `Select` inlines its own: a second component in this file trips the
22
+ * house rule, and this never varies.
23
+ */
24
+ const CHEVRON_DOWN = (
25
+ <svg
26
+ aria-hidden="true"
27
+ className="size-4"
28
+ fill="none"
29
+ focusable={false}
30
+ stroke="currentColor"
31
+ strokeLinecap="round"
32
+ strokeLinejoin="round"
33
+ strokeWidth={1.75}
34
+ viewBox="0 0 24 24"
35
+ xmlns="http://www.w3.org/2000/svg"
36
+ >
37
+ <path d="m6 9 6 6 6-6" />
38
+ </svg>
39
+ )
40
+
41
+ export type PickerProps = Omit<
42
+ ComponentPropsWithRef<"button">,
43
+ "disabled" | "onChange" | "value"
44
+ > & {
45
+ appearance?: IntentAppearance
46
+ className?: string
47
+ intent?: IntentName
48
+ isDisabled?: boolean
49
+ /**
50
+ * What the control is FOR — "Language", "Quantifier", "Rule type".
51
+ * Not the current value; that is read off `options`.
52
+ */
53
+ label: string
54
+ /**
55
+ * Defaults to a chevron. Pass `null` to drop it — a picker rendered
56
+ * inside a denser control row may not want the affordance, and a
57
+ * refactor moving an existing hand-rolled trigger onto `Picker`
58
+ * stays pixel-identical by passing `null` rather than re-baselining
59
+ * every screenshot that contains it.
60
+ */
61
+ iconEnd?: ReactNode
62
+ onChange: (value: string) => void
63
+ options: readonly PickerOption[]
64
+ placement?: Placement
65
+ /** Read by the trigger when `value` matches no option. */
66
+ placeholder?: string
67
+ size?: ControlSize
68
+ value?: string
69
+ }
70
+
71
+ /**
72
+ * A `Listbox` with the trigger already attached — the shape every app
73
+ * reaches for when it wants "a select, but ours".
74
+ *
75
+ * ### Four repos wrote this, and the fourth is this package
76
+ *
77
+ * `Listbox` is deliberately trigger-agnostic: it takes a `trigger`
78
+ * element and owns none of the open state, which is what lets it hang
79
+ * off a button, a tile, a table header. The cost is that the *common*
80
+ * case — a button showing the current value — is about thirty lines,
81
+ * and the fleet wrote them four times: queuepilot's `SelectListbox`,
82
+ * board-games' `SelectMenu` (on `useState` rather than
83
+ * `useVisibility`), mux-magic's `ListboxPicker`, and — the tell —
84
+ * **twice inside this package**, in `QueryBuilderCombinator` and in
85
+ * `QueryBuilder`'s own story. Each also hand-rolled the same chevron.
86
+ *
87
+ * `Listbox` stays exactly as it is. This is the assembled default.
88
+ *
89
+ * ### Why it is not called `SinglePicker`
90
+ *
91
+ * The state layer has `useSinglePicker` and `useMultiplePicker`, so a
92
+ * name near "picker" invites the question of whether this is their
93
+ * component. It is not, and no component is: `useSinglePicker` is a
94
+ * state kind that `Listbox`, `Combobox` and `Tabs` all compose. This
95
+ * component owns a `useVisibility` and delegates selection to
96
+ * `Listbox`, which is where the picker state actually lives. The name
97
+ * is the owner's, chosen over `SelectListbox` and `ListboxButton`.
98
+ *
99
+ * ### The accessible name carries the current value
100
+ *
101
+ * `"Language: English"`, not `"Language"`. The trigger's visible text
102
+ * IS the current value, and WCAG 2.5.3 requires the visible text to be
103
+ * contained in the accessible name — so a bare `aria-label={label}`
104
+ * fails it, which is a latent defect in one of the hand-rolled
105
+ * versions this replaces. It also makes each control uniquely findable
106
+ * when a form has several, which is the property `expectAgentDrivable`
107
+ * exists to protect.
108
+ *
109
+ * Query it as `getByRole("button", { name: /^Language: / })`.
110
+ *
111
+ * ### `id` does not survive; `data-testid` does
112
+ *
113
+ * `useAnchoredOverlay` overwrites the trigger's `id` with a generated
114
+ * one, so the portalled listbox can point `aria-labelledby` across at
115
+ * it. An `id` passed here is therefore silently replaced — queuepilot
116
+ * found this the hard way and switched its e2e handles to
117
+ * `data-testid`, which nothing injects. Everything else on the button
118
+ * (`data-*`, `onClick`, `form`, …) passes straight through.
119
+ */
120
+ export const Picker = ({
121
+ appearance = "outline",
122
+ className,
123
+ iconEnd = CHEVRON_DOWN,
124
+ intent = "neutral",
125
+ isDisabled = false,
126
+ label,
127
+ onChange,
128
+ options,
129
+ placement,
130
+ placeholder,
131
+ size,
132
+ value,
133
+ ...buttonProps
134
+ }: PickerProps): ReactNode => {
135
+ const { hide, isVisible, toggle } = useVisibility()
136
+
137
+ const currentOption = options.find(
138
+ (option) => option.value === value,
139
+ )
140
+
141
+ const currentText =
142
+ currentOption === undefined
143
+ ? placeholder
144
+ : (currentOption.textValue ??
145
+ (typeof currentOption.label === "string"
146
+ ? currentOption.label
147
+ : undefined))
148
+
149
+ const triggerText = currentText ?? placeholder ?? ""
150
+
151
+ return (
152
+ <Listbox
153
+ // A disabled control must not open, and hiding the panel is a
154
+ // truer way to say that than trusting the trigger's click alone —
155
+ // Escape and outside-press both route through `onDismiss`.
156
+ isVisible={isVisible && !isDisabled}
157
+ onDismiss={hide}
158
+ onSelect={onChange}
159
+ options={options}
160
+ placement={placement}
161
+ selectedValue={value}
162
+ trigger={
163
+ <Button
164
+ {...buttonProps}
165
+ appearance={appearance}
166
+ aria-label={`${label}: ${triggerText}`}
167
+ className={className}
168
+ iconEnd={iconEnd}
169
+ intent={intent}
170
+ isDisabled={isDisabled}
171
+ onClick={(clickEvent) => {
172
+ buttonProps.onClick?.(clickEvent)
173
+
174
+ toggle()
175
+ }}
176
+ size={size}
177
+ type="button"
178
+ >
179
+ {currentOption === undefined
180
+ ? placeholder
181
+ : currentOption.label}
182
+ </Button>
183
+ }
184
+ />
185
+ )
186
+ }
@@ -1,15 +1,10 @@
1
1
  import type { SerializedTree } from "@charcuterie/logic"
2
- import {
3
- createTree,
4
- useTree,
5
- useVisibility,
6
- } from "@charcuterie/logic"
2
+ import { createTree, useTree } from "@charcuterie/logic"
7
3
  import type { Meta, StoryObj } from "@storybook/react"
8
4
  import type { ReactNode } from "react"
9
5
  import { useState } from "react"
10
- import { Button } from "../Button/Button.tsx"
11
6
  import { StorySection } from "../board.storyHelpers.tsx"
12
- import { Listbox } from "../Listbox/Listbox.tsx"
7
+ import { Picker } from "../Picker/Picker.tsx"
13
8
  import { QueryBuilder } from "./QueryBuilder.tsx"
14
9
 
15
10
  /**
@@ -52,58 +47,6 @@ const createLeafValue = (): DemoLeaf => ({
52
47
  value: "",
53
48
  })
54
49
 
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
-
107
50
  const renderLeaf = ({
108
51
  onChange,
109
52
  value,
@@ -113,21 +56,25 @@ const renderLeaf = ({
113
56
  value: DemoLeaf
114
57
  }): ReactNode => (
115
58
  <div className="flex flex-wrap items-center gap-2">
116
- <LeafPicker
59
+ <Picker
60
+ iconEnd={null}
117
61
  label="Field"
118
62
  onChange={(field) => {
119
63
  onChange({ ...value, field })
120
64
  }}
121
65
  options={FIELD_OPTIONS}
66
+ size="sm"
122
67
  value={value.field}
123
68
  />
124
69
 
125
- <LeafPicker
70
+ <Picker
71
+ iconEnd={null}
126
72
  label="Operator"
127
73
  onChange={(operator) => {
128
74
  onChange({ ...value, operator })
129
75
  }}
130
76
  options={OPERATOR_OPTIONS}
77
+ size="sm"
131
78
  value={value.operator}
132
79
  />
133
80
 
@@ -357,7 +304,8 @@ const CombinatorPair = ({
357
304
  </span>
358
305
 
359
306
  <div className="flex items-center gap-1.5">
360
- <LeafPicker
307
+ <Picker
308
+ iconEnd={null}
361
309
  label="Quantifier"
362
310
  onChange={(nextQuantifier) => {
363
311
  setQuantifier(nextQuantifier)
@@ -374,10 +322,12 @@ const CombinatorPair = ({
374
322
  )
375
323
  }}
376
324
  options={QUANTIFIER_OPTIONS}
325
+ size="sm"
377
326
  value={quantifier}
378
327
  />
379
328
 
380
- <LeafPicker
329
+ <Picker
330
+ iconEnd={null}
381
331
  label="Target"
382
332
  onChange={(nextTarget) => {
383
333
  setTarget(nextTarget)
@@ -389,6 +339,7 @@ const CombinatorPair = ({
389
339
  )
390
340
  }}
391
341
  options={targetOptions}
342
+ size="sm"
392
343
  value={resolvedTarget}
393
344
  />
394
345
  </div>