@open-mercato/ui 0.6.4-develop.4178.1.aad9ddaa95 → 0.6.4-develop.4210.1.d412061cfe

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.
Files changed (92) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +14 -0
  3. package/dist/backend/DataTable.js +28 -62
  4. package/dist/backend/DataTable.js.map +2 -2
  5. package/dist/backend/detail/AttachmentMetadataDialog.js +1 -1
  6. package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
  7. package/dist/primitives/activity-feed.js +196 -0
  8. package/dist/primitives/activity-feed.js.map +7 -0
  9. package/dist/primitives/avatar.js +140 -14
  10. package/dist/primitives/avatar.js.map +3 -3
  11. package/dist/primitives/badge.js +94 -5
  12. package/dist/primitives/badge.js.map +2 -2
  13. package/dist/primitives/button-group.js +52 -0
  14. package/dist/primitives/button-group.js.map +7 -0
  15. package/dist/primitives/color-picker.js +520 -0
  16. package/dist/primitives/color-picker.js.map +7 -0
  17. package/dist/primitives/command-menu.js +355 -0
  18. package/dist/primitives/command-menu.js.map +7 -0
  19. package/dist/primitives/dialog.js +210 -35
  20. package/dist/primitives/dialog.js.map +2 -2
  21. package/dist/primitives/drawer.js +219 -0
  22. package/dist/primitives/drawer.js.map +7 -0
  23. package/dist/primitives/notification-feed.js +213 -0
  24. package/dist/primitives/notification-feed.js.map +7 -0
  25. package/dist/primitives/notification.js +23 -2
  26. package/dist/primitives/notification.js.map +2 -2
  27. package/dist/primitives/pagination.js +245 -0
  28. package/dist/primitives/pagination.js.map +7 -0
  29. package/dist/primitives/progress.js +178 -5
  30. package/dist/primitives/progress.js.map +2 -2
  31. package/dist/primitives/rating.js +174 -0
  32. package/dist/primitives/rating.js.map +7 -0
  33. package/dist/primitives/scroll-area.js +82 -0
  34. package/dist/primitives/scroll-area.js.map +7 -0
  35. package/dist/primitives/segmented-control.js +101 -0
  36. package/dist/primitives/segmented-control.js.map +7 -0
  37. package/dist/primitives/separator.js +103 -4
  38. package/dist/primitives/separator.js.map +2 -2
  39. package/dist/primitives/slider.js +115 -0
  40. package/dist/primitives/slider.js.map +7 -0
  41. package/dist/primitives/step-indicator.js +187 -0
  42. package/dist/primitives/step-indicator.js.map +7 -0
  43. package/dist/primitives/table.js +102 -10
  44. package/dist/primitives/table.js.map +2 -2
  45. package/dist/primitives/tabs.js +122 -14
  46. package/dist/primitives/tabs.js.map +2 -2
  47. package/dist/primitives/time-picker.js +9 -11
  48. package/dist/primitives/time-picker.js.map +2 -2
  49. package/package.json +8 -4
  50. package/src/backend/DataTable.tsx +43 -60
  51. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  52. package/src/primitives/__tests__/activity-feed.test.tsx +216 -0
  53. package/src/primitives/__tests__/avatar.test.tsx +131 -0
  54. package/src/primitives/__tests__/badge.test.tsx +189 -0
  55. package/src/primitives/__tests__/button-group.test.tsx +117 -0
  56. package/src/primitives/__tests__/color-picker.test.tsx +589 -0
  57. package/src/primitives/__tests__/command-menu.test.tsx +231 -0
  58. package/src/primitives/__tests__/dialog.test.tsx +290 -0
  59. package/src/primitives/__tests__/drawer.test.tsx +264 -0
  60. package/src/primitives/__tests__/notification-feed.test.tsx +303 -0
  61. package/src/primitives/__tests__/notification.test.tsx +102 -2
  62. package/src/primitives/__tests__/pagination.test.tsx +268 -0
  63. package/src/primitives/__tests__/progress.test.tsx +222 -0
  64. package/src/primitives/__tests__/rating.test.tsx +164 -0
  65. package/src/primitives/__tests__/scroll-area.test.tsx +109 -0
  66. package/src/primitives/__tests__/segmented-control.test.tsx +129 -0
  67. package/src/primitives/__tests__/separator.test.tsx +143 -0
  68. package/src/primitives/__tests__/slider.test.tsx +86 -0
  69. package/src/primitives/__tests__/step-indicator.test.tsx +205 -0
  70. package/src/primitives/__tests__/table.test.tsx +199 -0
  71. package/src/primitives/__tests__/tabs.test.tsx +139 -0
  72. package/src/primitives/activity-feed.tsx +326 -0
  73. package/src/primitives/avatar.tsx +225 -22
  74. package/src/primitives/badge.tsx +141 -6
  75. package/src/primitives/button-group.tsx +100 -0
  76. package/src/primitives/color-picker.tsx +693 -0
  77. package/src/primitives/command-menu.tsx +503 -0
  78. package/src/primitives/dialog.tsx +315 -51
  79. package/src/primitives/drawer.tsx +338 -0
  80. package/src/primitives/notification-feed.tsx +358 -0
  81. package/src/primitives/notification.tsx +42 -2
  82. package/src/primitives/pagination.tsx +376 -0
  83. package/src/primitives/progress.tsx +267 -6
  84. package/src/primitives/rating.tsx +245 -0
  85. package/src/primitives/scroll-area.tsx +169 -0
  86. package/src/primitives/segmented-control.tsx +162 -0
  87. package/src/primitives/separator.tsx +163 -3
  88. package/src/primitives/slider.tsx +160 -0
  89. package/src/primitives/step-indicator.tsx +287 -0
  90. package/src/primitives/table.tsx +142 -11
  91. package/src/primitives/tabs.tsx +174 -12
  92. package/src/primitives/time-picker.tsx +9 -11
@@ -0,0 +1,693 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import * as PopoverPrimitive from '@radix-ui/react-popover'
5
+ import { Pipette, Plus, Trash2 } from 'lucide-react'
6
+ import { cva, type VariantProps } from 'class-variance-authority'
7
+
8
+ import { cn } from '@open-mercato/shared/lib/utils'
9
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
10
+
11
+ // Local types for the experimental EyeDropper API (no DOM lib coverage yet).
12
+ interface EyeDropperResult { sRGBHex: string }
13
+ interface EyeDropperAPI { open(): Promise<EyeDropperResult> }
14
+ interface WindowWithEyeDropper extends Window { EyeDropper?: new () => EyeDropperAPI }
15
+
16
+ /**
17
+ * Color picker per Figma `Color Picker` frame (DS Open Mercato
18
+ * `167184:38583`, doc page `553:22078`). 4-section layout 316×334:
19
+ *
20
+ * 1. Choose Color — header row ("Choose color" label + hex preview
21
+ * on the right) + a single pill hue slider
22
+ * (gradient rainbow, draggable white thumb).
23
+ * 2. Input — hex input field + eyedropper button (Sip).
24
+ * 3. Saved Colors — title + row of swatch dots (24×24, rounded-full).
25
+ * 4. Action — "+ Add new color" footer button (optional).
26
+ *
27
+ * No 2D HSV spectrum, no opacity slider, no format dropdown — those
28
+ * belong to a heavier picker layout that's not in this DS source.
29
+ * Implementation is vanilla (no `react-colorful`): hue is a native
30
+ * `<input type="range">` styled as a gradient pill, hex → RGB → HSL
31
+ * conversion done inline so we can keep dependencies minimal.
32
+ *
33
+ * ```tsx
34
+ * const [color, setColor] = React.useState('#6366F1')
35
+ *
36
+ * // Basic
37
+ * <ColorPicker value={color} onChange={setColor} />
38
+ *
39
+ * // Saved-colors editor (user can append the current color)
40
+ * const [swatches, setSwatches] = React.useState<string[]>([])
41
+ * <ColorPicker
42
+ * value={color}
43
+ * onChange={setColor}
44
+ * swatches={swatches}
45
+ * onAddSwatch={(c) => setSwatches([...swatches, c])}
46
+ * />
47
+ *
48
+ * // Locked palette — no hex input, no add button
49
+ * <ColorPicker
50
+ * value={color}
51
+ * onChange={setColor}
52
+ * swatches={['#22C55E', '#F59E0B', '#EF4343']}
53
+ * allowCustom={false}
54
+ * />
55
+ * ```
56
+ */
57
+
58
+ /**
59
+ * Default swatch list anchored on the Figma `Color Dots [1.1]`
60
+ * component set (DS OM `3365:22464`) — 10 brand-curated colors that
61
+ * also drive the Tag colors. Kept inline so the primitive ships with
62
+ * a sensible default; consumers can override via `swatches`.
63
+ */
64
+ export const COLOR_PICKER_DEFAULT_SWATCHES: readonly string[] = [
65
+ '#71777C', // Gray
66
+ '#6366F1', // Blue (= DS OM accent-indigo)
67
+ '#F59E0B', // Orange
68
+ '#EF4343', // Red
69
+ '#22C55E', // Green
70
+ '#F6B51E', // Yellow (= Rating amber)
71
+ '#7D52F3', // Purple
72
+ '#47C2FF', // Sky
73
+ '#FB4BA3', // Pink
74
+ '#22D3BB', // Teal
75
+ ]
76
+
77
+ const HEX_PATTERN_3 = /^#?[0-9a-fA-F]{3}$/
78
+ const HEX_PATTERN_6 = /^#?[0-9a-fA-F]{6}$/
79
+
80
+ export function normalizeHex(input: string): string | null {
81
+ const trimmed = input.trim()
82
+ if (HEX_PATTERN_6.test(trimmed)) {
83
+ return ('#' + trimmed.replace(/^#/, '')).toUpperCase()
84
+ }
85
+ if (HEX_PATTERN_3.test(trimmed)) {
86
+ const stripped = trimmed.replace(/^#/, '')
87
+ return ('#' + stripped[0] + stripped[0] + stripped[1] + stripped[1] + stripped[2] + stripped[2]).toUpperCase()
88
+ }
89
+ return null
90
+ }
91
+
92
+ function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
93
+ const normalized = normalizeHex(hex)
94
+ if (!normalized) return null
95
+ const stripped = normalized.replace(/^#/, '')
96
+ return {
97
+ r: parseInt(stripped.slice(0, 2), 16),
98
+ g: parseInt(stripped.slice(2, 4), 16),
99
+ b: parseInt(stripped.slice(4, 6), 16),
100
+ }
101
+ }
102
+
103
+ function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
104
+ const rN = r / 255
105
+ const gN = g / 255
106
+ const bN = b / 255
107
+ const max = Math.max(rN, gN, bN)
108
+ const min = Math.min(rN, gN, bN)
109
+ const delta = max - min
110
+ let h = 0
111
+ let s = 0
112
+ const l = (max + min) / 2
113
+ if (delta !== 0) {
114
+ s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min)
115
+ if (max === rN) h = ((gN - bN) / delta + (gN < bN ? 6 : 0))
116
+ else if (max === gN) h = (bN - rN) / delta + 2
117
+ else h = (rN - gN) / delta + 4
118
+ h /= 6
119
+ }
120
+ return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) }
121
+ }
122
+
123
+ function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
124
+ const sN = s / 100
125
+ const lN = l / 100
126
+ const k = (n: number) => (n + h / 30) % 12
127
+ const a = sN * Math.min(lN, 1 - lN)
128
+ const f = (n: number) => lN - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
129
+ return {
130
+ r: Math.round(255 * f(0)),
131
+ g: Math.round(255 * f(8)),
132
+ b: Math.round(255 * f(4)),
133
+ }
134
+ }
135
+
136
+ function rgbToHex(r: number, g: number, b: number): string {
137
+ return (
138
+ '#' +
139
+ [r, g, b]
140
+ .map((v) => v.toString(16).padStart(2, '0'))
141
+ .join('')
142
+ .toUpperCase()
143
+ )
144
+ }
145
+
146
+ /**
147
+ * Extract a pure-hue degree (0..360) from a hex color, ignoring its
148
+ * saturation / lightness. Used to position the hue-slider thumb when
149
+ * the controlled `value` changes via swatch click or hex input.
150
+ */
151
+ function hexToHueDegrees(hex: string): number {
152
+ const rgb = hexToRgb(hex)
153
+ if (!rgb) return 0
154
+ return rgbToHsl(rgb.r, rgb.g, rgb.b).h
155
+ }
156
+
157
+ const triggerVariants = cva(
158
+ 'inline-flex items-center gap-2 rounded-md border border-input bg-background px-2.5 py-1.5 text-sm shadow-xs outline-none transition-colors ' +
159
+ 'hover:bg-muted/40 ' +
160
+ 'focus-visible:shadow-focus ' +
161
+ 'disabled:cursor-not-allowed disabled:opacity-60',
162
+ {
163
+ variants: {
164
+ size: {
165
+ sm: 'h-8',
166
+ default: 'h-9',
167
+ },
168
+ },
169
+ defaultVariants: { size: 'default' },
170
+ },
171
+ )
172
+
173
+ const swatchVariants = cva(
174
+ 'inline-flex size-6 items-center justify-center rounded-full outline-none ' +
175
+ 'focus-visible:shadow-focus ' +
176
+ 'disabled:cursor-not-allowed disabled:opacity-50',
177
+ )
178
+
179
+ const swatchDotVariants = cva(
180
+ // Subtle hairline border keeps very-light / white swatches visible on
181
+ // a white popover background. The Figma `Color Dots [1.1]` ellipse
182
+ // does NOT carry a border by default (only on Selected, via an inset
183
+ // white stroke), but in practice palettes routinely include #FFFFFF
184
+ // and other near-white values that vanish without a 1px outline.
185
+ // We use `border-foreground/10` so the outline stays invisible on
186
+ // saturated colors and only "appears" on light values.
187
+ 'inline-block size-4 rounded-full border border-foreground/10 transition-transform ' +
188
+ 'group-enabled/swatch:group-hover/swatch:scale-[0.875]',
189
+ {
190
+ variants: {
191
+ selected: {
192
+ true: 'ring-2 ring-inset ring-background',
193
+ false: '',
194
+ },
195
+ },
196
+ defaultVariants: { selected: false },
197
+ },
198
+ )
199
+
200
+ export type ColorPickerProps = Omit<
201
+ React.HTMLAttributes<HTMLButtonElement>,
202
+ 'value' | 'onChange'
203
+ > &
204
+ VariantProps<typeof triggerVariants> & {
205
+ /** Current selected color, `#RRGGBB`. */
206
+ value: string
207
+ /** Fired when the user picks a swatch, drags the hue slider, or
208
+ * commits a hex input. */
209
+ onChange: (next: string) => void
210
+ /** Optional palette ("Saved colors" row). When provided, the primitive
211
+ * runs in **controlled mode** — the consumer owns the list. When
212
+ * omitted (and `persistKey` is also absent) the primitive falls back
213
+ * to `COLOR_PICKER_DEFAULT_SWATCHES` read-only. */
214
+ swatches?: readonly string[]
215
+ /** Initial palette for the uncontrolled / persisted mode. Used when
216
+ * `swatches` is omitted. Defaults to `COLOR_PICKER_DEFAULT_SWATCHES`. */
217
+ defaultSwatches?: readonly string[]
218
+ /** When set, the primitive switches to **persisted mode**: hydrates
219
+ * the swatch list from `localStorage[persistKey]` on mount, and
220
+ * auto-saves after every `onAddSwatch` / `onRemoveColor`. Pair with
221
+ * no `swatches` prop. The "+ Add new color" footer and trash button
222
+ * render automatically when `persistKey` is provided (no need to
223
+ * wire `onAddSwatch` / `onRemoveColor` callbacks for the buttons to
224
+ * appear — they still fire as notification callbacks). */
225
+ persistKey?: string
226
+ /** Optional callback for the "+ Add new color" footer button. When
227
+ * omitted, the button is hidden — unless `persistKey` is set, in
228
+ * which case the primitive renders + handles the button itself. */
229
+ onAddSwatch?: (next: string) => void
230
+ /** Allow free-form hex input. Default `true`. */
231
+ allowCustom?: boolean
232
+ /** Block the popover when true; trigger dims. */
233
+ disabled?: boolean
234
+ /** Optional label for screen readers. */
235
+ 'aria-label'?: string
236
+ /** Show the eyedropper button in the input row. Default `true`. The
237
+ * button auto-hides when `window.EyeDropper` is unavailable. */
238
+ enableEyedropper?: boolean
239
+ /** Optional callback. When provided, renders a trash icon button
240
+ * after the hex container; click fires with the current value
241
+ * (consumer decides whether to remove from `swatches`, clear the
242
+ * field, or otherwise act). */
243
+ onRemoveColor?: (current: string) => void
244
+ /** Optional callback. When provided, renders an "Edit" link button
245
+ * at the right of the "Saved colors" header. Click fires this
246
+ * callback so the consumer can open a separate management UI. */
247
+ onEditSavedColors?: () => void
248
+ /** Show an opacity percentage badge inside the hex container.
249
+ * Default `false`. v5 always shows 100% — surface kept for
250
+ * forward-compatibility with an alpha-aware follow-up release. */
251
+ showOpacity?: boolean
252
+ /** Section title above the hue slider. Default `"Choose color"`. */
253
+ chooseLabel?: string
254
+ /** Section title above the swatch row. Default `"Saved colors"`. */
255
+ savedLabel?: string
256
+ /** Label for the "+ Add new color" footer. Default `"Add new color"`. */
257
+ addLabel?: string
258
+ /** Label for the "Edit" link in the saved-colors header. Default `"Edit"`. */
259
+ editLabel?: string
260
+ /** ARIA label for the trash button. Default `"Remove color"`. */
261
+ removeAriaLabel?: string
262
+ }
263
+
264
+ export const ColorPicker = React.forwardRef<HTMLButtonElement, ColorPickerProps>(
265
+ (
266
+ {
267
+ className,
268
+ value,
269
+ onChange,
270
+ swatches: swatchesProp,
271
+ defaultSwatches,
272
+ persistKey,
273
+ onAddSwatch,
274
+ allowCustom = true,
275
+ size,
276
+ disabled,
277
+ enableEyedropper = true,
278
+ onRemoveColor,
279
+ onEditSavedColors,
280
+ showOpacity = false,
281
+ chooseLabel = 'Choose color',
282
+ savedLabel = 'Saved colors',
283
+ addLabel = 'Add new color',
284
+ editLabel = 'Edit',
285
+ removeAriaLabel = 'Remove color',
286
+ ...props
287
+ },
288
+ ref,
289
+ ) => {
290
+ const t = useT()
291
+ const normalizedValue = React.useMemo(() => normalizeHex(value) ?? value, [value])
292
+ const [hexInput, setHexInput] = React.useState(normalizedValue)
293
+ const [hexError, setHexError] = React.useState<string | null>(null)
294
+ const [open, setOpen] = React.useState(false)
295
+ const [eyedropperSupported, setEyedropperSupported] = React.useState(false)
296
+
297
+ // Swatches state cascade:
298
+ // 1. `swatches` prop → controlled, primitive never mutates the list.
299
+ // 2. `persistKey` set → uncontrolled + auto-save to localStorage.
300
+ // 3. otherwise → uncontrolled in-memory only (lost on remount).
301
+ const initialUncontrolled = React.useMemo(
302
+ () => [...(defaultSwatches ?? COLOR_PICKER_DEFAULT_SWATCHES)],
303
+ [defaultSwatches],
304
+ )
305
+ const [internalSwatches, setInternalSwatches] = React.useState<string[]>(initialUncontrolled)
306
+
307
+ // Hydrate from localStorage on mount when persistKey is set. We do this
308
+ // in an effect (not initialState) so SSR rendering matches the initial
309
+ // hydration pass — otherwise React warns about mismatched output.
310
+ React.useEffect(() => {
311
+ if (!persistKey || typeof window === 'undefined') return
312
+ try {
313
+ const raw = window.localStorage.getItem(persistKey)
314
+ if (!raw) return
315
+ const parsed = JSON.parse(raw)
316
+ if (
317
+ Array.isArray(parsed) &&
318
+ parsed.every((c) => typeof c === 'string' && normalizeHex(c) !== null)
319
+ ) {
320
+ setInternalSwatches(parsed.map((c) => normalizeHex(c) as string))
321
+ }
322
+ } catch {
323
+ // Corrupt JSON or storage disabled — fall through to the in-memory
324
+ // default. No-op, no flash to user.
325
+ }
326
+ }, [persistKey])
327
+
328
+ const isControlled = swatchesProp !== undefined
329
+ const effectiveSwatches = isControlled ? swatchesProp! : internalSwatches
330
+
331
+ const persistSwatches = React.useCallback(
332
+ (next: string[]) => {
333
+ if (!persistKey || typeof window === 'undefined') return
334
+ try {
335
+ window.localStorage.setItem(persistKey, JSON.stringify(next))
336
+ } catch {
337
+ // Storage quota exceeded or disabled — silent no-op.
338
+ }
339
+ },
340
+ [persistKey],
341
+ )
342
+
343
+ const handleAddSwatch = React.useCallback(
344
+ (color: string) => {
345
+ const normalized = normalizeHex(color) ?? color
346
+ if (!isControlled) {
347
+ setInternalSwatches((prev) => {
348
+ if (prev.some((c) => c.toUpperCase() === normalized.toUpperCase())) return prev
349
+ const next = [...prev, normalized]
350
+ persistSwatches(next)
351
+ return next
352
+ })
353
+ }
354
+ onAddSwatch?.(normalized)
355
+ },
356
+ [isControlled, persistSwatches, onAddSwatch],
357
+ )
358
+
359
+ const handleRemoveColor = React.useCallback(
360
+ (color: string) => {
361
+ const normalized = normalizeHex(color) ?? color
362
+ if (!isControlled) {
363
+ setInternalSwatches((prev) => {
364
+ const next = prev.filter((c) => c.toUpperCase() !== normalized.toUpperCase())
365
+ persistSwatches(next)
366
+ return next
367
+ })
368
+ }
369
+ onRemoveColor?.(normalized)
370
+ },
371
+ [isControlled, persistSwatches, onRemoveColor],
372
+ )
373
+
374
+ // Auto-render the add + trash buttons when persistKey is set, even if
375
+ // the consumer omitted the callbacks. The primitive owns the list in
376
+ // that mode, so the callbacks are purely "notification" channels.
377
+ const showAddButton = Boolean(onAddSwatch) || Boolean(persistKey)
378
+ const showRemoveButton = Boolean(onRemoveColor) || Boolean(persistKey)
379
+
380
+ React.useEffect(() => {
381
+ if (typeof window === 'undefined') return
382
+ setEyedropperSupported(typeof (window as WindowWithEyeDropper).EyeDropper === 'function')
383
+ }, [])
384
+
385
+ React.useEffect(() => {
386
+ if (!open) {
387
+ setHexInput(normalizedValue)
388
+ setHexError(null)
389
+ }
390
+ }, [normalizedValue, open])
391
+
392
+ const commitHex = React.useCallback(
393
+ (raw: string) => {
394
+ const next = normalizeHex(raw)
395
+ if (next === null) {
396
+ setHexError(t('ui.colorPicker.error.invalidHex', 'Invalid hex'))
397
+ return
398
+ }
399
+ setHexError(null)
400
+ setHexInput(next)
401
+ if (next !== normalizedValue) onChange(next)
402
+ },
403
+ [normalizedValue, onChange, t],
404
+ )
405
+
406
+ const handleHueChange = React.useCallback(
407
+ (hueDeg: number) => {
408
+ // Pure saturated color at the picked hue (s=100%, l=50%).
409
+ const rgb = hslToRgb(hueDeg, 100, 50)
410
+ onChange(rgbToHex(rgb.r, rgb.g, rgb.b))
411
+ },
412
+ [onChange],
413
+ )
414
+
415
+ const handleEyedropper = React.useCallback(async () => {
416
+ if (!eyedropperSupported) return
417
+ const Ctor = (window as WindowWithEyeDropper).EyeDropper
418
+ if (!Ctor) return
419
+ try {
420
+ const dropper = new Ctor()
421
+ const result = await dropper.open()
422
+ if (result && typeof result.sRGBHex === 'string') {
423
+ const next = normalizeHex(result.sRGBHex)
424
+ if (next) onChange(next)
425
+ }
426
+ } catch {
427
+ // User cancelled — no-op.
428
+ }
429
+ }, [eyedropperSupported, onChange])
430
+
431
+ const currentHue = React.useMemo(() => hexToHueDegrees(normalizedValue), [normalizedValue])
432
+
433
+ return (
434
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
435
+ <PopoverPrimitive.Trigger asChild>
436
+ <button
437
+ ref={ref}
438
+ type="button"
439
+ disabled={disabled}
440
+ data-slot="color-picker-trigger"
441
+ className={cn(triggerVariants({ size }), className)}
442
+ {...props}
443
+ >
444
+ <span
445
+ data-slot="color-picker-preview"
446
+ aria-hidden="true"
447
+ className="inline-block size-4 shrink-0 rounded-full border border-border/30"
448
+ style={{ backgroundColor: normalizedValue }}
449
+ />
450
+ <span className="font-mono uppercase tracking-tight">{normalizedValue}</span>
451
+ </button>
452
+ </PopoverPrimitive.Trigger>
453
+
454
+ <PopoverPrimitive.Portal>
455
+ <PopoverPrimitive.Content
456
+ sideOffset={6}
457
+ align="start"
458
+ data-slot="color-picker-popover"
459
+ className={cn(
460
+ 'z-popover w-80 rounded-xl border border-input bg-popover p-0 text-popover-foreground shadow-md outline-none',
461
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out',
462
+ 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
463
+ )}
464
+ >
465
+ {/* Section 1 — Choose color */}
466
+ <div
467
+ data-slot="color-picker-section-choose"
468
+ className="space-y-2.5 px-5 pt-4 pb-5"
469
+ >
470
+ <div className="flex items-center justify-between">
471
+ <span className="text-sm text-muted-foreground">{chooseLabel}</span>
472
+ <span className="font-mono text-xs uppercase tracking-tight text-muted-foreground/80">
473
+ {normalizedValue}
474
+ </span>
475
+ </div>
476
+ <input
477
+ type="range"
478
+ min={0}
479
+ max={360}
480
+ step={1}
481
+ value={currentHue}
482
+ disabled={disabled}
483
+ onChange={(event) => handleHueChange(Number(event.target.value))}
484
+ data-slot="color-picker-hue-slider"
485
+ aria-label={`${chooseLabel} hue`}
486
+ className={cn(
487
+ 'block h-3 w-full cursor-pointer appearance-none rounded-full outline-none',
488
+ // Native range styling. The track is the gradient itself.
489
+ '[background-image:linear-gradient(to_right,hsl(0,100%,50%),hsl(60,100%,50%),hsl(120,100%,50%),hsl(180,100%,50%),hsl(240,100%,50%),hsl(300,100%,50%),hsl(360,100%,50%))]',
490
+ // Thumb: small white pill with shadow per Figma.
491
+ '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm',
492
+ '[&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm',
493
+ 'focus-visible:shadow-focus',
494
+ 'disabled:cursor-not-allowed disabled:opacity-50',
495
+ )}
496
+ />
497
+ </div>
498
+
499
+ <div className="h-px w-full bg-border/60" aria-hidden="true" />
500
+
501
+ {/* Section 2 — Hex input row.
502
+ *
503
+ * Layout matches Figma `Color Picker` frame 167184:38583 § 2:
504
+ *
505
+ * [● colored-bullet] [#HEX text] [opacity %?] (container)
506
+ * [eyedropper]?
507
+ * [trash]?
508
+ *
509
+ * The bullet, hex field, and optional opacity badge live
510
+ * INSIDE the rounded container. Eyedropper + trash render
511
+ * as separate icon buttons after the container.
512
+ */}
513
+ {allowCustom ? (
514
+ <div
515
+ data-slot="color-picker-section-input"
516
+ className="flex w-full min-w-0 items-center gap-2.5 px-5 py-4"
517
+ >
518
+ <div className="flex h-9 min-w-0 flex-1 items-center gap-2 rounded-md border border-input bg-background px-2 shadow-xs">
519
+ <span
520
+ aria-hidden="true"
521
+ data-slot="color-picker-current-dot"
522
+ className="inline-block size-4 shrink-0 rounded-full border border-foreground/10"
523
+ style={{ backgroundColor: normalizedValue }}
524
+ />
525
+ <input
526
+ type="text"
527
+ data-slot="color-picker-hex"
528
+ value={hexInput}
529
+ onChange={(event) => {
530
+ setHexInput(event.target.value)
531
+ setHexError(null)
532
+ }}
533
+ onBlur={(event) => commitHex(event.target.value)}
534
+ onKeyDown={(event) => {
535
+ if (event.key === 'Enter') {
536
+ event.preventDefault()
537
+ commitHex(event.currentTarget.value)
538
+ } else if (event.key === 'Escape') {
539
+ event.preventDefault()
540
+ setHexInput(normalizedValue)
541
+ setHexError(null)
542
+ setOpen(false)
543
+ }
544
+ }}
545
+ className={cn(
546
+ 'h-full min-w-0 flex-1 bg-transparent font-mono text-sm uppercase tracking-tight outline-none',
547
+ 'placeholder:text-muted-foreground',
548
+ hexError && 'text-destructive',
549
+ )}
550
+ />
551
+ {showOpacity ? (
552
+ <span
553
+ data-slot="color-picker-opacity"
554
+ aria-hidden="true"
555
+ className="shrink-0 font-mono text-xs text-muted-foreground tabular-nums"
556
+ >
557
+ 100%
558
+ </span>
559
+ ) : null}
560
+ </div>
561
+ {/* Eyedropper and trash are mutually exclusive — Figma
562
+ * section 2 renders ONE button on the right (trash).
563
+ * When the consumer wires `onRemoveColor`, the
564
+ * eyedropper steps aside so we stay 1:1 with the
565
+ * Figma layout. */}
566
+ {!showRemoveButton && enableEyedropper && eyedropperSupported ? (
567
+ <button
568
+ type="button"
569
+ onClick={handleEyedropper}
570
+ data-slot="color-picker-eyedropper"
571
+ aria-label={t('ui.colorPicker.eyedropper.ariaLabel', 'Pick color from screen')}
572
+ className={cn(
573
+ 'inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-xs transition-colors',
574
+ 'hover:bg-muted/40 hover:text-foreground',
575
+ 'focus-visible:shadow-focus focus-visible:outline-none',
576
+ )}
577
+ >
578
+ <Pipette aria-hidden="true" className="size-4" />
579
+ </button>
580
+ ) : null}
581
+ {showRemoveButton ? (
582
+ <button
583
+ type="button"
584
+ onClick={() => handleRemoveColor(normalizedValue)}
585
+ data-slot="color-picker-remove"
586
+ aria-label={removeAriaLabel}
587
+ className={cn(
588
+ 'inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-xs transition-colors',
589
+ 'hover:bg-status-error-bg hover:text-status-error-text hover:border-status-error-border',
590
+ 'focus-visible:shadow-focus focus-visible:outline-none',
591
+ )}
592
+ >
593
+ <Trash2 aria-hidden="true" className="size-4" />
594
+ </button>
595
+ ) : null}
596
+ </div>
597
+ ) : null}
598
+ {hexError ? (
599
+ <p
600
+ data-slot="color-picker-error"
601
+ className="px-5 pb-3 -mt-2 text-xs text-status-error-text"
602
+ >
603
+ {hexError}
604
+ </p>
605
+ ) : null}
606
+
607
+ <div className="h-px w-full bg-border/60" aria-hidden="true" />
608
+
609
+ {/* Section 3 — Saved colors */}
610
+ <div data-slot="color-picker-section-saved" className="px-5 pt-4 pb-5">
611
+ <div className="mb-3 flex items-center justify-between">
612
+ <span className="text-sm text-muted-foreground">{savedLabel}</span>
613
+ {onEditSavedColors ? (
614
+ <button
615
+ type="button"
616
+ onClick={() => onEditSavedColors()}
617
+ data-slot="color-picker-edit"
618
+ className={cn(
619
+ 'text-sm font-medium text-accent-indigo outline-none transition-colors',
620
+ 'hover:underline focus-visible:underline',
621
+ 'focus-visible:shadow-focus rounded-sm',
622
+ )}
623
+ >
624
+ {editLabel}
625
+ </button>
626
+ ) : null}
627
+ </div>
628
+ <div className="flex flex-wrap gap-3" role="radiogroup">
629
+ {effectiveSwatches.map((swatch) => {
630
+ const normalized = normalizeHex(swatch) ?? swatch
631
+ const selected = normalized.toUpperCase() === normalizedValue.toUpperCase()
632
+ return (
633
+ <button
634
+ key={normalized}
635
+ type="button"
636
+ role="radio"
637
+ aria-checked={selected}
638
+ aria-label={normalized}
639
+ data-slot="color-picker-swatch"
640
+ data-state={selected ? 'on' : 'off'}
641
+ className={cn(swatchVariants(), 'group/swatch')}
642
+ onClick={() => {
643
+ onChange(normalized)
644
+ if (!allowCustom && !showAddButton) setOpen(false)
645
+ }}
646
+ >
647
+ <span
648
+ aria-hidden="true"
649
+ data-slot="color-picker-swatch-dot"
650
+ className={swatchDotVariants({ selected })}
651
+ style={{ backgroundColor: normalized }}
652
+ />
653
+ {selected ? <span className="sr-only">Selected</span> : null}
654
+ </button>
655
+ )
656
+ })}
657
+ </div>
658
+ </div>
659
+
660
+ {/* Section 4 — Add new color (optional) */}
661
+ {showAddButton ? (
662
+ <>
663
+ <div className="h-px w-full bg-border/60" aria-hidden="true" />
664
+ <button
665
+ type="button"
666
+ onClick={() => handleAddSwatch(normalizedValue)}
667
+ data-slot="color-picker-add"
668
+ className={cn(
669
+ 'flex w-full items-center gap-1.5 rounded-b-xl px-4 py-3.5 text-left text-sm text-muted-foreground outline-none transition-colors',
670
+ 'hover:bg-muted/40 hover:text-foreground',
671
+ 'focus-visible:shadow-focus focus-visible:outline-none',
672
+ )}
673
+ >
674
+ <Plus aria-hidden="true" className="size-5 text-muted-foreground/70" />
675
+ <span>{addLabel}</span>
676
+ </button>
677
+ </>
678
+ ) : null}
679
+
680
+ <PopoverPrimitive.Arrow className="fill-popover stroke-input" />
681
+ </PopoverPrimitive.Content>
682
+ </PopoverPrimitive.Portal>
683
+ </PopoverPrimitive.Root>
684
+ )
685
+ },
686
+ )
687
+ ColorPicker.displayName = 'ColorPicker'
688
+
689
+ export {
690
+ triggerVariants as colorPickerTriggerVariants,
691
+ swatchVariants as colorPickerSwatchVariants,
692
+ swatchDotVariants as colorPickerSwatchDotVariants,
693
+ }