/*! * 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", ], });