├── LICENSE.md ├── README.md └── color-picker.js /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Benjamin De Cock 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | Insert the custom element and the library in your document: 4 | 5 | ```html 6 | 7 | Example 8 | 9 | 10 | 11 | 12 | ``` 13 | 14 | Listen for the `color-change` event to get the selected color: 15 | 16 | ```javascript 17 | const picker = document.querySelector("color-picker"); 18 | 19 | picker.addEventListener("color-change", () => { 20 | const { state } = picker; 21 | console.log(state); // => object containing the current rgb, hsb and hex values 22 | }); 23 | ``` 24 | 25 | Please note this component is based on the [Shadow DOM v1 26 | spec](http://w3c.github.io/webcomponents/spec/shadow/) which might require a 27 | [polyfill](https://github.com/webcomponents/shadydom) for older browsers. 28 | -------------------------------------------------------------------------------- /color-picker.js: -------------------------------------------------------------------------------- 1 | { 2 | "use strict"; 3 | 4 | 5 | // utils 6 | // =============================================================================================== 7 | 8 | const isEmpty = arr => !arr.length; 9 | 10 | const isObject = obj => Object(obj) === obj; 11 | 12 | const addProp = (obj, key, value) => 13 | Object.defineProperty(obj, key, { 14 | value, 15 | enumerable: true, 16 | configurable: true, 17 | writable: true 18 | }); 19 | 20 | const create = (el, attr) => 21 | Object.keys(attr).reduce((layer, key) => { 22 | layer.setAttribute(key, attr[key]); 23 | return layer; 24 | }, document.createElementNS("http://www.w3.org/2000/svg", el)); 25 | 26 | const fireEvent = host => 27 | host.dispatchEvent(new Event("color-change", { 28 | bubbles: true, 29 | composed: true 30 | })); 31 | 32 | 33 | // hue slider gradient 34 | // =============================================================================================== 35 | 36 | const defineColorStops = (steps = 20, arr = [], hue = 0, max = 360) => { 37 | arr.push({ 38 | "stop-color": `hsl(${hue}, 100%, 50%)`, 39 | "offset": (hue / max).toFixed(2) 40 | }); 41 | return hue >= max ? arr : defineColorStops(steps, arr, hue + max / steps); 42 | }; 43 | 44 | const buildHueSlider = (hue, defs) => { 45 | const gradientId = "sliderGradient"; 46 | const gradient = create("linearGradient", { 47 | id: gradientId 48 | }); 49 | 50 | defineColorStops().forEach(color => gradient.appendChild(create("stop", color))); 51 | defs.appendChild(gradient); 52 | hue.setAttribute("fill", `url(#${gradientId})`); 53 | }; 54 | 55 | 56 | // color conversions 57 | // =============================================================================================== 58 | 59 | const toHex = rgb => 60 | Object.keys(rgb).reduce((str, key) => { 61 | let hex = rgb[key].toString(16); 62 | if (hex.length < 2) hex = `0${hex}`; 63 | return str + hex; 64 | }, "").toUpperCase(); 65 | 66 | const toRGB = hsb => { 67 | const h = Number(hsb.h) / 360; 68 | const i = Math.floor(h * 6); 69 | const values = (() => { 70 | const [s, b] = [hsb.s, hsb.b].map(val => Number(val) / 100); 71 | const f = h * 6 - i; 72 | const p = b * (1 - s); 73 | const q = b * (1 - f * s); 74 | const t = b * (1 - (1 - f) * s); 75 | 76 | return { 77 | 0: [b, t, p], 78 | 1: [q, b, p], 79 | 2: [p, b, t], 80 | 3: [p, q, b], 81 | 4: [t, p, b], 82 | 5: [b, p, q] 83 | }; 84 | })(); 85 | 86 | const [r, g, b] = values[i % 6].map(val => Math.round(val * 255)); 87 | return { r, g, b }; 88 | }; 89 | 90 | const toHSB = color => { 91 | // RGB 92 | if (isObject(color)) { 93 | const keys = Object.keys(color); 94 | if (isEmpty(keys)) return {}; 95 | 96 | const rgb = keys.reduce((obj, key) => addProp(obj, key, Number(color[key])), {}); 97 | const min = Math.min(rgb.r, rgb.g, rgb.b); 98 | const max = Math.max(rgb.r, rgb.g, rgb.b); 99 | const d = max - min; 100 | const s = max == 0 ? 0 : d / max; 101 | const b = max / 255; 102 | let h; 103 | switch (max) { 104 | case min: h = 0; break; 105 | case rgb.r: h = (rgb.g - rgb.b) + d * (rgb.g < rgb.b ? 6 : 0); h /= 6 * d; break; 106 | case rgb.g: h = (rgb.b - rgb.r) + d * 2; h /= 6 * d; break; 107 | case rgb.b: h = (rgb.r - rgb.g) + d * 4; h /= 6 * d; break; 108 | } 109 | const hsb = { 110 | h: h * 360, 111 | s: s * 100, 112 | b: b * 100 113 | }; 114 | return Object.keys(hsb).reduce((obj, key) => addProp(obj, key, Math.round(hsb[key])), {}); 115 | } 116 | 117 | // HEX 118 | const convert = hex => hex.match(/[\d\w]{2}/g).map(val => parseInt(val, 16)); 119 | const [r, g, b] = convert(color); 120 | return toHSB({ r, g, b }); 121 | }; 122 | 123 | 124 | // input -> object 125 | // =============================================================================================== 126 | 127 | const selectInputs = (root, id) => 128 | [...root.querySelectorAll(`#${id} input`)].reduce((obj, el) => 129 | addProp(obj, el.className, el), {}); 130 | 131 | const extractValues = inputs => 132 | Object.keys(inputs).reduce((state, key) => 133 | addProp(state, key, Number(inputs[key].value)), {}); 134 | 135 | 136 | // math helpers 137 | // =============================================================================================== 138 | 139 | const getHandlerCoordinates = (pickers, type, color) => { 140 | const rect = pickers[type].palette.getBoundingClientRect(); 141 | if (type == "hue") { 142 | let x = color.h / 360 * rect.width; 143 | if (x < 5) x = 5; 144 | else if (x > rect.width - 5) x = rect.width - 5; 145 | return { x }; 146 | } 147 | return { 148 | x: color.s / 100 * rect.width, 149 | y: (1 - (color.b / 100)) * rect.height 150 | }; 151 | }; 152 | 153 | const getPickCoordinates = (el, event) => { 154 | const rect = el.getBoundingClientRect(); 155 | const { width, height } = rect; 156 | const x = event.clientX - rect.left; 157 | const y = event.clientY - rect.top; 158 | return { width, height, x, y }; 159 | }; 160 | 161 | const calcH = (x, width) => { 162 | if (x > width) return 360; 163 | if (x < 0) return 0; 164 | return x / width * 360; 165 | }; 166 | 167 | const calcS = (x, width) => { 168 | if (x > width) return 100; 169 | if (x < 0) return 0; 170 | return x / width * 100; 171 | }; 172 | 173 | const calcB = (y, height) => { 174 | if (y > height) return 0; 175 | if (y < 0) return 100; 176 | return (1 - (y / height)) * 100; 177 | }; 178 | 179 | 180 | // stylesheet 181 | // =============================================================================================== 182 | 183 | const css = ` 184 | :host, svg { 185 | display: block; 186 | } 187 | .pickerGradient { 188 | pointer-events: none; 189 | } 190 | section, label { 191 | display: flex; 192 | } 193 | section { 194 | justify-content: space-between; 195 | width: 200px; 196 | margin-top: 10px; 197 | } 198 | label, input { 199 | border: 1px solid #ddd; 200 | } 201 | label * { 202 | font: 12px -apple-system, BlinkMacSystemFont, helvetica, sans-serif; 203 | } 204 | attr { 205 | width: 18px; 206 | padding: 2px 0; 207 | text-align: center; 208 | font-weight: 500; 209 | } 210 | input { 211 | margin: 0; 212 | border-width: 0 0 0 1px; 213 | width: 175px; 214 | padding: 2px 0 2px 4px; 215 | } 216 | [type=number] { 217 | width: 37px; 218 | } 219 | `; 220 | 221 | 222 | // template markup 223 | // =============================================================================================== 224 | 225 | const html = ` 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 |
248 | 252 | 256 | 260 |
261 |
262 | 266 | 270 | 274 |
275 |
276 | 280 |
281 | `; 282 | 283 | 284 | // register custom element 285 | // =============================================================================================== 286 | 287 | customElements.define("color-picker", class extends HTMLElement { 288 | constructor() { 289 | super(); 290 | 291 | const root = this.attachShadow({ mode: "open" }); 292 | root.innerHTML = `${html}`; 293 | 294 | this.pickers = { 295 | hue: { 296 | palette: root.getElementById("slider"), 297 | handler: root.getElementById("sliderHandler") 298 | }, 299 | color: { 300 | palette: root.getElementById("picker"), 301 | handler: root.getElementById("pickerHandler") 302 | } 303 | }; 304 | 305 | this.hsbInputs = selectInputs(root, "hsb"); 306 | this.rgbInputs = selectInputs(root, "rgb"); 307 | this.hexInput = root.querySelector("#hex input"); 308 | 309 | buildHueSlider(this.pickers.hue.palette, root.querySelector("defs")); 310 | 311 | this.state = { 312 | hsb: extractValues(this.hsbInputs), 313 | rgb: extractValues(this.rgbInputs), 314 | hex: this.hexInput.value 315 | }; 316 | 317 | 318 | // mouse events 319 | // =========================================================================================== 320 | 321 | const onDrag = callback => { 322 | const listen = action => 323 | Object.keys(events).forEach(event => 324 | this[`${action}EventListener`](`mouse${event}`, events[event])); 325 | const end = () => listen("remove"); 326 | const events = { 327 | move: callback, 328 | up: end 329 | }; 330 | listen("add"); 331 | }; 332 | 333 | root.addEventListener("mousedown", e => { 334 | const callback = (() => { 335 | if (e.target == this.pickers.hue.palette) return this.pickHue; 336 | if (e.target == this.pickers.color.palette) return this.pickColor; 337 | })(); 338 | if (!callback) return; 339 | callback.call(this, e); 340 | onDrag(callback); 341 | }); 342 | 343 | 344 | // keyboard events 345 | // =========================================================================================== 346 | 347 | [this.hsbInputs, this.rgbInputs].forEach(color => 348 | Object.keys(color).forEach((key, i, arr) => { 349 | const el = color[key]; 350 | el.addEventListener("input", () => { 351 | if (!el.validity.valid) return this.updateState(); 352 | const val = el.value; 353 | this.updateState( 354 | color == this.hsbInputs 355 | ? { [key]: val } 356 | : toHSB(arr.reduce((rgb, val) => addProp(rgb, val, color[val].value), {}))); 357 | el.value = val; 358 | }); 359 | })); 360 | 361 | this.hexInput.addEventListener("input", () => { 362 | const val = this.hexInput.value; 363 | if (val.length < 6) return; 364 | this.updateState(toHSB(val)); 365 | this.hexInput.value = val; 366 | }); 367 | } 368 | 369 | updateState(obj = {}) { 370 | Object.keys(obj).forEach(key => addProp(this.state.hsb, key, Math.round(obj[key]))); 371 | addProp(this.state, "rgb", toRGB(this.state.hsb)); 372 | addProp(this.state, "hex", toHex(this.state.rgb)); 373 | fireEvent(this); 374 | this.updateUI(); 375 | } 376 | 377 | updateUI({ hsb, rgb, hex } = this.state) { 378 | const bindings = new Map([[this.hsbInputs, hsb], [this.rgbInputs, rgb]]); 379 | bindings.forEach((obj, el) => Object.keys(obj).forEach(key => el[key].value = obj[key])); 380 | this.hexInput.value = hex; 381 | this.pickers.color.palette.setAttribute("fill", `hsl(${hsb.h}, 100%, 50%)`); 382 | 383 | Object.keys(this.pickers).forEach(obj => { 384 | const coords = getHandlerCoordinates(this.pickers, obj, hsb); 385 | Object.keys(coords).forEach(axis => 386 | this.pickers[obj].handler.setAttribute(`c${axis}`, coords[axis])); 387 | }); 388 | } 389 | 390 | pickColor(e) { 391 | const { x, y, width, height } = getPickCoordinates(this.pickers.color.palette, e); 392 | this.updateState({ 393 | s: calcS(x, width), 394 | b: calcB(y, height) 395 | }); 396 | e.preventDefault(); 397 | } 398 | 399 | pickHue(e) { 400 | const { x, width } = getPickCoordinates(this.pickers.hue.palette, e); 401 | this.updateState({ h: calcH(x, width) }); 402 | e.preventDefault(); 403 | } 404 | }); 405 | } 406 | --------------------------------------------------------------------------------