From e81b650ae66b413d19eb567c3766f813dfa322c7 Mon Sep 17 00:00:00 2001 From: JC Franco Date: Fri, 1 Dec 2023 11:17:44 -0800 Subject: [PATCH 1/2] fix(label): associate label to component when `for` prop is set after initialization (#8309) **Related Issue:** #7364 ## Summary This allows `label` to find its corresponding labelable when `for` is dynamically set after the label and labelable have gone through the label matching process that happens when they are connected to the DOM. --- .../src/components/label/label.tsx | 23 +++++++++- .../src/tests/commonTests.ts | 24 +++++++++++ .../src/utils/component.spec.ts | 41 +++++++++++++++++- .../calcite-components/src/utils/component.ts | 18 ++++++++ .../calcite-components/src/utils/label.ts | 43 ++++++++++++++++++- 5 files changed, 144 insertions(+), 5 deletions(-) diff --git a/packages/calcite-components/src/components/label/label.tsx b/packages/calcite-components/src/components/label/label.tsx index bd24c6ddade..06f9d89d9c6 100644 --- a/packages/calcite-components/src/components/label/label.tsx +++ b/packages/calcite-components/src/components/label/label.tsx @@ -1,5 +1,19 @@ -import { Component, Element, Event, EventEmitter, h, Host, Prop, VNode } from "@stencil/core"; -import { labelConnectedEvent, labelDisconnectedEvent } from "../../utils/label"; +import { + Component, + Element, + Event, + EventEmitter, + h, + Host, + Prop, + VNode, + Watch, +} from "@stencil/core"; +import { + associateExplicitLabelToUnlabeledComponent, + labelConnectedEvent, + labelDisconnectedEvent, +} from "../../utils/label"; import { Alignment, Scale } from "../interfaces"; import { CSS } from "./resources"; @@ -24,6 +38,11 @@ export class Label { /** Specifies the `id` of the component the label is bound to. Use when the component the label is bound to does not reside within the component. */ @Prop({ reflect: true }) for: string; + @Watch("for") + handleForChange(): void { + associateExplicitLabelToUnlabeledComponent(this.el); + } + /** Specifies the size of the component. */ @Prop({ reflect: true }) scale: Scale = "m"; diff --git a/packages/calcite-components/src/tests/commonTests.ts b/packages/calcite-components/src/tests/commonTests.ts index e208679342f..b70c0a50b0f 100644 --- a/packages/calcite-components/src/tests/commonTests.ts +++ b/packages/calcite-components/src/tests/commonTests.ts @@ -646,6 +646,30 @@ export function labelable( shadowFocusTargetSelector, }); }); + + it("is labelable when label's for is set after initialization", async () => { + const siblingHtml = html` + ${labelTitle} + ${componentHtml} + `; + const siblingPage: E2EPage = await newE2EPage(); + beforeContent?.(siblingPage); + + await siblingPage.setContent(siblingHtml); + await siblingPage.waitForChanges(); + + const label = await siblingPage.find("calcite-label"); + label.setProperty("for", id); + await siblingPage.waitForChanges(); + + await assertLabelable({ + page: siblingPage, + componentTag, + propertyToToggle, + focusTargetSelector, + shadowFocusTargetSelector, + }); + }); }); } diff --git a/packages/calcite-components/src/utils/component.spec.ts b/packages/calcite-components/src/utils/component.spec.ts index 82cb4369348..ca7fa99d1bf 100644 --- a/packages/calcite-components/src/utils/component.spec.ts +++ b/packages/calcite-components/src/utils/component.spec.ts @@ -1,4 +1,6 @@ -import { getIconScale } from "./component"; +import { componentOnReady, getIconScale } from "./component"; +import { html } from "../../support/formatting"; +import { HTMLStencilElement } from "@stencil/core/internal"; describe("getIconScale", () => { it('should return "m" when input is "l"', () => { @@ -10,3 +12,40 @@ describe("getIconScale", () => { expect(getIconScale("s")).toBe("s"); }); }); + +describe("componentOnReady", () => { + let requestAnimationFrameSpy: jest.SpyInstance; + let fakeComponent: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = html` `; + fakeComponent = document.querySelector("fake-component"); + + const originalRaf = globalThis.requestAnimationFrame; + requestAnimationFrameSpy = jest + .spyOn(globalThis, "requestAnimationFrame") + .mockImplementation((callback) => originalRaf(callback)); + }); + + afterEach(() => requestAnimationFrameSpy.mockRestore()); + + it("should call componentOnReady if it exists on the element (lazy-loaded)", async () => { + const componentOnReadyStub = ((fakeComponent as HTMLStencilElement).componentOnReady = jest.fn()); + + const promise = componentOnReady(fakeComponent); + expect(promise).toBeInstanceOf(Promise); + + await promise; + expect(componentOnReadyStub).toHaveBeenCalled(); + }); + + it("waits for an animation frame if componentOnReady does not exist on the element", async () => { + expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); + + const promise = componentOnReady(fakeComponent); + expect(promise).toBeInstanceOf(Promise); + + await promise; + expect(requestAnimationFrameSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/calcite-components/src/utils/component.ts b/packages/calcite-components/src/utils/component.ts index c8477fee4cc..645f3552131 100644 --- a/packages/calcite-components/src/utils/component.ts +++ b/packages/calcite-components/src/utils/component.ts @@ -1,5 +1,23 @@ import { Scale } from "../components/interfaces"; +import { HTMLStencilElement } from "@stencil/core/internal"; export function getIconScale(componentScale: Scale): Extract { return componentScale === "l" ? "m" : "s"; } + +/** + * This util helps us wait for a component to be ready for both lazy-loading (`dist` output) and non-lazy-loading (`components` output) components. + * + * Based on https://github.com/ionic-team/ionic-framework/blob/1a8bd6d/core/src/utils/helpers.ts#L60C1-L79C3 + * + * @param el - the host element to wait for + */ +export async function componentOnReady(el: HTMLElement): Promise { + await (isStencilEl(el) + ? el.componentOnReady() + : new Promise((resolve) => requestAnimationFrame(() => resolve()))); +} + +function isStencilEl(el: HTMLElement): el is HTMLStencilElement { + return typeof (el as HTMLStencilElement).componentOnReady === "function"; +} diff --git a/packages/calcite-components/src/utils/label.ts b/packages/calcite-components/src/utils/label.ts index aeb3dd6968f..00c0a6f4f31 100644 --- a/packages/calcite-components/src/utils/label.ts +++ b/packages/calcite-components/src/utils/label.ts @@ -1,4 +1,5 @@ import { closestElementCrossShadowBoundary, isBefore, queryElementRoots } from "./dom"; +import { componentOnReady } from "./component"; export interface LabelableComponent { /** @@ -41,7 +42,7 @@ const labelToLabelables = new WeakMap(); const onLabelConnectedMap = new WeakMap(); const onLabelDisconnectedMap = new WeakMap(); -const unlabeledComponents = new WeakSet(); +const unlabeledComponents = new Set(); const findLabelForComponent = (componentEl: HTMLElement): HTMLCalciteLabelElement | null => { const { id } = componentEl; @@ -70,7 +71,7 @@ function hasAncestorCustomElements(label: HTMLCalciteLabelElement, componentEl: let traversedElements: HTMLElement[]; const customElementAncestorCheckEventType = "custom-element-ancestor-check"; - const listener = (event) => { + const listener = (event: CustomEvent) => { event.stopImmediatePropagation(); const composedPath = event.composedPath() as HTMLElement[]; traversedElements = composedPath.slice(composedPath.indexOf(componentEl), composedPath.indexOf(label)); @@ -94,6 +95,10 @@ function hasAncestorCustomElements(label: HTMLCalciteLabelElement, componentEl: * @param component */ export function connectLabel(component: LabelableComponent): void { + if (!component) { + return; + } + const labelEl = findLabelForComponent(component.el); if ( @@ -132,6 +137,10 @@ export function connectLabel(component: LabelableComponent): void { * @param component */ export function disconnectLabel(component: LabelableComponent): void { + if (!component) { + return; + } + unlabeledComponents.delete(component); document.removeEventListener(labelConnectedEvent, onLabelConnectedMap.get(component)); document.removeEventListener(labelDisconnectedEvent, onLabelDisconnectedMap.get(component)); @@ -202,3 +211,33 @@ function onLabelDisconnected(this: LabelableComponent): void { onLabelConnectedMap.set(this, boundOnLabelConnected); document.addEventListener(labelConnectedEvent, boundOnLabelConnected); } + +/** + * Helper to associate an explicit label (i.e., using `for`) with a labelable component that does not have an associated label. + * + * @param label - the label element + */ +export async function associateExplicitLabelToUnlabeledComponent(label: HTMLCalciteLabelElement): Promise { + await componentOnReady(label); + + const alreadyLabeled = labelToLabelables.has(label); + + if (alreadyLabeled) { + return; + } + + const forComponentEl = label.ownerDocument?.getElementById(label.for); + + if (!forComponentEl) { + return; + } + + requestAnimationFrame(() => { + for (const labelable of unlabeledComponents) { + if (labelable.el === forComponentEl) { + connectLabel(labelable); + break; + } + } + }); +} From 745c0fc0ca441857105d51af58f0c98fdaa8dda5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 Dec 2023 19:36:41 +0000 Subject: [PATCH 2/2] chore: release next --- package-lock.json | 14 +++++++------- .../projects/component-library/CHANGELOG.md | 4 ++++ .../projects/component-library/package.json | 4 ++-- packages/calcite-components-react/CHANGELOG.md | 4 ++++ packages/calcite-components-react/package.json | 4 ++-- packages/calcite-components/CHANGELOG.md | 6 ++++++ packages/calcite-components/package.json | 2 +- 7 files changed, 26 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index f289d91eb2d..5229b0f2c51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45525,7 +45525,7 @@ }, "packages/calcite-components": { "name": "@esri/calcite-components", - "version": "1.12.0-next.6", + "version": "1.12.0-next.7", "license": "SEE LICENSE.md", "dependencies": { "@floating-ui/dom": "1.5.3", @@ -49404,10 +49404,10 @@ }, "packages/calcite-components-angular/projects/component-library": { "name": "@esri/calcite-components-angular", - "version": "1.12.0-next.6", + "version": "1.12.0-next.7", "license": "SEE LICENSE.md", "dependencies": { - "@esri/calcite-components": "^1.12.0-next.6", + "@esri/calcite-components": "^1.12.0-next.7", "tslib": "2.3.0" }, "peerDependencies": { @@ -49417,10 +49417,10 @@ }, "packages/calcite-components-react": { "name": "@esri/calcite-components-react", - "version": "1.12.0-next.6", + "version": "1.12.0-next.7", "license": "SEE LICENSE.md", "dependencies": { - "@esri/calcite-components": "^1.12.0-next.6" + "@esri/calcite-components": "^1.12.0-next.7" }, "peerDependencies": { "react": ">=16.7", @@ -52294,14 +52294,14 @@ "@esri/calcite-components-angular": { "version": "file:packages/calcite-components-angular/projects/component-library", "requires": { - "@esri/calcite-components": "^1.12.0-next.6", + "@esri/calcite-components": "^1.12.0-next.7", "tslib": "2.3.0" } }, "@esri/calcite-components-react": { "version": "file:packages/calcite-components-react", "requires": { - "@esri/calcite-components": "^1.12.0-next.6" + "@esri/calcite-components": "^1.12.0-next.7" } }, "@esri/calcite-design-tokens": { diff --git a/packages/calcite-components-angular/projects/component-library/CHANGELOG.md b/packages/calcite-components-angular/projects/component-library/CHANGELOG.md index 75a71d57b8d..36dec1464a6 100644 --- a/packages/calcite-components-angular/projects/component-library/CHANGELOG.md +++ b/packages/calcite-components-angular/projects/component-library/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.12.0-next.7](https://github.com/Esri/calcite-design-system/compare/@esri/calcite-components-angular@1.12.0-next.6...@esri/calcite-components-angular@1.12.0-next.7) (2023-12-01) + +**Note:** Version bump only for package @esri/calcite-components-angular + ## [1.12.0-next.6](https://github.com/Esri/calcite-design-system/compare/@esri/calcite-components-angular@1.12.0-next.5...@esri/calcite-components-angular@1.12.0-next.6) (2023-12-01) **Note:** Version bump only for package @esri/calcite-components-angular diff --git a/packages/calcite-components-angular/projects/component-library/package.json b/packages/calcite-components-angular/projects/component-library/package.json index ee27b1efee6..183158815ce 100644 --- a/packages/calcite-components-angular/projects/component-library/package.json +++ b/packages/calcite-components-angular/projects/component-library/package.json @@ -1,6 +1,6 @@ { "name": "@esri/calcite-components-angular", - "version": "1.12.0-next.6", + "version": "1.12.0-next.7", "sideEffects": false, "homepage": "https://developers.arcgis.com/calcite-design-system/", "description": "A set of Angular components that wrap Esri's Calcite Components.", @@ -20,7 +20,7 @@ "@angular/core": ">=16.0.0" }, "dependencies": { - "@esri/calcite-components": "^1.12.0-next.6", + "@esri/calcite-components": "^1.12.0-next.7", "tslib": "2.3.0" }, "lerna": { diff --git a/packages/calcite-components-react/CHANGELOG.md b/packages/calcite-components-react/CHANGELOG.md index fab8386b4c8..b64c3ef4043 100644 --- a/packages/calcite-components-react/CHANGELOG.md +++ b/packages/calcite-components-react/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.12.0-next.7](https://github.com/Esri/calcite-design-system/compare/@esri/calcite-components-react@1.12.0-next.6...@esri/calcite-components-react@1.12.0-next.7) (2023-12-01) + +**Note:** Version bump only for package @esri/calcite-components-react + ## [1.12.0-next.6](https://github.com/Esri/calcite-design-system/compare/@esri/calcite-components-react@1.12.0-next.5...@esri/calcite-components-react@1.12.0-next.6) (2023-12-01) **Note:** Version bump only for package @esri/calcite-components-react diff --git a/packages/calcite-components-react/package.json b/packages/calcite-components-react/package.json index c3fbb6566a1..54f668864d1 100644 --- a/packages/calcite-components-react/package.json +++ b/packages/calcite-components-react/package.json @@ -1,7 +1,7 @@ { "name": "@esri/calcite-components-react", "sideEffects": false, - "version": "1.12.0-next.6", + "version": "1.12.0-next.7", "homepage": "https://developers.arcgis.com/calcite-design-system/", "description": "A set of React components that wrap calcite components", "license": "SEE LICENSE.md", @@ -20,7 +20,7 @@ "dist/" ], "dependencies": { - "@esri/calcite-components": "^1.12.0-next.6" + "@esri/calcite-components": "^1.12.0-next.7" }, "peerDependencies": { "react": ">=16.7", diff --git a/packages/calcite-components/CHANGELOG.md b/packages/calcite-components/CHANGELOG.md index fea510e8eb1..c796368a29d 100644 --- a/packages/calcite-components/CHANGELOG.md +++ b/packages/calcite-components/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.12.0-next.7](https://github.com/Esri/calcite-design-system/compare/@esri/calcite-components@1.12.0-next.6...@esri/calcite-components@1.12.0-next.7) (2023-12-01) + +### Bug Fixes + +- **label:** associate label to component when `for` prop is set after initialization ([#8309](https://github.com/Esri/calcite-design-system/issues/8309)) ([e81b650](https://github.com/Esri/calcite-design-system/commit/e81b650ae66b413d19eb567c3766f813dfa322c7)), closes [#7364](https://github.com/Esri/calcite-design-system/issues/7364) + ## [1.12.0-next.6](https://github.com/Esri/calcite-design-system/compare/@esri/calcite-components@1.12.0-next.5...@esri/calcite-components@1.12.0-next.6) (2023-12-01) ### Features diff --git a/packages/calcite-components/package.json b/packages/calcite-components/package.json index a166f03c3c8..2703068d594 100644 --- a/packages/calcite-components/package.json +++ b/packages/calcite-components/package.json @@ -1,6 +1,6 @@ { "name": "@esri/calcite-components", - "version": "1.12.0-next.6", + "version": "1.12.0-next.7", "homepage": "https://developers.arcgis.com/calcite-design-system/", "description": "Web Components for Esri's Calcite Design System.", "main": "dist/index.cjs.js",