diff --git a/packages/calcite-components/src/components/color-picker-hex-input/color-picker-hex-input.e2e.ts b/packages/calcite-components/src/components/color-picker-hex-input/color-picker-hex-input.e2e.ts
index 2466e3e2538..1d27deacd88 100644
--- a/packages/calcite-components/src/components/color-picker-hex-input/color-picker-hex-input.e2e.ts
+++ b/packages/calcite-components/src/components/color-picker-hex-input/color-picker-hex-input.e2e.ts
@@ -108,6 +108,66 @@ describe("calcite-color-picker-hex-input", () => {
expect(await input.getProperty("value")).toBe("#fafafafa");
});
+ it("commits shorthand hex on blur", async () => {
+ const defaultHex = "#b33f33";
+ const editedHex = "#aabbcc";
+ const page = await newE2EPage();
+ await page.setContent(``);
+
+ const input = await page.find(`calcite-color-picker-hex-input`);
+ await selectText(input);
+ await page.keyboard.type("ab");
+ await page.keyboard.press("Tab");
+ await page.waitForChanges();
+
+ expect(await input.getProperty("value")).toBe(defaultHex);
+
+ await selectText(input);
+ await page.keyboard.type("abc");
+ await page.keyboard.press("Tab");
+ await page.waitForChanges();
+
+ expect(await input.getProperty("value")).toBe(editedHex);
+
+ await selectText(input);
+ await page.keyboard.type("abcd");
+ await page.keyboard.press("Tab");
+ await page.waitForChanges();
+
+ expect(await input.getProperty("value")).toBe(editedHex);
+ });
+
+ it("commits shorthand hexa on blur", async () => {
+ const defaultHexa = "#b33f33ff";
+ const editedHexa = "#aabbccdd";
+ const page = await newE2EPage();
+ await page.setContent(
+ ``,
+ );
+
+ const input = await page.find(`calcite-color-picker-hex-input`);
+ await selectText(input);
+ await page.keyboard.type("abc");
+ await page.keyboard.press("Tab");
+ await page.waitForChanges();
+
+ expect(await input.getProperty("value")).toBe(defaultHexa);
+
+ await selectText(input);
+ await page.keyboard.type("abcd");
+ await page.keyboard.press("Tab");
+ await page.waitForChanges();
+
+ expect(await input.getProperty("value")).toBe(editedHexa);
+
+ await selectText(input);
+ await page.keyboard.type("abcde");
+ await page.keyboard.press("Tab");
+ await page.waitForChanges();
+
+ expect(await input.getProperty("value")).toBe(editedHexa);
+ });
+
it("normalizes value when initialized", async () => {
const page = await newE2EPage();
await page.setContent("");
@@ -272,11 +332,10 @@ describe("calcite-color-picker-hex-input", () => {
await selectText(input);
const longhandHexWithExtraChars = "bbbbbbbbc";
await page.keyboard.type(longhandHexWithExtraChars);
- await page.keyboard.press("Enter");
await page.waitForChanges();
- const hexWithPreviousAlphaCharsPreserved = "#bbbbbbdd";
- expect(await input.getProperty("value")).toBe(hexWithPreviousAlphaCharsPreserved);
+ const hexWithAlphaCharsPreserved = "#bbbbbbbb";
+ expect(await input.getProperty("value")).toBe(hexWithAlphaCharsPreserved);
});
describe("keyboard interaction", () => {
diff --git a/packages/calcite-components/src/components/color-picker-hex-input/color-picker-hex-input.tsx b/packages/calcite-components/src/components/color-picker-hex-input/color-picker-hex-input.tsx
index 5cdcabc7942..a2090909e2f 100644
--- a/packages/calcite-components/src/components/color-picker-hex-input/color-picker-hex-input.tsx
+++ b/packages/calcite-components/src/components/color-picker-hex-input/color-picker-hex-input.tsx
@@ -18,6 +18,7 @@ import {
hexChar,
hexify,
isLonghandHex,
+ isShorthandHex,
isValidHex,
normalizeHex,
opacityToAlpha,
@@ -146,8 +147,10 @@ export class ColorPickerHexInput implements LoadableComponent {
const willClearValue = allowEmpty && !inputValue;
const isLonghand = isLonghandHex(hex);
- // ensure modified pasted hex values are committed since we prevent default to remove the # char.
- this.onHexInputChange();
+ if (isShorthandHex(hex, this.alphaChannel)) {
+ // ensure modified pasted hex values are committed since we prevent default to remove the # char.
+ this.onHexInputChange();
+ }
if (willClearValue || (isValidHex(hex) && isLonghand)) {
return;
@@ -180,6 +183,10 @@ export class ColorPickerHexInput implements LoadableComponent {
allowEmpty && !internalColor ? "" : this.formatOpacityForInternalInput(internalColor);
};
+ private onOpacityInputInput = (): void => {
+ this.onOpacityInputChange();
+ };
+
private onHexInputChange = (): void => {
const nodeValue = this.hexInputNode.value;
let value = nodeValue;
@@ -210,7 +217,13 @@ export class ColorPickerHexInput implements LoadableComponent {
this.internalSetValue(value, this.value);
};
- private onHexInput = (): void => {
+ private onInputFocus = (event: Event): void => {
+ event.type === "calciteInternalInputTextFocus"
+ ? this.hexInputNode.selectText()
+ : this.opacityInputNode.selectText();
+ };
+
+ private onHexInputInput = (): void => {
const hexInputValue = `#${this.hexInputNode.value}`;
const oldValue = this.value;
@@ -228,7 +241,7 @@ export class ColorPickerHexInput implements LoadableComponent {
const { key } = event;
const composedPath = event.composedPath();
- if (key === "Tab" || key === "Enter") {
+ if ((key === "Tab" && isShorthandHex(value, this.alphaChannel)) || key === "Enter") {
if (composedPath.includes(hexInputNode)) {
this.onHexInputChange();
} else {
@@ -326,10 +339,11 @@ export class ColorPickerHexInput implements LoadableComponent {
{
const channelInput = await page.find(`calcite-color-picker >>> .${CSS.channel}`);
await selectText(channelInput);
await channelInput.type("254");
- await channelInput.press("Enter");
await page.waitForChanges();
- expect(changeSpy).toHaveReceivedEventTimes(4);
- expect(inputSpy).toHaveReceivedEventTimes(4);
+ expect(changeSpy).toHaveReceivedEventTimes(6);
+ expect(inputSpy).toHaveReceivedEventTimes(6);
// change by clicking stored color
await (await page.find(`calcite-color-picker >>> .${CSS.savedColor}`)).click();
- expect(changeSpy).toHaveReceivedEventTimes(5);
- expect(inputSpy).toHaveReceivedEventTimes(5);
+ expect(changeSpy).toHaveReceivedEventTimes(7);
+ expect(inputSpy).toHaveReceivedEventTimes(7);
// change by dragging color field thumb
const mouseDragSteps = 10;
@@ -222,8 +221,8 @@ describe("calcite-color-picker", () => {
await page.mouse.up();
await page.waitForChanges();
- expect(changeSpy).toHaveReceivedEventTimes(6);
- expect(inputSpy.length).toBeGreaterThan(6); // input event fires more than once
+ expect(changeSpy).toHaveReceivedEventTimes(8);
+ expect(inputSpy.length).toBeGreaterThan(8); // input event fires more than once
// change by dragging hue slider thumb
[hueScopeX, hueScopeY] = await getElementXY(page, "calcite-color-picker", `.${CSS.hueScope}`);
@@ -235,7 +234,7 @@ describe("calcite-color-picker", () => {
await page.mouse.up();
await page.waitForChanges();
- expect(changeSpy).toHaveReceivedEventTimes(7);
+ expect(changeSpy).toHaveReceivedEventTimes(9);
expect(inputSpy.length).toBeGreaterThan(previousInputEventLength + 1); // input event fires more than once
previousInputEventLength = inputSpy.length;
@@ -246,10 +245,112 @@ describe("calcite-color-picker", () => {
picker.setProperty("value", "#fff");
await page.waitForChanges();
- expect(changeSpy).toHaveReceivedEventTimes(7);
+ expect(changeSpy).toHaveReceivedEventTimes(9);
expect(inputSpy.length).toBe(previousInputEventLength);
});
+ it("increments channel's value by 1 when clearing input and pressing ArrowUp. Same should apply to other channel inputs", async () => {
+ const page = await newE2EPage();
+ await page.setContent("");
+ const channelInput = await page.find(`calcite-color-picker >>> .${CSS.channel}`);
+ const currentValue = await channelInput.getProperty("value");
+
+ await selectText(channelInput);
+ await page.keyboard.press("Backspace");
+ await page.keyboard.press("ArrowUp");
+ await page.waitForChanges();
+
+ expect(await channelInput.getProperty("value")).toBe(`${Number(currentValue) + 1}`);
+ });
+
+ it("decrements channel's value by 1 when clearing input and pressing ArrowDown. Same should apply to other channel inputs", async () => {
+ const page = await newE2EPage();
+ await page.setContent("");
+ const channelInput = await page.find(`calcite-color-picker >>> .${CSS.channel}`);
+ const currentValue = await channelInput.getProperty("value");
+
+ await selectText(channelInput);
+ await page.keyboard.press("Backspace");
+ await page.keyboard.press("ArrowDown");
+ await page.waitForChanges();
+
+ expect(await channelInput.getProperty("value")).toBe(`${Number(currentValue) - 1}`);
+ });
+
+ it("prevents channel's value from going over its limit when clearing input and pressing ArrowUp. Same should apply to other channel inputs", async () => {
+ const page = await newE2EPage();
+ await page.setContent("");
+ const channelInput = await page.find(`calcite-color-picker >>> .${CSS.channel}`);
+
+ await selectText(channelInput);
+ await page.keyboard.press("Backspace");
+ await page.keyboard.press("ArrowUp");
+ await page.waitForChanges();
+
+ expect(await channelInput.getProperty("value")).toBe("255");
+ });
+
+ it("prevents channel's value from being less than 0 when clearing input and pressing ArrowDown. Same should apply to other channel inputs", async () => {
+ const page = await newE2EPage();
+ await page.setContent("");
+ const channelInput = await page.find(`calcite-color-picker >>> .${CSS.channel}`);
+
+ await selectText(channelInput);
+ await page.keyboard.press("Backspace");
+ await page.keyboard.press("ArrowDown");
+ await page.waitForChanges();
+
+ expect(await channelInput.getProperty("value")).toBe("0");
+ });
+
+ it("restores original channel value when input is cleared and blur is triggered. Same should apply to other channel inputs", async () => {
+ const page = await newE2EPage();
+ await page.setContent("");
+ const channelInput = await page.find(`calcite-color-picker >>> .${CSS.channel}`);
+ const currentValue = await channelInput.getProperty("value");
+
+ await selectText(channelInput);
+ await page.keyboard.press("Backspace");
+ await page.keyboard.press("Tab");
+ await page.waitForChanges();
+
+ expect(await channelInput.getProperty("value")).toBe(currentValue);
+ });
+
+ it("auto commits channel value when typing. Same should apply to other channel inputs", async () => {
+ const page = await newE2EPage();
+ await page.setContent("");
+
+ const channelInput = await page.find(`calcite-color-picker >>> .${CSS.channel}`);
+ const picker = await page.find("calcite-color-picker");
+ const changeSpy = await picker.spyOnEvent("calciteColorPickerChange");
+
+ await selectText(channelInput);
+ await page.keyboard.type("123");
+ await page.waitForChanges();
+
+ expect(changeSpy).toHaveReceivedEventTimes(3);
+ expect(await channelInput.getProperty("value")).toBe("123");
+ });
+
+ it("blurs focused input when clicking anywhere within the component. It should apply to all inputs", async () => {
+ const page = await newE2EPage();
+ await page.setContent("");
+
+ const channelInput = await page.find(`calcite-color-picker >>> .${CSS.channel}`);
+ const currentValue = await channelInput.getProperty("value");
+ const picker = await page.find("calcite-color-picker");
+ const blurSpy = await picker.spyOnEvent("calciteInternalInputNumberBlur");
+
+ await selectText(channelInput);
+ await page.keyboard.press("Backspace");
+ await page.mouse.click(0, 0);
+ await page.waitForChanges();
+
+ expect(blurSpy).toHaveReceivedEventTimes(1);
+ expect(await channelInput.getProperty("value")).toBe(currentValue);
+ });
+
it("does not emit on initialization", async () => {
const page = await newProgrammaticE2EPage();
diff --git a/packages/calcite-components/src/components/color-picker/color-picker.tsx b/packages/calcite-components/src/components/color-picker/color-picker.tsx
index cc6f8d9b68d..53de0c34a36 100644
--- a/packages/calcite-components/src/components/color-picker/color-picker.tsx
+++ b/packages/calcite-components/src/components/color-picker/color-picker.tsx
@@ -13,7 +13,12 @@ import {
} from "@stencil/core";
import Color from "color";
import { throttle } from "lodash-es";
-import { Direction, getElementDir, isPrimaryPointerButton } from "../../utils/dom";
+import {
+ Direction,
+ focusFirstTabbable,
+ getElementDir,
+ isPrimaryPointerButton,
+} from "../../utils/dom";
import { Scale } from "../interfaces";
import {
connectInteractive,
@@ -79,9 +84,7 @@ const throttleFor60FpsInMs = 16;
@Component({
tag: "calcite-color-picker",
styleUrl: "color-picker.scss",
- shadow: {
- delegatesFocus: true,
- },
+ shadow: true,
assetsDirs: ["assets"],
})
export class ColorPicker
@@ -328,6 +331,8 @@ export class ColorPicker
private internalColorUpdateContext: "internal" | "initial" | "user-interaction" | null = null;
+ private isActiveChannelInputEmpty: boolean = false;
+
private isClearable: boolean;
private mode: SupportedMode = CSSColorMode.HEX;
@@ -340,6 +345,8 @@ export class ColorPicker
private shiftKeyChannelAdjustment = 0;
+ private upOrDownArrowKeyTracker: "down" | "up" | null = null;
+
@State() channelMode: ColorMode = "rgb";
@State() channels: Channels = this.toChannels(DEFAULT_COLOR);
@@ -476,8 +483,11 @@ export class ColorPicker
let inputValue: string;
- if (this.isClearable && !input.value) {
+ if (!input.value) {
inputValue = "";
+ this.isActiveChannelInputEmpty = true;
+ // reset this to allow typing in new value, when channel input is cleared after ArrowUp or ArrowDown have been pressed
+ this.upOrDownArrowKeyTracker = null;
} else {
const value = Number(input.value);
const adjustedValue = value + this.shiftKeyChannelAdjustment;
@@ -491,9 +501,27 @@ export class ColorPicker
if (inputValue !== "" && this.shiftKeyChannelAdjustment !== 0) {
// we treat nudging as a change event since the input won't emit when modifying the value directly
this.handleChannelChange(event);
+ } else if (inputValue !== "") {
+ this.handleChannelChange(event);
}
};
+ private handleChannelBlur = (event: CustomEvent): void => {
+ const input = event.currentTarget as HTMLCalciteInputNumberElement;
+ const channelIndex = Number(input.getAttribute("data-channel-index"));
+ const channels = [...this.channels] as this["channels"];
+ const restoreValueDueToEmptyInput = !input.value && !this.isClearable;
+
+ if (restoreValueDueToEmptyInput) {
+ input.value = channels[channelIndex]?.toString();
+ }
+ };
+
+ handleChannelFocus = (event: Event): void => {
+ const input = event.currentTarget as HTMLCalciteInputNumberElement;
+ input.selectText();
+ };
+
// using @Listen as a workaround for VDOM listener not firing
@Listen("keydown", { capture: true })
@Listen("keyup", { capture: true })
@@ -526,6 +554,19 @@ export class ColorPicker
: key === "ArrowDown" && shiftKey
? -complementaryBump
: 0;
+
+ if (key === "ArrowUp") {
+ this.upOrDownArrowKeyTracker = "up";
+ }
+ if (key === "ArrowDown") {
+ this.upOrDownArrowKeyTracker = "down";
+ }
+ }
+
+ private getChannelInputLimit(channelIndex: number): number {
+ return this.channelMode === "rgb"
+ ? RGB_LIMITS[Object.keys(RGB_LIMITS)[channelIndex]]
+ : HSV_LIMITS[Object.keys(HSV_LIMITS)[channelIndex]];
}
private handleChannelChange = (event: CustomEvent): void => {
@@ -542,7 +583,19 @@ export class ColorPicker
}
const isAlphaChannel = channelIndex === 3;
- const value = Number(input.value);
+
+ 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);
@@ -570,9 +623,15 @@ export class ColorPicker
bounds: this.colorFieldRenderingContext.canvas.getBoundingClientRect(),
};
this.captureColorFieldColor(offsetX, offsetY);
- this.colorFieldScopeNode.focus();
+ this.focusScope(this.colorFieldScopeNode);
};
+ private focusScope(focusEl: HTMLElement): void {
+ requestAnimationFrame(() => {
+ focusEl.focus();
+ });
+ }
+
private handleHueSliderPointerDown = (event: PointerEvent): void => {
if (!isPrimaryPointerButton(event)) {
return;
@@ -588,7 +647,7 @@ export class ColorPicker
bounds: this.hueSliderRenderingContext.canvas.getBoundingClientRect(),
};
this.captureHueSliderColor(offsetX);
- this.hueScopeNode.focus();
+ this.focusScope(this.hueScopeNode);
};
private handleOpacitySliderPointerDown = (event: PointerEvent): void => {
@@ -606,7 +665,7 @@ export class ColorPicker
bounds: this.opacitySliderRenderingContext.canvas.getBoundingClientRect(),
};
this.captureOpacitySliderValue(offsetX);
- this.opacityScopeNode.focus();
+ this.focusScope(this.opacityScopeNode);
};
private globalPointerUpHandler = (event: PointerEvent): void => {
@@ -679,7 +738,8 @@ export class ColorPicker
@Method()
async setFocus(): Promise {
await componentFocusable(this);
- this.el.focus();
+
+ focusFirstTabbable(this.el);
}
//--------------------------------------------------------------------------
@@ -1050,6 +1110,8 @@ export class ColorPicker
numberingSystem={this.numberingSystem}
onCalciteInputNumberChange={this.handleChannelChange}
onCalciteInputNumberInput={this.handleChannelInput}
+ onCalciteInternalInputNumberBlur={this.handleChannelBlur}
+ onCalciteInternalInputNumberFocus={this.handleChannelFocus}
onKeyDown={this.handleKeyDown}
scale={this.scale === "l" ? "m" : "s"}
// workaround to ensure input borders overlap as desired