From b2be62566821e36b985d133b1c867b3cfb51fa84 Mon Sep 17 00:00:00 2001 From: Abraham Preciado Morales Date: Fri, 19 Jul 2024 16:23:47 -0700 Subject: [PATCH] feat(color-picker, color-picker-hex-input): Add input auto commit, blur and auto select enhancements. (#9701) **Related Issue:** [#9624](https://github.com/Esri/calcite-design-system/issues/9624) ## Summary Updates `color-picker` and `color-picker-hex-input` to: -Auto select values when clicking on input fields. This applies to all inputs. -Auto commit a valid 6-char (or 8-char if alpha-channel is enabled). -Commit a 3-char (or 4-char with alpha-channel) shorthand value onBlur. -Auto commit channel inputs. If the user deletes the value completely, there will be no change. If left blank, the original value will be restored onBlur. -Increase/decrease channel input value, when the input is cleared and Arrow-up or Arrow-down are pressed. -Blur focused input when clicking anywhere within the component, this applies to all input fields. --- .../color-picker-hex-input.e2e.ts | 65 +++++++++- .../color-picker-hex-input.tsx | 29 +++-- .../color-picker/color-picker.e2e.ts | 119 ++++++++++++++++-- .../components/color-picker/color-picker.tsx | 82 ++++++++++-- 4 files changed, 266 insertions(+), 29 deletions(-) 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