From 83c58080ddfcf7ca487f30c3c3057a907fcc2960 Mon Sep 17 00:00:00 2001 From: JC Franco Date: Tue, 2 May 2023 19:55:02 -0700 Subject: [PATCH] feat(color-picker): add support for alpha channel (deprecates `hideChannels`, `hideHex`, `hideSaved`) (#2841) **Related Issue:** #749 ## Summary This brings opacity support to the color picker along with some design updates. --- .../color-picker-hex-input.e2e.ts | 519 +++-- .../color-picker-hex-input.scss | 36 +- .../color-picker-hex-input.tsx | 270 ++- .../color-picker-hex-input/resources.ts | 4 +- .../color-picker-swatch.e2e.ts | 71 +- .../color-picker-swatch.scss | 19 +- .../color-picker-swatch.stories.ts | 35 + .../color-picker-swatch.tsx | 114 +- .../color-picker-swatch/resources.ts | 10 +- .../color-picker/color-picker.e2e.ts | 1670 ++++++++++++----- src/components/color-picker/color-picker.scss | 138 +- .../color-picker/color-picker.stories.ts | 19 +- src/components/color-picker/color-picker.tsx | 1000 ++++++---- src/components/color-picker/interfaces.ts | 2 + src/components/color-picker/resources.ts | 63 +- src/components/color-picker/utils.spec.ts | 78 +- src/components/color-picker/utils.ts | 176 +- src/demos/color-picker.html | 18 +- 18 files changed, 2966 insertions(+), 1276 deletions(-) create mode 100644 src/components/color-picker-swatch/color-picker-swatch.stories.ts diff --git a/src/components/color-picker-hex-input/color-picker-hex-input.e2e.ts b/src/components/color-picker-hex-input/color-picker-hex-input.e2e.ts index 8461bbbac13..f92c08662dc 100644 --- a/src/components/color-picker-hex-input/color-picker-hex-input.e2e.ts +++ b/src/components/color-picker-hex-input/color-picker-hex-input.e2e.ts @@ -1,7 +1,7 @@ import { E2EElement, E2EPage, newE2EPage } from "@stencil/core/testing"; import { accessible, defaults, focusable, hidden, reflects, renders } from "../../tests/commonTests"; import { selectText } from "../../tests/utils"; -import { isValidHex, normalizeHex } from "../color-picker/utils"; +import { canConvertToHexa, isValidHex, normalizeHex } from "../color-picker/utils"; import { CSS } from "./resources"; describe("calcite-color-picker-hex-input", () => { @@ -23,6 +23,10 @@ describe("calcite-color-picker-hex-input", () => { propertyName: "allowEmpty", defaultValue: false }, + { + propertyName: "alphaChannel", + defaultValue: false + }, { propertyName: "value", defaultValue: "#000000" @@ -40,9 +44,8 @@ describe("calcite-color-picker-hex-input", () => { it("can be focused", async () => focusable("calcite-color-picker-hex-input")); it("supports no color", async () => { - const page = await newE2EPage({ - html: "" - }); + const page = await newE2EPage(); + await page.setContent(""); const input = await page.find(`calcite-color-picker-hex-input`); await input.setProperty("value", null); @@ -51,43 +54,35 @@ describe("calcite-color-picker-hex-input", () => { expect(await input.getProperty("value")).toBe(null); expect(input.getAttribute("value")).toBe(null); - const internalInput = await page.find(`calcite-color-picker-hex-input >>> .${CSS.input}`); + const internalInput = await page.find(`calcite-color-picker-hex-input >>> .${CSS.hexInput}`); expect(await internalInput.getProperty("value")).toBe(""); - - const internalSwatch = await page.find(`calcite-color-picker-hex-input >>> .${CSS.preview}`); - expect(internalSwatch).toBe(null); }); it("accepts shorthand hex", async () => { - const page = await newE2EPage({ - html: "" - }); + const page = await newE2EPage(); + await page.setContent(""); const input = await page.find(`calcite-color-picker-hex-input`); - await input.setProperty("value", "#fff"); + await input.setProperty("value", "#abc"); await page.waitForChanges(); - expect(await input.getProperty("value")).toBe("#ffffff"); + expect(await input.getProperty("value")).toBe("#aabbcc"); }); - it("allows entering text if it has a selection", async () => { - const page = await newE2EPage({ - html: "" - }); - const input = await page.find(`calcite-color-picker-hex-input`); + it("accepts shorthand hexa", async () => { + const page = await newE2EPage(); + await page.setContent(""); - await selectText(input); - await page.keyboard.type("000"); - await page.keyboard.press("Enter"); + const input = await page.find(`calcite-color-picker-hex-input`); + await input.setProperty("value", "#abcd"); await page.waitForChanges(); - expect(await input.getProperty("value")).toBe("#000000"); + expect(await input.getProperty("value")).toBe("#aabbccdd"); }); it("accepts longhand hex", async () => { - const page = await newE2EPage({ - html: "" - }); + const page = await newE2EPage(); + await page.setContent(""); const input = await page.find(`calcite-color-picker-hex-input`); await input.setProperty("value", "#fafafa"); @@ -96,21 +91,39 @@ describe("calcite-color-picker-hex-input", () => { expect(await input.getProperty("value")).toBe("#fafafa"); }); - it("normalizes value when initialized", async () => { - const page = await newE2EPage({ - html: "" - }); + it("accepts longhand hexa", async () => { + const page = await newE2EPage(); + await page.setContent(""); + + const input = await page.find(`calcite-color-picker-hex-input`); + await input.setProperty("value", "#fafafafa"); await page.waitForChanges(); + + expect(await input.getProperty("value")).toBe("#fafafafa"); + }); + + it("normalizes value when initialized", async () => { + const page = await newE2EPage(); + await page.setContent(""); const input = await page.find(`calcite-color-picker-hex-input`); expect(await input.getProperty("value")).toBe("#ff00ff"); }); + it("normalizes hexa value when initialized", async () => { + const page = await newE2EPage(); + await page.setContent( + "" + ); + const input = await page.find(`calcite-color-picker-hex-input`); + + expect(await input.getProperty("value")).toBe("#ff00ff00"); + }); + it("ignores invalid hex", async () => { const hex = "#b33f33"; - const page = await newE2EPage({ - html: `` - }); + const page = await newE2EPage(); + await page.setContent(``); const input = await page.find(`calcite-color-picker-hex-input`); await input.setProperty("value", null); @@ -149,10 +162,53 @@ describe("calcite-color-picker-hex-input", () => { expect(await input.getProperty("value")).toBe(hex); }); + it("ignores invalid hexa", async () => { + const hex = "#b33f33ff"; + const page = await newE2EPage(); + await page.setContent( + `` + ); + const input = await page.find(`calcite-color-picker-hex-input`); + + await input.setProperty("value", null); + await page.waitForChanges(); + + expect(await input.getProperty("value")).toBe(hex); + + await input.setProperty("value", "wrong"); + await page.waitForChanges(); + + expect(await input.getProperty("value")).toBe(hex); + + await input.setProperty("value", "#"); + await page.waitForChanges(); + + expect(await input.getProperty("value")).toBe(hex); + + await input.setProperty("value", "#a"); + await page.waitForChanges(); + + expect(await input.getProperty("value")).toBe(hex); + + await input.setProperty("value", "#aa"); + await page.waitForChanges(); + + expect(await input.getProperty("value")).toBe(hex); + + await input.setProperty("value", "#aaaaa"); + await page.waitForChanges(); + + expect(await input.getProperty("value")).toBe(hex); + + await input.setProperty("value", "#aaaaaaa"); + await page.waitForChanges(); + + expect(await input.getProperty("value")).toBe(hex); + }); + it("emits event when color changes via user and not programmatically", async () => { - const page = await newE2EPage({ - html: "" - }); + const page = await newE2EPage(); + await page.setContent(""); const input = await page.find("calcite-color-picker-hex-input"); const spy = await input.spyOnEvent("calciteColorPickerHexInputChange"); @@ -171,9 +227,8 @@ describe("calcite-color-picker-hex-input", () => { }); it("prevents entering chars if invalid hex chars or it exceeds max hex length", async () => { - const page = await newE2EPage({ - html: "" - }); + const page = await newE2EPage(); + await page.setContent(""); const input = await page.find("calcite-color-picker-hex-input"); const selectAllText = async (): Promise => await input.click({ clickCount: 3 }); @@ -192,19 +247,55 @@ describe("calcite-color-picker-hex-input", () => { expect(await input.getProperty("value")).toBe("#bbbbbb"); }); + it("prevents entering chars if invalid hexa chars or it exceeds max hexa length", async () => { + const page = await newE2EPage(); + await page.setContent( + "" + ); + const input = await page.find("calcite-color-picker-hex-input"); + const blockedCharsAndLonghandHexa = "zabcdz"; + + await selectText(input); + await page.keyboard.type(blockedCharsAndLonghandHexa); + await page.keyboard.press("Enter"); + await page.waitForChanges(); + + const expandedLonghandHexa = "#aabbccdd"; + expect(await input.getProperty("value")).toBe(expandedLonghandHexa); + + 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); + }); + describe("keyboard interaction", () => { + let page: E2EPage; + let input: E2EElement; + async function assertTabAndEnterBehavior( hexInputChars: string, expectedValue: string | null, - resetHex = "#efface" + alphaChannel = false ): Promise { const normalizedInputHex = normalizeHex(hexInputChars); + const resetHex = alphaChannel ? "#face0fff" : "#efface"; if (normalizedInputHex === resetHex) { throw new Error(`input hex (${hexInputChars}) cannot be the same as reset value (${resetHex})`); } - expectedValue = expectedValue === null || isValidHex(normalizedInputHex) ? expectedValue : resetHex; + expectedValue = + expectedValue === null || + (alphaChannel + ? isValidHex(normalizedInputHex, true) || canConvertToHexa(normalizedInputHex) + : isValidHex(normalizedInputHex)) + ? expectedValue + : resetHex; await typeHexValue(resetHex, "Enter"); expect(await input.getProperty("value")).toBe(resetHex); @@ -227,136 +318,236 @@ describe("calcite-color-picker-hex-input", () => { } async function clearText(): Promise { - await input.callMethod("setFocus"); - - await page.$eval("calcite-color-picker-hex-input", (el: HTMLCalciteColorPickerHexInputElement): void => { - const input = el.shadowRoot?.querySelector("calcite-input").shadowRoot?.querySelector("input"); - - if (!input) { - return; - } - - const inputType = input.type; - input.type = "text"; - input.setSelectionRange(input.value.length, input.value.length); - input.type = inputType; - }); - - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); + await selectText(input); await page.keyboard.press("Backspace"); } - const startingHex = "#b33f33"; - - let page: E2EPage; - let input: E2EElement; - - beforeEach(async () => { - page = await newE2EPage({ - html: `` - }); - - input = await page.find("calcite-color-picker-hex-input"); - }); - describe("when color value is required", () => { - it("commits hex chars on Tab and Enter", async () => { - await assertTabAndEnterBehavior("b00", "#bb0000"); - await assertTabAndEnterBehavior("c0ffee", "#c0ffee"); - await assertTabAndEnterBehavior("", startingHex); - }); - - it("prevents committing invalid hex values", async () => { - await assertTabAndEnterBehavior("aabbc", startingHex); - await assertTabAndEnterBehavior("aabb", startingHex); - await assertTabAndEnterBehavior("aa", startingHex); - await assertTabAndEnterBehavior("a", startingHex); - await assertTabAndEnterBehavior("", startingHex); - }); - - it("allows nudging RGB channels with arrow keys (+/-1) and shift modifies amount (+/-10)", async () => { - const initialHex = "#000000"; - - await input.callMethod("setFocus"); - await input.setProperty("value", initialHex); - await page.waitForChanges(); - - await page.keyboard.press("ArrowUp"); - await page.waitForChanges(); - expect(await input.getProperty("value")).toBe("#010101"); - - await page.keyboard.press("ArrowDown"); - await page.waitForChanges(); - expect(await input.getProperty("value")).toBe(initialHex); - - await page.keyboard.down("Shift"); - await page.keyboard.press("ArrowUp"); - await page.keyboard.up("Shift"); - expect(await input.getProperty("value")).toBe("#0a0a0a"); - - await page.keyboard.down("Shift"); - await page.keyboard.press("ArrowDown"); - await page.keyboard.up("Shift"); - expect(await input.getProperty("value")).toBe(initialHex); - }); - }); - - describe("when empty is allowed", () => { - beforeEach(async () => { - input.setProperty("allowEmpty", true); - await page.waitForChanges(); - }); - - it("commits hex chars on Tab and Enter", async () => { - await assertTabAndEnterBehavior("b00", "#bb0000"); - await assertTabAndEnterBehavior("c0ffee", "#c0ffee"); - await assertTabAndEnterBehavior("", null); - }); - - it("prevents committing invalid hex values", async () => { - await assertTabAndEnterBehavior("aabbc", startingHex); - await assertTabAndEnterBehavior("aabb", startingHex); - await assertTabAndEnterBehavior("aa", startingHex); - await assertTabAndEnterBehavior("a", startingHex); - await assertTabAndEnterBehavior("", null); + describe("hex", () => { + const startingHex = "#b33f33"; + + beforeEach(async () => { + page = await newE2EPage(); + await page.setContent( + `` + ); + await page.waitForChanges(); + + input = await page.find("calcite-color-picker-hex-input"); + }); + + it("commits hex chars on Tab and Enter", async () => { + await assertTabAndEnterBehavior("b00", "#bb0000"); + await assertTabAndEnterBehavior("c0ffee", "#c0ffee"); + await assertTabAndEnterBehavior("", startingHex); + }); + + it("prevents committing invalid hex values", async () => { + await assertTabAndEnterBehavior("aabbc", startingHex); + await assertTabAndEnterBehavior("aabb", startingHex); + await assertTabAndEnterBehavior("aa", startingHex); + await assertTabAndEnterBehavior("a", startingHex); + await assertTabAndEnterBehavior("", startingHex); + }); + + it("allows nudging RGB channels with arrow keys (+/-1) and shift modifies amount (+/-10)", async () => { + const initialHex = "#000000"; + + await input.callMethod("setFocus"); + await input.setProperty("value", initialHex); + await page.waitForChanges(); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe("#010101"); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(initialHex); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowUp"); + await page.keyboard.up("Shift"); + expect(await input.getProperty("value")).toBe("#0a0a0a"); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.up("Shift"); + expect(await input.getProperty("value")).toBe(initialHex); + }); + + describe("when empty is allowed", () => { + beforeEach(async () => { + input.setProperty("allowEmpty", true); + await page.waitForChanges(); + }); + + it("commits hex chars on Tab and Enter", async () => { + await assertTabAndEnterBehavior("b00", "#bb0000"); + await assertTabAndEnterBehavior("c0ffee", "#c0ffee"); + await assertTabAndEnterBehavior("", null); + }); + + it("prevents committing invalid hex values", async () => { + await assertTabAndEnterBehavior("aabbc", startingHex); + await assertTabAndEnterBehavior("aabb", startingHex); + await assertTabAndEnterBehavior("aa", startingHex); + await assertTabAndEnterBehavior("a", startingHex); + await assertTabAndEnterBehavior("", null); + }); + + it("restores previous value when a nudge key is pressed and no-color is allowed and set", async () => { + const noColorValue = null; + await input.setProperty("value", noColorValue); + await page.waitForChanges(); + await input.callMethod("setFocus"); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(startingHex); + + await input.setProperty("value", noColorValue); + await page.waitForChanges(); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(startingHex); + + await input.setProperty("value", noColorValue); + await page.waitForChanges(); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowUp"); + await page.keyboard.up("Shift"); + expect(await input.getProperty("value")).toBe(startingHex); + + await input.setProperty("value", noColorValue); + await page.waitForChanges(); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.up("Shift"); + expect(await input.getProperty("value")).toBe(startingHex); + }); + }); }); - it("restores previous value when a nudge key is pressed and no-color is allowed and set", async () => { - const noColorValue = null; - await input.setProperty("value", noColorValue); - await page.waitForChanges(); - await input.callMethod("setFocus"); - - await page.keyboard.press("ArrowUp"); - await page.waitForChanges(); - expect(await input.getProperty("value")).toBe(startingHex); - - await input.setProperty("value", noColorValue); - await page.waitForChanges(); - - await page.keyboard.press("ArrowDown"); - await page.waitForChanges(); - expect(await input.getProperty("value")).toBe(startingHex); - - await input.setProperty("value", noColorValue); - await page.waitForChanges(); - - await page.keyboard.down("Shift"); - await page.keyboard.press("ArrowUp"); - await page.keyboard.up("Shift"); - expect(await input.getProperty("value")).toBe(startingHex); - - await input.setProperty("value", noColorValue); - await page.waitForChanges(); - - await page.keyboard.down("Shift"); - await page.keyboard.press("ArrowDown"); - await page.keyboard.up("Shift"); - expect(await input.getProperty("value")).toBe(startingHex); + describe("hexa", () => { + const startingHexa = "#ff00ff00"; + + beforeEach(async () => { + page = await newE2EPage(); + await page.setContent( + `` + ); + + input = await page.find("calcite-color-picker-hex-input"); + }); + + it.skip("commits hexa chars on Tab and Enter", async () => { + await assertTabAndEnterBehavior("b00", "#bb0000ff", true); + await assertTabAndEnterBehavior("abcd", "#aabbccdd", true); + await assertTabAndEnterBehavior("c0ffee", "#c0ffeeff", true); + await assertTabAndEnterBehavior("b0b0b0b0", "#b0b0b0b0", true); + await assertTabAndEnterBehavior("", startingHexa, true); + }); + + it.skip("prevents committing invalid hexa values", async () => { + await assertTabAndEnterBehavior("aabbccd", startingHexa, true); + await assertTabAndEnterBehavior("aabbcc", "#aabbccff", true); + await assertTabAndEnterBehavior("ff00f", "#aabbccff", true); + await assertTabAndEnterBehavior("ff00", "#ffff0000", true); + await assertTabAndEnterBehavior("aab", "#aaaabbff", true); + await assertTabAndEnterBehavior("aa", "#aaaabbff", true); + await assertTabAndEnterBehavior("a", "#aaaabbff", true); + await assertTabAndEnterBehavior("", "#aaaabbff", true); + }); + + it("allows nudging RGB channels with arrow keys (+/-1) and shift modifies amount (+/-10)", async () => { + const initialHex = "#000000ff"; + + await input.callMethod("setFocus"); + await input.setProperty("value", initialHex); + await page.waitForChanges(); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe("#010101ff"); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(initialHex); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowUp"); + await page.keyboard.up("Shift"); + expect(await input.getProperty("value")).toBe("#0a0a0aff"); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.up("Shift"); + expect(await input.getProperty("value")).toBe(initialHex); + }); + + describe("when empty is allowed", () => { + beforeEach(async () => { + input.setProperty("allowEmpty", true); + await page.waitForChanges(); + }); + + it.skip("commits hexa chars on Tab and Enter", async () => { + await assertTabAndEnterBehavior("b00", "#bb0000ff", true); + await assertTabAndEnterBehavior("baba", "#bbaabbaa", true); + await assertTabAndEnterBehavior("c0ffee", "#c0ffeeff", true); + await assertTabAndEnterBehavior("c0c0c0c0", "#c0c0c0c0", true); + await assertTabAndEnterBehavior("", null, true); + }); + + it.skip("prevents committing invalid hexa values", async () => { + await assertTabAndEnterBehavior("aabbccd", startingHexa, true); + await assertTabAndEnterBehavior("aabbcc", "#aabbccff", true); + await assertTabAndEnterBehavior("ff00f", "#aabbccff", true); + await assertTabAndEnterBehavior("ff00", "#ffff0000", true); + await assertTabAndEnterBehavior("aab", "#aaaabbff", true); + await assertTabAndEnterBehavior("aa", "#aaaabbff", true); + await assertTabAndEnterBehavior("a", "#aaaabbff", true); + await assertTabAndEnterBehavior("", null, true); + }); + + it("restores previous value when a nudge key is pressed and no-color is allowed and set", async () => { + const noColorValue = null; + await input.setProperty("value", noColorValue); + await page.waitForChanges(); + await input.callMethod("setFocus"); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(startingHexa); + + await input.setProperty("value", noColorValue); + await page.waitForChanges(); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(startingHexa); + + await input.setProperty("value", noColorValue); + await page.waitForChanges(); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowUp"); + await page.keyboard.up("Shift"); + expect(await input.getProperty("value")).toBe(startingHexa); + + await input.setProperty("value", noColorValue); + await page.waitForChanges(); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.up("Shift"); + expect(await input.getProperty("value")).toBe(startingHexa); + }); + }); }); }); }); diff --git a/src/components/color-picker-hex-input/color-picker-hex-input.scss b/src/components/color-picker-hex-input/color-picker-hex-input.scss index e9d5f1220ef..7b560151520 100644 --- a/src/components/color-picker-hex-input/color-picker-hex-input.scss +++ b/src/components/color-picker-hex-input/color-picker-hex-input.scss @@ -3,27 +3,31 @@ } .container { - @apply inline-grid w-full items-center; - grid-template-columns: 1fr auto; + @apply flex w-full items-center flex-nowrap; } -.preview { - grid-column: 2 / 3; - - @apply pointer-events-none - my-0 - mx-1 - flex - items-center; +.hex-input { + @apply grow uppercase; } -.preview, -.input { - grid-row: 1; +.opacity-input { + inline-size: 68px; + margin-inline-start: -1px; } -.input { - grid-column: 1 / 3; +:host([scale="s"]) { + .container { + @apply flex-wrap gap-y-0.5; + } + + .opacity-input { + inline-size: unset; + margin-inline-start: unset; + } +} - @apply w-full uppercase; +:host([scale="l"]) { + .opacity-input { + inline-size: 88px; + } } diff --git a/src/components/color-picker-hex-input/color-picker-hex-input.tsx b/src/components/color-picker-hex-input/color-picker-hex-input.tsx index 631149c86b8..8c70ca98012 100644 --- a/src/components/color-picker-hex-input/color-picker-hex-input.tsx +++ b/src/components/color-picker-hex-input/color-picker-hex-input.tsx @@ -4,13 +4,25 @@ import { Event, EventEmitter, h, - Listen, Method, Prop, State, VNode, Watch } from "@stencil/core"; +import { + alphaToOpacity, + hexChar, + hexify, + isLonghandHex, + isValidHex, + normalizeHex, + opacityToAlpha, + rgbToHex +} from "../color-picker/utils"; +import { CSS } from "./resources"; +import { Scale } from "../interfaces"; +import { Channels, RGB } from "../color-picker/interfaces"; import Color from "color"; import { focusElement } from "../../utils/dom"; import { @@ -20,10 +32,8 @@ import { setUpLoadableComponent } from "../../utils/loadable"; import { NumberingSystem } from "../../utils/locale"; -import { RGB } from "../color-picker/interfaces"; -import { hexChar, isLonghandHex, isValidHex, normalizeHex, rgbToHex } from "../color-picker/utils"; -import { Scale } from "../interfaces"; -import { CSS } from "./resources"; +import { OPACITY_LIMITS } from "../color-picker/resources"; +import { ColorPickerMessages } from "../color-picker/assets/color-picker/t9n"; const DEFAULT_COLOR = Color(); @@ -41,11 +51,6 @@ export class ColorPickerHexInput implements LoadableComponent { @Element() el: HTMLCalciteColorPickerHexInputElement; - /** - * Specifies accessible label for the input field. - */ - @Prop() hexLabel = "Hex"; - //-------------------------------------------------------------------------- // // Lifecycle @@ -53,12 +58,12 @@ export class ColorPickerHexInput implements LoadableComponent { //-------------------------------------------------------------------------- connectedCallback(): void { - const { allowEmpty, value } = this; + const { allowEmpty, alphaChannel, value } = this; if (value) { - const normalized = normalizeHex(value); + const normalized = normalizeHex(value, alphaChannel); - if (isValidHex(normalized)) { + if (isValidHex(normalized, alphaChannel)) { this.internalSetValue(normalized, normalized, false); } @@ -91,17 +96,40 @@ export class ColorPickerHexInput implements LoadableComponent { */ @Prop() allowEmpty = false; - /** Specifies the size of the component. */ - @Prop({ reflect: true }) scale: Scale = "m"; + /** + * When true, the component will allow updates to the color's alpha value. + */ + @Prop() alphaChannel = false; + + /** + * Specifies accessible label for the input field. + * + * @deprecated use `messages` instead + */ + @Prop() hexLabel = "Hex"; /** - * The Hex value. + * Messages are passed by parent component for accessible labels. + * + * @internal */ - @Prop({ mutable: true, reflect: true }) value: string = normalizeHex(DEFAULT_COLOR.hex()); + @Prop() messages: ColorPickerMessages; /** Specifies the Unicode numeral system used by the component for localization. */ @Prop() numberingSystem?: NumberingSystem; + /** Specifies the size of the component. */ + @Prop({ reflect: true }) scale: Scale = "m"; + + /** + * The hex value. + */ + @Prop({ mutable: true, reflect: true }) value: string = normalizeHex( + hexify(DEFAULT_COLOR, this.alphaChannel), + this.alphaChannel, + true + ); + @Watch("value") handleValueChange(value: string, oldValue: string): void { this.internalSetValue(value, oldValue, false); @@ -118,36 +146,92 @@ export class ColorPickerHexInput implements LoadableComponent { */ @Event({ cancelable: false }) calciteColorPickerHexInputChange: EventEmitter; - private onCalciteInternalInputBlur = (): void => { - const node = this.inputNode; + private onHexInputBlur = (): void => { + const node = this.hexInputNode; const inputValue = node.value; const hex = `#${inputValue}`; - const willClearValue = this.allowEmpty && !inputValue; + const { allowEmpty, internalColor } = this; + const willClearValue = allowEmpty && !inputValue; + const isLonghand = isLonghandHex(hex); - if (willClearValue || (isValidHex(hex) && isLonghandHex(hex))) { + if (willClearValue || (isValidHex(hex) && isLonghand)) { return; } // manipulating DOM directly since rerender doesn't update input value node.value = - this.allowEmpty && !this.internalColor + allowEmpty && !internalColor ? "" - : this.formatForInternalInput(rgbToHex(this.internalColor.object() as any as RGB)); + : this.formatHexForInternalInput( + rgbToHex( + // always display hex input in RRGGBB format + internalColor.object() as any as RGB + ) + ); }; - private onInputChange = (): void => { - this.internalSetValue(this.inputNode.value, this.value); + private onOpacityInputBlur = (): void => { + const node = this.opacityInputNode; + const inputValue = node.value; + const { allowEmpty, internalColor } = this; + const willClearValue = allowEmpty && !inputValue; + + if (willClearValue) { + return; + } + + // manipulating DOM directly since rerender doesn't update input value + node.value = + allowEmpty && !internalColor ? "" : this.formatOpacityForInternalInput(internalColor); + }; + + private onHexInputChange = (): void => { + const nodeValue = this.hexInputNode.value; + let value = nodeValue; + + if (value) { + const normalized = normalizeHex(value, false); + const preserveExistingAlpha = isValidHex(normalized) && this.alphaChannel; + if (preserveExistingAlpha && this.internalColor) { + const alphaHex = normalizeHex(this.internalColor.hexa(), true).slice(-2); + value = `${normalized + alphaHex}`; + } + } + + this.internalSetValue(value, this.value); + }; + + private onOpacityInputChange = (): void => { + const node = this.opacityInputNode; + let value: number | string; + + if (!node.value) { + value = node.value; + } else { + const alpha = opacityToAlpha(Number(node.value)); + value = this.internalColor?.alpha(alpha).hexa(); + } + + this.internalSetValue(value, this.value); }; - // using @Listen as a workaround for VDOM listener not firing - @Listen("keydown", { capture: true }) - protected onInputKeyDown(event: KeyboardEvent): void { + protected onInputKeyDown = (event: KeyboardEvent): void => { const { altKey, ctrlKey, metaKey, shiftKey } = event; - const { internalColor, value } = this; + const { alphaChannel, hexInputNode, internalColor, value } = this; const { key } = event; + const composedPath = event.composedPath(); if (key === "Tab" || key === "Enter") { - this.onInputChange(); + if (composedPath.includes(hexInputNode)) { + this.onHexInputChange(); + } else { + this.onOpacityInputChange(); + } + + if (key === "Enter") { + event.preventDefault(); + } + return; } @@ -165,7 +249,14 @@ export class ColorPickerHexInput implements LoadableComponent { const bump = shiftKey ? 10 : 1; this.internalSetValue( - normalizeHex(this.nudgeRGBChannels(internalColor, bump * direction).hex()), + hexify( + this.nudgeRGBChannels( + internalColor, + bump * direction, + composedPath.includes(hexInputNode) ? "rgb" : "a" + ), + alphaChannel + ), oldValue ); @@ -180,16 +271,16 @@ export class ColorPickerHexInput implements LoadableComponent { if (singleChar && !withModifiers && !validHexChar) { event.preventDefault(); } - } + }; - private onPaste(event: ClipboardEvent): void { + private onHexInputPaste = (event: ClipboardEvent): void => { const hex = event.clipboardData.getData("text"); if (isValidHex(hex)) { event.preventDefault(); - this.inputNode.value = hex.slice(1); + this.hexInputNode.value = hex.slice(1); } - } + }; //-------------------------------------------------------------------------- // @@ -197,13 +288,15 @@ export class ColorPickerHexInput implements LoadableComponent { // //-------------------------------------------------------------------------- - private inputNode: HTMLCalciteInputElement; + private hexInputNode: HTMLCalciteInputElement; /** * The last valid/selected color. Used as a fallback if an invalid hex code is entered. */ @State() internalColor: Color | null = DEFAULT_COLOR; + private opacityInputNode: HTMLCalciteInputElement; + private previousNonNullValue: string = this.value; //-------------------------------------------------------------------------- @@ -213,31 +306,46 @@ export class ColorPickerHexInput implements LoadableComponent { //-------------------------------------------------------------------------- render(): VNode { - const { value } = this; - const hexInputValue = this.formatForInternalInput(value); + const { alphaChannel, hexLabel, internalColor, messages, scale, value } = this; + const hexInputValue = this.formatHexForInternalInput(value); + const opacityInputValue = this.formatOpacityForInternalInput(internalColor); + const inputScale = scale === "l" ? "m" : "s"; + return (
- {hexInputValue ? ( - ) : null}
@@ -255,7 +363,7 @@ export class ColorPickerHexInput implements LoadableComponent { async setFocus(): Promise { await componentLoaded(this); - focusElement(this.inputNode); + focusElement(this.hexInputNode); } //-------------------------------------------------------------------------- @@ -266,14 +374,21 @@ export class ColorPickerHexInput implements LoadableComponent { private internalSetValue(value: string | null, oldValue: string | null, emit = true): void { if (value) { - const normalized = normalizeHex(value); + const { alphaChannel } = this; + const normalized = normalizeHex(value, alphaChannel, alphaChannel); + + if (isValidHex(normalized, alphaChannel)) { + const { internalColor: currentColor } = this; + const nextColor = Color(normalized); + const normalizedLonghand = normalizeHex(hexify(nextColor, alphaChannel), alphaChannel); - if (isValidHex(normalized)) { - const { internalColor } = this; - const changed = !internalColor || normalized !== normalizeHex(internalColor.hex()); - this.internalColor = Color(normalized); - this.previousNonNullValue = normalized; - this.value = normalized; + const changed = + !currentColor || + normalizedLonghand !== normalizeHex(hexify(currentColor, alphaChannel), alphaChannel); + + this.internalColor = nextColor; + this.previousNonNullValue = normalizedLonghand; + this.value = normalizedLonghand; if (changed && emit) { this.calciteColorPickerHexInputChange.emit(); @@ -295,21 +410,38 @@ export class ColorPickerHexInput implements LoadableComponent { this.value = oldValue; } - private storeInputRef = (node: HTMLCalciteInputElement): void => { - this.inputNode = node; + private storeHexInputRef = (node: HTMLCalciteInputElement): void => { + this.hexInputNode = node; }; - private formatForInternalInput(hex: string): string { - return hex ? hex.replace("#", "") : ""; + private storeOpacityInputRef = (node: HTMLCalciteInputElement): void => { + this.opacityInputNode = node; + }; + + private formatHexForInternalInput(hex: string): string { + return hex ? hex.replace("#", "").slice(0, 6) : ""; } - private nudgeRGBChannels(color: Color, amount: number): Color { - return Color.rgb(color.array().map((channel) => channel + amount)); + private formatOpacityForInternalInput(color: Color): string { + return color ? `${alphaToOpacity(color.alpha())}` : ""; } - handleKeyDown(event: KeyboardEvent): void { - if (event.key === "Enter") { - event.preventDefault(); + private nudgeRGBChannels(color: Color, amount: number, context: "rgb" | "a"): Color { + let nudgedChannels: Channels; + const channels = color.array(); + const rgbChannels = channels.slice(0, 3); + + if (context === "rgb") { + const nudgedRGBChannels = rgbChannels.map((channel) => channel + amount); + nudgedChannels = [ + ...nudgedRGBChannels, + this.alphaChannel ? channels[3] : undefined + ] as Channels; + } else { + const nudgedAlpha = opacityToAlpha(alphaToOpacity(color.alpha()) + amount); + nudgedChannels = [...rgbChannels, nudgedAlpha] as Channels; } + + return Color(nudgedChannels); } } diff --git a/src/components/color-picker-hex-input/resources.ts b/src/components/color-picker-hex-input/resources.ts index f3b5cf377a5..4b516e46949 100644 --- a/src/components/color-picker-hex-input/resources.ts +++ b/src/components/color-picker-hex-input/resources.ts @@ -1,5 +1,5 @@ export const CSS = { container: "container", - preview: "preview", - input: "input" + hexInput: "hex-input", + opacityInput: "opacity-input" }; diff --git a/src/components/color-picker-swatch/color-picker-swatch.e2e.ts b/src/components/color-picker-swatch/color-picker-swatch.e2e.ts index 760bd54c974..8c19e705198 100644 --- a/src/components/color-picker-swatch/color-picker-swatch.e2e.ts +++ b/src/components/color-picker-swatch/color-picker-swatch.e2e.ts @@ -1,4 +1,4 @@ -import { newE2EPage } from "@stencil/core/testing"; +import { E2EPage, newE2EPage } from "@stencil/core/testing"; import { CSS } from "./resources"; import { accessible, defaults, reflects, renders, hidden } from "../../tests/commonTests"; @@ -33,62 +33,73 @@ describe("calcite-color-picker-swatch", () => { ])); describe("accepts CSS color strings", () => { + let page: E2EPage; + const fillSwatchPartSelector = `.${CSS.swatch} rect:nth-child(4)`; + + beforeEach(async () => (page = await newE2EPage())); + it("supports rgb", async () => { - const page = await newE2EPage({ - html: "" - }); - const swatch = await page.find(`calcite-color-picker-swatch >>> .${CSS.swatch} rect`); + await page.setContent(""); + const swatch = await page.find(`calcite-color-picker-swatch >>> ${fillSwatchPartSelector}`); const style = await swatch.getComputedStyle(); expect(style["fill"]).toBe("rgb(255, 255, 255)"); }); it("supports keywords", async () => { - const page = await newE2EPage({ - html: "" - }); - const swatch = await page.find(`calcite-color-picker-swatch >>> .${CSS.swatch} rect`); + await page.setContent(""); + const swatch = await page.find(`calcite-color-picker-swatch >>> ${fillSwatchPartSelector}`); const style = await swatch.getComputedStyle(); expect(style["fill"]).toBe("rgb(127, 255, 0)"); }); it("supports hsl", async () => { - const page = await newE2EPage({ - html: "" - }); - const swatch = await page.find(`calcite-color-picker-swatch >>> .${CSS.swatch} rect`); + await page.setContent(""); + const swatch = await page.find(`calcite-color-picker-swatch >>> ${fillSwatchPartSelector}`); const style = await swatch.getComputedStyle(); expect(style["fill"]).toBe("rgb(240, 255, 240)"); }); it("supports hex", async () => { - const page = await newE2EPage({ - html: "" - }); - const swatch = await page.find(`calcite-color-picker-swatch >>> .${CSS.swatch} rect`); - + await page.setContent(""); + const swatch = await page.find(`calcite-color-picker-swatch >>> ${fillSwatchPartSelector}`); const style = await swatch.getComputedStyle(); expect(style["fill"]).toBe("rgb(255, 130, 0)"); }); - }); - it("has an active state", async () => { - // this is probably better suited for a screenshot test + describe("with alpha values", () => { + const fillSwatchPartSelector = `.${CSS.swatch} rect:nth-child(5)`; - const page = await newE2EPage({ - html: "" - }); - const swatchRect = await page.find(`calcite-color-picker-swatch >>> .${CSS.swatch} rect`); + it("supports rgba", async () => { + await page.setContent( + "" + ); + const swatch = await page.find(`calcite-color-picker-swatch >>> ${fillSwatchPartSelector}`); + const style = await swatch.getComputedStyle(); + + expect(style["fill"]).toBe("rgba(255, 255, 255, 0.5)"); + }); - expect(swatchRect.getAttribute("rx")).toBe("0"); + it("supports hsla", async () => { + await page.setContent( + "" + ); + const swatch = await page.find(`calcite-color-picker-swatch >>> ${fillSwatchPartSelector}`); + const style = await swatch.getComputedStyle(); - const swatch = await page.find(`calcite-color-picker-swatch`); - swatch.setProperty("active", true); - await page.waitForChanges(); + expect(style["fill"]).toBe("rgba(240, 255, 240, 0.5)"); + }); - expect(swatchRect.getAttribute("rx")).toBe("100%"); + it("supports hexa", async () => { + await page.setContent(""); + const swatch = await page.find(`calcite-color-picker-swatch >>> ${fillSwatchPartSelector}`); + const style = await swatch.getComputedStyle(); + + expect(style["fill"]).toBe("rgba(255, 130, 0, 0.5)"); + }); + }); }); }); diff --git a/src/components/color-picker-swatch/color-picker-swatch.scss b/src/components/color-picker-swatch/color-picker-swatch.scss index 07ed9ef860a..219bb69cb14 100644 --- a/src/components/color-picker-swatch/color-picker-swatch.scss +++ b/src/components/color-picker-swatch/color-picker-swatch.scss @@ -19,7 +19,7 @@ $size-l: 28px; } .swatch { - @apply overflow-visible; + @apply overflow-hidden; block-size: inherit; inline-size: inherit; @@ -28,9 +28,16 @@ $size-l: 28px; } } -.no-color-icon { - @apply absolute - inset-0 - h-full - w-full; +.swatch--no-color { + rect { + fill: var(--calcite-ui-foreground-1); + } + + line { + stroke: var(--calcite-ui-danger); + } +} + +.checker { + fill: #cacaca; } diff --git a/src/components/color-picker-swatch/color-picker-swatch.stories.ts b/src/components/color-picker-swatch/color-picker-swatch.stories.ts new file mode 100644 index 00000000000..e669f6e1189 --- /dev/null +++ b/src/components/color-picker-swatch/color-picker-swatch.stories.ts @@ -0,0 +1,35 @@ +import { boolean, text } from "@storybook/addon-knobs"; +import { modesDarkDefault } from "../../../.storybook/utils"; +import colorPickerSwatchReadme from "./readme.md"; +import { html } from "../../../support/formatting"; +import { storyFilters } from "../../../.storybook/helpers"; + +export default { + title: "Components/Controls/ColorPicker/support/ColorPickerSwatch", + parameters: { + notes: colorPickerSwatchReadme + }, + ...storyFilters() +}; + +export const simple = (): string => + html``; + +export const active_TestOnly = (): string => + html``; + +export const emptyActive_TestOnly = (): string => + html``; + +export const withAlpha_TestOnly = (): string => + html``; + +export const withAlphaActive_TestOnly = (): string => + html``; + +export const darkModeRTL_TestOnly = (): string => + html``; +darkModeRTL_TestOnly.parameters = { modes: modesDarkDefault }; diff --git a/src/components/color-picker-swatch/color-picker-swatch.tsx b/src/components/color-picker-swatch/color-picker-swatch.tsx index 127a0fbf680..f06b620e1fc 100644 --- a/src/components/color-picker-swatch/color-picker-swatch.tsx +++ b/src/components/color-picker-swatch/color-picker-swatch.tsx @@ -1,8 +1,9 @@ -import { Component, Element, h, Prop, VNode, Watch } from "@stencil/core"; +import { Component, Element, Fragment, h, Prop, VNode, Watch } from "@stencil/core"; import Color from "color"; import { getModeName } from "../../utils/dom"; import { Scale } from "../interfaces"; -import { COLORS, CSS } from "./resources"; +import { hexify } from "../color-picker/utils"; +import { CHECKER_DIMENSIONS, COLORS, CSS } from "./resources"; @Component({ tag: "calcite-color-picker-swatch", @@ -30,11 +31,11 @@ export class ColorPickerSwatch { * @see https://developer.mozilla.org/en-US/docs/Web/CSS/color_value */ @Prop() - color: string; + color: string | null; @Watch("color") - handleColorChange(color: string): void { - this.internalColor = Color(color); + handleColorChange(color: string | null): void { + this.internalColor = color ? Color(color) : null; } /** @@ -67,28 +68,105 @@ export class ColorPickerSwatch { } render(): VNode { + const isEmpty = !this.internalColor; + const classes = { + [CSS.swatch]: true, + [CSS.noColorSwatch]: isEmpty + }; + + return ( + + {this.renderSwatch()} + + ); + } + + renderSwatch(): VNode { const { active, el, internalColor } = this; const borderRadius = active ? "100%" : "0"; - const hex = internalColor.hex(); const theme = getModeName(el); const borderColor = theme === "light" ? COLORS.borderLight : COLORS.borderDark; + const commonSwatchProps = { + height: "100%", + rx: borderRadius, + stroke: borderColor, + + // stroke-width and clip-path are needed to hide overflowing portion of stroke + // see https://stackoverflow.com/a/7273346/194216 + + // using attribute to work around Stencil using the prop name vs the attribute when rendering + ["stroke-width"]: "2", + width: "100%" + }; + + const isEmpty = !internalColor; + + if (isEmpty) { + return ( + + + + + + + + ); + } + + const alpha = internalColor.alpha(); + const hex = hexify(internalColor); + const hexa = hexify(internalColor, alpha < 1); return ( - - {hex} + + {hexa} + + + + + + + - + {alpha < 1 ? ( + + ) : null} + ); } } diff --git a/src/components/color-picker-swatch/resources.ts b/src/components/color-picker-swatch/resources.ts index 4aa4dc3a360..947ba0dc080 100644 --- a/src/components/color-picker-swatch/resources.ts +++ b/src/components/color-picker-swatch/resources.ts @@ -1,9 +1,17 @@ export const CSS = { swatch: "swatch", - noColorIcon: "no-color-icon" + noColorSwatch: "swatch--no-color", + checker: "checker" }; export const COLORS = { borderLight: "rgba(0, 0, 0, 0.3)", borderDark: "rgba(255, 255, 255, 0.15)" }; + +const checkerSquareSize = 4; + +export const CHECKER_DIMENSIONS = { + squareSize: checkerSquareSize, + size: checkerSquareSize * 2 +}; diff --git a/src/components/color-picker/color-picker.e2e.ts b/src/components/color-picker/color-picker.e2e.ts index 3e1524c456a..e8e2a98808b 100644 --- a/src/components/color-picker/color-picker.e2e.ts +++ b/src/components/color-picker/color-picker.e2e.ts @@ -3,7 +3,7 @@ import { CSS, DEFAULT_COLOR, DEFAULT_STORAGE_KEY_PREFIX, DIMENSIONS } from "./re import { E2EElement, E2EPage, EventSpy, newE2EPage } from "@stencil/core/testing"; import { ColorValue } from "./interfaces"; import SpyInstance = jest.SpyInstance; -import { GlobalTestProps, selectText, getElementXY } from "../../tests/utils"; +import { GlobalTestProps, selectText, getElementXY, newProgrammaticE2EPage } from "../../tests/utils"; describe("calcite-color-picker", () => { let consoleSpy: SpyInstance; @@ -17,6 +17,7 @@ describe("calcite-color-picker", () => { }, `.${scope === "hue" ? CSS.hueScope : CSS.colorFieldScope}` ); + await page.waitForChanges(); } beforeEach( @@ -60,10 +61,38 @@ describe("calcite-color-picker", () => { propertyName: "allowEmpty", defaultValue: false }, + { + propertyName: "alphaChannel", + defaultValue: false + }, + { + propertyName: "channelsDisabled", + defaultValue: false + }, { propertyName: "format", defaultValue: "auto" }, + { + propertyName: "hexDisabled", + defaultValue: false + }, + { + propertyName: "hideChannels", + defaultValue: false + }, + { + propertyName: "hideHex", + defaultValue: false + }, + { + propertyName: "hideSaved", + defaultValue: false + }, + { + propertyName: "savedDisabled", + defaultValue: false + }, { propertyName: "scale", defaultValue: "m" @@ -80,9 +109,8 @@ describe("calcite-color-picker", () => { it("supports translations", () => t9n("")); it(`should set all internal calcite-button types to 'button'`, async () => { - const page = await newE2EPage({ - html: "" - }); + const page = await newE2EPage(); + await page.setContent(""); const buttons = await page.findAll("calcite-color-picker >>> calcite-button"); @@ -93,7 +121,7 @@ describe("calcite-color-picker", () => { } }); - it.skip("emits event when value changes via user interaction and not programmatically", async () => { + it("emits event when value changes via user interaction and not programmatically", async () => { const page = await newE2EPage(); await page.setContent(""); const picker = await page.find("calcite-color-picker"); @@ -109,9 +137,16 @@ describe("calcite-color-picker", () => { // save for future test/assertion await (await page.find(`calcite-color-picker >>> .${CSS.saveColor}`)).click(); + await page.waitForChanges(); // change by clicking on field - await (await page.find(`calcite-color-picker >>> .${CSS.colorFieldAndSlider}`)).click(); + const [centerColorFieldScopeX, centerColorFieldScopeY] = await getElementXY( + page, + "calcite-color-picker", + `.${CSS.colorFieldScope}` + ); + await page.mouse.click(centerColorFieldScopeX + 10, centerColorFieldScopeY); + await page.waitForChanges(); expect(changeSpy).toHaveReceivedEventTimes(1); expect(inputSpy).toHaveReceivedEventTimes(1); @@ -127,6 +162,7 @@ describe("calcite-color-picker", () => { await selectText(hexInput); await hexInput.type("fff"); await hexInput.press("Enter"); + await page.waitForChanges(); expect(changeSpy).toHaveReceivedEventTimes(3); expect(inputSpy).toHaveReceivedEventTimes(3); @@ -135,6 +171,7 @@ describe("calcite-color-picker", () => { await selectText(channelInput); await channelInput.type("254"); await channelInput.press("Enter"); + await page.waitForChanges(); expect(changeSpy).toHaveReceivedEventTimes(4); expect(inputSpy).toHaveReceivedEventTimes(4); @@ -145,15 +182,15 @@ describe("calcite-color-picker", () => { // change by dragging color field thumb const mouseDragSteps = 10; - const [colorFieldScopeX, colorFieldScopeY] = await getElementXY( + const [draggedColorFieldScopeX, draggedColorFieldScopeY] = await getElementXY( page, "calcite-color-picker", `.${CSS.colorFieldScope}` ); - await page.mouse.move(colorFieldScopeX, colorFieldScopeY); + await page.mouse.move(draggedColorFieldScopeX, draggedColorFieldScopeY); await page.mouse.down(); - await page.mouse.move(colorFieldScopeX + 10, colorFieldScopeY, { + await page.mouse.move(draggedColorFieldScopeX + 10, draggedColorFieldScopeY, { steps: mouseDragSteps }); await page.mouse.up(); @@ -188,9 +225,7 @@ describe("calcite-color-picker", () => { }); it("does not emit on initialization", async () => { - // initialize page with calcite-color-picker to make it available in the evaluate callback below - const page = await newE2EPage({ html: "" }); - await page.setContent(""); + const page = await newProgrammaticE2EPage(); const emitted = await page.evaluate(() => { const emitted = []; @@ -217,6 +252,20 @@ describe("calcite-color-picker", () => { hsv: { h: 0, s: 0, v: 100 } }; + const supportedAlphaFormatToSampleValue = { + hexa: "#ffffffff", + "rgba-css": "rgba(255, 255, 255, 1)", + "hsla-css": "hsla(0, 0%, 100%, 1)", + rgba: { r: 255, g: 255, b: 255, a: 1 }, + hsla: { h: 0, s: 0, l: 100, a: 1 }, + hsva: { h: 0, s: 0, v: 100, a: 1 } + }; + + const allSupportedFormatToSampleValue = { + ...supportedFormatToSampleValue, + ...supportedAlphaFormatToSampleValue + }; + const clearAndEnterHexOrChannelValue = async ( page: E2EPage, channelInputOrHexInput: E2EElement, @@ -224,13 +273,7 @@ describe("calcite-color-picker", () => { ): Promise => { await channelInputOrHexInput.callMethod("setFocus"); await selectText(channelInputOrHexInput); - - const currentValue = await channelInputOrHexInput.getProperty("value"); - - for (let i = 0; i < currentValue?.length; i++) { - await page.keyboard.press("Backspace"); - } - + await channelInputOrHexInput.press("Backspace"); await channelInputOrHexInput.type(value); await page.keyboard.press("Enter"); await page.waitForChanges(); @@ -299,9 +342,8 @@ describe("calcite-color-picker", () => { }); it("allows specifying the color value format", async () => { - const page = await newE2EPage({ - html: "" - }); + const page = await newE2EPage(); + await page.setContent(""); const color = await page.find("calcite-color-picker"); @@ -325,9 +367,10 @@ describe("calcite-color-picker", () => { }); it("changing format updates value", async () => { - const page = await newE2EPage({ - html: `` - }); + const page = await newE2EPage(); + await page.setContent( + `` + ); const color = await page.find("calcite-color-picker"); for (const format in supportedFormatToSampleValue) { @@ -339,37 +382,76 @@ describe("calcite-color-picker", () => { }); }); - it("accepts multiple color value formats", async () => { - const page = await newE2EPage({ - html: "" - }); - const picker = await page.find("calcite-color-picker"); + describe("accepts multiple color value formats", () => { + it("default", async () => { + const page = await newE2EPage(); + await page.setContent(""); + const picker = await page.find("calcite-color-picker"); - const supportedStringFormats = [ - supportedFormatToSampleValue.hex, - supportedFormatToSampleValue["rgb-css"], - supportedFormatToSampleValue["hsl-css"] - ]; + const supportedStringFormats = [ + supportedFormatToSampleValue.hex, + supportedFormatToSampleValue["rgb-css"], + supportedFormatToSampleValue["hsl-css"] + ]; - for (const value of supportedStringFormats) { - picker.setProperty("value", value); - await page.waitForChanges(); + for (const value of supportedStringFormats) { + picker.setProperty("value", value); + await page.waitForChanges(); - expect(await picker.getProperty("value")).toBe(value); - } + expect(await picker.getProperty("value")).toBe(value); + } - const supportedObjectFormats = [ - supportedFormatToSampleValue.rgb, - supportedFormatToSampleValue.hsl, - supportedFormatToSampleValue.hsv - ]; + const supportedObjectFormats = [ + supportedFormatToSampleValue.rgb, + supportedFormatToSampleValue.hsl, + supportedFormatToSampleValue.hsv + ]; - for (const value of supportedObjectFormats) { - picker.setProperty("value", value); - await page.waitForChanges(); + for (const value of supportedObjectFormats) { + picker.setProperty("value", value); + await page.waitForChanges(); - expect(await picker.getProperty("value")).toMatchObject(value); - } + expect(await picker.getProperty("value")).toMatchObject(value); + } + }); + + it("keeps value in alpha-compatible format when applying updates", async () => { + const page = await newE2EPage(); + await page.setContent(""); + const picker = await page.find("calcite-color-picker"); + + const supportedStringFormats: [string, string][] = [ + [allSupportedFormatToSampleValue.hex, allSupportedFormatToSampleValue.hexa], + [allSupportedFormatToSampleValue.hexa, allSupportedFormatToSampleValue.hexa], + [allSupportedFormatToSampleValue["rgb-css"], allSupportedFormatToSampleValue["rgba-css"]], + [allSupportedFormatToSampleValue["rgba-css"], allSupportedFormatToSampleValue["rgba-css"]], + [allSupportedFormatToSampleValue["hsl-css"], allSupportedFormatToSampleValue["hsla-css"]], + [allSupportedFormatToSampleValue["hsla-css"], allSupportedFormatToSampleValue["hsla-css"]] + ]; + + for (const [value, expected] of supportedStringFormats) { + picker.setProperty("value", value); + await page.waitForChanges(); + + expect(await picker.getProperty("value")).toBe(expected); + } + + const supportedObjectFormats: [any, any][] = [ + [allSupportedFormatToSampleValue.rgb, allSupportedFormatToSampleValue.rgba], + [allSupportedFormatToSampleValue.rgba, allSupportedFormatToSampleValue.rgba], + [allSupportedFormatToSampleValue.hsl, allSupportedFormatToSampleValue.hsla], + [allSupportedFormatToSampleValue.hsla, allSupportedFormatToSampleValue.hsla], + [allSupportedFormatToSampleValue.hsv, allSupportedFormatToSampleValue.hsva], + [allSupportedFormatToSampleValue.hsva, allSupportedFormatToSampleValue.hsva] + ]; + + for (const [value, expected] of supportedObjectFormats) { + picker.setProperty("value", value); + await page.waitForChanges(); + + expect(await picker.getProperty("value")).toMatchObject(expected); + } + }); }); it("allows selecting colors via color field/slider", async () => { @@ -380,25 +462,29 @@ describe("calcite-color-picker", () => { let changes = 0; const mediumScaleDimensions = DIMENSIONS.m; const widthOffset = 0.5; - const [fieldAndSliderX, fieldAndSliderY] = await getElementXY(page, "calcite-color-picker", "canvas"); + const [colorFieldX, colorFieldY] = await getElementXY(page, "calcite-color-picker", `.${CSS.colorField}`); // clicking color field colors to pick a color - await page.mouse.click(fieldAndSliderX, fieldAndSliderY); + await page.mouse.click(colorFieldX, colorFieldY); + await page.waitForChanges(); expect(await picker.getProperty("value")).toBe("#ffffff"); expect(spy).toHaveReceivedEventTimes(++changes); - await page.mouse.click(fieldAndSliderX, fieldAndSliderY + mediumScaleDimensions.colorField.height); + await page.mouse.click(colorFieldX, colorFieldY + mediumScaleDimensions.colorField.height - 0.1); + await page.waitForChanges(); expect(await picker.getProperty("value")).toBe("#000000"); expect(spy).toHaveReceivedEventTimes(++changes); - await page.mouse.click(fieldAndSliderX + mediumScaleDimensions.colorField.width - widthOffset, fieldAndSliderY); + await page.mouse.click(colorFieldX + mediumScaleDimensions.colorField.width - widthOffset, colorFieldY); + await page.waitForChanges(); expect(await picker.getProperty("value")).toBe("#ff0000"); expect(spy).toHaveReceivedEventTimes(++changes); await page.mouse.click( - fieldAndSliderX + mediumScaleDimensions.colorField.width - widthOffset, - fieldAndSliderY + mediumScaleDimensions.colorField.height + colorFieldX + mediumScaleDimensions.colorField.width - widthOffset, + colorFieldY + mediumScaleDimensions.colorField.height - 0.1 ); + await page.waitForChanges(); expect(await picker.getProperty("value")).toBe("#000000"); expect(spy).toHaveReceivedEventTimes(++changes); @@ -410,10 +496,11 @@ describe("calcite-color-picker", () => { // clicking on color slider to set hue const colorsToSample = 7; const offsetX = (mediumScaleDimensions.slider.width - widthOffset) / colorsToSample; - let x = fieldAndSliderX; + const [hueSliderX, hueSliderY] = await getElementXY(page, "calcite-color-picker", `.${CSS.hueSlider}`); + + let x = hueSliderX; - const sliderHeight = - fieldAndSliderY + mediumScaleDimensions.colorField.height + mediumScaleDimensions.slider.height; + const sliderHeight = hueSliderY + mediumScaleDimensions.slider.height / 2; const expectedColorSamples = [ "#ff0000", @@ -430,6 +517,7 @@ describe("calcite-color-picker", () => { const expectedColor = expectedColorSamples[i]; await page.mouse.click(x, sliderHeight); + await page.waitForChanges(); expect(await picker.getProperty("value")).toBe(expectedColor); expect(spy).toHaveReceivedEventTimes(++changes); @@ -466,11 +554,10 @@ describe("calcite-color-picker", () => { }); it("keeps tracking mouse movement when a thumb is actively dragged", async () => { - const page = await newE2EPage({ - html: "" - }); + const page = await newE2EPage(); + await page.setContent(""); const picker = await page.find(`calcite-color-picker`); - const colorFieldAndSlider = await page.find(`calcite-color-picker >>> .${CSS.colorFieldAndSlider}`); + const colorFieldAndSlider = await page.find(`calcite-color-picker >>> .${CSS.colorField}`); await colorFieldAndSlider.click(); // click middle color @@ -527,9 +614,8 @@ describe("calcite-color-picker", () => { it(`mouse movement tracking is not offset by the component's padding (mimics issue from #3041 when the component was placed within another component's shadow DOM)`, async () => { const colorFieldCenterValueHsv = { h: 127, s: 50, v: 50 }; - const page = await newE2EPage({ - html: `` - }); + const page = await newE2EPage(); + await page.setContent(``); const colorPicker = await page.find("calcite-color-picker"); colorPicker.setProperty("value", colorFieldCenterValueHsv); @@ -579,9 +665,8 @@ describe("calcite-color-picker", () => { } beforeEach(async () => { - page = await newE2EPage({ - html: "" - }); + page = await newE2EPage(); + await page.setContent(""); }); it("ignores unsupported value types", () => assertUnsupportedValue(page, "unsupported-color-format")); @@ -589,22 +674,33 @@ describe("calcite-color-picker", () => { it("ignores null when not allowed", () => assertUnsupportedValue(page, null)); }); - it("normalizes shorthand CSS hex", async () => { - const page = await newE2EPage({ - html: "" + describe("normalizes shorthand CSS hex", () => { + it("normal", async () => { + const page = await newE2EPage(); + await page.setContent(""); + const picker = await page.find("calcite-color-picker"); + + picker.setProperty("value", "#ABC"); + await page.waitForChanges(); + + expect(await picker.getProperty("value")).toBe("#aabbcc"); }); - const picker = await page.find("calcite-color-picker"); - picker.setProperty("value", "#ABC"); - await page.waitForChanges(); + it("alpha channel", async () => { + const page = await newE2EPage(); + await page.setContent(""); + const picker = await page.find("calcite-color-picker"); - expect(await picker.getProperty("value")).toBe("#aabbcc"); + picker.setProperty("value", "#ABCD"); + await page.waitForChanges(); + + expect(await picker.getProperty("value")).toBe("#aabbccdd"); + }); }); it("has backdoor color prop for advanced use cases", async () => { - const page = await newE2EPage({ - html: "" - }); + const page = await newE2EPage(); + await page.setContent(""); const picker = await page.find("calcite-color-picker"); expect(await picker.getProperty("color")).toBeTruthy(); @@ -620,9 +716,8 @@ describe("calcite-color-picker", () => { } it("value as attribute", async () => { - const page = await newE2EPage({ - html: `` - }); + const page = await newE2EPage(); + await page.setContent(``); expect(await getInternalColorAsHex(page)).toBe(initialColor); }); @@ -647,192 +742,454 @@ describe("calcite-color-picker", () => { }); describe("color inputs", () => { - describe("keeps value in same format when applying updates", () => { - let page: E2EPage; - let picker: E2EElement; + // see https://jasmine.github.io/tutorials/custom_argument_matchers for more info + function toBeInteger(): any { + return { + asymmetricMatch(abc): boolean { + return Number.isInteger(abc); + }, + + jasmineToString(): string { + return `Expected value to be an integer.`; + } + }; + } - beforeEach(async () => { - page = await newE2EPage({ - html: "" - }); - picker = await page.find("calcite-color-picker"); - }); + function toBeNumber(): any { + return { + asymmetricMatch(expected): boolean { + return !isNaN(parseFloat(expected)) && isFinite(expected); + }, - const updateColorWithAllInputs = async (assertColorUpdate: (value: ColorValue) => void): Promise => { - const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); + jasmineToString(): string { + return `Expected value to be an number.`; + } + }; + } - await clearAndEnterHexOrChannelValue(page, hexInput, "abc"); + describe("default", () => { + describe("keeps value in same format when applying updates", () => { + let page: E2EPage; + let picker: E2EElement; - assertColorUpdate(await picker.getProperty("value")); + beforeEach(async () => { + page = await newE2EPage(); + await page.setContent(""); + picker = await page.find("calcite-color-picker"); + }); - const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); - const [rInput, gInput, bInput, hInput, sInput, vInput] = await page.findAll( - `calcite-color-picker >>> calcite-input.${CSS.channel}` - ); + const updateColorWithAllInputs = async ( + page: E2EPage, + assertColorUpdate: (value: ColorValue) => void + ): Promise => { + const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); - await rgbModeButton.click(); + await clearAndEnterHexOrChannelValue(page, hexInput, "abc"); - await clearAndEnterHexOrChannelValue(page, rInput, "128"); - await clearAndEnterHexOrChannelValue(page, gInput, "64"); - await clearAndEnterHexOrChannelValue(page, bInput, "32"); + assertColorUpdate(await picker.getProperty("value")); - assertColorUpdate(await picker.getProperty("value")); + const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + const [rInput, gInput, bInput, hInput, sInput, vInput] = await page.findAll( + `calcite-color-picker >>> calcite-input.${CSS.channel}` + ); - await hsvModeButton.click(); + await rgbModeButton.click(); - await clearAndEnterHexOrChannelValue(page, hInput, "180"); - await clearAndEnterHexOrChannelValue(page, sInput, "90"); - await clearAndEnterHexOrChannelValue(page, vInput, "45"); + await clearAndEnterHexOrChannelValue(page, rInput, "128"); + await clearAndEnterHexOrChannelValue(page, gInput, "64"); + await clearAndEnterHexOrChannelValue(page, bInput, "32"); - assertColorUpdate(await picker.getProperty("value")); - }; + assertColorUpdate(await picker.getProperty("value")); - // see https://jasmine.github.io/tutorials/custom_argument_matchers for more info - function toBeInteger(): any { - return { - asymmetricMatch(abc): boolean { - return Number.isInteger(abc); - }, + await hsvModeButton.click(); + + await clearAndEnterHexOrChannelValue(page, hInput, "180"); + await clearAndEnterHexOrChannelValue(page, sInput, "90"); + await clearAndEnterHexOrChannelValue(page, vInput, "45"); - jasmineToString(): string { - return `Expected value to be an integer.`; - } + assertColorUpdate(await picker.getProperty("value")); }; - } - it("supports hex", async () => { - const hex = supportedFormatToSampleValue.hex; - picker.setProperty("value", hex); - await page.waitForChanges(); + it("supports hex", async () => { + const hex = supportedFormatToSampleValue.hex; + picker.setProperty("value", hex); + await page.waitForChanges(); - await updateColorWithAllInputs((value: ColorValue) => { - expect(value).not.toBe(hex); - expect(value).toMatch(/^#[a-f0-9]{6}$/); + await updateColorWithAllInputs(page, (value: ColorValue) => { + expect(value).not.toBe(hex); + expect(value).toMatch(/^#[a-f0-9]{6}$/); + }); + + expect(() => assertUnsupportedValueMessage(hex, "auto")).toThrow(); }); - expect(() => assertUnsupportedValueMessage(hex, "auto")).toThrow(); - }); + it("supports rgb", async () => { + const rgbCss = supportedFormatToSampleValue["rgb-css"]; + picker.setProperty("value", rgbCss); + await page.waitForChanges(); - it("supports rgb", async () => { - const rgbCss = supportedFormatToSampleValue["rgb-css"]; - picker.setProperty("value", rgbCss); - await page.waitForChanges(); + await updateColorWithAllInputs(page, (value: ColorValue) => { + expect(value).not.toBe(rgbCss); + expect(value).toMatch(/^rgb\(\d+, \d+, \d+\)/); + }); - await updateColorWithAllInputs((value: ColorValue) => { - expect(value).not.toBe(rgbCss); - expect(value).toMatch(/^rgb\(\d+, \d+, \d+\)/); + expect(() => assertUnsupportedValueMessage(rgbCss, "auto")).toThrow(); }); - expect(() => assertUnsupportedValueMessage(rgbCss, "auto")).toThrow(); - }); + it("supports hsl", async () => { + const hslCss = supportedFormatToSampleValue["hsl-css"]; + picker.setProperty("value", hslCss); + await page.waitForChanges(); - it("supports hsl", async () => { - const hslCss = supportedFormatToSampleValue["hsl-css"]; - picker.setProperty("value", hslCss); - await page.waitForChanges(); + await updateColorWithAllInputs(page, (value: ColorValue) => { + expect(value).not.toBe(hslCss); + expect(value).toMatch(/^hsl\([0-9.]+, [0-9.]+%, [0-9.]+%\)/); + }); - await updateColorWithAllInputs((value: ColorValue) => { - expect(value).not.toBe(hslCss); - expect(value).toMatch(/^hsl\([0-9.]+, [0-9.]+%, [0-9.]+%\)/); + expect(() => assertUnsupportedValueMessage(hslCss, "auto")).toThrow(); }); - expect(() => assertUnsupportedValueMessage(hslCss, "auto")).toThrow(); - }); + it("supports rgb (object)", async () => { + const rgbObject = supportedFormatToSampleValue.rgb; + picker.setProperty("value", rgbObject); + await page.waitForChanges(); - it("supports rgb (object)", async () => { - const rgbObject = supportedFormatToSampleValue.rgb; - picker.setProperty("value", rgbObject); - await page.waitForChanges(); + await updateColorWithAllInputs(page, (value: ColorValue) => { + expect(value).not.toMatchObject(rgbObject); + expect(value).toMatchObject({ + r: toBeInteger(), + g: toBeInteger(), + b: toBeInteger() + }); + }); - await updateColorWithAllInputs((value: ColorValue) => { - expect(value).not.toMatchObject(rgbObject); - expect(value).toMatchObject({ - r: toBeInteger(), - g: toBeInteger(), - b: toBeInteger() + expect(() => assertUnsupportedValueMessage(rgbObject, "auto")).toThrow(); + }); + + it("supports hsl (object)", async () => { + const hslObject = supportedFormatToSampleValue.hsl; + picker.setProperty("value", hslObject); + await page.waitForChanges(); + + await updateColorWithAllInputs(page, (value: ColorValue) => { + expect(value).not.toMatchObject(hslObject); + expect(value).toMatchObject({ + h: toBeInteger(), + s: toBeInteger(), + l: toBeInteger() + }); }); + + expect(() => assertUnsupportedValueMessage(hslObject, "auto")).toThrow(); }); - expect(() => assertUnsupportedValueMessage(rgbObject, "auto")).toThrow(); + it("supports hsv (object)", async () => { + const hsvObject = supportedFormatToSampleValue.hsv; + picker.setProperty("value", hsvObject); + await page.waitForChanges(); + + await updateColorWithAllInputs(page, (value: ColorValue) => { + expect(value).not.toMatchObject(hsvObject); + expect(value).toMatchObject({ + h: toBeInteger(), + s: toBeInteger(), + v: toBeInteger() + }); + }); + + expect(() => assertUnsupportedValueMessage(hsvObject, "auto")).toThrow(); + }); }); - it("supports hsl (object)", async () => { - const hslObject = supportedFormatToSampleValue.hsl; - picker.setProperty("value", hslObject); - await page.waitForChanges(); + describe("color gets propagated to support inputs", () => { + describe("valid color", () => { + it("color gets propagated to hex, RGB & HSV inputs", async () => { + const page = await newE2EPage(); + await page.setContent(""); + + const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); + + expect(await hexInput.getProperty("value")).toBe("#fff000"); + + const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + const [rInput, gInput, bInput, hInput, sInput, vInput] = await page.findAll( + `calcite-color-picker >>> calcite-input.${CSS.channel}` + ); + + await rgbModeButton.click(); + + expect(await rInput.getProperty("value")).toBe("255"); + expect(await gInput.getProperty("value")).toBe("240"); + expect(await bInput.getProperty("value")).toBe("0"); + + await hsvModeButton.click(); - await updateColorWithAllInputs((value: ColorValue) => { - expect(value).not.toMatchObject(hslObject); - expect(value).toMatchObject({ - h: toBeInteger(), - s: toBeInteger(), - l: toBeInteger() + expect(await hInput.getProperty("value")).toBe("56"); + expect(await sInput.getProperty("value")).toBe("100"); + expect(await vInput.getProperty("value")).toBe("100"); }); - }); - expect(() => assertUnsupportedValueMessage(hslObject, "auto")).toThrow(); - }); + it("allows modifying color via hex, RGB, HSV inputs", async () => { + const page = await newE2EPage(); + await page.setContent(""); + const picker = await page.find("calcite-color-picker"); - it("supports hsv (object)", async () => { - const hsvObject = supportedFormatToSampleValue.hsv; - picker.setProperty("value", hsvObject); - await page.waitForChanges(); + const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); + await clearAndEnterHexOrChannelValue(page, hexInput, "abc"); + + expect(await picker.getProperty("value")).toBe("#aabbcc"); + + const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + const [rInput, gInput, bInput, hInput, sInput, vInput] = await page.findAll( + `calcite-color-picker >>> calcite-input.${CSS.channel}` + ); + + await rgbModeButton.click(); + + await clearAndEnterHexOrChannelValue(page, rInput, "128"); + await clearAndEnterHexOrChannelValue(page, gInput, "64"); + await clearAndEnterHexOrChannelValue(page, bInput, "32"); + + expect(await picker.getProperty("value")).toBe("#804020"); + + await hsvModeButton.click(); - await updateColorWithAllInputs((value: ColorValue) => { - expect(value).not.toMatchObject(hsvObject); - expect(value).toMatchObject({ - h: toBeInteger(), - s: toBeInteger(), - v: toBeInteger() + await clearAndEnterHexOrChannelValue(page, hInput, "180"); + await clearAndEnterHexOrChannelValue(page, sInput, "90"); + await clearAndEnterHexOrChannelValue(page, vInput, "45"); + + expect(await picker.getProperty("value")).toBe("#0b7373"); + }); + + it("allows nudging values", async () => { + const assertChannelValueNudge = async (page: E2EPage, calciteInput: E2EElement): Promise => { + await calciteInput.callMethod("setFocus"); + const currentValue = await calciteInput.getProperty("value"); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe(`${Number(currentValue) + 1}`); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe(currentValue); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowUp"); + await page.keyboard.up("Shift"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe(`${Number(currentValue) + 10}`); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.up("Shift"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe(currentValue); + }; + + const page = await newE2EPage(); + await page.setContent(""); + + // TODO: revisit – hex values sometimes can't be remapped back because of loss of precision when nudging channels back and forth + + const [/*rgbModeButton,*/ hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + const [/*rInput, gInput, bInput, */ hInput, sInput, vInput] = await page.findAll( + `calcite-color-picker >>> calcite-input.${CSS.channel}` + ); + + // await rgbModeButton.click(); + + // await assertChannelValueNudge(page, rInput); + // await assertChannelValueNudge(page, gInput); + // await assertChannelValueNudge(page, bInput); + + await hsvModeButton.click(); + + await assertChannelValueNudge(page, hInput); + await assertChannelValueNudge(page, sInput); + await assertChannelValueNudge(page, vInput); }); }); - expect(() => assertUnsupportedValueMessage(hsvObject, "auto")).toThrow(); - }); - }); + describe("when no-color", () => { + it("color gets propagated to hex, RGB & HSV inputs", async () => { + const page = await newE2EPage(); + await page.setContent(""); + + const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); + + expect(await hexInput.getProperty("value")).toBe(null); + + const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + const [rInput, gInput, bInput, hInput, sInput, vInput] = await page.findAll( + `calcite-color-picker >>> calcite-input.${CSS.channel}` + ); + + await rgbModeButton.click(); + + expect(await rInput.getProperty("value")).toBe(""); + expect(await gInput.getProperty("value")).toBe(""); + expect(await bInput.getProperty("value")).toBe(""); + + await hsvModeButton.click(); - describe("color gets propagated to support inputs", () => { - describe("valid color", () => { - it("color gets propagated to hex, RGB & HSV inputs", async () => { - const page = await newE2EPage({ - html: "" + expect(await hInput.getProperty("value")).toBe(""); + expect(await sInput.getProperty("value")).toBe(""); + expect(await vInput.getProperty("value")).toBe(""); }); - const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); + it("restores previous color value when a nudge key is pressed", async () => { + const consistentRgbHsvChannelValue = "0"; + const initialValue = "#".padEnd(7, consistentRgbHsvChannelValue); - expect(await hexInput.getProperty("value")).toBe("#fff000"); + const assertChannelValueNudge = async (page: E2EPage, calciteInput: E2EElement): Promise => { + await calciteInput.callMethod("setFocus"); + await clearAndEnterHexOrChannelValue(page, calciteInput, ""); - const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); - const [rInput, gInput, bInput, hInput, sInput, vInput] = await page.findAll( - `calcite-color-picker >>> calcite-input.${CSS.channel}` - ); + // using page.waitForChanges as keyboard nudges occur in the next frame - await rgbModeButton.click(); + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe(consistentRgbHsvChannelValue); - expect(await rInput.getProperty("value")).toBe("255"); - expect(await gInput.getProperty("value")).toBe("240"); - expect(await bInput.getProperty("value")).toBe("0"); + await clearAndEnterHexOrChannelValue(page, calciteInput, ""); - await hsvModeButton.click(); + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe(consistentRgbHsvChannelValue); - expect(await hInput.getProperty("value")).toBe("56"); - expect(await sInput.getProperty("value")).toBe("100"); - expect(await vInput.getProperty("value")).toBe("100"); - }); + await clearAndEnterHexOrChannelValue(page, calciteInput, ""); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowUp"); + await page.keyboard.up("Shift"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe(consistentRgbHsvChannelValue); - it("allows modifying color via hex, RGB, HSV inputs", async () => { - const page = await newE2EPage({ - html: "" + await clearAndEnterHexOrChannelValue(page, calciteInput, ""); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.up("Shift"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe(consistentRgbHsvChannelValue); + }; + + const page = await newE2EPage(); + await page.setContent(``); + + const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + const [rInput, gInput, bInput, hInput, sInput, vInput] = await page.findAll( + `calcite-color-picker >>> calcite-input.${CSS.channel}` + ); + + await rgbModeButton.click(); + + await assertChannelValueNudge(page, rInput); + await assertChannelValueNudge(page, gInput); + await assertChannelValueNudge(page, bInput); + + await hsvModeButton.click(); + + await assertChannelValueNudge(page, hInput); + await assertChannelValueNudge(page, sInput); + await assertChannelValueNudge(page, vInput); }); - const picker = await page.find("calcite-color-picker"); + it("changes the value to the specified format after being empty", async () => { + const page = await newE2EPage(); + await page.setContent(""); + const color = await page.find("calcite-color-picker"); + + const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); + await clearAndEnterHexOrChannelValue(page, hexInput, supportedFormatToSampleValue.hex); + + expect(await color.getProperty("value")).toEqual(supportedFormatToSampleValue.rgb); + }); + + describe("clearing color via supporting inputs", () => { + it("clears color via hex input", async () => { + const page = await newE2EPage(); + await page.setContent(""); + const picker = await page.find("calcite-color-picker"); + const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); + + await clearAndEnterHexOrChannelValue(page, hexInput, ""); + + expect(await picker.getProperty("value")).toBe(null); + }); + + it("clears color via RGB channel inputs", async () => { + const page = await newE2EPage(); + await page.setContent(""); + const picker = await page.find("calcite-color-picker"); + + const [rgbModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + const [rInput, gInput, bInput] = await page.findAll( + `calcite-color-picker >>> calcite-input.${CSS.channel}` + ); + + await rgbModeButton.click(); + + await clearAndEnterHexOrChannelValue(page, rInput, ""); + + // clearing one clears the rest + expect(await gInput.getProperty("value")).toBe(""); + expect(await bInput.getProperty("value")).toBe(""); + + expect(await picker.getProperty("value")).toBeNull(); + }); + + it("clears color via HSV channel inputs", async () => { + const page = await newE2EPage(); + await page.setContent(""); + const picker = await page.find("calcite-color-picker"); + + const [, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + + const [, , , hInput, sInput, vInput] = await page.findAll( + `calcite-color-picker >>> calcite-input.${CSS.channel}` + ); + + await hsvModeButton.click(); + + await clearAndEnterHexOrChannelValue(page, hInput, ""); + + // clearing one clears the rest + expect(await sInput.getProperty("value")).toBe(""); + expect(await vInput.getProperty("value")).toBe(""); + + expect(await picker.getProperty("value")).toBeNull(); + }); + }); + }); + }); + }); + + describe("alpha channel", () => { + describe("keeps value in alpha-compatible format when applying updates", () => { + let page: E2EPage; + let picker: E2EElement; + + beforeEach(async () => { + page = await newE2EPage(); + await page.setContent(""); + picker = await page.find("calcite-color-picker"); + await page.waitForChanges(); + }); + + const updateColorWithAllInputs = async ( + page: E2EPage, + assertColorUpdate: (value: ColorValue) => Promise + ): Promise => { const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); - await clearAndEnterHexOrChannelValue(page, hexInput, "abc"); - expect(await picker.getProperty("value")).toBe("#aabbcc"); + await clearAndEnterHexOrChannelValue(page, hexInput, "abc0"); + + await assertColorUpdate(await picker.getProperty("value")); const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); - const [rInput, gInput, bInput, hInput, sInput, vInput] = await page.findAll( + const [rInput, gInput, bInput, rgbAInput, hInput, sInput, vInput, hsvAInput] = await page.findAll( `calcite-color-picker >>> calcite-input.${CSS.channel}` ); @@ -841,333 +1198,734 @@ describe("calcite-color-picker", () => { await clearAndEnterHexOrChannelValue(page, rInput, "128"); await clearAndEnterHexOrChannelValue(page, gInput, "64"); await clearAndEnterHexOrChannelValue(page, bInput, "32"); + await clearAndEnterHexOrChannelValue(page, rgbAInput, "75"); - expect(await picker.getProperty("value")).toBe("#804020"); + await assertColorUpdate(await picker.getProperty("value")); await hsvModeButton.click(); await clearAndEnterHexOrChannelValue(page, hInput, "180"); await clearAndEnterHexOrChannelValue(page, sInput, "90"); await clearAndEnterHexOrChannelValue(page, vInput, "45"); + await clearAndEnterHexOrChannelValue(page, hsvAInput, "75"); + + await assertColorUpdate(await picker.getProperty("value")); + }; + + it("supports hex", async () => { + const hex = supportedFormatToSampleValue.hex; + picker.setProperty("value", hex); + await page.waitForChanges(); - expect(await picker.getProperty("value")).toBe("#0b7373"); + await updateColorWithAllInputs(page, async (value: ColorValue) => { + expect(value).not.toBe(hex); + expect(value).toMatch(/^#[a-f0-9]{8}$/); + }); + + expect(() => assertUnsupportedValueMessage(hex, "auto")).toThrow(); }); - it.skip("allows nudging values", async () => { - const assertChannelValueNudge = async (page: E2EPage, calciteInput: E2EElement): Promise => { - await calciteInput.callMethod("setFocus"); - const currentValue = await calciteInput.getProperty("value"); + it("supports hexa", async () => { + const hexa = supportedAlphaFormatToSampleValue.hexa; + picker.setProperty("value", hexa); + await page.waitForChanges(); + + await updateColorWithAllInputs(page, async (value: ColorValue) => { + expect(value).not.toBe(hexa); + expect(value).toMatch(/^#[a-f0-9]{8}$/); + }); + + expect(() => assertUnsupportedValueMessage(hexa, "auto")).toThrow(); + }); - await page.keyboard.press("ArrowUp"); - expect(await calciteInput.getProperty("value")).toBe(`${Number(currentValue) + 1}`); + it("supports rgb", async () => { + const rgbCss = supportedFormatToSampleValue["rgb-css"]; + picker.setProperty("value", rgbCss); + await page.waitForChanges(); - await page.keyboard.press("ArrowDown"); - expect(await calciteInput.getProperty("value")).toBe(currentValue); + await updateColorWithAllInputs(page, async (value: ColorValue) => { + expect(value).not.toBe(rgbCss); + expect(value).toMatch(/^rgba\(\d+, \d+, \d+\, [0-9.]+\)/); + }); - await page.keyboard.down("Shift"); - await page.keyboard.press("ArrowUp"); - await page.keyboard.up("Shift"); - expect(await calciteInput.getProperty("value")).toBe(`${Number(currentValue) + 10}`); + expect(() => assertUnsupportedValueMessage(rgbCss, "auto")).toThrow(); + }); - await page.keyboard.down("Shift"); - await page.keyboard.press("ArrowDown"); - await page.keyboard.up("Shift"); - expect(await calciteInput.getProperty("value")).toBe(currentValue); - }; + it("supports rgba", async () => { + const rgbaCss = supportedAlphaFormatToSampleValue["rgba-css"]; + picker.setProperty("value", rgbaCss); + await page.waitForChanges(); - const page = await newE2EPage({ - html: "" + await updateColorWithAllInputs(page, async (value: ColorValue) => { + expect(value).not.toBe(rgbaCss); + expect(value).toMatch(/^rgba\(\d+, \d+, \d+\, [0-9.]+\)/); }); - const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); - const [rInput, gInput, bInput, hInput, sInput, vInput] = await page.findAll( - `calcite-color-picker >>> calcite-input.${CSS.channel}` - ); + expect(() => assertUnsupportedValueMessage(rgbaCss, "auto")).toThrow(); + }); - await rgbModeButton.click(); + it("supports hsl", async () => { + const hslCss = supportedFormatToSampleValue["hsl-css"]; + picker.setProperty("value", hslCss); + await page.waitForChanges(); - await assertChannelValueNudge(page, rInput); - await assertChannelValueNudge(page, gInput); - await assertChannelValueNudge(page, bInput); + await updateColorWithAllInputs(page, async (value: ColorValue) => { + expect(value).not.toBe(hslCss); + expect(value).toMatch(/^hsla\([0-9.]+, [0-9.]+%, [0-9.]+%\, [0-9.]+\)/); + }); - await hsvModeButton.click(); + expect(() => assertUnsupportedValueMessage(hslCss, "auto")).toThrow(); + }); + + it("supports hsla", async () => { + const hslaCss = supportedAlphaFormatToSampleValue["hsla-css"]; + picker.setProperty("value", hslaCss); + await page.waitForChanges(); - await assertChannelValueNudge(page, hInput); - await assertChannelValueNudge(page, sInput); - await assertChannelValueNudge(page, vInput); + await updateColorWithAllInputs(page, async (value: ColorValue) => { + expect(value).not.toBe(hslaCss); + expect(value).toMatch(/^hsla\([0-9.]+, [0-9.]+%, [0-9.]+%\, [0-9.]+\)/); + }); + + expect(() => assertUnsupportedValueMessage(hslaCss, "auto")).toThrow(); }); - }); - describe("when no-color", () => { - it("color gets propagated to hex, RGB & HSV inputs", async () => { - const page = await newE2EPage({ - html: "" + it("supports rgb (object)", async () => { + const rgbObject = supportedFormatToSampleValue.rgb; + picker.setProperty("value", rgbObject); + await page.waitForChanges(); + + await updateColorWithAllInputs(page, async (value: ColorValue) => { + expect(value).not.toMatchObject(rgbObject); + expect(value).toMatchObject({ + r: toBeInteger(), + g: toBeInteger(), + b: toBeInteger(), + a: toBeNumber() + }); }); - const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); + expect(() => assertUnsupportedValueMessage(rgbObject, "auto")).toThrow(); + }); - expect(await hexInput.getProperty("value")).toBe(null); + it("supports rgba (object)", async () => { + const rgbaObject = supportedAlphaFormatToSampleValue.rgba; + picker.setProperty("value", rgbaObject); + await page.waitForChanges(); - const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); - const [rInput, gInput, bInput, hInput, sInput, vInput] = await page.findAll( - `calcite-color-picker >>> calcite-input.${CSS.channel}` - ); + await updateColorWithAllInputs(page, async (value: ColorValue) => { + expect(value).not.toMatchObject(rgbaObject); + expect(value).toMatchObject({ + r: toBeInteger(), + g: toBeInteger(), + b: toBeInteger(), + a: toBeNumber() + }); + }); - await rgbModeButton.click(); + expect(() => assertUnsupportedValueMessage(rgbaObject, "auto")).toThrow(); + }); - expect(await rInput.getProperty("value")).toBe(""); - expect(await gInput.getProperty("value")).toBe(""); - expect(await bInput.getProperty("value")).toBe(""); + it("supports hsl (object)", async () => { + const hslObject = supportedFormatToSampleValue.hsl; + picker.setProperty("value", hslObject); + await page.waitForChanges(); - await hsvModeButton.click(); + await updateColorWithAllInputs(page, async (value: ColorValue) => { + expect(value).not.toMatchObject(hslObject); + expect(value).toMatchObject({ + h: toBeInteger(), + s: toBeInteger(), + l: toBeInteger(), + a: toBeNumber() + }); + }); - expect(await hInput.getProperty("value")).toBe(""); - expect(await sInput.getProperty("value")).toBe(""); - expect(await vInput.getProperty("value")).toBe(""); + expect(() => assertUnsupportedValueMessage(hslObject, "auto")).toThrow(); }); - describe("clearing color via supporting inputs", () => { - it("clears color via hex input", async () => { - const page = await newE2EPage({ - html: "" + it("supports hsla (object)", async () => { + const hslaObject = supportedAlphaFormatToSampleValue.hsla; + picker.setProperty("value", hslaObject); + await page.waitForChanges(); + + await updateColorWithAllInputs(page, async (value: ColorValue) => { + expect(value).not.toMatchObject(hslaObject); + expect(value).toMatchObject({ + h: toBeInteger(), + s: toBeInteger(), + l: toBeInteger(), + a: toBeNumber() }); - const picker = await page.find("calcite-color-picker"); + }); - const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); - await clearAndEnterHexOrChannelValue(page, hexInput, ""); + expect(() => assertUnsupportedValueMessage(hslaObject, "auto")).toThrow(); + }); + + it("supports hsv (object)", async () => { + const hsvObject = supportedFormatToSampleValue.hsv; + picker.setProperty("value", hsvObject); + await page.waitForChanges(); - expect(await picker.getProperty("value")).toBe(null); + await updateColorWithAllInputs(page, async (value: ColorValue) => { + expect(value).not.toMatchObject(hsvObject); + expect(value).toMatchObject({ + h: toBeInteger(), + s: toBeInteger(), + v: toBeInteger() + }); }); - it("clears color via RGB channel inputs", async () => { - const page = await newE2EPage({ - html: "" + expect(() => assertUnsupportedValueMessage(hsvObject, "auto")).toThrow(); + }); + + it("supports hsva (object)", async () => { + const hsvaObject = supportedAlphaFormatToSampleValue.hsva; + picker.setProperty("value", hsvaObject); + await page.waitForChanges(); + + await updateColorWithAllInputs(page, async (value: ColorValue) => { + expect(value).not.toMatchObject(hsvaObject); + expect(value).toMatchObject({ + h: toBeInteger(), + s: toBeInteger(), + v: toBeInteger(), + a: toBeNumber() }); - const picker = await page.find("calcite-color-picker"); + }); + + expect(() => assertUnsupportedValueMessage(hsvaObject, "auto")).toThrow(); + }); + }); - const [rgbModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); - const [rInput, gInput, bInput] = await page.findAll( + describe("color gets propagated to support inputs", () => { + describe("valid color", () => { + it("color gets propagated to hex, RGB, HSV", async () => { + const page = await newE2EPage(); + await page.setContent(""); + + const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); + + expect(await hexInput.getProperty("value")).toBe("#fff00000"); + + const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + const [rInput, gInput, bInput, rgbAInput, hInput, sInput, vInput, hsvAInput] = await page.findAll( `calcite-color-picker >>> calcite-input.${CSS.channel}` ); await rgbModeButton.click(); - await clearAndEnterHexOrChannelValue(page, rInput, ""); + expect(await rInput.getProperty("value")).toBe("255"); + expect(await gInput.getProperty("value")).toBe("240"); + expect(await bInput.getProperty("value")).toBe("0"); + expect(await rgbAInput.getProperty("value")).toBe("0"); - // clearing one clears the rest - expect(await gInput.getProperty("value")).toBe(""); - expect(await bInput.getProperty("value")).toBe(""); + await hsvModeButton.click(); - expect(await picker.getProperty("value")).toBeNull(); + expect(await hInput.getProperty("value")).toBe("56"); + expect(await sInput.getProperty("value")).toBe("100"); + expect(await vInput.getProperty("value")).toBe("100"); + expect(await hsvAInput.getProperty("value")).toBe("0"); }); - it("clears color via HSV channel inputs", async () => { - const page = await newE2EPage({ - html: "" - }); + it("allows modifying color via hex, RGB, HSV", async () => { + const page = await newE2EPage(); + await page.setContent(""); const picker = await page.find("calcite-color-picker"); - const [, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); + await clearAndEnterHexOrChannelValue(page, hexInput, "abcf"); + + expect(await picker.getProperty("value")).toBe("#aabbccff"); - const [, , , hInput, sInput, vInput] = await page.findAll( + const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + const [rInput, gInput, bInput, rgbAInput, hInput, sInput, vInput, hsvAInput] = await page.findAll( `calcite-color-picker >>> calcite-input.${CSS.channel}` ); + await rgbModeButton.click(); + + await clearAndEnterHexOrChannelValue(page, rInput, "128"); + await clearAndEnterHexOrChannelValue(page, gInput, "64"); + await clearAndEnterHexOrChannelValue(page, bInput, "32"); + await clearAndEnterHexOrChannelValue(page, rgbAInput, "50"); + + expect(await picker.getProperty("value")).toBe("#80402080"); + await hsvModeButton.click(); - await clearAndEnterHexOrChannelValue(page, hInput, ""); + await clearAndEnterHexOrChannelValue(page, hInput, "180"); + await clearAndEnterHexOrChannelValue(page, sInput, "90"); + await clearAndEnterHexOrChannelValue(page, vInput, "45"); + await clearAndEnterHexOrChannelValue(page, hsvAInput, "50"); - // clearing one clears the rest - expect(await sInput.getProperty("value")).toBe(""); - expect(await vInput.getProperty("value")).toBe(""); + expect(await picker.getProperty("value")).toBe("#0b737380"); + }); + + it("allows nudging values", async () => { + const assertChannelValueNudge = async (page: E2EPage, calciteInputOrSlider: E2EElement): Promise => { + await calciteInputOrSlider.callMethod("setFocus"); + const currentValue = await calciteInputOrSlider.getProperty("value"); + + function ensureValueType(value: string | number): number | string { + return typeof currentValue === "string" ? `${value}` : value; + } + + function nudgeValue(value: string | number, amount: number): number | string { + return Number(value) + amount; + } + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + expect(await calciteInputOrSlider.getProperty("value")).toBe( + ensureValueType(nudgeValue(currentValue, 1)) + ); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + expect(await calciteInputOrSlider.getProperty("value")).toBe(ensureValueType(currentValue)); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowUp"); + await page.keyboard.up("Shift"); + await page.waitForChanges(); + expect(await calciteInputOrSlider.getProperty("value")).toBe( + ensureValueType(nudgeValue(currentValue, 10)) + ); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.up("Shift"); + await page.waitForChanges(); + expect(await calciteInputOrSlider.getProperty("value")).toBe(ensureValueType(currentValue)); + }; + + const page = await newE2EPage(); + await page.setContent(""); + + const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + const [rInput, gInput, bInput, rgbAInput, hInput, sInput, vInput, hsvAInput] = await page.findAll( + `calcite-color-picker >>> calcite-input.${CSS.channel}` + ); - expect(await picker.getProperty("value")).toBeNull(); + await rgbModeButton.click(); + + await assertChannelValueNudge(page, rInput); + await assertChannelValueNudge(page, gInput); + await assertChannelValueNudge(page, bInput); + await assertChannelValueNudge(page, rgbAInput); + + await hsvModeButton.click(); + + await assertChannelValueNudge(page, hInput); + await assertChannelValueNudge(page, sInput); + await assertChannelValueNudge(page, vInput); + await assertChannelValueNudge(page, hsvAInput); }); }); - it("restores previous color value when a nudge key is pressed", async () => { - const consistentRgbHsvChannelValue = "0"; - const initialValue = "#".padEnd(7, consistentRgbHsvChannelValue); + describe("when no-color", () => { + it("color gets propagated to hex, RGB, HSV & opacity inputs", async () => { + const page = await newE2EPage(); + await page.setContent(""); + + const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); + + expect(await hexInput.getProperty("value")).toBe(null); + + const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + const [rInput, gInput, bInput, rgbAInput, hInput, sInput, vInput, hsvAInput] = await page.findAll( + `calcite-color-picker >>> calcite-input.${CSS.channel}` + ); + + await rgbModeButton.click(); + + expect(await rInput.getProperty("value")).toBe(""); + expect(await gInput.getProperty("value")).toBe(""); + expect(await bInput.getProperty("value")).toBe(""); + expect(await rgbAInput.getProperty("value")).toBe(""); - const assertChannelValueNudge = async (page: E2EPage, calciteInput: E2EElement): Promise => { - await calciteInput.callMethod("setFocus"); - await clearAndEnterHexOrChannelValue(page, calciteInput, ""); + await hsvModeButton.click(); - // using page.waitForChanges as keyboard nudges occur in the next frame + expect(await hInput.getProperty("value")).toBe(""); + expect(await sInput.getProperty("value")).toBe(""); + expect(await vInput.getProperty("value")).toBe(""); + expect(await hsvAInput.getProperty("value")).toBe(""); + }); - await page.keyboard.press("ArrowUp"); - await page.waitForChanges(); - expect(await calciteInput.getProperty("value")).toBe(consistentRgbHsvChannelValue); + it("restores previous color value when a nudge key is pressed", async () => { + const consistentRgbHsvChannelValue = "0"; + const initialValue = "#".padEnd(9, consistentRgbHsvChannelValue); + + const assertChannelValueNudge = async ( + page: E2EPage, + calciteInputOrSlider: E2EElement, + customValueClearingFn?: () => Promise + ): Promise => { + async function clearValue(): Promise { + customValueClearingFn + ? await customValueClearingFn() + : await clearAndEnterHexOrChannelValue(page, calciteInputOrSlider, ""); + } + + const initialInputValue = await calciteInputOrSlider.getProperty("value"); + + function ensureValueType(value: string | number): number | string { + return typeof initialInputValue === "string" ? `${value}` : Number(value); + } + + await calciteInputOrSlider.callMethod("setFocus"); + await clearValue(); + + // using page.waitForChanges as keyboard nudges occur in the next frame + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(await calciteInputOrSlider.getProperty("value")).toBe( + ensureValueType(consistentRgbHsvChannelValue) + ); + + await clearValue(); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + expect(await calciteInputOrSlider.getProperty("value")).toBe( + ensureValueType(consistentRgbHsvChannelValue) + ); + + await clearValue(); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowUp"); + await page.keyboard.up("Shift"); + await page.waitForChanges(); + expect(await calciteInputOrSlider.getProperty("value")).toBe( + ensureValueType(consistentRgbHsvChannelValue) + ); + + await clearValue(); + + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.up("Shift"); + await page.waitForChanges(); + expect(await calciteInputOrSlider.getProperty("value")).toBe( + ensureValueType(consistentRgbHsvChannelValue) + ); + }; + + const page = await newE2EPage(); + await page.setContent( + `` + ); - await clearAndEnterHexOrChannelValue(page, calciteInput, ""); + const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + const [rInput, gInput, bInput, rgbAInput, hInput, sInput, vInput, hsvAInput] = await page.findAll( + `calcite-color-picker >>> calcite-input.${CSS.channel}` + ); - await page.keyboard.press("ArrowDown"); - await page.waitForChanges(); - expect(await calciteInput.getProperty("value")).toBe(consistentRgbHsvChannelValue); + await rgbModeButton.click(); - await clearAndEnterHexOrChannelValue(page, calciteInput, ""); + await assertChannelValueNudge(page, rInput); + await assertChannelValueNudge(page, gInput); + await assertChannelValueNudge(page, bInput); + await assertChannelValueNudge(page, rgbAInput); - await page.keyboard.down("Shift"); - await page.keyboard.press("ArrowUp"); - await page.keyboard.up("Shift"); - await page.waitForChanges(); - expect(await calciteInput.getProperty("value")).toBe(consistentRgbHsvChannelValue); + await hsvModeButton.click(); - await clearAndEnterHexOrChannelValue(page, calciteInput, ""); + await assertChannelValueNudge(page, hInput); + await assertChannelValueNudge(page, sInput); + await assertChannelValueNudge(page, vInput); + await assertChannelValueNudge(page, hsvAInput); + }); - await page.keyboard.down("Shift"); - await page.keyboard.press("ArrowDown"); - await page.keyboard.up("Shift"); - await page.waitForChanges(); - expect(await calciteInput.getProperty("value")).toBe(consistentRgbHsvChannelValue); - }; + it("changes the value to the specified format after being empty", async () => { + const page = await newE2EPage(); + await page.setContent( + "" + ); + const color = await page.find("calcite-color-picker"); - const page = await newE2EPage({ - html: `` + const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); + await clearAndEnterHexOrChannelValue(page, hexInput, supportedAlphaFormatToSampleValue.hexa); + + expect(await color.getProperty("value")).toEqual(supportedAlphaFormatToSampleValue.rgba); }); - const [rgbModeButton, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); - const [rInput, gInput, bInput, hInput, sInput, vInput] = await page.findAll( - `calcite-color-picker >>> calcite-input.${CSS.channel}` - ); + describe("clearing color via supporting inputs", () => { + it("clears color via hex input", async () => { + const page = await newE2EPage(); + await page.setContent( + "" + ); + const picker = await page.find("calcite-color-picker"); - await rgbModeButton.click(); + const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); + await clearAndEnterHexOrChannelValue(page, hexInput, ""); - await assertChannelValueNudge(page, rInput); - await assertChannelValueNudge(page, gInput); - await assertChannelValueNudge(page, bInput); + expect(await picker.getProperty("value")).toBe(null); + }); - await hsvModeButton.click(); + it("clears color via RGB channel inputs", async () => { + const page = await newE2EPage(); + await page.setContent( + "" + ); + const picker = await page.find("calcite-color-picker"); - await assertChannelValueNudge(page, hInput); - await assertChannelValueNudge(page, sInput); - await assertChannelValueNudge(page, vInput); - }); + const [rgbModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + const [rInput, gInput, bInput, rgbAInput] = await page.findAll( + `calcite-color-picker >>> calcite-input.${CSS.channel}` + ); - it("changes the value to the specified format after being empty", async () => { - const page = await newE2EPage({ - html: "" - }); - const color = await page.find("calcite-color-picker"); + await rgbModeButton.click(); - const hexInput = await page.find(`calcite-color-picker >>> calcite-color-picker-hex-input`); - await clearAndEnterHexOrChannelValue(page, hexInput, supportedFormatToSampleValue.hex); + await clearAndEnterHexOrChannelValue(page, rInput, ""); + + // clearing one clears the rest + expect(await gInput.getProperty("value")).toBe(""); + expect(await bInput.getProperty("value")).toBe(""); + expect(await rgbAInput.getProperty("value")).toBe(""); + + expect(await picker.getProperty("value")).toBeNull(); + }); - expect(await color.getProperty("value")).toEqual(supportedFormatToSampleValue.rgb); + it("clears color via HSV channel inputs", async () => { + const page = await newE2EPage(); + await page.setContent( + "" + ); + const picker = await page.find("calcite-color-picker"); + + const [, hsvModeButton] = await page.findAll(`calcite-color-picker >>> .${CSS.colorMode}`); + + const [, , , , hInput, sInput, vInput, hsvAInput] = await page.findAll( + `calcite-color-picker >>> calcite-input.${CSS.channel}` + ); + + await hsvModeButton.click(); + + await clearAndEnterHexOrChannelValue(page, hInput, ""); + + // clearing one clears the rest + expect(await sInput.getProperty("value")).toBe(""); + expect(await vInput.getProperty("value")).toBe(""); + expect(await hsvAInput.getProperty("value")).toBe(""); + + expect(await picker.getProperty("value")).toBeNull(); + }); + }); }); }); }); }); describe("color storage", () => { - const storageId = "test-storage-id"; - const color1 = "#ff00ff"; - const color2 = "#beefee"; + describe("default", () => { + const storageId = "test-storage-id"; + const color1 = "#ff00ff"; + const color2 = "#beefee"; + + async function clearStorage(): Promise { + const storageKey = `${DEFAULT_STORAGE_KEY_PREFIX}${storageId}`; + const page = await newE2EPage(); + await page.setContent(""); + await page.evaluate((storageKey) => localStorage.removeItem(storageKey), [storageKey]); + } - async function clearStorage(): Promise { - const storageKey = `${DEFAULT_STORAGE_KEY_PREFIX}${storageId}`; - const page = await newE2EPage({ - html: `` + beforeAll(clearStorage); + afterAll(clearStorage); + + it("allows saving unique colors", async () => { + const page = await newE2EPage(); + await page.setContent(``); + + const picker = await page.find("calcite-color-picker"); + const saveColor = await page.find(`calcite-color-picker >>> .${CSS.saveColor}`); + await saveColor.click(); + + picker.setProperty("value", color1); + await page.waitForChanges(); + await saveColor.click(); + + picker.setProperty("value", color2); + await page.waitForChanges(); + await saveColor.click(); + + picker.setProperty("value", color1); + await page.waitForChanges(); + await saveColor.click(); + + picker.setProperty("value", color2); + await page.waitForChanges(); + await saveColor.click(); + + const savedColors = await page.findAll( + `calcite-color-picker >>> .${CSS.savedColors} calcite-color-picker-swatch` + ); + expect(savedColors).toHaveLength(3); }); - await page.evaluate((storageKey) => localStorage.removeItem(storageKey), [storageKey]); - } - beforeAll(clearStorage); - afterAll(clearStorage); + it("loads saved colors", async () => { + const page = await newE2EPage(); + await page.setContent(``); - it("allows saving unique colors", async () => { - const page = await newE2EPage({ - html: `` + const savedColors = await page.findAll( + `calcite-color-picker >>> .${CSS.savedColors} calcite-color-picker-swatch` + ); + expect(savedColors).toHaveLength(3); }); - const picker = await page.find("calcite-color-picker"); - const saveColor = await page.find(`calcite-color-picker >>> .${CSS.saveColor}`); - await saveColor.click(); + it("allows removing stored colors", async () => { + const page = await newE2EPage(); + await page.setContent(``); - picker.setProperty("value", color1); - await page.waitForChanges(); - await saveColor.click(); + const picker = await page.find("calcite-color-picker"); + const saveColor = await page.find(`calcite-color-picker >>> .${CSS.saveColor}`); + await saveColor.click(); - picker.setProperty("value", color2); - await page.waitForChanges(); - await saveColor.click(); + picker.setProperty("value", color1); + await page.waitForChanges(); + await saveColor.click(); - picker.setProperty("value", color1); - await page.waitForChanges(); - await saveColor.click(); + picker.setProperty("value", color2); + await page.waitForChanges(); + await saveColor.click(); - picker.setProperty("value", color2); - await page.waitForChanges(); - await saveColor.click(); + const saved: E2EElement[] = await page.findAll( + `calcite-color-picker >>> .${CSS.savedColors} calcite-color-picker-swatch` + ); + let expectedSaved = 3; - const savedColors = await page.findAll( - `calcite-color-picker >>> .${CSS.savedColors} calcite-color-picker-swatch` - ); - expect(savedColors).toHaveLength(3); - }); + const removeColor = await page.find(`calcite-color-picker >>> .${CSS.deleteColor}`); - it("loads saved colors", async () => { - const page = await newE2EPage({ - html: `` + for (const swatch of saved) { + await swatch.click(); + await removeColor.click(); + + expect( + await page.findAll(`calcite-color-picker >>> .${CSS.savedColors} calcite-color-picker-swatch`) + ).toHaveLength(--expectedSaved); + } }); - const savedColors = await page.findAll( - `calcite-color-picker >>> .${CSS.savedColors} calcite-color-picker-swatch` - ); - expect(savedColors).toHaveLength(3); + it("does not allow saving/removing when no-color is set", async () => { + const page = await newE2EPage(); + await page.setContent(``); + + const saveColor = await page.find(`calcite-color-picker >>> .${CSS.saveColor}`); + const removeColor = await page.find(`calcite-color-picker >>> .${CSS.deleteColor}`); + + expect(await saveColor.getProperty("disabled")).toBe(true); + expect(await removeColor.getProperty("disabled")).toBe(true); + }); }); - it("allows removing stored colors", async () => { - const page = await newE2EPage({ - html: `` + describe("alpha channel", () => { + const storageId = "test-storage-id"; + const color1 = "#ff00ff00"; + const color2 = "#beefeeff"; + + async function clearStorage(): Promise { + const storageKey = `${DEFAULT_STORAGE_KEY_PREFIX}${storageId}`; + const page = await newE2EPage(); + await page.setContent(""); + await page.evaluate((storageKey) => localStorage.removeItem(storageKey), [storageKey]); + } + + beforeAll(clearStorage); + afterAll(clearStorage); + + it("allows saving unique colors", async () => { + const page = await newE2EPage(); + await page.setContent(``); + + const picker = await page.find("calcite-color-picker"); + const saveColor = await page.find(`calcite-color-picker >>> .${CSS.saveColor}`); + await saveColor.click(); + + picker.setProperty("value", color1); + await page.waitForChanges(); + await saveColor.click(); + + picker.setProperty("value", color2); + await page.waitForChanges(); + await saveColor.click(); + + picker.setProperty("value", color1); + await page.waitForChanges(); + await saveColor.click(); + + picker.setProperty("value", color2); + await page.waitForChanges(); + await saveColor.click(); + + const savedColors = await page.findAll( + `calcite-color-picker >>> .${CSS.savedColors} calcite-color-picker-swatch` + ); + expect(savedColors).toHaveLength(3); }); - const picker = await page.find("calcite-color-picker"); - const saveColor = await page.find(`calcite-color-picker >>> .${CSS.saveColor}`); - await saveColor.click(); + it("loads saved colors", async () => { + const page = await newE2EPage(); + await page.setContent(``); - picker.setProperty("value", color1); - await page.waitForChanges(); - await saveColor.click(); + const savedColors = await page.findAll( + `calcite-color-picker >>> .${CSS.savedColors} calcite-color-picker-swatch` + ); + expect(savedColors).toHaveLength(3); + }); - picker.setProperty("value", color2); - await page.waitForChanges(); - await saveColor.click(); + it("allows removing stored colors", async () => { + const page = await newE2EPage(); + await page.setContent(``); - const saved: E2EElement[] = await page.findAll( - `calcite-color-picker >>> .${CSS.savedColors} calcite-color-picker-swatch` - ); - let expectedSaved = 3; + const picker = await page.find("calcite-color-picker"); + const saveColor = await page.find(`calcite-color-picker >>> .${CSS.saveColor}`); + await saveColor.click(); - const removeColor = await page.find(`calcite-color-picker >>> .${CSS.deleteColor}`); + picker.setProperty("value", color1); + await page.waitForChanges(); + await saveColor.click(); - for (const swatch of saved) { - await swatch.click(); - await removeColor.click(); + picker.setProperty("value", color2); + await page.waitForChanges(); + await saveColor.click(); - expect( - await page.findAll(`calcite-color-picker >>> .${CSS.savedColors} calcite-color-picker-swatch`) - ).toHaveLength(--expectedSaved); - } - }); + const saved: E2EElement[] = await page.findAll( + `calcite-color-picker >>> .${CSS.savedColors} calcite-color-picker-swatch` + ); + let expectedSaved = 3; - it("does not allow saving/removing when no-color is set", async () => { - const page = await newE2EPage({ - html: `` + const removeColor = await page.find(`calcite-color-picker >>> .${CSS.deleteColor}`); + + for (const swatch of saved) { + await swatch.click(); + await removeColor.click(); + + expect( + await page.findAll(`calcite-color-picker >>> .${CSS.savedColors} calcite-color-picker-swatch`) + ).toHaveLength(--expectedSaved); + } }); - const saveColor = await page.find(`calcite-color-picker >>> .${CSS.saveColor}`); - const removeColor = await page.find(`calcite-color-picker >>> .${CSS.deleteColor}`); + it("does not allow saving/removing when no-color is set", async () => { + const page = await newE2EPage(); + await page.setContent(``); + + const saveColor = await page.find(`calcite-color-picker >>> .${CSS.saveColor}`); + const removeColor = await page.find(`calcite-color-picker >>> .${CSS.deleteColor}`); - expect(await saveColor.getProperty("disabled")).toBe(true); - expect(await removeColor.getProperty("disabled")).toBe(true); + expect(await saveColor.getProperty("disabled")).toBe(true); + expect(await removeColor.getProperty("disabled")).toBe(true); + }); }); }); it("allows setting no-color", async () => { - const page = await newE2EPage({ - html: `` - }); + const page = await newE2EPage(); + await page.setContent(``); const color = await page.find("calcite-color-picker"); @@ -1184,9 +1942,8 @@ describe("calcite-color-picker", () => { }); it("allows hiding sections", async () => { - const page = await newE2EPage({ - html: `` - }); + const page = await newE2EPage(); + await page.setContent(``); type HiddenSection = "hex" | "channels" | "saved"; @@ -1242,12 +1999,11 @@ describe("calcite-color-picker", () => { describe("scope keyboard interaction", () => { it("allows editing color field via keyboard", async () => { - const page = await newE2EPage({ - html: `` - }); + const page = await newE2EPage(); + await page.setContent(``); const picker = await page.find("calcite-color-picker"); - const scope = await page.find(`calcite-color-picker >>> .${CSS.scope}`); + const scope = await page.find(`calcite-color-picker >>> .${CSS.colorFieldScope}`); await scope.press("Tab"); expect(await picker.getProperty("value")).toBeFalsy(); @@ -1266,9 +2022,8 @@ describe("calcite-color-picker", () => { }); it("allows nudging color's saturation even if it does not change RGB value", async () => { - const page = await newE2EPage({ - html: `` - }); + const page = await newE2EPage(); + await page.setContent(``); const scope = await page.find(`calcite-color-picker >>> .${CSS.colorFieldScope}`); const initialStyle = await scope.getComputedStyle(); @@ -1281,22 +2036,21 @@ describe("calcite-color-picker", () => { while (nudgesToTheEdge--) { await scope.press("ArrowRight"); } + await page.waitForChanges(); const finalStyle = await scope.getComputedStyle(); expect(finalStyle.left).toBe(`${DIMENSIONS.m.colorField.width}px`); }); it("allows nudging color's hue even if it does not change RGB value", async () => { - const page = await newE2EPage({ - html: `` - }); + const page = await newE2EPage(); + await page.setContent(``); const scope = await page.find(`calcite-color-picker >>> .${CSS.hueScope}`); - const nudgeAThirdOfSlider = async () => { - let stepsToShiftNudgeToAThird = 18; + const nudgeAQuarterOfSlider = async () => { + let totalNudgesByTen = 12; - while (stepsToShiftNudgeToAThird--) { - // pressing shift to move faster across slider + while (totalNudgesByTen--) { await page.keyboard.down("Shift"); await scope.press("ArrowRight"); await page.keyboard.up("Shift"); @@ -1307,54 +2061,52 @@ describe("calcite-color-picker", () => { expect(await getScopeLeftOffset()).toBe(0); - await clickScope(page, "hue"); - await nudgeAThirdOfSlider(); - - expect(await getScopeLeftOffset()).toBeCloseTo(DIMENSIONS.m.colorField.width / 2); + await nudgeAQuarterOfSlider(); + expect(await getScopeLeftOffset()).toBe(68); - await nudgeAThirdOfSlider(); + await nudgeAQuarterOfSlider(); + expect(await getScopeLeftOffset()).toBe(136); + await nudgeAQuarterOfSlider(); // hue wraps around, so we nudge it back to assert position at the edge await scope.press("ArrowLeft"); - expect(await getScopeLeftOffset()).toBeCloseTo(DIMENSIONS.m.colorField.width - 1, 0); + expect(await getScopeLeftOffset()).toBeLessThanOrEqual(204); + expect(await getScopeLeftOffset()).toBeGreaterThan(203); - // nudge it back to wrap around + // nudge it to wrap around await scope.press("ArrowRight"); - expect(await getScopeLeftOffset()).toBeCloseTo(0); + expect(await getScopeLeftOffset()).toBe(0); }); it("allows editing hue slider via keyboard", async () => { - const page = await newE2EPage({ - html: `` - }); + const page = await newE2EPage(); + await page.setContent(``); const picker = await page.find("calcite-color-picker"); - const scopes = await page.findAll(`calcite-color-picker >>> .${CSS.scope}`); + const hueScope = await page.find(`calcite-color-picker >>> .${CSS.hueScope}`); - await scopes[0].press("Tab"); - await scopes[1].press("ArrowDown"); + await hueScope.press("ArrowDown"); expect(await picker.getProperty("value")).toBe("#007ec2"); - await scopes[1].press("ArrowRight"); + await hueScope.press("ArrowRight"); expect(await picker.getProperty("value")).toBe("#007bc2"); - await scopes[1].press("ArrowLeft"); + await hueScope.press("ArrowLeft"); expect(await picker.getProperty("value")).toBe("#007ec2"); await page.keyboard.press("Shift"); - await scopes[1].press("ArrowDown"); + await hueScope.press("ArrowDown"); expect(await picker.getProperty("value")).toBe("#0081c2"); - await scopes[1].press("ArrowUp"); + await hueScope.press("ArrowUp"); expect(await picker.getProperty("value")).toBe("#007ec2"); }); it("positions the scope correctly when the color is 000", async () => { - const page = await newE2EPage({ - html: `` - }); + const page = await newE2EPage(); + await page.setContent(``); - const [, hueSliderScope] = await page.findAll(`calcite-color-picker >>> .${CSS.scope}`); + const hueSliderScope = await page.find(`calcite-color-picker >>> .${CSS.hueScope}`); expect(await hueSliderScope.getComputedStyle()).toMatchObject({ - top: "157px", + top: "10px", left: "0px" }); }); diff --git a/src/components/color-picker/color-picker.scss b/src/components/color-picker/color-picker.scss index 106a7439b4c..b6c2b03aa70 100644 --- a/src/components/color-picker/color-picker.scss +++ b/src/components/color-picker/color-picker.scss @@ -1,9 +1,3 @@ -$section-padding: 12px; -$section-padding--large: 16px; -$gap: 8px; -$gap--small: 4px; -$gap--large: 12px; - :host { @apply text-n2h inline-block font-normal; } @@ -11,72 +5,51 @@ $gap--large: 12px; @include disabled(); :host([scale="s"]) { + --calcite-color-picker-spacing: 8px; + .container { inline-size: 160px; } .saved-colors { - grid-template-columns: repeat(auto-fill, minmax(20px, 1fr)); - } - - .channels { - flex-direction: column; - } - - .channel { - inline-size: 100%; - margin-block-end: $gap--small; - - &:last-child { - margin-block-end: 0; - } + @apply gap-1; + grid-template-columns: repeat(auto-fill, 20px); } } :host([scale="m"]) { + --calcite-color-picker-spacing: 12px; + .container { inline-size: 272px; } } :host([scale="l"]) { - .header { - @apply pb-0; - } -} + --calcite-color-picker-spacing: 16px; -:host([scale="l"]) { @apply text-n1h; .container { inline-size: 464px; } - .color-field-and-slider { - margin-block-end: -20px; - } - .section { - padding-block: 0 $section-padding--large; - padding-inline: $section-padding--large; - &:first-of-type { - padding-block-start: $section-padding--large; + padding-block-start: var(--calcite-color-picker-spacing); } } .saved-colors { - grid-template-columns: repeat(auto-fill, minmax(28px, 1fr)); - grid-gap: $gap--large; - padding-block-start: $section-padding--large; + grid-template-columns: repeat(auto-fill, 32px); } .control-section { @apply flex-nowrap items-baseline; + } - > :nth-child(2) { - margin-inline-start: $gap--large; - } + .control-section { + @apply flex-wrap; } .color-hex-options { @@ -84,8 +57,6 @@ $gap--large: 12px; flex-shrink flex-col justify-around; - min-block-size: 98px; - inline-size: 160px; } .color-mode-container { @@ -99,16 +70,15 @@ $gap--large: 12px; border: 1px solid var(--calcite-ui-border-1); } -.color-field-and-slider-wrap { - position: relative; +.control-and-scope { + @apply flex relative cursor-pointer touch-none; } .scope { @apply text-n1 focus-base - absolute; - - outline-offset: 14px; + absolute + z-default; &:focus { @apply focus-outset; @@ -116,30 +86,34 @@ $gap--large: 12px; } } -.color-field-and-slider { - margin-block-end: -16px; - touch-action: none; - - &--interactive { - cursor: pointer; - } +.hex-and-channels-group { + @apply w-full; } +.hex-and-channels-group, .control-section { - display: flex; - flex-direction: row; - flex-wrap: wrap; + @apply flex flex-row flex-wrap; } .section { - padding-block: 0 $section-padding; - padding-inline: $section-padding; + padding-block: 0 var(--calcite-color-picker-spacing); + padding-inline: var(--calcite-color-picker-spacing); &:first-of-type { - padding-block-start: $section-padding; + padding-block-start: var(--calcite-color-picker-spacing); } } +.sliders { + @apply flex flex-col justify-between; + margin-inline-start: var(--calcite-color-picker-spacing); +} + +.preview-and-sliders { + @apply flex items-center; + padding: var(--calcite-color-picker-spacing); +} + .color-hex-options, .section--split { flex-grow: 1; @@ -149,31 +123,51 @@ $gap--large: 12px; @apply text-color-1 flex items-center - justify-between - pb-1; + justify-between; } -.header--hex, .color-mode-container { - padding-block-start: $section-padding; + padding-block-start: var(--calcite-color-picker-spacing); } .channels { - display: flex; - justify-content: space-between; + @apply flex gap-y-0.5; } .channel { - inline-size: 31%; + &[data-channel-index="3"] { + inline-size: 159px; + } +} + +:host([scale="s"]) { + .channels { + @apply flex-wrap; + } + + .channel { + flex-basis: 30%; + flex-grow: 1; + + &[data-channel-index="3"] { + inline-size: unset; + margin-inline-start: unset; + } + } +} + +:host([scale="l"]) { + .channel { + &[data-channel-index="3"] { + inline-size: 131px; + } + } } .saved-colors { - padding-block-start: $section-padding; - display: -ms-grid; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(24px, 1fr)); - grid-gap: $gap; - inline-size: 100%; + @apply grid gap-2; + padding-block-start: var(--calcite-color-picker-spacing); + grid-template-columns: repeat(auto-fill, 24px); } .saved-colors-buttons { diff --git a/src/components/color-picker/color-picker.stories.ts b/src/components/color-picker/color-picker.stories.ts index c8d326ecd1a..a26a7a5c187 100644 --- a/src/components/color-picker/color-picker.stories.ts +++ b/src/components/color-picker/color-picker.stories.ts @@ -27,25 +27,25 @@ const createColorAttributes: (options?: { exceptions: string[] }) => Attributes return filterComponentAttributes( [ { - name: "hide-channels", + name: "channels-disabled", commit(): Attribute { - this.value = boolean("hide-channels", false); + this.value = boolean("channels-disabled", false); delete this.build; return this; } }, { - name: "hide-hex", + name: "hex-disabled", commit(): Attribute { - this.value = boolean("hide-hex", false); + this.value = boolean("hex-disabled", false); delete this.build; return this; } }, { - name: "hide-saved", + name: "saved-disabled", commit(): Attribute { - this.value = boolean("hide-saved", false); + this.value = boolean("saved-disabled", false); delete this.build; return this; } @@ -76,6 +76,13 @@ export const simple = (): string => } ]); +export const alphaChannel = (): string => + create("calcite-color-picker", [ + ...createColorAttributes(), + { name: "alpha-channel", value: true }, + { name: "value", value: text("value", "#b33f3333") } + ]); + export const disabled_TestOnly = (): string => html``; export const darkModeRTL_TestOnly = (): string => diff --git a/src/components/color-picker/color-picker.tsx b/src/components/color-picker/color-picker.tsx index 8b49c5bf6b9..fde68fa29bb 100644 --- a/src/components/color-picker/color-picker.tsx +++ b/src/components/color-picker/color-picker.tsx @@ -13,19 +13,35 @@ import { } from "@stencil/core"; import Color from "color"; +import { Channels, ColorMode, ColorValue, HSLA, HSVA, InternalColor, RGBA } from "./interfaces"; import { throttle } from "lodash-es"; import { Direction, getElementDir, isPrimaryPointerButton } from "../../utils/dom"; import { Scale } from "../interfaces"; -import { ColorMode, ColorValue, InternalColor } from "./interfaces"; import { CSS, DEFAULT_COLOR, DEFAULT_STORAGE_KEY_PREFIX, DIMENSIONS, HSV_LIMITS, + OPACITY_LIMITS, RGB_LIMITS } from "./resources"; -import { colorEqual, CSSColorMode, Format, normalizeHex, parseMode, SupportedMode } from "./utils"; +import { + alphaCompatible, + alphaToOpacity, + colorEqual, + CSSColorMode, + Format, + hexify, + normalizeAlpha, + normalizeColor, + normalizeHex, + opacityToAlpha, + parseMode, + SupportedMode, + toAlphaMode, + toNonAlphaMode +} from "./utils"; import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; import { isActivationKey } from "../../utils/key"; @@ -52,8 +68,6 @@ import { import { ColorPickerMessages } from "./assets/color-picker/t9n"; const throttleFor60FpsInMs = 16; -const defaultValue = normalizeHex(DEFAULT_COLOR.hex()); -const defaultFormat = "auto"; @Component({ tag: "calcite-color-picker", @@ -85,6 +99,26 @@ export class ColorPicker */ @Prop({ reflect: true }) allowEmpty = false; + /** + * When true, the component will allow updates to the color's alpha value. + */ + @Prop() alphaChannel = false; + + @Watch("alphaChannel") + handleAlphaChannelChange(alphaChannel: boolean): void { + 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; + } + } + + /** When true, hides the RGB/HSV channel inputs */ + @Prop() channelsDisabled = false; + /** * Internal prop for advanced use-cases. * @@ -94,7 +128,7 @@ export class ColorPicker @Watch("color") handleColorChange(color: Color | null, oldColor: Color | null): void { - this.drawColorFieldAndSlider(); + this.drawColorControls(); this.updateChannelsFromColor(color); this.previousColor = oldColor; } @@ -111,30 +145,49 @@ export class ColorPicker * * @default "auto" */ - @Prop({ reflect: true }) format: Format = defaultFormat; + @Prop({ reflect: true }) format: Format = "auto"; @Watch("format") - handleFormatChange(format: ColorPicker["format"]): void { + handleFormatChange(format: Format): void { this.setMode(format); this.internalColorSet(this.color, false, "internal"); } - /** When `true`, hides the Hex input. */ - @Prop({ reflect: true }) hideHex = false; - - /** When `true`, hides the RGB/HSV channel inputs. */ + /** + * When `true`, hides the RGB/HSV channel inputs. + * + * @deprecated use `channelsDisabled` instead + */ @Prop({ reflect: true }) hideChannels = false; - /** When `true`, hides the saved colors section. */ + /** When true, hides the hex input */ + @Prop() hexDisabled = false; + + /** + * When `true`, hides the hex input. + * + * @deprecated use `hexDisabled` instead + */ + @Prop({ reflect: true }) hideHex = false; + + /** + * When `true`, hides the saved colors section. + * + * @deprecated use `savedDisabled` instead + */ @Prop({ reflect: true }) hideSaved = false; + /** When true, hides the saved colors section */ + @Prop({ reflect: true }) savedDisabled = false; + /** Specifies the size of the component. */ @Prop({ reflect: true }) scale: Scale = "m"; @Watch("scale") handleScaleChange(scale: Scale = "m"): void { this.updateDimensions(scale); - this.updateCanvasSize(this.fieldAndSliderRenderingContext?.canvas); + this.updateCanvasSize("all"); + this.drawColorControls(); } /** Specifies the storage ID for colors. */ @@ -163,7 +216,9 @@ export class ColorPicker * @see [CSS Color](https://developer.mozilla.org/en-US/docs/Web/CSS/color) * @see [ColorValue](https://github.com/Esri/calcite-components/blob/master/src/components/color-picker/interfaces.ts#L10) */ - @Prop({ mutable: true }) value: ColorValue | null = defaultValue; + @Prop({ mutable: true }) value: ColorValue | null = normalizeHex( + hexify(DEFAULT_COLOR, this.alphaChannel) + ); @Watch("value") handleValueChange(value: ColorValue | null, oldValue: ColorValue | null): void { @@ -181,10 +236,10 @@ export class ColorPicker } modeChanged = this.mode !== nextMode; - this.setMode(nextMode); + this.setMode(nextMode, this.internalColorUpdateContext === null); } - const dragging = this.sliderThumbState === "drag" || this.hueThumbState === "drag"; + const dragging = this.activeCanvasInfo; if (this.internalColorUpdateContext === "initial") { return; @@ -199,52 +254,67 @@ export class ColorPicker return; } - const color = allowEmpty && !value ? null : Color(value); + const color = + allowEmpty && !value + ? null + : Color( + value != null && typeof value === "object" && alphaCompatible(this.mode) + ? normalizeColor(value as RGBA | HSVA | HSLA) + : value + ); const colorChanged = !colorEqual(color, this.color); if (modeChanged || colorChanged) { - this.internalColorSet(color, true, "internal"); + this.internalColorSet( + color, + this.alphaChannel && !(this.mode.endsWith("a") || this.mode.endsWith("a-css")), + "internal" + ); } } + //-------------------------------------------------------------------------- // // Internal State/Props // //-------------------------------------------------------------------------- + private activeCanvasInfo: { + context: CanvasRenderingContext2D; + bounds: DOMRect; + }; + private get baseColorFieldColor(): Color { return this.color || this.previousColor || DEFAULT_COLOR; } - private activeColorFieldAndSliderRect: DOMRect; + private checkerPattern: HTMLCanvasElement; - private colorFieldAndSliderHovered = false; - - private fieldAndSliderRenderingContext: CanvasRenderingContext2D; + private colorFieldRenderingContext: CanvasRenderingContext2D; private colorFieldScopeNode: HTMLDivElement; - private hueThumbState: "idle" | "hover" | "drag" = "idle"; + private hueSliderRenderingContext: CanvasRenderingContext2D; private hueScopeNode: HTMLDivElement; private internalColorUpdateContext: "internal" | "initial" | "user-interaction" | null = null; - private previousColor: InternalColor | null; - private mode: SupportedMode = CSSColorMode.HEX; - private shiftKeyChannelAdjustment = 0; + private opacityScopeNode: HTMLDivElement; - private sliderThumbState: "idle" | "hover" | "drag" = "idle"; + private opacitySliderRenderingContext: CanvasRenderingContext2D; - @State() defaultMessages: ColorPickerMessages; + private previousColor: InternalColor | null; - @State() colorFieldAndSliderInteractive = false; + private shiftKeyChannelAdjustment = 0; + + @State() defaultMessages: ColorPickerMessages; @State() channelMode: ColorMode = "rgb"; - @State() channels: [number, number, number] = this.toChannels(DEFAULT_COLOR); + @State() channels: Channels = this.toChannels(DEFAULT_COLOR); @State() dimensions = DIMENSIONS.m; @@ -269,11 +339,11 @@ export class ColorPicker @State() colorFieldScopeLeft: number; - @State() scopeOrientation: "vertical" | "horizontal"; - @State() hueScopeLeft: number; - @State() hueScopeTop: number; + @State() opacityScopeLeft: number; + + @State() scopeOrientation: "vertical" | "horizontal"; //-------------------------------------------------------------------------- // @@ -351,7 +421,7 @@ export class ColorPicker return; } - const normalizedHex = color && normalizeHex(color.hex()); + const normalizedHex = color && normalizeHex(hexify(color, alphaCompatible(this.mode))); if (hex !== normalizedHex) { this.internalColorSet(Color(hex)); @@ -366,19 +436,22 @@ export class ColorPicker private handleChannelInput = (event: CustomEvent): void => { const input = event.currentTarget as HTMLCalciteInputElement; const channelIndex = Number(input.getAttribute("data-channel-index")); + const isAlphaChannel = channelIndex === 3; - const limit = - this.channelMode === "rgb" - ? RGB_LIMITS[Object.keys(RGB_LIMITS)[channelIndex]] - : HSV_LIMITS[Object.keys(HSV_LIMITS)[channelIndex]]; + 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: string; if (this.allowEmpty && !input.value) { inputValue = ""; } else { - const value = Number(input.value) + this.shiftKeyChannelAdjustment; - const clamped = clamp(value, 0, limit); + const value = Number(input.value); + const adjustedValue = value + this.shiftKeyChannelAdjustment; + const clamped = clamp(adjustedValue, 0, limit); inputValue = clamped.toString(); } @@ -432,12 +505,15 @@ export class ColorPicker const shouldClearChannels = this.allowEmpty && !input.value; if (shouldClearChannels) { - this.channels = [null, null, null]; + this.channels = [null, null, null, null]; this.internalColorSet(null); return; } - channels[channelIndex] = Number(input.value); + const isAlphaChannel = channelIndex === 3; + const value = Number(input.value); + + channels[channelIndex] = isAlphaChannel ? opacityToAlpha(value) : value; this.updateColorFromChannels(channels); }; @@ -448,40 +524,58 @@ export class ColorPicker } }; - private handleColorFieldAndSliderPointerLeave = (): void => { - this.colorFieldAndSliderInteractive = false; - this.colorFieldAndSliderHovered = false; - - if (this.sliderThumbState !== "drag" && this.hueThumbState !== "drag") { - this.hueThumbState = "idle"; - this.sliderThumbState = "idle"; - this.drawColorFieldAndSlider(); + private handleColorFieldPointerDown = (event: PointerEvent): void => { + if (!isPrimaryPointerButton(event)) { + return; } + + const { offsetX, offsetY } = event; + + document.addEventListener("pointermove", this.globalPointerMoveHandler); + document.addEventListener("pointerup", this.globalPointerUpHandler, { once: true }); + + this.activeCanvasInfo = { + context: this.colorFieldRenderingContext, + bounds: this.colorFieldRenderingContext.canvas.getBoundingClientRect() + }; + this.captureColorFieldColor(offsetX, offsetY); + this.colorFieldScopeNode.focus(); }; - private handleColorFieldAndSliderPointerDown = (event: PointerEvent): void => { + private handleHueSliderPointerDown = (event: PointerEvent): void => { if (!isPrimaryPointerButton(event)) { return; } - const { offsetX, offsetY } = event; - const region = this.getCanvasRegion(offsetY); - - if (region === "color-field") { - this.hueThumbState = "drag"; - this.captureColorFieldColor(offsetX, offsetY); - this.colorFieldScopeNode?.focus(); - } else if (region === "slider") { - this.sliderThumbState = "drag"; - this.captureHueSliderColor(offsetX); - this.hueScopeNode?.focus(); + const { offsetX } = event; + + document.addEventListener("pointermove", this.globalPointerMoveHandler); + document.addEventListener("pointerup", this.globalPointerUpHandler, { once: true }); + + this.activeCanvasInfo = { + context: this.hueSliderRenderingContext, + bounds: this.hueSliderRenderingContext.canvas.getBoundingClientRect() + }; + this.captureHueSliderColor(offsetX); + this.hueScopeNode.focus(); + }; + + private handleOpacitySliderPointerDown = (event: PointerEvent): void => { + if (!isPrimaryPointerButton(event)) { + return; } + const { offsetX } = event; + document.addEventListener("pointermove", this.globalPointerMoveHandler); document.addEventListener("pointerup", this.globalPointerUpHandler, { once: true }); - this.activeColorFieldAndSliderRect = - this.fieldAndSliderRenderingContext.canvas.getBoundingClientRect(); + this.activeCanvasInfo = { + context: this.opacitySliderRenderingContext, + bounds: this.opacitySliderRenderingContext.canvas.getBoundingClientRect() + }; + this.captureOpacitySliderValue(offsetX); + this.opacityScopeNode.focus(); }; private globalPointerUpHandler = (event: PointerEvent): void => { @@ -489,12 +583,9 @@ export class ColorPicker return; } - const previouslyDragging = this.sliderThumbState === "drag" || this.hueThumbState === "drag"; - - this.hueThumbState = "idle"; - this.sliderThumbState = "idle"; - this.activeColorFieldAndSliderRect = null; - this.drawColorFieldAndSlider(); + const previouslyDragging = this.activeCanvasInfo; + this.activeCanvasInfo = null; + this.drawColorControls(); if (previouslyDragging) { this.calciteColorPickerChange.emit(); @@ -502,130 +593,48 @@ export class ColorPicker }; private globalPointerMoveHandler = (event: PointerEvent): void => { - const { el, dimensions } = this; - const sliderThumbDragging = this.sliderThumbState === "drag"; - const hueThumbDragging = this.hueThumbState === "drag"; + const { activeCanvasInfo, el } = this; - if (!el.isConnected || (!sliderThumbDragging && !hueThumbDragging)) { + if (!el.isConnected || !activeCanvasInfo) { return; } + const { context, bounds } = activeCanvasInfo; + let samplingX: number; let samplingY: number; - const colorFieldAndSliderRect = this.activeColorFieldAndSliderRect; const { clientX, clientY } = event; - if (this.colorFieldAndSliderHovered) { - samplingX = clientX - colorFieldAndSliderRect.x; - samplingY = clientY - colorFieldAndSliderRect.y; + if (context.canvas.matches(":hover")) { + samplingX = clientX - bounds.x; + samplingY = clientY - bounds.y; } else { - const colorFieldWidth = dimensions.colorField.width; - const colorFieldHeight = dimensions.colorField.height; - const hueSliderHeight = dimensions.slider.height; + // snap x and y to the closest edge - if ( - clientX < colorFieldAndSliderRect.x + colorFieldWidth && - clientX > colorFieldAndSliderRect.x - ) { - samplingX = clientX - colorFieldAndSliderRect.x; - } else if (clientX < colorFieldAndSliderRect.x) { + if (clientX < bounds.x + bounds.width && clientX > bounds.x) { + samplingX = clientX - bounds.x; + } else if (clientX < bounds.x) { samplingX = 0; } else { - samplingX = colorFieldWidth - 1; + samplingX = bounds.width; } - if ( - clientY < colorFieldAndSliderRect.y + colorFieldHeight + hueSliderHeight && - clientY > colorFieldAndSliderRect.y - ) { - samplingY = clientY - colorFieldAndSliderRect.y; - } else if (clientY < colorFieldAndSliderRect.y) { + if (clientY < bounds.y + bounds.height && clientY > bounds.y) { + samplingY = clientY - bounds.y; + } else if (clientY < bounds.y) { samplingY = 0; } else { - samplingY = colorFieldHeight + hueSliderHeight; + samplingY = bounds.height; } } - if (hueThumbDragging) { + if (context === this.colorFieldRenderingContext) { this.captureColorFieldColor(samplingX, samplingY, false); - } else { + } else if (context === this.hueSliderRenderingContext) { this.captureHueSliderColor(samplingX); - } - }; - - private handleColorFieldAndSliderPointerEnterOrMove = ({ - offsetX, - offsetY - }: PointerEvent): void => { - const { - dimensions: { colorField, slider, thumb } - } = this; - - this.colorFieldAndSliderInteractive = offsetY <= colorField.height + slider.height; - this.colorFieldAndSliderHovered = true; - - const region = this.getCanvasRegion(offsetY); - - if (region === "color-field") { - const prevHueThumbState = this.hueThumbState; - const color = this.baseColorFieldColor.hsv(); - - const centerX = Math.round(color.saturationv() / (HSV_LIMITS.s / colorField.width)); - const centerY = Math.round( - colorField.height - color.value() / (HSV_LIMITS.v / colorField.height) - ); - - const hoveringThumb = this.containsPoint(offsetX, offsetY, centerX, centerY, thumb.radius); - - let transitionedBetweenHoverAndIdle = false; - - if (prevHueThumbState === "idle" && hoveringThumb) { - this.hueThumbState = "hover"; - transitionedBetweenHoverAndIdle = true; - } else if (prevHueThumbState === "hover" && !hoveringThumb) { - this.hueThumbState = "idle"; - transitionedBetweenHoverAndIdle = true; - } - - if (this.hueThumbState !== "drag") { - if (transitionedBetweenHoverAndIdle) { - // refresh since we won't update color and thus no redraw - this.drawColorFieldAndSlider(); - } - } - } else if (region === "slider") { - const sliderThumbColor = this.baseColorFieldColor.hsv().saturationv(100).value(100); - - const prevSliderThumbState = this.sliderThumbState; - const sliderThumbCenterX = Math.round(sliderThumbColor.hue() / (360 / slider.width)); - const sliderThumbCenterY = - Math.round((slider.height + this.getSliderCapSpacing()) / 2) + colorField.height; - - const hoveringSliderThumb = this.containsPoint( - offsetX, - offsetY, - sliderThumbCenterX, - sliderThumbCenterY, - thumb.radius - ); - - let sliderThumbTransitionedBetweenHoverAndIdle = false; - - if (prevSliderThumbState === "idle" && hoveringSliderThumb) { - this.sliderThumbState = "hover"; - sliderThumbTransitionedBetweenHoverAndIdle = true; - } else if (prevSliderThumbState === "hover" && !hoveringSliderThumb) { - this.sliderThumbState = "idle"; - sliderThumbTransitionedBetweenHoverAndIdle = true; - } - - if (this.sliderThumbState !== "drag") { - if (sliderThumbTransitionedBetweenHoverAndIdle) { - // refresh since we won't update color and thus no redraw - this.drawColorFieldAndSlider(); - } - } + } else if (context === this.opacitySliderRenderingContext) { + this.captureOpacitySliderValue(samplingX); } }; @@ -663,7 +672,7 @@ export class ColorPicker this.showIncompatibleColorWarning(value, format); } - this.setMode(format); + this.setMode(format, false); this.internalColorSet(initialColor, false, "initial"); this.updateDimensions(this.scale); @@ -704,40 +713,51 @@ export class ColorPicker //-------------------------------------------------------------------------- render(): VNode { - const { allowEmpty, color, messages, hideHex, hideChannels, hideSaved, savedColors, scale } = - this; - const selectedColorInHex = color ? color.hex() : null; - const hexInputScale = scale === "l" ? "m" : "s"; const { - colorFieldAndSliderInteractive, - colorFieldScopeTop, + allowEmpty, + channelsDisabled, + color, colorFieldScopeLeft, - hueScopeLeft, - hueScopeTop, - scopeOrientation, + colorFieldScopeTop, dimensions: { - colorField: { height: colorFieldHeight, width: colorFieldWidth }, - slider: { height: sliderHeight } - } + colorField: { width: colorFieldWidth }, + slider: { width: sliderWidth }, + thumb: { radius: thumbRadius } + }, + hexDisabled, + hideChannels, + hideHex, + hideSaved, + hueScopeLeft, + messages, + alphaChannel, + opacityScopeLeft, + savedColors, + savedDisabled, + scale, + scopeOrientation } = this; - const hueTop = hueScopeTop ?? sliderHeight / 2 + colorFieldHeight; - const hueLeft = hueScopeLeft ?? (colorFieldWidth * DEFAULT_COLOR.hue()) / HSV_LIMITS.h; + const selectedColorInHex = color ? hexify(color, alphaChannel) : null; + const hueTop = thumbRadius; + const hueLeft = hueScopeLeft ?? (sliderWidth * DEFAULT_COLOR.hue()) / HSV_LIMITS.h; + const opacityTop = thumbRadius; + const opacityLeft = + opacityScopeLeft ?? + (colorFieldWidth * alphaToOpacity(DEFAULT_COLOR.alpha())) / OPACITY_LIMITS.max; const noColor = color === null; const vertical = scopeOrientation === "vertical"; + const noHex = hexDisabled || hideHex; + const noChannels = channelsDisabled || hideChannels; + const noSaved = savedDisabled || hideSaved; + return (
-
+
-
- {hideHex && hideChannels ? null : ( +
+ +
+
+ +
+
+ {alphaChannel ? ( +
+ +
+
+ ) : null} +
+
+ {noHex && noChannels ? null : (
- {hideHex ? null : ( -
- + {noHex ? null : ( +
+ +
+ )} + {noChannels ? null : ( + - {messages.hex} -
- -
- )} - {hideChannels ? null : ( - - - {this.renderChannelsTabTitle("rgb")} - {this.renderChannelsTabTitle("hsv")} - - {this.renderChannelsTab("rgb")} - {this.renderChannelsTab("hsv")} - - )} + + {this.renderChannelsTabTitle("rgb")} + {this.renderChannelsTabTitle("hsv")} + + {this.renderChannelsTab("rgb")} + {this.renderChannelsTab("hsv")} + + )} +
)} - {hideSaved ? null : ( + {noSaved ? null : (
@@ -825,7 +876,7 @@ export class ColorPicker kind="neutral" label={messages.deleteColor} onClick={this.deleteColor} - scale={hexInputScale} + scale={scale} type="button" />
@@ -846,7 +897,6 @@ export class ColorPicker {[ ...savedColors.map((color) => ( { - const { channelMode: activeChannelMode, channels, messages } = this; + const { allowEmpty, channelMode: activeChannelMode, channels, messages, alphaChannel } = this; const selected = channelMode === activeChannelMode; const isRgb = channelMode === "rgb"; - const channelLabels = isRgb - ? [messages.r, messages.g, messages.b] - : [messages.h, messages.s, messages.v]; 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 ( {/* channel order should not be mirrored */}
- {channels.map((channel, index) => + {channelsToRender.map((channelValue, index) => { + const isAlphaChannel = index === 3; + + if (isAlphaChannel) { + channelValue = + allowEmpty && !channelValue ? channelValue : alphaToOpacity(channelValue); + } + /* the channel container is ltr, so we apply the host's direction */ - this.renderChannel( - channel, + return this.renderChannel( + channelValue, index, - channelLabels[index], channelAriaLabels[index], - direction - ) - )} + direction, + isAlphaChannel ? "%" : "" + ); + })}
); @@ -925,27 +980,37 @@ export class ColorPicker private renderChannel = ( value: number | null, index: number, - label: string, ariaLabel: string, - direction: Direction - ): VNode => ( - - ); + direction: Direction, + suffix?: string + ): VNode => { + return ( + 0 && !(this.scale === "s" && this.alphaChannel && index === 3) ? "-1px" : "" + }} + suffixText={suffix} + type="number" + value={value?.toString()} + /> + ); + }; // -------------------------------------------------------------------------- // @@ -965,8 +1030,40 @@ export class ColorPicker ); } - private setMode(format: ColorPicker["format"]): void { - this.mode = format === "auto" ? this.mode : format; + private setMode(format: ColorPicker["format"], warn = true): void { + const mode = format === "auto" ? this.mode : format; + this.mode = this.ensureCompatibleMode(mode, warn); + } + + private ensureCompatibleMode(mode: SupportedMode, warn): SupportedMode { + 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; } private captureHueSliderColor(x: number): void { @@ -980,23 +1077,15 @@ export class ColorPicker this.internalColorSet(this.baseColorFieldColor.hue(hue), false); } - private getCanvasRegion(y: number): "color-field" | "slider" | "none" { + private captureOpacitySliderValue(x: number): void { const { dimensions: { - colorField: { height: colorFieldHeight }, - slider: { height: sliderHeight } + slider: { width } } } = this; + const alpha = opacityToAlpha((OPACITY_LIMITS.max / width) * x); - if (y <= colorFieldHeight) { - return "color-field"; - } - - if (y <= colorFieldHeight + sliderHeight) { - return "slider"; - } - - return "none"; + this.internalColorSet(this.baseColorFieldColor.alpha(alpha), false); } private internalColorSet( @@ -1022,19 +1111,31 @@ export class ColorPicker const hexMode = "hex"; if (format.includes(hexMode)) { - return normalizeHex(color.round()[hexMode]()); + const hasAlpha = format === CSSColorMode.HEXA; + return normalizeHex(hexify(color.round(), hasAlpha), hasAlpha); } if (format.includes("-css")) { - return color[format.replace("-css", "").replace("a", "")]().round().string(); + const value = color[format.replace("-css", "").replace("a", "")]().round().string(); + + // Color omits alpha values when alpha is 1 + const needToInjectAlpha = + (format.endsWith("a") || format.endsWith("a-css")) && color.alpha() === 1; + if (needToInjectAlpha) { + const model = value.slice(0, 3); + const values = value.slice(4, -1); + return `${model}a(${values}, ${color.alpha()})`; + } + + return value; } - const colorObject = color[format]().round().object(); + 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")) { - // normalize alpha prop - colorObject.a = colorObject.alpha; - delete colorObject.alpha; + return normalizeAlpha(colorObject); } return colorObject; @@ -1056,7 +1157,7 @@ export class ColorPicker } private deleteColor = (): void => { - const colorToDelete = this.color.hex(); + const colorToDelete = hexify(this.color, this.alphaChannel); const inStorage = this.savedColors.indexOf(colorToDelete) > -1; if (!inStorage) { @@ -1075,7 +1176,7 @@ export class ColorPicker }; private saveColor = (): void => { - const colorToSave = this.color.hex(); + const colorToSave = hexify(this.color, this.alphaChannel); const alreadySaved = this.savedColors.indexOf(colorToSave) > -1; if (alreadySaved) { @@ -1093,24 +1194,41 @@ export class ColorPicker } }; - private drawColorFieldAndSlider = throttle((): void => { - if (!this.fieldAndSliderRenderingContext) { - return; - } + private drawColorControls = throttle( + (type: "all" | "color-field" | "hue-slider" | "opacity-slider" = "all"): void => { + if ((type === "all" || type === "color-field") && this.colorFieldRenderingContext) { + this.drawColorField(); + } - this.drawColorField(); - this.drawHueSlider(); - }, throttleFor60FpsInMs); + if ((type === "all" || type === "hue-slider") && this.hueSliderRenderingContext) { + this.drawHueSlider(); + } + + if ( + this.alphaChannel && + (type === "all" || type === "opacity-slider") && + this.opacitySliderRenderingContext + ) { + this.drawOpacitySlider(); + } + }, + throttleFor60FpsInMs + ); private drawColorField(): void { - const context = this.fieldAndSliderRenderingContext; + const context = this.colorFieldRenderingContext; const { dimensions: { colorField: { height, width } } } = this; - context.fillStyle = this.baseColorFieldColor.hsv().saturationv(100).value(100).string(); + 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); @@ -1132,6 +1250,10 @@ export class ColorPicker canvas: HTMLCanvasElement, { height, width }: { height: number; width: number } ): void { + if (!canvas) { + return; + } + const devicePixelRatio = window.devicePixelRatio || 1; canvas.width = width * devicePixelRatio; @@ -1158,38 +1280,49 @@ export class ColorPicker ); }; - private initColorFieldAndSlider = (canvas: HTMLCanvasElement): void => { - this.fieldAndSliderRenderingContext = canvas.getContext("2d"); - this.updateCanvasSize(canvas); + private initColorField = (canvas: HTMLCanvasElement): void => { + this.colorFieldRenderingContext = canvas.getContext("2d"); + this.updateCanvasSize("color-field"); + this.drawColorControls(); }; - private updateCanvasSize(canvas: HTMLCanvasElement) { - if (!canvas) { - return; + private initHueSlider = (canvas: HTMLCanvasElement): void => { + this.hueSliderRenderingContext = canvas.getContext("2d"); + this.updateCanvasSize("hue-slider"); + this.drawHueSlider(); + }; + + private initOpacitySlider = (canvas: HTMLCanvasElement): void => { + this.opacitySliderRenderingContext = canvas.getContext("2d"); + this.updateCanvasSize("opacity-slider"); + this.drawOpacitySlider(); + }; + + private updateCanvasSize( + context: "all" | "color-field" | "hue-slider" | "opacity-slider" = "all" + ): void { + const { dimensions } = this; + + if (context === "all" || context === "color-field") { + this.setCanvasContextSize(this.colorFieldRenderingContext?.canvas, dimensions.colorField); } - this.setCanvasContextSize(canvas, { - width: this.dimensions.colorField.width, + const adjustedSliderDimensions = { + width: dimensions.slider.width, height: - this.dimensions.colorField.height + - this.dimensions.slider.height + - this.getSliderCapSpacing() * 2 - }); + dimensions.slider.height + (dimensions.thumb.radius - dimensions.slider.height / 2) * 2 + }; - this.drawColorFieldAndSlider(); - } + if (context === "all" || context === "hue-slider") { + this.setCanvasContextSize(this.hueSliderRenderingContext?.canvas, adjustedSliderDimensions); + } - private containsPoint( - testPointX: number, - testPointY: number, - boundsX: number, - boundsY: number, - boundsRadius: number - ): boolean { - return ( - Math.pow(testPointX - boundsX, 2) + Math.pow(testPointY - boundsY, 2) <= - Math.pow(boundsRadius, 2) - ); + if (context === "all" || context === "opacity-slider") { + this.setCanvasContextSize( + this.opacitySliderRenderingContext?.canvas, + adjustedSliderDimensions + ); + } } private drawActiveColorFieldColor(): void { @@ -1216,7 +1349,7 @@ export class ColorPicker this.colorFieldScopeTop = y; }); - this.drawThumb(this.fieldAndSliderRenderingContext, radius, x, y, hsvColor, this.hueThumbState); + this.drawThumb(this.colorFieldRenderingContext, radius, x, y, hsvColor); } private drawThumb( @@ -1224,24 +1357,24 @@ export class ColorPicker radius: number, x: number, y: number, - color: Color, - state: "idle" | "hover" | "drag" + color: Color ): void { const startAngle = 0; const endAngle = 2 * Math.PI; + const outlineWidth = 1; + radius = radius - outlineWidth; context.beginPath(); context.arc(x, y, radius, startAngle, endAngle); - context.shadowBlur = state === "hover" ? 32 : 16; - context.shadowColor = `rgba(0, 0, 0, ${state === "drag" ? 0.32 : 0.16})`; context.fillStyle = "#fff"; context.fill(); + context.strokeStyle = "rgba(0,0,0,0.3)"; + context.lineWidth = outlineWidth; + context.stroke(); context.beginPath(); context.arc(x, y, radius - 3, startAngle, endAngle); - context.shadowBlur = 0; - context.shadowColor = "transparent"; - context.fillStyle = color.rgb().string(); + context.fillStyle = color.rgb().alpha(1).string(); context.fill(); } @@ -1256,39 +1389,33 @@ export class ColorPicker const { dimensions: { - colorField: { height: colorFieldHeight }, slider: { height, width }, thumb: { radius } } } = this; const x = hsvColor.hue() / (360 / width); - const y = height / 2 + colorFieldHeight; + const y = radius - height / 2 + height / 2; requestAnimationFrame(() => { this.hueScopeLeft = x; - this.hueScopeTop = y; }); - this.drawThumb( - this.fieldAndSliderRenderingContext, - radius, - x, - y, - hsvColor, - this.sliderThumbState - ); + this.drawThumb(this.hueSliderRenderingContext, radius, x, y, hsvColor); } private drawHueSlider(): void { - const context = this.fieldAndSliderRenderingContext; + const context = this.hueSliderRenderingContext; const { dimensions: { - colorField: { height: colorFieldHeight }, - slider: { height, width } + slider: { height, width }, + thumb: { radius: thumbRadius } } } = this; + const x = 0; + const y = thumbRadius - height / 2; + const gradient = context.createLinearGradient(0, 0, width, 0); const hueSliderColorStopKeywords = ["red", "yellow", "lime", "cyan", "blue", "magenta", "red"]; @@ -1301,26 +1428,171 @@ export class ColorPicker currentOffset += offset; }); + context.clearRect(0, 0, width, height + this.getSliderCapSpacing() * 2); + + this.drawSliderPath(context, height, width, x, y); + context.fillStyle = gradient; - context.clearRect(0, colorFieldHeight, width, height + this.getSliderCapSpacing() * 2); - context.fillRect(0, colorFieldHeight, width, height); + context.fill(); + + context.strokeStyle = "rgba(0,0,0,0.3)"; + context.lineWidth = 1; + context.stroke(); this.drawActiveHueSliderColor(); } + private drawOpacitySlider(): void { + const context = this.opacitySliderRenderingContext; + const { + baseColorFieldColor: previousColor, + dimensions: { + slider: { height, width }, + thumb: { radius: thumbRadius } + } + } = this; + + const x = 0; + const y = thumbRadius - height / 2; + + context.clearRect(0, 0, width, height + this.getSliderCapSpacing() * 2); + + const gradient = context.createLinearGradient(0, y, width, 0); + const startColor = previousColor.rgb().alpha(0); + const midColor = previousColor.rgb().alpha(0.5); + const endColor = previousColor.rgb().alpha(1); + + gradient.addColorStop(0, startColor.string()); + gradient.addColorStop(0.5, midColor.string()); + gradient.addColorStop(1, endColor.string()); + + this.drawSliderPath(context, height, width, x, y); + + const pattern = context.createPattern(this.getCheckeredBackgroundPattern(), "repeat"); + context.fillStyle = pattern; + context.fill(); + + context.fillStyle = gradient; + context.fill(); + + context.strokeStyle = "rgba(0,0,0,0.3)"; + context.lineWidth = 1; + context.stroke(); + + this.drawActiveOpacitySliderColor(); + } + + private drawSliderPath( + context: CanvasRenderingContext2D, + height: number, + width: number, + x: number, + y: number + ): void { + 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(); + } + + private getCheckeredBackgroundPattern(): HTMLCanvasElement { + 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; + } + + private drawActiveOpacitySliderColor(): void { + const { color } = this; + + if (!color) { + return; + } + + const hsvColor = color; + + const { + dimensions: { + slider: { width }, + thumb: { radius } + } + } = this; + + const x = alphaToOpacity(hsvColor.alpha()) / (OPACITY_LIMITS.max / width); + const y = radius; + + requestAnimationFrame(() => { + this.opacityScopeLeft = x; + }); + + this.drawThumb(this.opacitySliderRenderingContext, radius, x, y, hsvColor); + } + + private storeOpacityScope = (node: HTMLDivElement): void => { + this.opacityScopeNode = node; + }; + + private handleOpacityScopeKeyDown = (event: KeyboardEvent): void => { + const modifier = event.shiftKey ? 10 : 1; + const { key } = event; + const arrowKeyToXOffset = { + ArrowUp: 1, + ArrowRight: 1, + ArrowDown: -1, + ArrowLeft: -1 + }; + + if (arrowKeyToXOffset[key]) { + event.preventDefault(); + const delta = opacityToAlpha(arrowKeyToXOffset[key] * modifier); + this.captureHueSliderColor(this.opacityScopeLeft + delta); + } + }; + private updateColorFromChannels(channels: this["channels"]): void { this.internalColorSet(Color(channels, this.channelMode)); } private updateChannelsFromColor(color: Color | null): void { - this.channels = color ? this.toChannels(color) : [null, null, null]; + this.channels = color ? this.toChannels(color) : [null, null, null, null]; } - private toChannels(color: Color): [number, number, number] { + private toChannels(color: Color): Channels { const { channelMode } = this; - return color[channelMode]() + const channels = color[channelMode]() .array() - .map((value) => Math.floor(value)) as [number, number, number]; + .map((value, index) => { + const isAlpha = index === 3; + return isAlpha ? value : Math.floor(value); + }); + + if (channels.length === 3) { + channels.push(1); // Color omits alpha when 1 + } + + return channels as Channels; } } diff --git a/src/components/color-picker/interfaces.ts b/src/components/color-picker/interfaces.ts index cc9a6f3f48d..e0ec1e689c1 100644 --- a/src/components/color-picker/interfaces.ts +++ b/src/components/color-picker/interfaces.ts @@ -2,6 +2,8 @@ import type Color from "color"; export type ColorMode = "rgb" | "hsv"; +export type Channels = [number, number, number, number]; + // need to do this otherwise, stencil build doesn't pick up the type import export type InternalColor = Color; diff --git a/src/components/color-picker/resources.ts b/src/components/color-picker/resources.ts index 3185e1025fb..28b2418ca00 100644 --- a/src/components/color-picker/resources.ts +++ b/src/components/color-picker/resources.ts @@ -1,30 +1,36 @@ import Color from "color"; export const CSS = { + channel: "channel", + channels: "channels", + colorField: "color-field", + colorFieldScope: "scope--color-field", + colorMode: "color-mode", + colorModeContainer: "color-mode-container", container: "container", + control: "control", + controlAndScope: "control-and-scope", controlSection: "control-section", - hexOptions: "color-hex-options", - section: "section", + deleteColor: "delete-color", header: "header", - control: "control", - splitSection: "section--split", - colorModeContainer: "color-mode-container", - colorMode: "color-mode", - channels: "channels", - channel: "channel", - savedColors: "saved-colors", - savedColorsSection: "saved-colors-section", + hexAndChannelsGroup: "hex-and-channels-group", + hexOptions: "color-hex-options", + hueScope: "scope--hue", + hueSlider: "hue-slider", + opacityScope: "scope--opacity", + opacitySlider: "opacity-slider", + preview: "preview", + previewAndSliders: "preview-and-sliders", saveColor: "save-color", - deleteColor: "delete-color", + savedColor: "saved-color", + savedColors: "saved-colors", savedColorsButtons: "saved-colors-buttons", - headerHex: "header--hex", - colorFieldAndSlider: "color-field-and-slider", - colorFieldAndSliderInteractive: "color-field-and-slider--interactive", - colorFieldAndSliderWrap: "color-field-and-slider-wrap", + savedColorsSection: "saved-colors-section", scope: "scope", - hueScope: "scope--hue", - colorFieldScope: "scope--color-field", - savedColor: "saved-color" + section: "section", + slider: "slider", + sliders: "sliders", + splitSection: "section--split" }; export const DEFAULT_COLOR = Color("#007AC2"); @@ -42,24 +48,29 @@ export const HSV_LIMITS = { v: 100 }; +export const OPACITY_LIMITS = { + min: 0, + max: 100 +}; + export const DIMENSIONS = { s: { slider: { - height: 10, - width: 160 + height: 12, + width: 104 }, colorField: { height: 80, width: 160 }, thumb: { - radius: 8 + radius: 10 } }, m: { slider: { - height: 14, - width: 272 + height: 12, + width: 204 }, colorField: { height: 150, @@ -71,15 +82,15 @@ export const DIMENSIONS = { }, l: { slider: { - height: 16, - width: 464 + height: 12, + width: 384 }, colorField: { height: 200, width: 464 }, thumb: { - radius: 12 + radius: 10 } } }; diff --git a/src/components/color-picker/utils.spec.ts b/src/components/color-picker/utils.spec.ts index 890a4c02d33..ed81e9484b7 100644 --- a/src/components/color-picker/utils.spec.ts +++ b/src/components/color-picker/utils.spec.ts @@ -42,23 +42,47 @@ describe("utils", () => { expect(hexToRGB("#00ff00")).toMatchObject({ r: 0, g: 255, b: 0 }); expect(hexToRGB("0f0")).toBeNull(); expect(hexToRGB("00ff00")).toBeNull(); + + expect(hexToRGB("#0f0f", true)).toMatchObject({ r: 0, g: 255, b: 0, a: 1 }); + expect(hexToRGB("#00ff00ff", true)).toMatchObject({ r: 0, g: 255, b: 0, a: 1 }); }); - it("can convert RGB to hex", () => + it("can convert RGB to hex", () => { expect( rgbToHex({ r: 0, g: 255, b: 0 }) - ).toBe("#00ff00")); + ).toBe("#00ff00"); + + expect( + rgbToHex({ + r: 0, + g: 255, + b: 0, + a: 1 + }) + ).toBe("#00ff00ff"); + }); it("can determine shorthand hex", () => { expect(isShorthandHex("#0f0")).toBe(true); - - expect(isShorthandHex("#0f00")).toBe(false); + expect(isShorthandHex("")).toBe(false); + expect(isShorthandHex("#")).toBe(false); + expect(isShorthandHex("#0")).toBe(false); expect(isShorthandHex("#0f")).toBe(false); + expect(isShorthandHex("#0f00")).toBe(false); expect(isShorthandHex("#00ff00")).toBe(false); + + expect(isShorthandHex("#0f0f", true)).toBe(true); + expect(isShorthandHex("", true)).toBe(false); + expect(isShorthandHex("#", true)).toBe(false); + expect(isShorthandHex("#0", true)).toBe(false); + expect(isShorthandHex("#0f", true)).toBe(false); + expect(isShorthandHex("#0f0", true)).toBe(false); + expect(isShorthandHex("#0f0f0", true)).toBe(false); + expect(isShorthandHex("#00ff00", true)).toBe(false); }); it("can normalize hex", () => { @@ -66,25 +90,63 @@ describe("utils", () => { expect(normalizeHex("f00")).toBe("#ff0000"); expect(normalizeHex("#ff0000")).toBe("#ff0000"); expect(normalizeHex("ff0000")).toBe("#ff0000"); + + expect(normalizeHex("#f00f", true)).toBe("#ff0000ff"); + expect(normalizeHex("f00f", true)).toBe("#ff0000ff"); + expect(normalizeHex("#ff0000ff", true)).toBe("#ff0000ff"); + expect(normalizeHex("ff0000ff", true)).toBe("#ff0000ff"); + + expect(normalizeHex("#f00", true, true)).toBe("#ff0000ff"); + expect(normalizeHex("f00", true, true)).toBe("#ff0000ff"); + expect(normalizeHex("#ff0000", true, true)).toBe("#ff0000ff"); + expect(normalizeHex("ff0000", true, true)).toBe("#ff0000ff"); }); it("can validate hex", () => { expect(isValidHex("#ff0")).toBe(true); expect(isValidHex("#ffff00")).toBe(true); - + expect(isValidHex("")).toBe(false); + expect(isValidHex("#")).toBe(false); + expect(isValidHex("#f")).toBe(false); expect(isValidHex("#f0")).toBe(false); expect(isValidHex("#ff00")).toBe(false); expect(isValidHex("#ffff0")).toBe(false); expect(isValidHex("#ffff000")).toBe(false); expect(isValidHex("ff0")).toBe(false); expect(isValidHex("ffff00")).toBe(false); + + expect(isValidHex("#ff00", true)).toBe(true); + expect(isValidHex("#ffff0000", true)).toBe(true); + expect(isValidHex("")).toBe(false); + expect(isValidHex("#")).toBe(false); + expect(isValidHex("#f")).toBe(false); + expect(isValidHex("#f0", true)).toBe(false); + expect(isValidHex("#ff0", true)).toBe(false); + expect(isValidHex("#ffff0", true)).toBe(false); + expect(isValidHex("#ffff000", true)).toBe(false); + expect(isValidHex("ff00", true)).toBe(false); + expect(isValidHex("ffff0000", true)).toBe(false); }); it("can determine longhand hex", () => { expect(isLonghandHex("#00ff00")).toBe(true); - - expect(isLonghandHex("#00ff000")).toBe(false); - expect(isLonghandHex("#00ff0")).toBe(false); + expect(isLonghandHex("")).toBe(false); + expect(isLonghandHex("#")).toBe(false); + expect(isLonghandHex("#0f")).toBe(false); expect(isLonghandHex("#0f0")).toBe(false); + expect(isLonghandHex("#00ff")).toBe(false); + expect(isLonghandHex("#00ff0")).toBe(false); + expect(isLonghandHex("#00ff000")).toBe(false); + + expect(isLonghandHex("#00ff00ff", true)).toBe(true); + expect(isLonghandHex("", true)).toBe(false); + expect(isLonghandHex("#", true)).toBe(false); + expect(isLonghandHex("#0", true)).toBe(false); + expect(isLonghandHex("#0f", true)).toBe(false); + expect(isLonghandHex("#0f0", true)).toBe(false); + expect(isLonghandHex("#0f0f", true)).toBe(false); + expect(isLonghandHex("#00ff00", true)).toBe(false); + expect(isLonghandHex("#00ff00f", true)).toBe(false); + expect(isLonghandHex("#00ff00ff0", true)).toBe(false); }); }); diff --git a/src/components/color-picker/utils.ts b/src/components/color-picker/utils.ts index 8c664f2358d..36992aaf31e 100644 --- a/src/components/color-picker/utils.ts +++ b/src/components/color-picker/utils.ts @@ -1,66 +1,125 @@ -import { ColorValue, RGB } from "./interfaces"; +import { ColorValue, HSLA, HSVA, RGB, RGBA } from "./interfaces"; import Color from "color"; -export function rgbToHex(color: RGB): string { - const { r, g, b } = color; +export const hexChar = /^[0-9A-F]$/i; +const shorthandHex = /^#[0-9A-F]{3}$/i; +const longhandHex = /^#[0-9A-F]{6}$/i; +const shorthandHexWithAlpha = /^#[0-9A-F]{4}$/i; +const longhandHexWithAlpha = /^#[0-9A-F]{8}$/i; + +export const alphaToOpacity = (alpha: number): number => Number((alpha * 100).toFixed()); + +export const opacityToAlpha = (opacity: number): number => Number((opacity / 100).toFixed(2)); - return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b - .toString(16) - .padStart(2, "0")}`.toLowerCase(); +export function isValidHex(hex: string, hasAlpha = false): boolean { + return isShorthandHex(hex, hasAlpha) || isLonghandHex(hex, hasAlpha); } -export const hexChar = /^[0-9A-F]$/i; -const shortHandHex = /^#[0-9A-F]{3}$/i; -const longhandHex = /^#[0-9A-F]{6}$/i; +export function canConvertToHexa(hex: string): boolean { + const validHex = isValidHex(hex, false); + const validHexa = isValidHex(hex, true); + return !validHexa && validHex; +} + +function evaluateHex(hex: string, length: number, pattern: RegExp): boolean { + if (!hex) { + return false; + } -export function isValidHex(hex: string): boolean { - return isShorthandHex(hex) || isLonghandHex(hex); + return hex.length === length && pattern.test(hex); } -export function isShorthandHex(hex: string): boolean { - return hex && hex.length === 4 && shortHandHex.test(hex); +export function isShorthandHex(hex: string, hasAlpha = false): boolean { + const hexLength = hasAlpha ? 5 : 4; + const hexPattern = hasAlpha ? shorthandHexWithAlpha : shorthandHex; + + return evaluateHex(hex, hexLength, hexPattern); } -export function isLonghandHex(hex: string): boolean { - return hex && hex.length === 7 && longhandHex.test(hex); +export function isLonghandHex(hex: string, hasAlpha = false): boolean { + const hexLength = hasAlpha ? 9 : 7; + const hexPattern = hasAlpha ? longhandHexWithAlpha : longhandHex; + + return evaluateHex(hex, hexLength, hexPattern); } -export function normalizeHex(hex: string): string { +export function normalizeHex(hex: string, hasAlpha = false, convertFromHexToHexa = false): string { hex = hex.toLowerCase(); if (!hex.startsWith("#")) { hex = `#${hex}`; } - if (isShorthandHex(hex)) { - return rgbToHex(hexToRGB(hex)); + if (isShorthandHex(hex, hasAlpha)) { + return rgbToHex(hexToRGB(hex, hasAlpha)); + } + + if (hasAlpha && convertFromHexToHexa && isValidHex(hex, false /* we only care about RGB hex for conversion */)) { + const isShorthand = isShorthandHex(hex, false); + return rgbToHex(hexToRGB(`${hex}${isShorthand ? "f" : "ff"}`, true)); } return hex; } -export function hexToRGB(hex: string): RGB { - if (!isValidHex(hex)) { - return null; - } +export function hexify(color: Color, hasAlpha = false): string { + return hasAlpha ? color.hexa() : color.hex(); +} - hex = hex.replace("#", ""); +export function rgbToHex(color: RGB | RGBA): string { + const { r, g, b } = color; - if (hex.length === 3) { - const [first, second, third] = hex.split(""); + const rChars = numToHex(r); + const gChars = numToHex(g); + const bChars = numToHex(b); + const alphaChars = "a" in color ? numToHex(color.a * 255) : ""; - const r = parseInt(`${first}${first}`, 16); - const g = parseInt(`${second}${second}`, 16); - const b = parseInt(`${third}${third}`, 16); + return `#${rChars}${gChars}${bChars}${alphaChars}`.toLowerCase(); +} + +function numToHex(num: number): string { + return num.toString(16).padStart(2, "0"); +} + +export function normalizeAlpha(colorObject: ReturnType): T { + const normalized = { ...colorObject, a: colorObject.alpha ?? 1 /* Color() will omit alpha if 1 */ }; + delete normalized.alpha; + + return normalized as T; +} + +export function normalizeColor(alphaColorObject: RGBA | HSVA | HSLA): ReturnType { + const normalized = { ...alphaColorObject, alpha: alphaColorObject.a ?? 1 }; + delete normalized.a; - return { r, g, b }; + return normalized; +} + +export function hexToRGB(hex: string, hasAlpha = false): RGB | RGBA { + if (!isValidHex(hex, hasAlpha)) { + return null; } - const r = parseInt(hex.slice(0, 2), 16); - const g = parseInt(hex.slice(2, 4), 16); - const b = parseInt(hex.slice(4, 6), 16); + hex = hex.replace("#", ""); + + let r: number, g: number, b: number, a: number; + const isShorthand = hex.length === 3 || hex.length === 4; + + if (isShorthand) { + const [first, second, third, fourth] = hex.split(""); + + r = parseInt(`${first}${first}`, 16); + g = parseInt(`${second}${second}`, 16); + b = parseInt(`${third}${third}`, 16); + a = parseInt(`${fourth}${fourth}`, 16) / 255; + } else { + r = parseInt(hex.slice(0, 2), 16); + g = parseInt(hex.slice(2, 4), 16); + b = parseInt(hex.slice(4, 6), 16); + a = parseInt(hex.slice(6, 8), 16) / 255; + } - return { r, g, b }; + return isNaN(a) ? { r, g, b } : { r, g, b, a }; } // these utils allow users to pass enum values as strings without having to access the enum @@ -144,5 +203,54 @@ function hasChannels(colorObject: Exclude | null, ...channel } export function colorEqual(value1: Color | null, value2: Color | null): boolean { - return value1?.rgbNumber() === value2?.rgbNumber(); + return value1?.rgb().array().toString() === value2?.rgb().array().toString(); +} + +export function alphaCompatible(mode: SupportedMode): boolean { + return ( + mode === CSSColorMode.HEXA || + mode === CSSColorMode.RGBA_CSS || + mode === CSSColorMode.HSLA_CSS || + mode === ObjectColorMode.RGBA || + mode === ObjectColorMode.HSLA || + mode === ObjectColorMode.HSVA + ); +} + +export function toAlphaMode(mode: SupportedMode): SupportedMode { + const alphaMode = + mode === CSSColorMode.HEX + ? CSSColorMode.HEXA + : mode === CSSColorMode.RGB_CSS + ? CSSColorMode.RGBA_CSS + : mode === CSSColorMode.HSL_CSS + ? CSSColorMode.HSLA_CSS + : mode === ObjectColorMode.RGB + ? ObjectColorMode.RGBA + : mode === ObjectColorMode.HSL + ? ObjectColorMode.HSLA + : mode === ObjectColorMode.HSV + ? ObjectColorMode.HSVA + : mode; + + return alphaMode; +} + +export function toNonAlphaMode(mode: SupportedMode): SupportedMode { + const nonAlphaMode = + mode === CSSColorMode.HEXA + ? CSSColorMode.HEX + : mode === CSSColorMode.RGBA_CSS + ? CSSColorMode.RGB_CSS + : mode === CSSColorMode.HSLA_CSS + ? CSSColorMode.HSL_CSS + : mode === ObjectColorMode.RGBA + ? ObjectColorMode.RGB + : mode === ObjectColorMode.HSLA + ? ObjectColorMode.HSL + : mode === ObjectColorMode.HSVA + ? ObjectColorMode.HSV + : mode; + + return nonAlphaMode; } diff --git a/src/demos/color-picker.html b/src/demos/color-picker.html index 25166fe44a5..2947819a7d8 100644 --- a/src/demos/color-picker.html +++ b/src/demos/color-picker.html @@ -35,7 +35,7 @@ -

Alert

+

Color picker

@@ -77,6 +77,22 @@

Alert

+ +
+
With opacity
+
+ +
+ +
+ +
+ +
+ +
+
+
Hidden sections (all)