@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +14 -0
- package/dist/backend/DataTable.js +28 -62
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/detail/AttachmentMetadataDialog.js +1 -1
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
- package/dist/primitives/activity-feed.js +196 -0
- package/dist/primitives/activity-feed.js.map +7 -0
- package/dist/primitives/avatar.js +140 -14
- package/dist/primitives/avatar.js.map +3 -3
- package/dist/primitives/badge.js +94 -5
- package/dist/primitives/badge.js.map +2 -2
- package/dist/primitives/button-group.js +52 -0
- package/dist/primitives/button-group.js.map +7 -0
- package/dist/primitives/color-picker.js +520 -0
- package/dist/primitives/color-picker.js.map +7 -0
- package/dist/primitives/command-menu.js +355 -0
- package/dist/primitives/command-menu.js.map +7 -0
- package/dist/primitives/dialog.js +210 -35
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/drawer.js +219 -0
- package/dist/primitives/drawer.js.map +7 -0
- package/dist/primitives/notification-feed.js +213 -0
- package/dist/primitives/notification-feed.js.map +7 -0
- package/dist/primitives/notification.js +23 -2
- package/dist/primitives/notification.js.map +2 -2
- package/dist/primitives/pagination.js +245 -0
- package/dist/primitives/pagination.js.map +7 -0
- package/dist/primitives/progress.js +178 -5
- package/dist/primitives/progress.js.map +2 -2
- package/dist/primitives/rating.js +174 -0
- package/dist/primitives/rating.js.map +7 -0
- package/dist/primitives/scroll-area.js +82 -0
- package/dist/primitives/scroll-area.js.map +7 -0
- package/dist/primitives/segmented-control.js +101 -0
- package/dist/primitives/segmented-control.js.map +7 -0
- package/dist/primitives/separator.js +103 -4
- package/dist/primitives/separator.js.map +2 -2
- package/dist/primitives/slider.js +115 -0
- package/dist/primitives/slider.js.map +7 -0
- package/dist/primitives/step-indicator.js +187 -0
- package/dist/primitives/step-indicator.js.map +7 -0
- package/dist/primitives/table.js +102 -10
- package/dist/primitives/table.js.map +2 -2
- package/dist/primitives/tabs.js +122 -14
- package/dist/primitives/tabs.js.map +2 -2
- package/dist/primitives/time-picker.js +9 -11
- package/dist/primitives/time-picker.js.map +2 -2
- package/package.json +8 -4
- package/src/backend/DataTable.tsx +43 -60
- package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
- package/src/primitives/__tests__/activity-feed.test.tsx +216 -0
- package/src/primitives/__tests__/avatar.test.tsx +131 -0
- package/src/primitives/__tests__/badge.test.tsx +189 -0
- package/src/primitives/__tests__/button-group.test.tsx +117 -0
- package/src/primitives/__tests__/color-picker.test.tsx +589 -0
- package/src/primitives/__tests__/command-menu.test.tsx +231 -0
- package/src/primitives/__tests__/dialog.test.tsx +290 -0
- package/src/primitives/__tests__/drawer.test.tsx +264 -0
- package/src/primitives/__tests__/notification-feed.test.tsx +303 -0
- package/src/primitives/__tests__/notification.test.tsx +102 -2
- package/src/primitives/__tests__/pagination.test.tsx +268 -0
- package/src/primitives/__tests__/progress.test.tsx +222 -0
- package/src/primitives/__tests__/rating.test.tsx +164 -0
- package/src/primitives/__tests__/scroll-area.test.tsx +109 -0
- package/src/primitives/__tests__/segmented-control.test.tsx +129 -0
- package/src/primitives/__tests__/separator.test.tsx +143 -0
- package/src/primitives/__tests__/slider.test.tsx +86 -0
- package/src/primitives/__tests__/step-indicator.test.tsx +205 -0
- package/src/primitives/__tests__/table.test.tsx +199 -0
- package/src/primitives/__tests__/tabs.test.tsx +139 -0
- package/src/primitives/activity-feed.tsx +326 -0
- package/src/primitives/avatar.tsx +225 -22
- package/src/primitives/badge.tsx +141 -6
- package/src/primitives/button-group.tsx +100 -0
- package/src/primitives/color-picker.tsx +693 -0
- package/src/primitives/command-menu.tsx +503 -0
- package/src/primitives/dialog.tsx +315 -51
- package/src/primitives/drawer.tsx +338 -0
- package/src/primitives/notification-feed.tsx +358 -0
- package/src/primitives/notification.tsx +42 -2
- package/src/primitives/pagination.tsx +376 -0
- package/src/primitives/progress.tsx +267 -6
- package/src/primitives/rating.tsx +245 -0
- package/src/primitives/scroll-area.tsx +169 -0
- package/src/primitives/segmented-control.tsx +162 -0
- package/src/primitives/separator.tsx +163 -3
- package/src/primitives/slider.tsx +160 -0
- package/src/primitives/step-indicator.tsx +287 -0
- package/src/primitives/table.tsx +142 -11
- package/src/primitives/tabs.tsx +174 -12
- 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
|
+
}
|