pickit-color 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,1181 @@
1
+ /**
2
+ * Pickit Color Picker
3
+ * An accessible, lightweight color picker inspired by flatpickr
4
+ * Supports HSL, RGB, HEX formats with keyboard navigation
5
+ */
6
+
7
+ export interface ColorPickerOptions {
8
+ defaultColor?: string;
9
+ format?: "hex" | "rgb" | "hsl";
10
+ showAlpha?: boolean;
11
+ sliderMode?: boolean;
12
+ eyeDropper?: boolean;
13
+ presetColors?: string[];
14
+ presetLabels?: string[];
15
+ presetsOnly?: boolean;
16
+ listView?: boolean;
17
+ inline?: boolean;
18
+ compact?: boolean;
19
+ inputPreview?: boolean;
20
+ onChange?: (color: string) => void;
21
+ onOpen?: () => void;
22
+ onClose?: () => void;
23
+ appendTo?: HTMLElement;
24
+ position?: "auto" | "above" | "below";
25
+ closeOnSelect?: boolean;
26
+ ariaLabels?: {
27
+ hue?: string;
28
+ saturation?: string;
29
+ lightness?: string;
30
+ alpha?: string;
31
+ presets?: string;
32
+ };
33
+ }
34
+
35
+ interface HSL {
36
+ h: number; // 0-360
37
+ s: number; // 0-100
38
+ l: number; // 0-100
39
+ a: number; // 0-1
40
+ }
41
+
42
+ interface RGB {
43
+ r: number; // 0-255
44
+ g: number; // 0-255
45
+ b: number; // 0-255
46
+ a: number; // 0-1
47
+ }
48
+
49
+ export class ColorPicker {
50
+ private input: HTMLInputElement;
51
+ private options: Required<ColorPickerOptions>;
52
+ private container: HTMLElement | null = null;
53
+ private colorBox: HTMLElement | null = null;
54
+ private hueSlider: HTMLInputElement | null = null;
55
+ private saturationSlider: HTMLInputElement | null = null;
56
+ private lightnessSlider: HTMLInputElement | null = null;
57
+ private alphaSlider: HTMLInputElement | null = null;
58
+ private hexInput: HTMLInputElement | null = null;
59
+ private currentColor: HSL = { h: 0, s: 100, l: 50, a: 1 };
60
+ private isOpen = false;
61
+ private saturationPointer: HTMLElement | null = null;
62
+ private compactButton: HTMLButtonElement | null = null;
63
+ private inputPreview: HTMLElement | null = null;
64
+
65
+ private static instances: Map<HTMLElement, ColorPicker> = new Map();
66
+
67
+ constructor(
68
+ element: string | HTMLInputElement,
69
+ options: ColorPickerOptions = {}
70
+ ) {
71
+ this.input =
72
+ typeof element === "string"
73
+ ? (document.querySelector(element) as HTMLInputElement)
74
+ : element;
75
+
76
+ if (!this.input) {
77
+ throw new Error("ColorPicker: Invalid element selector");
78
+ }
79
+
80
+ // Read preset colors from data attribute if available
81
+ const dataPresets = this.input.getAttribute("data-preset-colors");
82
+ const presetsFromAttr = dataPresets ? dataPresets.split(",").map(c => c.trim()) : null;
83
+
84
+ this.options = {
85
+ defaultColor: options.defaultColor || "#3b82f6",
86
+ format: options.format || "hex",
87
+ showAlpha: options.showAlpha ?? false,
88
+ sliderMode: options.sliderMode ?? false,
89
+ eyeDropper: options.eyeDropper ?? false,
90
+ presetColors: options.presetColors || presetsFromAttr || [
91
+ "#ef4444",
92
+ "#f59e0b",
93
+ "#10b981",
94
+ "#3b82f6",
95
+ "#8b5cf6",
96
+ "#ec4899",
97
+ "#000000",
98
+ "#ffffff",
99
+ ],
100
+ presetLabels: options.presetLabels || [],
101
+ presetsOnly: options.presetsOnly ?? false,
102
+ listView: options.listView ?? false,
103
+ inline: options.inline ?? false,
104
+ compact: options.compact ?? false,
105
+ inputPreview: options.inputPreview ?? false,
106
+ onChange: options.onChange || (() => {}),
107
+ onOpen: options.onOpen || (() => {}),
108
+ onClose: options.onClose || (() => {}),
109
+ appendTo: options.appendTo || document.body,
110
+ position: options.position || "auto",
111
+ closeOnSelect: options.closeOnSelect ?? true,
112
+ ariaLabels: {
113
+ hue: options.ariaLabels?.hue || "Hue",
114
+ saturation:
115
+ options.ariaLabels?.saturation || "Saturation and Lightness",
116
+ lightness: options.ariaLabels?.lightness || "Lightness",
117
+ alpha: options.ariaLabels?.alpha || "Alpha",
118
+ presets: options.ariaLabels?.presets || "Preset colors",
119
+ },
120
+ };
121
+
122
+ this.init();
123
+ ColorPicker.instances.set(this.input, this);
124
+ }
125
+
126
+ private init(): void {
127
+ // Parse initial color
128
+ const initialColor = this.input.value || this.options.defaultColor;
129
+ this.currentColor = this.parseColor(initialColor);
130
+
131
+ // Create compact button if needed
132
+ if (this.options.compact) {
133
+ this.createCompactButton();
134
+ }
135
+
136
+ // Create input preview if needed
137
+ if (this.options.inputPreview && !this.options.compact) {
138
+ this.createInputPreview();
139
+ }
140
+
141
+ // Build UI
142
+ this.buildColorPicker();
143
+
144
+ // Setup event listeners
145
+ this.setupEventListeners();
146
+
147
+ // Update display
148
+ this.updateColorDisplay();
149
+
150
+ // Open if inline
151
+ if (this.options.inline) {
152
+ this.open();
153
+ }
154
+ }
155
+
156
+ private buildColorPicker(): void {
157
+ this.container = document.createElement("div");
158
+ this.container.className = "colorpicker-container";
159
+ if (this.options.presetsOnly) {
160
+ this.container.classList.add("colorpicker-presets-only");
161
+ }
162
+ if (this.options.inline) {
163
+ this.container.classList.add("colorpicker-inline");
164
+ }
165
+ this.container.setAttribute("role", "dialog");
166
+ this.container.setAttribute("aria-label", "Color picker");
167
+ this.container.style.display = "none";
168
+
169
+ const content = `
170
+ <div class="colorpicker-content">
171
+ ${!this.options.presetsOnly ? `
172
+ ${!this.options.sliderMode ? `
173
+ <div class="colorpicker-saturation"
174
+ role="slider"
175
+ aria-label="${this.options.ariaLabels.saturation}"
176
+ aria-valuemin="0"
177
+ aria-valuemax="100"
178
+ aria-valuenow="${this.currentColor.s}"
179
+ tabindex="0">
180
+ <div class="colorpicker-saturation-overlay"></div>
181
+ <div class="colorpicker-saturation-pointer" role="presentation"></div>
182
+ </div>
183
+ ` : ''}
184
+
185
+ <div class="colorpicker-controls">
186
+ <div class="colorpicker-sliders${this.options.sliderMode ? ' colorpicker-sliders-only' : ''}">
187
+ <div class="colorpicker-slider-group">
188
+ <label for="colorpicker-hue">
189
+ <span class="colorpicker-label">${
190
+ this.options.ariaLabels.hue
191
+ }</span>
192
+ </label>
193
+ <input
194
+ type="range"
195
+ id="colorpicker-hue"
196
+ class="colorpicker-slider colorpicker-hue-slider"
197
+ min="0"
198
+ max="360"
199
+ value="${this.currentColor.h}"
200
+ aria-label="${this.options.ariaLabels.hue}"
201
+ />
202
+ </div>
203
+ ${
204
+ this.options.sliderMode
205
+ ? `
206
+ <div class="colorpicker-slider-group">
207
+ <label for="colorpicker-saturation">
208
+ <span class="colorpicker-label">${
209
+ this.options.ariaLabels.saturation
210
+ }</span>
211
+ </label>
212
+ <input
213
+ type="range"
214
+ id="colorpicker-saturation"
215
+ class="colorpicker-slider colorpicker-saturation-slider"
216
+ min="0"
217
+ max="100"
218
+ value="${this.currentColor.s}"
219
+ aria-label="${this.options.ariaLabels.saturation}"
220
+ />
221
+ </div>
222
+ <div class="colorpicker-slider-group">
223
+ <label for="colorpicker-lightness">
224
+ <span class="colorpicker-label">${
225
+ this.options.ariaLabels.lightness
226
+ }</span>
227
+ </label>
228
+ <input
229
+ type="range"
230
+ id="colorpicker-lightness"
231
+ class="colorpicker-slider colorpicker-lightness-slider"
232
+ min="0"
233
+ max="100"
234
+ value="${this.currentColor.l}"
235
+ aria-label="${this.options.ariaLabels.lightness}"
236
+ />
237
+ </div>
238
+ `
239
+ : ""
240
+ }
241
+ ${
242
+ this.options.showAlpha
243
+ ? `
244
+ <div class="colorpicker-slider-group">
245
+ <label for="colorpicker-alpha">
246
+ <span class="colorpicker-label">${
247
+ this.options.ariaLabels.alpha
248
+ }</span>
249
+ </label>
250
+ <input
251
+ type="range"
252
+ id="colorpicker-alpha"
253
+ class="colorpicker-slider colorpicker-alpha-slider"
254
+ min="0"
255
+ max="100"
256
+ value="${this.currentColor.a * 100}"
257
+ aria-label="${this.options.ariaLabels.alpha}"
258
+ />
259
+ </div>
260
+ `
261
+ : ""
262
+ }
263
+ </div>
264
+
265
+ <div class="colorpicker-preview">
266
+ <div class="colorpicker-preview-color" role="presentation"></div>
267
+ </div>
268
+ </div>
269
+
270
+ <div class="colorpicker-input-wrapper">
271
+ <label for="colorpicker-hex">
272
+ <span class="colorpicker-sr-only">Color value</span>
273
+ </label>
274
+ <div class="colorpicker-input-row">
275
+ <input
276
+ type="text"
277
+ id="colorpicker-hex"
278
+ class="colorpicker-input"
279
+ placeholder="${this.getPlaceholder()}"
280
+ aria-label="Color value in ${this.options.format} format"
281
+ />
282
+ ${
283
+ this.options.eyeDropper
284
+ ? `
285
+ ${this.supportsEyeDropper() ? `
286
+ <button
287
+ type="button"
288
+ class="colorpicker-eyedropper-btn"
289
+ aria-label="Pick color from screen"
290
+ title="Pick color from screen"
291
+ >
292
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
293
+ <path d="M2 22l1-1"/>
294
+ <path d="M8.5 16.5l-1-1"/>
295
+ <path d="M17 3l4 4"/>
296
+ <path d="M12 8l4 4"/>
297
+ <path d="M3 21l9-9"/>
298
+ <path d="M14.5 9.5l-1 1"/>
299
+ <path d="M20 14l-8 8"/>
300
+ </svg>
301
+ </button>
302
+ ` : ''}
303
+ <button
304
+ type="button"
305
+ class="colorpicker-system-picker-btn"
306
+ aria-label="Open system color picker"
307
+ title="Open system color picker"
308
+ >
309
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
310
+ <circle cx="12" cy="12" r="10"/>
311
+ <path d="M12 2v20"/>
312
+ <path d="M2 12h20"/>
313
+ <circle cx="12" cy="12" r="3"/>
314
+ </svg>
315
+ </button>
316
+ <input
317
+ type="color"
318
+ class="colorpicker-system-picker-input"
319
+ style="position: absolute; opacity: 0; pointer-events: none; width: 0; height: 0;"
320
+ />
321
+ `
322
+ : ""
323
+ }
324
+ </div>
325
+ </div>
326
+ ` : ''}
327
+
328
+ ${
329
+ this.options.presetColors.length > 0
330
+ ? `
331
+ <div class="colorpicker-presets${this.options.listView ? ' colorpicker-presets-list' : ''}" role="group" aria-label="${
332
+ this.options.ariaLabels.presets
333
+ }">
334
+ ${this.options.presetColors
335
+ .map(
336
+ (color, index) => {
337
+ const label = this.options.presetLabels[index] || '';
338
+ return this.options.listView && label
339
+ ? `
340
+ <button
341
+ type="button"
342
+ class="colorpicker-preset colorpicker-preset-list-item"
343
+ data-color="${color}"
344
+ aria-label="Select color ${label}"
345
+ >
346
+ <span class="colorpicker-preset-color" style="background-color: ${color}"></span>
347
+ <span class="colorpicker-preset-label">${label}</span>
348
+ </button>
349
+ `
350
+ : `
351
+ <button
352
+ type="button"
353
+ class="colorpicker-preset"
354
+ style="background-color: ${color}"
355
+ data-color="${color}"
356
+ aria-label="Select color ${label || color}"
357
+ ></button>
358
+ `;
359
+ }
360
+ )
361
+ .join("")}
362
+ </div>
363
+ `
364
+ : ""
365
+ }
366
+ </div>
367
+ `;
368
+
369
+ this.container.innerHTML = content;
370
+
371
+ // Cache element references
372
+ this.colorBox = this.container.querySelector(".colorpicker-saturation");
373
+ this.saturationPointer = this.container.querySelector(
374
+ ".colorpicker-saturation-pointer"
375
+ );
376
+ this.hueSlider = this.container.querySelector(".colorpicker-hue-slider");
377
+ this.saturationSlider = this.container.querySelector(".colorpicker-saturation-slider");
378
+ this.lightnessSlider = this.container.querySelector(".colorpicker-lightness-slider");
379
+ this.alphaSlider = this.container.querySelector(
380
+ ".colorpicker-alpha-slider"
381
+ );
382
+ this.hexInput = this.container.querySelector(".colorpicker-input");
383
+
384
+ // Append to DOM
385
+ if (this.options.inline) {
386
+ // For inline mode, insert after the input
387
+ this.input.parentNode?.insertBefore(this.container, this.input.nextSibling);
388
+ // Hide the original input
389
+ this.input.style.display = "none";
390
+ } else {
391
+ // For popup mode, append to specified container
392
+ this.options.appendTo.appendChild(this.container);
393
+ }
394
+ }
395
+
396
+ private createCompactButton(): void {
397
+ // Hide the original input
398
+ this.input.style.position = "absolute";
399
+ this.input.style.opacity = "0";
400
+ this.input.style.pointerEvents = "none";
401
+ this.input.style.width = "0";
402
+ this.input.style.height = "0";
403
+
404
+ // Create compact button
405
+ this.compactButton = document.createElement("button") as HTMLButtonElement;
406
+ this.compactButton.type = "button";
407
+ this.compactButton.className = "colorpicker-compact-button";
408
+ this.compactButton.setAttribute("aria-label", "Select color");
409
+ this.compactButton.tabIndex = 0;
410
+
411
+ // Compact Mode: Immer Farbvorschau
412
+ const preview = document.createElement("span");
413
+ preview.className = "colorpicker-compact-preview";
414
+ preview.style.backgroundColor = this.input.value || this.options.defaultColor;
415
+
416
+ this.compactButton.appendChild(preview);
417
+
418
+ // Insert after input
419
+ this.input.parentNode?.insertBefore(this.compactButton, this.input.nextSibling);
420
+
421
+ // Click handler
422
+ this.compactButton.addEventListener("click", (e) => {
423
+ e.preventDefault();
424
+ e.stopPropagation();
425
+ this.toggle();
426
+ });
427
+
428
+ // Keyboard handler
429
+ this.compactButton.addEventListener("keydown", (e) => {
430
+ if (e.key === "Enter" || e.key === " ") {
431
+ e.preventDefault();
432
+ e.stopPropagation();
433
+ this.toggle();
434
+ }
435
+ });
436
+ }
437
+
438
+ private createInputPreview(): void {
439
+ // Create wrapper
440
+ const wrapper = document.createElement("div");
441
+ wrapper.className = "colorpicker-input-group";
442
+
443
+ // Create preview element
444
+ this.inputPreview = document.createElement("span");
445
+ this.inputPreview.className = "colorpicker-input-preview";
446
+ this.inputPreview.style.backgroundColor = this.input.value || this.options.defaultColor;
447
+
448
+ // Wrap input
449
+ this.input.parentNode?.insertBefore(wrapper, this.input);
450
+ wrapper.appendChild(this.inputPreview);
451
+ wrapper.appendChild(this.input);
452
+
453
+ // Add class to input for styling
454
+ this.input.classList.add("colorpicker-has-preview");
455
+ }
456
+
457
+ private supportsEyeDropper(): boolean {
458
+ return typeof window !== 'undefined' && 'EyeDropper' in window;
459
+ }
460
+
461
+ private async openEyeDropper(): Promise<void> {
462
+ if (!this.supportsEyeDropper()) {
463
+ return; // Silently fail if not supported
464
+ }
465
+
466
+ try {
467
+ // @ts-ignore - EyeDropper API not yet in TypeScript lib
468
+ const eyeDropper = new EyeDropper();
469
+ const result = await eyeDropper.open();
470
+
471
+ if (result.sRGBHex) {
472
+ this.currentColor = this.parseColor(result.sRGBHex);
473
+ this.input.value = result.sRGBHex;
474
+ this.updateColorDisplay();
475
+ this.options.onChange(result.sRGBHex);
476
+ }
477
+ } catch (error) {
478
+ // User cancelled - do nothing
479
+ if ((error as Error).name !== 'AbortError') {
480
+ console.error('EyeDropper error:', error);
481
+ }
482
+ }
483
+ }
484
+
485
+ private setupEventListeners(): void {
486
+ // Input click/keyboard to open (not in compact mode)
487
+ if (!this.options.compact) {
488
+ this.input.addEventListener("click", () => {
489
+ if (!this.options.inline) {
490
+ this.toggle();
491
+ }
492
+ });
493
+
494
+ this.input.addEventListener("keydown", (e) => {
495
+ if ((e.key === "Enter" || e.key === " ") && !this.options.inline) {
496
+ e.preventDefault();
497
+ this.toggle();
498
+ }
499
+ });
500
+ }
501
+
502
+ // Input change
503
+ this.input.addEventListener("change", () => {
504
+ this.currentColor = this.parseColor(this.input.value);
505
+ this.updateColorDisplay();
506
+ });
507
+
508
+ // Hue slider
509
+ if (this.hueSlider) {
510
+ this.hueSlider.addEventListener("input", (e) => {
511
+ this.currentColor.h = parseInt((e.target as HTMLInputElement).value);
512
+ this.updateColorDisplay();
513
+ this.announceColorChange();
514
+ });
515
+ }
516
+
517
+ // Saturation slider (sliderMode only)
518
+ if (this.saturationSlider) {
519
+ this.saturationSlider.addEventListener("input", (e) => {
520
+ this.currentColor.s = parseInt((e.target as HTMLInputElement).value);
521
+ this.updateColorDisplay();
522
+ this.announceColorChange();
523
+ });
524
+ }
525
+
526
+ // Lightness slider (sliderMode only)
527
+ if (this.lightnessSlider) {
528
+ this.lightnessSlider.addEventListener("input", (e) => {
529
+ this.currentColor.l = parseInt((e.target as HTMLInputElement).value);
530
+ this.updateColorDisplay();
531
+ this.announceColorChange();
532
+ });
533
+ }
534
+
535
+ // Alpha slider
536
+ if (this.alphaSlider) {
537
+ this.alphaSlider.addEventListener("input", (e) => {
538
+ this.currentColor.a =
539
+ parseInt((e.target as HTMLInputElement).value) / 100;
540
+ this.updateColorDisplay();
541
+ this.announceColorChange();
542
+ });
543
+ }
544
+
545
+ // Saturation box
546
+ if (this.colorBox) {
547
+ this.colorBox.addEventListener("mousedown", (e) =>
548
+ this.onSaturationMouseDown(e)
549
+ );
550
+ this.colorBox.addEventListener("keydown", (e) =>
551
+ this.onSaturationKeyDown(e)
552
+ );
553
+ }
554
+
555
+ // Color value input (accepts all formats)
556
+ if (this.hexInput) {
557
+ this.hexInput.addEventListener("input", (e) => {
558
+ const value = (e.target as HTMLInputElement).value.trim();
559
+ if (this.isValidColor(value)) {
560
+ this.currentColor = this.parseColor(value);
561
+ this.updateColorDisplay(false);
562
+ }
563
+ });
564
+ }
565
+
566
+ // Preset colors
567
+ const presets = this.container?.querySelectorAll(".colorpicker-preset");
568
+ presets?.forEach((preset) => {
569
+ preset.addEventListener("click", (e) => {
570
+ const color = (e.currentTarget as HTMLElement).dataset.color!;
571
+ this.currentColor = this.parseColor(color);
572
+ this.updateColorDisplay();
573
+ if (this.options.closeOnSelect) {
574
+ this.close();
575
+ }
576
+ });
577
+ });
578
+
579
+ // EyeDropper button
580
+ const eyeDropperBtn = this.container?.querySelector(".colorpicker-eyedropper-btn");
581
+ if (eyeDropperBtn) {
582
+ eyeDropperBtn.addEventListener("click", async (e) => {
583
+ e.preventDefault();
584
+ e.stopPropagation();
585
+ await this.openEyeDropper();
586
+ });
587
+ }
588
+
589
+ // System Color Picker button (Safari fallback)
590
+ const systemPickerBtn = this.container?.querySelector(".colorpicker-system-picker-btn");
591
+ const systemPickerInput = this.container?.querySelector(".colorpicker-system-picker-input") as HTMLInputElement;
592
+ if (systemPickerBtn && systemPickerInput) {
593
+ // Set current color to system picker
594
+ systemPickerInput.value = this.formatColor(this.currentColor).substring(0, 7); // HEX only
595
+
596
+ systemPickerBtn.addEventListener("click", (e) => {
597
+ e.preventDefault();
598
+ e.stopPropagation();
599
+ systemPickerInput.click();
600
+ });
601
+
602
+ systemPickerInput.addEventListener("change", () => {
603
+ const color = systemPickerInput.value;
604
+ this.currentColor = this.parseColor(color);
605
+ this.input.value = color;
606
+ this.updateColorDisplay();
607
+ this.options.onChange(color);
608
+ });
609
+ }
610
+
611
+ // Close on outside click
612
+ if (!this.options.inline) {
613
+ document.addEventListener("mousedown", (e) => {
614
+ if (
615
+ this.isOpen &&
616
+ !this.container?.contains(e.target as Node) &&
617
+ e.target !== this.input
618
+ ) {
619
+ this.close();
620
+ }
621
+ });
622
+ }
623
+
624
+ // Escape key to close
625
+ document.addEventListener("keydown", (e) => {
626
+ if (e.key === "Escape" && this.isOpen && !this.options.inline) {
627
+ this.close();
628
+ this.input.focus();
629
+ }
630
+ });
631
+ }
632
+
633
+ private onSaturationMouseDown(e: MouseEvent): void {
634
+ e.preventDefault();
635
+ this.updateSaturationFromMouse(e);
636
+
637
+ const onMouseMove = (e: MouseEvent) => {
638
+ this.updateSaturationFromMouse(e);
639
+ };
640
+
641
+ const onMouseUp = () => {
642
+ document.removeEventListener("mousemove", onMouseMove);
643
+ document.removeEventListener("mouseup", onMouseUp);
644
+ };
645
+
646
+ document.addEventListener("mousemove", onMouseMove);
647
+ document.addEventListener("mouseup", onMouseUp);
648
+ }
649
+
650
+ private updateSaturationFromMouse(e: MouseEvent): void {
651
+ if (!this.colorBox) return;
652
+
653
+ const rect = this.colorBox.getBoundingClientRect();
654
+ const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
655
+ const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
656
+
657
+ this.currentColor.s = (x / rect.width) * 100;
658
+ this.currentColor.l = 100 - (y / rect.height) * 100;
659
+
660
+ this.updateColorDisplay();
661
+ this.announceColorChange();
662
+ }
663
+
664
+ private onSaturationKeyDown(e: KeyboardEvent): void {
665
+ const step = e.shiftKey ? 10 : 1;
666
+ let handled = false;
667
+
668
+ switch (e.key) {
669
+ case "ArrowRight":
670
+ this.currentColor.s = Math.min(100, this.currentColor.s + step);
671
+ handled = true;
672
+ break;
673
+ case "ArrowLeft":
674
+ this.currentColor.s = Math.max(0, this.currentColor.s - step);
675
+ handled = true;
676
+ break;
677
+ case "ArrowUp":
678
+ this.currentColor.l = Math.min(100, this.currentColor.l + step);
679
+ handled = true;
680
+ break;
681
+ case "ArrowDown":
682
+ this.currentColor.l = Math.max(0, this.currentColor.l - step);
683
+ handled = true;
684
+ break;
685
+ }
686
+
687
+ if (handled) {
688
+ e.preventDefault();
689
+ this.updateColorDisplay();
690
+ this.announceColorChange();
691
+ }
692
+ }
693
+
694
+ private updateColorDisplay(updateInput = true): void {
695
+ // Update saturation box background
696
+ if (this.colorBox) {
697
+ this.colorBox.style.backgroundColor = `hsl(${this.currentColor.h}, 100%, 50%)`;
698
+ }
699
+
700
+ // Update saturation pointer position
701
+ if (this.saturationPointer && this.colorBox) {
702
+ const x = (this.currentColor.s / 100) * 100;
703
+ const y = (1 - this.currentColor.l / 100) * 100;
704
+ this.saturationPointer.style.left = `${x}%`;
705
+ this.saturationPointer.style.top = `${y}%`;
706
+ }
707
+
708
+ // Update slider backgrounds (sliderMode)
709
+ if (this.saturationSlider) {
710
+ this.saturationSlider.style.background = `linear-gradient(to right, hsl(${this.currentColor.h}, 0%, 50%), hsl(${this.currentColor.h}, 100%, 50%))`;
711
+ }
712
+ if (this.lightnessSlider) {
713
+ this.lightnessSlider.style.background = `linear-gradient(to right, hsl(${this.currentColor.h}, ${this.currentColor.s}%, 0%), hsl(${this.currentColor.h}, ${this.currentColor.s}%, 50%), hsl(${this.currentColor.h}, ${this.currentColor.s}%, 100%))`;
714
+ }
715
+
716
+ // Update preview
717
+ const preview = this.container?.querySelector(
718
+ ".colorpicker-preview-color"
719
+ ) as HTMLElement;
720
+ if (preview) {
721
+ preview.style.backgroundColor = this.toHSLString(this.currentColor);
722
+ }
723
+
724
+ // Update color value input (shows current format)
725
+ if (this.hexInput && updateInput) {
726
+ this.hexInput.value = this.formatColor(this.currentColor);
727
+ }
728
+
729
+ // Update sliders
730
+ if (this.hueSlider) {
731
+ this.hueSlider.value = String(this.currentColor.h);
732
+ }
733
+ if (this.saturationSlider) {
734
+ this.saturationSlider.value = String(this.currentColor.s);
735
+ }
736
+ if (this.lightnessSlider) {
737
+ this.lightnessSlider.value = String(this.currentColor.l);
738
+ }
739
+ if (this.alphaSlider) {
740
+ this.alphaSlider.value = String(this.currentColor.a * 100);
741
+ }
742
+
743
+ // Update input field
744
+ if (updateInput) {
745
+ this.input.value = this.formatColor(this.currentColor);
746
+ this.options.onChange(this.input.value);
747
+
748
+ // Update compact button preview
749
+ if (this.compactButton) {
750
+ const preview = this.compactButton.querySelector('.colorpicker-compact-preview') as HTMLElement;
751
+ if (preview) {
752
+ preview.style.backgroundColor = this.toHSLString(this.currentColor);
753
+ }
754
+ }
755
+
756
+ // Update input preview
757
+ if (this.inputPreview) {
758
+ this.inputPreview.style.backgroundColor = this.toHSLString(this.currentColor);
759
+ }
760
+ }
761
+ }
762
+
763
+ private formatColor(color: HSL): string {
764
+ switch (this.options.format) {
765
+ case "hsl":
766
+ return this.toHSLString(color);
767
+ case "rgb":
768
+ return this.toRGBString(this.hslToRgb(color));
769
+ case "hex":
770
+ default:
771
+ return this.toHex(color);
772
+ }
773
+ }
774
+
775
+ private getPlaceholder(): string {
776
+ switch (this.options.format) {
777
+ case "hsl":
778
+ return this.options.showAlpha ? "hsla(0, 0%, 0%, 1)" : "hsl(0, 0%, 0%)";
779
+ case "rgb":
780
+ return this.options.showAlpha ? "rgba(0, 0, 0, 1)" : "rgb(0, 0, 0)";
781
+ case "hex":
782
+ default:
783
+ return "#000000";
784
+ }
785
+ }
786
+
787
+ private parseColor(colorString: string): HSL {
788
+ colorString = colorString.trim();
789
+
790
+ // Try hex
791
+ if (colorString.startsWith("#")) {
792
+ return this.hexToHsl(colorString);
793
+ }
794
+
795
+ // Try rgb/rgba
796
+ const rgbMatch = colorString.match(
797
+ /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/
798
+ );
799
+ if (rgbMatch) {
800
+ const rgb: RGB = {
801
+ r: parseInt(rgbMatch[1]),
802
+ g: parseInt(rgbMatch[2]),
803
+ b: parseInt(rgbMatch[3]),
804
+ a: rgbMatch[4] ? parseFloat(rgbMatch[4]) : 1,
805
+ };
806
+ return this.rgbToHsl(rgb);
807
+ }
808
+
809
+ // Try hsl/hsla
810
+ const hslMatch = colorString.match(
811
+ /hsla?\((\d+),\s*(\d+)%,\s*(\d+)%(?:,\s*([\d.]+))?\)/
812
+ );
813
+ if (hslMatch) {
814
+ return {
815
+ h: parseInt(hslMatch[1]),
816
+ s: parseInt(hslMatch[2]),
817
+ l: parseInt(hslMatch[3]),
818
+ a: hslMatch[4] ? parseFloat(hslMatch[4]) : 1,
819
+ };
820
+ }
821
+
822
+ // Default to current color
823
+ return this.currentColor;
824
+ }
825
+
826
+ private hexToHsl(hex: string): HSL {
827
+ hex = hex.replace("#", "");
828
+ const r = parseInt(hex.substring(0, 2), 16) / 255;
829
+ const g = parseInt(hex.substring(2, 4), 16) / 255;
830
+ const b = parseInt(hex.substring(4, 6), 16) / 255;
831
+ const a = hex.length === 8 ? parseInt(hex.substring(6, 8), 16) / 255 : 1;
832
+
833
+ return this.rgbToHsl({ r: r * 255, g: g * 255, b: b * 255, a });
834
+ }
835
+
836
+ private rgbToHsl(rgb: RGB): HSL {
837
+ const r = rgb.r / 255;
838
+ const g = rgb.g / 255;
839
+ const b = rgb.b / 255;
840
+
841
+ const max = Math.max(r, g, b);
842
+ const min = Math.min(r, g, b);
843
+ let h = 0;
844
+ let s = 0;
845
+ const l = (max + min) / 2;
846
+
847
+ if (max !== min) {
848
+ const d = max - min;
849
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
850
+
851
+ switch (max) {
852
+ case r:
853
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
854
+ break;
855
+ case g:
856
+ h = ((b - r) / d + 2) / 6;
857
+ break;
858
+ case b:
859
+ h = ((r - g) / d + 4) / 6;
860
+ break;
861
+ }
862
+ }
863
+
864
+ return {
865
+ h: Math.round(h * 360),
866
+ s: Math.round(s * 100),
867
+ l: Math.round(l * 100),
868
+ a: rgb.a,
869
+ };
870
+ }
871
+
872
+ private hslToRgb(hsl: HSL): RGB {
873
+ const h = hsl.h / 360;
874
+ const s = hsl.s / 100;
875
+ const l = hsl.l / 100;
876
+
877
+ let r, g, b;
878
+
879
+ if (s === 0) {
880
+ r = g = b = l;
881
+ } else {
882
+ const hue2rgb = (p: number, q: number, t: number) => {
883
+ if (t < 0) t += 1;
884
+ if (t > 1) t -= 1;
885
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
886
+ if (t < 1 / 2) return q;
887
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
888
+ return p;
889
+ };
890
+
891
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
892
+ const p = 2 * l - q;
893
+
894
+ r = hue2rgb(p, q, h + 1 / 3);
895
+ g = hue2rgb(p, q, h);
896
+ b = hue2rgb(p, q, h - 1 / 3);
897
+ }
898
+
899
+ return {
900
+ r: Math.round(r * 255),
901
+ g: Math.round(g * 255),
902
+ b: Math.round(b * 255),
903
+ a: hsl.a,
904
+ };
905
+ }
906
+
907
+ private toHex(hsl: HSL): string {
908
+ const rgb = this.hslToRgb(hsl);
909
+ const toHex = (n: number) => n.toString(16).padStart(2, "0");
910
+
911
+ // Include alpha channel if showAlpha is enabled and alpha < 1
912
+ if (this.options.showAlpha && hsl.a < 1) {
913
+ const alpha = Math.round(hsl.a * 255);
914
+ return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}${toHex(alpha)}`;
915
+ }
916
+
917
+ return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
918
+ }
919
+
920
+ private toHSLString(hsl: HSL): string {
921
+ if (this.options.showAlpha && hsl.a < 1) {
922
+ return `hsla(${hsl.h}, ${hsl.s}%, ${hsl.l}%, ${hsl.a})`;
923
+ }
924
+ return `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`;
925
+ }
926
+
927
+ private toRGBString(rgb: RGB): string {
928
+ if (this.options.showAlpha && rgb.a < 1) {
929
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${rgb.a})`;
930
+ }
931
+ return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
932
+ }
933
+
934
+ private isValidHex(hex: string): boolean {
935
+ return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/.test(hex);
936
+ }
937
+
938
+ private isValidColor(value: string): boolean {
939
+ // Check if valid HEX
940
+ if (this.isValidHex(value)) return true;
941
+
942
+ // Check if valid RGB/RGBA
943
+ if (/^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(,\s*[\d.]+\s*)?\)$/.test(value)) return true;
944
+
945
+ // Check if valid HSL/HSLA
946
+ if (/^hsla?\(\s*\d+\s*,\s*\d+%\s*,\s*\d+%\s*(,\s*[\d.]+\s*)?\)$/.test(value)) return true;
947
+
948
+ return false;
949
+ }
950
+
951
+ private announceColorChange(): void {
952
+ // Throttled ARIA live region announcement
953
+ if (!this.announceTimeout) {
954
+ this.announceTimeout = setTimeout(() => {
955
+ const announcement = document.createElement("div");
956
+ announcement.setAttribute("role", "status");
957
+ announcement.setAttribute("aria-live", "polite");
958
+ announcement.className = "colorpicker-sr-only";
959
+ announcement.textContent = `Color changed to ${this.formatColor(
960
+ this.currentColor
961
+ )}`;
962
+ this.container?.appendChild(announcement);
963
+ setTimeout(() => announcement.remove(), 1000);
964
+ this.announceTimeout = null;
965
+ }, 500);
966
+ }
967
+ }
968
+ private announceTimeout: ReturnType<typeof setTimeout> | null = null;
969
+
970
+ public open(): void {
971
+ if (this.isOpen || !this.container) return;
972
+
973
+ this.isOpen = true;
974
+ this.container.style.display = "block";
975
+
976
+ if (!this.options.inline) {
977
+ this.positionPicker();
978
+ }
979
+
980
+ this.options.onOpen();
981
+
982
+ // Focus first interactive element
983
+ setTimeout(() => {
984
+ this.colorBox?.focus();
985
+ }, 0);
986
+ }
987
+
988
+ public close(): void {
989
+ if (!this.isOpen || !this.container) return;
990
+
991
+ this.isOpen = false;
992
+ if (!this.options.inline) {
993
+ this.container.style.display = "none";
994
+ }
995
+
996
+ this.options.onClose();
997
+ }
998
+
999
+ public toggle(): void {
1000
+ if (this.isOpen) {
1001
+ this.close();
1002
+ } else {
1003
+ this.open();
1004
+ }
1005
+ }
1006
+
1007
+ private positionPicker(): void {
1008
+ if (!this.container) return;
1009
+
1010
+ const inputRect = this.input.getBoundingClientRect();
1011
+ const containerRect = this.container.getBoundingClientRect();
1012
+ const viewportHeight = window.innerHeight;
1013
+
1014
+ let top = inputRect.bottom + window.scrollY + 4;
1015
+ const left = inputRect.left + window.scrollX;
1016
+
1017
+ // Check if there's enough space below
1018
+ if (this.options.position === "auto") {
1019
+ const spaceBelow = viewportHeight - inputRect.bottom;
1020
+ const spaceAbove = inputRect.top;
1021
+
1022
+ if (spaceBelow < containerRect.height && spaceAbove > spaceBelow) {
1023
+ top = inputRect.top + window.scrollY - containerRect.height - 4;
1024
+ }
1025
+ } else if (this.options.position === "above") {
1026
+ top = inputRect.top + window.scrollY - containerRect.height - 4;
1027
+ }
1028
+
1029
+ this.container.style.position = "absolute";
1030
+ this.container.style.top = `${top}px`;
1031
+ this.container.style.left = `${left}px`;
1032
+ this.container.style.zIndex = "9999";
1033
+ }
1034
+
1035
+ public setColor(color: string): void {
1036
+ this.currentColor = this.parseColor(color);
1037
+ this.updateColorDisplay();
1038
+ }
1039
+
1040
+ public getColor(): string {
1041
+ return this.formatColor(this.currentColor);
1042
+ }
1043
+
1044
+ public destroy(): void {
1045
+ this.container?.remove();
1046
+ this.compactButton?.remove();
1047
+
1048
+ // Restore input if it was hidden for compact mode
1049
+ if (this.options.compact && this.input) {
1050
+ this.input.style.position = "";
1051
+ this.input.style.opacity = "";
1052
+ this.input.style.pointerEvents = "";
1053
+ this.input.style.width = "";
1054
+ this.input.style.height = "";
1055
+ }
1056
+
1057
+ // Restore input if it had preview
1058
+ if (this.options.inputPreview && this.input) {
1059
+ this.input.classList.remove("colorpicker-has-preview");
1060
+ const wrapper = this.input.parentElement;
1061
+ if (wrapper && wrapper.classList.contains("colorpicker-input-group")) {
1062
+ wrapper.parentNode?.insertBefore(this.input, wrapper);
1063
+ wrapper.remove();
1064
+ }
1065
+ }
1066
+
1067
+ ColorPicker.instances.delete(this.input);
1068
+ }
1069
+
1070
+ public static getInstance(element: HTMLElement): ColorPicker | undefined {
1071
+ return ColorPicker.instances.get(element);
1072
+ }
1073
+ }
1074
+
1075
+ // Factory function
1076
+ export default function colorpicker(
1077
+ selector: string | HTMLInputElement,
1078
+ options?: ColorPickerOptions
1079
+ ): ColorPicker {
1080
+ return new ColorPicker(selector, options);
1081
+ }
1082
+
1083
+ // Auto-initialization helper
1084
+ export function initColorPickers(root: Document | HTMLElement = document): ColorPicker[] {
1085
+ const pickers: ColorPicker[] = [];
1086
+ const elements = root.querySelectorAll<HTMLInputElement>(
1087
+ '[data-colorpicker], .colorpicker, input[type="color"][data-format]'
1088
+ );
1089
+
1090
+ elements.forEach((element) => {
1091
+ // Skip if already initialized
1092
+ if (ColorPicker.getInstance(element)) {
1093
+ return;
1094
+ }
1095
+
1096
+ const dataset = element.dataset;
1097
+ const options: ColorPickerOptions = {};
1098
+
1099
+ // Parse data-colorpicker attribute
1100
+ if (dataset.colorpicker) {
1101
+ const config = dataset.colorpicker;
1102
+ const parts = config.split(/[,;]/);
1103
+
1104
+ parts.forEach((part) => {
1105
+ const [key, value] = part.split(':').map(s => s.trim());
1106
+
1107
+ switch (key) {
1108
+ case 'format':
1109
+ if (value === 'hex' || value === 'rgb' || value === 'hsl') {
1110
+ options.format = value;
1111
+ }
1112
+ break;
1113
+ case 'alpha':
1114
+ case 'showAlpha':
1115
+ options.showAlpha = value === 'true' || value === '1';
1116
+ break;
1117
+ case 'compact':
1118
+ options.compact = value === 'true' || value === '1';
1119
+ break;
1120
+ case 'inline':
1121
+ options.inline = value === 'true' || value === '1';
1122
+ break;
1123
+ case 'presets':
1124
+ options.presetsOnly = value === 'true' || value === '1';
1125
+ break;
1126
+ case 'list':
1127
+ options.listView = value === 'true' || value === '1';
1128
+ break;
1129
+ case 'sliderMode':
1130
+ options.sliderMode = value === 'true' || value === '1';
1131
+ break;
1132
+ case 'eyeDropper':
1133
+ options.eyeDropper = value === 'true' || value === '1';
1134
+ break;
1135
+ case 'inputPreview':
1136
+ options.inputPreview = value === 'true' || value === '1';
1137
+ break;
1138
+ }
1139
+ });
1140
+ }
1141
+
1142
+ // Parse individual data attributes
1143
+ if (dataset.format && (dataset.format === 'hex' || dataset.format === 'rgb' || dataset.format === 'hsl')) {
1144
+ options.format = dataset.format as "hex" | "rgb" | "hsl";
1145
+ }
1146
+ if (dataset.alpha !== undefined) {
1147
+ options.showAlpha = dataset.alpha === 'true' || dataset.alpha === '1';
1148
+ }
1149
+ if (dataset.compact !== undefined) {
1150
+ options.compact = dataset.compact === 'true' || dataset.compact === '1';
1151
+ }
1152
+ if (dataset.inline !== undefined) {
1153
+ options.inline = dataset.inline === 'true' || dataset.inline === '1';
1154
+ }
1155
+ if (dataset.presetsOnly !== undefined) {
1156
+ options.presetsOnly = dataset.presetsOnly === 'true' || dataset.presetsOnly === '1';
1157
+ }
1158
+ if (dataset.listView !== undefined) {
1159
+ options.listView = dataset.listView === 'true' || dataset.listView === '1';
1160
+ }
1161
+ if (dataset.defaultColor) {
1162
+ options.defaultColor = dataset.defaultColor;
1163
+ }
1164
+
1165
+ // Create picker instance
1166
+ const picker = new ColorPicker(element, options);
1167
+ pickers.push(picker);
1168
+ });
1169
+
1170
+ return pickers;
1171
+ }
1172
+
1173
+ // Auto-init on DOMContentLoaded
1174
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
1175
+ if (document.readyState === 'loading') {
1176
+ document.addEventListener('DOMContentLoaded', () => initColorPickers());
1177
+ } else {
1178
+ // DOM already loaded
1179
+ initColorPickers();
1180
+ }
1181
+ }