1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
7 const EventEmitter = require("devtools/shared/event-emitter");
8 const { MultiLocalizationHelper } = require("devtools/shared/l10n");
10 loader.lazyRequireGetter(this, "colorUtils", "devtools/shared/css/color", true);
11 loader.lazyRequireGetter(
14 "devtools/shared/css/color-db",
17 loader.lazyRequireGetter(
20 "devtools/shared/accessibility",
23 loader.lazyRequireGetter(
25 "getContrastRatioAgainstBackground",
26 "devtools/shared/accessibility",
30 const L10N = new MultiLocalizationHelper(
31 "devtools/shared/locales/en-US/accessibility.properties",
32 "devtools/client/locales/en-US/accessibility.properties",
33 "devtools/client/locales/en-US/inspector.properties"
35 const ARROW_KEYS = ["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"];
36 const [ArrowUp, ArrowRight, ArrowDown, ArrowLeft] = ARROW_KEYS;
37 const XHTML_NS = "http://www.w3.org/1999/xhtml";
52 * Spectrum creates a color picker widget in any container you give it.
54 * Simple usage example:
56 * const {Spectrum} = require("devtools/client/shared/widgets/Spectrum");
57 * let s = new Spectrum(containerElement, [255, 126, 255, 1]);
58 * s.on("changed", (rgba, color) => {
59 * console.log("rgba(" + rgba[0] + ", " + rgba[1] + ", " + rgba[2] + ", " +
65 * Note that the color picker is hidden by default and you need to call show to
66 * make it appear. This 2 stages initialization helps in cases you are creating
67 * the color picker in a parent element that hasn't been appended anywhere yet
68 * or that is hidden. Calling show() when the parent element is appended and
69 * visible will allow spectrum to correctly initialize its various parts.
71 * Fires the following events:
72 * - changed : When the user changes the current color
74 function Spectrum(parentEl, rgb) {
75 EventEmitter.decorate(this);
77 this.document = parentEl.ownerDocument;
78 this.element = parentEl.ownerDocument.createElementNS(XHTML_NS, "div");
79 this.parentEl = parentEl;
81 this.element.className = "spectrum-container";
82 // eslint-disable-next-line no-unsanitized/property
83 this.element.innerHTML = `
84 <section class="spectrum-color-picker">
85 <div class="spectrum-color spectrum-box"
88 title="${L10N.getStr("colorPickerTooltip.spectrumDraggerTitle")}"
89 aria-describedby="spectrum-dragger">
90 <div class="spectrum-sat">
91 <div class="spectrum-val">
92 <div class="spectrum-dragger" id="spectrum-dragger"></div>
97 <section class="spectrum-controls">
98 <div class="spectrum-color-preview"></div>
99 <div class="spectrum-slider-container">
100 <div class="spectrum-hue spectrum-box"></div>
101 <div class="spectrum-alpha spectrum-checker spectrum-box"></div>
104 <section class="spectrum-color-contrast accessibility-color-contrast">
105 <div class="contrast-ratio-header-and-single-ratio">
106 <span class="contrast-ratio-label" role="presentation"></span>
107 <span class="contrast-value-and-swatch contrast-ratio-single" role="presentation">
108 <span class="accessibility-contrast-value"></span>
111 <div class="contrast-ratio-range">
112 <span class="contrast-value-and-swatch contrast-ratio-min" role="presentation">
113 <span class="accessibility-contrast-value"></span>
115 <span class="accessibility-color-contrast-separator"></span>
116 <span class="contrast-value-and-swatch contrast-ratio-max" role="presentation">
117 <span class="accessibility-contrast-value"></span>
123 this.onElementClick = this.onElementClick.bind(this);
124 this.element.addEventListener("click", this.onElementClick);
126 this.parentEl.appendChild(this.element);
128 // Color spectrum dragger.
129 this.dragger = this.element.querySelector(".spectrum-color");
130 this.dragHelper = this.element.querySelector(".spectrum-dragger");
134 this.onDraggerMove.bind(this)
137 // Here we define the components for the "controls" section of the color picker.
138 this.controls = this.element.querySelector(".spectrum-controls");
139 this.colorPreview = this.element.querySelector(".spectrum-color-preview");
141 // Create the eyedropper.
142 const eyedropper = this.document.createElementNS(XHTML_NS, "button");
143 eyedropper.id = "eyedropper-button";
144 eyedropper.className = "devtools-button";
145 eyedropper.style.pointerEvents = "auto";
146 eyedropper.setAttribute(
148 L10N.getStr("colorPickerTooltip.eyedropperTitle")
150 this.controls.insertBefore(eyedropper, this.colorPreview);
152 // Hue slider and alpha slider
153 this.hueSlider = this.createSlider("hue", this.onHueSliderMove.bind(this));
154 this.hueSlider.setAttribute("aria-describedby", this.dragHelper.id);
155 this.alphaSlider = this.createSlider(
157 this.onAlphaSliderMove.bind(this)
161 this.spectrumContrast = this.element.querySelector(
162 ".spectrum-color-contrast"
164 this.contrastLabel = this.element.querySelector(".contrast-ratio-label");
167 this.contrastValueMin,
168 this.contrastValueMax,
169 ] = this.element.querySelectorAll(".accessibility-contrast-value");
171 // Create the learn more info button
172 const learnMore = this.document.createElementNS(XHTML_NS, "button");
173 learnMore.id = "learn-more-button";
174 learnMore.className = "learn-more";
175 learnMore.title = L10N.getStr("accessibility.learnMore");
177 .querySelector(".contrast-ratio-header-and-single-ratio")
178 .appendChild(learnMore);
186 module.exports.Spectrum = Spectrum;
188 Spectrum.hsvToRgb = function(h, s, v, a) {
191 const i = Math.floor(h * 6);
193 const p = v * (1 - s);
194 const q = v * (1 - f * s);
195 const t = v * (1 - (1 - f) * s);
230 return [r * 255, g * 255, b * 255, a];
233 Spectrum.rgbToHsv = function(r, g, b, a) {
238 const max = Math.max(r, g, b);
239 const min = Math.min(r, g, b);
243 const s = max == 0 ? 0 : d / max;
252 h = (g - b) / d + (g < b ? 6 : 0);
266 Spectrum.draggable = function(element, dragHelper, onmove) {
267 onmove = onmove || function() {};
269 const doc = element.ownerDocument;
270 let dragging = false;
275 function setDraggerDimensionsAndOffset() {
276 maxHeight = element.offsetHeight;
277 maxWidth = element.offsetWidth;
278 offset = element.getBoundingClientRect();
281 function prevent(e) {
288 if (e.buttons === 0) {
289 // The button is no longer pressed but we did not get a mouseup event.
293 const pageX = e.pageX;
294 const pageY = e.pageY;
296 const dragX = Math.max(0, Math.min(pageX - offset.left, maxWidth));
297 const dragY = Math.max(0, Math.min(pageY - offset.top, maxHeight));
299 onmove.apply(element, [dragX, dragY]);
304 const rightClick = e.which === 3;
306 if (!rightClick && !dragging) {
308 setDraggerDimensionsAndOffset();
312 doc.addEventListener("selectstart", prevent);
313 doc.addEventListener("dragstart", prevent);
314 doc.addEventListener("mousemove", move);
315 doc.addEventListener("mouseup", stop);
323 doc.removeEventListener("selectstart", prevent);
324 doc.removeEventListener("dragstart", prevent);
325 doc.removeEventListener("mousemove", move);
326 doc.removeEventListener("mouseup", stop);
331 function onKeydown(e) {
334 if (!ARROW_KEYS.includes(key)) {
338 setDraggerDimensionsAndOffset();
339 const { offsetHeight, offsetTop, offsetLeft } = dragHelper;
340 let dragX = offsetLeft + offsetHeight / 2;
341 let dragY = offsetTop + offsetHeight / 2;
343 if (key === ArrowLeft && dragX > 0) {
345 } else if (key === ArrowRight && dragX < maxWidth) {
347 } else if (key === ArrowUp && dragY > 0) {
349 } else if (key === ArrowDown && dragY < maxHeight) {
353 onmove.apply(element, [dragX, dragY]);
356 element.addEventListener("mousedown", start);
357 element.addEventListener("keydown", onKeydown);
361 * Calculates the contrast ratio for a DOM node's computed style against
362 * a given background.
364 * @param {Object} computedStyle
365 * The computed style for which we want to calculate the contrast ratio.
366 * @param {Object} backgroundColor
367 * Object with one or more of the following properties: value, min, max
369 * An object that may contain one or more of the following fields: error,
370 * isLargeText, value, score for contrast.
372 function getContrastRatio(computedStyle, backgroundColor) {
373 const props = getTextProperties(computedStyle);
381 return getContrastRatioAgainstBackground(backgroundColor, props);
384 Spectrum.prototype = {
385 set textProps(style) {
386 this._textProps = style
388 fontSize: style["font-size"].value,
389 fontWeight: style["font-weight"].value,
390 opacity: style.opacity.value,
396 this.hsv = Spectrum.rgbToHsv(color[0], color[1], color[2], color[3]);
399 set backgroundColorData(colorData) {
400 this._backgroundColorData = colorData;
403 get backgroundColorData() {
404 return this._backgroundColorData;
408 return this._textProps;
412 const rgb = Spectrum.hsvToRgb(
422 Math.round(rgb[3] * 100) / 100,
427 * Map current rgb to the closest color available in the database by
428 * calculating the delta-E between each available color and the current rgb
431 * Color name or closest color name
434 const labColorEntries = Object.entries(labColors);
436 const deltaEs = labColorEntries.map(color =>
437 colorUtils.calculateDeltaE(color[1], colorUtils.rgbToLab(this.rgb))
440 // Get the color name for the one that has the lowest delta-E
441 const minDeltaE = Math.min(...deltaEs);
442 const colorName = labColorEntries[deltaEs.indexOf(minDeltaE)][0];
443 return minDeltaE === 0
445 : L10N.getFormatStr("colorPickerTooltip.colorNameTitle", colorName);
449 const rgb = Spectrum.hsvToRgb(this.hsv[0], 1, 1);
450 return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2]), rgb[3]];
454 const rgb = this.rgb;
456 "rgba(" + rgb[0] + ", " + rgb[1] + ", " + rgb[2] + ", " + rgb[3] + ")"
461 this.dragWidth = this.dragger.offsetWidth;
462 this.dragHeight = this.dragger.offsetHeight;
463 this.dragHelperHeight = this.dragHelper.offsetHeight;
468 onElementClick: function(e) {
472 onHueSliderMove: function() {
473 this.hsv[0] = this.hueSlider.value / this.hueSlider.max;
478 onDraggerMove: function(dragX, dragY) {
479 this.hsv[1] = dragX / this.dragWidth;
480 this.hsv[2] = (this.dragHeight - dragY) / this.dragHeight;
485 onAlphaSliderMove: function() {
486 this.hsv[3] = this.alphaSlider.value / this.alphaSlider.max;
491 onChange: function() {
492 this.emit("changed", this.rgb, this.rgbCssString);
496 * Creates and initializes a slider element, attaches it to its parent container
497 * based on the slider type and returns it
499 * @param {String} sliderType
500 * The type of the slider (i.e. alpha or hue)
501 * @param {Function} onSliderMove
502 * The function to tie the slider to on input
504 * Newly created slider
506 createSlider: function(sliderType, onSliderMove) {
507 const container = this.element.querySelector(`.spectrum-${sliderType}`);
509 const slider = this.document.createElementNS(XHTML_NS, "input");
510 slider.className = `spectrum-${sliderType}-input`;
511 slider.type = "range";
512 slider.min = SLIDER[sliderType].MIN;
513 slider.max = SLIDER[sliderType].MAX;
514 slider.step = SLIDER[sliderType].STEP;
515 slider.title = L10N.getStr(`colorPickerTooltip.${sliderType}SliderTitle`);
516 slider.addEventListener("input", onSliderMove);
518 container.appendChild(slider);
523 * Updates the contrast label with appropriate content (i.e. large text indicator
524 * if the contrast is calculated for large text, or a base label otherwise)
526 * @param {Boolean} isLargeText
527 * True if contrast is calculated for large text.
529 updateContrastLabel: function(isLargeText) {
531 this.contrastLabel.textContent = L10N.getStr(
532 "accessibility.contrast.ratio.label"
537 // Clear previously appended children before appending any new children
538 while (this.contrastLabel.firstChild) {
539 this.contrastLabel.firstChild.remove();
542 const largeTextStr = L10N.getStr("accessibility.contrast.large.text");
543 const contrastLabelStr = L10N.getFormatStr(
544 "colorPickerTooltip.contrast.large.title",
548 // Build an array of children nodes for the contrast label element
549 const contents = contrastLabelStr
550 .split(new RegExp(largeTextStr), 2)
551 .map(content => this.document.createTextNode(content));
552 const largeTextIndicator = this.document.createElementNS(XHTML_NS, "span");
553 largeTextIndicator.className = "accessibility-color-contrast-large-text";
554 largeTextIndicator.textContent = largeTextStr;
555 largeTextIndicator.title = L10N.getStr(
556 "accessibility.contrast.large.title"
558 contents.splice(1, 0, largeTextIndicator);
560 // Append children to contrast label
561 for (const content of contents) {
562 this.contrastLabel.appendChild(content);
567 * Updates a contrast value element with the given score, value and swatches.
569 * @param {DOMNode} el
570 * Contrast value element to update.
571 * @param {String} score
572 * Contrast ratio score.
573 * @param {Number} value
574 * Contrast ratio value.
575 * @param {Array} backgroundColor
576 * RGBA color array for the background color to show in the swatch.
578 updateContrastValueEl: function(el, score, value, backgroundColor) {
579 el.classList.toggle(score, true);
580 el.textContent = value.toFixed(2);
581 el.title = L10N.getFormatStr(
582 `accessibility.contrast.annotation.${score}`,
584 "colorPickerTooltip.contrastAgainstBgTitle",
585 `rgba(${backgroundColor})`
588 el.parentElement.style.setProperty(
589 "--accessibility-contrast-color",
592 el.parentElement.style.setProperty(
593 "--accessibility-contrast-bg",
594 `rgba(${backgroundColor})`
598 updateAlphaSlider: function() {
599 // Set alpha slider background
600 const rgb = this.rgb;
602 const rgbNoAlpha = "rgb(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ")";
603 const rgbAlpha0 = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ", 0)";
604 const alphaGradient =
605 "linear-gradient(to right, " + rgbAlpha0 + ", " + rgbNoAlpha + ")";
606 this.alphaSlider.style.background = alphaGradient;
609 updateColorPreview: function() {
610 // Overlay the rgba color over a checkered image background.
611 this.colorPreview.style.setProperty("--overlay-color", this.rgbCssString);
613 // We should be able to distinguish the color preview on high luminance rgba values.
614 // Give the color preview a light grey border if the luminance of the current rgba
616 const colorLuminance = colorUtils.calculateLuminance(this.rgb);
617 this.colorPreview.classList.toggle("high-luminance", colorLuminance > 0.85);
619 // Set title on color preview for better UX
620 this.colorPreview.title = this.colorName;
623 updateDragger: function() {
624 // Set dragger background color
627 this.rgbNoSatVal[0] +
629 this.rgbNoSatVal[1] +
631 this.rgbNoSatVal[2] +
633 this.dragger.style.backgroundColor = flatColor;
635 // Set dragger aria attributes
636 this.dragger.setAttribute("aria-valuetext", this.rgbCssString);
639 updateHueSlider: function() {
640 // Set hue slider aria attributes
641 this.hueSlider.setAttribute("aria-valuetext", this.rgbCssString);
644 updateHelperLocations: function() {
645 const h = this.hsv[0];
646 const s = this.hsv[1];
647 const v = this.hsv[2];
649 // Placing the color dragger
650 let dragX = s * this.dragWidth;
651 let dragY = this.dragHeight - v * this.dragHeight;
652 const helperDim = this.dragHelperHeight / 2;
656 Math.min(this.dragWidth - helperDim, dragX - helperDim)
660 Math.min(this.dragHeight - helperDim, dragY - helperDim)
663 this.dragHelper.style.top = dragY + "px";
664 this.dragHelper.style.left = dragX + "px";
666 // Placing the hue slider
667 this.hueSlider.value = h * this.hueSlider.max;
669 // Placing the alpha slider
670 this.alphaSlider.value = this.hsv[3] * this.alphaSlider.max;
673 /* Calculates the contrast ratio for the currently selected
674 * color against a single or range of background colors and displays contrast ratio section
675 * components depending on the contrast ratio calculated.
677 * Contrast ratio components include:
678 * - contrastLargeTextIndicator: Hidden by default, shown when text has large font
679 * size if there is no error in calculation.
680 * - contrastValue(s): Set to calculated value(s), score(s) and text color on
681 * background swatches. Set to error text
682 * if there is an error in calculation.
684 updateContrast: function() {
685 // Remove additional classes on spectrum contrast, leaving behind only base classes
686 this.spectrumContrast.classList.toggle("visible", false);
687 this.spectrumContrast.classList.toggle("range", false);
688 this.spectrumContrast.classList.toggle("error", false);
689 // Assign only base class to all contrastValues, removing any score class
690 this.contrastValue.className = this.contrastValueMin.className = this.contrastValueMax.className =
691 "accessibility-contrast-value";
693 if (!this.contrastEnabled) {
697 const isRange = this.backgroundColorData.min !== undefined;
698 this.spectrumContrast.classList.toggle("visible", true);
699 this.spectrumContrast.classList.toggle("range", isRange);
701 const colorContrast = getContrastRatio(
704 color: this.rgbCssString,
706 this.backgroundColorData
724 this.updateContrastLabel(false);
725 this.spectrumContrast.classList.toggle("error", true);
727 // If current background color is a range, show the error text in the contrast range
728 // span. Otherwise, show it in the single contrast span.
729 const contrastValEl = isRange
730 ? this.contrastValueMin
731 : this.contrastValue;
732 contrastValEl.textContent = L10N.getStr("accessibility.contrast.error");
733 contrastValEl.title = L10N.getStr(
734 "accessibility.contrast.annotation.transparent.error"
740 this.updateContrastLabel(isLargeText);
742 this.updateContrastValueEl(
752 this.updateContrastValueEl(
753 this.contrastValueMin,
758 this.updateContrastValueEl(
759 this.contrastValueMax,
766 updateUI: function() {
767 this.updateHelperLocations();
769 this.updateColorPreview();
770 this.updateDragger();
771 this.updateHueSlider();
772 this.updateAlphaSlider();
773 this.updateContrast();
776 destroy: function() {
777 this.element.removeEventListener("click", this.onElementClick);
778 this.hueSlider.removeEventListener("input", this.onHueSliderMove);
779 this.alphaSlider.removeEventListener("input", this.onAlphaSliderMove);
781 this.parentEl.removeChild(this.element);
783 this.dragger = this.dragHelper = null;
784 this.alphaSlider = null;
785 this.hueSlider = null;
786 this.colorPreview = null;
788 this.parentEl = null;
789 this.spectrumContrast = null;
790 this.contrastValue = this.contrastValueMin = this.contrastValueMax = null;
791 this.contrastLabel = null;