@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,347 @@
1
+ import { useVisibility } from "@charcuterie/logic"
2
+ import type { ControlSize } from "@charcuterie/tokens"
3
+ import type {
4
+ ComponentPropsWithRef,
5
+ ReactNode,
6
+ } from "react"
7
+ import { useRef, useState } from "react"
8
+
9
+ import { Button } from "../Button/Button.tsx"
10
+ import { Combobox } from "../Combobox/Combobox.tsx"
11
+ import type { ListboxItem } from "../Listbox/Listbox.tsx"
12
+ import { Swatch } from "../Swatch/Swatch.tsx"
13
+ import { toClassName } from "../toClassName.ts"
14
+ import { ColorPickerCustomPanel } from "./ColorPickerCustomPanel.tsx"
15
+ import { parseHexColor } from "./parseHexColor.ts"
16
+
17
+ export type ColorPreset = {
18
+ /**
19
+ * Other words that find it — the family a name does not say. "Blush"
20
+ * and "Cotton Candy" are pinks, and a person types "pink".
21
+ */
22
+ keywords?: readonly string[]
23
+ /** What a person reads and types to find it — "Hot Pink", not "Pink 3". */
24
+ name: string
25
+ /** A `#RRGGBB` color. */
26
+ value: string
27
+ }
28
+
29
+ /**
30
+ * The value the "Custom color…" row carries. It cannot collide with a
31
+ * preset, because every preset value starts with `#`.
32
+ */
33
+ const CUSTOM_OPTION_VALUE = "custom-color"
34
+
35
+ /** See `Picker` — a constant element, so the file holds one component. */
36
+ const CHEVRON_DOWN = (
37
+ <svg
38
+ aria-hidden="true"
39
+ className="size-4"
40
+ fill="none"
41
+ focusable={false}
42
+ stroke="currentColor"
43
+ strokeLinecap="round"
44
+ strokeLinejoin="round"
45
+ strokeWidth={1.75}
46
+ viewBox="0 0 24 24"
47
+ xmlns="http://www.w3.org/2000/svg"
48
+ >
49
+ <path d="m6 9 6 6 6-6" />
50
+ </svg>
51
+ )
52
+
53
+ export type ColorPickerProps = Omit<
54
+ ComponentPropsWithRef<"button">,
55
+ "color" | "disabled" | "onChange" | "value"
56
+ > & {
57
+ /**
58
+ * Shown under a custom draft when the app refuses it, and the reason
59
+ * **Use color** stays disabled. Answer `null` to accept. It is asked
60
+ * of custom colors only: the presets are the app's own list, so it
61
+ * already vetted them.
62
+ */
63
+ getColorError?: (hex: string) => null | string
64
+ /** Adds the "Custom color…" row and the custom panel it opens. */
65
+ isCustomAllowed?: boolean
66
+ isDisabled?: boolean
67
+ /** What the color is FOR — "Second's color". Not the current value. */
68
+ label: string
69
+ /**
70
+ * A preset answers its own `value`, as written. A custom color
71
+ * answers `#RRGGBB` in upper case.
72
+ */
73
+ onChange: (hex: string) => void
74
+ /** Read by the trigger when there is no value yet. */
75
+ placeholder?: string
76
+ presets: readonly ColorPreset[]
77
+ /** The search field's hint. */
78
+ searchPlaceholder?: string
79
+ size?: ControlSize
80
+ value?: string
81
+ }
82
+
83
+ const isSameColor = (first: string, second: string) =>
84
+ (parseHexColor(first) ?? first) ===
85
+ (parseHexColor(second) ?? second)
86
+
87
+ /**
88
+ * A named color from a list, or a color of the person's own.
89
+ *
90
+ * ### Presets are a `Combobox`, because a palette worth offering is long
91
+ *
92
+ * Two dozen colors in a swatch grid is a wall of dots; the same list
93
+ * as rows with a name lets a person type "pink" and see only the
94
+ * pinks. Each row is a `Swatch` and its name; the name and the
95
+ * preset's `keywords` are the search target, so "Blush" can answer
96
+ * "pink". A hex code finds its preset too.
97
+ *
98
+ * ### Custom is a mode, not the whole control
99
+ *
100
+ * `isCustomAllowed` adds a last row, "Custom color…", which is always
101
+ * listed whatever the search says. Choosing it reveals
102
+ * `ColorPickerCustomPanel` under the trigger: the browser's color
103
+ * wheel and a hex field, applied only on **Use color**. Typing a hex
104
+ * code into the search and choosing the custom row opens the panel on
105
+ * that code.
106
+ *
107
+ * ### The app decides what a custom color may be
108
+ *
109
+ * `getColorError` is the app's rule — a reader board that cannot show
110
+ * gray, a label that has to clear contrast on both schemes. The
111
+ * component does not know why a color is wrong, only that it is, and
112
+ * it shows the app's sentence.
113
+ *
114
+ * ### The trigger says the name, or the code
115
+ *
116
+ * A preset reads `"Color: Hot Pink"`; a custom color reads
117
+ * `"Color: #FF3399"`, because a custom color has no name and its code
118
+ * is what is on screen (WCAG 2.5.3, as `Picker` argues).
119
+ */
120
+ export const ColorPicker = ({
121
+ className,
122
+ getColorError,
123
+ isCustomAllowed = false,
124
+ isDisabled = false,
125
+ label,
126
+ onChange,
127
+ placeholder = "Choose a color",
128
+ presets,
129
+ searchPlaceholder = "Search colors…",
130
+ size = "md",
131
+ value,
132
+ ...buttonProps
133
+ }: ColorPickerProps): ReactNode => {
134
+ const { hide, isVisible, toggle } = useVisibility()
135
+
136
+ const [query, setQuery] = useState("")
137
+
138
+ /**
139
+ * The color the custom panel opens on, or `null` while it is closed.
140
+ * Held here rather than inside the panel so a reopen starts fresh.
141
+ */
142
+ const [customSeed, setCustomSeed] = useState<
143
+ null | string
144
+ >(null)
145
+
146
+ const triggerElement = useRef<HTMLButtonElement>(null)
147
+
148
+ const currentPreset =
149
+ value === undefined
150
+ ? undefined
151
+ : presets.find((preset) =>
152
+ isSameColor(preset.value, value),
153
+ )
154
+
155
+ const customValue =
156
+ value === undefined || currentPreset !== undefined
157
+ ? null
158
+ : (parseHexColor(value) ?? value)
159
+
160
+ const triggerText =
161
+ currentPreset?.name ?? customValue ?? placeholder
162
+
163
+ const swatchColor = currentPreset?.value ?? customValue
164
+
165
+ const normalizedQuery = query.trim().toLowerCase()
166
+
167
+ const queryDigits = normalizedQuery.replace(/^#/, "")
168
+
169
+ const matchingPresets =
170
+ normalizedQuery === ""
171
+ ? presets
172
+ : presets.filter(
173
+ (preset) =>
174
+ [preset.name, ...(preset.keywords ?? [])].some(
175
+ (word) =>
176
+ word
177
+ .toLowerCase()
178
+ .includes(normalizedQuery),
179
+ ) ||
180
+ (queryDigits.length > 0 &&
181
+ preset.value
182
+ .toLowerCase()
183
+ .replace(/^#/, "")
184
+ .startsWith(queryDigits)),
185
+ )
186
+
187
+ const queryColor = parseHexColor(query)
188
+
189
+ const customOptionText =
190
+ queryColor === null
191
+ ? "Custom color…"
192
+ : `Custom color ${queryColor}…`
193
+
194
+ const options: ListboxItem[] = [
195
+ ...matchingPresets.map((preset) => ({
196
+ label: (
197
+ <span className="flex items-center gap-2">
198
+ {/* Hidden: the row's text already names it, and a named
199
+ image beside it would read "Hot Pink Hot Pink". */}
200
+ <span aria-hidden="true" className="inline-flex">
201
+ <Swatch
202
+ color={preset.value}
203
+ label={preset.name}
204
+ size="sm"
205
+ />
206
+ </span>
207
+ <span>{preset.name}</span>
208
+ </span>
209
+ ),
210
+ textValue: preset.name,
211
+ value: preset.value,
212
+ })),
213
+ ...(isCustomAllowed
214
+ ? [
215
+ {
216
+ label: (
217
+ <span className="flex items-center gap-2">
218
+ {queryColor === null ? null : (
219
+ <span
220
+ aria-hidden="true"
221
+ className="inline-flex"
222
+ >
223
+ <Swatch
224
+ color={queryColor}
225
+ label={queryColor}
226
+ size="sm"
227
+ />
228
+ </span>
229
+ )}
230
+ <span>{customOptionText}</span>
231
+ </span>
232
+ ),
233
+ textValue: customOptionText,
234
+ value: CUSTOM_OPTION_VALUE,
235
+ },
236
+ ]
237
+ : []),
238
+ ]
239
+
240
+ const selectedValue =
241
+ currentPreset?.value ??
242
+ (customValue === null ? undefined : CUSTOM_OPTION_VALUE)
243
+
244
+ const dismiss = () => {
245
+ setQuery("")
246
+
247
+ hide()
248
+ }
249
+
250
+ const closeCustomPanel = () => {
251
+ setCustomSeed(null)
252
+
253
+ triggerElement.current?.focus()
254
+ }
255
+
256
+ return (
257
+ <div className="flex min-w-0 flex-col gap-2">
258
+ <Combobox
259
+ emptyLabel="No color by that name"
260
+ isVisible={isVisible && !isDisabled}
261
+ itemSize={size}
262
+ // The selection is read off `value` on each open. Combobox seeds
263
+ // its tick once, so a value that moved under it — a custom color
264
+ // applied from the panel — remounts it rather than leaving the
265
+ // old preset ticked.
266
+ key={`combobox-${selectedValue ?? "none"}`}
267
+ onDismiss={dismiss}
268
+ onQueryChange={setQuery}
269
+ onSelect={(optionValue) => {
270
+ if (optionValue === CUSTOM_OPTION_VALUE) {
271
+ setCustomSeed(
272
+ queryColor ??
273
+ customValue ??
274
+ currentPreset?.value ??
275
+ presets[0]?.value ??
276
+ "",
277
+ )
278
+ } else {
279
+ setCustomSeed(null)
280
+
281
+ onChange(optionValue)
282
+ }
283
+
284
+ setQuery("")
285
+ }}
286
+ options={options}
287
+ placeholder={searchPlaceholder}
288
+ query={query}
289
+ selectedValue={selectedValue}
290
+ trigger={
291
+ <Button
292
+ {...buttonProps}
293
+ appearance="outline"
294
+ aria-label={`${label}: ${triggerText}`}
295
+ className={toClassName(
296
+ "justify-between",
297
+ className,
298
+ )}
299
+ iconEnd={CHEVRON_DOWN}
300
+ intent="neutral"
301
+ isDisabled={isDisabled}
302
+ onClick={(clickEvent) => {
303
+ buttonProps.onClick?.(clickEvent)
304
+
305
+ toggle()
306
+ }}
307
+ ref={triggerElement}
308
+ size={size}
309
+ type="button"
310
+ >
311
+ <span className="-mx-1 flex min-w-0 grow items-center gap-2 overflow-x-clip px-1 text-start">
312
+ {swatchColor === null ||
313
+ swatchColor === undefined ? null : (
314
+ <Swatch
315
+ color={swatchColor}
316
+ label={triggerText}
317
+ size="sm"
318
+ />
319
+ )}
320
+ <span className="min-w-0 truncate">
321
+ {triggerText}
322
+ </span>
323
+ </span>
324
+ </Button>
325
+ }
326
+ />
327
+
328
+ {customSeed === null ? null : (
329
+ <ColorPickerCustomPanel
330
+ getColorError={getColorError}
331
+ initialColor={customSeed}
332
+ isDisabled={isDisabled}
333
+ // A new seed is a new draft.
334
+ key={`custom-${customSeed}`}
335
+ label={label}
336
+ onApply={(hex) => {
337
+ onChange(hex)
338
+
339
+ closeCustomPanel()
340
+ }}
341
+ onCancel={closeCustomPanel}
342
+ size={size}
343
+ />
344
+ )}
345
+ </div>
346
+ )
347
+ }
@@ -0,0 +1,224 @@
1
+ import type { ControlSize } from "@charcuterie/tokens"
2
+ import type { ReactNode } from "react"
3
+ import { useEffect, useRef, useState } from "react"
4
+
5
+ import { Button } from "../Button/Button.tsx"
6
+ import { CONTROL_SIZE_CLASS } from "../controlStyles.ts"
7
+ import { Field } from "../Field/Field.tsx"
8
+ import {
9
+ DISABLED_CLASS,
10
+ FOCUS_RING_CLASS,
11
+ } from "../intentStyles.ts"
12
+ import { Swatch } from "../Swatch/Swatch.tsx"
13
+ import { toClassName } from "../toClassName.ts"
14
+ import { parseHexColor } from "./parseHexColor.ts"
15
+
16
+ const TEXT_INPUT_CLASS =
17
+ "w-full min-w-0 rounded-md border border-border-default bg-surface-raised font-mono text-content-primary transition-colors duration-(--duration-fast) ease-standard placeholder:text-content-muted hover:border-border-strong aria-invalid:border-intent-danger-border"
18
+
19
+ /**
20
+ * The browser's own swatch button. Its inner padding and the
21
+ * `::-webkit-color-swatch` border are what make the stock control look
22
+ * like a grey box with a colored stamp in it; zeroing both leaves a
23
+ * flat chip the height of the field beside it.
24
+ */
25
+ const NATIVE_COLOR_INPUT_CLASS =
26
+ "h-(--control-height-md) w-16 shrink-0 cursor-pointer rounded-md border border-border-default bg-surface-raised p-1 hover:border-border-strong [&::-moz-color-swatch]:rounded-sm [&::-moz-color-swatch]:border-0 [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:rounded-sm [&::-webkit-color-swatch]:border-0"
27
+
28
+ export type ColorPickerCustomPanelProps = {
29
+ /** The color the panel opens on — the current custom value, or a seed. */
30
+ initialColor: string
31
+ isDisabled: boolean
32
+ label: string
33
+ onApply: (hex: string) => void
34
+ onCancel: () => void
35
+ getColorError?: (hex: string) => null | string
36
+ size: ControlSize
37
+ }
38
+
39
+ /**
40
+ * The custom-color surface `ColorPicker` reveals under its trigger.
41
+ *
42
+ * ### The native color input is allowed here, and only here
43
+ *
44
+ * Every other picker in this library is ours because the OS paints the
45
+ * native one and no token reaches inside it. A custom color is the one
46
+ * case where the OS dialog is the right tool: it has the wheel, the
47
+ * eyedropper and the sliders, none of which a person wants rebuilt.
48
+ * So the native `<input type="color">` is the custom MODE, inside a
49
+ * panel this component owns — never the whole picker, which is the
50
+ * shape the presets replace.
51
+ *
52
+ * ### Nothing is applied until the person says so
53
+ *
54
+ * The native input fires on every movement of the wheel. Applying each
55
+ * one would write dozens of colors to an app's server while a person
56
+ * drags, and a refused color would flash an error mid-drag. The panel
57
+ * holds a draft; **Use color** applies it, Enter in the hex field does
58
+ * the same, and Escape or **Cancel** drops it.
59
+ */
60
+ export const ColorPickerCustomPanel = ({
61
+ getColorError,
62
+ initialColor,
63
+ isDisabled,
64
+ label,
65
+ onApply,
66
+ onCancel,
67
+ size,
68
+ }: ColorPickerCustomPanelProps): ReactNode => {
69
+ const [draftText, setDraftText] = useState(initialColor)
70
+
71
+ const [lastValidColor, setLastValidColor] = useState(
72
+ parseHexColor(initialColor) ?? "",
73
+ )
74
+
75
+ const hexInputElement = useRef<HTMLInputElement>(null)
76
+
77
+ // A frame late on purpose. The panel opens from a `Combobox` row, and
78
+ // closing that popup hands focus back to the trigger after this
79
+ // effect has run; focusing now would be undone a moment later.
80
+ useEffect(() => {
81
+ const frame = requestAnimationFrame(() => {
82
+ hexInputElement.current?.focus()
83
+ hexInputElement.current?.select()
84
+ })
85
+
86
+ return () => {
87
+ cancelAnimationFrame(frame)
88
+ }
89
+ }, [])
90
+
91
+ const draftColor = parseHexColor(draftText)
92
+
93
+ const error =
94
+ draftColor === null
95
+ ? draftText.trim() === ""
96
+ ? undefined
97
+ : "Enter three or six hex digits, such as 3A86FF."
98
+ : (getColorError?.(draftColor) ?? undefined)
99
+
100
+ const isApplicable =
101
+ !isDisabled &&
102
+ draftColor !== null &&
103
+ error === undefined
104
+
105
+ const apply = () => {
106
+ if (isApplicable) {
107
+ onApply(draftColor)
108
+ }
109
+ }
110
+
111
+ const updateDraft = (text: string) => {
112
+ setDraftText(text)
113
+
114
+ const parsed = parseHexColor(text)
115
+
116
+ if (parsed !== null) {
117
+ setLastValidColor(parsed)
118
+ }
119
+ }
120
+
121
+ return (
122
+ <fieldset
123
+ aria-label={`${label}: custom color`}
124
+ className="flex min-w-0 flex-col gap-3 rounded-md border border-border-subtle bg-surface-raised p-3"
125
+ onKeyDown={(keyEvent) => {
126
+ if (keyEvent.key === "Escape") {
127
+ keyEvent.preventDefault()
128
+ keyEvent.stopPropagation()
129
+
130
+ onCancel()
131
+ }
132
+ }}
133
+ >
134
+ <div className="flex flex-wrap items-start gap-3">
135
+ <Field label="Color wheel">
136
+ <input
137
+ aria-label={`${label}: color wheel`}
138
+ className={toClassName(
139
+ NATIVE_COLOR_INPUT_CLASS,
140
+ FOCUS_RING_CLASS,
141
+ DISABLED_CLASS,
142
+ )}
143
+ disabled={isDisabled}
144
+ onChange={(changeEvent) => {
145
+ updateDraft(
146
+ changeEvent.currentTarget.value.toUpperCase(),
147
+ )
148
+ }}
149
+ type="color"
150
+ value={(
151
+ draftColor ?? lastValidColor
152
+ ).toLowerCase()}
153
+ />
154
+ </Field>
155
+
156
+ <Field
157
+ className="min-w-40 flex-1"
158
+ error={error}
159
+ label="Hex code"
160
+ >
161
+ <input
162
+ autoComplete="off"
163
+ className={toClassName(
164
+ TEXT_INPUT_CLASS,
165
+ CONTROL_SIZE_CLASS[size],
166
+ FOCUS_RING_CLASS,
167
+ DISABLED_CLASS,
168
+ )}
169
+ disabled={isDisabled}
170
+ onChange={(changeEvent) => {
171
+ updateDraft(changeEvent.currentTarget.value)
172
+ }}
173
+ onKeyDown={(keyEvent) => {
174
+ if (keyEvent.key === "Enter") {
175
+ keyEvent.preventDefault()
176
+
177
+ apply()
178
+ }
179
+ }}
180
+ placeholder="RRGGBB"
181
+ ref={hexInputElement}
182
+ spellCheck={false}
183
+ type="text"
184
+ value={draftText}
185
+ />
186
+ </Field>
187
+ </div>
188
+
189
+ <div className="flex flex-wrap items-center gap-2">
190
+ {draftColor === null ? null : (
191
+ <Swatch
192
+ className="me-auto"
193
+ color={draftColor}
194
+ isLabelVisible
195
+ label={draftColor}
196
+ size="sm"
197
+ />
198
+ )}
199
+
200
+ <Button
201
+ appearance="ghost"
202
+ intent="neutral"
203
+ isDisabled={isDisabled}
204
+ onClick={onCancel}
205
+ size={size}
206
+ type="button"
207
+ >
208
+ Cancel
209
+ </Button>
210
+
211
+ <Button
212
+ appearance="solid"
213
+ intent="accent"
214
+ isDisabled={!isApplicable}
215
+ onClick={apply}
216
+ size={size}
217
+ type="button"
218
+ >
219
+ Use color
220
+ </Button>
221
+ </div>
222
+ </fieldset>
223
+ )
224
+ }
@@ -0,0 +1,37 @@
1
+ const SHORT_HEX_PATTERN = /^[0-9a-f]{3}$/i
2
+
3
+ const LONG_HEX_PATTERN = /^[0-9a-f]{6}$/i
4
+
5
+ /**
6
+ * Reads what a person types into a hex field — `3a86ff`, `#3A86FF`,
7
+ * `#38f`, with or without the leading mark and with stray spaces — and
8
+ * answers the one canonical form, `#RRGGBB` in upper case, or `null`
9
+ * when it is not a color.
10
+ *
11
+ * Upper case because that is how the fleet's palettes are written and
12
+ * how a person reads a code aloud; a native `<input type="color">`
13
+ * answers lower case, and two spellings of one color are how a
14
+ * "is this the preset?" comparison quietly fails.
15
+ *
16
+ * No alpha. A child's color, a label's color, a sticker's color is
17
+ * opaque, and an eight-digit code would let a translucent value
18
+ * through every contrast rule an app writes against it.
19
+ */
20
+ export const parseHexColor = (
21
+ text: string,
22
+ ): string | null => {
23
+ const digits = text.trim().replace(/^#/, "")
24
+
25
+ if (SHORT_HEX_PATTERN.test(digits)) {
26
+ return `#${[...digits]
27
+ .map((digit) => `${digit}${digit}`)
28
+ .join("")
29
+ .toUpperCase()}`
30
+ }
31
+
32
+ if (LONG_HEX_PATTERN.test(digits)) {
33
+ return `#${digits.toUpperCase()}`
34
+ }
35
+
36
+ return null
37
+ }
package/src/index.ts CHANGED
@@ -121,6 +121,12 @@ export type { ChartProps } from "./Chart/Chart.tsx"
121
121
  export { Chart } from "./Chart/Chart.tsx"
122
122
  export type { CheckboxProps } from "./Checkbox/Checkbox.tsx"
123
123
  export { Checkbox } from "./Checkbox/Checkbox.tsx"
124
+ export type {
125
+ ColorPickerProps,
126
+ ColorPreset,
127
+ } from "./ColorPicker/ColorPicker.tsx"
128
+ export { ColorPicker } from "./ColorPicker/ColorPicker.tsx"
129
+ export { parseHexColor } from "./ColorPicker/parseHexColor.ts"
124
130
  export type { ColorSchemeSwitcherProps } from "./ColorSchemeSwitcher/ColorSchemeSwitcher.tsx"
125
131
  export { ColorSchemeSwitcher } from "./ColorSchemeSwitcher/ColorSchemeSwitcher.tsx"
126
132
  export type {
@@ -627,7 +627,9 @@ test("the barrel is the only place components are re-exported", async () => {
627
627
  // and whereabouts each drew it by hand. `GroupedListRow.tsx` is its
628
628
  // member and stays out of this count by the `<Name>/<Name>.tsx` rule.
629
629
  // CommandPalette composes Modal and SearchInput into the launcher.
630
- expect(componentNames.length).toBe(79)
630
+ // ColorPicker composes Combobox and Swatch with a custom-color panel;
631
+ // `ColorPickerCustomPanel.tsx` is its member.
632
+ expect(componentNames.length).toBe(80)
631
633
 
632
634
  for (const name of componentNames) {
633
635
  expect(barrel).toContain(`export { ${name} }`)