@charcuterie/ui 4.26.2 → 4.27.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.
@@ -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
+ }
@@ -0,0 +1,385 @@
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 "./ColorPicker.stories.tsx"
9
+ import { parseHexColor } from "./parseHexColor.ts"
10
+
11
+ const {
12
+ AllStates,
13
+ AllVariants,
14
+ CustomPanel,
15
+ Interactive,
16
+ Playground,
17
+ Responsive,
18
+ } = composeStories(stories)
19
+
20
+ const openInteractive = async () => {
21
+ const mounted = await mountStory(Interactive)
22
+
23
+ await userEvent.click(
24
+ expectAgentDrivable(mounted.canvas, {
25
+ name: "Interactive color: Blue",
26
+ role: "button",
27
+ }),
28
+ )
29
+
30
+ const input = expectAgentDrivable(mounted.body, {
31
+ role: "combobox",
32
+ })
33
+
34
+ await waitFor(() => {
35
+ expect(input).toHaveFocus()
36
+ })
37
+
38
+ return { ...mounted, input }
39
+ }
40
+
41
+ test("the trigger names the preset and shows its swatch", async () => {
42
+ const { canvas, canvasElement } =
43
+ await mountStory(Playground)
44
+
45
+ const trigger = expectAgentDrivable(canvas, {
46
+ name: "Color: Hot Pink",
47
+ role: "button",
48
+ })
49
+
50
+ await expect(trigger).toHaveTextContent("Hot Pink")
51
+
52
+ const swatchDot = trigger.querySelector(
53
+ '[role="img"] > span',
54
+ ) as HTMLElement
55
+
56
+ await expect(
57
+ getComputedStyle(swatchDot).backgroundColor,
58
+ ).toBe("rgb(255, 0, 110)")
59
+
60
+ await expectNoAxeViolations(canvasElement)
61
+ })
62
+
63
+ test("typing a name narrows the list to that family", async () => {
64
+ const { body, input } = await openInteractive()
65
+
66
+ await userEvent.type(input, "pink")
67
+
68
+ await waitFor(() => {
69
+ const names = body
70
+ .getAllByRole("option")
71
+ .map((option: HTMLElement) =>
72
+ // The tick is drawn in every row and shown on the chosen one.
73
+ option.textContent?.replace("✓", "").trim(),
74
+ )
75
+
76
+ // "Hot Pink" by its name, the rest by their `keywords`.
77
+ expect(names).toStrictEqual([
78
+ "Raspberry",
79
+ "Hot Pink",
80
+ "Blush",
81
+ "Bubblegum",
82
+ "Cotton Candy",
83
+ "Custom color…",
84
+ ])
85
+ })
86
+ })
87
+
88
+ test("a hex code finds its preset", async () => {
89
+ const { body, input } = await openInteractive()
90
+
91
+ await userEvent.type(input, "#ff69")
92
+
93
+ await waitFor(() => {
94
+ body.getByRole("option", { name: "Bubblegum" })
95
+ })
96
+ })
97
+
98
+ test("choosing a preset changes the value and the trigger", async () => {
99
+ const { body, canvas } = await openInteractive()
100
+
101
+ await userEvent.click(
102
+ body.getByRole("option", { name: "Raspberry" }),
103
+ )
104
+
105
+ await waitFor(() => {
106
+ expectAgentDrivable(canvas, {
107
+ name: "Interactive color: Raspberry",
108
+ role: "button",
109
+ })
110
+ })
111
+
112
+ await expect(
113
+ canvas.getByText("Value: #DB2777"),
114
+ ).toBeInTheDocument()
115
+ })
116
+
117
+ test("a custom color is applied from the hex field and the trigger reads its code", async () => {
118
+ const { body, canvas } = await openInteractive()
119
+
120
+ await userEvent.click(
121
+ body.getByRole("option", { name: "Custom color…" }),
122
+ )
123
+
124
+ const hexInput = expectAgentDrivable(canvas, {
125
+ name: "Hex code",
126
+ role: "textbox",
127
+ })
128
+
129
+ await waitFor(() => {
130
+ expect(hexInput).toHaveFocus()
131
+ })
132
+
133
+ await userEvent.clear(hexInput)
134
+ await userEvent.type(hexInput, "c2185b{Enter}")
135
+
136
+ await waitFor(() => {
137
+ expectAgentDrivable(canvas, {
138
+ name: "Interactive color: #C2185B",
139
+ role: "button",
140
+ })
141
+ })
142
+
143
+ await expect(
144
+ canvas.getByText("Value: #C2185B"),
145
+ ).toBeInTheDocument()
146
+
147
+ // Applying closes the panel and returns focus to the trigger.
148
+ await expect(
149
+ canvas.queryByRole("textbox", { name: "Hex code" }),
150
+ ).toBeNull()
151
+ })
152
+
153
+ test("a hex typed into the search seeds the custom panel", async () => {
154
+ const { body, canvas, input } = await openInteractive()
155
+
156
+ await userEvent.type(input, "7b1fa2")
157
+
158
+ await userEvent.click(
159
+ body.getByRole("option", {
160
+ name: "Custom color #7B1FA2…",
161
+ }),
162
+ )
163
+
164
+ await expect(
165
+ expectAgentDrivable(canvas, {
166
+ name: "Hex code",
167
+ role: "textbox",
168
+ }),
169
+ ).toHaveValue("#7B1FA2")
170
+ })
171
+
172
+ test("the app's rule blocks a custom color with its own message", async () => {
173
+ const { canvas, canvasElement } =
174
+ await mountStory(CustomPanel)
175
+
176
+ await CustomPanel.play?.({
177
+ canvasElement,
178
+ } as never)
179
+
180
+ const hexInput = expectAgentDrivable(canvas, {
181
+ name: "Hex code",
182
+ role: "textbox",
183
+ })
184
+
185
+ await expect(hexInput).toHaveAttribute(
186
+ "aria-invalid",
187
+ "true",
188
+ )
189
+
190
+ await expect(
191
+ canvas.getByText(/Gray, white, and black/),
192
+ ).toBeInTheDocument()
193
+
194
+ await expect(
195
+ expectAgentDrivable(canvas, {
196
+ name: "Use color",
197
+ role: "button",
198
+ }),
199
+ ).toBeDisabled()
200
+
201
+ await userEvent.clear(hexInput)
202
+ await userEvent.type(hexInput, "E040FB")
203
+
204
+ await waitFor(() => {
205
+ expect(hexInput).not.toHaveAttribute("aria-invalid")
206
+ })
207
+
208
+ await expect(
209
+ expectAgentDrivable(canvas, {
210
+ name: "Use color",
211
+ role: "button",
212
+ }),
213
+ ).toBeEnabled()
214
+
215
+ await expectNoAxeViolations(canvasElement)
216
+ })
217
+
218
+ test("an unreadable code explains itself and cannot be applied", async () => {
219
+ const { canvas, canvasElement } =
220
+ await mountStory(CustomPanel)
221
+
222
+ await CustomPanel.play?.({
223
+ canvasElement,
224
+ } as never)
225
+
226
+ const hexInput = expectAgentDrivable(canvas, {
227
+ name: "Hex code",
228
+ role: "textbox",
229
+ })
230
+
231
+ await userEvent.clear(hexInput)
232
+ await userEvent.type(hexInput, "12345")
233
+
234
+ await expect(
235
+ canvas.getByText(/three or six hex digits/),
236
+ ).toBeInTheDocument()
237
+
238
+ await expect(
239
+ expectAgentDrivable(canvas, {
240
+ name: "Use color",
241
+ role: "button",
242
+ }),
243
+ ).toBeDisabled()
244
+ })
245
+
246
+ test("the color wheel follows the hex field", async () => {
247
+ const { canvas, canvasElement } =
248
+ await mountStory(CustomPanel)
249
+
250
+ await CustomPanel.play?.({
251
+ canvasElement,
252
+ } as never)
253
+
254
+ const hexInput = expectAgentDrivable(canvas, {
255
+ name: "Hex code",
256
+ role: "textbox",
257
+ })
258
+
259
+ await userEvent.clear(hexInput)
260
+ await userEvent.type(hexInput, "#0af")
261
+
262
+ await expect(
263
+ canvas.getByLabelText("Rule color: color wheel"),
264
+ ).toHaveValue("#00aaff")
265
+ })
266
+
267
+ test("Escape and Cancel drop the draft and keep the value", async () => {
268
+ const { canvas, canvasElement } =
269
+ await mountStory(CustomPanel)
270
+
271
+ await CustomPanel.play?.({
272
+ canvasElement,
273
+ } as never)
274
+
275
+ const hexInput = expectAgentDrivable(canvas, {
276
+ name: "Hex code",
277
+ role: "textbox",
278
+ })
279
+
280
+ await userEvent.clear(hexInput)
281
+ await userEvent.type(hexInput, "E040FB{Escape}")
282
+
283
+ await expect(
284
+ canvas.queryByRole("textbox", { name: "Hex code" }),
285
+ ).toBeNull()
286
+
287
+ await expect(
288
+ canvas.getByText("Value: #808080"),
289
+ ).toBeInTheDocument()
290
+ })
291
+
292
+ test("every state is named and has no axe violations", async () => {
293
+ const { canvas, canvasElement } =
294
+ await mountStory(AllStates)
295
+
296
+ expectAgentDrivable(canvas, {
297
+ name: "Preset color: Bubblegum",
298
+ role: "button",
299
+ })
300
+ expectAgentDrivable(canvas, {
301
+ name: "Custom color: #C2185B",
302
+ role: "button",
303
+ })
304
+ expectAgentDrivable(canvas, {
305
+ name: "Empty color: Choose a color",
306
+ role: "button",
307
+ })
308
+
309
+ await expect(
310
+ expectAgentDrivable(canvas, {
311
+ name: "Disabled color: Purple",
312
+ role: "button",
313
+ }),
314
+ ).toBeDisabled()
315
+
316
+ await expectNoAxeViolations(canvasElement)
317
+ })
318
+
319
+ test("presets only offers no custom row", async () => {
320
+ const { body, canvas } = await mountStory(AllStates)
321
+
322
+ await userEvent.click(
323
+ expectAgentDrivable(canvas, {
324
+ name: "Presets-only color: Green",
325
+ role: "button",
326
+ }),
327
+ )
328
+
329
+ await waitFor(() => {
330
+ expect(
331
+ body.getAllByRole("option").length,
332
+ ).toBeGreaterThan(0)
333
+ })
334
+
335
+ await expect(
336
+ body.queryByRole("option", { name: "Custom color…" }),
337
+ ).toBeNull()
338
+ })
339
+
340
+ test("every size renders a named trigger", async () => {
341
+ const { canvas, canvasElement } =
342
+ await mountStory(AllVariants)
343
+
344
+ for (const size of ["sm", "md", "lg"]) {
345
+ expectAgentDrivable(canvas, {
346
+ name: `Color ${size}: Blue`,
347
+ role: "button",
348
+ })
349
+ }
350
+
351
+ await expectNoAxeViolations(canvasElement)
352
+ })
353
+
354
+ test("a stretched trigger keeps its chevron inside", async () => {
355
+ const { canvas } = await mountStory(Responsive)
356
+
357
+ for (const trigger of canvas.getAllByRole("button", {
358
+ name: /^Color at .*: Cotton Candy$/,
359
+ })) {
360
+ const triggerBox = trigger.getBoundingClientRect()
361
+ const chevronBox = (
362
+ trigger.querySelector("svg") as SVGElement
363
+ ).getBoundingClientRect()
364
+
365
+ await expect(trigger.scrollWidth).toBe(
366
+ trigger.clientWidth,
367
+ )
368
+ await expect(chevronBox.right).toBeLessThanOrEqual(
369
+ triggerBox.right,
370
+ )
371
+ }
372
+ })
373
+
374
+ test.each([
375
+ ["3a86ff", "#3A86FF"],
376
+ ["#3A86FF", "#3A86FF"],
377
+ [" #38f ", "#3388FF"],
378
+ ["abc", "#AABBCC"],
379
+ ["12345", null],
380
+ ["#GGGGGG", null],
381
+ ["#3A86FF80", null],
382
+ ["", null],
383
+ ])("parseHexColor(%j) is %j", (text, expected) => {
384
+ expect(parseHexColor(text)).toBe(expected)
385
+ })