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; + } + } + }); +}