@sonny-ui/core 0.1.0-alpha.15 → 0.1.0-alpha.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,537 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ effect,
6
+ ElementRef,
7
+ forwardRef,
8
+ HostListener,
9
+ inject,
10
+ input,
11
+ model,
12
+ OnDestroy,
13
+ output,
14
+ signal,
15
+ untracked,
16
+ viewChild,
17
+ } from '@angular/core';
18
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
19
+ import { cn } from '../core/utils/cn';
20
+ import type { ColorFormat, ColorPickerPreset, HSV, RGB } from './color-picker.types';
21
+ import {
22
+ rgbToHex,
23
+ rgbToHsv,
24
+ hsvToRgb,
25
+ parseColor,
26
+ formatColor,
27
+ isValidColor,
28
+ } from './color-picker.utils';
29
+ import { colorPickerTriggerVariants, type ColorPickerSize } from './color-picker.variants';
30
+
31
+ @Component({
32
+ selector: 'sny-color-picker',
33
+ standalone: true,
34
+ changeDetection: ChangeDetectionStrategy.OnPush,
35
+ host: { class: 'relative inline-block' },
36
+ providers: [
37
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SnyColorPickerComponent), multi: true },
38
+ ],
39
+ template: `
40
+ <!-- Trigger -->
41
+ @if (!inline()) {
42
+ <button
43
+ #triggerEl
44
+ type="button"
45
+ role="combobox"
46
+ [attr.aria-expanded]="open()"
47
+ aria-haspopup="dialog"
48
+ [disabled]="isDisabled()"
49
+ [class]="triggerClass()"
50
+ (click)="toggle()"
51
+ (blur)="onTouched()"
52
+ >
53
+ <div
54
+ class="h-5 w-5 rounded-sm border border-border shrink-0"
55
+ [style.backgroundColor]="displayValue()"
56
+ ></div>
57
+ <span class="truncate">{{ displayValue() || placeholder() }}</span>
58
+ </button>
59
+ }
60
+
61
+ <!-- Panel -->
62
+ @if (open() || inline()) {
63
+ <div
64
+ #panelEl
65
+ [class]="panelClass()"
66
+ role="dialog"
67
+ aria-modal="true"
68
+ aria-label="Color picker"
69
+ >
70
+ <!-- Saturation/Brightness Panel -->
71
+ <div
72
+ #satPanel
73
+ class="relative h-36 w-full rounded-md cursor-crosshair overflow-hidden"
74
+ [style.background]="saturationBg()"
75
+ (mousedown)="onSatPanelDown($event)"
76
+ (touchstart)="onSatPanelTouch($event)"
77
+ >
78
+ <div class="absolute inset-0 bg-gradient-to-t from-black to-transparent"></div>
79
+ <div
80
+ class="absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-md pointer-events-none"
81
+ [style.left.%]="hsv().s * 100"
82
+ [style.top.%]="(1 - hsv().v) * 100"
83
+ ></div>
84
+ </div>
85
+
86
+ <!-- Hue Slider -->
87
+ <div
88
+ #hueTrack
89
+ class="relative h-3 w-full rounded-full cursor-pointer mt-3"
90
+ style="background: 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%))"
91
+ (mousedown)="onHueDown($event)"
92
+ (touchstart)="onHueTouch($event)"
93
+ >
94
+ <div
95
+ class="absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-md pointer-events-none"
96
+ [style.left.%]="hsv().h / 360 * 100"
97
+ [style.backgroundColor]="'hsl(' + hsv().h + ', 100%, 50%)'"
98
+ ></div>
99
+ </div>
100
+
101
+ <!-- Input + Format + Copy + Actions -->
102
+ @if (showInput()) {
103
+ <div class="mt-3 flex items-center gap-1.5">
104
+ <div
105
+ class="h-8 w-8 rounded-sm border border-border shrink-0"
106
+ [style.backgroundColor]="displayValue()"
107
+ ></div>
108
+ <input
109
+ class="flex-1 min-w-0 h-8 rounded-sm border border-border bg-background px-2 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring"
110
+ [value]="inputValue()"
111
+ (input)="onInputChange($event)"
112
+ (blur)="commitInput()"
113
+ (keydown.enter)="commitInput()"
114
+ />
115
+ <button
116
+ type="button"
117
+ class="h-8 px-1.5 rounded-sm border border-border text-[10px] font-semibold uppercase hover:bg-accent transition-colors shrink-0"
118
+ (click)="cycleFormat()"
119
+ title="Switch format"
120
+ >
121
+ {{ currentFormat() }}
122
+ </button>
123
+ <button
124
+ type="button"
125
+ class="h-8 w-8 inline-flex items-center justify-center rounded-sm border border-border hover:bg-accent transition-colors shrink-0"
126
+ (click)="copyColor()"
127
+ [title]="copied() ? 'Copied!' : 'Copy color'"
128
+ >
129
+ @if (copied()) {
130
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
131
+ } @else {
132
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
133
+ }
134
+ </button>
135
+ </div>
136
+ <!-- Secondary actions row -->
137
+ <div class="mt-2 flex items-center gap-1.5">
138
+ @if (showEyeDropper() && hasEyeDropper) {
139
+ <button
140
+ type="button"
141
+ class="h-7 px-2 inline-flex items-center gap-1.5 rounded-sm border border-border text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
142
+ (click)="pickFromScreen()"
143
+ title="Pick from screen"
144
+ >
145
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 22 1-1h3l9-9"/><path d="M3 21v-3l9-9"/><path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.4.4a2.1 2.1 0 1 1-3 3l-3.8-3.8a2.1 2.1 0 1 1 3-3l.4.4Z"/></svg>
146
+ Pick
147
+ </button>
148
+ }
149
+ @if (showFavorites()) {
150
+ <button
151
+ type="button"
152
+ class="h-7 px-2 inline-flex items-center gap-1.5 rounded-sm border border-border text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
153
+ (click)="addFavorite()"
154
+ title="Save to favorites"
155
+ >
156
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/></svg>
157
+ Save
158
+ </button>
159
+ }
160
+ </div>
161
+ }
162
+
163
+ <!-- Presets -->
164
+ @for (preset of presets(); track $index) {
165
+ <div class="mt-3">
166
+ @if (preset.label) {
167
+ <p class="text-xs font-medium text-muted-foreground mb-1.5">{{ preset.label }}</p>
168
+ }
169
+ <div class="flex flex-wrap gap-1.5">
170
+ @for (color of preset.colors; track color) {
171
+ <button
172
+ type="button"
173
+ class="h-6 w-6 rounded-sm border border-border hover:scale-110 transition-transform cursor-pointer"
174
+ [style.backgroundColor]="color"
175
+ [title]="color"
176
+ (click)="selectColor(color)"
177
+ ></button>
178
+ }
179
+ </div>
180
+ </div>
181
+ }
182
+
183
+ <!-- Favorites -->
184
+ @if (showFavorites() && favorites().length > 0) {
185
+ <div class="mt-3">
186
+ <p class="text-xs font-medium text-muted-foreground mb-1.5">Favorites</p>
187
+ <div class="flex flex-wrap gap-1.5">
188
+ @for (fav of favorites(); track fav) {
189
+ <div class="relative group">
190
+ <button
191
+ type="button"
192
+ class="h-6 w-6 rounded-sm border border-border hover:scale-110 transition-transform cursor-pointer"
193
+ [style.backgroundColor]="fav"
194
+ [title]="fav"
195
+ (click)="selectColor(fav)"
196
+ ></button>
197
+ <button
198
+ type="button"
199
+ class="absolute -top-1 -right-1 h-3.5 w-3.5 rounded-full bg-destructive text-destructive-foreground text-[8px] leading-none items-center justify-center hidden group-hover:inline-flex"
200
+ (click)="removeFavorite(fav); $event.stopPropagation()"
201
+ >×</button>
202
+ </div>
203
+ }
204
+ </div>
205
+ </div>
206
+ }
207
+ </div>
208
+ }
209
+ `,
210
+ })
211
+ export class SnyColorPickerComponent implements ControlValueAccessor, OnDestroy {
212
+ // Public API
213
+ readonly value = model('#000000');
214
+ readonly format = input<ColorFormat>('hex');
215
+ readonly presets = input<ColorPickerPreset[]>([]);
216
+ readonly showInput = input(true);
217
+ readonly showEyeDropper = input(true);
218
+ readonly showFavorites = input(false);
219
+ readonly inline = input(false);
220
+ readonly disabled = input(false);
221
+ readonly placeholder = input('Pick a color...');
222
+ readonly size = input<ColorPickerSize>('md');
223
+ readonly class = input<string>('');
224
+
225
+ readonly colorChange = output<string>();
226
+ readonly formatChange = output<ColorFormat>();
227
+
228
+ // Internal state
229
+ readonly hsv = signal<HSV>({ h: 0, s: 0, v: 0 });
230
+ readonly currentFormat = signal<ColorFormat>('hex');
231
+ readonly inputValue = signal('');
232
+ readonly open = signal(false);
233
+ readonly favorites = signal<string[]>([]);
234
+ readonly copied = signal(false);
235
+
236
+ private readonly _disabledByCva = signal(false);
237
+ protected readonly isDisabled = computed(() => this.disabled() || this._disabledByCva());
238
+
239
+ private readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('triggerEl');
240
+ private readonly panelRef = viewChild<ElementRef<HTMLDivElement>>('panelEl');
241
+ private readonly satPanelRef = viewChild<ElementRef<HTMLDivElement>>('satPanel');
242
+ private readonly hueTrackRef = viewChild<ElementRef<HTMLDivElement>>('hueTrack');
243
+ private readonly elRef = inject(ElementRef);
244
+
245
+ private moveHandler: ((e: MouseEvent | TouchEvent) => void) | null = null;
246
+ private upHandler: (() => void) | null = null;
247
+ private scrollHandler: (() => void) | null = null;
248
+ private resizeHandler: (() => void) | null = null;
249
+
250
+ private _onChange: (value: string) => void = () => {};
251
+ protected onTouched: () => void = () => {};
252
+
253
+ readonly hasEyeDropper = typeof window !== 'undefined' && 'EyeDropper' in window;
254
+
255
+ // Computed
256
+ readonly rgb = computed<RGB>(() => hsvToRgb(this.hsv()));
257
+ readonly displayValue = computed(() =>
258
+ formatColor(this.rgb(), this.currentFormat())
259
+ );
260
+
261
+ readonly saturationBg = computed(() =>
262
+ `linear-gradient(to right, #fff, hsl(${this.hsv().h}, 100%, 50%))`
263
+ );
264
+
265
+ protected readonly triggerClass = computed(() =>
266
+ cn(colorPickerTriggerVariants({ size: this.size() }), this.class())
267
+ );
268
+
269
+ protected readonly panelClass = computed(() =>
270
+ this.inline()
271
+ ? 'inline-block p-3 rounded-md border border-border bg-popover text-popover-foreground w-60'
272
+ : 'fixed z-50 p-3 rounded-md border border-border bg-popover text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95 w-60'
273
+ );
274
+
275
+ constructor() {
276
+ // Sync format input
277
+ effect(() => {
278
+ const fmt = this.format();
279
+ untracked(() => this.currentFormat.set(fmt));
280
+ });
281
+
282
+ // Sync value → HSV when value changes externally
283
+ effect(() => {
284
+ const val = this.value();
285
+ untracked(() => {
286
+ const rgb = parseColor(val);
287
+ if (rgb) {
288
+ this.hsv.set(rgbToHsv(rgb));
289
+ this.inputValue.set(this.displayValue());
290
+ }
291
+ });
292
+ });
293
+ }
294
+
295
+ // CVA
296
+ writeValue(val: string): void {
297
+ this.value.set(val ?? '#000000');
298
+ }
299
+
300
+ registerOnChange(fn: (value: string) => void): void {
301
+ this._onChange = fn;
302
+ }
303
+
304
+ registerOnTouched(fn: () => void): void {
305
+ this.onTouched = fn;
306
+ }
307
+
308
+ setDisabledState(isDisabled: boolean): void {
309
+ this._disabledByCva.set(isDisabled);
310
+ }
311
+
312
+ // Emit helper
313
+ private emitColor(): void {
314
+ const formatted = this.displayValue();
315
+ this.value.set(formatted);
316
+ this.inputValue.set(formatted);
317
+ this._onChange(formatted);
318
+ this.colorChange.emit(formatted);
319
+ }
320
+
321
+ // Saturation panel
322
+ onSatPanelDown(event: MouseEvent): void {
323
+ event.preventDefault();
324
+ this.updateSatFromPosition(event.clientX, event.clientY);
325
+ this.startDrag((e) => {
326
+ const x = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
327
+ const y = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY;
328
+ this.updateSatFromPosition(x, y);
329
+ });
330
+ }
331
+
332
+ onSatPanelTouch(event: TouchEvent): void {
333
+ this.updateSatFromPosition(event.touches[0].clientX, event.touches[0].clientY);
334
+ this.startDrag((e) => {
335
+ const x = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
336
+ const y = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY;
337
+ this.updateSatFromPosition(x, y);
338
+ }, true);
339
+ }
340
+
341
+ private updateSatFromPosition(clientX: number, clientY: number): void {
342
+ const panel = this.satPanelRef()?.nativeElement;
343
+ if (!panel) return;
344
+ const rect = panel.getBoundingClientRect();
345
+ const s = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
346
+ const v = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height));
347
+ this.hsv.update((prev) => ({ ...prev, s, v }));
348
+ this.emitColor();
349
+ }
350
+
351
+ // Hue slider
352
+ onHueDown(event: MouseEvent): void {
353
+ event.preventDefault();
354
+ this.updateHueFromPosition(event.clientX);
355
+ this.startDrag((e) => {
356
+ const x = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
357
+ this.updateHueFromPosition(x);
358
+ });
359
+ }
360
+
361
+ onHueTouch(event: TouchEvent): void {
362
+ this.updateHueFromPosition(event.touches[0].clientX);
363
+ this.startDrag((e) => {
364
+ const x = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
365
+ this.updateHueFromPosition(x);
366
+ }, true);
367
+ }
368
+
369
+ private updateHueFromPosition(clientX: number): void {
370
+ const track = this.hueTrackRef()?.nativeElement;
371
+ if (!track) return;
372
+ const rect = track.getBoundingClientRect();
373
+ const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
374
+ const h = Math.round(percent * 360);
375
+ this.hsv.update((prev) => ({ ...prev, h }));
376
+ this.emitColor();
377
+ }
378
+
379
+ // Drag helpers (same pattern as slider component)
380
+ private startDrag(handler: (e: MouseEvent | TouchEvent) => void, touch = false): void {
381
+ this.removeDragListeners();
382
+ this.moveHandler = handler;
383
+ this.upHandler = () => {
384
+ this.onTouched();
385
+ this.removeDragListeners();
386
+ };
387
+ if (touch) {
388
+ document.addEventListener('touchmove', this.moveHandler as EventListener, { passive: true });
389
+ document.addEventListener('touchend', this.upHandler);
390
+ } else {
391
+ document.addEventListener('mousemove', this.moveHandler as EventListener);
392
+ document.addEventListener('mouseup', this.upHandler);
393
+ }
394
+ }
395
+
396
+ private removeDragListeners(): void {
397
+ if (this.moveHandler) {
398
+ document.removeEventListener('mousemove', this.moveHandler as EventListener);
399
+ document.removeEventListener('touchmove', this.moveHandler as EventListener);
400
+ this.moveHandler = null;
401
+ }
402
+ if (this.upHandler) {
403
+ document.removeEventListener('mouseup', this.upHandler);
404
+ document.removeEventListener('touchend', this.upHandler);
405
+ this.upHandler = null;
406
+ }
407
+ }
408
+
409
+ // Input
410
+ onInputChange(event: Event): void {
411
+ this.inputValue.set((event.target as HTMLInputElement).value);
412
+ }
413
+
414
+ commitInput(): void {
415
+ const val = this.inputValue().trim();
416
+ if (isValidColor(val)) {
417
+ const rgb = parseColor(val)!;
418
+ this.hsv.set(rgbToHsv(rgb));
419
+ this.emitColor();
420
+ } else {
421
+ this.inputValue.set(this.displayValue());
422
+ }
423
+ }
424
+
425
+ // Format
426
+ cycleFormat(): void {
427
+ const formats: ColorFormat[] = ['hex', 'rgb', 'hsl'];
428
+ const idx = formats.indexOf(this.currentFormat());
429
+ const next = formats[(idx + 1) % formats.length];
430
+ this.currentFormat.set(next);
431
+ this.inputValue.set(this.displayValue());
432
+ this.formatChange.emit(next);
433
+ }
434
+
435
+ // Copy
436
+ copyColor(): void {
437
+ navigator.clipboard.writeText(this.displayValue());
438
+ this.copied.set(true);
439
+ setTimeout(() => this.copied.set(false), 2000);
440
+ }
441
+
442
+ // Presets & favorites
443
+ selectColor(color: string): void {
444
+ const rgb = parseColor(color);
445
+ if (rgb) {
446
+ this.hsv.set(rgbToHsv(rgb));
447
+ this.emitColor();
448
+ }
449
+ }
450
+
451
+ addFavorite(): void {
452
+ const hex = rgbToHex(this.rgb());
453
+ this.favorites.update((favs) =>
454
+ favs.includes(hex) ? favs : [...favs, hex]
455
+ );
456
+ }
457
+
458
+ removeFavorite(color: string): void {
459
+ this.favorites.update((favs) => favs.filter((f) => f !== color));
460
+ }
461
+
462
+ // EyeDropper
463
+ async pickFromScreen(): Promise<void> {
464
+ if (!this.hasEyeDropper) return;
465
+ try {
466
+ const dropper = new (window as any).EyeDropper();
467
+ const result = await dropper.open();
468
+ this.selectColor(result.sRGBHex);
469
+ } catch {
470
+ // User cancelled
471
+ }
472
+ }
473
+
474
+ // Popover
475
+ toggle(): void {
476
+ if (this.open()) {
477
+ this.close();
478
+ } else {
479
+ this.open.set(true);
480
+ this.addPositionListeners();
481
+ setTimeout(() => this.updatePanelPosition());
482
+ }
483
+ }
484
+
485
+ close(): void {
486
+ this.open.set(false);
487
+ this.removePositionListeners();
488
+ }
489
+
490
+ private updatePanelPosition(): void {
491
+ if (this.inline()) return;
492
+ const trigger = this.triggerRef()?.nativeElement;
493
+ if (!trigger) return;
494
+ const rect = trigger.getBoundingClientRect();
495
+ const panel = this.panelRef()?.nativeElement;
496
+ if (panel) {
497
+ panel.style.top = `${rect.bottom + 4}px`;
498
+ panel.style.left = `${rect.left}px`;
499
+ }
500
+ }
501
+
502
+ private addPositionListeners(): void {
503
+ this.removePositionListeners();
504
+ this.scrollHandler = () => requestAnimationFrame(() => this.updatePanelPosition());
505
+ this.resizeHandler = () => requestAnimationFrame(() => this.updatePanelPosition());
506
+ document.addEventListener('scroll', this.scrollHandler, { capture: true, passive: true });
507
+ window.addEventListener('resize', this.resizeHandler, { passive: true });
508
+ }
509
+
510
+ private removePositionListeners(): void {
511
+ if (this.scrollHandler) {
512
+ document.removeEventListener('scroll', this.scrollHandler, { capture: true } as EventListenerOptions);
513
+ this.scrollHandler = null;
514
+ }
515
+ if (this.resizeHandler) {
516
+ window.removeEventListener('resize', this.resizeHandler);
517
+ this.resizeHandler = null;
518
+ }
519
+ }
520
+
521
+ @HostListener('document:click', ['$event'])
522
+ onDocumentClick(event: MouseEvent): void {
523
+ if (!this.elRef.nativeElement.contains(event.target)) {
524
+ this.close();
525
+ }
526
+ }
527
+
528
+ @HostListener('keydown.escape')
529
+ onEscape(): void {
530
+ this.close();
531
+ }
532
+
533
+ ngOnDestroy(): void {
534
+ this.removeDragListeners();
535
+ this.removePositionListeners();
536
+ }
537
+ }
@@ -0,0 +1,24 @@
1
+ export type ColorFormat = 'hex' | 'rgb' | 'hsl';
2
+
3
+ export interface RGB {
4
+ r: number;
5
+ g: number;
6
+ b: number;
7
+ }
8
+
9
+ export interface HSL {
10
+ h: number;
11
+ s: number;
12
+ l: number;
13
+ }
14
+
15
+ export interface HSV {
16
+ h: number;
17
+ s: number;
18
+ v: number;
19
+ }
20
+
21
+ export interface ColorPickerPreset {
22
+ label?: string;
23
+ colors: string[];
24
+ }