diff --git a/packages/calcite-components/index.html b/packages/calcite-components/index.html index 735936934ce..25dea80c327 100644 --- a/packages/calcite-components/index.html +++ b/packages/calcite-components/index.html @@ -449,7 +449,12 @@

Calcite demo

- +
+ + + + +
diff --git a/packages/calcite-components/src/components/swatch-group/readme.md b/packages/calcite-components/src/components/swatch-group/readme.md new file mode 100644 index 00000000000..7afec0f74f7 --- /dev/null +++ b/packages/calcite-components/src/components/swatch-group/readme.md @@ -0,0 +1,5 @@ +# calcite-swatch-group + +For comprehensive guidance on using and implementing `calcite-swatch-group`, refer to the [documentation page](https://developers.arcgis.com/calcite-design-system/components/swatch-group/). + + diff --git a/packages/calcite-components/src/components/swatch-group/resources.ts b/packages/calcite-components/src/components/swatch-group/resources.ts new file mode 100644 index 00000000000..8ce4e2bdc7b --- /dev/null +++ b/packages/calcite-components/src/components/swatch-group/resources.ts @@ -0,0 +1,3 @@ +export const CSS = { + container: "container", +}; diff --git a/packages/calcite-components/src/components/swatch-group/swatch-group.e2e.ts b/packages/calcite-components/src/components/swatch-group/swatch-group.e2e.ts new file mode 100644 index 00000000000..d8f0dbed0ed --- /dev/null +++ b/packages/calcite-components/src/components/swatch-group/swatch-group.e2e.ts @@ -0,0 +1,694 @@ +import { newE2EPage } from "@arcgis/lumina-compiler/puppeteerTesting"; +import { describe, expect, it } from "vitest"; +import { html } from "../../../support/formatting"; +import { accessible, renders, hidden, disabled } from "../../tests/commonTests"; +import { createSelectedItemsAsserter } from "../../tests/utils/puppeteer"; + +describe("calcite-swatch-group", () => { + describe("renders", () => { + renders("", { + display: "flex", + }); + }); + + describe("honors hidden attribute", () => { + hidden("calcite-swatch-group"); + }); + + describe("disabled", () => { + disabled("", { + focusTarget: "child", + }); + }); + + describe("is accessible in selection mode none (default)", () => { + accessible( + html` + + + `, + ); + }); + + describe("is accessible in selection mode single", () => { + accessible( + html` + + + `, + ); + }); + + describe("is selection mode single persists", () => { + accessible( + html` + + + `, + ); + }); + + describe("is accessible in selection mode multiple", () => { + accessible( + html` + + + `, + ); + }); + + describe("selection modes function as intended", () => { + it("selection mode single allows one or no swatches to be selected", async () => { + const page = await newE2EPage(); + await page.setContent( + html` + + + + `, + ); + await page.waitForChanges(); + + const element = await page.find("calcite-swatch-group"); + const swatch1 = await page.find("#swatch-1"); + const swatch2 = await page.find("#swatch-2"); + + const swatchGroupSelectSpy = await element.spyOnEvent("calciteSwatchGroupSelect"); + const selectedItemAsserter = await createSelectedItemsAsserter( + page, + "calcite-swatch-group", + "calciteSwatchGroupSelect", + ); + + const swatchSelectSpy1 = await swatch1.spyOnEvent("calciteSwatchSelect"); + const swatchSelectSpy2 = await swatch2.spyOnEvent("calciteSwatchSelect"); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch2.id]); + + await swatch1.click(); + await page.waitForChanges(); + + expect(await swatchGroupSelectSpy).toHaveReceivedEventTimes(1); + expect(await swatchSelectSpy1).toHaveReceivedEventTimes(1); + expect(await swatchSelectSpy2).toHaveReceivedEventTimes(0); + expect(await swatch1.getProperty("selected")).toBe(true); + expect(await swatch2.getProperty("selected")).toBe(false); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch1.id]); + + await swatch2.click(); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(2); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(1); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(1); + expect(await swatch1.getProperty("selected")).toBe(false); + expect(await swatch2.getProperty("selected")).toBe(true); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch2.id]); + + await swatch2.click(); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(3); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(1); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(2); + expect(await swatch1.getProperty("selected")).toBe(false); + expect(await swatch2.getProperty("selected")).toBe(false); + expect(await element.getProperty("selectedItems")).toEqual([]); + await selectedItemAsserter([]); + }); + + it("selection mode none (default) allows no swatch to be selected", async () => { + const page = await newE2EPage(); + await page.setContent( + html` + + + `, + ); + await page.waitForChanges(); + + const element = await page.find("calcite-swatch-group"); + const swatch1 = await page.find("#swatch-1"); + const swatch2 = await page.find("#swatch-2"); + const swatchGroupSelectSpy = await element.spyOnEvent("calciteSwatchGroupSelect"); + const selectedItemAsserter = await createSelectedItemsAsserter( + page, + "calcite-swatch-group", + "calciteSwatchGroupSelect", + ); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toEqual([]); + await selectedItemAsserter([]); + + await swatch1.click(); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(1); + expect(await swatch1.getProperty("selected")).toBe(false); + expect(await swatch2.getProperty("selected")).toBe(false); + expect(await element.getProperty("selectedItems")).toEqual([]); + await selectedItemAsserter([]); + + await swatch2.click(); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(2); + expect(await swatch1.getProperty("selected")).toBe(false); + expect(await swatch2.getProperty("selected")).toBe(false); + expect(await element.getProperty("selectedItems")).toEqual([]); + await selectedItemAsserter([]); + + await swatch2.click(); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(3); + expect(await swatch1.getProperty("selected")).toBe(false); + expect(await swatch2.getProperty("selected")).toBe(false); + expect(await element.getProperty("selectedItems")).toEqual([]); + await selectedItemAsserter([]); + }); + + it("selection mode single-persist allows one swatch to be selected", async () => { + const page = await newE2EPage(); + await page.setContent( + html` + + + `, + ); + await page.waitForChanges(); + + const element = await page.find("calcite-swatch-group"); + const swatch1 = await page.find("#swatch-1"); + const swatch2 = await page.find("#swatch-2"); + const swatchGroupSelectSpy = await element.spyOnEvent("calciteSwatchGroupSelect"); + const selectedItemAsserter = await createSelectedItemsAsserter( + page, + "calcite-swatch-group", + "calciteSwatchGroupSelect", + ); + + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch1.id]); + + await swatch1.click(); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(1); + expect(await swatch1.getProperty("selected")).toBe(true); + expect(await swatch2.getProperty("selected")).toBe(false); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch1.id]); + + await swatch2.click(); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(2); + expect(await swatch1.getProperty("selected")).toBe(false); + expect(await swatch2.getProperty("selected")).toBe(true); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch2.id]); + + await swatch2.click(); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(3); + expect(await swatch1.getProperty("selected")).toBe(false); + expect(await swatch2.getProperty("selected")).toBe(true); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch2.id]); + }); + + it("selection mode multiple allows none, one, or multiple to be selected", async () => { + const page = await newE2EPage(); + await page.setContent( + html` + + + + `, + ); + await page.waitForChanges(); + + const element = await page.find("calcite-swatch-group"); + const swatch1 = await page.find("#swatch-1"); + const swatch2 = await page.find("#swatch-2"); + const swatch3 = await page.find("#swatch-3"); + + const swatchGroupSelectSpy = await element.spyOnEvent("calciteSwatchGroupSelect"); + const selectedItemAsserter = await createSelectedItemsAsserter( + page, + "calcite-swatch-group", + "calciteSwatchGroupSelect", + ); + + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toEqual([]); + await selectedItemAsserter([]); + + await swatch1.click(); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(1); + expect(await swatch1.getProperty("selected")).toBe(true); + expect(await swatch2.getProperty("selected")).toBe(false); + expect(await swatch3.getProperty("selected")).toBe(false); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch1.id]); + + await swatch2.click(); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(2); + expect(await swatch1.getProperty("selected")).toBe(true); + expect(await swatch2.getProperty("selected")).toBe(true); + expect(await swatch3.getProperty("selected")).toBe(false); + expect(await element.getProperty("selectedItems")).toHaveLength(2); + await selectedItemAsserter([swatch1.id, swatch2.id]); + + await swatch3.click(); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(3); + expect(await swatch1.getProperty("selected")).toBe(true); + expect(await swatch2.getProperty("selected")).toBe(true); + expect(await swatch3.getProperty("selected")).toBe(true); + expect(await element.getProperty("selectedItems")).toHaveLength(3); + await selectedItemAsserter([swatch1.id, swatch2.id, swatch3.id]); + + await swatch1.click(); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(4); + expect(await swatch1.getProperty("selected")).toBe(false); + expect(await swatch2.getProperty("selected")).toBe(true); + expect(await swatch3.getProperty("selected")).toBe(true); + expect(await element.getProperty("selectedItems")).toHaveLength(2); + await selectedItemAsserter([swatch2.id, swatch3.id]); + + await swatch2.click(); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(5); + expect(await swatch1.getProperty("selected")).toBe(false); + expect(await swatch2.getProperty("selected")).toBe(false); + expect(await swatch3.getProperty("selected")).toBe(true); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch3.id]); + + await swatch3.click(); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(6); + expect(await swatch1.getProperty("selected")).toBe(false); + expect(await swatch2.getProperty("selected")).toBe(false); + expect(await swatch3.getProperty("selected")).toBe(false); + expect(await element.getProperty("selectedItems")).toEqual([]); + await selectedItemAsserter([]); + }); + }); + + describe("focus and interaction function as intended", () => { + it("navigation with keyboard works as expected", async () => { + const page = await newE2EPage(); + await page.setContent( + html` + + + + + + `, + ); + + const element = await page.find("calcite-swatch-group"); + const swatchGroupSelectSpy = await element.spyOnEvent("calciteSwatchGroupSelect"); + const selectedItemAsserter = await createSelectedItemsAsserter( + page, + "calcite-swatch-group", + "calciteSwatchGroupSelect", + ); + + const swatch1 = await page.find("#swatch-1"); + const swatch2 = await page.find("#swatch-2"); + const swatch3 = await page.find("#swatch-3"); + const swatch4 = await page.find("#swatch-4"); + const swatch5 = await page.find("#swatch-5"); + + await swatch1.click(); + await page.waitForChanges(); + expect(await page.evaluate(() => document.activeElement.id)).toEqual(swatch1.id); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch1.id]); + + await page.keyboard.press("ArrowRight"); + await page.waitForChanges(); + expect(await page.evaluate(() => document.activeElement.id)).toEqual(swatch2.id); + + await page.keyboard.press("ArrowRight"); + await page.waitForChanges(); + expect(await page.evaluate(() => document.activeElement.id)).toEqual(swatch3.id); + + await page.keyboard.press("End"); + await page.waitForChanges(); + expect(await page.evaluate(() => document.activeElement.id)).toEqual(swatch5.id); + + await page.keyboard.press("Space"); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(2); + expect(await element.getProperty("selectedItems")).toHaveLength(2); + await selectedItemAsserter([swatch1.id, swatch5.id]); + + await page.keyboard.press("ArrowLeft"); + await page.waitForChanges(); + expect(await page.evaluate(() => document.activeElement.id)).toEqual(swatch4.id); + + await page.keyboard.press("Enter"); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(3); + expect(await element.getProperty("selectedItems")).toHaveLength(3); + await selectedItemAsserter([swatch1.id, swatch4.id, swatch5.id]); + + await page.keyboard.press("Space"); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(4); + expect(await element.getProperty("selectedItems")).toHaveLength(2); + await selectedItemAsserter([swatch1.id, swatch5.id]); + + await page.keyboard.press("Home"); + await page.waitForChanges(); + expect(await page.evaluate(() => document.activeElement.id)).toEqual(swatch1.id); + + await page.keyboard.press("ArrowLeft"); + await page.waitForChanges(); + expect(await page.evaluate(() => document.activeElement.id)).toEqual(swatch5.id); + + await page.keyboard.press("ArrowRight"); + await page.waitForChanges(); + expect(await page.evaluate(() => document.activeElement.id)).toEqual(swatch1.id); + }); + + it("selectedItems property is correctly populated at load when property is set on swatches in DOM", async () => { + const page = await newE2EPage(); + await page.setContent( + html` + + + + + + `, + ); + const element = await page.find("calcite-swatch-group"); + const swatch4 = await page.find("#swatch-4"); + const swatch5 = await page.find("#swatch-5"); + const selectedItemAsserter = await createSelectedItemsAsserter( + page, + "calcite-swatch-group", + "calciteSwatchGroupSelect", + ); + await page.waitForChanges(); + + expect(await element.getProperty("selectedItems")).toHaveLength(2); + await selectedItemAsserter([swatch4.id, swatch5.id]); + }); + }); + + describe("programmatically selecting Swatches", () => { + it("programmatically setting selected on a swatch should update the component but not emit public events", async () => { + const page = await newE2EPage(); + await page.setContent( + html` + + + + + + `, + ); + const element = await page.find("calcite-swatch-group"); + const swatch4 = await page.find("#swatch-4"); + const swatch5 = await page.find("#swatch-5"); + const swatchGroupSelectSpy = await element.spyOnEvent("calciteSwatchGroupSelect"); + const selectedItemAsserter = await createSelectedItemsAsserter( + page, + "calcite-swatch-group", + "calciteSwatchGroupSelect", + ); + + const swatchSelectSpy1 = await swatch4.spyOnEvent("calciteSwatchSelect"); + const swatchSelectSpy2 = await swatch5.spyOnEvent("calciteSwatchSelect"); + await page.waitForChanges(); + + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch4.id]); + + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(0); + + await swatch5.toggleAttribute("selected", true); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch5.id]); + }); + + it("programmatically setting selected on a swatch in single-persist should update the component but not emit public events", async () => { + const page = await newE2EPage(); + await page.setContent( + html` + + + + + + `, + ); + const element = await page.find("calcite-swatch-group"); + const swatch4 = await page.find("#swatch-4"); + const swatch5 = await page.find("#swatch-5"); + const swatchGroupSelectSpy = await element.spyOnEvent("calciteSwatchGroupSelect"); + const selectedItemAsserter = await createSelectedItemsAsserter( + page, + "calcite-swatch-group", + "calciteSwatchGroupSelect", + ); + + const swatchSelectSpy1 = await swatch4.spyOnEvent("calciteSwatchSelect"); + const swatchSelectSpy2 = await swatch5.spyOnEvent("calciteSwatchSelect"); + await page.waitForChanges(); + + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch4.id]); + + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(0); + + swatch4.removeAttribute("selected"); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toHaveLength(0); + await selectedItemAsserter([]); + + swatch5.toggleAttribute("selected", true); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch5.id]); + + swatch4.toggleAttribute("selected", true); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch4.id]); + }); + it("programmatically setting selected on a swatch in multiple should update the component but not emit public events", async () => { + const page = await newE2EPage(); + await page.setContent( + html` + + + + + + `, + ); + const element = await page.find("calcite-swatch-group"); + const swatch4 = await page.find("#swatch-4"); + const swatch5 = await page.find("#swatch-5"); + const swatchGroupSelectSpy = await element.spyOnEvent("calciteSwatchGroupSelect"); + const selectedItemAsserter = await createSelectedItemsAsserter( + page, + "calcite-swatch-group", + "calciteSwatchGroupSelect", + ); + + const swatchSelectSpy1 = await swatch4.spyOnEvent("calciteSwatchSelect"); + const swatchSelectSpy2 = await swatch5.spyOnEvent("calciteSwatchSelect"); + await page.waitForChanges(); + + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch4.id]); + + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(0); + + swatch5.toggleAttribute("selected", true); + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toHaveLength(2); + await selectedItemAsserter([swatch4.id, swatch5.id]); + }); + }); + + describe("updating component after page load", () => { + it("should update selected items without emitting event if swatches are added after page load in multiple", async () => { + const page = await newE2EPage(); + await page.setContent( + ` + + + + + + `, + ); + const element = await page.find("calcite-swatch-group"); + const swatch4 = await page.find("#swatch-4"); + const swatch5 = await page.find("#swatch-5"); + const swatchGroupSelectSpy = await element.spyOnEvent("calciteSwatchGroupSelect"); + const selectedItemAsserter = await createSelectedItemsAsserter( + page, + "calcite-swatch-group", + "calciteSwatchGroupSelect", + ); + + const swatchSelectSpy1 = await swatch4.spyOnEvent("calciteSwatchSelect"); + const swatchSelectSpy2 = await swatch5.spyOnEvent("calciteSwatchSelect"); + await page.waitForChanges(); + + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toHaveLength(2); + await selectedItemAsserter([swatch4.id, swatch5.id]); + + await page.evaluate(() => { + const group = document.querySelector("calcite-swatch-group"); + const newSwatch = document.createElement("calcite-swatch"); + newSwatch.id = "swatch-6"; + newSwatch.selected = true; + group.appendChild(newSwatch); + }); + + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toHaveLength(3); + await selectedItemAsserter([swatch4.id, swatch5.id, "swatch-6"]); + }); + + it("should update selected items without emitting event if swatches are added after page load in single", async () => { + const page = await newE2EPage(); + await page.setContent( + ` + + + + + + `, + ); + const element = await page.find("calcite-swatch-group"); + const swatch4 = await page.find("#swatch-4"); + const swatchGroupSelectSpy = await element.spyOnEvent("calciteSwatchGroupSelect"); + const selectedItemAsserter = await createSelectedItemsAsserter( + page, + "calcite-swatch-group", + "calciteSwatchGroupSelect", + ); + + const swatchSelectSpy1 = await swatch4.spyOnEvent("calciteSwatchSelect"); + await page.waitForChanges(); + + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch4.id]); + + await page.evaluate(() => { + const group = document.querySelector("calcite-swatch-group"); + const newSwatch = document.createElement("calcite-swatch"); + newSwatch.id = "swatch-6"; + newSwatch.selected = true; + group.appendChild(newSwatch); + }); + + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter(["swatch-6"]); + }); + + it("should update selected items without emitting event if swatches are removed after page load", async () => { + const page = await newE2EPage(); + await page.setContent( + ` + + + + + + `, + ); + const element = await page.find("calcite-swatch-group"); + const swatch4 = await page.find("#swatch-4"); + const swatch5 = await page.find("#swatch-5"); + const swatchGroupSelectSpy = await element.spyOnEvent("calciteSwatchGroupSelect"); + const selectedItemAsserter = await createSelectedItemsAsserter( + page, + "calcite-swatch-group", + "calciteSwatchGroupSelect", + ); + + const swatchSelectSpy1 = await swatch4.spyOnEvent("calciteSwatchSelect"); + const swatchSelectSpy2 = await swatch5.spyOnEvent("calciteSwatchSelect"); + await page.waitForChanges(); + + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toHaveLength(2); + await selectedItemAsserter([swatch4.id, swatch5.id]); + + await page.evaluate(() => { + document.querySelector("calcite-swatch:last-child").remove(); + }); + + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toHaveLength(1); + await selectedItemAsserter([swatch4.id]); + + await page.evaluate(() => { + document.querySelector("calcite-swatch:last-child").remove(); + }); + + await page.waitForChanges(); + expect(swatchGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy1).toHaveReceivedEventTimes(0); + expect(swatchSelectSpy2).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toHaveLength(0); + await selectedItemAsserter([]); + }); + }); +}); diff --git a/packages/calcite-components/src/components/swatch-group/swatch-group.scss b/packages/calcite-components/src/components/swatch-group/swatch-group.scss new file mode 100644 index 00000000000..bbc442c5b12 --- /dev/null +++ b/packages/calcite-components/src/components/swatch-group/swatch-group.scss @@ -0,0 +1,23 @@ +/** + * CSS Custom Properties + * + * These properties can be overridden using the component's tag as selector. + * + * @prop --calcite-swatch-group-space: Specifies the space between slotted elements. + */ + +:host { + @apply flex; +} + +.container { + display: flex; + flex-wrap: wrap; + gap: var(--calcite-swatch-group-space, var(--calcite-spacing-sm)); +} + +:host([scale="s"]) .container { + gap: var(--calcite-swatch-group-space, var(--calcite-spacing-xs)); +} +@include base-component(); +@include disabled(); diff --git a/packages/calcite-components/src/components/swatch-group/swatch-group.stories.ts b/packages/calcite-components/src/components/swatch-group/swatch-group.stories.ts new file mode 100644 index 00000000000..383c0d01fb4 --- /dev/null +++ b/packages/calcite-components/src/components/swatch-group/swatch-group.stories.ts @@ -0,0 +1,109 @@ +import { modesDarkDefault } from "../../../.storybook/utils"; +import { html } from "../../../support/formatting"; +import { ATTRIBUTES } from "../../../.storybook/resources"; +import { placeholderImage } from "../../../.storybook/placeholder-image"; +import { SwatchGroup } from "./swatch-group"; + +const { selectionMode, scale } = ATTRIBUTES; + +type SwatchGroupStoryArgs = Pick; + +export default { + title: "Components/Swatch Group", + args: { selectionMode: selectionMode.defaultValue, scale: scale.defaultValue }, + argTypes: { + selectionMode: { + options: selectionMode.values.filter( + (option) => option !== "children" && option !== "multichildren" && option !== "ancestors", + ), + control: { type: "select" }, + }, + scale: { options: scale.values, control: { type: "select" } }, + }, +}; + +export const simple = (args: SwatchGroupStoryArgs): string => html` + + + + + + + + + + + + + + + + +`; + +export const darkThemeRTL_TestOnly = (): string => html` +
+ + + + + + + + + + + + + + + + + + +
+`; + +darkThemeRTL_TestOnly.parameters = { themes: modesDarkDefault }; diff --git a/packages/calcite-components/src/components/swatch-group/swatch-group.tsx b/packages/calcite-components/src/components/swatch-group/swatch-group.tsx new file mode 100644 index 00000000000..9f38f153359 --- /dev/null +++ b/packages/calcite-components/src/components/swatch-group/swatch-group.tsx @@ -0,0 +1,243 @@ +import { PropertyValues } from "lit"; +import { createRef } from "lit-html/directives/ref.js"; +import { LitElement, property, createEvent, h, method, JsxNode } from "@arcgis/lumina"; +import { focusElementInGroup, slotChangeGetAssignedElements } from "../../utils/dom"; +import { + InteractiveComponent, + InteractiveContainer, + updateHostInteraction, +} from "../../utils/interactive"; +import { Scale, SelectionMode } from "../interfaces"; +import { useSetFocus } from "../../controllers/useSetFocus"; +import type { Swatch } from "../swatch/swatch"; +import { CSS } from "./resources"; +import { styles } from "./swatch-group.scss"; + +declare global { + interface DeclareElements { + "calcite-swatch-group": SwatchGroup; + } +} +/** @slot - A slot for adding one or more `calcite-swatch`s. */ +export class SwatchGroup extends LitElement implements InteractiveComponent { + // #region Static Members + + static override styles = styles; + + // #endregion + + // #region Private Properties + + private items: Swatch["el"][] = []; + + private slotRefEl = createRef(); + + private focusSetter = useSetFocus()(this); + + // #endregion + + // #region Public Properties + + /** When `true`, interaction is prevented and the component is displayed with lower opacity. */ + @property({ reflect: true }) disabled = false; + + /** + * Accessible name for the component. + * + * @required + */ + @property() label: string; + + /** Specifies the size of the component. Child `calcite-swatch`s inherit the component's value. */ + @property({ reflect: true }) scale: Scale = "m"; + + /** + * Specifies the component's selected items. + * + * @readonly + */ + @property() selectedItems: Swatch["el"][] = []; + + /** + * Specifies the selection mode of the component, where: + * + * `"multiple"` allows any number of selections, + * + * `"single"` allows only one selection, + * + * `"single-persist"` allows one selection and prevents de-selection, and + * + * `"none"` does not allow any selections. + */ + @property({ reflect: true }) selectionMode: Extract< + "multiple" | "single" | "single-persist" | "none", + SelectionMode + > = "none"; + + // #endregion + + // #region Public Methods + + /** + * Sets focus on the component's first focusable element. + * + * @param options + */ + @method() + async setFocus(options?: FocusOptions): Promise { + return this.focusSetter(() => { + return this.el; + }, options); + } + + // #endregion + + // #region Events + + /** Fires when the component's selection changes. */ + calciteSwatchGroupSelect = createEvent({ cancelable: false }); + + // #endregion + + // #region Lifecycle + + constructor() { + super(); + this.listen("calciteInternalSwatchKeyEvent", this.calciteInternalSwatchKeyEventListener); + this.listen("calciteSwatchSelect", this.calciteSwatchSelectListener); + this.listen("calciteInternalSwatchSelect", this.calciteInternalSwatchSelectListener); + this.listen("calciteInternalSyncSelectedSwatches", this.calciteInternalSyncSelectedSwatches); + } + + override willUpdate(changes: PropertyValues): void { + if (changes.has("selectionMode") && (this.hasUpdated || this.selectionMode !== "none")) { + this.updateItems(); + } + } + + override updated(): void { + updateHostInteraction(this); + } + + // #endregion + + // #region Private Methods + private calciteInternalSwatchKeyEventListener(event: CustomEvent): void { + if (event.composedPath().includes(this.el)) { + const interactiveItems = this.items?.filter((el) => !el.disabled); + switch (event.detail.key) { + case "ArrowRight": + focusElementInGroup(interactiveItems, event.detail.target, "next"); + break; + case "ArrowLeft": + focusElementInGroup(interactiveItems, event.detail.target, "previous"); + break; + case "Home": + focusElementInGroup(interactiveItems, event.detail.target, "first"); + break; + case "End": + focusElementInGroup(interactiveItems, event.detail.target, "last"); + break; + } + } + event.stopPropagation(); + } + + private calciteSwatchSelectListener(event: CustomEvent): void { + if (event.composedPath().includes(this.el)) { + this.setSelectedItems(true, event.target as Swatch["el"]); + } + event.stopPropagation(); + } + + private calciteInternalSwatchSelectListener(event: CustomEvent): void { + if (event.composedPath().includes(this.el)) { + this.setSelectedItems(false, event.target as Swatch["el"]); + } + event.stopPropagation(); + } + + private calciteInternalSyncSelectedSwatches(event: CustomEvent): void { + if (event.composedPath().includes(this.el)) { + this.updateSelectedItems(); + if (this.selectionMode === "single" && this.selectedItems.length > 1) { + this.setSelectedItems(false, event.target as Swatch["el"]); + } + } + event.stopPropagation(); + } + + private updateItems(event?: Event): void { + const itemsFromSlot = this.slotRefEl.value + ?.assignedElements({ flatten: true }) + .filter((el): el is Swatch["el"] => el?.matches("calcite-swatch")); + + this.items = !event ? itemsFromSlot : slotChangeGetAssignedElements(event); + + if (this.items?.length < 1) { + return; + } + + this.items?.forEach((el) => { + el.interactive = true; + el.scale = this.scale; + el.selectionMode = this.selectionMode; + el.parentSwatchGroup = this.el; + }); + + this.setSelectedItems(false); + } + + private updateSelectedItems(): void { + this.selectedItems = this.items?.filter((el) => el.selected); + } + + private setSelectedItems(emit: boolean, elToMatch?: Swatch["el"]): void { + if (elToMatch) { + this.items?.forEach((el) => { + const matchingEl = elToMatch === el; + switch (this.selectionMode) { + case "multiple": + if (matchingEl) { + el.selected = !el.selected; + } + break; + + case "single": + el.selected = matchingEl ? !el.selected : false; + break; + + case "single-persist": + el.selected = !!matchingEl; + break; + } + }); + } + + this.updateSelectedItems(); + + if (emit) { + this.calciteSwatchGroupSelect.emit(); + } + } + + // #endregion + + // #region Rendering + + override render(): JsxNode { + const role = + this.selectionMode === "none" || this.selectionMode === "multiple" ? "group" : "radiogroup"; + const { disabled } = this; + + return ( + +
+ +
+
+ ); + } + + // #endregion +} diff --git a/packages/calcite-components/src/components/swatch/readme.md b/packages/calcite-components/src/components/swatch/readme.md new file mode 100644 index 00000000000..679617201cc --- /dev/null +++ b/packages/calcite-components/src/components/swatch/readme.md @@ -0,0 +1,5 @@ +# calcite-swatch + +For comprehensive guidance on using and implementing `calcite-swatch`, refer to the [documentation page](https://developers.arcgis.com/calcite-design-system/components/swatch/). + + diff --git a/packages/calcite-components/src/components/swatch/resources.ts b/packages/calcite-components/src/components/swatch/resources.ts new file mode 100644 index 00000000000..353b5ad177f --- /dev/null +++ b/packages/calcite-components/src/components/swatch/resources.ts @@ -0,0 +1,31 @@ +export const CSS = { + imageContainer: "image-container", + container: "container", + imageSlotted: "image--slotted", + selectable: "selectable", + nonInteractive: "non-interactive", + selected: "selected", + internalSvgContainer: "internal-svg-container", + internalSvgDisabled: "internal-svg-disabled", + internalSvgEmpty: "internal-svg-empty", + swatch: "swatch", + checker: "checker", + noColorSwatch: "swatch--no-color", +}; + +export const SLOTS = { + image: "image", +}; + +const checkerSquareSize = 4; + +export const CHECKER_DIMENSIONS = { + squareSize: checkerSquareSize, + size: checkerSquareSize * 2, +}; + +export const IDS = { + checker: "checker", + shape: "shape", + swatchRect: "swatch-rect", +}; diff --git a/packages/calcite-components/src/components/swatch/swatch.e2e.ts b/packages/calcite-components/src/components/swatch/swatch.e2e.ts new file mode 100644 index 00000000000..e59579b4ef8 --- /dev/null +++ b/packages/calcite-components/src/components/swatch/swatch.e2e.ts @@ -0,0 +1,161 @@ +import { E2EPage, newE2EPage } from "@arcgis/lumina-compiler/puppeteerTesting"; +import { beforeEach, describe, expect, it } from "vitest"; +import { accessible, disabled, focusable, hidden, renders, slots, themed } from "../../tests/commonTests"; +import { html } from "../../../support/formatting"; +import { CSS, SLOTS } from "./resources"; + +describe("calcite-swatch", () => { + describe("renders", () => { + renders("calcite-swatch", { display: "block" }); + }); + + describe("honors hidden attribute", () => { + hidden("calcite-swatch"); + }); + + describe("accessible", () => { + accessible("calcite-swatch"); + accessible(``); + accessible(``); + accessible(``); + accessible(``); + }); + + describe("slots", () => { + slots("calcite-swatch", SLOTS); + }); + + describe.skip("is focusable", () => { + focusable(""); + }); + + describe.skip("can be disabled", () => { + disabled(""); + }); + + it("should not emit event after the swatch is clicked if interactive if not set", async () => { + const page = await newE2EPage(); + await page.setContent(``); + + const eventSpy = await page.spyOnEvent("calciteSwatchSelect", "window"); + + const swatch1 = await page.find("#swatch-1"); + await swatch1.click(); + await page.waitForChanges(); + + expect(eventSpy).not.toHaveReceivedEvent(); + }); + + it.skip("should emit event after the swatch button is clicked when interactive", async () => { + const page = await newE2EPage(); + await page.setContent(``); + + const eventSpy = await page.spyOnEvent("calciteSwatchSelect", "window"); + + const swatch1 = await page.find("#swatch-1"); + await swatch1.click(); + await page.waitForChanges(); + + expect(eventSpy).toHaveReceivedEvent(); + }); + + it.skip("should receive focus when clicked", async () => { + const page = await newE2EPage(); + await page.setContent(``); + + const swatch1 = await page.find("#swatch-1"); + await swatch1.click(); + await page.waitForChanges(); + expect(await page.evaluate(() => document.activeElement.id)).toEqual(swatch1.id); + }); + + it("renders default props when none are provided", async () => { + const page = await newE2EPage(); + await page.setContent(`Swatch content`); + + const element = await page.find("calcite-swatch"); + expect(element).toEqualAttribute("scale", "m"); + }); + + it("renders requested props when valid props are provided", async () => { + const page = await newE2EPage(); + await page.setContent(`Swatch content`); + + const element = await page.find("calcite-swatch"); + expect(element).toEqualAttribute("scale", "l"); + }); + + 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 () => { + await page.setContent(""); + const swatch = await page.find(`calcite-swatch >>> ${fillSwatchPartSelector}`); + const style = await swatch.getComputedStyle(); + + expect(style["fill"]).toBe("rgb(255, 255, 255)"); + }); + + it("supports keywords", async () => { + await page.setContent(""); + const swatch = await page.find(`calcite-swatch >>> ${fillSwatchPartSelector}`); + const style = await swatch.getComputedStyle(); + + expect(style["fill"]).toBe("rgb(127, 255, 0)"); + }); + + it("supports hsl", async () => { + await page.setContent(""); + const swatch = await page.find(`calcite-swatch >>> ${fillSwatchPartSelector}`); + const style = await swatch.getComputedStyle(); + + expect(style["fill"]).toBe("rgb(240, 255, 240)"); + }); + + it("supports hex", async () => { + await page.setContent(""); + const swatch = await page.find(`calcite-swatch >>> ${fillSwatchPartSelector}`); + const style = await swatch.getComputedStyle(); + + expect(style["fill"]).toBe("rgb(255, 130, 0)"); + }); + + describe("with alpha values", () => { + const fillSwatchPartSelector = `.${CSS.swatch} rect:nth-child(5)`; + + it("supports rgba", async () => { + await page.setContent(""); + const swatch = await page.find(`calcite-swatch >>> ${fillSwatchPartSelector}`); + const style = await swatch.getComputedStyle(); + + expect(style["fill"]).toBe("rgba(255, 255, 255, 0.5)"); + }); + + it("supports hsla", async () => { + await page.setContent(""); + const swatch = await page.find(`calcite-swatch >>> ${fillSwatchPartSelector}`); + const style = await swatch.getComputedStyle(); + + expect(style["fill"]).toBe("rgba(240, 255, 240, 0.5)"); + }); + + it("supports hexa", async () => { + await page.setContent(""); + const swatch = await page.find(`calcite-swatch >>> ${fillSwatchPartSelector}`); + const style = await swatch.getComputedStyle(); + + expect(style["fill"]).toBe("rgba(255, 130, 0, 0.5)"); + }); + }); + }); + describe("themed", () => { + describe("default", () => { + themed(html`calcite-swatch`, { + "--calcite-swatch-corner-radius": { shadowSelector: `.${CSS.container}`, targetProp: "borderRadius" }, + }); + }); + }); +}); diff --git a/packages/calcite-components/src/components/swatch/swatch.scss b/packages/calcite-components/src/components/swatch/swatch.scss new file mode 100644 index 00000000000..33fac6a54e4 --- /dev/null +++ b/packages/calcite-components/src/components/swatch/swatch.scss @@ -0,0 +1,141 @@ +/** + * CSS Custom Properties + * + * These properties can be overridden using the component's tag as selector. + * + * @prop --calcite-swatch-corner-radius: Specifies the component's corner radius. + * + */ + +// AUTO-GENERATED — do not modify. Changes will be overwritten. +// +// Internal CSS custom properties for component use only. Overwriting is not recommended. +// +// --calcite-internal-swatch-inset +// --calcite-internal-swatch-size + +:host { + @apply block; + --calcite-internal-swatch-inset: var(--calcite-spacing-xxs); +} + +:host([scale="s"]) { + .container { + --calcite-internal-swatch-size: var(--calcite-spacing-xl); + } +} + +:host([scale="m"]) { + .container { + --calcite-internal-swatch-size: var(--calcite-spacing-xxl); + } +} + +:host([scale="l"]) { + .container { + --calcite-internal-swatch-size: var(--calcite-spacing-xxxl); + } +} + +.container { + @apply relative focus-base justify-center box-border overflow-hidden; + font-size: var(--calcite-internal-swatch-font-size, var(--calcite-font-size)); + block-size: var(--calcite-internal-swatch-size, auto); + inline-size: var(--calcite-internal-swatch-size, auto); + min-inline-size: var(--calcite-internal-swatch-size, auto); + border-radius: var(--calcite-swatch-corner-radius, 0); + + &:not(.non-interactive):hover { + @apply cursor-pointer; + } + &:not(.non-interactive):hover { + box-shadow: 0 0 0 var(--calcite-border-width-md) var(--calcite-color-border-2); + } + &.selectable { + @apply cursor-pointer; + } + &:not(.non-interactive):focus { + @apply focus-outset; + } +} + +.swatch { + position: absolute; + @apply inline-flex overflow-hidden absolute; + z-index: calc(var(--calcite-z-index) + 1); + block-size: var(--calcite-internal-swatch-size, auto); + inline-size: var(--calcite-internal-swatch-size, auto); + min-inline-size: var(--calcite-internal-swatch-size, auto); + border-radius: var(--calcite-swatch-corner-radius, 0); +} + +:host([selected]) .swatch { + inset: var(--calcite-internal-swatch-inset); + block-size: calc(var(--calcite-internal-swatch-size, auto) - calc(2 * var(--calcite-internal-swatch-inset))); + inline-size: calc(var(--calcite-internal-swatch-size, auto) - calc(2 * var(--calcite-internal-swatch-inset))); + min-inline-size: calc(var(--calcite-internal-swatch-size, auto) - calc(2 * var(--calcite-internal-swatch-inset))); +} + +:host([selected]) .container { + box-shadow: + inset 0 0 0 var(--calcite-border-width-md) var(--calcite-color-text-1), + inset 0 0 0 var(--calcite-border-width-lg) var(--calcite-color-foreground-1); +} + +:host([selected]) .image-container { + inset: var(--calcite-internal-swatch-inset); +} + +.image-container { + @apply inline-flex overflow-hidden absolute; + z-index: calc(var(--calcite-z-index) + 2); + inset: var(--calcite-spacing-px); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + border-radius: var(--calcite-swatch-corner-radius, 0); +} + +.internal-svg-container { + @apply absolute inset-0 flex items-center justify-center; + z-index: calc(var(--calcite-z-index) + 2); +} + +.swatch { + @apply overflow-hidden; + block-size: inherit; + inline-size: inherit; +} + +.swatch--no-color { + rect { + fill: var(--calcite-color-foreground-1); + } +} + +:host([selected]) #swatch-rect { + stroke-width: 0; +} + +#swatch-rect { + stroke: var(--calcite-color-text-1); + stroke-width: var(--calcite-border-width-md); + stroke-opacity: 0.3; +} + +.internal-svg-disabled { + stroke: #6a6a6a; + fill: white; +} + +.internal-svg-empty { + stroke: var(--calcite-color-status-danger); + stroke-width: 3; +} + +.checker { + fill: #cacaca; +} + +@include base-component(); diff --git a/packages/calcite-components/src/components/swatch/swatch.stories.ts b/packages/calcite-components/src/components/swatch/swatch.stories.ts new file mode 100644 index 00000000000..7ca3e5bb672 --- /dev/null +++ b/packages/calcite-components/src/components/swatch/swatch.stories.ts @@ -0,0 +1,81 @@ +import { boolean, modesDarkDefault } from "../../../.storybook/utils"; +import { placeholderImage } from "../../../.storybook/placeholder-image"; +import { html } from "../../../support/formatting"; +import { ATTRIBUTES } from "../../../.storybook/resources"; +import { Swatch } from "./swatch"; + +const { scale } = ATTRIBUTES; + +type SwatchStoryArgs = Pick; + +export default { + title: "Components/Swatch", + args: { scale: scale.defaultValue, selected: false, label: "My great swatch" }, + argTypes: { scale: { options: scale.values, control: { type: "select" } }, label: { control: { type: "text" } } }, +}; + +export const simple = (args: SwatchStoryArgs): string => html` +
+ +
+`; + +export const withHex = (args: SwatchStoryArgs): string => html` +
+ +
+`; + +export const withRgba = (args: SwatchStoryArgs): string => html` +
+ +
+`; + +export const hexDisabled = (args: SwatchStoryArgs): string => html` +
+ +
+`; + +export const emptyDisabled = (args: SwatchStoryArgs): string => html` +
+ +
+`; + +export const withImage = (args: SwatchStoryArgs): string => html` +
+ + + +
+`; + +export const withImageDisabled = (args: SwatchStoryArgs): string => html` +
+ + + +
+`; + +export const darkModeRTL_TestOnly = (args: SwatchStoryArgs): string => html` +
+ +
+`; + +darkModeRTL_TestOnly.parameters = { themes: modesDarkDefault }; diff --git a/packages/calcite-components/src/components/swatch/swatch.tsx b/packages/calcite-components/src/components/swatch/swatch.tsx new file mode 100644 index 00000000000..f4aab6ea09e --- /dev/null +++ b/packages/calcite-components/src/components/swatch/swatch.tsx @@ -0,0 +1,461 @@ +import { PropertyValues } from "lit"; +import { createRef } from "lit-html/directives/ref.js"; +import { + LitElement, + property, + createEvent, + h, + method, + state, + JsxNode, + Fragment, +} from "@arcgis/lumina"; +import Color, { ColorInstance } from "color"; +import { slotChangeHasAssignedElement } from "../../utils/dom"; +import { Scale, SelectionMode } from "../interfaces"; +import { + InteractiveComponent, + InteractiveContainer, + updateHostInteraction, +} from "../../utils/interactive"; +import { useSetFocus } from "../../controllers/useSetFocus"; +import type { SwatchGroup } from "../swatch-group/swatch-group"; +import { hexify } from "../color-picker/utils"; +import { CHECKER_DIMENSIONS } from "../color-picker-swatch/resources"; +import { CSS, SLOTS, IDS } from "./resources"; +import { styles } from "./swatch.scss"; + +declare global { + interface DeclareElements { + "calcite-swatch": Swatch; + } +} + +export class Swatch extends LitElement implements InteractiveComponent { + //#region Static Members + + static override styles = styles; + + //#endregion + + //#region Private Properties + + private internalColor: ColorInstance; + + private containerEl = createRef(); + + private focusSetter = useSetFocus()(this); + + //#endregion + + //#region State Properties + + @state() private hasImage = false; + + //#endregion + + //#region Public Properties + + /** + * Specifies the component's color + * + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/color_value + */ + @property() color: string; + + /** When `true`, interaction is prevented and the component is displayed with lower opacity. */ + @property({ reflect: true }) disabled = false; + + /** + * When true, enables the swatch to be focused, and allows the `calciteSwatchSelect` to emit. + * This is set to `true` by a parent Swatch Group component. + * + * @private + */ + @property() interactive = false; + + /** + * Accessible name for the component. + * + * @required + */ + @property() label: string; + + /** @private */ + @property() parentSwatchGroup: SwatchGroup["el"]; + + /** Specifies the size of the component. When contained in a parent `calcite-swatch-group` inherits the parent's `scale` value. */ + @property({ reflect: true }) scale: Scale = "m"; + + /** When `true`, the component is selected. */ + @property({ reflect: true }) selected = false; + + /** + * This internal property, managed by a containing `calcite-swatch-group`, is + * conditionally set based on the `selectionMode` of the parent + * + * @private + */ + @property() selectionMode: Extract< + "multiple" | "single" | "single-persist" | "none", + SelectionMode + > = "none"; + + /** The component's value. */ + @property() value: any; + + //#endregion + + //#region Public Methods + + /** + * Sets focus on the component. + * + * @param options + */ + @method() + async setFocus(options?: FocusOptions): Promise { + return this.focusSetter(() => { + return this.el; + }, options); + } + + //#endregion + + //#region Events + + /** @private */ + calciteInternalSwatchKeyEvent = createEvent({ cancelable: false }); + + /** @private */ + calciteInternalSwatchSelect = createEvent({ cancelable: false }); + + /** @private */ + calciteInternalSyncSelectedSwatches = createEvent({ cancelable: false }); + + /** Fires when the selected state of the component changes. */ + calciteSwatchSelect = createEvent({ cancelable: false }); + + //#endregion + + //#region Lifecycle + + constructor() { + super(); + this.listen("keydown", this.keyDownHandler); + } + + async load(): Promise { + this.handleColorChange(this.color); + } + + override willUpdate(changes: PropertyValues): void { + if (changes.has("selected") && this.hasUpdated) { + this.watchSelected(this.selected); + } + if (changes.has("color")) { + this.handleColorChange(this.color); + } + } + + override updated(): void { + updateHostInteraction(this); + } + + loaded(): void { + if (this.selectionMode !== "none" && this.interactive && this.selected) { + this.handleSelectionPropertyChange(this.selected); + } + } + + //#endregion + + //#region Private Methods + + private watchSelected(selected: boolean): void { + if (this.selectionMode === "none") { + return; + } + this.handleSelectionPropertyChange(selected); + } + + private keyDownHandler(event: KeyboardEvent): void { + if (event.target === this.el) { + switch (event.key) { + case " ": + case "Enter": + this.handleEmittingEvent(); + event.preventDefault(); + break; + case "ArrowRight": + case "ArrowLeft": + case "Home": + case "End": + this.calciteInternalSwatchKeyEvent.emit(event); + event.preventDefault(); + break; + } + } + } + + private handleSlotImageChange(event: Event): void { + this.hasImage = slotChangeHasAssignedElement(event); + } + + private handleEmittingEvent(): void { + if (this.interactive) { + this.calciteSwatchSelect.emit(); + } + } + + private handleSelectionPropertyChange(selected: boolean): void { + if (this.selectionMode === "single") { + this.calciteInternalSyncSelectedSwatches.emit(); + } + const selectedInParent = this.parentSwatchGroup.selectedItems.includes(this.el); + + if (!selectedInParent && selected && this.selectionMode !== "multiple") { + this.calciteInternalSwatchSelect.emit(); + } + if (this.selectionMode !== "single") { + this.calciteInternalSyncSelectedSwatches.emit(); + } + } + + private handleColorChange(color: string | null): void { + this.internalColor = color ? Color(color) : null; + } + + //#endregion + + //#region Rendering + + private renderSwatchImage(): JsxNode { + return ( +
+ +
+ ); + } + + private renderEmptyDisplay(): JsxNode { + const scale = this.scale === "s" ? "12" : this.scale === "m" ? "16" : "20"; + + return ( +
+ + + +
+ ); + } + + private renderDisabledDisplay(): JsxNode { + const svgSmMdPath = ( + + + + ); + + const svgLgPath = ( + + + + ); + return ( +
{this.scale === "l" ? svgLgPath : svgSmMdPath}
+ ); + } + + private renderSwatch(): JsxNode { + const { internalColor } = this; + const borderRadius = "0"; + const isEmpty = !internalColor; + const commonSwatchProps = { + height: "100%", + rx: borderRadius, + width: "100%", + }; + + if (isEmpty) { + return ( + <> + + + + {this.renderSwatchRect({ + clipPath: `inset(0 round "${borderRadius}")`, + ...commonSwatchProps, + })} + + + ); + } + + const alpha = internalColor.alpha(); + const hex = hexify(internalColor); + const hexa = hexify(internalColor, alpha < 1); + + return ( + <> + {hexa} + + + + + + + {this.renderSwatchRect({ + fill: "url(#checker)", + rx: commonSwatchProps.rx, + height: commonSwatchProps.height, + width: commonSwatchProps.width, + })} + {this.renderSwatchRect({ + clipPath: alpha < 1 ? "polygon(100% 0, 0 0, 0 100%)" : `inset(0 round "${borderRadius}")`, + fill: hex, + ...commonSwatchProps, + })} + {alpha < 1 + ? this.renderSwatchRect({ + clipPath: "polygon(100% 0, 100% 100%, 0 100%)", + fill: hexa, + key: "opacity-fill", + ...commonSwatchProps, + }) + : null} + + ); + } + + private renderSwatchRect({ + clipPath, + fill, + height, + key, + rx, + stroke, + strokeWidth, + width, + }: { + clipPath?: string; + fill?: string; + height: string; + key?: string; + rx: string; + + // note: stroke-width and clip-path are needed to hide overflowing portion of stroke + // @see https://stackoverflow.com/a/7273346/194216 + stroke?: string; + strokeWidth?: string; + + width: string; + }): JsxNode { + return ( + + ); + } + + override render(): JsxNode { + const { disabled } = this; + const disableInteraction = disabled || (!disabled && !this.interactive); + const role = + this.selectionMode === "multiple" && this.interactive + ? "checkbox" + : this.selectionMode !== "none" && this.interactive + ? "radio" + : this.interactive + ? "button" + : "presentation"; + + const isEmpty = !this.internalColor; + + const classes = { + [CSS.swatch]: true, + [CSS.noColorSwatch]: isEmpty || (this.hasImage && !this.internalColor), + }; + + return ( + +
+ {this.renderSwatchImage()} + {!this.internalColor && !this.hasImage && this.renderEmptyDisplay()} + {this.disabled && this.renderDisabledDisplay()} + + {this.renderSwatch()} + +
+
+ ); + } + + //#endregion +} diff --git a/packages/calcite-components/src/demos/_assets/images/hatch-1.png b/packages/calcite-components/src/demos/_assets/images/hatch-1.png new file mode 100644 index 00000000000..58ecf7f4192 Binary files /dev/null and b/packages/calcite-components/src/demos/_assets/images/hatch-1.png differ diff --git a/packages/calcite-components/src/demos/_assets/images/hatch-2.png b/packages/calcite-components/src/demos/_assets/images/hatch-2.png new file mode 100644 index 00000000000..b9b89dd8709 Binary files /dev/null and b/packages/calcite-components/src/demos/_assets/images/hatch-2.png differ diff --git a/packages/calcite-components/src/demos/_assets/images/hatch-3.png b/packages/calcite-components/src/demos/_assets/images/hatch-3.png new file mode 100644 index 00000000000..a53069b4ec4 Binary files /dev/null and b/packages/calcite-components/src/demos/_assets/images/hatch-3.png differ diff --git a/packages/calcite-components/src/demos/_assets/images/hatch-4.png b/packages/calcite-components/src/demos/_assets/images/hatch-4.png new file mode 100644 index 00000000000..c59ec86c623 Binary files /dev/null and b/packages/calcite-components/src/demos/_assets/images/hatch-4.png differ diff --git a/packages/calcite-components/src/demos/_assets/images/hatch-5.png b/packages/calcite-components/src/demos/_assets/images/hatch-5.png new file mode 100644 index 00000000000..57c3ac2e2db Binary files /dev/null and b/packages/calcite-components/src/demos/_assets/images/hatch-5.png differ diff --git a/packages/calcite-components/src/demos/_assets/images/pattern-1.png b/packages/calcite-components/src/demos/_assets/images/pattern-1.png new file mode 100644 index 00000000000..07fc8bc8b8b Binary files /dev/null and b/packages/calcite-components/src/demos/_assets/images/pattern-1.png differ diff --git a/packages/calcite-components/src/demos/_assets/images/pattern-2.png b/packages/calcite-components/src/demos/_assets/images/pattern-2.png new file mode 100644 index 00000000000..c2c0d220c14 Binary files /dev/null and b/packages/calcite-components/src/demos/_assets/images/pattern-2.png differ diff --git a/packages/calcite-components/src/demos/_assets/images/pattern-3.png b/packages/calcite-components/src/demos/_assets/images/pattern-3.png new file mode 100644 index 00000000000..ca13e9fe53b Binary files /dev/null and b/packages/calcite-components/src/demos/_assets/images/pattern-3.png differ diff --git a/packages/calcite-components/src/demos/_assets/images/pattern-4.png b/packages/calcite-components/src/demos/_assets/images/pattern-4.png new file mode 100644 index 00000000000..700a440f3dc Binary files /dev/null and b/packages/calcite-components/src/demos/_assets/images/pattern-4.png differ diff --git a/packages/calcite-components/src/demos/swatch-group.html b/packages/calcite-components/src/demos/swatch-group.html new file mode 100644 index 00000000000..ad56b0d7989 --- /dev/null +++ b/packages/calcite-components/src/demos/swatch-group.html @@ -0,0 +1,701 @@ + + + + + + + Swatch Group + + + + + + + +
+

Swatch group selection mode examples

+
+
+
Swatch outside of Swatch Group
+ + + + + + + + + + + + + + + + + +
+ +
+
None
+ + + + + + + + + + + + + + + + + + +
+ + +
+
Single
+ + + + + + + + + + + + + + + + + + +
+ + +
+
Single-persist
+ + + + + + + + + + + + + + + + + + +
+ + +
+
Multiple
+ + + + + + + + + + + + + + + + + + +
+
+
Swatch Group Disabled
+ + + + + + + + + + + + + + + + + + +
+ +
+
Themed
+ + + + + + + + + + + + + + + + + + +
+ +
+ + +
+

Swatch usage combinations

+
+ + +
+
no selection
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
single selection
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
single persist
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
multiple selection
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

Programmatic selection

+
+
+
Single programmatic
+ + + + + + + + + Programmatically select swatch +
+
+
Single-persist programmatic
+ + + + + + + Programmatically select swatch +
+
+
Multiple programmatic
+ + + + + + + Programmatically select swatch +
+
+
setFocus programmatic on group
+ + + + + + + Programmatically set focus +
+
+
setFocus programmatic on swatch
+ + + + + + + Programmatically set focus on swatch +
+
+ + +