@esri/calcite-components
Version:
Web Components for Esri's Calcite Design System.
907 lines (906 loc) • 45.7 kB
JavaScript
/*! 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)} =${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 })} =${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 })} =${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 })} =${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 })} =${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 })} =${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} =${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} =${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} =${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} =${this.handleSavedColorSelect} =${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} =${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} =${this.handleKeyDown} =${this.handleChannelChange} =${this.handleChannelInput} =${this.handleChannelBlur} =${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
};