Spectrum.js (11671B)
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/. */ 4 5 "use strict"; 6 7 const { ColorPickerCommon } = ChromeUtils.importESModule( 8 "chrome://global/content/bindings/colorpicker-common.mjs" 9 ); 10 11 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 12 const { 13 MultiLocalizationHelper, 14 } = require("resource://devtools/shared/l10n.js"); 15 16 loader.lazyRequireGetter( 17 this, 18 ["getTextProperties", "getContrastRatioAgainstBackground"], 19 "resource://devtools/shared/accessibility.js", 20 true 21 ); 22 loader.lazyGetter(this, "ColorPickerBundle", () => { 23 return new Localization(["devtools/client/inspector.ftl"], true); 24 }); 25 26 const L10N = new MultiLocalizationHelper( 27 "devtools/client/locales/accessibility.properties", 28 "devtools/client/locales/inspector.properties" 29 ); 30 const XHTML_NS = "http://www.w3.org/1999/xhtml"; 31 32 /** 33 * Spectrum creates a color picker widget in any container you give it. 34 * 35 * Simple usage example: 36 * 37 * const {Spectrum} = require("devtools/client/shared/widgets/Spectrum"); 38 * let s = new Spectrum(containerElement, [255, 126, 255, 1]); 39 * s.on("changed", (rgba, color) => { 40 * console.log("rgba(" + rgba[0] + ", " + rgba[1] + ", " + rgba[2] + ", " + 41 * rgba[3] + ")"); 42 * }); 43 * s.show(); 44 * s.destroy(); 45 * 46 * Note that the color picker is hidden by default and you need to call show to 47 * make it appear. This 2 stages initialization helps in cases you are creating 48 * the color picker in a parent element that hasn't been appended anywhere yet 49 * or that is hidden. Calling show() when the parent element is appended and 50 * visible will allow spectrum to correctly initialize its various parts. 51 * 52 * Fires the following events: 53 * - changed : When the user changes the current color 54 */ 55 class Spectrum extends ColorPickerCommon { 56 constructor(parentEl, rgb) { 57 const element = parentEl.ownerDocument.createElement("div"); 58 // eslint-disable-next-line no-unsanitized/property 59 element.innerHTML = ` 60 <section class="spectrum-color-picker"> 61 <div class="spectrum-color spectrum-box" 62 tabindex="0" 63 role="slider" 64 aria-describedby="spectrum-dragger"> 65 <div class="spectrum-sat"> 66 <div class="spectrum-val"> 67 <div class="spectrum-dragger" id="spectrum-dragger"></div> 68 </div> 69 </div> 70 </div> 71 </section> 72 <section class="spectrum-controls"> 73 <div class="spectrum-color-preview"></div> 74 <div class="spectrum-slider-container"> 75 <div class="spectrum-hue spectrum-box"></div> 76 <div class="spectrum-alpha spectrum-checker spectrum-box"></div> 77 </div> 78 </section> 79 <section class="spectrum-color-contrast accessibility-color-contrast"> 80 <div class="contrast-ratio-header-and-single-ratio"> 81 <span class="contrast-ratio-label" role="presentation"></span> 82 <span class="contrast-value-and-swatch contrast-ratio-single" role="presentation"> 83 <span class="accessibility-contrast-value"></span> 84 </span> 85 </div> 86 <div class="contrast-ratio-range"> 87 <span class="contrast-value-and-swatch contrast-ratio-min" role="presentation"> 88 <span class="accessibility-contrast-value"></span> 89 </span> 90 <span class="accessibility-color-contrast-separator"></span> 91 <span class="contrast-value-and-swatch contrast-ratio-max" role="presentation"> 92 <span class="accessibility-contrast-value"></span> 93 </span> 94 </div> 95 </section> 96 `; 97 super(element); 98 EventEmitter.decorate(this); 99 100 parentEl.appendChild(this.element); 101 102 // Create the eyedropper. 103 const eyedropper = this.document.createElementNS(XHTML_NS, "button"); 104 eyedropper.id = "eyedropper-button"; 105 eyedropper.className = "devtools-button"; 106 eyedropper.style.pointerEvents = "auto"; 107 eyedropper.setAttribute( 108 "aria-label", 109 ColorPickerBundle.formatValueSync("colorpicker-tooltip-eyedropper-title") 110 ); 111 this.controls.insertBefore(eyedropper, this.colorPreview); 112 113 // Color contrast 114 this.spectrumContrast = this.element.querySelector( 115 ".spectrum-color-contrast" 116 ); 117 this.contrastLabel = this.element.querySelector(".contrast-ratio-label"); 118 [this.contrastValue, this.contrastValueMin, this.contrastValueMax] = 119 this.element.querySelectorAll(".accessibility-contrast-value"); 120 121 // Create the learn more info button 122 const learnMore = this.document.createElementNS(XHTML_NS, "button"); 123 learnMore.id = "learn-more-button"; 124 learnMore.className = "learn-more"; 125 learnMore.title = L10N.getStr("accessibility.learnMore"); 126 this.element 127 .querySelector(".contrast-ratio-header-and-single-ratio") 128 .appendChild(learnMore); 129 130 if (rgb) { 131 this.rgb = rgb; 132 this.updateUI(); 133 } 134 } 135 136 set textProps(style) { 137 this._textProps = style 138 ? { 139 fontSize: style["font-size"].value, 140 fontWeight: style["font-weight"].value, 141 opacity: style.opacity.value, 142 } 143 : null; 144 } 145 146 set backgroundColorData(colorData) { 147 this._backgroundColorData = colorData; 148 } 149 150 get backgroundColorData() { 151 return this._backgroundColorData; 152 } 153 154 get textProps() { 155 return this._textProps; 156 } 157 158 onChange() { 159 this.emit("changed", this.rgb, this.rgbCssString); 160 } 161 162 /** 163 * Updates the contrast label with appropriate content (i.e. large text indicator 164 * if the contrast is calculated for large text, or a base label otherwise) 165 * 166 * @param {boolean} isLargeText 167 * True if contrast is calculated for large text. 168 */ 169 updateContrastLabel(isLargeText) { 170 if (!isLargeText) { 171 this.contrastLabel.textContent = L10N.getStr( 172 "accessibility.contrast.ratio.label" 173 ); 174 return; 175 } 176 177 const largeTextStr = L10N.getStr("accessibility.contrast.large.text"); 178 const contrastLabelStr = L10N.getFormatStr( 179 "colorPickerTooltip.contrast.large.title", 180 largeTextStr 181 ); 182 183 // Build an array of children nodes for the contrast label element 184 const contents = contrastLabelStr 185 .split(new RegExp(largeTextStr), 2) 186 .map(content => this.document.createTextNode(content)); 187 const largeTextIndicator = this.document.createElementNS(XHTML_NS, "span"); 188 largeTextIndicator.className = "accessibility-color-contrast-large-text"; 189 largeTextIndicator.textContent = largeTextStr; 190 largeTextIndicator.title = L10N.getStr( 191 "accessibility.contrast.large.title" 192 ); 193 contents.splice(1, 0, largeTextIndicator); 194 195 // Update contrast label 196 this.contrastLabel.replaceChildren(...contents); 197 } 198 199 /** 200 * Updates a contrast value element with the given score, value and swatches. 201 * 202 * @param {DOMNode} el 203 * Contrast value element to update. 204 * @param {string} score 205 * Contrast ratio score. 206 * @param {number} value 207 * Contrast ratio value. 208 * @param {Array} backgroundColor 209 * RGBA color array for the background color to show in the swatch. 210 */ 211 updateContrastValueEl(el, score, value, backgroundColor) { 212 el.classList.toggle(score, true); 213 el.textContent = value.toFixed(2); 214 el.title = L10N.getFormatStr( 215 `accessibility.contrast.annotation.${score}`, 216 L10N.getFormatStr( 217 "colorPickerTooltip.contrastAgainstBgTitle", 218 `rgba(${backgroundColor})` 219 ) 220 ); 221 el.parentElement.style.setProperty( 222 "--accessibility-contrast-color", 223 this.rgbCssString 224 ); 225 el.parentElement.style.setProperty( 226 "--accessibility-contrast-bg", 227 `rgba(${backgroundColor})` 228 ); 229 } 230 231 /* Calculates the contrast ratio for the currently selected 232 * color against a single or range of background colors and displays contrast ratio section 233 * components depending on the contrast ratio calculated. 234 * 235 * Contrast ratio components include: 236 * - contrastLargeTextIndicator: Hidden by default, shown when text has large font 237 * size if there is no error in calculation. 238 * - contrastValue(s): Set to calculated value(s), score(s) and text color on 239 * background swatches. Set to error text 240 * if there is an error in calculation. 241 */ 242 updateContrast() { 243 // Remove additional classes on spectrum contrast, leaving behind only base classes 244 this.spectrumContrast.classList.toggle("visible", false); 245 this.spectrumContrast.classList.toggle("range", false); 246 this.spectrumContrast.classList.toggle("error", false); 247 // Assign only base class to all contrastValues, removing any score class 248 this.contrastValue.className = 249 this.contrastValueMin.className = 250 this.contrastValueMax.className = 251 "accessibility-contrast-value"; 252 253 if (!this.contrastEnabled) { 254 return; 255 } 256 257 const isRange = this.backgroundColorData.min !== undefined; 258 this.spectrumContrast.classList.toggle("visible", true); 259 this.spectrumContrast.classList.toggle("range", isRange); 260 261 const colorContrast = getContrastRatio( 262 { 263 ...this.textProps, 264 color: this.rgbCssString, 265 }, 266 this.backgroundColorData 267 ); 268 269 const { 270 value, 271 min, 272 max, 273 score, 274 scoreMin, 275 scoreMax, 276 backgroundColor, 277 backgroundColorMin, 278 backgroundColorMax, 279 isLargeText, 280 error, 281 } = colorContrast; 282 283 if (error) { 284 this.updateContrastLabel(false); 285 this.spectrumContrast.classList.toggle("error", true); 286 287 // If current background color is a range, show the error text in the contrast range 288 // span. Otherwise, show it in the single contrast span. 289 const contrastValEl = isRange 290 ? this.contrastValueMin 291 : this.contrastValue; 292 contrastValEl.textContent = L10N.getStr("accessibility.contrast.error"); 293 contrastValEl.title = L10N.getStr( 294 "accessibility.contrast.annotation.transparent.error" 295 ); 296 297 return; 298 } 299 300 this.updateContrastLabel(isLargeText); 301 if (!isRange) { 302 this.updateContrastValueEl( 303 this.contrastValue, 304 score, 305 value, 306 backgroundColor 307 ); 308 309 return; 310 } 311 312 this.updateContrastValueEl( 313 this.contrastValueMin, 314 scoreMin, 315 min, 316 backgroundColorMin 317 ); 318 this.updateContrastValueEl( 319 this.contrastValueMax, 320 scoreMax, 321 max, 322 backgroundColorMax 323 ); 324 } 325 326 updateUI() { 327 super.updateUI(); 328 this.updateContrast(); 329 } 330 331 destroy() { 332 super.destroy(); 333 this.spectrumContrast = null; 334 this.contrastValue = this.contrastValueMin = this.contrastValueMax = null; 335 this.contrastLabel = null; 336 } 337 } 338 339 /** 340 * Calculates the contrast ratio for a DOM node's computed style against 341 * a given background. 342 * 343 * @param {object} computedStyle 344 * The computed style for which we want to calculate the contrast ratio. 345 * @param {object} backgroundColor 346 * Object with one or more of the following properties: value, min, max 347 * @return {object} 348 * An object that may contain one or more of the following fields: error, 349 * isLargeText, value, score for contrast. 350 */ 351 function getContrastRatio(computedStyle, backgroundColor) { 352 const props = getTextProperties(computedStyle); 353 354 if (!props) { 355 return { 356 error: true, 357 }; 358 } 359 360 return getContrastRatioAgainstBackground(backgroundColor, props); 361 } 362 363 module.exports = Spectrum;