/*! * Copyright (c) 2021 Momo Bassit. * Licensed under the MIT License (MIT) * https://github.com/mdbassit/Coloris */ ((window, document, Math) => { const ctx = document.createElement('canvas').getContext('2d'); const currentColor = { r: 0, g: 0, b: 0, h: 0, s: 0, v: 0, a: 1 }; let picker, colorArea, colorAreaDims, colorMarker, colorPreview, colorValue, clearButton, hueSlider, hueMarker, alphaSlider, alphaMarker, currentEl, currentFormat, oldColor; // Default settings const settings = { el: '.coloris', parent: null, theme: 'default', themeMode: 'light', wrap: true, margin: 2, format: 'hex', formatToggle: false, swatches: [], swatchesOnly: false, alpha: true, focusInput: true, autoClose: false, clearButton: { show: false, label: 'Clear' }, a11y: { open: 'Open color picker', close: 'Close color picker', marker: 'Saturation: {s}. Brightness: {v}.', hueSlider: 'Hue slider', alphaSlider: 'Opacity slider', input: 'Color value field', format: 'Color format', swatch: 'Color swatch', instruction: 'Saturation and brightness selector. Use up, down, left and right arrow keys to select.' } }; /** * Configure the color picker. * @param {object} options Configuration options. */ function configure(options) { if (typeof options !== 'object') { return; } for (const key in options) { switch (key) { case 'el': bindFields(options.el); if (options.wrap !== false) { wrapFields(options.el); } break; case 'parent': settings.parent = document.querySelector(options.parent); if (settings.parent) { settings.parent.appendChild(picker); } break; case 'themeMode': settings.themeMode = options.themeMode; if (options.themeMode === 'auto' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { settings.themeMode = 'dark'; } // The lack of a break statement is intentional case 'theme': if (options.theme) { settings.theme = options.theme; } picker.className = `clr-picker clr-${settings.theme} clr-${settings.themeMode}`; break; case 'margin': options.margin *= 1; settings.margin = !isNaN(options.margin) ? options.margin : settings.margin; break; case 'wrap': if (options.el && options.wrap) { wrapFields(options.el); } break; case 'formatToggle': getEl('clr-format').style.display = options.formatToggle ? 'block' : 'none'; if (options.formatToggle) { settings.format = 'auto'; } break; case 'swatches': if (Array.isArray(options.swatches)) { const swatches = []; options.swatches.forEach((swatch, i) => { swatches.push(``); }); if (swatches.length) { getEl('clr-swatches').innerHTML = `
${swatches.join('')}
`; } } break; case 'swatchesOnly': settings.swatchesOnly = !!options.swatchesOnly; picker.setAttribute('data-minimal', settings.swatchesOnly); if (settings.swatchesOnly) { settings.autoClose = true; } break; case 'alpha': settings.alpha = !!options.alpha; picker.setAttribute('data-alpha', settings.alpha); break; case 'clearButton': let display = 'none'; if (options.clearButton.show) { display = 'block'; } if (options.clearButton.label) { clearButton.innerHTML = options.clearButton.label; } clearButton.style.display = display; break; case 'a11y': const labels = options.a11y; let update = false; if (typeof labels === 'object') { for (const label in labels) { if (labels[label] && settings.a11y[label]) { settings.a11y[label] = labels[label]; update = true; } } } if (update) { const openLabel = getEl('clr-open-label'); const swatchLabel = getEl('clr-swatch-label'); openLabel.innerHTML = settings.a11y.open; swatchLabel.innerHTML = settings.a11y.swatch; colorPreview.setAttribute('aria-label', settings.a11y.close); hueSlider.setAttribute('aria-label', settings.a11y.hueSlider); alphaSlider.setAttribute('aria-label', settings.a11y.alphaSlider); colorValue.setAttribute('aria-label', settings.a11y.input); colorArea.setAttribute('aria-label', settings.a11y.instruction); } default: settings[key] = options[key]; } } } /** * Bind the color picker to input fields that match the selector. * @param {string} selector One or more selectors pointing to input fields. */ function bindFields(selector) { // Show the color picker on click on the input fields that match the selector addListener(document, 'click', selector, event => { const parent = settings.parent; const coords = event.target.getBoundingClientRect(); const scrollY = window.scrollY; let reposition = { left: false, top: false }; let offset = { x: 0, y: 0 }; let left = coords.x; let top = scrollY + coords.y + coords.height + settings.margin; currentEl = event.target; oldColor = currentEl.value; currentFormat = getColorFormatFromStr(oldColor); picker.classList.add('clr-open'); const pickerWidth = picker.offsetWidth; const pickerHeight = picker.offsetHeight; // If the color picker is inside a custom container // set the position relative to it if (parent) { const style = window.getComputedStyle(parent); const marginTop = parseFloat(style.marginTop); const borderTop = parseFloat(style.borderTopWidth); offset = parent.getBoundingClientRect(); offset.y += borderTop + scrollY; left -= offset.x; top -= offset.y; if (left + pickerWidth > parent.clientWidth) { left += coords.width - pickerWidth; reposition.left = true; } if (top + pickerHeight > parent.clientHeight - marginTop) { top -= coords.height + pickerHeight + settings.margin * 2; reposition.top = true; } top += parent.scrollTop; // Otherwise set the position relative to the whole document } else { if (left + pickerWidth > document.documentElement.clientWidth) { left += coords.width - pickerWidth; reposition.left = true; } if (top + pickerHeight - scrollY > document.documentElement.clientHeight) { top = scrollY + coords.y - pickerHeight - settings.margin; reposition.top = true; } } picker.classList.toggle('clr-left', reposition.left); picker.classList.toggle('clr-top', reposition.top); picker.style.left = `${left}px`; picker.style.top = `${top}px`; colorAreaDims = { width: colorArea.offsetWidth, height: colorArea.offsetHeight, x: picker.offsetLeft + colorArea.offsetLeft + offset.x, y: picker.offsetTop + colorArea.offsetTop + offset.y }; setColorFromStr(oldColor); if (settings.focusInput) { colorValue.focus({ preventScroll: true }); } }); // Update the color preview of the input fields that match the selector addListener(document, 'input', selector, event => { const parent = event.target.parentNode; // Only update the preview if the field has been previously wrapped if (parent.classList.contains('clr-field')) { parent.style.color = event.target.value; } }); } /** * Wrap the linked input fields in a div that adds a color preview. * @param {string} selector One or more selectors pointing to input fields. */ function wrapFields(selector) { document.querySelectorAll(selector).forEach(field => { const parentNode = field.parentNode; if (!parentNode.classList.contains('clr-field')) { const wrapper = document.createElement('div'); wrapper.innerHTML = ``; parentNode.insertBefore(wrapper, field); wrapper.setAttribute('class', 'clr-field'); wrapper.style.color = field.value; wrapper.appendChild(field); } }); } /** * Close the color picker. * @param {boolean} [revert] If true, revert the color to the original value. */ function closePicker(revert) { if (currentEl) { // Revert the color to the original value if needed if (revert && oldColor !== currentEl.value) { currentEl.value = oldColor; // Trigger an "input" event to force update the thumbnail next to the input field currentEl.dispatchEvent(new Event('input', { bubbles: true })); } if (oldColor !== currentEl.value) { currentEl.dispatchEvent(new Event('change', { bubbles: true })); } picker.classList.remove('clr-open'); if (settings.focusInput) { currentEl.focus({ preventScroll: true }); } currentEl = null; } } /** * Set the active color from a string. * @param {string} str String representing a color. */ function setColorFromStr(str) { const rgba = strToRGBA(str); const hsva = RGBAtoHSVA(rgba); updateMarkerA11yLabel(hsva.s, hsva.v); updateColor(rgba, hsva); // Update the UI hueSlider.value = hsva.h; picker.style.color = `hsl(${hsva.h}, 100%, 50%)`; hueMarker.style.left = `${hsva.h / 360 * 100}%`; colorMarker.style.left = `${colorAreaDims.width * hsva.s / 100}px`; colorMarker.style.top = `${colorAreaDims.height - (colorAreaDims.height * hsva.v / 100)}px`; alphaSlider.value = hsva.a * 100; alphaMarker.style.left = `${hsva.a * 100}%`; } /** * Guess the color format from a string. * @param {string} str String representing a color. * @return {string} The color format. */ function getColorFormatFromStr(str) { const format = str.substring(0, 3).toLowerCase(); if (format === 'rgb' || format === 'hsl') { return format; } return 'hex'; } /** * Copy the active color to the linked input field. * @param {number} [color] Color value to override the active color. */ function pickColor(color) { if (currentEl) { currentEl.value = color !== undefined ? color : colorValue.value; currentEl.dispatchEvent(new Event('input', { bubbles: true })); } } /** * Set the active color based on a specific point in the color gradient. * @param {number} x Left position. * @param {number} y Top position. */ function setColorAtPosition(x, y) { const hsva = { h: hueSlider.value * 1, s: x / colorAreaDims.width * 100, v: 100 - (y / colorAreaDims.height * 100), a: alphaSlider.value / 100 }; const rgba = HSVAtoRGBA(hsva); updateMarkerA11yLabel(hsva.s, hsva.v); updateColor(rgba, hsva); pickColor(); } /** * Update the color marker's accessibility label. * @param {number} saturation * @param {number} value */ function updateMarkerA11yLabel(saturation, value) { let label = settings.a11y.marker; saturation = saturation.toFixed(1) * 1; value = value.toFixed(1) * 1; label = label.replace('{s}', saturation); label = label.replace('{v}', value); colorMarker.setAttribute('aria-label', label); } // /** * Get the pageX and pageY positions of the pointer. * @param {object} event The MouseEvent or TouchEvent object. * @return {object} The pageX and pageY positions. */ function getPointerPosition(event) { return { pageX: event.changedTouches ? event.changedTouches[0].pageX : event.pageX, pageY: event.changedTouches ? event.changedTouches[0].pageY : event.pageY }; } /** * Move the color marker when dragged. * @param {object} event The MouseEvent object. */ function moveMarker(event) { const pointer = getPointerPosition(event); let x = pointer.pageX - colorAreaDims.x; let y = pointer.pageY - colorAreaDims.y; if (settings.parent) { y += settings.parent.scrollTop; } x = (x < 0) ? 0 : (x > colorAreaDims.width) ? colorAreaDims.width : x; y = (y < 0) ? 0 : (y > colorAreaDims.height) ? colorAreaDims.height : y; colorMarker.style.left = `${x}px`; colorMarker.style.top = `${y}px`; setColorAtPosition(x, y); // Prevent scrolling while dragging the marker event.preventDefault(); event.stopPropagation(); } /** * Move the color marker when the arrow keys are pressed. * @param {number} offsetX The horizontal amount to move. * * @param {number} offsetY The vertical amount to move. */ function moveMarkerOnKeydown(offsetX, offsetY) { const x = colorMarker.style.left.replace('px', '') * 1 + offsetX; const y = colorMarker.style.top.replace('px', '') * 1 + offsetY; colorMarker.style.left = `${x}px`; colorMarker.style.top = `${y}px`; setColorAtPosition(x, y); } /** * Update the color picker's input field and preview thumb. * @param {Object} rgba Red, green, blue and alpha values. * @param {Object} [hsva] Hue, saturation, value and alpha values. */ function updateColor(rgba = {}, hsva = {}) { let format = settings.format; for (const key in rgba) { currentColor[key] = rgba[key]; } for (const key in hsva) { currentColor[key] = hsva[key]; } const hex = RGBAToHex(currentColor); const opaqueHex = hex.substring(0, 7); colorMarker.style.color = opaqueHex; alphaMarker.parentNode.style.color = opaqueHex; alphaMarker.style.color = hex; colorPreview.style.color = hex; // Force repaint the color and alpha gradients as a workaround for a Google Chrome bug colorArea.style.display = 'none'; colorArea.offsetHeight; colorArea.style.display = ''; alphaMarker.nextElementSibling.style.display = 'none'; alphaMarker.nextElementSibling.offsetHeight; alphaMarker.nextElementSibling.style.display = ''; if (format === 'mixed') { format = currentColor.a === 1 ? 'hex' : 'rgb'; } else if (format === 'auto') { format = currentFormat; } switch (format) { case 'hex': colorValue.value = hex; break; case 'rgb': colorValue.value = RGBAToStr(currentColor); break; case 'hsl': colorValue.value = HSLAToStr(HSVAtoHSLA(currentColor)); break; } // Select the current format in the format switcher document.querySelector(`.clr-format [value="${format}"]`).checked = true; } /** * Set the hue when its slider is moved. */ function setHue() { const hue = hueSlider.value * 1; const x = colorMarker.style.left.replace('px', '') * 1; const y = colorMarker.style.top.replace('px', '') * 1; picker.style.color = `hsl(${hue}, 100%, 50%)`; hueMarker.style.left = `${hue / 360 * 100}%`; setColorAtPosition(x, y); } /** * Set the alpha when its slider is moved. */ function setAlpha() { const alpha = alphaSlider.value / 100; alphaMarker.style.left = `${alpha * 100}%`; updateColor({ a: alpha }); pickColor(); } /** * Convert HSVA to RGBA. * @param {object} hsva Hue, saturation, value and alpha values. * @return {object} Red, green, blue and alpha values. */ function HSVAtoRGBA(hsva) { const saturation = hsva.s / 100; const value = hsva.v / 100; let chroma = saturation * value; let hueBy60 = hsva.h / 60; let x = chroma * (1 - Math.abs(hueBy60 % 2 - 1)); let m = value - chroma; chroma = (chroma + m); x = (x + m); const index = Math.floor(hueBy60) % 6; const red = [chroma, x, m, m, x, chroma][index]; const green = [x, chroma, chroma, x, m, m][index]; const blue = [m, m, x, chroma, chroma, x][index]; return { r: Math.round(red * 255), g: Math.round(green * 255), b: Math.round(blue * 255), a: hsva.a }; } /** * Convert HSVA to HSLA. * @param {object} hsva Hue, saturation, value and alpha values. * @return {object} Hue, saturation, lightness and alpha values. */ function HSVAtoHSLA(hsva) { const value = hsva.v / 100; const lightness = value * (1 - (hsva.s / 100) / 2); let saturation; if (lightness > 0 && lightness < 1) { saturation = Math.round((value - lightness) / Math.min(lightness, 1 - lightness) * 100); } return { h: hsva.h, s: saturation || 0, l: Math.round(lightness * 100), a: hsva.a }; } /** * Convert RGBA to HSVA. * @param {object} rgba Red, green, blue and alpha values. * @return {object} Hue, saturation, value and alpha values. */ function RGBAtoHSVA(rgba) { const red = rgba.r / 255; const green = rgba.g / 255; const blue = rgba.b / 255; const xmax = Math.max(red, green, blue); const xmin = Math.min(red, green, blue); const chroma = xmax - xmin; const value = xmax; let hue = 0; let saturation = 0; if (chroma) { if (xmax === red) { hue = ((green - blue) / chroma); } if (xmax === green) { hue = 2 + (blue - red) / chroma; } if (xmax === blue) { hue = 4 + (red - green) / chroma; } if (xmax) { saturation = chroma / xmax; } } hue = Math.floor(hue * 60); return { h: hue < 0 ? hue + 360 : hue, s: Math.round(saturation * 100), v: Math.round(value * 100), a: rgba.a }; } /** * Parse a string to RGBA. * @param {string} str String representing a color. * @return {object} Red, green, blue and alpha values. */ function strToRGBA(str) { const regex = /^((rgba)|rgb)[\D]+([\d.]+)[\D]+([\d.]+)[\D]+([\d.]+)[\D]*?([\d.]+|$)/i; let match, rgba; // Default to black for invalid color strings ctx.fillStyle = '#000'; // Use canvas to convert the string to a valid color string ctx.fillStyle = str; match = regex.exec(ctx.fillStyle); if (match) { rgba = { r: match[3] * 1, g: match[4] * 1, b: match[5] * 1, a: match[6] * 1 }; } else { match = ctx.fillStyle.replace('#', '').match(/.{2}/g).map(h => parseInt(h, 16)); rgba = { r: match[0], g: match[1], b: match[2], a: 1 }; } return rgba; } /** * Convert RGBA to Hex. * @param {object} rgba Red, green, blue and alpha values. * @return {string} Hex color string. */ function RGBAToHex(rgba) { let R = rgba.r.toString(16); let G = rgba.g.toString(16); let B = rgba.b.toString(16); let A = ''; if (rgba.r < 16) { R = '0' + R; } if (rgba.g < 16) { G = '0' + G; } if (rgba.b < 16) { B = '0' + B; } if (settings.alpha && rgba.a < 1) { const alpha = rgba.a * 255 | 0; A = alpha.toString(16); if (alpha < 16) { A = '0' + A; } } return '#' + R + G + B + A; } /** * Convert RGBA values to a CSS rgb/rgba string. * @param {object} rgba Red, green, blue and alpha values. * @return {string} CSS color string. */ function RGBAToStr(rgba) { if (!settings.alpha || rgba.a === 1) { return `rgb(${rgba.r}, ${rgba.g}, ${rgba.b})`; } else { return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})`; } } /** * Convert HSLA values to a CSS hsl/hsla string. * @param {object} hsla Hue, saturation, lightness and alpha values. * @return {string} CSS color string. */ function HSLAToStr(hsla) { if (!settings.alpha || hsla.a === 1) { return `hsl(${hsla.h}, ${hsla.s}%, ${hsla.l}%)`; } else { return `hsla(${hsla.h}, ${hsla.s}%, ${hsla.l}%, ${hsla.a})`; } } /** * Init the color picker. */ function init() { // Render the UI picker = document.createElement('div'); picker.setAttribute('id', 'clr-picker'); picker.className = 'clr-picker'; picker.innerHTML = `` + `
` + '
' + '
' + '
' + `` + '
' + '
' + '
' + `` + '
' + '' + '
' + '
' + '
' + `${settings.a11y.format}` + '' + '' + '' + '' + '' + '' + '' + '
' + '
' + '
' + `` + `` + `` + ``; // Append the color picker to the DOM document.body.appendChild(picker); // Reference the UI elements colorArea = getEl('clr-color-area'); colorMarker = getEl('clr-color-marker'); clearButton = getEl('clr-clear'); colorPreview = getEl('clr-color-preview'); colorValue = getEl('clr-color-value'); hueSlider = getEl('clr-hue-slider'); hueMarker = getEl('clr-hue-marker'); alphaSlider = getEl('clr-alpha-slider'); alphaMarker = getEl('clr-alpha-marker'); // Bind the picker to the default selector bindFields(settings.el); wrapFields(settings.el); addListener(picker, 'mousedown', event => { picker.classList.remove('clr-keyboard-nav'); event.stopPropagation(); }); addListener(colorArea, 'mousedown', event => { addListener(document, 'mousemove', moveMarker); }); addListener(colorArea, 'touchstart', event => { document.addEventListener('touchmove', moveMarker, { passive: false }); }); addListener(colorMarker, 'mousedown', event => { addListener(document, 'mousemove', moveMarker); }); addListener(colorMarker, 'touchstart', event => { document.addEventListener('touchmove', moveMarker, { passive: false }); }); addListener(colorValue, 'change', event => { setColorFromStr(colorValue.value); pickColor(); }); addListener(clearButton, 'click', event => { pickColor(''); closePicker(); }); addListener(colorPreview, 'click', event => { pickColor(); closePicker(); }); addListener(document, 'click', '.clr-format input', event => { currentFormat = event.target.value; updateColor(); pickColor(); }); addListener(picker, 'click', '.clr-swatches button', event => { setColorFromStr(event.target.textContent); pickColor(); if (settings.autoClose) { closePicker(); } }); addListener(document, 'mouseup', event => { document.removeEventListener('mousemove', moveMarker); }); addListener(document, 'touchend', event => { document.removeEventListener('touchmove', moveMarker); }); addListener(document, 'mousedown', event => { picker.classList.remove('clr-keyboard-nav'); closePicker(); }); addListener(document, 'keydown', event => { if (event.key === 'Escape') { closePicker(true); } else if (event.key === 'Tab') { picker.classList.add('clr-keyboard-nav'); } }); addListener(document, 'click', '.clr-field button', event => { event.target.nextElementSibling.dispatchEvent(new Event('click', { bubbles: true })); }); addListener(colorMarker, 'keydown', event => { const movements = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }; if (Object.keys(movements).indexOf(event.key) !== -1) { moveMarkerOnKeydown(...movements[event.key]); event.preventDefault(); } }); addListener(colorArea, 'click', moveMarker); addListener(hueSlider, 'input', setHue); addListener(alphaSlider, 'input', setAlpha); } /** * Shortcut for getElementById to optimize the minified JS. * @param {string} id The element id. * @return {object} The DOM element with the provided id. */ function getEl(id) { return document.getElementById(id); } /** * Shortcut for addEventListener to optimize the minified JS. * @param {object} context The context to which the listener is attached. * @param {string} type Event type. * @param {(string|function)} selector Event target if delegation is used, event handler if not. * @param {function} [fn] Event handler if delegation is used. */ function addListener(context, type, selector, fn) { const matches = Element.prototype.matches || Element.prototype.msMatchesSelector; // Delegate event to the target of the selector if (typeof selector === 'string') { context.addEventListener(type, event => { if (matches.call(event.target, selector)) { fn.call(event.target, event); } }); // If the selector is not a string then it's a function // in which case we need regular event listener } else { fn = selector; context.addEventListener(type, fn); } } /** * Call a function only when the DOM is ready. * @param {function} fn The function to call. * @param {array} [args] Arguments to pass to the function. */ function DOMReady(fn, args) { args = args !== undefined ? args : []; if (document.readyState !== 'loading') { fn(...args); } else { document.addEventListener('DOMContentLoaded', () => { fn(...args); }); } } // Polyfill for Nodelist.forEach if (NodeList !== undefined && NodeList.prototype && !NodeList.prototype.forEach) { NodeList.prototype.forEach = Array.prototype.forEach; } // Expose the color picker to the global scope window.Coloris = (() => { const methods = { set: configure, wrap: wrapFields, close: closePicker }; function Coloris(options) { DOMReady(() => { if (options) { if (typeof options === 'string') { bindFields(options); } else { configure(options); } } }); } for (const key in methods) { Coloris[key] = (...args) => { DOMReady(methods[key], args); }; } return Coloris; })(); // Init the color picker when the DOM is ready DOMReady(init); })(window, document, Math); Coloris({ el: '.coloris', theme: 'large', themeMode: 'dark', format: 'hex', alpha: false, swatches: [ '#471616', '#1e4716', '#16473f', '#161c47', '#371647', '#47163f', '#471627', '#3a3a3a', '#ffffff', '#1a1a1a' ] });