UNPKG

@esri/calcite-components

Version:

Web Components for Esri's Calcite Design System.

1,273 lines • 51.4 kB
/*! * All material copyright ESRI, All Rights Reserved, unless otherwise specified. * See https://github.com/Esri/calcite-components/blob/master/LICENSE.md for details. * v1.5.0-next.4 */ import { h } from "@stencil/core"; import Color from "color"; import { throttle } from "lodash-es"; import { getElementDir, isPrimaryPointerButton } from "../../utils/dom"; import { CSS, DEFAULT_COLOR, DEFAULT_STORAGE_KEY_PREFIX, DIMENSIONS, HSV_LIMITS, OPACITY_LIMITS, RGB_LIMITS } from "./resources"; import { alphaCompatible, alphaToOpacity, colorEqual, CSSColorMode, hexify, normalizeAlpha, normalizeColor, normalizeHex, opacityToAlpha, parseMode, toAlphaMode, toNonAlphaMode } from "./utils"; import { connectInteractive, disconnectInteractive, updateHostInteraction } from "../../utils/interactive"; import { isActivationKey } from "../../utils/key"; import { componentLoaded, setComponentLoaded, setUpLoadableComponent } from "../../utils/loadable"; import { connectLocalized, disconnectLocalized } from "../../utils/locale"; import { clamp } from "../../utils/math"; import { connectMessages, disconnectMessages, setUpMessages, updateMessages } from "../../utils/t9n"; const throttleFor60FpsInMs = 16; export class ColorPicker { constructor() { this.internalColorUpdateContext = null; this.mode = CSSColorMode.HEX; this.shiftKeyChannelAdjustment = 0; this.handleTabActivate = (event) => { this.channelMode = event.currentTarget.getAttribute("data-color-mode"); this.updateChannelsFromColor(this.color); }; this.handleColorFieldScopeKeyDown = (event) => { const { key } = event; const arrowKeyToXYOffset = { ArrowUp: { x: 0, y: -10 }, ArrowRight: { x: 10, y: 0 }, ArrowDown: { x: 0, y: 10 }, ArrowLeft: { x: -10, y: 0 } }; if (arrowKeyToXYOffset[key]) { event.preventDefault(); this.scopeOrientation = key === "ArrowDown" || key === "ArrowUp" ? "vertical" : "horizontal"; this.captureColorFieldColor(this.colorFieldScopeLeft + arrowKeyToXYOffset[key].x || 0, this.colorFieldScopeTop + arrowKeyToXYOffset[key].y || 0, false); } }; this.handleHueScopeKeyDown = (event) => { const modifier = event.shiftKey ? 10 : 1; const { key } = event; const arrowKeyToXOffset = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 }; if (arrowKeyToXOffset[key]) { event.preventDefault(); const delta = arrowKeyToXOffset[key] * modifier; const hue = this.baseColorFieldColor.hue(); const color = this.baseColorFieldColor.hue(hue + delta); this.internalColorSet(color, false); } }; this.handleHexInputChange = (event) => { event.stopPropagation(); const { allowEmpty, color } = this; const input = event.target; const hex = input.value; if (allowEmpty && !hex) { this.internalColorSet(null); return; } const normalizedHex = color && normalizeHex(hexify(color, alphaCompatible(this.mode))); if (hex !== normalizedHex) { this.internalColorSet(Color(hex)); } }; this.handleSavedColorSelect = (event) => { const swatch = event.currentTarget; this.internalColorSet(Color(swatch.color)); }; this.handleChannelInput = (event) => { const input = event.currentTarget; const channelIndex = Number(input.getAttribute("data-channel-index")); const isAlphaChannel = channelIndex === 3; const limit = isAlphaChannel ? OPACITY_LIMITS.max : this.channelMode === "rgb" ? RGB_LIMITS[Object.keys(RGB_LIMITS)[channelIndex]] : HSV_LIMITS[Object.keys(HSV_LIMITS)[channelIndex]]; let inputValue; if (this.allowEmpty && !input.value) { inputValue = ""; } else { const value = Number(input.value); const adjustedValue = value + this.shiftKeyChannelAdjustment; const clamped = clamp(adjustedValue, 0, limit); inputValue = clamped.toString(); } input.value = inputValue; // TODO: refactor calcite-input so we don't need to sync the internals // https://github.com/Esri/calcite-components/issues/6100 input.internalSyncChildElValue(); }; this.handleChannelChange = (event) => { const input = event.currentTarget; const channelIndex = Number(input.getAttribute("data-channel-index")); const channels = [...this.channels]; const shouldClearChannels = this.allowEmpty && !input.value; if (shouldClearChannels) { this.channels = [null, null, null, null]; this.internalColorSet(null); return; } const isAlphaChannel = channelIndex === 3; const value = Number(input.value); channels[channelIndex] = isAlphaChannel ? opacityToAlpha(value) : value; this.updateColorFromChannels(channels); }; this.handleSavedColorKeyDown = (event) => { if (isActivationKey(event.key)) { event.preventDefault(); this.handleSavedColorSelect(event); } }; this.handleColorFieldPointerDown = (event) => { if (!isPrimaryPointerButton(event)) { return; } const { offsetX, offsetY } = event; document.addEventListener("pointermove", this.globalPointerMoveHandler); document.addEventListener("pointerup", this.globalPointerUpHandler, { once: true }); this.activeCanvasInfo = { context: this.colorFieldRenderingContext, bounds: this.colorFieldRenderingContext.canvas.getBoundingClientRect() }; this.captureColorFieldColor(offsetX, offsetY); this.colorFieldScopeNode.focus(); }; this.handleHueSliderPointerDown = (event) => { if (!isPrimaryPointerButton(event)) { return; } const { offsetX } = event; document.addEventListener("pointermove", this.globalPointerMoveHandler); document.addEventListener("pointerup", this.globalPointerUpHandler, { once: true }); this.activeCanvasInfo = { context: this.hueSliderRenderingContext, bounds: this.hueSliderRenderingContext.canvas.getBoundingClientRect() }; this.captureHueSliderColor(offsetX); this.hueScopeNode.focus(); }; this.handleOpacitySliderPointerDown = (event) => { if (!isPrimaryPointerButton(event)) { return; } const { offsetX } = event; document.addEventListener("pointermove", this.globalPointerMoveHandler); document.addEventListener("pointerup", this.globalPointerUpHandler, { once: true }); this.activeCanvasInfo = { context: this.opacitySliderRenderingContext, bounds: this.opacitySliderRenderingContext.canvas.getBoundingClientRect() }; this.captureOpacitySliderValue(offsetX); this.opacityScopeNode.focus(); }; this.globalPointerUpHandler = (event) => { if (!isPrimaryPointerButton(event)) { return; } const previouslyDragging = this.activeCanvasInfo; this.activeCanvasInfo = null; this.drawColorControls(); if (previouslyDragging) { this.calciteColorPickerChange.emit(); } }; this.globalPointerMoveHandler = (event) => { const { activeCanvasInfo, el } = this; if (!el.isConnected || !activeCanvasInfo) { return; } const { context, bounds } = activeCanvasInfo; let samplingX; let samplingY; const { clientX, clientY } = event; if (context.canvas.matches(":hover")) { samplingX = clientX - bounds.x; samplingY = clientY - bounds.y; } else { // snap x and y to the closest edge if (clientX < bounds.x + bounds.width && clientX > bounds.x) { samplingX = clientX - bounds.x; } else if (clientX < bounds.x) { samplingX = 0; } else { samplingX = bounds.width - 1; } if (clientY < bounds.y + bounds.height && clientY > bounds.y) { samplingY = clientY - bounds.y; } else if (clientY < bounds.y) { samplingY = 0; } else { samplingY = bounds.height; } } if (context === this.colorFieldRenderingContext) { this.captureColorFieldColor(samplingX, samplingY, false); } else if (context === this.hueSliderRenderingContext) { this.captureHueSliderColor(samplingX); } else if (context === this.opacitySliderRenderingContext) { this.captureOpacitySliderValue(samplingX); } }; this.storeColorFieldScope = (node) => { this.colorFieldScopeNode = node; }; this.storeHueScope = (node) => { this.hueScopeNode = node; }; this.renderChannelsTabTitle = (channelMode) => { const { channelMode: activeChannelMode, messages } = this; const selected = channelMode === activeChannelMode; const label = channelMode === "rgb" ? messages.rgb : messages.hsv; return (h("calcite-tab-title", { class: CSS.colorMode, "data-color-mode": channelMode, key: channelMode, onCalciteTabsActivate: this.handleTabActivate, selected: selected }, label)); }; this.renderChannelsTab = (channelMode) => { const { allowEmpty, channelMode: activeChannelMode, channels, messages, alphaChannel } = this; const selected = channelMode === activeChannelMode; const isRgb = channelMode === "rgb"; const channelAriaLabels = isRgb ? [messages.red, messages.green, messages.blue] : [messages.hue, messages.saturation, messages.value]; const direction = getElementDir(this.el); const channelsToRender = alphaChannel ? channels : channels.slice(0, 3); return (h("calcite-tab", { class: CSS.control, key: channelMode, selected: selected }, h("div", { class: CSS.channels, dir: "ltr" }, channelsToRender.map((channelValue, index) => { const isAlphaChannel = index === 3; if (isAlphaChannel) { channelValue = allowEmpty && !channelValue ? channelValue : alphaToOpacity(channelValue); } /* the channel container is ltr, so we apply the host's direction */ return this.renderChannel(channelValue, index, channelAriaLabels[index], direction, isAlphaChannel ? "%" : ""); })))); }; this.renderChannel = (value, index, ariaLabel, direction, suffix) => { return (h("calcite-input", { class: CSS.channel, "data-channel-index": index, dir: direction, key: index, label: ariaLabel, lang: this.effectiveLocale, numberButtonType: "none", numberingSystem: this.numberingSystem, onCalciteInputChange: this.handleChannelChange, onCalciteInputInput: this.handleChannelInput, onKeyDown: this.handleKeyDown, scale: this.scale === "l" ? "m" : "s", // workaround to ensure input borders overlap as desired // this is because the build transforms margin-left to its // logical-prop, which is undesired as channels are always ltr style: { marginLeft: index > 0 && !(this.scale === "s" && this.alphaChannel && index === 3) ? "-1px" : "" }, suffixText: suffix, type: "number", value: value?.toString() })); }; this.deleteColor = () => { const colorToDelete = hexify(this.color, this.alphaChannel); const inStorage = this.savedColors.indexOf(colorToDelete) > -1; if (!inStorage) { return; } const savedColors = this.savedColors.filter((color) => color !== colorToDelete); this.savedColors = savedColors; const storageKey = `${DEFAULT_STORAGE_KEY_PREFIX}${this.storageId}`; if (this.storageId) { localStorage.setItem(storageKey, JSON.stringify(savedColors)); } }; this.saveColor = () => { const colorToSave = hexify(this.color, this.alphaChannel); const alreadySaved = this.savedColors.indexOf(colorToSave) > -1; if (alreadySaved) { return; } const savedColors = [...this.savedColors, colorToSave]; this.savedColors = savedColors; const storageKey = `${DEFAULT_STORAGE_KEY_PREFIX}${this.storageId}`; if (this.storageId) { localStorage.setItem(storageKey, JSON.stringify(savedColors)); } }; this.drawColorControls = throttle((type = "all") => { if ((type === "all" || type === "color-field") && this.colorFieldRenderingContext) { this.drawColorField(); } if ((type === "all" || type === "hue-slider") && this.hueSliderRenderingContext) { this.drawHueSlider(); } if (this.alphaChannel && (type === "all" || type === "opacity-slider") && this.opacitySliderRenderingContext) { this.drawOpacitySlider(); } }, throttleFor60FpsInMs); this.captureColorFieldColor = (x, y, skipEqual = true) => { const { dimensions: { colorField: { height, width } } } = this; const saturation = Math.round((HSV_LIMITS.s / width) * x); const value = Math.round((HSV_LIMITS.v / height) * (height - y)); this.internalColorSet(this.baseColorFieldColor.hsv().saturationv(saturation).value(value), skipEqual); }; this.initColorField = (canvas) => { this.colorFieldRenderingContext = canvas.getContext("2d"); this.updateCanvasSize("color-field"); this.drawColorControls(); }; this.initHueSlider = (canvas) => { this.hueSliderRenderingContext = canvas.getContext("2d"); this.updateCanvasSize("hue-slider"); this.drawHueSlider(); }; this.initOpacitySlider = (canvas) => { this.opacitySliderRenderingContext = canvas.getContext("2d"); this.updateCanvasSize("opacity-slider"); this.drawOpacitySlider(); }; this.storeOpacityScope = (node) => { this.opacityScopeNode = node; }; this.handleOpacityScopeKeyDown = (event) => { const modifier = event.shiftKey ? 10 : 1; const { key } = event; const arrowKeyToXOffset = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 }; if (arrowKeyToXOffset[key]) { event.preventDefault(); const delta = opacityToAlpha(arrowKeyToXOffset[key] * modifier); this.captureHueSliderColor(this.opacityScopeLeft + delta); } }; this.allowEmpty = false; this.alphaChannel = false; this.channelsDisabled = false; this.color = DEFAULT_COLOR; this.disabled = false; this.format = "auto"; this.hideChannels = false; this.hexDisabled = false; this.hideHex = false; this.hideSaved = false; this.savedDisabled = false; this.scale = "m"; this.storageId = undefined; this.messageOverrides = undefined; this.numberingSystem = undefined; this.value = normalizeHex(hexify(DEFAULT_COLOR, this.alphaChannel)); this.defaultMessages = undefined; this.channelMode = "rgb"; this.channels = this.toChannels(DEFAULT_COLOR); this.dimensions = DIMENSIONS.m; this.effectiveLocale = ""; this.messages = undefined; this.savedColors = []; this.colorFieldScopeTop = undefined; this.colorFieldScopeLeft = undefined; this.hueScopeLeft = undefined; this.opacityScopeLeft = undefined; this.scopeOrientation = undefined; } handleAlphaChannelChange(alphaChannel) { const { format } = this; if (alphaChannel && format !== "auto" && !alphaCompatible(format)) { console.warn(`ignoring alphaChannel as the current format (${format}) does not support alpha`); this.alphaChannel = false; } } handleColorChange(color, oldColor) { this.drawColorControls(); this.updateChannelsFromColor(color); this.previousColor = oldColor; } handleFormatChange(format) { this.setMode(format); this.internalColorSet(this.color, false, "internal"); } handleScaleChange(scale = "m") { this.updateDimensions(scale); this.updateCanvasSize("all"); this.drawColorControls(); } onMessagesChange() { /* wired up by t9n util */ } handleValueChange(value, oldValue) { const { allowEmpty, format } = this; const checkMode = !allowEmpty || value; let modeChanged = false; if (checkMode) { const nextMode = parseMode(value); if (!nextMode || (format !== "auto" && nextMode !== format)) { this.showIncompatibleColorWarning(value, format); this.value = oldValue; return; } modeChanged = this.mode !== nextMode; this.setMode(nextMode, this.internalColorUpdateContext === null); } const dragging = this.activeCanvasInfo; if (this.internalColorUpdateContext === "initial") { return; } if (this.internalColorUpdateContext === "user-interaction") { this.calciteColorPickerInput.emit(); if (!dragging) { this.calciteColorPickerChange.emit(); } return; } const color = allowEmpty && !value ? null : Color(value != null && typeof value === "object" && alphaCompatible(this.mode) ? normalizeColor(value) : value); const colorChanged = !colorEqual(color, this.color); if (modeChanged || colorChanged) { this.internalColorSet(color, this.alphaChannel && !(this.mode.endsWith("a") || this.mode.endsWith("a-css")), "internal"); } } get baseColorFieldColor() { return this.color || this.previousColor || DEFAULT_COLOR; } effectiveLocaleChange() { updateMessages(this, this.effectiveLocale); } // using @Listen as a workaround for VDOM listener not firing handleChannelKeyUpOrDown(event) { this.shiftKeyChannelAdjustment = 0; const { key } = event; if ((key !== "ArrowUp" && key !== "ArrowDown") || !event.composedPath().some((node) => node.classList?.contains(CSS.channel))) { return; } const { shiftKey } = event; event.preventDefault(); if (!this.color) { this.internalColorSet(this.previousColor); event.stopPropagation(); return; } // this gets applied to the input's up/down arrow increment/decrement const complementaryBump = 9; this.shiftKeyChannelAdjustment = key === "ArrowUp" && shiftKey ? complementaryBump : key === "ArrowDown" && shiftKey ? -complementaryBump : 0; } //-------------------------------------------------------------------------- // // Public Methods // //-------------------------------------------------------------------------- /** Sets focus on the component's first focusable element. */ async setFocus() { await componentLoaded(this); this.el.focus(); } //-------------------------------------------------------------------------- // // Lifecycle // //-------------------------------------------------------------------------- async componentWillLoad() { setUpLoadableComponent(this); const { allowEmpty, color, format, value } = this; const willSetNoColor = allowEmpty && !value; const parsedMode = parseMode(value); const valueIsCompatible = willSetNoColor || (format === "auto" && parsedMode) || format === parsedMode; const initialColor = willSetNoColor ? null : valueIsCompatible ? Color(value) : color; if (!valueIsCompatible) { this.showIncompatibleColorWarning(value, format); } this.setMode(format, false); this.internalColorSet(initialColor, false, "initial"); this.updateDimensions(this.scale); const storageKey = `${DEFAULT_STORAGE_KEY_PREFIX}${this.storageId}`; if (this.storageId && localStorage.getItem(storageKey)) { this.savedColors = JSON.parse(localStorage.getItem(storageKey)); } await setUpMessages(this); } connectedCallback() { connectInteractive(this); connectLocalized(this); connectMessages(this); } componentDidLoad() { setComponentLoaded(this); } disconnectedCallback() { document.removeEventListener("pointermove", this.globalPointerMoveHandler); document.removeEventListener("pointerup", this.globalPointerUpHandler); disconnectInteractive(this); disconnectLocalized(this); disconnectMessages(this); } componentDidRender() { updateHostInteraction(this); } //-------------------------------------------------------------------------- // // Render Methods // //-------------------------------------------------------------------------- render() { const { allowEmpty, channelsDisabled, color, colorFieldScopeLeft, colorFieldScopeTop, dimensions: { colorField: { width: colorFieldWidth }, slider: { width: sliderWidth }, thumb: { radius: thumbRadius } }, hexDisabled, hideChannels, hideHex, hideSaved, hueScopeLeft, messages, alphaChannel, opacityScopeLeft, savedColors, savedDisabled, scale, scopeOrientation } = this; const selectedColorInHex = color ? hexify(color, alphaChannel) : null; const hueTop = thumbRadius; const hueLeft = hueScopeLeft ?? (sliderWidth * DEFAULT_COLOR.hue()) / HSV_LIMITS.h; const opacityTop = thumbRadius; const opacityLeft = opacityScopeLeft ?? (colorFieldWidth * alphaToOpacity(DEFAULT_COLOR.alpha())) / OPACITY_LIMITS.max; const noColor = color === null; const vertical = scopeOrientation === "vertical"; const noHex = hexDisabled || hideHex; const noChannels = channelsDisabled || hideChannels; const noSaved = savedDisabled || hideSaved; return (h("div", { class: CSS.container }, h("div", { class: CSS.controlAndScope }, h("canvas", { class: CSS.colorField, onPointerDown: this.handleColorFieldPointerDown, // eslint-disable-next-line react/jsx-sort-props ref: this.initColorField }), h("div", { "aria-label": vertical ? messages.value : messages.saturation, "aria-valuemax": vertical ? HSV_LIMITS.v : HSV_LIMITS.s, "aria-valuemin": "0", "aria-valuenow": (vertical ? color?.saturationv() : color?.value()) || "0", class: { [CSS.scope]: true, [CSS.colorFieldScope]: true }, onKeyDown: this.handleColorFieldScopeKeyDown, role: "slider", style: { top: `${colorFieldScopeTop || 0}px`, left: `${colorFieldScopeLeft || 0}px` }, tabindex: "0", // eslint-disable-next-line react/jsx-sort-props ref: this.storeColorFieldScope })), h("div", { class: CSS.previewAndSliders }, h("calcite-color-picker-swatch", { class: CSS.preview, color: selectedColorInHex, scale: "l" }), h("div", { class: CSS.sliders }, h("div", { class: CSS.controlAndScope }, h("canvas", { class: { [CSS.slider]: true, [CSS.hueSlider]: true }, onPointerDown: this.handleHueSliderPointerDown, // eslint-disable-next-line react/jsx-sort-props ref: this.initHueSlider }), h("div", { "aria-label": messages.hue, "aria-valuemax": HSV_LIMITS.h, "aria-valuemin": "0", "aria-valuenow": color?.round().hue() || DEFAULT_COLOR.round().hue(), class: { [CSS.scope]: true, [CSS.hueScope]: true }, onKeyDown: this.handleHueScopeKeyDown, role: "slider", style: { top: `${hueTop}px`, left: `${hueLeft}px` }, tabindex: "0", // eslint-disable-next-line react/jsx-sort-props ref: this.storeHueScope })), alphaChannel ? (h("div", { class: CSS.controlAndScope }, h("canvas", { class: { [CSS.slider]: true, [CSS.opacitySlider]: true }, onPointerDown: this.handleOpacitySliderPointerDown, // eslint-disable-next-line react/jsx-sort-props ref: this.initOpacitySlider }), h("div", { "aria-label": messages.opacity, "aria-valuemax": OPACITY_LIMITS.max, "aria-valuemin": OPACITY_LIMITS.min, "aria-valuenow": (color || DEFAULT_COLOR).round().alpha(), class: { [CSS.scope]: true, [CSS.opacityScope]: true }, onKeyDown: this.handleOpacityScopeKeyDown, role: "slider", style: { top: `${opacityTop}px`, left: `${opacityLeft}px` }, tabindex: "0", // eslint-disable-next-line react/jsx-sort-props ref: this.storeOpacityScope }))) : null)), noHex && noChannels ? null : (h("div", { class: { [CSS.controlSection]: true, [CSS.section]: true } }, h("div", { class: CSS.hexAndChannelsGroup }, noHex ? null : (h("div", { class: CSS.hexOptions }, h("calcite-color-picker-hex-input", { allowEmpty: allowEmpty, alphaChannel: alphaChannel, class: CSS.control, messages: messages, numberingSystem: this.numberingSystem, onCalciteColorPickerHexInputChange: this.handleHexInputChange, scale: scale, value: selectedColorInHex }))), noChannels ? null : (h("calcite-tabs", { class: { [CSS.colorModeContainer]: true, [CSS.splitSection]: true }, scale: scale === "l" ? "m" : "s" }, h("calcite-tab-nav", { slot: "title-group" }, this.renderChannelsTabTitle("rgb"), this.renderChannelsTabTitle("hsv")), this.renderChannelsTab("rgb"), this.renderChannelsTab("hsv")))))), noSaved ? null : (h("div", { class: { [CSS.savedColorsSection]: true, [CSS.section]: true } }, h("div", { class: CSS.header }, h("label", null, messages.saved), h("div", { class: CSS.savedColorsButtons }, h("calcite-button", { appearance: "transparent", class: CSS.deleteColor, disabled: noColor, iconStart: "minus", kind: "neutral", label: messages.deleteColor, onClick: this.deleteColor, scale: scale, type: "button" }), h("calcite-button", { appearance: "transparent", class: CSS.saveColor, disabled: noColor, iconStart: "plus", kind: "neutral", label: messages.saveColor, onClick: this.saveColor, scale: scale, type: "button" }))), savedColors.length > 0 ? (h("div", { class: CSS.savedColors }, [ ...savedColors.map((color) => (h("calcite-color-picker-swatch", { class: CSS.savedColor, color: color, key: color, onClick: this.handleSavedColorSelect, onKeyDown: this.handleSavedColorKeyDown, scale: scale, tabIndex: 0 }))) ])) : null)))); } // -------------------------------------------------------------------------- // // Private Methods // //-------------------------------------------------------------------------- handleKeyDown(event) { if (event.key === "Enter") { event.preventDefault(); } } showIncompatibleColorWarning(value, format) { console.warn(`ignoring color value (${value}) as it is not compatible with the current format (${format})`); } setMode(format, warn = true) { const mode = format === "auto" ? this.mode : format; this.mode = this.ensureCompatibleMode(mode, warn); } ensureCompatibleMode(mode, warn) { const { alphaChannel } = this; const isAlphaCompatible = alphaCompatible(mode); if (alphaChannel && !isAlphaCompatible) { const alphaMode = toAlphaMode(mode); if (warn) { console.warn(`setting format to (${alphaMode}) as the provided one (${mode}) does not support alpha`); } return alphaMode; } if (!alphaChannel && isAlphaCompatible) { const nonAlphaMode = toNonAlphaMode(mode); if (warn) { console.warn(`setting format to (${nonAlphaMode}) as the provided one (${mode}) does not support alpha`); } return nonAlphaMode; } return mode; } captureHueSliderColor(x) { const { dimensions: { slider: { width } } } = this; const hue = (360 / width) * x; this.internalColorSet(this.baseColorFieldColor.hue(hue), false); } captureOpacitySliderValue(x) { const { dimensions: { slider: { width } } } = this; const alpha = opacityToAlpha((OPACITY_LIMITS.max / width) * x); this.internalColorSet(this.baseColorFieldColor.alpha(alpha), false); } internalColorSet(color, skipEqual = true, context = "user-interaction") { if (skipEqual && colorEqual(color, this.color)) { return; } this.internalColorUpdateContext = context; this.color = color; this.value = this.toValue(color); this.internalColorUpdateContext = null; } toValue(color, format = this.mode) { if (!color) { return null; } const hexMode = "hex"; if (format.includes(hexMode)) { const hasAlpha = format === CSSColorMode.HEXA; return normalizeHex(hexify(color.round(), hasAlpha), hasAlpha); } if (format.includes("-css")) { const value = color[format.replace("-css", "").replace("a", "")]().round().string(); // Color omits alpha values when alpha is 1 const needToInjectAlpha = (format.endsWith("a") || format.endsWith("a-css")) && color.alpha() === 1; if (needToInjectAlpha) { const model = value.slice(0, 3); const values = value.slice(4, -1); return `${model}a(${values}, ${color.alpha()})`; } return value; } const colorObject = /* Color() does not support hsva, hsla nor rgba, so we use the non-alpha mode */ color[toNonAlphaMode(format)]().round().object(); if (format.endsWith("a")) { return normalizeAlpha(colorObject); } return colorObject; } getSliderCapSpacing() { const { dimensions: { slider: { height }, thumb: { radius } } } = this; return radius * 2 - height; } updateDimensions(scale = "m") { this.dimensions = DIMENSIONS[scale]; } drawColorField() { const context = this.colorFieldRenderingContext; const { dimensions: { colorField: { height, width } } } = this; context.fillStyle = this.baseColorFieldColor .hsv() .saturationv(100) .value(100) .alpha(1) .string(); context.fillRect(0, 0, width, height); const whiteGradient = context.createLinearGradient(0, 0, width, 0); whiteGradient.addColorStop(0, "rgba(255,255,255,1)"); whiteGradient.addColorStop(1, "rgba(255,255,255,0)"); context.fillStyle = whiteGradient; context.fillRect(0, 0, width, height); const blackGradient = context.createLinearGradient(0, 0, 0, height); blackGradient.addColorStop(0, "rgba(0,0,0,0)"); blackGradient.addColorStop(1, "rgba(0,0,0,1)"); context.fillStyle = blackGradient; context.fillRect(0, 0, width, height); this.drawActiveColorFieldColor(); } setCanvasContextSize(canvas, { height, width }) { if (!canvas) { return; } const devicePixelRatio = window.devicePixelRatio || 1; canvas.width = width * devicePixelRatio; canvas.height = height * devicePixelRatio; canvas.style.height = `${height}px`; canvas.style.width = `${width}px`; const context = canvas.getContext("2d"); context.scale(devicePixelRatio, devicePixelRatio); } updateCanvasSize(context = "all") { const { dimensions } = this; if (context === "all" || context === "color-field") { this.setCanvasContextSize(this.colorFieldRenderingContext?.canvas, dimensions.colorField); } const adjustedSliderDimensions = { width: dimensions.slider.width, height: dimensions.slider.height + (dimensions.thumb.radius - dimensions.slider.height / 2) * 2 }; if (context === "all" || context === "hue-slider") { this.setCanvasContextSize(this.hueSliderRenderingContext?.canvas, adjustedSliderDimensions); } if (context === "all" || context === "opacity-slider") { this.setCanvasContextSize(this.opacitySliderRenderingContext?.canvas, adjustedSliderDimensions); } } drawActiveColorFieldColor() { const { color } = this; if (!color) { return; } const hsvColor = color.hsv(); const { dimensions: { colorField: { height, width }, thumb: { radius } } } = this; const x = hsvColor.saturationv() / (HSV_LIMITS.s / width); const y = height - hsvColor.value() / (HSV_LIMITS.v / height); requestAnimationFrame(() => { this.colorFieldScopeLeft = x; this.colorFieldScopeTop = y; }); this.drawThumb(this.colorFieldRenderingContext, radius, x, y, hsvColor); } drawThumb(context, radius, x, y, color) { const startAngle = 0; const endAngle = 2 * Math.PI; const outlineWidth = 1; radius = radius - outlineWidth; context.beginPath(); context.arc(x, y, radius, startAngle, endAngle); context.fillStyle = "#fff"; context.fill(); context.strokeStyle = "rgba(0,0,0,0.3)"; context.lineWidth = outlineWidth; context.stroke(); context.beginPath(); context.arc(x, y, radius - 3, startAngle, endAngle); context.fillStyle = color.rgb().alpha(1).string(); context.fill(); } drawActiveHueSliderColor() { const { color } = this; if (!color) { return; } const hsvColor = color.hsv().saturationv(100).value(100); const { dimensions: { slider: { height, width }, thumb: { radius } } } = this; const x = hsvColor.hue() / (360 / width); const y = radius - height / 2 + height / 2; requestAnimationFrame(() => { this.hueScopeLeft = x; }); this.drawThumb(this.hueSliderRenderingContext, radius, x, y, hsvColor); } drawHueSlider() { const context = this.hueSliderRenderingContext; const { dimensions: { slider: { height, width }, thumb: { radius: thumbRadius } } } = this; const x = 0; const y = thumbRadius - height / 2; const gradient = context.createLinearGradient(0, 0, width, 0); const hueSliderColorStopKeywords = ["red", "yellow", "lime", "cyan", "blue", "magenta", "red"]; const offset = 1 / (hueSliderColorStopKeywords.length - 1); let currentOffset = 0; hueSliderColorStopKeywords.forEach((keyword) => { gradient.addColorStop(currentOffset, Color(keyword).string()); currentOffset += offset; }); context.clearRect(0, 0, width, height + this.getSliderCapSpacing() * 2); this.drawSliderPath(context, height, width, x, y); context.fillStyle = gradient; context.fill(); context.strokeStyle = "rgba(0,0,0,0.3)"; context.lineWidth = 1; context.stroke(); this.drawActiveHueSliderColor(); } drawOpacitySlider() { const context = this.opacitySliderRenderingContext; const { baseColorFieldColor: previousColor, dimensions: { slider: { height, width }, thumb: { radius: thumbRadius } } } = this; const x = 0; const y = thumbRadius - height / 2; context.clearRect(0, 0, width, height + this.getSliderCapSpacing() * 2); const gradient = context.createLinearGradient(0, y, width, 0); const startColor = previousColor.rgb().alpha(0); const midColor = previousColor.rgb().alpha(0.5); const endColor = previousColor.rgb().alpha(1); gradient.addColorStop(0, startColor.string()); gradient.addColorStop(0.5, midColor.string()); gradient.addColorStop(1, endColor.string()); this.drawSliderPath(context, height, width, x, y); const pattern = context.createPattern(this.getCheckeredBackgroundPattern(), "repeat"); context.fillStyle = pattern; context.fill(); context.fillStyle = gradient; context.fill(); context.strokeStyle = "rgba(0,0,0,0.3)"; context.lineWidth = 1; context.stroke(); this.drawActiveOpacitySliderColor(); } drawSliderPath(context, height, width, x, y) { const radius = height / 2 + 1; context.beginPath(); context.moveTo(x + radius, y); context.lineTo(x + width - radius, y); context.quadraticCurveTo(x + width, y, x + width, y + radius); context.lineTo(x + width, y + height - radius); context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); context.lineTo(x + radius, y + height); context.quadraticCurveTo(x, y + height, x, y + height - radius); context.lineTo(x, y + radius); context.quadraticCurveTo(x, y, x + radius, y); context.closePath(); } getCheckeredBackgroundPattern() { if (this.checkerPattern) { return this.checkerPattern; } const pattern = document.createElement("canvas"); pattern.width = 10; pattern.height = 10; const patternContext = pattern.getContext("2d"); patternContext.fillStyle = "#ccc"; patternContext.fillRect(0, 0, 10, 10); patternContext.fillStyle = "#fff"; patternContext.fillRect(0, 0, 5, 5); patternContext.fillRect(5, 5, 5, 5); this.checkerPattern = pattern; return pattern; } drawActiveOpacitySliderColor() { const { color } = this; if (!color) { return; } const hsvColor = color; const { dimensions: { slider: { width }, thumb: { radius } } } = this; const x = alphaToOpacity(hsvColor.alpha()) / (OPACITY_LIMITS.max / width); const y = radius; requestAnimationFrame(() => { this.opacityScopeLeft = x; }); this.drawThumb(this.opacitySliderRenderingContext, radius, x, y, hsvColor); } updateColorFromChannels(channels) { this.internalColorSet(Color(channels, this.channelMode)); } updateChannelsFromColor(color) { this.channels = color ? this.toChannels(color) : [null, null, null, null]; } toChannels(color) { const { channelMode } = this; const channels = color[channelMode]() .array() .map((value, index) => { const isAlpha = index === 3; return isAlpha ? value : Math.floor(value); }); if (channels.length === 3) { channels.push(1); // Color omits alpha when 1 } return channels; } static get is() { return "calcite-color-picker"; } static get encapsulation() { return "shadow"; } static get delegatesFocus() { return true; } static get originalStyleUrls() { return { "$": ["color-picker.scss"] }; } static get styleUrls() { return { "$": ["color-picker.css"] }; } static get assetsDirs() { return ["assets"]; } static get properties() { return { "allowEmpty": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "When `true`, an empty color (`null`) will be allowed as a `value`. When `false`, a color value is enforced, and clearing the input or blurring will restore the last valid `value`." }, "attribute": "allow-empty", "reflect": true, "defaultValue": "false" }, "alphaChannel": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "When true, the component will allow updates to the color's alpha value." }, "attribute": "alpha-channel", "reflect": false, "defaultValue": "false" }, "channelsDisabled": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "When true, hides the RGB/HSV channel inputs" }, "attribute": "channels-disabled", "reflect": false, "defaultValue": "false" }, "color": { "type": "unknown", "mutable": true, "complexType": { "original": "InternalColor | null", "resolved": "Color<ColorParam>", "references": { "InternalColor": { "location": "import", "path": "./interfaces" } } }, "required": false, "optional": false, "docs": { "tags": [{ "name": "internal", "text": undefined }], "text": "Internal prop for advanced use-cases." }, "defaultValue": "DEFAULT_COLOR" }, "disabled": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "When `true`, interaction is prevented and the component is displayed with lower opacity." }, "attribute": "disabled", "reflect": true, "defaultValue": "false" }, "format": { "type": "string", "mutable": false, "complexType": { "original": "Format", "resolved": "\"auto\" | \"hex\" | \"hexa\" | \"hsl\" | \"hsl-css\" | \"hsla\" | \"hsla-css\" | \"hsv\" | \"hsva\" | \"rgb\" | \"rgb-css\" | \"rgba\" | \"rgba-css\"", "references": { "Format": { "location": "import", "path": "./utils" } } }, "required": false, "optional": false, "docs": { "tags": [{ "name": "default", "text": "\"auto\"" }], "text": "The format of `value`.\n\nWhen `\"auto\"`, the format will be inferred from `value` when set." }, "attribute": "format", "reflect": true, "defaultValue": "\"auto\"" }, "hideChannels": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "deprecated", "text": "use `channelsDisabled` instead" }], "text": "When `true`, hides the RGB/HSV channel inputs." }, "attribute": "hide-channels", "reflect": true, "defaultValue": "false" }, "hexDisabled": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "When true, hides the hex input" }, "attribute": "hex-disabled", "reflect": false, "defaultValue": "false" }, "hideHex": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "deprecated", "text": "use `hexDisabled` instead" }], "text": "When `true`, hides the hex input." }, "attribute": "hide-hex", "reflect": true, "defaultValue": "false" }, "hideSaved": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "deprecated", "text": "use `savedDisabled` instead" }], "text": "When `true`, hides the saved colors section." }, "attribute": "hide-saved", "reflect": true, "defaultValue": "false" }, "savedDisabled": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "When true, hides the saved colors section" }, "attribute": "saved-disabled", "reflect": true, "defaultValue": "false" }, "scale": { "type": "string", "mutable": false, "complexType": { "original": "Scale", "resolved": "\"l\" | \"m\" | \"s\"", "references": { "Scale": { "location": "import", "path": "../interfaces" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Specifies the size of the component." }, "attribute": "scale", "reflect": true, "defaultValue": "\"m\"" }, "storageId": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Specifies the storage ID for colors." }, "attribute": "storage-id", "reflect": true }, "messageOverrides": { "type": "unknown", "mutable": true, "complexType": { "original": "Partial<ColorPickerMessages>", "resolved": "{ b?: string; blue?: string; deleteColor?: string; g?: string; green?: string; h?: string; hsv?: string; hex?: string; hue?: string; noColor?: string; opacity?: string; r?: string; red?: string; rgb?: string; s?: string; saturation?: string; saveColor?: string; saved?: string; v?: string; value?: string; }", "references": { "Partial": { "location": "global" }, "ColorPickerMessages": { "location": "import", "path": "./assets/color-picker/t9n" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Use this property to override individual strings used by the component." } }, "numberingSystem": { "type": "string", "mutable": false, "complexType": { "original": "NumberingSystem", "resolved": "\"arab\" | \"arabext\" | \"bali\" | \"beng\" | \"deva\" | \"fullwide\" | \"gujr\" | \"guru\" | \"hanidec\" | \"khmr\" | \"knda\" | \"laoo\" | \"latn\" | \"limb\" | \"mlym\" | \"mong\" | \"mymr\" | \"orya\" | \"tamldec\" | \"telu\" | \"thai\" | \"tibt\"", "references": { "NumberingSystem": { "location": "import", "path": "../../utils/locale" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Specifies the Unicode numeral system used by the component for localization." }, "attribute": "numbering-system", "reflect": true }, "value": { "type": "string", "mutable": true, "complexType": { "original": "ColorValue | null", "resolved": "HSL | HSL & ObjectWithAlpha | HSV | HSV & ObjectWithAlpha | RGB | RGB & ObjectWithAlpha | string", "references": { "ColorValue": { "location": "import", "path": "./interfaces" } } }, "required": false, "optional": false, "docs": { "tags": [{ "name": "default", "text": "\"#007ac2\"" }, { "name": "see", "text": "[CSS Color](https://developer.mozilla.org/en-US/docs/Web/CSS/color)" }, { "name": "see", "text": "[ColorValue](https://github.com/Esri/calcite-components/blob/master/src/components/color-picker/interfaces.ts#L10)" }], "text": "The component's value, where the value can be a CSS color string, or a RGB, HSL or HSV object.\n\nThe type will be preserved as the color is updated." }, "attribute": "value", "reflect": false, "defaultValue": "normalizeHex(\n hexify(DEFAULT_COLOR, this.alphaChannel)\n )" }, "messages": { "type": "unknown", "mutable": true, "complexType": { "original": "ColorPickerMessages", "resolved": "{ b: string; blue: string; deleteColor: string; g: string; green: string; h: string; hsv: string; hex: string; hue: string; noColor: string; opacity: string; r: string; red: string; rgb: string; s: string; saturation: string; saveColor: string; saved: string; v: string; value: string; }", "references": { "ColorPickerMessages": { "location": "import", "path": "./assets/color-picker/t9n" } } }, "required": false, "optional": false, "docs": { "tags": [{ "name": "internal", "text": undefined }], "text": "Made into a prop for testing purposes only" } } }; } static get states() { return { "defaultMessages": {}, "channelMode": {}, "channels": {}, "dimensions": {}, "effectiveLocale": {}, "savedColors": {}, "colorFieldScopeTop": {}, "colorFieldScopeLeft": {}, "hueScopeLeft": {}, "opacityScopeLeft": {}, "scopeOrientation": {} }; } static get events() { return [{ "method": "calciteColorPickerChange", "name": "calciteColorPickerChange", "bubbles": true, "cancelable": false, "composed": true, "docs": { "tags": [], "text": "Fires when the color value has changed." }, "complexType": { "original": "void", "resolved": "void", "references": {} } }, { "method": "calciteColorPickerInput", "name": "calciteColorPickerInput", "bubbles": true, "cancelable": false, "composed": true, "docs": { "tags": [], "text": "Fires as the color value changes.\n\nSimilar to the `calciteColorPickerChange` event with the exception of dragging. When dragging the color field or hue slider thumb, this event fires as the thumb is moved." }, "complexType": { "original": "void", "resolved": "void", "references": {} } }]; } static get methods() { return { "setFocus": { "complexType": { "signa