├── 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 |
261 |
275 |
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 |
--------------------------------------------------------------------------------