From 0056e6e18c8a989e75280c8c1ffcb66d2ebc9205 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 23 Apr 2026 22:10:21 +0900 Subject: [PATCH 1/3] refactor(multiple): simplify LabelControl and include in public api --- goldens/aria/private/index.api.md | 16 +++++++ src/aria/private/BUILD.bazel | 1 + .../private/behaviors/label/label.spec.ts | 35 ++++++++------- src/aria/private/behaviors/label/label.ts | 44 +++++++++---------- src/aria/private/public-api.ts | 1 + src/aria/private/tabs/BUILD.bazel | 1 - 6 files changed, 58 insertions(+), 40 deletions(-) diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index e4ecbf2b9529..2742f8247ee8 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -429,6 +429,22 @@ export interface HasElement { element: HTMLElement; } +// @public +export class LabelControl { + constructor(inputs: LabelControlInputs); + // (undocumented) + readonly inputs: LabelControlInputs; + readonly label: SignalLike; + readonly labelledBy: SignalLike; +} + +// @public +export interface LabelControlInputs { + defaultLabelledBy: SignalLike; + label: SignalLike; + labelledBy: SignalLike; +} + // @public (undocumented) export function linkedSignal(sourceFn: () => T): WritableSignalLike; diff --git a/src/aria/private/BUILD.bazel b/src/aria/private/BUILD.bazel index 3597476d8aa4..7eda2ae51bbb 100644 --- a/src/aria/private/BUILD.bazel +++ b/src/aria/private/BUILD.bazel @@ -18,6 +18,7 @@ ts_project( deps = [ "//:node_modules/@angular/core", "//src/aria/private/accordion", + "//src/aria/private/behaviors/label", "//src/aria/private/behaviors/signal-like", "//src/aria/private/combobox", "//src/aria/private/deferred-content", diff --git a/src/aria/private/behaviors/label/label.spec.ts b/src/aria/private/behaviors/label/label.spec.ts index 6c7beef7c6c3..d7bfa6ceaa8f 100644 --- a/src/aria/private/behaviors/label/label.spec.ts +++ b/src/aria/private/behaviors/label/label.spec.ts @@ -7,21 +7,19 @@ */ import {signal, WritableSignalLike} from '../signal-like/signal-like'; -import {LabelControl, LabelControlInputs, LabelControlOptionalInputs} from './label'; +import {LabelControl, LabelControlInputs} from './label'; // This is a helper type for the initial values passed to the setup function. type TestInputs = Partial<{ label: string | undefined; - defaultLabelledBy: string[]; + defaultLabelledBy: string; labelledBy: string[]; }>; -type TestLabelControlInputs = LabelControlInputs & Required; - // This is a helper type to make all properties of LabelControlInputs writable signals. type WritableLabelControlInputs = { - [K in keyof TestLabelControlInputs]: WritableSignalLike< - TestLabelControlInputs[K] extends {(): infer T} ? T : never + [K in keyof LabelControlInputs]: WritableSignalLike< + LabelControlInputs[K] extends {(): infer T} ? T : never >; }; @@ -30,7 +28,7 @@ function getLabelControl(initialValues: TestInputs = {}): { inputs: WritableLabelControlInputs; } { const inputs: WritableLabelControlInputs = { - defaultLabelledBy: signal(initialValues.defaultLabelledBy ?? []), + defaultLabelledBy: signal(initialValues.defaultLabelledBy), label: signal(initialValues.label), labelledBy: signal(initialValues.labelledBy ?? []), }; @@ -47,6 +45,11 @@ describe('LabelControl', () => { expect(control.label()).toBe('My Label'); }); + it('should return the user-provided label even ifdefaultLabelledBy is provided ', () => { + const {control} = getLabelControl({defaultLabelledBy: 'default-id', label: 'My Label'}); + expect(control.label()).toBe('My Label'); + }); + it('should return undefined if no label is provided', () => { const {control} = getLabelControl(); expect(control.label()).toBeUndefined(); @@ -62,27 +65,27 @@ describe('LabelControl', () => { }); describe('#labelledBy', () => { - it('should return user-provided labelledBy even if a label is provided', () => { + it('should return user-provided labelledBy ids even if a label is provided', () => { const {control} = getLabelControl({ label: 'My Label', - defaultLabelledBy: ['default-id'], - labelledBy: ['user-id'], + defaultLabelledBy: 'default-id', + labelledBy: ['user-id', 'user-id-2'], }); - expect(control.labelledBy()).toEqual(['user-id']); + expect(control.labelledBy()).toEqual('user-id user-id-2'); }); it('should return defaultLabelledBy if no user-provided labelledBy exists', () => { - const {control} = getLabelControl({defaultLabelledBy: ['default-id']}); - expect(control.labelledBy()).toEqual(['default-id']); + const {control} = getLabelControl({defaultLabelledBy: 'default-id'}); + expect(control.labelledBy()).toEqual('default-id'); }); it('should update when label changes from undefined to a string', () => { const {control, inputs} = getLabelControl({ - defaultLabelledBy: ['default-id'], + defaultLabelledBy: 'default-id', }); - expect(control.labelledBy()).toEqual(['default-id']); + expect(control.labelledBy()).toEqual('default-id'); inputs.label.set('A wild label appears'); - expect(control.labelledBy()).toEqual([]); + expect(control.labelledBy()).toEqual(undefined); }); }); }); diff --git a/src/aria/private/behaviors/label/label.ts b/src/aria/private/behaviors/label/label.ts index e8eb5a09f053..5abfe47a2074 100644 --- a/src/aria/private/behaviors/label/label.ts +++ b/src/aria/private/behaviors/label/label.ts @@ -7,44 +7,42 @@ */ import {computed, SignalLike} from '../signal-like/signal-like'; -/** Represents the required inputs for the label control. */ +/** The required inputs for the label control. */ export interface LabelControlInputs { - /** The default `aria-labelledby` ids. */ - defaultLabelledBy: SignalLike; -} + /** The default `aria-labelledby` id to use if no other inputs specified. */ + defaultLabelledBy: SignalLike; -/** Represents the optional inputs for the label control. */ -export interface LabelControlOptionalInputs { - /** The `aria-label`. */ - label?: SignalLike; + /** The `aria-label` to use instead of the default id. */ + label: SignalLike; - /** The user-provided `aria-labelledby` ids. */ - labelledBy?: SignalLike; + /** The `aria-labelledby` id(s) to use instead of the default id (or label). */ + labelledBy: SignalLike; } -/** Controls label and description of an element. */ +/** Controls label for an element. */ export class LabelControl { - /** The `aria-label`. */ - readonly label = computed(() => this.inputs.label?.()); + /** Use this value to set the `aria-label` attribute on the element. */ + readonly label = computed(() => this.inputs.label()); - /** The `aria-labelledby` ids. */ + /** Use this value to set the `aria-labelledby` attribute on the element. */ readonly labelledBy = computed(() => { - const label = this.label(); - const labelledBy = this.inputs.labelledBy?.(); const defaultLabelledBy = this.inputs.defaultLabelledBy(); + const label = this.label(); + const labelledBy = this.inputs.labelledBy(); + // Always use any specified labelledby ids. if (labelledBy && labelledBy.length > 0) { - return labelledBy; + return labelledBy.join(' '); } - // If an aria-label is provided by developers, do not set aria-labelledby with the - // defaultLabelledBy value because if both attributes are set, aria-labelledby will be used. - if (label) { - return []; + // If an aria-label is provided, do not set aria-labelledby with the defaultLabelledBy value + // because if both attributes are set, aria-labelledby will be used. + if (!label && defaultLabelledBy) { + return defaultLabelledBy; } - return defaultLabelledBy; + return undefined; }); - constructor(readonly inputs: LabelControlInputs & LabelControlOptionalInputs) {} + constructor(readonly inputs: LabelControlInputs) {} } diff --git a/src/aria/private/public-api.ts b/src/aria/private/public-api.ts index a5f1285bc1e8..393201ac20f1 100644 --- a/src/aria/private/public-api.ts +++ b/src/aria/private/public-api.ts @@ -11,6 +11,7 @@ export * from './listbox/listbox'; export * from './listbox/option'; export * from './listbox/combobox-listbox'; export * from './menu/menu'; +export * from './behaviors/label/label'; export * from './behaviors/signal-like/signal-like'; export * from './tabs/tabs'; export * from './toolbar/toolbar'; diff --git a/src/aria/private/tabs/BUILD.bazel b/src/aria/private/tabs/BUILD.bazel index 891074f610e6..ecb65241f155 100644 --- a/src/aria/private/tabs/BUILD.bazel +++ b/src/aria/private/tabs/BUILD.bazel @@ -11,7 +11,6 @@ ts_project( "//:node_modules/@angular/core", "//src/aria/private/behaviors/event-manager", "//src/aria/private/behaviors/expansion", - "//src/aria/private/behaviors/label", "//src/aria/private/behaviors/list-focus", "//src/aria/private/behaviors/list-navigation", "//src/aria/private/behaviors/signal-like", From 6c37c5ae5784dfea3d2e321abe3f1ea31acdf170 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 23 Apr 2026 22:16:51 +0900 Subject: [PATCH 2/3] feat(aria/accordion): add label inputs coordinated by LabelControl --- goldens/aria/accordion/index.api.md | 5 ++++- src/aria/accordion/accordion-panel.ts | 20 ++++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/goldens/aria/accordion/index.api.md b/goldens/aria/accordion/index.api.md index 3378a901b755..9bfaea45cac0 100644 --- a/goldens/aria/accordion/index.api.md +++ b/goldens/aria/accordion/index.api.md @@ -43,11 +43,14 @@ export class AccordionPanel { readonly element: HTMLElement; expand(): void; readonly id: _angular_core.InputSignal; + readonly label: _angular_core.InputSignal; + readonly _labelControl: LabelControl; + readonly labelledBy: _angular_core.InputSignal; _pattern?: AccordionTriggerPattern; toggle(): void; readonly visible: _angular_core.Signal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/src/aria/accordion/accordion-panel.ts b/src/aria/accordion/accordion-panel.ts index 99e6e60b55dd..ef835a7954d1 100644 --- a/src/aria/accordion/accordion-panel.ts +++ b/src/aria/accordion/accordion-panel.ts @@ -8,7 +8,7 @@ import {Directive, ElementRef, afterRenderEffect, computed, inject, input} from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; -import {DeferredContentAware, AccordionTriggerPattern} from '../private'; +import {AccordionTriggerPattern, DeferredContentAware, LabelControl} from '../private'; /** * The content panel of an accordion item that is conditionally visible. @@ -43,7 +43,8 @@ import {DeferredContentAware, AccordionTriggerPattern} from '../private'; host: { 'role': 'region', '[attr.id]': 'id()', - '[attr.aria-labelledby]': '_pattern?.id()', + '[attr.aria-label]': '_labelControl.label()', + '[attr.aria-labelledby]': '_labelControl.labelledBy()', '[attr.inert]': '!visible() ? true : null', }, }) @@ -57,9 +58,18 @@ export class AccordionPanel { /** The DeferredContentAware host directive. */ private readonly _deferredContentAware = inject(DeferredContentAware); + /** Controls label for this tabpanel. */ + readonly _labelControl: LabelControl; + /** A global unique identifier for the panel. */ readonly id = input(inject(_IdGenerator).getId('ng-accordion-panel-', true)); + /** The (optional) label for the accordion panel. */ + readonly label = input(undefined); + + /** The (optional) labelledBy ids for the accordion panel. */ + readonly labelledBy = input([]); + /** Whether the accordion panel is visible. True if the associated trigger is expanded. */ readonly visible = computed(() => this._pattern?.expanded() === true); @@ -71,6 +81,12 @@ export class AccordionPanel { _pattern?: AccordionTriggerPattern; constructor() { + this._labelControl = new LabelControl({ + defaultLabelledBy: computed(() => this._pattern?.id()), + label: this.label, + labelledBy: this.labelledBy, + }); + // Connect the panel's hidden state to the DeferredContentAware's visibility. afterRenderEffect({ write: () => { From a8674294eed74222021838fd5b0454029a63a973 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Mon, 27 Apr 2026 18:52:22 -0700 Subject: [PATCH 3/3] feat(aria/tabs): add label inputs coordinated by LabelControl --- goldens/aria/tabs/index.api.md | 5 ++++- src/aria/tabs/tab-panel.ts | 20 ++++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/goldens/aria/tabs/index.api.md b/goldens/aria/tabs/index.api.md index 9cb495531bf1..760eb1714eb8 100644 --- a/goldens/aria/tabs/index.api.md +++ b/goldens/aria/tabs/index.api.md @@ -75,6 +75,9 @@ export class TabPanel implements OnInit, OnDestroy { constructor(); readonly element: HTMLElement; readonly id: _angular_core.InputSignal; + readonly label: _angular_core.InputSignal; + readonly _labelControl: LabelControl; + readonly labelledBy: _angular_core.InputSignal; // (undocumented) ngOnDestroy(): void; // (undocumented) @@ -84,7 +87,7 @@ export class TabPanel implements OnInit, OnDestroy { readonly value: _angular_core.InputSignal; readonly visible: _angular_core.Signal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/src/aria/tabs/tab-panel.ts b/src/aria/tabs/tab-panel.ts index 418b2ec49f04..c318dea34f64 100644 --- a/src/aria/tabs/tab-panel.ts +++ b/src/aria/tabs/tab-panel.ts @@ -19,7 +19,7 @@ import { input, signal, } from '@angular/core'; -import {TabPattern, TabPanelPattern, DeferredContentAware} from '../private'; +import {DeferredContentAware, LabelControl, TabPattern, TabPanelPattern} from '../private'; import {TABS} from './tab-tokens'; /** @@ -49,7 +49,8 @@ import {TABS} from './tab-tokens'; '[attr.id]': '_pattern.id()', '[attr.tabindex]': '_pattern.tabIndex()', '[attr.inert]': '!visible() ? true : null', - '[attr.aria-labelledby]': '_pattern.labelledBy()', + '[attr.aria-label]': '_labelControl.label()', + '[attr.aria-labelledby]': '_labelControl.labelledBy()', }, hostDirectives: [ { @@ -71,9 +72,18 @@ export class TabPanel implements OnInit, OnDestroy { /** The parent Tabs. */ private readonly _tabs = inject(TABS); + /** Controls label for this tabpanel. */ + readonly _labelControl: LabelControl; + /** A global unique identifier for the tab. */ readonly id = input(inject(_IdGenerator).getId('ng-tabpanel-', true)); + /** The (optional) label for the accordion panel. */ + readonly label = input(undefined); + + /** The (optional) labelledBy ids for the accordion panel. */ + readonly labelledBy = input([]); + /** The Tab UIPattern associated with the tabpanel */ readonly _tabPattern: WritableSignal = signal(undefined); @@ -90,6 +100,12 @@ export class TabPanel implements OnInit, OnDestroy { }); constructor() { + this._labelControl = new LabelControl({ + defaultLabelledBy: computed(() => this._pattern?.labelledBy()), + label: this.label, + labelledBy: this.labelledBy, + }); + afterRenderEffect({ write: () => { this._deferredContentAware.contentVisible.set(this.visible());