UNPKG

@esri/calcite-components

Version:

Web Components for Esri's Calcite Design System.

907 lines (906 loc) • 45.7 kB
/*! All material copyright ESRI, All Rights Reserved, unless otherwise specified. See https://github.com/Esri/calcite-design-system/blob/dev/LICENSE.md for details. v3.2.1 */ import { c as customElement } from "../../chunks/runtime.js"; import { ref } from "lit-html/directives/ref.js"; import { repeat } from "lit-html/directives/repeat.js"; import { keyed } from "lit-html/directives/keyed.js"; import Color from "color"; import { throttle } from "lodash-es"; import { html, nothing } from "lit"; import { LitElement, createEvent, safeClassMap, safeStyleMap } from "@arcgis/lumina"; import { i as isPrimaryPointerButton, h as focusFirstTabbable, g as getElementDir } from "../../chunks/dom.js"; import { u as updateHostInteraction, I as InteractiveContainer } from "../../chunks/interactive.js"; import { i as isActivationKey } from "../../chunks/key.js"; import { c as componentFocusable } from "../../chunks/component.js"; import { c as clamp, r as remap, a as closeToRangeEdge } from "../../chunks/math.js"; import { u as useT9n } from "../../chunks/useT9n.js"; import { c as createObserver } from "../../chunks/observers.js"; import { D as DEFAULT_COLOR, C as CSSColorMode, g as getSliderWidth, e as getColorFieldDimensions, S as STATIC_DIMENSIONS, n as normalizeHex, h as hexify, p as parseMode, f as DEFAULT_STORAGE_KEY_PREFIX, j as alphaCompatible, k as normalizeColor, l as colorEqual, O as OPACITY_LIMITS, R as RGB_LIMITS, H as HSV_LIMITS, m as CSS, o as opacityToAlpha, t as toAlphaMode, q as toNonAlphaMode, s as HUE_LIMIT_CONSTRAINED, u as normalizeAlpha, c as alphaToOpacity, v as SCOPE_SIZE } from "../../chunks/utils4.js"; import { css } from "@lit/reactive-element/css-tag.js"; const styles = css`:host([disabled]){cursor:default;-webkit-user-select:none;user-select:none;opacity:var(--calcite-opacity-disabled)}:host([disabled]) *,:host([disabled]) ::slotted(*){pointer-events:none}:host{display:inline-block;font-size:var(--calcite-font-size--2);line-height:1rem;font-weight:var(--calcite-font-weight-normal);inline-size:var(--calcite-internal-color-picker-min-width);min-inline-size:var(--calcite-internal-color-picker-min-width)}:host([disabled]) ::slotted([calcite-hydrated][disabled]),:host([disabled]) [calcite-hydrated][disabled]{opacity:1}.interaction-container{display:contents}:host([scale=s]){--calcite-internal-color-picker-min-width: 200px;--calcite-color-picker-spacing: 8px}:host([scale=s]) .saved-colors{gap:.25rem;grid-template-columns:repeat(auto-fill,20px)}:host([scale=m]){--calcite-internal-color-picker-min-width: 240px;--calcite-color-picker-spacing: 12px}:host([scale=l]){--calcite-internal-color-picker-min-width: 304px;--calcite-color-picker-spacing: 16px;font-size:var(--calcite-font-size--1);line-height:1rem}:host([scale=l]) .section:first-of-type{padding-block-start:var(--calcite-color-picker-spacing)}:host([scale=l]) .saved-colors{grid-template-columns:repeat(auto-fill,32px)}:host([scale=l]) .control-section{display:flex;flex-direction:column;flex-wrap:wrap;align-items:baseline}:host([scale=l]) .color-hex-options{inline-size:100%;display:flex;flex-shrink:1;flex-direction:column;justify-content:space-around}:host([scale=l]) .color-mode-container{flex-shrink:3}.container{background-color:var(--calcite-color-foreground-1);display:flex;flex-direction:column;block-size:min-content;border:1px solid var(--calcite-color-border-1)}.control-and-scope{position:relative;display:flex;cursor:pointer;touch-action:none}.color-field,.control-and-scope{-webkit-user-select:none;user-select:none}.scope{pointer-events:none;position:absolute;z-index:var(--calcite-z-index);block-size:1px;inline-size:1px;border-radius:9999px;background-color:transparent;font-size:var(--calcite-font-size--1);outline-color:transparent}.scope:focus{outline:2px solid var(--calcite-color-focus, var(--calcite-ui-focus-color, var(--calcite-color-brand)));outline-offset:calc(2px*(1 - (2*clamp(0,var(--calcite-offset-invert-focus),1))));outline-offset:6px}.hex-and-channels-group{display:flex;inline-size:100%;flex-direction:column;flex-wrap:wrap}.section{padding-block:0 var(--calcite-color-picker-spacing);padding-inline:var(--calcite-color-picker-spacing)}.section:first-of-type{padding-block-start:var(--calcite-color-picker-spacing)}.sliders{display:flex;flex-direction:column;justify-content:space-between;margin-inline-start:var(--calcite-color-picker-spacing);gap:var(--calcite-spacing-xxs)}.preview-and-sliders{display:flex;align-items:center;padding:var(--calcite-color-picker-spacing)}.color-hex-options,.section--split{flex-grow:1}.header{display:flex;align-items:center;justify-content:space-between;color:var(--calcite-color-text-1)}.color-mode-container{padding-block-start:var(--calcite-color-picker-spacing)}.channels{display:flex}.channel{flex-grow:1}.channel[data-channel-index="3"]{margin-inline-start:-1px;min-inline-size:81px}:host([scale=s]) .channel[data-channel-index="3"]{min-inline-size:68px}:host([scale=l]) .channel[data-channel-index="3"]{min-inline-size:88px}.saved-colors{display:grid;gap:.5rem;padding-block-start:var(--calcite-color-picker-spacing);grid-template-columns:repeat(auto-fill,24px)}.saved-colors-buttons{display:flex}.saved-color{outline-offset:0;outline-color:transparent;cursor:pointer}.saved-color:focus{outline:2px solid var(--calcite-color-brand);outline-offset:2px}.saved-color:hover{transition:outline-color var(--calcite-internal-animation-timing-fast) ease-in-out;outline:2px solid var(--calcite-color-border-2);outline-offset:2px}:host([hidden]){display:none}[hidden]{display:none}`; const throttleFor60FpsInMs = 16; class ColorPicker extends LitElement { constructor() { super(); this._color = DEFAULT_COLOR; this.internalColorUpdateContext = null; this.isActiveChannelInputEmpty = false; this.mode = CSSColorMode.HEX; this.resizeObserver = createObserver("resize", (entries) => this.resizeCanvas(entries)); this.shiftKeyChannelAdjustment = 0; this.upOrDownArrowKeyTracker = null; this._valueWasSet = false; this.messages = useT9n({ blocking: true }); this.captureColorFieldColor = (x, y, skipEqual = true) => { const { width, height } = this.dynamicDimensions.colorField; 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.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.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 { if (clientX < bounds.x + bounds.width && clientX > bounds.x) { samplingX = clientX - bounds.x; } else if (clientX < bounds.x) { samplingX = 0; } else { samplingX = bounds.width; } 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.globalPointerUpHandler = (event) => { if (!isPrimaryPointerButton(event)) { return; } const previouslyDragging = this.activeCanvasInfo; this.activeCanvasInfo = null; this.drawColorControls(); if (previouslyDragging) { this.calciteColorPickerChange.emit(); } }; this.resizeCanvas = throttle((entries) => { if (!this.hasUpdated) { return; } const [first] = entries; const availableWidth = Math.floor(first.contentBoxSize[0].inlineSize); if (this.dynamicDimensions.colorField.width === availableWidth) { return; } this.updateDynamicDimensions(availableWidth); this.updateCanvasSize(); this.drawColorControls(); }, throttleFor60FpsInMs); this.updateDynamicDimensions = (width) => { const sliderDims = { width: getSliderWidth(width, this.staticDimensions, this.alphaChannel), height: this.staticDimensions.slider.height }; this.dynamicDimensions = { colorField: getColorFieldDimensions(width), slider: sliderDims }; }; this.channelMode = "rgb"; this.channels = this.toChannels(DEFAULT_COLOR); this.staticDimensions = STATIC_DIMENSIONS.m; this.savedColors = []; this.allowEmpty = false; this.alphaChannel = false; this.channelsDisabled = false; this.clearable = false; this.disabled = false; this.format = "auto"; this.hexDisabled = false; this.savedDisabled = false; this.scale = "m"; this.calciteColorPickerChange = createEvent({ cancelable: false }); this.calciteColorPickerInput = createEvent({ cancelable: false }); this.listen("keydown", this.handleChannelKeyUpOrDown, { capture: true }); this.listen("keyup", this.handleChannelKeyUpOrDown, { capture: true }); } static { this.properties = { channelMode: [16, {}, { state: true }], channels: [16, {}, { state: true }], colorFieldScopeLeft: [16, {}, { state: true }], colorFieldScopeTop: [16, {}, { state: true }], staticDimensions: [16, {}, { state: true }], hueScopeLeft: [16, {}, { state: true }], opacityScopeLeft: [16, {}, { state: true }], savedColors: [16, {}, { state: true }], scopeOrientation: [16, {}, { state: true }], allowEmpty: [7, {}, { reflect: true, type: Boolean }], alphaChannel: [5, {}, { type: Boolean }], channelsDisabled: [5, {}, { type: Boolean }], clearable: [7, {}, { reflect: true, type: Boolean }], color: [0, {}, { attribute: false }], disabled: [7, {}, { reflect: true, type: Boolean }], format: [3, {}, { reflect: true }], hexDisabled: [5, {}, { type: Boolean }], messageOverrides: [0, {}, { attribute: false }], numberingSystem: [3, {}, { reflect: true }], savedDisabled: [7, {}, { reflect: true, type: Boolean }], scale: [3, {}, { reflect: true }], storageId: [3, {}, { reflect: true }], value: 1 }; } static { this.styles = styles; } get color() { return this._color; } set color(color) { const oldColor = this._color; this._color = color; this.handleColorChange(color, oldColor); } get value() { return this._value; } set value(value) { const oldValue = this._value; this._value = value; this.handleValueChange(value, oldValue); this._valueWasSet = true; } async setFocus() { await componentFocusable(this); focusFirstTabbable(this.el); } connectedCallback() { super.connectedCallback(); this.observeResize(); } async load() { if (!this._valueWasSet) { this._value ??= normalizeHex(hexify(DEFAULT_COLOR, this.alphaChannel)); } this.handleAllowEmptyOrClearableChange(); const { isClearable, color, format, value } = this; const willSetNoColor = isClearable && !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.updateStaticDimensions(this.scale); this.updateDynamicDimensions(STATIC_DIMENSIONS[this.scale].minWidth); const storageKey = `${DEFAULT_STORAGE_KEY_PREFIX}${this.storageId}`; if (this.storageId && localStorage.getItem(storageKey)) { this.savedColors = JSON.parse(localStorage.getItem(storageKey)); } } willUpdate(changes) { if (changes.has("allowEmpty") && (this.hasUpdated || this.allowEmpty !== false) || changes.has("clearable") && (this.hasUpdated || this.clearable !== false)) { this.handleAllowEmptyOrClearableChange(); } if (changes.has("alphaChannel") && (this.hasUpdated || this.alphaChannel !== false)) { this.handleAlphaChannelChange(this.alphaChannel); } if (this.hasUpdated && (changes.has("alphaChannel") && this.alphaChannel !== false || changes.has("staticDimensions") && this.staticDimensions !== STATIC_DIMENSIONS.m)) { this.handleAlphaChannelDimensionsChange(); } if (changes.has("alphaChannel") && (this.hasUpdated || this.alphaChannel !== false) || changes.has("format") && (this.hasUpdated || this.format !== "auto")) { this.handleFormatOrAlphaChannelChange(); } if (changes.has("scale") && (this.hasUpdated || this.scale !== "m")) { this.handleScaleChange(this.scale); } } updated() { updateHostInteraction(this); } loaded() { this.handleAlphaChannelDimensionsChange(); } disconnectedCallback() { super.disconnectedCallback(); window.removeEventListener("pointermove", this.globalPointerMoveHandler); window.removeEventListener("pointerup", this.globalPointerUpHandler); this.resizeObserver?.disconnect(); } get baseColorFieldColor() { return this.color || this.previousColor || DEFAULT_COLOR; } get effectiveSliderWidth() { return this.dynamicDimensions.slider.width; } observeResize() { this.resizeObserver?.observe(this.el); } handleAllowEmptyOrClearableChange() { this.isClearable = this.clearable || this.allowEmpty; } 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; } } handleAlphaChannelDimensionsChange() { this.drawColorControls(); } handleColorChange(color, oldColor) { this.drawColorControls(); this.updateChannelsFromColor(color); this.previousColor = oldColor; } handleFormatOrAlphaChannelChange() { this.setMode(this.format); this.internalColorSet(this.color, false, "internal"); } handleScaleChange(scale = "m") { this.updateStaticDimensions(scale); this.updateCanvasSize(); this.drawColorControls(); } handleValueChange(value, oldValue) { const { isClearable, format } = this; const checkMode = !isClearable || 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 = isClearable && !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")) || this.internalColorUpdateContext === "internal", "internal"); } } handleTabActivate(event) { this.channelMode = event.currentTarget.getAttribute("data-color-mode"); this.updateChannelsFromColor(this.color); } 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); } } 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); } } handleHexInputChange(event) { event.stopPropagation(); const { isClearable, color } = this; const input = event.target; const hex = input.value; if (isClearable && !hex) { this.internalColorSet(null); return; } const normalizedHex = color && normalizeHex(hexify(color, alphaCompatible(this.mode))); if (hex !== normalizedHex) { this.internalColorSet(Color(hex)); } } handleSavedColorSelect(event) { const swatch = event.currentTarget; this.internalColorSet(Color(swatch.color)); } 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 (!input.value) { inputValue = ""; this.isActiveChannelInputEmpty = true; this.upOrDownArrowKeyTracker = null; } else { const value = Number(input.value); const adjustedValue = value + this.shiftKeyChannelAdjustment; const clamped = clamp(adjustedValue, 0, limit); inputValue = clamped.toString(); } input.value = inputValue; if (inputValue !== "" && this.shiftKeyChannelAdjustment !== 0) { this.handleChannelChange(event); } else if (inputValue !== "") { this.handleChannelChange(event); } } handleChannelBlur(event) { const input = event.currentTarget; const channelIndex = Number(input.getAttribute("data-channel-index")); const channels = [...this.channels]; const restoreValueDueToEmptyInput = !input.value && !this.isClearable; if (restoreValueDueToEmptyInput) { input.value = channels[channelIndex]?.toString(); } } handleChannelFocus(event) { const input = event.currentTarget; input.selectText(); } 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; } const complementaryBump = 9; this.shiftKeyChannelAdjustment = key === "ArrowUp" && shiftKey ? complementaryBump : key === "ArrowDown" && shiftKey ? -9 : 0; if (key === "ArrowUp") { this.upOrDownArrowKeyTracker = "up"; } if (key === "ArrowDown") { this.upOrDownArrowKeyTracker = "down"; } } getChannelInputLimit(channelIndex) { return this.channelMode === "rgb" ? RGB_LIMITS[Object.keys(RGB_LIMITS)[channelIndex]] : HSV_LIMITS[Object.keys(HSV_LIMITS)[channelIndex]]; } handleChannelChange(event) { const input = event.currentTarget; const channelIndex = Number(input.getAttribute("data-channel-index")); const channels = [...this.channels]; const shouldClearChannels = this.isClearable && !input.value; if (shouldClearChannels) { this.channels = [null, null, null, null]; this.internalColorSet(null); return; } const isAlphaChannel = channelIndex === 3; if (this.isActiveChannelInputEmpty && this.upOrDownArrowKeyTracker) { input.value = this.upOrDownArrowKeyTracker === "up" ? (channels[channelIndex] + 1 <= this.getChannelInputLimit(channelIndex) ? channels[channelIndex] + 1 : this.getChannelInputLimit(channelIndex)).toString() : (channels[channelIndex] - 1 >= 0 ? channels[channelIndex] - 1 : 0).toString(); this.isActiveChannelInputEmpty = false; this.upOrDownArrowKeyTracker = null; } const value = input.value ? Number(input.value) : channels[channelIndex]; channels[channelIndex] = isAlphaChannel ? opacityToAlpha(value) : value; this.updateColorFromChannels(channels); } handleSavedColorKeyDown(event) { if (isActivationKey(event.key)) { event.preventDefault(); this.handleSavedColorSelect(event); } } handleColorFieldPointerDown(event) { this.handleCanvasControlPointerDown(event, this.colorFieldRenderingContext, this.captureColorFieldColor, this.colorFieldScopeNode); } focusScope(focusEl) { requestAnimationFrame(() => { focusEl.focus(); }); } handleHueSliderPointerDown(event) { this.handleCanvasControlPointerDown(event, this.hueSliderRenderingContext, this.captureHueSliderColor, this.hueScopeNode); } handleOpacitySliderPointerDown(event) { this.handleCanvasControlPointerDown(event, this.opacitySliderRenderingContext, this.captureOpacitySliderValue, this.opacityScopeNode); } handleCanvasControlPointerDown(event, renderingContext, captureValue, scopeNode) { if (!isPrimaryPointerButton(event)) { return; } window.addEventListener("pointermove", this.globalPointerMoveHandler); window.addEventListener("pointerup", this.globalPointerUpHandler, { once: true }); this.activeCanvasInfo = { context: renderingContext, bounds: renderingContext.canvas.getBoundingClientRect() }; captureValue.call(this, event.offsetX, event.offsetY); this.focusScope(scopeNode); } storeColorFieldScope(node) { this.colorFieldScopeNode = node; } storeHueScope(node) { this.hueScopeNode = node; } 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 hue = HUE_LIMIT_CONSTRAINED / this.effectiveSliderWidth * x; this.internalColorSet(this.baseColorFieldColor.hue(hue), false); } captureOpacitySliderValue(x) { const alpha = opacityToAlpha(OPACITY_LIMITS.max / this.effectiveSliderWidth * 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(); 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 { staticDimensions: { slider: { height }, thumb: { radius } } } = this; return radius * 2 - height; } updateStaticDimensions(scale = "m") { this.staticDimensions = STATIC_DIMENSIONS[scale]; } 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)); } } 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)); } } drawColorField() { const context = this.colorFieldRenderingContext; const { width, height } = this.dynamicDimensions.colorField; 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); } initColorField(canvas) { if (!canvas) { return; } this.colorFieldRenderingContext = canvas.getContext("2d"); this.updateCanvasSize("color-field"); this.drawColorControls(); } initHueSlider(canvas) { if (!canvas) { return; } this.hueSliderRenderingContext = canvas.getContext("2d"); this.updateCanvasSize("hue-slider"); this.drawHueSlider(); } initOpacitySlider(canvas) { if (!canvas) { return; } this.opacitySliderRenderingContext = canvas.getContext("2d"); this.updateCanvasSize("opacity-slider"); this.drawOpacitySlider(); } updateCanvasSize(context = "all") { const { dynamicDimensions, staticDimensions } = this; if (context === "all" || context === "color-field") { this.setCanvasContextSize(this.colorFieldRenderingContext?.canvas, dynamicDimensions.colorField); } const adjustedSliderDimensions = { width: this.effectiveSliderWidth, height: staticDimensions.slider.height + (staticDimensions.thumb.radius - dynamicDimensions.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 { staticDimensions: { thumb: { radius } } } = this; const { width, height } = this.dynamicDimensions.colorField; 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, false); } drawThumb(context, radius, x, y, color, applyAlpha) { const startAngle = 0; const endAngle = 2 * Math.PI; const outlineWidth = 1; 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(); if (applyAlpha && color.alpha() < 1) { const pattern = context.createPattern(this.getCheckeredBackgroundPattern(), "repeat"); context.beginPath(); context.arc(x, y, radius - 3, startAngle, endAngle); context.fillStyle = pattern; context.fill(); } context.globalCompositeOperation = "source-atop"; context.beginPath(); context.arc(x, y, radius - 3, startAngle, endAngle); const alpha = applyAlpha ? color.alpha() : 1; context.fillStyle = color.rgb().alpha(alpha).string(); context.fill(); context.globalCompositeOperation = "source-over"; } drawActiveHueSliderColor() { const { color } = this; if (!color) { return; } const hsvColor = color.hsv().saturationv(100).value(100); const { staticDimensions: { thumb: { radius } } } = this; const width = this.effectiveSliderWidth; const x = hsvColor.hue() / (HUE_LIMIT_CONSTRAINED / width); const y = radius; const sliderBoundX = this.getSliderBoundX(x, width, radius); requestAnimationFrame(() => { this.hueScopeLeft = sliderBoundX; }); this.drawThumb(this.hueSliderRenderingContext, radius, sliderBoundX, y, hsvColor, false); } drawHueSlider() { const context = this.hueSliderRenderingContext; const { staticDimensions: { slider: { height }, thumb: { radius: thumbRadius } } } = this; const x = 0; const y = thumbRadius - height / 2; const width = this.effectiveSliderWidth; const gradient = context.createLinearGradient(0, 0, width, 0); const hueSliderColorStopKeywords = [ "red", "yellow", "lime", "cyan", "blue", "magenta", "#ff0004" ]; 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, staticDimensions: { slider: { height }, thumb: { radius: thumbRadius } } } = this; const x = 0; const y = thumbRadius - height / 2; const width = this.effectiveSliderWidth; 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 { staticDimensions: { thumb: { radius } } } = this; const width = this.effectiveSliderWidth; const x = alphaToOpacity(hsvColor.alpha()) / (OPACITY_LIMITS.max / width); const y = radius; const sliderBoundX = this.getSliderBoundX(x, width, radius); requestAnimationFrame(() => { this.opacityScopeLeft = sliderBoundX; }); this.drawThumb(this.opacitySliderRenderingContext, radius, sliderBoundX, y, hsvColor, true); } getSliderBoundX(x, width, radius) { const closeToEdge = closeToRangeEdge(x, width, radius); return closeToEdge === 0 ? x : closeToEdge === -1 ? remap(x, 0, width, radius, radius * 2) : remap(x, 0, width, width - radius * 2, width - radius); } storeOpacityScope(node) { this.opacityScopeNode = node; } handleOpacityScopeKeyDown(event) { const modifier = event.shiftKey ? 10 : 1; const { key } = event; const arrowKeyToXOffset = { ArrowUp: 0.01, ArrowRight: 0.01, ArrowDown: -0.01, ArrowLeft: -0.01 }; if (arrowKeyToXOffset[key]) { event.preventDefault(); const delta = arrowKeyToXOffset[key] * modifier; const alpha = this.baseColorFieldColor.alpha(); const color = this.baseColorFieldColor.alpha(alpha + delta); this.internalColorSet(color, false); } } 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); } return channels; } getAdjustedScopePosition(left, top) { return [left - SCOPE_SIZE / 2, top - SCOPE_SIZE / 2]; } render() { const { channelsDisabled, color, colorFieldScopeLeft, colorFieldScopeTop, staticDimensions: { thumb: { radius: thumbRadius } }, hexDisabled, hueScopeLeft, messages, alphaChannel, opacityScopeLeft, savedColors, savedDisabled, scale, scopeOrientation } = this; const sliderWidth = this.effectiveSliderWidth; 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 ?? sliderWidth * alphaToOpacity(DEFAULT_COLOR.alpha()) / OPACITY_LIMITS.max; const noColor = color === void 0; const vertical = scopeOrientation === "vertical"; const [adjustedColorFieldScopeLeft, adjustedColorFieldScopeTop] = this.getAdjustedScopePosition(colorFieldScopeLeft, colorFieldScopeTop); const [adjustedHueScopeLeft, adjustedHueScopeTop] = this.getAdjustedScopePosition(hueLeft, hueTop); const [adjustedOpacityScopeLeft, adjustedOpacityScopeTop] = this.getAdjustedScopePosition(opacityLeft, opacityTop); return InteractiveContainer({ disabled: this.disabled, children: html`<div class=${safeClassMap(CSS.container)}><div class=${safeClassMap(CSS.controlAndScope)}><canvas class=${safeClassMap(CSS.colorField)} @pointerdown=${this.handleColorFieldPointerDown} ${ref(this.initColorField)}></canvas><div .ariaLabel=${vertical ? messages.value : messages.saturation} .ariaValueMax=${vertical ? HSV_LIMITS.v : HSV_LIMITS.s} aria-valuemin=0 .ariaValueNow=${(vertical ? color?.saturationv() : color?.value()) || "0"} class=${safeClassMap({ [CSS.scope]: true, [CSS.colorFieldScope]: true })} @keydown=${this.handleColorFieldScopeKeyDown} role=slider style=${safeStyleMap({ top: `${adjustedColorFieldScopeTop || 0}px`, left: `${adjustedColorFieldScopeLeft || 0}px` })} tabindex=0 ${ref(this.storeColorFieldScope)}></div></div><div class=${safeClassMap(CSS.previewAndSliders)}><calcite-color-picker-swatch class=${safeClassMap(CSS.preview)} .color=${selectedColorInHex} .scale=${this.alphaChannel ? "l" : this.scale}></calcite-color-picker-swatch><div class=${safeClassMap(CSS.sliders)}><div class=${safeClassMap(CSS.controlAndScope)}><canvas class=${safeClassMap({ [CSS.slider]: true, [CSS.hueSlider]: true })} @pointerdown=${this.handleHueSliderPointerDown} ${ref(this.initHueSlider)}></canvas><div .ariaLabel=${messages.hue} .ariaValueMax=${HSV_LIMITS.h} aria-valuemin=0 .ariaValueNow=${color?.round().hue() || DEFAULT_COLOR.round().hue()} class=${safeClassMap({ [CSS.scope]: true, [CSS.hueScope]: true })} @keydown=${this.handleHueScopeKeyDown} role=slider style=${safeStyleMap({ top: `${adjustedHueScopeTop}px`, left: `${adjustedHueScopeLeft}px` })} tabindex=0 ${ref(this.storeHueScope)}></div></div>${alphaChannel ? html`<div class=${safeClassMap(CSS.controlAndScope)}><canvas class=${safeClassMap({ [CSS.slider]: true, [CSS.opacitySlider]: true })} @pointerdown=${this.handleOpacitySliderPointerDown} ${ref(this.initOpacitySlider)}></canvas><div .ariaLabel=${messages.opacity} .ariaValueMax=${OPACITY_LIMITS.max} .ariaValueMin=${OPACITY_LIMITS.min} .ariaValueNow=${(color || DEFAULT_COLOR).round().alpha()} class=${safeClassMap({ [CSS.scope]: true, [CSS.opacityScope]: true })} @keydown=${this.handleOpacityScopeKeyDown} role=slider style=${safeStyleMap({ top: `${adjustedOpacityScopeTop}px`, left: `${adjustedOpacityScopeLeft}px` })} tabindex=0 ${ref(this.storeOpacityScope)}></div></div>` : null}</div></div>${hexDisabled && channelsDisabled ? null : html`<div class=${safeClassMap({ [CSS.controlSection]: true, [CSS.section]: true })}><div class=${safeClassMap(CSS.hexAndChannelsGroup)}>${hexDisabled ? null : html`<div class=${safeClassMap(CSS.hexOptions)}><calcite-color-picker-hex-input .allowEmpty=${this.isClearable} .alphaChannel=${alphaChannel} class=${safeClassMap(CSS.control)} .messages=${messages} .numberingSystem=${this.numberingSystem} @calciteColorPickerHexInputChange=${this.handleHexInputChange} .scale=${scale} .value=${selectedColorInHex}></calcite-color-picker-hex-input></div>`}${channelsDisabled ? null : html`<calcite-tabs class=${safeClassMap({ [CSS.colorModeContainer]: true, [CSS.splitSection]: true })} .scale=${scale === "l" ? "m" : "s"}><calcite-tab-nav slot=title-group>${this.renderChannelsTabTitle("rgb")}${this.renderChannelsTabTitle("hsv")}</calcite-tab-nav>${this.renderChannelsTab("rgb")}${this.renderChannelsTab("hsv")}</calcite-tabs>`}</div></div>`}${savedDisabled ? null : html`<div class=${safeClassMap({ [CSS.savedColorsSection]: true, [CSS.section]: true })}><div class=${safeClassMap(CSS.header)}><label>${messages.saved}</label><div class=${safeClassMap(CSS.savedColorsButtons)}><calcite-button appearance=transparent class=${safeClassMap(CSS.deleteColor)} .disabled=${noColor} icon-start=minus kind=neutral .label=${messages.deleteColor} @click=${this.deleteColor} .scale=${scale} type=button></calcite-button><calcite-button appearance=transparent class=${safeClassMap(CSS.saveColor)} .disabled=${noColor} icon-start=plus kind=neutral .label=${messages.saveColor} @click=${this.saveColor} .scale=${scale} type=button></calcite-button></div></div>${savedColors.length > 0 ? html`<div class=${safeClassMap(CSS.savedColors)}>${repeat(savedColors, (color2) => color2, (color2) => html`<calcite-color-picker-swatch class=${safeClassMap(CSS.savedColor)} .color=${color2} @click=${this.handleSavedColorSelect} @keydown=${this.handleSavedColorKeyDown} .scale=${scale} tabindex=0></calcite-color-picker-swatch>`)}</div>` : null}</div>`}</div>` }); } renderChannelsTabTitle(channelMode) { const { channelMode: activeChannelMode, messages } = this; const selected = channelMode === activeChannelMode; const label = channelMode === "rgb" ? messages.rgb : messages.hsv; return keyed(channelMode, html`<calcite-tab-title class=${safeClassMap(CSS.colorMode)} data-color-mode=${channelMode ?? nothing} @calciteTabsActivate=${this.handleTabActivate} .selected=${selected}>${label}</calcite-tab-title>`); } renderChannelsTab(channelMode) { const { isClearable, 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 keyed(channelMode, html`<calcite-tab class=${safeClassMap(CSS.control)} .selected=${selected}><div class=${safeClassMap(CSS.channels)} dir=ltr>${channelsToRender.map((channelValue, index) => { const isAlphaChannel = index === 3; if (isAlphaChannel) { channelValue = isClearable && !channelValue ? channelValue : alphaToOpacity(channelValue); } return this.renderChannel(channelValue, index, channelAriaLabels[index], direction, isAlphaChannel ? "%" : ""); })}</div></calcite-tab>`); } renderChannel(value, index, ariaLabel, direction, suffix) { return keyed(index, html`<calcite-input-number class=${safeClassMap(CSS.channel)} data-channel-index=${index ?? nothing} dir=${direction ?? nothing} .label=${ariaLabel} lang=${this.messages._lang ?? nothing} number-button-type=none .numberingSystem=${this.numberingSystem} @keydown=${this.handleKeyDown} @calciteInputNumberChange=${this.handleChannelChange} @calciteInputNumberInput=${this.handleChannelInput} @calciteInternalInputNumberBlur=${this.handleChannelBlur} @calciteInternalInputNumberFocus=${this.handleChannelFocus} .scale=${this.scale === "l" ? "m" : "s"} style=${safeStyleMap({ marginLeft: index > 0 && !(this.scale === "s" && this.alphaChannel && index === 3) ? "-1px" : "" })} .suffixText=${suffix} .value=${value?.toString()}></calcite-input-number>`); } } customElement("calcite-color-picker", ColorPicker); export { ColorPicker };