diff --git a/2nd-gen/packages/core/controllers/index.ts b/2nd-gen/packages/core/controllers/index.ts index 71efb81b073..fd9b34e7ee0 100644 --- a/2nd-gen/packages/core/controllers/index.ts +++ b/2nd-gen/packages/core/controllers/index.ts @@ -25,3 +25,10 @@ export { LanguageResolutionController, languageResolverUpdatedSymbol, } from './language-resolution.js'; +export { + hasNativeReferenceTarget, + ReferenceTargetController, + referenceTargetUpdatedSymbol, + type ForwardableAttribute, + type ReferenceTargetOptions, +} from './reference-target-controller/index.js'; diff --git a/2nd-gen/packages/core/controllers/reference-target-controller/index.ts b/2nd-gen/packages/core/controllers/reference-target-controller/index.ts new file mode 100644 index 00000000000..2ce64d762cf --- /dev/null +++ b/2nd-gen/packages/core/controllers/reference-target-controller/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export { + hasNativeReferenceTarget, + ReferenceTargetController, + referenceTargetUpdatedSymbol, + type ForwardableAttribute, + type ReferenceTargetOptions, +} from './src/reference-target-controller.js'; diff --git a/2nd-gen/packages/core/controllers/reference-target-controller/src/reference-target-controller.ts b/2nd-gen/packages/core/controllers/reference-target-controller/src/reference-target-controller.ts new file mode 100644 index 00000000000..efda7dbfeec --- /dev/null +++ b/2nd-gen/packages/core/controllers/reference-target-controller/src/reference-target-controller.ts @@ -0,0 +1,433 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { ReactiveController, ReactiveElement } from 'lit'; + +/** + * Symbol used as the `requestUpdate` change key when the controller + * syncs a new relationship onto the shadow target. Host elements can + * react to this in `willUpdate` or `updated`. + */ +export const referenceTargetUpdatedSymbol = Symbol('reference target updated'); + +/** + * ARIA relationship attributes that the shim can forward. Phase 1 of the + * native `referenceTarget` proposal covers all IDREF attributes; this POC + * focuses on the labelling and description subset that SWC components need. + */ +export type ForwardableAttribute = + | 'aria-labelledby' + | 'aria-describedby' + | 'aria-errormessage' + | 'aria-details'; + +/** + * Mapping from a forwarded attribute name to the materialized + * string-value attribute set on the shadow target when true cross-root + * IDREF resolution is impossible. + */ +const MATERIALIZED_ATTR: Record = { + 'aria-labelledby': 'aria-label', + 'aria-describedby': 'aria-description', + 'aria-errormessage': 'aria-description', + 'aria-details': 'aria-details', +}; + +export interface ReferenceTargetOptions { + /** + * The shadow-internal element that should act as the reference target. + * Accepts a CSS selector resolved against the host's shadow root, or + * a callback that returns the element directly. + */ + target: string | (() => HTMLElement | null); + + /** + * Attributes to forward from light-DOM referrers to the shadow target. + * Defaults to `['aria-labelledby', 'aria-describedby']`. + */ + forwardedAttributes?: ForwardableAttribute[]; +} + +/** + * Checks whether the browser natively supports `ShadowRoot.referenceTarget`. + */ +export function hasNativeReferenceTarget(): boolean { + return 'referenceTarget' in ShadowRoot.prototype; +} + +/** + * Reactive controller that approximates the behavior of the + * [Reference Target for Cross-Root ARIA](https://github.com/WICG/webcomponents/blob/gh-pages/proposals/reference-target-explainer.md) + * proposal. + * + * When native `referenceTarget` is unavailable, the controller observes + * light-DOM elements whose IDREF attributes point at the host and + * materializes equivalent accessible text or relationships on the + * shadow-internal target element. + * + * Lifecycle handled: connect, disconnect, host `id` changes, referrer + * attribute mutations, and referrer addition/removal from the DOM. + */ +export class ReferenceTargetController implements ReactiveController { + private host: ReactiveElement; + private options: Required; + + private lightDomObserver: MutationObserver | null = null; + private hostIdObserver: MutationObserver | null = null; + private connected = false; + + /** + * Tracks the last value written for each materialized attribute so + * the controller can detect true changes and avoid redundant DOM writes. + */ + private lastSynced = new Map(); + + constructor(host: ReactiveElement, options: ReferenceTargetOptions) { + this.host = host; + this.options = { + target: options.target, + forwardedAttributes: options.forwardedAttributes ?? [ + 'aria-labelledby', + 'aria-describedby', + ], + }; + this.host.addController(this); + } + + // ────────────────────────────────────────────────── + // Lifecycle + // ────────────────────────────────────────────────── + + hostConnected(): void { + this.connected = true; + + if (hasNativeReferenceTarget()) { + this.applyNative(); + return; + } + + this.observeHostId(); + this.observeLightDom(); + this.sync(); + } + + hostDisconnected(): void { + this.connected = false; + this.lightDomObserver?.disconnect(); + this.lightDomObserver = null; + this.hostIdObserver?.disconnect(); + this.hostIdObserver = null; + this.clearMaterialized(); + } + + hostUpdated(): void { + if (!this.connected) { + return; + } + + if (hasNativeReferenceTarget()) { + this.applyNative(); + return; + } + + this.sync(); + } + + // ────────────────────────────────────────────────── + // Native path + // ────────────────────────────────────────────────── + + private applyNative(): void { + const target = this.resolveTarget(); + if (!target) { + return; + } + + const shadowRoot = this.host.shadowRoot; + if (!shadowRoot) { + return; + } + + (shadowRoot as ShadowRoot & { referenceTarget: string }).referenceTarget = + target.id || ''; + } + + // ────────────────────────────────────────────────── + // Shim path + // ────────────────────────────────────────────────── + + /** + * The main synchronization routine. For each forwarded attribute, + * finds light-DOM elements referencing the host by ID, reads their + * text content, and materializes it on the shadow target. + */ + sync(): void { + const target = this.resolveTarget(); + if (!target) { + return; + } + + const hostId = this.host.id; + if (!hostId) { + return; + } + + const root = this.host.getRootNode() as Document | ShadowRoot; + + for (const attr of this.options.forwardedAttributes) { + const referrers = this.findReferrers(root, hostId, attr); + const text = this.collectText(referrers, attr); + const materializedAttr = MATERIALIZED_ATTR[attr]; + + const previous = this.lastSynced.get(attr); + if (text === previous) { + continue; + } + + if (text) { + target.setAttribute(materializedAttr, text); + } else { + target.removeAttribute(materializedAttr); + } + this.lastSynced.set(attr, text); + } + + this.host.requestUpdate(referenceTargetUpdatedSymbol, undefined); + } + + /** + * Finds all elements in the given root whose `attr` value includes + * `hostId` as one of the space-separated IDREF tokens. + */ + private findReferrers( + root: Document | ShadowRoot, + hostId: string, + attr: ForwardableAttribute + ): HTMLElement[] { + const candidates = root.querySelectorAll(`[${attr}]`); + const referrers: HTMLElement[] = []; + for (const el of candidates) { + const ids = el.getAttribute(attr)?.split(/\s+/) ?? []; + if (ids.includes(hostId)) { + referrers.push(el); + } + } + return referrers; + } + + /** + * For `aria-labelledby` and `aria-describedby`, collects text content + * from the full IDREF list on each referrer (not just the host token). + * This matches the spec behavior where `aria-labelledby="id1 id2"` + * concatenates the text of both referenced elements. + * + * For other attributes, collects only the text of the referrer itself. + */ + private collectText( + referrers: HTMLElement[], + attr: ForwardableAttribute + ): string { + if (referrers.length === 0) { + return ''; + } + + if (attr === 'aria-labelledby' || attr === 'aria-describedby') { + const texts: string[] = []; + for (const referrer of referrers) { + const text = this.resolveIdrefText(referrer, attr); + if (text) { + texts.push(text); + } + } + return texts.join(' '); + } + + return referrers + .map((el) => el.textContent?.trim() ?? '') + .filter(Boolean) + .join(' '); + } + + /** + * Given an element with an IDREF-list attribute (e.g. `aria-labelledby`), + * resolves the text content of each referenced ID element except the host + * itself (since the host's text is what we're computing *for*). + */ + private resolveIdrefText( + referrer: HTMLElement, + attr: ForwardableAttribute + ): string { + const root = referrer.getRootNode() as Document | ShadowRoot; + const ids = referrer.getAttribute(attr)?.split(/\s+/) ?? []; + const hostId = this.host.id; + const parts: string[] = []; + + for (const id of ids) { + if (id === hostId) { + continue; + } + const el = root.getElementById + ? root.getElementById(id) + : root.querySelector(`#${CSS.escape(id)}`); + if (el) { + const text = el.textContent?.trim(); + if (text) { + parts.push(text); + } + } + } + + if (parts.length === 0) { + const directText = referrer.textContent?.trim(); + if (directText) { + return directText; + } + } + + return parts.join(' '); + } + + /** + * For `