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