From 7ab86e3511e02d9e8de93007911396385634b1e1 Mon Sep 17 00:00:00 2001 From: JC Franco Date: Wed, 9 Jul 2025 00:29:33 -0700 Subject: [PATCH 1/3] refactor(dom): restore currentTarget in slot utils --- packages/calcite-components/src/utils/dom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/calcite-components/src/utils/dom.ts b/packages/calcite-components/src/utils/dom.ts index b2c2e677df7..3cba4a11884 100644 --- a/packages/calcite-components/src/utils/dom.ts +++ b/packages/calcite-components/src/utils/dom.ts @@ -483,7 +483,7 @@ export function slotChangeHasAssignedElement(event: Event): boolean { * @returns {Element[]} An array of elements. */ export function slotChangeGetAssignedElements(event: Event, selector?: string): T[] | null { - return getSlotAssignedElements(event.target as HTMLSlotElement, selector); + return getSlotAssignedElements(event.currentTarget as HTMLSlotElement, selector); } /** From 0259ee48ddd15d5d754a817120fb719e3f44af1b Mon Sep 17 00:00:00 2001 From: JC Franco Date: Thu, 10 Jul 2025 21:27:18 -0700 Subject: [PATCH 2/3] browser-modify dom spec tests --- .../{dom.spec.ts => dom.browser.spec.ts} | 5 ++-- packages/calcite-components/src/utils/dom.ts | 27 ++++++++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) rename packages/calcite-components/src/utils/{dom.spec.ts => dom.browser.spec.ts} (98%) diff --git a/packages/calcite-components/src/utils/dom.spec.ts b/packages/calcite-components/src/utils/dom.browser.spec.ts similarity index 98% rename from packages/calcite-components/src/utils/dom.spec.ts rename to packages/calcite-components/src/utils/dom.browser.spec.ts index 31e4cadb682..290ed0d28f8 100644 --- a/packages/calcite-components/src/utils/dom.spec.ts +++ b/packages/calcite-components/src/utils/dom.browser.spec.ts @@ -25,6 +25,7 @@ import { slotChangeHasContent, slotChangeHasTextContent, toAriaBoolean, + viewportUnitToPixel, whenAnimationDone, whenTransitionDone, } from "./dom"; @@ -527,13 +528,13 @@ describe("dom", () => { it("calculates the pixel value for 'vw' values", () => { const viewportWidth = window.innerWidth; - expect(getStylePixelValue("50vw")).toBe((viewportWidth / 100) * 50); + expect(getStylePixelValue("50vw")).toBe(viewportUnitToPixel(50, viewportWidth)); expect(getStylePixelValue("100vw")).toBe(viewportWidth); }); it("calculates the pixel value for 'vh' values", () => { const viewportHeight = window.innerHeight; - expect(getStylePixelValue("50vh")).toBe((viewportHeight / 100) * 50); + expect(getStylePixelValue("50vh")).toBe(viewportUnitToPixel(50, viewportHeight)); expect(getStylePixelValue("100vh")).toBe(viewportHeight); }); diff --git a/packages/calcite-components/src/utils/dom.ts b/packages/calcite-components/src/utils/dom.ts index 3cba4a11884..30f1823483e 100644 --- a/packages/calcite-components/src/utils/dom.ts +++ b/packages/calcite-components/src/utils/dom.ts @@ -662,14 +662,23 @@ function nextFrame(): Promise { * @returns {number} The pixel equivalent of the provided value. */ export function getStylePixelValue(value: string): number { - switch (true) { - case value.endsWith("px"): - return parseFloat(value); - case value.endsWith("vw"): - return (window.innerWidth / 100) * parseFloat(value); - case value.endsWith("vh"): - return (window.innerHeight / 100) * parseFloat(value); - default: - return 0; + if (value.endsWith("px")) { + return parseFloat(value); + } else if (value.endsWith("vw")) { + return viewportUnitToPixel(parseFloat(value), window.innerWidth); + } else if (value.endsWith("vh")) { + return viewportUnitToPixel(parseFloat(value), window.innerHeight); } + + return 0; +} + +/** + * Exported for testing purposes only. + * + * @private + */ +export function viewportUnitToPixel(value: number, viewportSize: number): number { + // intentionally dividing last to avoid rounding errors + return (value * viewportSize) / 100; } From 943fc1b4cc371228351d0335497ca26f4b5be6fc Mon Sep 17 00:00:00 2001 From: JC Franco Date: Thu, 10 Jul 2025 21:36:08 -0700 Subject: [PATCH 3/3] add test coverage for updated dom utils --- .../src/utils/dom.browser.spec.ts | 427 +++++++++++++----- 1 file changed, 302 insertions(+), 125 deletions(-) diff --git a/packages/calcite-components/src/utils/dom.browser.spec.ts b/packages/calcite-components/src/utils/dom.browser.spec.ts index 290ed0d28f8..30f8c43b79b 100644 --- a/packages/calcite-components/src/utils/dom.browser.spec.ts +++ b/packages/calcite-components/src/utils/dom.browser.spec.ts @@ -197,152 +197,329 @@ describe("dom", () => { }); }); - describe("getSlotAssignedElements()", () => { - it("returns slotted elements with no selector", () => { - const slotEl = document.createElement("slot"); - slotEl.assignedElements = () => [document.createElement("div"), document.createElement("div")]; - expect(getSlotAssignedElements(slotEl)).toHaveLength(2); - }); - it("returns no slotted elements", () => { - const slotEl = document.createElement("slot"); - slotEl.assignedElements = () => []; - expect(getSlotAssignedElements(slotEl)).toHaveLength(0); - }); - it("returns slotted elements with direct element selector", () => { - const slotEl = document.createElement("slot"); - slotEl.assignedElements = () => [ - document.createElement("span"), - document.createElement("div"), - document.createElement("span"), - ]; - expect(getSlotAssignedElements(slotEl, "div")).toHaveLength(1); - expect(getSlotAssignedElements(slotEl, "span")).toHaveLength(2); - }); - it("returns slotted elements with class selector", () => { - const slotEl = document.createElement("slot"); - const spanEl = document.createElement("span"); - spanEl.className = "my-span"; - const divEl = document.createElement("div"); - divEl.className = "my-div"; - slotEl.assignedElements = () => [document.createElement("span"), spanEl, document.createElement("div"), divEl]; - expect(getSlotAssignedElements(slotEl, ".my-div")).toHaveLength(1); - expect(getSlotAssignedElements(slotEl, ".my-span")).toHaveLength(1); - }); - }); + describe("slot utils", () => { + function defineTestElement(slotHandler: (slotEl: HTMLSlotElement) => void, slotHtml = ""): string { + // ensure unique tag name per test to avoid "custom element already defined" error + const tagName = + "test-element-" + + expect + .getState() + .currentTestName.split(">") + .map((part) => part.trim()) + .join(" ") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") // replace non-alphanumeric with dashes + .replace(/^-+|-+$/g, "") // trim leading/trailing dashes + .replace(/--+/g, "-"); + + class TestElement extends HTMLElement { + constructor() { + super(); + const shadow = this.attachShadow({ mode: "open" }); + shadow.innerHTML = slotHtml; + shadow.querySelectorAll("slot").forEach(slotHandler); + } + } + customElements.define(tagName, TestElement); - describe("slotChangeGetAssignedElements()", () => { - it("handles slotted elements", async () => - await setUpSlotChange({ - assignedElements: [document.createElement("div"), document.createElement("div")], - onSlotChange: (event) => expect(slotChangeGetAssignedElements(event)).toHaveLength(2), - })); + return tagName; + } - it("handles no slotted elements", async () => - await setUpSlotChange({ - onSlotChange: (event) => expect(slotChangeGetAssignedElements(event)).toHaveLength(0), - })); - }); + function appendChildren(parent: HTMLElement, children: Node[]): void { + parent.append(...children); + document.body.append(parent); + } - describe("slotChangeHasAssignedElement()", () => { - it("handles slotted elements", async () => - await setUpSlotChange({ - assignedElements: [document.createElement("div"), document.createElement("div")], - onSlotChange: (event) => expect(slotChangeHasAssignedElement(event)).toBe(true), - })); + function createEl( + tag: string, + props?: Partial, + ): HTMLElement { + const el = document.createElement(tag); - it("handles no slotted elements", async () => - await setUpSlotChange({ - onSlotChange: (event) => expect(slotChangeHasAssignedElement(event)).toBe(false), - })); - }); + if (props) { + Object.entries(props).forEach(([key, value]) => { + el[key] = value; + }); + } - describe("slotChangeHasAssignedNode()", () => { - it("handles slotted nodes", async () => - await setUpSlotChange({ - assignedNodes: [document.createTextNode("hello"), document.createTextNode("world")], - onSlotChange: (event) => expect(slotChangeHasAssignedNode(event)).toBe(true), - })); + return el; + } - it("handles no slotted nodes", async () => - await setUpSlotChange({ - onSlotChange: (event) => expect(slotChangeHasAssignedNode(event)).toBe(false), - })); - }); + describe("getSlotAssignedElements()", () => { + it("returns slotted elements with no selector", () => { + const slotEl = document.createElement("slot"); + slotEl.assignedElements = () => [document.createElement("div"), document.createElement("div")]; + expect(getSlotAssignedElements(slotEl)).toHaveLength(2); + }); + it("returns no slotted elements", () => { + const slotEl = document.createElement("slot"); + slotEl.assignedElements = () => []; + expect(getSlotAssignedElements(slotEl)).toHaveLength(0); + }); + it("returns slotted elements with direct element selector", () => { + const slotEl = document.createElement("slot"); + slotEl.assignedElements = () => [ + document.createElement("span"), + document.createElement("div"), + document.createElement("span"), + ]; + expect(getSlotAssignedElements(slotEl, "div")).toHaveLength(1); + expect(getSlotAssignedElements(slotEl, "span")).toHaveLength(2); + }); + it("returns slotted elements with class selector", () => { + const slotEl = document.createElement("slot"); + const spanEl = document.createElement("span"); + spanEl.className = "my-span"; + const divEl = document.createElement("div"); + divEl.className = "my-div"; + slotEl.assignedElements = () => [document.createElement("span"), spanEl, document.createElement("div"), divEl]; + expect(getSlotAssignedElements(slotEl, ".my-div")).toHaveLength(1); + expect(getSlotAssignedElements(slotEl, ".my-span")).toHaveLength(1); + }); + }); - describe("slotChangeGetAssignedNodes()", () => { - it("handles slotted nodes", async () => - await setUpSlotChange({ - assignedNodes: [document.createTextNode("hello"), document.createTextNode("world")], - onSlotChange: (event) => expect(slotChangeGetAssignedNodes(event)).toHaveLength(2), - })); + describe("slotChangeGetAssignedElements()", () => { + it("handles slotted elements", async () => { + let assigned: Element[]; + const testElName = defineTestElement((slotEl) => { + slotEl.addEventListener("slotchange", (event) => { + assigned = slotChangeGetAssignedElements(event); + }); + }); + const testEl = createEl(testElName); + const slottedEls = [createEl("div"), createEl("div")]; - it("handles no slotted nodes", async () => - await setUpSlotChange({ - onSlotChange: (event) => expect(slotChangeGetAssignedNodes(event)).toHaveLength(0), - })); - }); + appendChildren(testEl, slottedEls); + await waitForAnimationFrame(); + + expect(assigned).toEqual(slottedEls); - describe("slotChangeGetTextContent()", () => { - it("handles slotted nodes", async () => { - await setUpSlotChange({ - assignedNodes: [document.createTextNode("hello"), document.createTextNode("world")], - onSlotChange: (event) => expect(slotChangeGetTextContent(event)).toEqual("helloworld"), + assigned = null; + slottedEls.forEach((el) => el.remove()); + await waitForAnimationFrame(); + + expect(assigned).toEqual([]); + }); + + it("handles nested slot structure", async () => { + const slotToAssigned: Record = {}; + const slotHtml = html` + + + + `; + const testElName = defineTestElement((slotEl) => { + slotEl.addEventListener("slotchange", (event) => { + slotToAssigned[slotEl.name] = slotChangeGetAssignedElements(event); + }); + }, slotHtml); + const testEl = createEl(testElName); + const nodes = [ + document.createTextNode("hello"), + createEl("div"), + createEl("div", { slot: "foo" }), + createEl("div", { slot: "bar" }), + createEl("div", { slot: "bar" }), + createEl("div", { slot: "baz" }), + createEl("div", { slot: "baz" }), + createEl("div", { slot: "baz" }), + ]; + + appendChildren(testEl, nodes); + await waitForAnimationFrame(); + + expect(slotToAssigned).toEqual({ + "": [nodes[1]], + foo: [nodes[2]], + bar: [nodes[3], nodes[4]], + baz: [nodes[5], nodes[6], nodes[7]], + }); + + Object.keys(slotToAssigned).forEach((key) => delete slotToAssigned[key]); + nodes.forEach((el) => el.remove()); + await waitForAnimationFrame(); + + expect(slotToAssigned).toEqual({ + "": [], + foo: [], + bar: [], + baz: [], + }); }); }); - it("handles no slotted nodes", async () => - await setUpSlotChange({ - onSlotChange: (event) => expect(slotChangeGetTextContent(event)).toEqual(""), - })); - }); + describe("slotChangeHasAssignedElement()", () => { + it("handles slotted elements", async () => + await setUpSlotChange({ + assignedElements: [document.createElement("div"), document.createElement("div")], + onSlotChange: (event) => expect(slotChangeHasAssignedElement(event)).toBe(true), + })); - describe("slotChangeHasContent()", () => { - it("handles slotted nodes", async () => - await setUpSlotChange({ - assignedNodes: [document.createTextNode("hello"), document.createTextNode("world")], - onSlotChange: (event) => expect(slotChangeHasContent(event)).toEqual(true), - })); + it("handles no slotted elements", async () => + await setUpSlotChange({ + onSlotChange: (event) => expect(slotChangeHasAssignedElement(event)).toBe(false), + })); + }); - it("handles slotted elements", async () => - await setUpSlotChange({ - assignedElements: [document.createElement("div")], - onSlotChange: (event) => expect(slotChangeHasContent(event)).toEqual(true), - })); + describe("slotChangeHasAssignedNode()", () => { + it("handles slotted nodes", async () => + await setUpSlotChange({ + assignedNodes: [document.createTextNode("hello"), document.createTextNode("world")], + onSlotChange: (event) => expect(slotChangeHasAssignedNode(event)).toBe(true), + })); - it("handles no slotted nodes or elements", async () => - await setUpSlotChange({ - onSlotChange: (event) => expect(slotChangeHasContent(event)).toEqual(false), - })); - }); + it("handles no slotted nodes", async () => + await setUpSlotChange({ + onSlotChange: (event) => expect(slotChangeHasAssignedNode(event)).toBe(false), + })); + }); - describe("slotChangeHasTextContent()", () => { - it("handles slotted nodes", async () => - await setUpSlotChange({ - assignedNodes: [document.createTextNode("hello"), document.createTextNode("world")], - onSlotChange: (event) => expect(slotChangeHasTextContent(event)).toEqual(true), - })); + describe("slotChangeGetAssignedNodes()", () => { + it("returns assigned nodes on slotchange", async () => { + let assigned: Node[]; + const testElName = defineTestElement((slotEl) => { + slotEl.addEventListener("slotchange", (event) => { + assigned = slotChangeGetAssignedNodes(event); + }); + }); + const testEl = createEl(testElName); + const nodes = [document.createTextNode("hello"), createEl("div"), document.createTextNode("world")]; - it("handles no slotted nodes", async () => - await setUpSlotChange({ - onSlotChange: (event) => expect(slotChangeHasTextContent(event)).toEqual(false), - })); - }); + appendChildren(testEl, nodes); + await waitForAnimationFrame(); + + expect(assigned).toEqual(nodes); + + assigned = null; + nodes.forEach((el) => el.remove()); + await waitForAnimationFrame(); + + expect(assigned).toEqual([]); + }); + + it("handles nested slot structure", async () => { + const slotToAssigned: Record = {}; + const slotHtml = html` + + + + `; + const testElName = defineTestElement((slotEl) => { + slotEl.addEventListener("slotchange", (event) => { + slotToAssigned[slotEl.name] = slotChangeGetAssignedNodes(event); + }); + }, slotHtml); + const testEl = createEl(testElName); + const nodes = [ + document.createTextNode("hello"), + createEl("div"), + createEl("div", { slot: "foo" }), + createEl("div", { slot: "bar" }), + createEl("div", { slot: "bar" }), + createEl("div", { slot: "baz" }), + createEl("div", { slot: "baz" }), + createEl("div", { slot: "baz" }), + ]; + + appendChildren(testEl, nodes); + await waitForAnimationFrame(); + + expect(slotToAssigned).toEqual({ + "": [nodes[0], nodes[1]], + foo: [nodes[2]], + bar: [nodes[3], nodes[4]], + baz: [nodes[5], nodes[6], nodes[7]], + }); + + Object.keys(slotToAssigned).forEach((key) => delete slotToAssigned[key]); + nodes.forEach((node) => node.remove()); + await waitForAnimationFrame(); - describe("hasVisibleContent", () => { - it("should return true if element has visible content", () => { - const element = document.createElement("div"); - element.innerHTML = "

hello

"; - document.body.append(element); - expect(hasVisibleContent(element)).toBe(true); + expect(slotToAssigned).toEqual({ + "": [], + foo: [], + bar: [], + baz: [], + }); + }); }); - it("should return false if element has no visible content", () => { - const element = document.createElement("div"); - document.body.append(element); - expect(hasVisibleContent(element)).toBe(false); + describe("slotChangeGetTextContent()", () => { + it("handles slotted nodes", async () => { + await setUpSlotChange({ + assignedNodes: [document.createTextNode("hello"), document.createTextNode("world")], + onSlotChange: (event) => expect(slotChangeGetTextContent(event)).toEqual("helloworld"), + }); + }); + + it("handles no slotted nodes", async () => + await setUpSlotChange({ + onSlotChange: (event) => expect(slotChangeGetTextContent(event)).toEqual(""), + })); + }); + + describe("slotChangeHasContent()", () => { + it("handles slotted nodes", async () => + await setUpSlotChange({ + assignedNodes: [document.createTextNode("hello"), document.createTextNode("world")], + onSlotChange: (event) => expect(slotChangeHasContent(event)).toEqual(true), + })); + + it("handles slotted elements", async () => + await setUpSlotChange({ + assignedElements: [document.createElement("div")], + onSlotChange: (event) => expect(slotChangeHasContent(event)).toEqual(true), + })); + + it("handles no slotted nodes or elements", async () => + await setUpSlotChange({ + onSlotChange: (event) => expect(slotChangeHasContent(event)).toEqual(false), + })); + }); + + describe("slotChangeHasTextContent()", () => { + it("handles slotted nodes", async () => + await setUpSlotChange({ + assignedNodes: [document.createTextNode("hello"), document.createTextNode("world")], + onSlotChange: (event) => expect(slotChangeHasTextContent(event)).toEqual(true), + })); + + it("handles no slotted nodes", async () => + await setUpSlotChange({ + onSlotChange: (event) => expect(slotChangeHasTextContent(event)).toEqual(false), + })); + }); + + describe("hasVisibleContent", () => { + it("should return true if element has visible content", () => { + const element = document.createElement("div"); + element.innerHTML = "

hello

"; + document.body.append(element); + expect(hasVisibleContent(element)).toBe(true); + }); + + it("should return false if element has no visible content", () => { + const element = document.createElement("div"); + document.body.append(element); + expect(hasVisibleContent(element)).toBe(false); - element.innerHTML = "\n\n"; - expect(hasVisibleContent(element)).toBe(false); + element.innerHTML = "\n\n"; + expect(hasVisibleContent(element)).toBe(false); + }); }); });