coloris 0.0.2 → 0.0.4

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,1271 @@
1
+ /*!
2
+ * Copyright (c) 2021 Momo Bassit.
3
+ * Licensed under the MIT License (MIT)
4
+ * https://github.com/mdbassit/Coloris
5
+ */
6
+
7
+ ((window, document, Math, undefined) => {
8
+ const ctx = document.createElement('canvas').getContext('2d');
9
+ const currentColor = { r: 0, g: 0, b: 0, h: 0, s: 0, v: 0, a: 1 };
10
+ let container, picker, colorArea, colorMarker, colorPreview, colorValue, clearButton, closeButton,
11
+ hueSlider, hueMarker, alphaSlider, alphaMarker, currentEl, currentFormat, oldColor, keyboardNav,
12
+ colorAreaDims = {};
13
+
14
+ // Default settings
15
+ const settings = {
16
+ el: '[data-coloris]',
17
+ parent: 'body',
18
+ theme: 'default',
19
+ themeMode: 'light',
20
+ rtl: false,
21
+ wrap: true,
22
+ margin: 2,
23
+ format: 'hex',
24
+ formatToggle : false,
25
+ swatches: [],
26
+ swatchesOnly: false,
27
+ alpha: true,
28
+ forceAlpha: false,
29
+ focusInput: true,
30
+ selectInput: false,
31
+ inline: false,
32
+ defaultColor: '#000000',
33
+ clearButton: false,
34
+ clearLabel: 'Clear',
35
+ closeButton: false,
36
+ closeLabel: 'Close',
37
+ onChange: () => undefined,
38
+ a11y: {
39
+ open: 'Open color picker',
40
+ close: 'Close color picker',
41
+ clear: 'Clear the selected color',
42
+ marker: 'Saturation: {s}. Brightness: {v}.',
43
+ hueSlider: 'Hue slider',
44
+ alphaSlider: 'Opacity slider',
45
+ input: 'Color value field',
46
+ format: 'Color format',
47
+ swatch: 'Color swatch',
48
+ instruction: 'Saturation and brightness selector. Use up, down, left and right arrow keys to select.'
49
+ }
50
+ };
51
+
52
+ // Virtual instances cache
53
+ const instances = {};
54
+ let currentInstanceId = '';
55
+ let defaultInstance = {};
56
+ let hasInstance = false;
57
+
58
+ /**
59
+ * Configure the color picker.
60
+ * @param {object} options Configuration options.
61
+ */
62
+ function configure(options) {
63
+ if (typeof options !== 'object') {
64
+ return;
65
+ }
66
+
67
+ for (const key in options) {
68
+ switch (key) {
69
+ case 'el':
70
+ bindFields(options.el);
71
+ if (options.wrap !== false) {
72
+ wrapFields(options.el);
73
+ }
74
+ break;
75
+ case 'parent':
76
+ container = options.parent instanceof HTMLElement ? options.parent : document.querySelector(options.parent);
77
+ if (container) {
78
+ container.appendChild(picker);
79
+ settings.parent = options.parent;
80
+
81
+ // document.body is special
82
+ if (container === document.body) {
83
+ container = undefined;
84
+ }
85
+ }
86
+ break;
87
+ case 'themeMode':
88
+ settings.themeMode = options.themeMode;
89
+ if (options.themeMode === 'auto' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
90
+ settings.themeMode = 'dark';
91
+ }
92
+ // The lack of a break statement is intentional
93
+ case 'theme':
94
+ if (options.theme) {
95
+ settings.theme = options.theme;
96
+ }
97
+
98
+ // Set the theme and color scheme
99
+ picker.className = `clr-picker clr-${settings.theme} clr-${settings.themeMode}`;
100
+
101
+ // Update the color picker's position if inline mode is in use
102
+ if (settings.inline) {
103
+ updatePickerPosition();
104
+ }
105
+ break;
106
+ case 'rtl':
107
+ settings.rtl = !!options.rtl;
108
+ Array.from(document.getElementsByClassName('clr-field')).forEach(field => field.classList.toggle('clr-rtl', settings.rtl));
109
+ break;
110
+ case 'margin':
111
+ options.margin *= 1;
112
+ settings.margin = !isNaN(options.margin) ? options.margin : settings.margin;
113
+ break;
114
+ case 'wrap':
115
+ if (options.el && options.wrap) {
116
+ wrapFields(options.el);
117
+ }
118
+ break;
119
+ case 'formatToggle':
120
+ settings.formatToggle = !!options.formatToggle;
121
+ getEl('clr-format').style.display = settings.formatToggle ? 'block' : 'none';
122
+ if (settings.formatToggle) {
123
+ settings.format = 'auto';
124
+ }
125
+ break;
126
+ case 'swatches':
127
+ if (Array.isArray(options.swatches)) {
128
+ const swatchesContainer = getEl('clr-swatches');
129
+ const swatches = document.createElement('div');
130
+
131
+ // Clear current swatches
132
+ swatchesContainer.textContent = '';
133
+
134
+ // Build new swatches
135
+ options.swatches.forEach((swatch, i) => {
136
+ const button = document.createElement('button');
137
+
138
+ button.setAttribute('type', `button`);
139
+ button.setAttribute('id', `clr-swatch-${i}`);
140
+ button.setAttribute('aria-labelledby', `clr-swatch-label clr-swatch-${i}`);
141
+ button.style.color = swatch;
142
+ button.textContent = swatch;
143
+
144
+ swatches.appendChild(button);
145
+ });
146
+
147
+ // Append new swatches if any
148
+ if (options.swatches.length) {
149
+ swatchesContainer.appendChild(swatches);
150
+ }
151
+
152
+ settings.swatches = options.swatches.slice();
153
+ }
154
+ break;
155
+ case 'swatchesOnly':
156
+ settings.swatchesOnly = !!options.swatchesOnly;
157
+ picker.setAttribute('data-minimal', settings.swatchesOnly);
158
+ break;
159
+ case 'alpha':
160
+ settings.alpha = !!options.alpha;
161
+ picker.setAttribute('data-alpha', settings.alpha);
162
+ break;
163
+ case 'inline':
164
+ settings.inline = !!options.inline;
165
+ picker.setAttribute('data-inline', settings.inline);
166
+
167
+ if (settings.inline) {
168
+ const defaultColor = options.defaultColor || settings.defaultColor;
169
+
170
+ currentFormat = getColorFormatFromStr(defaultColor);
171
+ updatePickerPosition();
172
+ setColorFromStr(defaultColor);
173
+ }
174
+ break;
175
+ case 'clearButton':
176
+ // Backward compatibility
177
+ if (typeof options.clearButton === 'object') {
178
+ if (options.clearButton.label) {
179
+ settings.clearLabel = options.clearButton.label;
180
+ clearButton.innerHTML = settings.clearLabel;
181
+ }
182
+
183
+ options.clearButton = options.clearButton.show;
184
+ }
185
+
186
+ settings.clearButton = !!options.clearButton;
187
+ clearButton.style.display = settings.clearButton ? 'block' : 'none';
188
+ break;
189
+ case 'clearLabel':
190
+ settings.clearLabel = options.clearLabel;
191
+ clearButton.innerHTML = settings.clearLabel;
192
+ break;
193
+ case 'closeButton':
194
+ settings.closeButton = !!options.closeButton;
195
+
196
+ if (settings.closeButton) {
197
+ picker.insertBefore(closeButton, colorPreview);
198
+ } else {
199
+ colorPreview.appendChild(closeButton);
200
+ }
201
+
202
+ break;
203
+ case 'closeLabel':
204
+ settings.closeLabel = options.closeLabel;
205
+ closeButton.innerHTML = settings.closeLabel;
206
+ break;
207
+ case 'a11y':
208
+ const labels = options.a11y;
209
+ let update = false;
210
+
211
+ if (typeof labels === 'object') {
212
+ for (const label in labels) {
213
+ if (labels[label] && settings.a11y[label]) {
214
+ settings.a11y[label] = labels[label];
215
+ update = true;
216
+ }
217
+ }
218
+ }
219
+
220
+ if (update) {
221
+ const openLabel = getEl('clr-open-label');
222
+ const swatchLabel = getEl('clr-swatch-label');
223
+
224
+ openLabel.innerHTML = settings.a11y.open;
225
+ swatchLabel.innerHTML = settings.a11y.swatch;
226
+ closeButton.setAttribute('aria-label', settings.a11y.close);
227
+ clearButton.setAttribute('aria-label', settings.a11y.clear);
228
+ hueSlider.setAttribute('aria-label', settings.a11y.hueSlider);
229
+ alphaSlider.setAttribute('aria-label', settings.a11y.alphaSlider);
230
+ colorValue.setAttribute('aria-label', settings.a11y.input);
231
+ colorArea.setAttribute('aria-label', settings.a11y.instruction);
232
+ }
233
+ break;
234
+ default:
235
+ settings[key] = options[key];
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Add or update a virtual instance.
242
+ * @param {String} selector The CSS selector of the elements to which the instance is attached.
243
+ * @param {Object} options Per-instance options to apply.
244
+ */
245
+ function setVirtualInstance(selector, options) {
246
+ if (typeof selector === 'string' && typeof options === 'object') {
247
+ instances[selector] = options;
248
+ hasInstance = true;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Remove a virtual instance.
254
+ * @param {String} selector The CSS selector of the elements to which the instance is attached.
255
+ */
256
+ function removeVirtualInstance(selector) {
257
+ delete instances[selector];
258
+
259
+ if (Object.keys(instances).length === 0) {
260
+ hasInstance = false;
261
+
262
+ if (selector === currentInstanceId) {
263
+ resetVirtualInstance();
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Attach a virtual instance to an element if it matches a selector.
270
+ * @param {Object} element Target element that will receive a virtual instance if applicable.
271
+ */
272
+ function attachVirtualInstance(element) {
273
+ if (hasInstance) {
274
+ // These options can only be set globally, not per instance
275
+ const unsupportedOptions = ['el', 'wrap', 'rtl', 'inline', 'defaultColor', 'a11y'];
276
+
277
+ for (let selector in instances) {
278
+ const options = instances[selector];
279
+
280
+ // If the element matches an instance's CSS selector
281
+ if (element.matches(selector)) {
282
+ currentInstanceId = selector;
283
+ defaultInstance = {};
284
+
285
+ // Delete unsupported options
286
+ unsupportedOptions.forEach(option => delete options[option]);
287
+
288
+ // Back up the default options so we can restore them later
289
+ for (let option in options) {
290
+ defaultInstance[option] = Array.isArray(settings[option]) ? settings[option].slice() : settings[option];
291
+ }
292
+
293
+ // Set the instance's options
294
+ configure(options);
295
+ break;
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Revert any per-instance options that were previously applied.
303
+ */
304
+ function resetVirtualInstance() {
305
+ if (Object.keys(defaultInstance).length > 0) {
306
+ configure(defaultInstance);
307
+ currentInstanceId = '';
308
+ defaultInstance = {};
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Bind the color picker to input fields that match the selector.
314
+ * @param {(string|HTMLElement|HTMLElement[])} selector A CSS selector string, a DOM element or a list of DOM elements.
315
+ */
316
+ function bindFields(selector) {
317
+ if (selector instanceof HTMLElement) {
318
+ selector = [selector];
319
+ }
320
+
321
+ if (Array.isArray(selector)) {
322
+ selector.forEach(field => {
323
+ addListener(field, 'click', openPicker);
324
+ addListener(field, 'input', updateColorPreview);
325
+ });
326
+ } else {
327
+ addListener(document, 'click', selector, openPicker);
328
+ addListener(document, 'input', selector, updateColorPreview);
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Open the color picker.
334
+ * @param {object} event The event that opens the color picker.
335
+ */
336
+ function openPicker(event) {
337
+ // Skip if inline mode is in use
338
+ if (settings.inline) {
339
+ return;
340
+ }
341
+
342
+ // Apply any per-instance options first
343
+ attachVirtualInstance(event.target);
344
+
345
+ currentEl = event.target;
346
+ oldColor = currentEl.value;
347
+ currentFormat = getColorFormatFromStr(oldColor);
348
+ picker.classList.add('clr-open');
349
+
350
+ updatePickerPosition();
351
+ setColorFromStr(oldColor);
352
+
353
+ if (settings.focusInput || settings.selectInput) {
354
+ colorValue.focus({ preventScroll: true });
355
+ colorValue.setSelectionRange(currentEl.selectionStart, currentEl.selectionEnd);
356
+ }
357
+
358
+ if (settings.selectInput) {
359
+ colorValue.select();
360
+ }
361
+
362
+ // Always focus the first element when using keyboard navigation
363
+ if (keyboardNav || settings.swatchesOnly) {
364
+ getFocusableElements().shift().focus();
365
+ }
366
+
367
+ // Trigger an "open" event
368
+ currentEl.dispatchEvent(new Event('open', { bubbles: false }));
369
+ }
370
+
371
+ /**
372
+ * Update the color picker's position and the color gradient's offset
373
+ */
374
+ function updatePickerPosition() {
375
+ const parent = container;
376
+ const scrollY = window.scrollY;
377
+ const pickerWidth = picker.offsetWidth;
378
+ const pickerHeight = picker.offsetHeight;
379
+ const reposition = { left: false, top: false };
380
+ let parentStyle, parentMarginTop, parentBorderTop;
381
+ let offset = { x: 0, y: 0 };
382
+
383
+ if (parent) {
384
+ parentStyle = window.getComputedStyle(parent);
385
+ parentMarginTop = parseFloat(parentStyle.marginTop);
386
+ parentBorderTop = parseFloat(parentStyle.borderTopWidth);
387
+
388
+ offset = parent.getBoundingClientRect();
389
+ offset.y += parentBorderTop + scrollY;
390
+ }
391
+
392
+ if (!settings.inline) {
393
+ const coords = currentEl.getBoundingClientRect();
394
+ let left = coords.x;
395
+ let top = scrollY + coords.y + coords.height + settings.margin;
396
+
397
+ // If the color picker is inside a custom container
398
+ // set the position relative to it
399
+ if (parent) {
400
+ left -= offset.x;
401
+ top -= offset.y;
402
+
403
+ if (left + pickerWidth > parent.clientWidth) {
404
+ left += coords.width - pickerWidth;
405
+ reposition.left = true;
406
+ }
407
+
408
+ if (top + pickerHeight > parent.clientHeight - parentMarginTop) {
409
+ if (pickerHeight + settings.margin <= coords.top - (offset.y - scrollY)) {
410
+ top -= coords.height + pickerHeight + settings.margin * 2;
411
+ reposition.top = true;
412
+ }
413
+ }
414
+
415
+ top += parent.scrollTop;
416
+
417
+ // Otherwise set the position relative to the whole document
418
+ } else {
419
+ if (left + pickerWidth > document.documentElement.clientWidth) {
420
+ left += coords.width - pickerWidth;
421
+ reposition.left = true;
422
+ }
423
+
424
+ if (top + pickerHeight - scrollY > document.documentElement.clientHeight) {
425
+ if (pickerHeight + settings.margin <= coords.top) {
426
+ top = scrollY + coords.y - pickerHeight - settings.margin;
427
+ reposition.top = true;
428
+ }
429
+ }
430
+ }
431
+
432
+ picker.classList.toggle('clr-left', reposition.left);
433
+ picker.classList.toggle('clr-top', reposition.top);
434
+ picker.style.left = `${left}px`;
435
+ picker.style.top = `${top}px`;
436
+ offset.x += picker.offsetLeft;
437
+ offset.y += picker.offsetTop;
438
+ }
439
+
440
+ colorAreaDims = {
441
+ width: colorArea.offsetWidth,
442
+ height: colorArea.offsetHeight,
443
+ x: colorArea.offsetLeft + offset.x,
444
+ y: colorArea.offsetTop + offset.y
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Wrap the linked input fields in a div that adds a color preview.
450
+ * @param {(string|HTMLElement|HTMLElement[])} selector A CSS selector string, a DOM element or a list of DOM elements.
451
+ */
452
+ function wrapFields(selector) {
453
+ if (selector instanceof HTMLElement) {
454
+ wrapColorField(selector);
455
+ } else if (Array.isArray(selector)) {
456
+ selector.forEach(wrapColorField);
457
+ } else {
458
+ document.querySelectorAll(selector).forEach(wrapColorField);
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Wrap an input field in a div that adds a color preview.
464
+ * @param {object} field The input field.
465
+ */
466
+ function wrapColorField(field) {
467
+ const parentNode = field.parentNode;
468
+
469
+ if (!parentNode.classList.contains('clr-field')) {
470
+ const wrapper = document.createElement('div');
471
+ let classes = 'clr-field';
472
+
473
+ if (settings.rtl || field.classList.contains('clr-rtl')) {
474
+ classes += ' clr-rtl';
475
+ }
476
+
477
+ wrapper.innerHTML = '<button type="button" aria-labelledby="clr-open-label"></button>';
478
+ parentNode.insertBefore(wrapper, field);
479
+ wrapper.className = classes;
480
+ wrapper.style.color = field.value;
481
+ wrapper.appendChild(field);
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Update the color preview of an input field
487
+ * @param {object} event The "input" event that triggers the color change.
488
+ */
489
+ function updateColorPreview(event) {
490
+ const parent = event.target.parentNode;
491
+
492
+ // Only update the preview if the field has been previously wrapped
493
+ if (parent.classList.contains('clr-field')) {
494
+ parent.style.color = event.target.value;
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Close the color picker.
500
+ * @param {boolean} [revert] If true, revert the color to the original value.
501
+ */
502
+ function closePicker(revert) {
503
+ if (currentEl && !settings.inline) {
504
+ const prevEl = currentEl;
505
+
506
+ // Revert the color to the original value if needed
507
+ if (revert) {
508
+ // This will prevent the "change" event on the colorValue input to execute its handler
509
+ currentEl = undefined;
510
+
511
+ if (oldColor !== prevEl.value) {
512
+ prevEl.value = oldColor;
513
+
514
+ // Trigger an "input" event to force update the thumbnail next to the input field
515
+ prevEl.dispatchEvent(new Event('input', { bubbles: true }));
516
+ }
517
+ }
518
+
519
+ // Trigger a "change" event if needed
520
+ setTimeout(() => { // Add this to the end of the event loop
521
+ if (oldColor !== prevEl.value) {
522
+ prevEl.dispatchEvent(new Event('change', { bubbles: true }));
523
+ }
524
+ });
525
+
526
+ // Hide the picker dialog
527
+ picker.classList.remove('clr-open');
528
+
529
+ // Reset any previously set per-instance options
530
+ if (hasInstance) {
531
+ resetVirtualInstance();
532
+ }
533
+
534
+ // Trigger a "close" event
535
+ prevEl.dispatchEvent(new Event('close', { bubbles: false }));
536
+
537
+ if (settings.focusInput) {
538
+ prevEl.focus({ preventScroll: true });
539
+ }
540
+
541
+ // This essentially marks the picker as closed
542
+ currentEl = undefined;
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Set the active color from a string.
548
+ * @param {string} str String representing a color.
549
+ */
550
+ function setColorFromStr(str) {
551
+ const rgba = strToRGBA(str);
552
+ const hsva = RGBAtoHSVA(rgba);
553
+
554
+ updateMarkerA11yLabel(hsva.s, hsva.v);
555
+ updateColor(rgba, hsva);
556
+
557
+ // Update the UI
558
+ hueSlider.value = hsva.h;
559
+ picker.style.color = `hsl(${hsva.h}, 100%, 50%)`;
560
+ hueMarker.style.left = `${hsva.h / 360 * 100}%`;
561
+
562
+ colorMarker.style.left = `${colorAreaDims.width * hsva.s / 100}px`;
563
+ colorMarker.style.top = `${colorAreaDims.height - (colorAreaDims.height * hsva.v / 100)}px`;
564
+
565
+ alphaSlider.value = hsva.a * 100;
566
+ alphaMarker.style.left = `${hsva.a * 100}%`;
567
+ }
568
+
569
+ /**
570
+ * Guess the color format from a string.
571
+ * @param {string} str String representing a color.
572
+ * @return {string} The color format.
573
+ */
574
+ function getColorFormatFromStr(str) {
575
+ const format = str.substring(0, 3).toLowerCase();
576
+
577
+ if (format === 'rgb' || format === 'hsl' ) {
578
+ return format;
579
+ }
580
+
581
+ return 'hex';
582
+ }
583
+
584
+ /**
585
+ * Copy the active color to the linked input field.
586
+ * @param {number} [color] Color value to override the active color.
587
+ */
588
+ function pickColor(color) {
589
+ color = color !== undefined ? color : colorValue.value;
590
+
591
+ if (currentEl) {
592
+ currentEl.value = color;
593
+ currentEl.dispatchEvent(new Event('input', { bubbles: true }));
594
+ }
595
+
596
+ if (settings.onChange) {
597
+ settings.onChange.call(window, color, currentEl);
598
+ }
599
+
600
+ document.dispatchEvent(new CustomEvent('coloris:pick', { detail: { color, currentEl } }));
601
+ }
602
+
603
+ /**
604
+ * Set the active color based on a specific point in the color gradient.
605
+ * @param {number} x Left position.
606
+ * @param {number} y Top position.
607
+ */
608
+ function setColorAtPosition(x, y) {
609
+ const hsva = {
610
+ h: hueSlider.value * 1,
611
+ s: x / colorAreaDims.width * 100,
612
+ v: 100 - (y / colorAreaDims.height * 100),
613
+ a: alphaSlider.value / 100
614
+ };
615
+ const rgba = HSVAtoRGBA(hsva);
616
+
617
+ updateMarkerA11yLabel(hsva.s, hsva.v);
618
+ updateColor(rgba, hsva);
619
+ pickColor();
620
+ }
621
+
622
+ /**
623
+ * Update the color marker's accessibility label.
624
+ * @param {number} saturation
625
+ * @param {number} value
626
+ */
627
+ function updateMarkerA11yLabel(saturation, value) {
628
+ let label = settings.a11y.marker;
629
+
630
+ saturation = saturation.toFixed(1) * 1;
631
+ value = value.toFixed(1) * 1;
632
+ label = label.replace('{s}', saturation);
633
+ label = label.replace('{v}', value);
634
+ colorMarker.setAttribute('aria-label', label);
635
+ }
636
+
637
+ //
638
+ /**
639
+ * Get the pageX and pageY positions of the pointer.
640
+ * @param {object} event The MouseEvent or TouchEvent object.
641
+ * @return {object} The pageX and pageY positions.
642
+ */
643
+ function getPointerPosition(event) {
644
+ return {
645
+ pageX: event.changedTouches ? event.changedTouches[0].pageX : event.pageX,
646
+ pageY: event.changedTouches ? event.changedTouches[0].pageY : event.pageY
647
+ };
648
+ }
649
+
650
+ /**
651
+ * Move the color marker when dragged.
652
+ * @param {object} event The MouseEvent object.
653
+ */
654
+ function moveMarker(event) {
655
+ const pointer = getPointerPosition(event);
656
+ let x = pointer.pageX - colorAreaDims.x;
657
+ let y = pointer.pageY - colorAreaDims.y;
658
+
659
+ if (container) {
660
+ y += container.scrollTop;
661
+ }
662
+
663
+ setMarkerPosition(x, y);
664
+
665
+ // Prevent scrolling while dragging the marker
666
+ event.preventDefault();
667
+ event.stopPropagation();
668
+ }
669
+
670
+ /**
671
+ * Move the color marker when the arrow keys are pressed.
672
+ * @param {number} offsetX The horizontal amount to move.
673
+ * @param {number} offsetY The vertical amount to move.
674
+ */
675
+ function moveMarkerOnKeydown(offsetX, offsetY) {
676
+ let x = colorMarker.style.left.replace('px', '') * 1 + offsetX;
677
+ let y = colorMarker.style.top.replace('px', '') * 1 + offsetY;
678
+
679
+ setMarkerPosition(x, y);
680
+ }
681
+
682
+ /**
683
+ * Set the color marker's position.
684
+ * @param {number} x Left position.
685
+ * @param {number} y Top position.
686
+ */
687
+ function setMarkerPosition(x, y) {
688
+ // Make sure the marker doesn't go out of bounds
689
+ x = (x < 0) ? 0 : (x > colorAreaDims.width) ? colorAreaDims.width : x;
690
+ y = (y < 0) ? 0 : (y > colorAreaDims.height) ? colorAreaDims.height : y;
691
+
692
+ // Set the position
693
+ colorMarker.style.left = `${x}px`;
694
+ colorMarker.style.top = `${y}px`;
695
+
696
+ // Update the color
697
+ setColorAtPosition(x, y);
698
+
699
+ // Make sure the marker is focused
700
+ colorMarker.focus();
701
+ }
702
+
703
+ /**
704
+ * Update the color picker's input field and preview thumb.
705
+ * @param {Object} rgba Red, green, blue and alpha values.
706
+ * @param {Object} [hsva] Hue, saturation, value and alpha values.
707
+ */
708
+ function updateColor(rgba = {}, hsva = {}) {
709
+ let format = settings.format;
710
+
711
+ for (const key in rgba) {
712
+ currentColor[key] = rgba[key];
713
+ }
714
+
715
+ for (const key in hsva) {
716
+ currentColor[key] = hsva[key];
717
+ }
718
+
719
+ const hex = RGBAToHex(currentColor);
720
+ const opaqueHex = hex.substring(0, 7);
721
+
722
+ colorMarker.style.color = opaqueHex;
723
+ alphaMarker.parentNode.style.color = opaqueHex;
724
+ alphaMarker.style.color = hex;
725
+ colorPreview.style.color = hex;
726
+
727
+ // Force repaint the color and alpha gradients as a workaround for a Google Chrome bug
728
+ colorArea.style.display = 'none';
729
+ colorArea.offsetHeight;
730
+ colorArea.style.display = '';
731
+ alphaMarker.nextElementSibling.style.display = 'none';
732
+ alphaMarker.nextElementSibling.offsetHeight;
733
+ alphaMarker.nextElementSibling.style.display = '';
734
+
735
+ if (format === 'mixed') {
736
+ format = currentColor.a === 1 ? 'hex' : 'rgb';
737
+ } else if (format === 'auto') {
738
+ format = currentFormat;
739
+ }
740
+
741
+ switch (format) {
742
+ case 'hex':
743
+ colorValue.value = hex;
744
+ break;
745
+ case 'rgb':
746
+ colorValue.value = RGBAToStr(currentColor);
747
+ break;
748
+ case 'hsl':
749
+ colorValue.value = HSLAToStr(HSVAtoHSLA(currentColor));
750
+ break;
751
+ }
752
+
753
+ // Select the current format in the format switcher
754
+ document.querySelector(`.clr-format [value="${format}"]`).checked = true;
755
+ }
756
+
757
+ /**
758
+ * Set the hue when its slider is moved.
759
+ */
760
+ function setHue() {
761
+ const hue = hueSlider.value * 1;
762
+ const x = colorMarker.style.left.replace('px', '') * 1;
763
+ const y = colorMarker.style.top.replace('px', '') * 1;
764
+
765
+ picker.style.color = `hsl(${hue}, 100%, 50%)`;
766
+ hueMarker.style.left = `${hue / 360 * 100}%`;
767
+
768
+ setColorAtPosition(x, y);
769
+ }
770
+
771
+ /**
772
+ * Set the alpha when its slider is moved.
773
+ */
774
+ function setAlpha() {
775
+ const alpha = alphaSlider.value / 100;
776
+
777
+ alphaMarker.style.left = `${alpha * 100}%`;
778
+ updateColor({ a: alpha });
779
+ pickColor();
780
+ }
781
+
782
+ /**
783
+ * Convert HSVA to RGBA.
784
+ * @param {object} hsva Hue, saturation, value and alpha values.
785
+ * @return {object} Red, green, blue and alpha values.
786
+ */
787
+ function HSVAtoRGBA(hsva) {
788
+ const saturation = hsva.s / 100;
789
+ const value = hsva.v / 100;
790
+ let chroma = saturation * value;
791
+ let hueBy60 = hsva.h / 60;
792
+ let x = chroma * (1 - Math.abs(hueBy60 % 2 - 1));
793
+ let m = value - chroma;
794
+
795
+ chroma = (chroma + m);
796
+ x = (x + m);
797
+
798
+ const index = Math.floor(hueBy60) % 6;
799
+ const red = [chroma, x, m, m, x, chroma][index];
800
+ const green = [x, chroma, chroma, x, m, m][index];
801
+ const blue = [m, m, x, chroma, chroma, x][index];
802
+
803
+ return {
804
+ r: Math.round(red * 255),
805
+ g: Math.round(green * 255),
806
+ b: Math.round(blue * 255),
807
+ a: hsva.a
808
+ };
809
+ }
810
+
811
+ /**
812
+ * Convert HSVA to HSLA.
813
+ * @param {object} hsva Hue, saturation, value and alpha values.
814
+ * @return {object} Hue, saturation, lightness and alpha values.
815
+ */
816
+ function HSVAtoHSLA(hsva) {
817
+ const value = hsva.v / 100;
818
+ const lightness = value * (1 - (hsva.s / 100) / 2);
819
+ let saturation;
820
+
821
+ if (lightness > 0 && lightness < 1) {
822
+ saturation = Math.round((value - lightness) / Math.min(lightness, 1 - lightness) * 100);
823
+ }
824
+
825
+ return {
826
+ h: hsva.h,
827
+ s: saturation || 0,
828
+ l: Math.round(lightness * 100),
829
+ a: hsva.a
830
+ };
831
+ }
832
+
833
+ /**
834
+ * Convert RGBA to HSVA.
835
+ * @param {object} rgba Red, green, blue and alpha values.
836
+ * @return {object} Hue, saturation, value and alpha values.
837
+ */
838
+ function RGBAtoHSVA(rgba) {
839
+ const red = rgba.r / 255;
840
+ const green = rgba.g / 255;
841
+ const blue = rgba.b / 255;
842
+ const xmax = Math.max(red, green, blue);
843
+ const xmin = Math.min(red, green, blue);
844
+ const chroma = xmax - xmin;
845
+ const value = xmax;
846
+ let hue = 0;
847
+ let saturation = 0;
848
+
849
+ if (chroma) {
850
+ if (xmax === red ) { hue = ((green - blue) / chroma); }
851
+ if (xmax === green ) { hue = 2 + (blue - red) / chroma; }
852
+ if (xmax === blue ) { hue = 4 + (red - green) / chroma; }
853
+ if (xmax) { saturation = chroma / xmax; }
854
+ }
855
+
856
+ hue = Math.floor(hue * 60);
857
+
858
+ return {
859
+ h: hue < 0 ? hue + 360 : hue,
860
+ s: Math.round(saturation * 100),
861
+ v: Math.round(value * 100),
862
+ a: rgba.a
863
+ };
864
+ }
865
+
866
+ /**
867
+ * Parse a string to RGBA.
868
+ * @param {string} str String representing a color.
869
+ * @return {object} Red, green, blue and alpha values.
870
+ */
871
+ function strToRGBA(str) {
872
+ const regex = /^((rgba)|rgb)[\D]+([\d.]+)[\D]+([\d.]+)[\D]+([\d.]+)[\D]*?([\d.]+|$)/i;
873
+ let match, rgba;
874
+
875
+ // Default to black for invalid color strings
876
+ ctx.fillStyle = '#000';
877
+
878
+ // Use canvas to convert the string to a valid color string
879
+ ctx.fillStyle = str;
880
+ match = regex.exec(ctx.fillStyle);
881
+
882
+ if (match) {
883
+ rgba = {
884
+ r: match[3] * 1,
885
+ g: match[4] * 1,
886
+ b: match[5] * 1,
887
+ a: match[6] * 1
888
+ };
889
+
890
+ } else {
891
+ match = ctx.fillStyle.replace('#', '').match(/.{2}/g).map(h => parseInt(h, 16));
892
+ rgba = {
893
+ r: match[0],
894
+ g: match[1],
895
+ b: match[2],
896
+ a: 1
897
+ };
898
+ }
899
+
900
+ return rgba;
901
+ }
902
+
903
+ /**
904
+ * Convert RGBA to Hex.
905
+ * @param {object} rgba Red, green, blue and alpha values.
906
+ * @return {string} Hex color string.
907
+ */
908
+ function RGBAToHex(rgba) {
909
+ let R = rgba.r.toString(16);
910
+ let G = rgba.g.toString(16);
911
+ let B = rgba.b.toString(16);
912
+ let A = '';
913
+
914
+ if (rgba.r < 16) {
915
+ R = '0' + R;
916
+ }
917
+
918
+ if (rgba.g < 16) {
919
+ G = '0' + G;
920
+ }
921
+
922
+ if (rgba.b < 16) {
923
+ B = '0' + B;
924
+ }
925
+
926
+ if (settings.alpha && (rgba.a < 1 || settings.forceAlpha)) {
927
+ const alpha = rgba.a * 255 | 0;
928
+ A = alpha.toString(16);
929
+
930
+ if (alpha < 16) {
931
+ A = '0' + A;
932
+ }
933
+ }
934
+
935
+ return '#' + R + G + B + A;
936
+ }
937
+
938
+ /**
939
+ * Convert RGBA values to a CSS rgb/rgba string.
940
+ * @param {object} rgba Red, green, blue and alpha values.
941
+ * @return {string} CSS color string.
942
+ */
943
+ function RGBAToStr(rgba) {
944
+ if (!settings.alpha || (rgba.a === 1 && !settings.forceAlpha)) {
945
+ return `rgb(${rgba.r}, ${rgba.g}, ${rgba.b})`;
946
+ } else {
947
+ return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})`;
948
+ }
949
+ }
950
+
951
+ /**
952
+ * Convert HSLA values to a CSS hsl/hsla string.
953
+ * @param {object} hsla Hue, saturation, lightness and alpha values.
954
+ * @return {string} CSS color string.
955
+ */
956
+ function HSLAToStr(hsla) {
957
+ if (!settings.alpha || (hsla.a === 1 && !settings.forceAlpha)) {
958
+ return `hsl(${hsla.h}, ${hsla.s}%, ${hsla.l}%)`;
959
+ } else {
960
+ return `hsla(${hsla.h}, ${hsla.s}%, ${hsla.l}%, ${hsla.a})`;
961
+ }
962
+ }
963
+
964
+ /**
965
+ * Init the color picker.
966
+ */
967
+ function init() {
968
+ // Render the UI
969
+ container = undefined;
970
+ picker = document.createElement('div');
971
+ picker.setAttribute('id', 'clr-picker');
972
+ picker.className = 'clr-picker';
973
+ picker.innerHTML =
974
+ `<input id="clr-color-value" name="clr-color-value" class="clr-color" type="text" value="" spellcheck="false" aria-label="${settings.a11y.input}">`+
975
+ `<div id="clr-color-area" class="clr-gradient" role="application" aria-label="${settings.a11y.instruction}">`+
976
+ '<div id="clr-color-marker" class="clr-marker" tabindex="0"></div>'+
977
+ '</div>'+
978
+ '<div class="clr-hue">'+
979
+ `<input id="clr-hue-slider" name="clr-hue-slider" type="range" min="0" max="360" step="1" aria-label="${settings.a11y.hueSlider}">`+
980
+ '<div id="clr-hue-marker"></div>'+
981
+ '</div>'+
982
+ '<div class="clr-alpha">'+
983
+ `<input id="clr-alpha-slider" name="clr-alpha-slider" type="range" min="0" max="100" step="1" aria-label="${settings.a11y.alphaSlider}">`+
984
+ '<div id="clr-alpha-marker"></div>'+
985
+ '<span></span>'+
986
+ '</div>'+
987
+ '<div id="clr-format" class="clr-format">'+
988
+ '<fieldset class="clr-segmented">'+
989
+ `<legend>${settings.a11y.format}</legend>`+
990
+ '<input id="clr-f1" type="radio" name="clr-format" value="hex">'+
991
+ '<label for="clr-f1">Hex</label>'+
992
+ '<input id="clr-f2" type="radio" name="clr-format" value="rgb">'+
993
+ '<label for="clr-f2">RGB</label>'+
994
+ '<input id="clr-f3" type="radio" name="clr-format" value="hsl">'+
995
+ '<label for="clr-f3">HSL</label>'+
996
+ '<span></span>'+
997
+ '</fieldset>'+
998
+ '</div>'+
999
+ '<div id="clr-swatches" class="clr-swatches"></div>'+
1000
+ `<button type="button" id="clr-clear" class="clr-clear" aria-label="${settings.a11y.clear}">${settings.clearLabel}</button>`+
1001
+ '<div id="clr-color-preview" class="clr-preview">'+
1002
+ `<button type="button" id="clr-close" class="clr-close" aria-label="${settings.a11y.close}">${settings.closeLabel}</button>`+
1003
+ '</div>'+
1004
+ `<span id="clr-open-label" hidden>${settings.a11y.open}</span>`+
1005
+ `<span id="clr-swatch-label" hidden>${settings.a11y.swatch}</span>`;
1006
+
1007
+ // Append the color picker to the DOM
1008
+ document.body.appendChild(picker);
1009
+
1010
+ // Reference the UI elements
1011
+ colorArea = getEl('clr-color-area');
1012
+ colorMarker = getEl('clr-color-marker');
1013
+ clearButton = getEl('clr-clear');
1014
+ closeButton = getEl('clr-close');
1015
+ colorPreview = getEl('clr-color-preview');
1016
+ colorValue = getEl('clr-color-value');
1017
+ hueSlider = getEl('clr-hue-slider');
1018
+ hueMarker = getEl('clr-hue-marker');
1019
+ alphaSlider = getEl('clr-alpha-slider');
1020
+ alphaMarker = getEl('clr-alpha-marker');
1021
+
1022
+ // Bind the picker to the default selector
1023
+ bindFields(settings.el);
1024
+ wrapFields(settings.el);
1025
+
1026
+ addListener(picker, 'mousedown', event => {
1027
+ picker.classList.remove('clr-keyboard-nav');
1028
+ event.stopPropagation();
1029
+ });
1030
+
1031
+ addListener(colorArea, 'mousedown', event => {
1032
+ addListener(document, 'mousemove', moveMarker);
1033
+ });
1034
+
1035
+ addListener(colorArea, 'contextmenu', event => {
1036
+ event.preventDefault();
1037
+ });
1038
+
1039
+ addListener(colorArea, 'touchstart', event => {
1040
+ document.addEventListener('touchmove', moveMarker, { passive: false });
1041
+ });
1042
+
1043
+ addListener(colorMarker, 'mousedown', event => {
1044
+ addListener(document, 'mousemove', moveMarker);
1045
+ });
1046
+
1047
+ addListener(colorMarker, 'touchstart', event => {
1048
+ document.addEventListener('touchmove', moveMarker, { passive: false });
1049
+ });
1050
+
1051
+ addListener(colorValue, 'change', event => {
1052
+ const value = colorValue.value;
1053
+
1054
+ if (currentEl || settings.inline) {
1055
+ const color = value === '' ? value : setColorFromStr(value);
1056
+ pickColor(color);
1057
+ }
1058
+ });
1059
+
1060
+ addListener(clearButton, 'click', event => {
1061
+ pickColor('');
1062
+ closePicker();
1063
+ });
1064
+
1065
+ addListener(closeButton, 'click', event => {
1066
+ pickColor();
1067
+ closePicker();
1068
+ });
1069
+
1070
+ addListener(getEl('clr-format'), 'click', '.clr-format input', event => {
1071
+ currentFormat = event.target.value;
1072
+ updateColor();
1073
+ pickColor();
1074
+ });
1075
+
1076
+ addListener(picker, 'click', '.clr-swatches button', event => {
1077
+ setColorFromStr(event.target.textContent);
1078
+ pickColor();
1079
+
1080
+ if (settings.swatchesOnly) {
1081
+ closePicker();
1082
+ }
1083
+ });
1084
+
1085
+ addListener(document, 'mouseup', event => {
1086
+ document.removeEventListener('mousemove', moveMarker);
1087
+ });
1088
+
1089
+ addListener(document, 'touchend', event => {
1090
+ document.removeEventListener('touchmove', moveMarker);
1091
+ });
1092
+
1093
+ addListener(document, 'mousedown', event => {
1094
+ keyboardNav = false;
1095
+ picker.classList.remove('clr-keyboard-nav');
1096
+ closePicker();
1097
+ });
1098
+
1099
+ addListener(document, 'keydown', event => {
1100
+ const key = event.key;
1101
+ const target = event.target;
1102
+ const shiftKey = event.shiftKey;
1103
+ const navKeys = ['Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
1104
+
1105
+ if (key === 'Escape') {
1106
+ closePicker(true);
1107
+ return;
1108
+
1109
+ // Close the color picker and keep the selected color on press on Enter
1110
+ } else if (key === 'Enter' && target.tagName !== 'BUTTON') {
1111
+ closePicker();
1112
+ return;
1113
+
1114
+ // Display focus rings when using the keyboard
1115
+ } else if (navKeys.includes(key)) {
1116
+ keyboardNav = true;
1117
+ picker.classList.add('clr-keyboard-nav');
1118
+ }
1119
+
1120
+ // Trap the focus within the color picker while it's open
1121
+ if (key === 'Tab' && target.matches('.clr-picker *')) {
1122
+ const focusables = getFocusableElements();
1123
+ const firstFocusable = focusables.shift();
1124
+ const lastFocusable = focusables.pop();
1125
+
1126
+ if (shiftKey && target === firstFocusable) {
1127
+ lastFocusable.focus();
1128
+ event.preventDefault();
1129
+ } else if (!shiftKey && target === lastFocusable) {
1130
+ firstFocusable.focus();
1131
+ event.preventDefault();
1132
+ }
1133
+ }
1134
+ });
1135
+
1136
+ addListener(document, 'click', '.clr-field button', event => {
1137
+ // Reset any previously set per-instance options
1138
+ if (hasInstance) {
1139
+ resetVirtualInstance();
1140
+ }
1141
+
1142
+ // Open the color picker
1143
+ event.target.nextElementSibling.dispatchEvent(new Event('click', { bubbles: true }));
1144
+ });
1145
+
1146
+ addListener(colorMarker, 'keydown', event => {
1147
+ const movements = {
1148
+ ArrowUp: [0, -1],
1149
+ ArrowDown: [0, 1],
1150
+ ArrowLeft: [-1, 0],
1151
+ ArrowRight: [1, 0]
1152
+ };
1153
+
1154
+ if (Object.keys(movements).includes(event.key)) {
1155
+ moveMarkerOnKeydown(...movements[event.key]);
1156
+ event.preventDefault();
1157
+ }
1158
+ });
1159
+
1160
+ addListener(colorArea, 'click', moveMarker);
1161
+ addListener(hueSlider, 'input', setHue);
1162
+ addListener(alphaSlider, 'input', setAlpha);
1163
+ }
1164
+
1165
+ /**
1166
+ * Return a list of focusable elements within the color picker.
1167
+ * @return {array} The list of focusable DOM elemnts.
1168
+ */
1169
+ function getFocusableElements() {
1170
+ const controls = Array.from(picker.querySelectorAll('input, button'));
1171
+ const focusables = controls.filter(node => !!node.offsetWidth);
1172
+
1173
+ return focusables;
1174
+ }
1175
+
1176
+ /**
1177
+ * Shortcut for getElementById to optimize the minified JS.
1178
+ * @param {string} id The element id.
1179
+ * @return {object} The DOM element with the provided id.
1180
+ */
1181
+ function getEl(id) {
1182
+ return document.getElementById(id);
1183
+ }
1184
+
1185
+ /**
1186
+ * Shortcut for addEventListener to optimize the minified JS.
1187
+ * @param {object} context The context to which the listener is attached.
1188
+ * @param {string} type Event type.
1189
+ * @param {(string|function)} selector Event target if delegation is used, event handler if not.
1190
+ * @param {function} [fn] Event handler if delegation is used.
1191
+ */
1192
+ function addListener(context, type, selector, fn) {
1193
+ const matches = Element.prototype.matches || Element.prototype.msMatchesSelector;
1194
+
1195
+ // Delegate event to the target of the selector
1196
+ if (typeof selector === 'string') {
1197
+ context.addEventListener(type, event => {
1198
+ if (matches.call(event.target, selector)) {
1199
+ fn.call(event.target, event);
1200
+ }
1201
+ });
1202
+
1203
+ // If the selector is not a string then it's a function
1204
+ // in which case we need a regular event listener
1205
+ } else {
1206
+ fn = selector;
1207
+ context.addEventListener(type, fn);
1208
+ }
1209
+ }
1210
+
1211
+ /**
1212
+ * Call a function only when the DOM is ready.
1213
+ * @param {function} fn The function to call.
1214
+ * @param {array} [args] Arguments to pass to the function.
1215
+ */
1216
+ function DOMReady(fn, args) {
1217
+ args = args !== undefined ? args : [];
1218
+
1219
+ if (document.readyState !== 'loading') {
1220
+ fn(...args);
1221
+ } else {
1222
+ document.addEventListener('DOMContentLoaded', () => {
1223
+ fn(...args);
1224
+ });
1225
+ }
1226
+ }
1227
+
1228
+ // Polyfill for Nodelist.forEach
1229
+ if (NodeList !== undefined && NodeList.prototype && !NodeList.prototype.forEach) {
1230
+ NodeList.prototype.forEach = Array.prototype.forEach;
1231
+ }
1232
+
1233
+ // Expose the color picker to the global scope
1234
+ window.Coloris = (() => {
1235
+ const methods = {
1236
+ set: configure,
1237
+ wrap: wrapFields,
1238
+ close: closePicker,
1239
+ setInstance: setVirtualInstance,
1240
+ removeInstance: removeVirtualInstance,
1241
+ updatePosition: updatePickerPosition,
1242
+ ready: DOMReady
1243
+ };
1244
+
1245
+ function Coloris(options) {
1246
+ DOMReady(() => {
1247
+ if (options) {
1248
+ if (typeof options === 'string') {
1249
+ bindFields(options);
1250
+ } else {
1251
+ configure(options);
1252
+ }
1253
+ }
1254
+ });
1255
+ }
1256
+
1257
+ for (const key in methods) {
1258
+ Coloris[key] = (...args) => {
1259
+ DOMReady(methods[key], args);
1260
+ };
1261
+ }
1262
+
1263
+ return Coloris;
1264
+ })();
1265
+
1266
+ // Init the color picker when the DOM is ready
1267
+ document.addEventListener("turbo:load", () => {
1268
+ DOMReady(init);
1269
+ })
1270
+
1271
+ })(window, document, Math);