@charcuterie/ui 4.26.2 → 4.27.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,70 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks"
2
+
3
+ import * as ColorPickerStories from "./ColorPicker.stories.tsx"
4
+
5
+ <Meta of={ColorPickerStories} />
6
+
7
+ # ColorPicker
8
+
9
+ A named color from the app's list, or a color of the person's own. The presets are a
10
+ searchable `Combobox`, each row a `Swatch` and its name; an optional **Custom color…** row
11
+ opens a panel with the browser's color wheel and a hex field.
12
+
13
+ <Canvas of={ColorPickerStories.Playground} />
14
+
15
+ ## Props
16
+
17
+ <Controls />
18
+
19
+ ## When to use it, and when not
20
+
21
+ | You have | Use |
22
+ | --- | --- |
23
+ | A palette the **app** owns, maybe plus colors a person makes up | `ColorPicker` |
24
+ | A choice from the library's ten `categorical` hues | A row of `Swatch` buttons over `--color-categorical-<n>-solid`, as Docket's `CategoricalPicker` does |
25
+ | A color that arrived from the world and is only shown | `Swatch` |
26
+
27
+ The presets are data, so the result is a `Swatch`-class color, not a token: it does not
28
+ re-theme and the library cannot contrast-check it. That is why `getColorError` exists. The
29
+ app knows where the color is drawn and what it has to clear; the component only shows the
30
+ app's sentence and refuses to apply.
31
+
32
+ ## Typing narrows the list
33
+
34
+ A palette worth offering is long, and siblings may all want a different pink. Typing a name
35
+ narrows the rows to that family; typing a hex code finds its preset. The **Custom color…**
36
+ row is always listed, whatever the search says, and a valid code in the search opens the
37
+ custom panel on that code.
38
+
39
+ <Canvas of={ColorPickerStories.Interactive} />
40
+
41
+ ## The custom panel
42
+
43
+ The browser's `<input type="color">` is allowed here and only here. It is the custom
44
+ **mode**, inside a panel this component owns — never the whole picker. The wheel, the
45
+ eyedropper, and the sliders are worth keeping, and the presets are what the native control
46
+ alone could never offer.
47
+
48
+ Nothing is applied while a person drags the wheel. The panel holds a draft: **Use color** or
49
+ Enter applies it, and Escape or **Cancel** drops it. A custom color answers `#RRGGBB` in
50
+ upper case, so the app stores one spelling for one color.
51
+
52
+ <Canvas of={ColorPickerStories.CustomPanel} />
53
+
54
+ ## The trigger says the name, or the code
55
+
56
+ `"Color: Hot Pink"` for a preset and `"Color: #C2185B"` for a custom color. The visible text
57
+ is in the accessible name (WCAG 2.5.3), as with `Picker`. Query it as:
58
+
59
+ ```tsx
60
+ getByRole("button", { name: /^Color: / })
61
+ ```
62
+
63
+ <Canvas of={ColorPickerStories.AllStates} />
64
+
65
+ ## Inside a `Field`
66
+
67
+ `Field` clones its `id` and `aria-describedby` onto the trigger button, and the trigger
68
+ stretches to the container with the swatch and name at the start and the chevron at the end.
69
+
70
+ <Canvas of={ColorPickerStories.Responsive} />
@@ -0,0 +1,289 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import type { ReactNode } from "react"
4
+ import { useState } from "react"
5
+ import { userEvent, within } from "storybook/test"
6
+
7
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
8
+ import {
9
+ ContainerBoard,
10
+ StoryRow,
11
+ StorySection,
12
+ } from "../board.storyHelpers.tsx"
13
+ import { Field } from "../Field/Field.tsx"
14
+ import type { ColorPreset } from "./ColorPicker.tsx"
15
+ import { ColorPicker } from "./ColorPicker.tsx"
16
+ import { parseHexColor } from "./parseHexColor.ts"
17
+
18
+ /**
19
+ * A palette with several shades of one hue on purpose: the case that
20
+ * made this a searchable list is a family where every child wants a
21
+ * different pink.
22
+ */
23
+ const PRESETS: ColorPreset[] = [
24
+ { name: "Red", value: "#E63946" },
25
+ {
26
+ keywords: ["pink", "red"],
27
+ name: "Raspberry",
28
+ value: "#DB2777",
29
+ },
30
+ {
31
+ keywords: ["pink"],
32
+ name: "Hot Pink",
33
+ value: "#FF006E",
34
+ },
35
+ { keywords: ["pink"], name: "Blush", value: "#FB7185" },
36
+ {
37
+ keywords: ["pink"],
38
+ name: "Bubblegum",
39
+ value: "#FF69B4",
40
+ },
41
+ {
42
+ keywords: ["pink"],
43
+ name: "Cotton Candy",
44
+ value: "#F9A8D4",
45
+ },
46
+ {
47
+ keywords: ["purple"],
48
+ name: "Lavender",
49
+ value: "#A78BFA",
50
+ },
51
+ { name: "Purple", value: "#8338EC" },
52
+ { name: "Blue", value: "#3A86FF" },
53
+ {
54
+ keywords: ["green", "blue"],
55
+ name: "Teal",
56
+ value: "#2A9D8F",
57
+ },
58
+ { name: "Green", value: "#22C55E" },
59
+ { name: "Yellow", value: "#FFBE0B" },
60
+ { name: "Orange", value: "#FB5607" },
61
+ { name: "Brown", value: "#B5651D" },
62
+ ]
63
+
64
+ /**
65
+ * A stand-in for an app's own rule. This one refuses grays and
66
+ * near-whites, the way a reader board that draws the color on a dark
67
+ * panel would.
68
+ */
69
+ const getStoryColorError = (hex: string): null | string => {
70
+ const digits = (parseHexColor(hex) ?? "").slice(1)
71
+ const channels = [0, 2, 4].map(
72
+ (start) =>
73
+ Number.parseInt(digits.slice(start, start + 2), 16) /
74
+ 255,
75
+ )
76
+ const highest = Math.max(...channels)
77
+ const lowest = Math.min(...channels)
78
+
79
+ if (highest - lowest < 0.2) {
80
+ return "Pick a color with more hue. Gray, white, and black do not show on the board."
81
+ }
82
+
83
+ return null
84
+ }
85
+
86
+ const meta = {
87
+ title: "Components/Controls/ColorPicker",
88
+ component: ColorPicker,
89
+ parameters: { layout: "padded" },
90
+ argTypes: {
91
+ size: controlSizeArgType,
92
+ },
93
+ args: {
94
+ isCustomAllowed: true,
95
+ label: "Color",
96
+ onChange: () => {},
97
+ presets: PRESETS,
98
+ },
99
+ } satisfies Meta<typeof ColorPicker>
100
+
101
+ export default meta
102
+
103
+ type Story = StoryObj<typeof meta>
104
+
105
+ const ColorPickerHarness = ({
106
+ hasColorRule = false,
107
+ initialValue,
108
+ isCustomAllowed = true,
109
+ label,
110
+ }: {
111
+ hasColorRule?: boolean
112
+ initialValue?: string
113
+ isCustomAllowed?: boolean
114
+ label: string
115
+ }): ReactNode => {
116
+ const [value, setValue] = useState(initialValue)
117
+
118
+ return (
119
+ <div className="flex flex-col gap-2">
120
+ <ColorPicker
121
+ getColorError={
122
+ hasColorRule ? getStoryColorError : undefined
123
+ }
124
+ isCustomAllowed={isCustomAllowed}
125
+ label={label}
126
+ onChange={setValue}
127
+ presets={PRESETS}
128
+ value={value}
129
+ />
130
+ <p className="text-content-muted text-xs">
131
+ Value: {value ?? "none"}
132
+ </p>
133
+ </div>
134
+ )
135
+ }
136
+
137
+ /** A preset chosen, custom colors allowed, and an app rule. */
138
+ export const Playground: Story = {
139
+ parameters: playgroundParameters,
140
+ render: () => (
141
+ <ColorPickerHarness
142
+ hasColorRule
143
+ initialValue="#FF006E"
144
+ label="Color"
145
+ />
146
+ ),
147
+ }
148
+
149
+ /** The three sizes, each reading a density-aware control height. */
150
+ export const AllVariants: Story = {
151
+ render: () => (
152
+ <StorySection title="Sizes">
153
+ <StoryRow>
154
+ {(["sm", "md", "lg"] as const).map((size) => (
155
+ <ColorPicker
156
+ key={size}
157
+ label={`Color ${size}`}
158
+ onChange={() => {}}
159
+ presets={PRESETS}
160
+ size={size}
161
+ value="#3A86FF"
162
+ />
163
+ ))}
164
+ </StoryRow>
165
+ </StorySection>
166
+ ),
167
+ }
168
+
169
+ /**
170
+ * A preset, a custom color (the trigger reads the code), nothing chosen
171
+ * yet, presets only, and disabled.
172
+ */
173
+ export const AllStates: Story = {
174
+ render: () => (
175
+ <div className="flex flex-col gap-6">
176
+ <StorySection title="A preset">
177
+ <StoryRow>
178
+ <ColorPickerHarness
179
+ initialValue="#FF69B4"
180
+ label="Preset color"
181
+ />
182
+ </StoryRow>
183
+ </StorySection>
184
+
185
+ <StorySection title="A custom color reads its code">
186
+ <StoryRow>
187
+ <ColorPickerHarness
188
+ initialValue="#C2185B"
189
+ label="Custom color"
190
+ />
191
+ </StoryRow>
192
+ </StorySection>
193
+
194
+ <StorySection title="Nothing chosen">
195
+ <StoryRow>
196
+ <ColorPickerHarness label="Empty color" />
197
+ </StoryRow>
198
+ </StorySection>
199
+
200
+ <StorySection title="Presets only">
201
+ <StoryRow>
202
+ <ColorPickerHarness
203
+ initialValue="#22C55E"
204
+ isCustomAllowed={false}
205
+ label="Presets-only color"
206
+ />
207
+ </StoryRow>
208
+ </StorySection>
209
+
210
+ <StorySection title="Disabled">
211
+ <StoryRow>
212
+ <ColorPicker
213
+ isDisabled
214
+ label="Disabled color"
215
+ onChange={() => {}}
216
+ presets={PRESETS}
217
+ value="#8338EC"
218
+ />
219
+ </StoryRow>
220
+ </StorySection>
221
+ </div>
222
+ ),
223
+ }
224
+
225
+ /**
226
+ * The custom panel, opened: the browser's color wheel beside a hex
227
+ * field, and an app rule refusing a gray.
228
+ */
229
+ export const CustomPanel: Story = {
230
+ render: () => (
231
+ <ColorPickerHarness
232
+ hasColorRule
233
+ initialValue="#808080"
234
+ label="Rule color"
235
+ />
236
+ ),
237
+ play: async ({ canvasElement }) => {
238
+ const canvas = within(canvasElement)
239
+
240
+ await userEvent.click(
241
+ canvas.getByRole("button", {
242
+ name: "Rule color: #808080",
243
+ }),
244
+ )
245
+
246
+ await userEvent.click(
247
+ within(canvasElement.ownerDocument.body).getByRole(
248
+ "option",
249
+ { name: "Custom color…" },
250
+ ),
251
+ )
252
+ },
253
+ }
254
+
255
+ /**
256
+ * Drive it: open, type "pink" to narrow the list, choose one, then
257
+ * choose "Custom color…" and apply a code of your own.
258
+ */
259
+ export const Interactive: Story = {
260
+ render: () => (
261
+ <ColorPickerHarness
262
+ hasColorRule
263
+ initialValue="#3A86FF"
264
+ label="Interactive color"
265
+ />
266
+ ),
267
+ }
268
+
269
+ /**
270
+ * Inside a `Field` the trigger stretches to the container, and a long
271
+ * name truncates rather than pushing the chevron out.
272
+ */
273
+ export const Responsive: Story = {
274
+ render: () => (
275
+ <ContainerBoard>
276
+ {(width) => (
277
+ <Field label={`Color at ${width}`}>
278
+ <ColorPicker
279
+ isCustomAllowed
280
+ label={`Color at ${width}`}
281
+ onChange={() => {}}
282
+ presets={PRESETS}
283
+ value="#F9A8D4"
284
+ />
285
+ </Field>
286
+ )}
287
+ </ContainerBoard>
288
+ ),
289
+ }