@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,589 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { render as rtlRender, fireEvent, screen, waitFor } from '@testing-library/react'
5
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
6
+ import { ColorPicker, normalizeHex, COLOR_PICKER_DEFAULT_SWATCHES } from '../color-picker'
7
+
8
+ // ColorPicker uses useT() for the eyedropper aria-label; wrap every
9
+ // render in an empty-dict I18nProvider so the primitive falls back
10
+ // to its hardcoded English defaults.
11
+ const render: typeof rtlRender = (ui: React.ReactElement, options?: Parameters<typeof rtlRender>[1]) =>
12
+ rtlRender(
13
+ <I18nProvider locale="en" dict={{}}>
14
+ {ui}
15
+ </I18nProvider>,
16
+ options,
17
+ )
18
+
19
+ describe('normalizeHex', () => {
20
+ it('uppercases and prefixes 6-digit hex', () => {
21
+ expect(normalizeHex('6366f1')).toBe('#6366F1')
22
+ expect(normalizeHex('#6366f1')).toBe('#6366F1')
23
+ expect(normalizeHex(' #abc123 ')).toBe('#ABC123')
24
+ })
25
+
26
+ it('expands 3-digit hex to 6 and uppercases', () => {
27
+ expect(normalizeHex('#f0a')).toBe('#FF00AA')
28
+ expect(normalizeHex('abc')).toBe('#AABBCC')
29
+ })
30
+
31
+ it('returns null for invalid input', () => {
32
+ expect(normalizeHex('not-a-hex')).toBeNull()
33
+ expect(normalizeHex('#12345')).toBeNull()
34
+ expect(normalizeHex('#1234567')).toBeNull()
35
+ expect(normalizeHex('')).toBeNull()
36
+ })
37
+ })
38
+
39
+ describe('ColorPicker', () => {
40
+ it('renders a trigger button with the current color preview and hex label', () => {
41
+ const { container } = render(<ColorPicker value="#6366F1" onChange={() => {}} />)
42
+ const trigger = container.querySelector('[data-slot="color-picker-trigger"]') as HTMLButtonElement
43
+ expect(trigger).not.toBeNull()
44
+ expect(trigger.textContent).toContain('#6366F1')
45
+ const preview = container.querySelector('[data-slot="color-picker-preview"]') as HTMLSpanElement
46
+ expect(preview.style.backgroundColor).toBeTruthy()
47
+ })
48
+
49
+ it('opens the popover when the trigger is clicked', async () => {
50
+ const { container } = render(<ColorPicker value="#6366F1" onChange={() => {}} />)
51
+ const trigger = container.querySelector('[data-slot="color-picker-trigger"]') as HTMLButtonElement
52
+ fireEvent.click(trigger)
53
+ await waitFor(() => {
54
+ expect(screen.queryByText('Choose color')).toBeInTheDocument()
55
+ })
56
+ })
57
+
58
+ describe('Section 1 — Choose color', () => {
59
+ it('renders the hue slider as a native range input', async () => {
60
+ render(<ColorPicker value="#6366F1" onChange={() => {}} />)
61
+ fireEvent.click(screen.getByRole('button'))
62
+ await waitFor(() => {
63
+ const slider = document.querySelector('[data-slot="color-picker-hue-slider"]') as HTMLInputElement
64
+ expect(slider).not.toBeNull()
65
+ expect(slider.tagName).toBe('INPUT')
66
+ expect(slider.type).toBe('range')
67
+ expect(slider.min).toBe('0')
68
+ expect(slider.max).toBe('360')
69
+ })
70
+ })
71
+
72
+ it('changing the hue slider fires onChange with a pure saturated color', async () => {
73
+ const onChange = jest.fn()
74
+ render(<ColorPicker value="#6366F1" onChange={onChange} />)
75
+ fireEvent.click(screen.getByRole('button'))
76
+ await waitFor(() => {
77
+ expect(document.querySelector('[data-slot="color-picker-hue-slider"]')).not.toBeNull()
78
+ })
79
+ const slider = document.querySelector(
80
+ '[data-slot="color-picker-hue-slider"]',
81
+ ) as HTMLInputElement
82
+ fireEvent.change(slider, { target: { value: '0' } })
83
+ // Hue 0° + s=100% + l=50% = pure red #FF0000
84
+ expect(onChange).toHaveBeenCalledWith('#FF0000')
85
+ })
86
+
87
+ it('honors the custom chooseLabel prop', async () => {
88
+ render(<ColorPicker value="#6366F1" onChange={() => {}} chooseLabel="Pick a hue" />)
89
+ fireEvent.click(screen.getByRole('button'))
90
+ await waitFor(() => {
91
+ expect(screen.queryByText('Pick a hue')).toBeInTheDocument()
92
+ })
93
+ })
94
+ })
95
+
96
+ describe('Section 2 — Hex input + Eyedropper', () => {
97
+ it('renders the hex input when allowCustom is true (default)', async () => {
98
+ render(<ColorPicker value="#6366F1" onChange={() => {}} />)
99
+ fireEvent.click(screen.getByRole('button'))
100
+ await waitFor(() => {
101
+ expect(document.querySelector('[data-slot="color-picker-hex"]')).not.toBeNull()
102
+ })
103
+ })
104
+
105
+ it('does NOT render the hex input when allowCustom=false', async () => {
106
+ render(<ColorPicker value="#6366F1" onChange={() => {}} allowCustom={false} />)
107
+ fireEvent.click(screen.getByRole('button'))
108
+ await waitFor(() => {
109
+ expect(document.querySelector('[data-slot="color-picker-swatch"]')).not.toBeNull()
110
+ })
111
+ expect(document.querySelector('[data-slot="color-picker-hex"]')).toBeNull()
112
+ })
113
+
114
+ it('commits hex on blur when input is valid', async () => {
115
+ const onChange = jest.fn()
116
+ render(<ColorPicker value="#6366F1" onChange={onChange} />)
117
+ fireEvent.click(screen.getByRole('button'))
118
+ await waitFor(() => {
119
+ expect(document.querySelector('[data-slot="color-picker-hex"]')).not.toBeNull()
120
+ })
121
+ const input = document.querySelector('[data-slot="color-picker-hex"]') as HTMLInputElement
122
+ fireEvent.change(input, { target: { value: '#abcdef' } })
123
+ fireEvent.blur(input)
124
+ expect(onChange).toHaveBeenCalledWith('#ABCDEF')
125
+ })
126
+
127
+ it('shows an inline error and does NOT call onChange when hex is invalid', async () => {
128
+ const onChange = jest.fn()
129
+ render(<ColorPicker value="#6366F1" onChange={onChange} />)
130
+ fireEvent.click(screen.getByRole('button'))
131
+ await waitFor(() => {
132
+ expect(document.querySelector('[data-slot="color-picker-hex"]')).not.toBeNull()
133
+ })
134
+ const input = document.querySelector('[data-slot="color-picker-hex"]') as HTMLInputElement
135
+ fireEvent.change(input, { target: { value: 'nope' } })
136
+ fireEvent.blur(input)
137
+ expect(onChange).not.toHaveBeenCalled()
138
+ await waitFor(() => {
139
+ expect(document.querySelector('[data-slot="color-picker-error"]')).not.toBeNull()
140
+ })
141
+ })
142
+
143
+ it('commits hex on Enter', async () => {
144
+ const onChange = jest.fn()
145
+ render(<ColorPicker value="#6366F1" onChange={onChange} />)
146
+ fireEvent.click(screen.getByRole('button'))
147
+ await waitFor(() => {
148
+ expect(document.querySelector('[data-slot="color-picker-hex"]')).not.toBeNull()
149
+ })
150
+ const input = document.querySelector('[data-slot="color-picker-hex"]') as HTMLInputElement
151
+ fireEvent.change(input, { target: { value: '#111222' } })
152
+ fireEvent.keyDown(input, { key: 'Enter' })
153
+ expect(onChange).toHaveBeenCalledWith('#111222')
154
+ })
155
+
156
+ it('does NOT render the eyedropper button when browser lacks EyeDropper API', async () => {
157
+ // jsdom does not implement EyeDropper — confirm the button is absent.
158
+ render(<ColorPicker value="#6366F1" onChange={() => {}} />)
159
+ fireEvent.click(screen.getByRole('button'))
160
+ await waitFor(() => {
161
+ expect(document.querySelector('[data-slot="color-picker-hex"]')).not.toBeNull()
162
+ })
163
+ expect(document.querySelector('[data-slot="color-picker-eyedropper"]')).toBeNull()
164
+ })
165
+ })
166
+
167
+ describe('Section 3 — Saved colors', () => {
168
+ it('renders the default 10 swatches when no swatches prop is provided', async () => {
169
+ render(<ColorPicker value="#6366F1" onChange={() => {}} />)
170
+ fireEvent.click(screen.getByRole('button'))
171
+ await waitFor(() => {
172
+ const swatches = document.querySelectorAll('[data-slot="color-picker-swatch"]')
173
+ expect(swatches.length).toBe(COLOR_PICKER_DEFAULT_SWATCHES.length)
174
+ expect(COLOR_PICKER_DEFAULT_SWATCHES.length).toBe(10)
175
+ })
176
+ })
177
+
178
+ it('selected swatch dot carries an inset white ring (Figma Selected state)', async () => {
179
+ render(<ColorPicker value="#6366F1" onChange={() => {}} />)
180
+ fireEvent.click(screen.getByRole('button'))
181
+ await waitFor(() => {
182
+ const selected = document.querySelector(
183
+ '[data-slot="color-picker-swatch"][data-state="on"]',
184
+ ) as HTMLButtonElement
185
+ expect(selected).not.toBeNull()
186
+ const dot = selected.querySelector(
187
+ '[data-slot="color-picker-swatch-dot"]',
188
+ ) as HTMLSpanElement
189
+ expect(dot.className).toContain('ring-2')
190
+ expect(dot.className).toContain('ring-inset')
191
+ expect(dot.className).toContain('ring-background')
192
+ })
193
+ })
194
+
195
+ it('fires onChange with the swatch value when a swatch is clicked', async () => {
196
+ const onChange = jest.fn()
197
+ render(<ColorPicker value="#6366F1" onChange={onChange} />)
198
+ fireEvent.click(screen.getByRole('button'))
199
+ await waitFor(() => {
200
+ expect(document.querySelectorAll('[data-slot="color-picker-swatch"]').length).toBeGreaterThan(0)
201
+ })
202
+ const target = Array.from(
203
+ document.querySelectorAll('[data-slot="color-picker-swatch"]'),
204
+ ).find((node) => node.getAttribute('aria-label') === '#22C55E') as HTMLButtonElement
205
+ fireEvent.click(target)
206
+ expect(onChange).toHaveBeenCalledWith('#22C55E')
207
+ })
208
+
209
+ it('honors a custom swatches palette', async () => {
210
+ render(
211
+ <ColorPicker
212
+ value="#FF0000"
213
+ onChange={() => {}}
214
+ swatches={['#FF0000', '#00FF00', '#0000FF']}
215
+ />,
216
+ )
217
+ fireEvent.click(screen.getByRole('button'))
218
+ await waitFor(() => {
219
+ const swatches = document.querySelectorAll('[data-slot="color-picker-swatch"]')
220
+ expect(swatches.length).toBe(3)
221
+ })
222
+ })
223
+
224
+ it('honors the savedLabel prop', async () => {
225
+ render(<ColorPicker value="#6366F1" onChange={() => {}} savedLabel="My palette" />)
226
+ fireEvent.click(screen.getByRole('button'))
227
+ await waitFor(() => {
228
+ expect(screen.queryByText('My palette')).toBeInTheDocument()
229
+ })
230
+ })
231
+ })
232
+
233
+ describe('Section 4 — Add new color (optional)', () => {
234
+ it('does NOT render the add button when onAddSwatch is omitted', async () => {
235
+ render(<ColorPicker value="#6366F1" onChange={() => {}} />)
236
+ fireEvent.click(screen.getByRole('button'))
237
+ await waitFor(() => {
238
+ expect(document.querySelector('[data-slot="color-picker-swatch"]')).not.toBeNull()
239
+ })
240
+ expect(document.querySelector('[data-slot="color-picker-add"]')).toBeNull()
241
+ })
242
+
243
+ it('renders the add button when onAddSwatch is provided', async () => {
244
+ render(
245
+ <ColorPicker value="#6366F1" onChange={() => {}} onAddSwatch={() => {}} />,
246
+ )
247
+ fireEvent.click(screen.getByRole('button'))
248
+ await waitFor(() => {
249
+ expect(document.querySelector('[data-slot="color-picker-add"]')).not.toBeNull()
250
+ })
251
+ })
252
+
253
+ it('fires onAddSwatch with the current value when the add button is clicked', async () => {
254
+ const onAddSwatch = jest.fn()
255
+ render(
256
+ <ColorPicker value="#123456" onChange={() => {}} onAddSwatch={onAddSwatch} />,
257
+ )
258
+ fireEvent.click(screen.getByRole('button'))
259
+ await waitFor(() => {
260
+ expect(document.querySelector('[data-slot="color-picker-add"]')).not.toBeNull()
261
+ })
262
+ const addButton = document.querySelector('[data-slot="color-picker-add"]') as HTMLButtonElement
263
+ fireEvent.click(addButton)
264
+ expect(onAddSwatch).toHaveBeenCalledWith('#123456')
265
+ })
266
+
267
+ it('honors the addLabel prop', async () => {
268
+ render(
269
+ <ColorPicker
270
+ value="#6366F1"
271
+ onChange={() => {}}
272
+ onAddSwatch={() => {}}
273
+ addLabel="Save color"
274
+ />,
275
+ )
276
+ fireEvent.click(screen.getByRole('button'))
277
+ await waitFor(() => {
278
+ expect(screen.queryByText('Save color')).toBeInTheDocument()
279
+ })
280
+ })
281
+ })
282
+
283
+ describe('Section 2 — extras (opacity / trash)', () => {
284
+ it('renders the "100%" opacity badge inside the hex container when showOpacity=true', async () => {
285
+ render(<ColorPicker value="#6366F1" onChange={() => {}} showOpacity />)
286
+ fireEvent.click(screen.getByRole('button'))
287
+ await waitFor(() => {
288
+ expect(document.querySelector('[data-slot="color-picker-opacity"]')).not.toBeNull()
289
+ })
290
+ expect(
291
+ document.querySelector('[data-slot="color-picker-opacity"]')?.textContent,
292
+ ).toBe('100%')
293
+ })
294
+
295
+ it('does NOT render the opacity badge by default', async () => {
296
+ render(<ColorPicker value="#6366F1" onChange={() => {}} />)
297
+ fireEvent.click(screen.getByRole('button'))
298
+ await waitFor(() => {
299
+ expect(document.querySelector('[data-slot="color-picker-hex"]')).not.toBeNull()
300
+ })
301
+ expect(document.querySelector('[data-slot="color-picker-opacity"]')).toBeNull()
302
+ })
303
+
304
+ it('does NOT render the remove button when onRemoveColor is omitted', async () => {
305
+ render(<ColorPicker value="#6366F1" onChange={() => {}} />)
306
+ fireEvent.click(screen.getByRole('button'))
307
+ await waitFor(() => {
308
+ expect(document.querySelector('[data-slot="color-picker-hex"]')).not.toBeNull()
309
+ })
310
+ expect(document.querySelector('[data-slot="color-picker-remove"]')).toBeNull()
311
+ })
312
+
313
+ it('renders the remove (trash) button when onRemoveColor is provided', async () => {
314
+ render(
315
+ <ColorPicker
316
+ value="#6366F1"
317
+ onChange={() => {}}
318
+ onRemoveColor={() => {}}
319
+ />,
320
+ )
321
+ fireEvent.click(screen.getByRole('button'))
322
+ await waitFor(() => {
323
+ expect(document.querySelector('[data-slot="color-picker-remove"]')).not.toBeNull()
324
+ })
325
+ })
326
+
327
+ it('fires onRemoveColor with the current value when the trash button is clicked', async () => {
328
+ const onRemoveColor = jest.fn()
329
+ render(
330
+ <ColorPicker
331
+ value="#ABCDEF"
332
+ onChange={() => {}}
333
+ onRemoveColor={onRemoveColor}
334
+ />,
335
+ )
336
+ fireEvent.click(screen.getByRole('button'))
337
+ await waitFor(() => {
338
+ expect(document.querySelector('[data-slot="color-picker-remove"]')).not.toBeNull()
339
+ })
340
+ const removeButton = document.querySelector(
341
+ '[data-slot="color-picker-remove"]',
342
+ ) as HTMLButtonElement
343
+ fireEvent.click(removeButton)
344
+ expect(onRemoveColor).toHaveBeenCalledWith('#ABCDEF')
345
+ })
346
+ })
347
+
348
+ describe('Section 3 — Edit link', () => {
349
+ it('does NOT render the Edit link when onEditSavedColors is omitted', async () => {
350
+ render(<ColorPicker value="#6366F1" onChange={() => {}} />)
351
+ fireEvent.click(screen.getByRole('button'))
352
+ await waitFor(() => {
353
+ expect(document.querySelector('[data-slot="color-picker-swatch"]')).not.toBeNull()
354
+ })
355
+ expect(document.querySelector('[data-slot="color-picker-edit"]')).toBeNull()
356
+ })
357
+
358
+ it('renders the Edit link in the saved-colors header when onEditSavedColors is provided', async () => {
359
+ render(
360
+ <ColorPicker
361
+ value="#6366F1"
362
+ onChange={() => {}}
363
+ onEditSavedColors={() => {}}
364
+ />,
365
+ )
366
+ fireEvent.click(screen.getByRole('button'))
367
+ await waitFor(() => {
368
+ expect(document.querySelector('[data-slot="color-picker-edit"]')).not.toBeNull()
369
+ })
370
+ expect(
371
+ document.querySelector('[data-slot="color-picker-edit"]')?.textContent,
372
+ ).toBe('Edit')
373
+ })
374
+
375
+ it('fires onEditSavedColors when the Edit link is clicked', async () => {
376
+ const onEditSavedColors = jest.fn()
377
+ render(
378
+ <ColorPicker
379
+ value="#6366F1"
380
+ onChange={() => {}}
381
+ onEditSavedColors={onEditSavedColors}
382
+ />,
383
+ )
384
+ fireEvent.click(screen.getByRole('button'))
385
+ await waitFor(() => {
386
+ expect(document.querySelector('[data-slot="color-picker-edit"]')).not.toBeNull()
387
+ })
388
+ const editLink = document.querySelector(
389
+ '[data-slot="color-picker-edit"]',
390
+ ) as HTMLButtonElement
391
+ fireEvent.click(editLink)
392
+ expect(onEditSavedColors).toHaveBeenCalled()
393
+ })
394
+
395
+ it('honors the editLabel prop', async () => {
396
+ render(
397
+ <ColorPicker
398
+ value="#6366F1"
399
+ onChange={() => {}}
400
+ onEditSavedColors={() => {}}
401
+ editLabel="Manage"
402
+ />,
403
+ )
404
+ fireEvent.click(screen.getByRole('button'))
405
+ await waitFor(() => {
406
+ expect(screen.queryByText('Manage')).toBeInTheDocument()
407
+ })
408
+ })
409
+ })
410
+
411
+ describe('persistKey — uncontrolled swatches with localStorage', () => {
412
+ beforeEach(() => {
413
+ window.localStorage.clear()
414
+ })
415
+
416
+ it('hydrates the swatches from localStorage on mount when persistKey is set', async () => {
417
+ window.localStorage.setItem(
418
+ 'color-picker:test-1',
419
+ JSON.stringify(['#FF0000', '#00FF00', '#0000FF']),
420
+ )
421
+ render(<ColorPicker value="#6366F1" onChange={() => {}} persistKey="color-picker:test-1" />)
422
+ fireEvent.click(screen.getByRole('button'))
423
+ await waitFor(() => {
424
+ const swatches = document.querySelectorAll('[data-slot="color-picker-swatch"]')
425
+ expect(swatches.length).toBe(3)
426
+ })
427
+ })
428
+
429
+ it('falls back to defaultSwatches when localStorage is empty', async () => {
430
+ render(
431
+ <ColorPicker
432
+ value="#6366F1"
433
+ onChange={() => {}}
434
+ persistKey="color-picker:test-empty"
435
+ defaultSwatches={['#000000', '#FFFFFF']}
436
+ />,
437
+ )
438
+ fireEvent.click(screen.getByRole('button'))
439
+ await waitFor(() => {
440
+ const swatches = document.querySelectorAll('[data-slot="color-picker-swatch"]')
441
+ expect(swatches.length).toBe(2)
442
+ })
443
+ })
444
+
445
+ it('renders the add + trash buttons automatically when persistKey is set (no callbacks needed)', async () => {
446
+ render(<ColorPicker value="#6366F1" onChange={() => {}} persistKey="color-picker:test-auto" />)
447
+ fireEvent.click(screen.getByRole('button'))
448
+ await waitFor(() => {
449
+ expect(document.querySelector('[data-slot="color-picker-add"]')).not.toBeNull()
450
+ expect(document.querySelector('[data-slot="color-picker-remove"]')).not.toBeNull()
451
+ })
452
+ })
453
+
454
+ it('persists newly-added color to localStorage', async () => {
455
+ render(
456
+ <ColorPicker
457
+ value="#ABCDEF"
458
+ onChange={() => {}}
459
+ persistKey="color-picker:test-add"
460
+ defaultSwatches={['#000000']}
461
+ />,
462
+ )
463
+ fireEvent.click(screen.getByRole('button'))
464
+ await waitFor(() => {
465
+ expect(document.querySelector('[data-slot="color-picker-add"]')).not.toBeNull()
466
+ })
467
+ const addButton = document.querySelector(
468
+ '[data-slot="color-picker-add"]',
469
+ ) as HTMLButtonElement
470
+ fireEvent.click(addButton)
471
+ const stored = JSON.parse(window.localStorage.getItem('color-picker:test-add') ?? '[]')
472
+ expect(stored).toContain('#ABCDEF')
473
+ expect(stored).toContain('#000000')
474
+ })
475
+
476
+ it('persists removal of current color from localStorage', async () => {
477
+ window.localStorage.setItem(
478
+ 'color-picker:test-remove',
479
+ JSON.stringify(['#FF0000', '#00FF00', '#0000FF']),
480
+ )
481
+ render(
482
+ <ColorPicker value="#00FF00" onChange={() => {}} persistKey="color-picker:test-remove" />,
483
+ )
484
+ fireEvent.click(screen.getByRole('button'))
485
+ await waitFor(() => {
486
+ expect(document.querySelector('[data-slot="color-picker-remove"]')).not.toBeNull()
487
+ })
488
+ const trash = document.querySelector('[data-slot="color-picker-remove"]') as HTMLButtonElement
489
+ fireEvent.click(trash)
490
+ const stored = JSON.parse(window.localStorage.getItem('color-picker:test-remove') ?? '[]')
491
+ expect(stored).not.toContain('#00FF00')
492
+ expect(stored).toContain('#FF0000')
493
+ expect(stored).toContain('#0000FF')
494
+ })
495
+
496
+ it('does NOT duplicate when adding a color that is already in the palette', async () => {
497
+ window.localStorage.setItem(
498
+ 'color-picker:test-dedup',
499
+ JSON.stringify(['#AABBCC']),
500
+ )
501
+ render(
502
+ <ColorPicker value="#AABBCC" onChange={() => {}} persistKey="color-picker:test-dedup" />,
503
+ )
504
+ fireEvent.click(screen.getByRole('button'))
505
+ await waitFor(() => {
506
+ expect(document.querySelector('[data-slot="color-picker-add"]')).not.toBeNull()
507
+ })
508
+ const addButton = document.querySelector(
509
+ '[data-slot="color-picker-add"]',
510
+ ) as HTMLButtonElement
511
+ fireEvent.click(addButton)
512
+ const stored = JSON.parse(window.localStorage.getItem('color-picker:test-dedup') ?? '[]')
513
+ expect(stored.length).toBe(1)
514
+ })
515
+
516
+ it('still fires onAddSwatch / onRemoveColor as notification callbacks in persisted mode', async () => {
517
+ const onAddSwatch = jest.fn()
518
+ const onRemoveColor = jest.fn()
519
+ window.localStorage.setItem(
520
+ 'color-picker:test-notify',
521
+ JSON.stringify(['#111111']),
522
+ )
523
+ render(
524
+ <ColorPicker
525
+ value="#222222"
526
+ onChange={() => {}}
527
+ persistKey="color-picker:test-notify"
528
+ onAddSwatch={onAddSwatch}
529
+ onRemoveColor={onRemoveColor}
530
+ />,
531
+ )
532
+ fireEvent.click(screen.getByRole('button'))
533
+ await waitFor(() => {
534
+ expect(document.querySelector('[data-slot="color-picker-add"]')).not.toBeNull()
535
+ })
536
+ fireEvent.click(document.querySelector('[data-slot="color-picker-add"]') as HTMLButtonElement)
537
+ expect(onAddSwatch).toHaveBeenCalledWith('#222222')
538
+ })
539
+
540
+ it('controlled mode (swatches prop) takes priority over persistKey', async () => {
541
+ window.localStorage.setItem(
542
+ 'color-picker:test-priority',
543
+ JSON.stringify(['#ABCDEF', '#FEDCBA']),
544
+ )
545
+ render(
546
+ <ColorPicker
547
+ value="#6366F1"
548
+ onChange={() => {}}
549
+ swatches={['#FF0000']}
550
+ persistKey="color-picker:test-priority"
551
+ />,
552
+ )
553
+ fireEvent.click(screen.getByRole('button'))
554
+ await waitFor(() => {
555
+ const swatches = document.querySelectorAll('[data-slot="color-picker-swatch"]')
556
+ expect(swatches.length).toBe(1)
557
+ })
558
+ })
559
+
560
+ it('ignores corrupt JSON in localStorage gracefully', async () => {
561
+ window.localStorage.setItem('color-picker:test-corrupt', 'not-json{')
562
+ render(
563
+ <ColorPicker
564
+ value="#6366F1"
565
+ onChange={() => {}}
566
+ persistKey="color-picker:test-corrupt"
567
+ defaultSwatches={['#AAAAAA']}
568
+ />,
569
+ )
570
+ fireEvent.click(screen.getByRole('button'))
571
+ await waitFor(() => {
572
+ const swatches = document.querySelectorAll('[data-slot="color-picker-swatch"]')
573
+ expect(swatches.length).toBe(1)
574
+ })
575
+ })
576
+ })
577
+
578
+ it('disables the trigger when disabled prop is set', () => {
579
+ const { container } = render(<ColorPicker value="#6366F1" onChange={() => {}} disabled />)
580
+ const trigger = container.querySelector('[data-slot="color-picker-trigger"]') as HTMLButtonElement
581
+ expect(trigger.disabled).toBe(true)
582
+ })
583
+
584
+ it('forwards ref to the trigger element', () => {
585
+ const ref = React.createRef<HTMLButtonElement>()
586
+ render(<ColorPicker ref={ref} value="#6366F1" onChange={() => {}} />)
587
+ expect(ref.current?.getAttribute('data-slot')).toBe('color-picker-trigger')
588
+ })
589
+ })