From 47532ee116fe752bc7cbe796492a2d6dfd7591ee Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 23 Apr 2026 22:10:21 +0900 Subject: [PATCH 1/2] refactor(multiple): simplify LabelControl and include in public api --- src/aria/private/behaviors/label/label.spec.ts | 10 +++++----- src/aria/private/behaviors/label/label.ts | 6 +++--- src/aria/private/public-api.ts | 1 + 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/aria/private/behaviors/label/label.spec.ts b/src/aria/private/behaviors/label/label.spec.ts index 6c7beef7c6c3..b5e941b012e9 100644 --- a/src/aria/private/behaviors/label/label.spec.ts +++ b/src/aria/private/behaviors/label/label.spec.ts @@ -12,7 +12,7 @@ import {LabelControl, LabelControlInputs, LabelControlOptionalInputs} from './la // 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[]; }>; @@ -30,7 +30,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 ?? []), }; @@ -65,20 +65,20 @@ describe('LabelControl', () => { it('should return user-provided labelledBy even if a label is provided', () => { const {control} = getLabelControl({ label: 'My Label', - defaultLabelledBy: ['default-id'], + defaultLabelledBy: 'default-id', labelledBy: ['user-id'], }); expect(control.labelledBy()).toEqual(['user-id']); }); it('should return defaultLabelledBy if no user-provided labelledBy exists', () => { - const {control} = getLabelControl({defaultLabelledBy: ['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']); inputs.label.set('A wild label appears'); diff --git a/src/aria/private/behaviors/label/label.ts b/src/aria/private/behaviors/label/label.ts index e8eb5a09f053..a23496876cd4 100644 --- a/src/aria/private/behaviors/label/label.ts +++ b/src/aria/private/behaviors/label/label.ts @@ -9,8 +9,8 @@ import {computed, SignalLike} from '../signal-like/signal-like'; /** Represents the required inputs for the label control. */ export interface LabelControlInputs { - /** The default `aria-labelledby` ids. */ - defaultLabelledBy: SignalLike; + /** The default `aria-labelledby` id. */ + defaultLabelledBy: SignalLike; } /** Represents the optional inputs for the label control. */ @@ -43,7 +43,7 @@ export class LabelControl { return []; } - return defaultLabelledBy; + return defaultLabelledBy ? [defaultLabelledBy] : []; }); constructor(readonly inputs: LabelControlInputs & LabelControlOptionalInputs) {} diff --git a/src/aria/private/public-api.ts b/src/aria/private/public-api.ts index 6f99a5798c14..f362bf4ff54f 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'; From 2f3b5d63dbc9180417a71a672af632be8ea15cbd Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 23 Apr 2026 22:16:51 +0900 Subject: [PATCH 2/2] feat(aria/accordion): add label inputs coordinated by LabelControl --- src/aria/accordion/accordion-panel.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/aria/accordion/accordion-panel.ts b/src/aria/accordion/accordion-panel.ts index 99e6e60b55dd..ff7afdf4009d 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 {DeferredContentAware, AccordionTriggerPattern, 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,16 @@ 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)); + readonly label = input(undefined); + + 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 +79,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: () => {