summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/widgets/Spectrum.js
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:44:51 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:44:51 +0000
commit9e3c08db40b8916968b9f30096c7be3f00ce9647 (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/client/shared/widgets/Spectrum.js
parentInitial commit. (diff)
downloadthunderbird-9e3c08db40b8916968b9f30096c7be3f00ce9647.tar.xz
thunderbird-9e3c08db40b8916968b9f30096c7be3f00ce9647.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/shared/widgets/Spectrum.js')
-rw-r--r--devtools/client/shared/widgets/Spectrum.js783
1 files changed, 783 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/Spectrum.js b/devtools/client/shared/widgets/Spectrum.js
new file mode 100644
index 0000000000..cdf5f2df6b
--- /dev/null
+++ b/devtools/client/shared/widgets/Spectrum.js
@@ -0,0 +1,783 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const {
+ MultiLocalizationHelper,
+} = require("resource://devtools/shared/l10n.js");
+
+loader.lazyRequireGetter(
+ this,
+ "colorUtils",
+ "resource://devtools/shared/css/color.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "labColors",
+ "resource://devtools/shared/css/color-db.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["getTextProperties", "getContrastRatioAgainstBackground"],
+ "resource://devtools/shared/accessibility.js",
+ true
+);
+
+const L10N = new MultiLocalizationHelper(
+ "devtools/client/locales/accessibility.properties",
+ "devtools/client/locales/inspector.properties"
+);
+const ARROW_KEYS = ["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"];
+const [ArrowUp, ArrowRight, ArrowDown, ArrowLeft] = ARROW_KEYS;
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const SLIDER = {
+ hue: {
+ MIN: "0",
+ MAX: "128",
+ STEP: "1",
+ },
+ alpha: {
+ MIN: "0",
+ MAX: "1",
+ STEP: "0.01",
+ },
+};
+
+/**
+ * Spectrum creates a color picker widget in any container you give it.
+ *
+ * Simple usage example:
+ *
+ * const {Spectrum} = require("devtools/client/shared/widgets/Spectrum");
+ * let s = new Spectrum(containerElement, [255, 126, 255, 1]);
+ * s.on("changed", (rgba, color) => {
+ * console.log("rgba(" + rgba[0] + ", " + rgba[1] + ", " + rgba[2] + ", " +
+ * rgba[3] + ")");
+ * });
+ * s.show();
+ * s.destroy();
+ *
+ * Note that the color picker is hidden by default and you need to call show to
+ * make it appear. This 2 stages initialization helps in cases you are creating
+ * the color picker in a parent element that hasn't been appended anywhere yet
+ * or that is hidden. Calling show() when the parent element is appended and
+ * visible will allow spectrum to correctly initialize its various parts.
+ *
+ * Fires the following events:
+ * - changed : When the user changes the current color
+ */
+class Spectrum {
+ constructor(parentEl, rgb) {
+ EventEmitter.decorate(this);
+
+ this.document = parentEl.ownerDocument;
+ this.element = parentEl.ownerDocument.createElementNS(XHTML_NS, "div");
+ this.parentEl = parentEl;
+
+ this.element.className = "spectrum-container";
+ // eslint-disable-next-line no-unsanitized/property
+ this.element.innerHTML = `
+ <section class="spectrum-color-picker">
+ <div class="spectrum-color spectrum-box"
+ tabindex="0"
+ role="slider"
+ title="${L10N.getStr("colorPickerTooltip.spectrumDraggerTitle")}"
+ aria-describedby="spectrum-dragger">
+ <div class="spectrum-sat">
+ <div class="spectrum-val">
+ <div class="spectrum-dragger" id="spectrum-dragger"></div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <section class="spectrum-controls">
+ <div class="spectrum-color-preview"></div>
+ <div class="spectrum-slider-container">
+ <div class="spectrum-hue spectrum-box"></div>
+ <div class="spectrum-alpha spectrum-checker spectrum-box"></div>
+ </div>
+ </section>
+ <section class="spectrum-color-contrast accessibility-color-contrast">
+ <div class="contrast-ratio-header-and-single-ratio">
+ <span class="contrast-ratio-label" role="presentation"></span>
+ <span class="contrast-value-and-swatch contrast-ratio-single" role="presentation">
+ <span class="accessibility-contrast-value"></span>
+ </span>
+ </div>
+ <div class="contrast-ratio-range">
+ <span class="contrast-value-and-swatch contrast-ratio-min" role="presentation">
+ <span class="accessibility-contrast-value"></span>
+ </span>
+ <span class="accessibility-color-contrast-separator"></span>
+ <span class="contrast-value-and-swatch contrast-ratio-max" role="presentation">
+ <span class="accessibility-contrast-value"></span>
+ </span>
+ </div>
+ </section>
+ `;
+
+ this.onElementClick = this.onElementClick.bind(this);
+ this.element.addEventListener("click", this.onElementClick);
+
+ this.parentEl.appendChild(this.element);
+
+ // Color spectrum dragger.
+ this.dragger = this.element.querySelector(".spectrum-color");
+ this.dragHelper = this.element.querySelector(".spectrum-dragger");
+ draggable(this.dragger, this.dragHelper, this.onDraggerMove.bind(this));
+
+ // Here we define the components for the "controls" section of the color picker.
+ this.controls = this.element.querySelector(".spectrum-controls");
+ this.colorPreview = this.element.querySelector(".spectrum-color-preview");
+
+ // Create the eyedropper.
+ const eyedropper = this.document.createElementNS(XHTML_NS, "button");
+ eyedropper.id = "eyedropper-button";
+ eyedropper.className = "devtools-button";
+ eyedropper.style.pointerEvents = "auto";
+ eyedropper.setAttribute(
+ "aria-label",
+ L10N.getStr("colorPickerTooltip.eyedropperTitle")
+ );
+ this.controls.insertBefore(eyedropper, this.colorPreview);
+
+ // Hue slider and alpha slider
+ this.hueSlider = this.createSlider("hue", this.onHueSliderMove.bind(this));
+ this.hueSlider.setAttribute("aria-describedby", this.dragHelper.id);
+ this.alphaSlider = this.createSlider(
+ "alpha",
+ this.onAlphaSliderMove.bind(this)
+ );
+
+ // Color contrast
+ this.spectrumContrast = this.element.querySelector(
+ ".spectrum-color-contrast"
+ );
+ this.contrastLabel = this.element.querySelector(".contrast-ratio-label");
+ [this.contrastValue, this.contrastValueMin, this.contrastValueMax] =
+ this.element.querySelectorAll(".accessibility-contrast-value");
+
+ // Create the learn more info button
+ const learnMore = this.document.createElementNS(XHTML_NS, "button");
+ learnMore.id = "learn-more-button";
+ learnMore.className = "learn-more";
+ learnMore.title = L10N.getStr("accessibility.learnMore");
+ this.element
+ .querySelector(".contrast-ratio-header-and-single-ratio")
+ .appendChild(learnMore);
+
+ if (rgb) {
+ this.rgb = rgb;
+ this.updateUI();
+ }
+ }
+
+ set textProps(style) {
+ this._textProps = style
+ ? {
+ fontSize: style["font-size"].value,
+ fontWeight: style["font-weight"].value,
+ opacity: style.opacity.value,
+ }
+ : null;
+ }
+
+ set rgb(color) {
+ this.hsv = rgbToHsv(color[0], color[1], color[2], color[3]);
+ }
+
+ set backgroundColorData(colorData) {
+ this._backgroundColorData = colorData;
+ }
+
+ get backgroundColorData() {
+ return this._backgroundColorData;
+ }
+
+ get textProps() {
+ return this._textProps;
+ }
+
+ get rgb() {
+ const rgb = hsvToRgb(this.hsv[0], this.hsv[1], this.hsv[2], this.hsv[3]);
+ return [
+ Math.round(rgb[0]),
+ Math.round(rgb[1]),
+ Math.round(rgb[2]),
+ Math.round(rgb[3] * 100) / 100,
+ ];
+ }
+
+ /**
+ * Map current rgb to the closest color available in the database by
+ * calculating the delta-E between each available color and the current rgb
+ *
+ * @return {String}
+ * Color name or closest color name
+ */
+ get colorName() {
+ const labColorEntries = Object.entries(labColors);
+
+ const deltaEs = labColorEntries.map(color =>
+ colorUtils.calculateDeltaE(color[1], colorUtils.rgbToLab(this.rgb))
+ );
+
+ // Get the color name for the one that has the lowest delta-E
+ const minDeltaE = Math.min(...deltaEs);
+ const colorName = labColorEntries[deltaEs.indexOf(minDeltaE)][0];
+ return minDeltaE === 0
+ ? colorName
+ : L10N.getFormatStr("colorPickerTooltip.colorNameTitle", colorName);
+ }
+
+ get rgbNoSatVal() {
+ const rgb = hsvToRgb(this.hsv[0], 1, 1);
+ return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2]), rgb[3]];
+ }
+
+ get rgbCssString() {
+ const rgb = this.rgb;
+ return (
+ "rgba(" + rgb[0] + ", " + rgb[1] + ", " + rgb[2] + ", " + rgb[3] + ")"
+ );
+ }
+
+ show() {
+ this.dragWidth = this.dragger.offsetWidth;
+ this.dragHeight = this.dragger.offsetHeight;
+ this.dragHelperHeight = this.dragHelper.offsetHeight;
+
+ this.updateUI();
+ }
+
+ onElementClick(e) {
+ e.stopPropagation();
+ }
+
+ onHueSliderMove() {
+ this.hsv[0] = this.hueSlider.value / this.hueSlider.max;
+ this.updateUI();
+ this.onChange();
+ }
+
+ onDraggerMove(dragX, dragY) {
+ this.hsv[1] = dragX / this.dragWidth;
+ this.hsv[2] = (this.dragHeight - dragY) / this.dragHeight;
+ this.updateUI();
+ this.onChange();
+ }
+
+ onAlphaSliderMove() {
+ this.hsv[3] = this.alphaSlider.value / this.alphaSlider.max;
+ this.updateUI();
+ this.onChange();
+ }
+
+ onChange() {
+ this.emit("changed", this.rgb, this.rgbCssString);
+ }
+
+ /**
+ * Creates and initializes a slider element, attaches it to its parent container
+ * based on the slider type and returns it
+ *
+ * @param {String} sliderType
+ * The type of the slider (i.e. alpha or hue)
+ * @param {Function} onSliderMove
+ * The function to tie the slider to on input
+ * @return {DOMNode}
+ * Newly created slider
+ */
+ createSlider(sliderType, onSliderMove) {
+ const container = this.element.querySelector(`.spectrum-${sliderType}`);
+
+ const slider = this.document.createElementNS(XHTML_NS, "input");
+ slider.className = `spectrum-${sliderType}-input`;
+ slider.type = "range";
+ slider.min = SLIDER[sliderType].MIN;
+ slider.max = SLIDER[sliderType].MAX;
+ slider.step = SLIDER[sliderType].STEP;
+ slider.title = L10N.getStr(`colorPickerTooltip.${sliderType}SliderTitle`);
+ slider.addEventListener("input", onSliderMove);
+
+ container.appendChild(slider);
+ return slider;
+ }
+
+ /**
+ * Updates the contrast label with appropriate content (i.e. large text indicator
+ * if the contrast is calculated for large text, or a base label otherwise)
+ *
+ * @param {Boolean} isLargeText
+ * True if contrast is calculated for large text.
+ */
+ updateContrastLabel(isLargeText) {
+ if (!isLargeText) {
+ this.contrastLabel.textContent = L10N.getStr(
+ "accessibility.contrast.ratio.label"
+ );
+ return;
+ }
+
+ // Clear previously appended children before appending any new children
+ while (this.contrastLabel.firstChild) {
+ this.contrastLabel.firstChild.remove();
+ }
+
+ const largeTextStr = L10N.getStr("accessibility.contrast.large.text");
+ const contrastLabelStr = L10N.getFormatStr(
+ "colorPickerTooltip.contrast.large.title",
+ largeTextStr
+ );
+
+ // Build an array of children nodes for the contrast label element
+ const contents = contrastLabelStr
+ .split(new RegExp(largeTextStr), 2)
+ .map(content => this.document.createTextNode(content));
+ const largeTextIndicator = this.document.createElementNS(XHTML_NS, "span");
+ largeTextIndicator.className = "accessibility-color-contrast-large-text";
+ largeTextIndicator.textContent = largeTextStr;
+ largeTextIndicator.title = L10N.getStr(
+ "accessibility.contrast.large.title"
+ );
+ contents.splice(1, 0, largeTextIndicator);
+
+ // Append children to contrast label
+ for (const content of contents) {
+ this.contrastLabel.appendChild(content);
+ }
+ }
+
+ /**
+ * Updates a contrast value element with the given score, value and swatches.
+ *
+ * @param {DOMNode} el
+ * Contrast value element to update.
+ * @param {String} score
+ * Contrast ratio score.
+ * @param {Number} value
+ * Contrast ratio value.
+ * @param {Array} backgroundColor
+ * RGBA color array for the background color to show in the swatch.
+ */
+ updateContrastValueEl(el, score, value, backgroundColor) {
+ el.classList.toggle(score, true);
+ el.textContent = value.toFixed(2);
+ el.title = L10N.getFormatStr(
+ `accessibility.contrast.annotation.${score}`,
+ L10N.getFormatStr(
+ "colorPickerTooltip.contrastAgainstBgTitle",
+ `rgba(${backgroundColor})`
+ )
+ );
+ el.parentElement.style.setProperty(
+ "--accessibility-contrast-color",
+ this.rgbCssString
+ );
+ el.parentElement.style.setProperty(
+ "--accessibility-contrast-bg",
+ `rgba(${backgroundColor})`
+ );
+ }
+
+ updateAlphaSlider() {
+ // Set alpha slider background
+ const rgb = this.rgb;
+
+ const rgbNoAlpha = "rgb(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ")";
+ const rgbAlpha0 = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ", 0)";
+ const alphaGradient =
+ "linear-gradient(to right, " + rgbAlpha0 + ", " + rgbNoAlpha + ")";
+ this.alphaSlider.style.background = alphaGradient;
+ }
+
+ updateColorPreview() {
+ // Overlay the rgba color over a checkered image background.
+ this.colorPreview.style.setProperty("--overlay-color", this.rgbCssString);
+
+ // We should be able to distinguish the color preview on high luminance rgba values.
+ // Give the color preview a light grey border if the luminance of the current rgba
+ // tuple is great.
+ const colorLuminance = colorUtils.calculateLuminance(this.rgb);
+ this.colorPreview.classList.toggle("high-luminance", colorLuminance > 0.85);
+
+ // Set title on color preview for better UX
+ this.colorPreview.title = this.colorName;
+ }
+
+ updateDragger() {
+ // Set dragger background color
+ const flatColor =
+ "rgb(" +
+ this.rgbNoSatVal[0] +
+ ", " +
+ this.rgbNoSatVal[1] +
+ ", " +
+ this.rgbNoSatVal[2] +
+ ")";
+ this.dragger.style.backgroundColor = flatColor;
+
+ // Set dragger aria attributes
+ this.dragger.setAttribute("aria-valuetext", this.rgbCssString);
+ }
+
+ updateHueSlider() {
+ // Set hue slider aria attributes
+ this.hueSlider.setAttribute("aria-valuetext", this.rgbCssString);
+ }
+
+ updateHelperLocations() {
+ const h = this.hsv[0];
+ const s = this.hsv[1];
+ const v = this.hsv[2];
+
+ // Placing the color dragger
+ let dragX = s * this.dragWidth;
+ let dragY = this.dragHeight - v * this.dragHeight;
+ const helperDim = this.dragHelperHeight / 2;
+
+ dragX = Math.max(
+ -helperDim,
+ Math.min(this.dragWidth - helperDim, dragX - helperDim)
+ );
+ dragY = Math.max(
+ -helperDim,
+ Math.min(this.dragHeight - helperDim, dragY - helperDim)
+ );
+
+ this.dragHelper.style.top = dragY + "px";
+ this.dragHelper.style.left = dragX + "px";
+
+ // Placing the hue slider
+ this.hueSlider.value = h * this.hueSlider.max;
+
+ // Placing the alpha slider
+ this.alphaSlider.value = this.hsv[3] * this.alphaSlider.max;
+ }
+
+ /* Calculates the contrast ratio for the currently selected
+ * color against a single or range of background colors and displays contrast ratio section
+ * components depending on the contrast ratio calculated.
+ *
+ * Contrast ratio components include:
+ * - contrastLargeTextIndicator: Hidden by default, shown when text has large font
+ * size if there is no error in calculation.
+ * - contrastValue(s): Set to calculated value(s), score(s) and text color on
+ * background swatches. Set to error text
+ * if there is an error in calculation.
+ */
+ updateContrast() {
+ // Remove additional classes on spectrum contrast, leaving behind only base classes
+ this.spectrumContrast.classList.toggle("visible", false);
+ this.spectrumContrast.classList.toggle("range", false);
+ this.spectrumContrast.classList.toggle("error", false);
+ // Assign only base class to all contrastValues, removing any score class
+ this.contrastValue.className =
+ this.contrastValueMin.className =
+ this.contrastValueMax.className =
+ "accessibility-contrast-value";
+
+ if (!this.contrastEnabled) {
+ return;
+ }
+
+ const isRange = this.backgroundColorData.min !== undefined;
+ this.spectrumContrast.classList.toggle("visible", true);
+ this.spectrumContrast.classList.toggle("range", isRange);
+
+ const colorContrast = getContrastRatio(
+ {
+ ...this.textProps,
+ color: this.rgbCssString,
+ },
+ this.backgroundColorData
+ );
+
+ const {
+ value,
+ min,
+ max,
+ score,
+ scoreMin,
+ scoreMax,
+ backgroundColor,
+ backgroundColorMin,
+ backgroundColorMax,
+ isLargeText,
+ error,
+ } = colorContrast;
+
+ if (error) {
+ this.updateContrastLabel(false);
+ this.spectrumContrast.classList.toggle("error", true);
+
+ // If current background color is a range, show the error text in the contrast range
+ // span. Otherwise, show it in the single contrast span.
+ const contrastValEl = isRange
+ ? this.contrastValueMin
+ : this.contrastValue;
+ contrastValEl.textContent = L10N.getStr("accessibility.contrast.error");
+ contrastValEl.title = L10N.getStr(
+ "accessibility.contrast.annotation.transparent.error"
+ );
+
+ return;
+ }
+
+ this.updateContrastLabel(isLargeText);
+ if (!isRange) {
+ this.updateContrastValueEl(
+ this.contrastValue,
+ score,
+ value,
+ backgroundColor
+ );
+
+ return;
+ }
+
+ this.updateContrastValueEl(
+ this.contrastValueMin,
+ scoreMin,
+ min,
+ backgroundColorMin
+ );
+ this.updateContrastValueEl(
+ this.contrastValueMax,
+ scoreMax,
+ max,
+ backgroundColorMax
+ );
+ }
+
+ updateUI() {
+ this.updateHelperLocations();
+
+ this.updateColorPreview();
+ this.updateDragger();
+ this.updateHueSlider();
+ this.updateAlphaSlider();
+ this.updateContrast();
+ }
+
+ destroy() {
+ this.element.removeEventListener("click", this.onElementClick);
+ this.hueSlider.removeEventListener("input", this.onHueSliderMove);
+ this.alphaSlider.removeEventListener("input", this.onAlphaSliderMove);
+
+ this.parentEl.removeChild(this.element);
+
+ this.dragger = this.dragHelper = null;
+ this.alphaSlider = null;
+ this.hueSlider = null;
+ this.colorPreview = null;
+ this.element = null;
+ this.parentEl = null;
+ this.spectrumContrast = null;
+ this.contrastValue = this.contrastValueMin = this.contrastValueMax = null;
+ this.contrastLabel = null;
+ }
+}
+
+function hsvToRgb(h, s, v, a) {
+ let r, g, b;
+
+ const i = Math.floor(h * 6);
+ const f = h * 6 - i;
+ const p = v * (1 - s);
+ const q = v * (1 - f * s);
+ const t = v * (1 - (1 - f) * s);
+
+ switch (i % 6) {
+ case 0:
+ r = v;
+ g = t;
+ b = p;
+ break;
+ case 1:
+ r = q;
+ g = v;
+ b = p;
+ break;
+ case 2:
+ r = p;
+ g = v;
+ b = t;
+ break;
+ case 3:
+ r = p;
+ g = q;
+ b = v;
+ break;
+ case 4:
+ r = t;
+ g = p;
+ b = v;
+ break;
+ case 5:
+ r = v;
+ g = p;
+ b = q;
+ break;
+ }
+
+ return [r * 255, g * 255, b * 255, a];
+}
+
+function rgbToHsv(r, g, b, a) {
+ r = r / 255;
+ g = g / 255;
+ b = b / 255;
+
+ const max = Math.max(r, g, b);
+ const min = Math.min(r, g, b);
+
+ const v = max;
+ const d = max - min;
+ const s = max == 0 ? 0 : d / max;
+
+ let h;
+ if (max == min) {
+ // achromatic
+ h = 0;
+ } else {
+ switch (max) {
+ case r:
+ h = (g - b) / d + (g < b ? 6 : 0);
+ break;
+ case g:
+ h = (b - r) / d + 2;
+ break;
+ case b:
+ h = (r - g) / d + 4;
+ break;
+ }
+ h /= 6;
+ }
+ return [h, s, v, a];
+}
+
+function draggable(element, dragHelper, onmove) {
+ onmove = onmove || function () {};
+
+ const doc = element.ownerDocument;
+ let dragging = false;
+ let offset = {};
+ let maxHeight = 0;
+ let maxWidth = 0;
+
+ function setDraggerDimensionsAndOffset() {
+ maxHeight = element.offsetHeight;
+ maxWidth = element.offsetWidth;
+ offset = element.getBoundingClientRect();
+ }
+
+ function prevent(e) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+
+ function move(e) {
+ if (dragging) {
+ if (e.buttons === 0) {
+ // The button is no longer pressed but we did not get a mouseup event.
+ stop();
+ return;
+ }
+ const pageX = e.pageX;
+ const pageY = e.pageY;
+
+ const dragX = Math.max(0, Math.min(pageX - offset.left, maxWidth));
+ const dragY = Math.max(0, Math.min(pageY - offset.top, maxHeight));
+
+ onmove.apply(element, [dragX, dragY]);
+ }
+ }
+
+ function start(e) {
+ const rightClick = e.which === 3;
+
+ if (!rightClick && !dragging) {
+ dragging = true;
+ setDraggerDimensionsAndOffset();
+
+ move(e);
+
+ doc.addEventListener("selectstart", prevent);
+ doc.addEventListener("dragstart", prevent);
+ doc.addEventListener("mousemove", move);
+ doc.addEventListener("mouseup", stop);
+
+ prevent(e);
+ }
+ }
+
+ function stop() {
+ if (dragging) {
+ doc.removeEventListener("selectstart", prevent);
+ doc.removeEventListener("dragstart", prevent);
+ doc.removeEventListener("mousemove", move);
+ doc.removeEventListener("mouseup", stop);
+ }
+ dragging = false;
+ }
+
+ function onKeydown(e) {
+ const { key } = e;
+
+ if (!ARROW_KEYS.includes(key)) {
+ return;
+ }
+
+ setDraggerDimensionsAndOffset();
+ const { offsetHeight, offsetTop, offsetLeft } = dragHelper;
+ let dragX = offsetLeft + offsetHeight / 2;
+ let dragY = offsetTop + offsetHeight / 2;
+
+ if (key === ArrowLeft && dragX > 0) {
+ dragX -= 1;
+ } else if (key === ArrowRight && dragX < maxWidth) {
+ dragX += 1;
+ } else if (key === ArrowUp && dragY > 0) {
+ dragY -= 1;
+ } else if (key === ArrowDown && dragY < maxHeight) {
+ dragY += 1;
+ }
+
+ onmove.apply(element, [dragX, dragY]);
+ }
+
+ element.addEventListener("mousedown", start);
+ element.addEventListener("keydown", onKeydown);
+}
+
+/**
+ * Calculates the contrast ratio for a DOM node's computed style against
+ * a given background.
+ *
+ * @param {Object} computedStyle
+ * The computed style for which we want to calculate the contrast ratio.
+ * @param {Object} backgroundColor
+ * Object with one or more of the following properties: value, min, max
+ * @return {Object}
+ * An object that may contain one or more of the following fields: error,
+ * isLargeText, value, score for contrast.
+ */
+function getContrastRatio(computedStyle, backgroundColor) {
+ const props = getTextProperties(computedStyle);
+
+ if (!props) {
+ return {
+ error: true,
+ };
+ }
+
+ return getContrastRatioAgainstBackground(backgroundColor, props);
+}
+
+module.exports = Spectrum;