From 73fee5b45a463c3132550f504d6d0266ae332e6d Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Fri, 27 Jun 2025 22:18:53 +0000 Subject: [PATCH] feat(cdk-experimental/ui-patterns): add label control --- src/cdk-experimental/tabs/tabs.spec.ts | 6 + src/cdk-experimental/tabs/tabs.ts | 1 + .../ui-patterns/behaviors/label/BUILD | 31 ++++ .../ui-patterns/behaviors/label/label.spec.ts | 159 ++++++++++++++++++ .../ui-patterns/behaviors/label/label.ts | 85 ++++++++++ .../ui-patterns/tabs/BUILD.bazel | 1 + .../ui-patterns/tabs/tabs.spec.ts | 6 + src/cdk-experimental/ui-patterns/tabs/tabs.ts | 20 ++- 8 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 src/cdk-experimental/ui-patterns/behaviors/label/BUILD create mode 100644 src/cdk-experimental/ui-patterns/behaviors/label/label.spec.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/label/label.ts diff --git a/src/cdk-experimental/tabs/tabs.spec.ts b/src/cdk-experimental/tabs/tabs.spec.ts index 260d9da3f314..f90d8aba1e55 100644 --- a/src/cdk-experimental/tabs/tabs.spec.ts +++ b/src/cdk-experimental/tabs/tabs.spec.ts @@ -234,6 +234,12 @@ describe('CdkTabs', () => { expect(tabPanelElements[2].getAttribute('tabindex')).toBe('-1'); }); + it('should have aria-labelledby pointing to its tab id', () => { + expect(tabPanelElements[0].getAttribute('aria-labelledby')).toBe(tabElements[0].id); + expect(tabPanelElements[1].getAttribute('aria-labelledby')).toBe(tabElements[1].id); + expect(tabPanelElements[2].getAttribute('aria-labelledby')).toBe(tabElements[2].id); + }); + it('should have inert attribute when hidden and not when visible', () => { updateTabs({selectedTab: 'tab1'}); expect(tabPanelElements[0].hasAttribute('inert')).toBe(false); diff --git a/src/cdk-experimental/tabs/tabs.ts b/src/cdk-experimental/tabs/tabs.ts index 4f311f63a643..29c6fcd6a0cb 100644 --- a/src/cdk-experimental/tabs/tabs.ts +++ b/src/cdk-experimental/tabs/tabs.ts @@ -292,6 +292,7 @@ export class CdkTab implements HasElement, OnInit, OnDestroy { '[attr.id]': 'pattern.id()', '[attr.tabindex]': 'pattern.tabindex()', '[attr.inert]': 'pattern.hidden() ? true : null', + '[attr.aria-labelledby]': 'pattern.labelledBy()', }, hostDirectives: [ { diff --git a/src/cdk-experimental/ui-patterns/behaviors/label/BUILD b/src/cdk-experimental/ui-patterns/behaviors/label/BUILD new file mode 100644 index 000000000000..8d347dfd6d1d --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/label/BUILD @@ -0,0 +1,31 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "label", + srcs = [ + "label.ts", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "label.spec.ts", + ], + deps = [ + ":label", + "//:node_modules/@angular/core", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/behaviors/label/label.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/label/label.spec.ts new file mode 100644 index 000000000000..f2ae2e54ca2b --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/label/label.spec.ts @@ -0,0 +1,159 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {signal, WritableSignal} from '@angular/core'; +import {LabelControl, LabelControlInputs, LabelControlOptionalInputs} from './label'; + +// This is a helper type for the initial values passed to the setup function. +type TestInputs = Partial<{ + label: string | undefined; + defaultLabelledBy: string[]; + labelledBy: string[]; + labelledByAppend: boolean; + defaultDescribedBy: string[]; + describedBy: string[]; + describedByAppend: boolean; +}>; + +type TestLabelControlInputs = LabelControlInputs & Required; + +// This is a helper type to make all properties of LabelControlInputs writable signals. +type WritableLabelControlInputs = { + [K in keyof TestLabelControlInputs]: WritableSignal< + TestLabelControlInputs[K] extends {(): infer T} ? T : never + >; +}; + +function getLabelControl(initialValues: TestInputs = {}): { + control: LabelControl; + inputs: WritableLabelControlInputs; +} { + const inputs: WritableLabelControlInputs = { + defaultLabelledBy: signal(initialValues.defaultLabelledBy ?? []), + defaultDescribedBy: signal(initialValues.defaultDescribedBy ?? []), + label: signal(initialValues.label), + labelledBy: signal(initialValues.labelledBy ?? []), + labelledByAppend: signal(initialValues.labelledByAppend ?? false), + describedBy: signal(initialValues.describedBy ?? []), + describedByAppend: signal(initialValues.describedByAppend ?? false), + }; + + const control = new LabelControl(inputs); + + return {control, inputs}; +} + +describe('LabelControl', () => { + describe('#label', () => { + it('should return the user-provided label', () => { + const {control} = getLabelControl({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(); + }); + + it('should update when the input signal changes', () => { + const {control, inputs} = getLabelControl({label: 'Initial Label'}); + expect(control.label()).toBe('Initial Label'); + + inputs.label.set('Updated Label'); + expect(control.label()).toBe('Updated Label'); + }); + }); + + describe('#labelledBy', () => { + it('should return an empty array if a label is provided', () => { + const {control} = getLabelControl({ + label: 'My Label', + defaultLabelledBy: ['default-id'], + labelledBy: ['user-id'], + }); + expect(control.labelledBy()).toEqual([]); + }); + + it('should return defaultLabelledBy if no user-provided labelledBy exists', () => { + const {control} = getLabelControl({defaultLabelledBy: ['default-id']}); + expect(control.labelledBy()).toEqual(['default-id']); + }); + + it('should return only user-provided labelledBy if labelledByAppend is false', () => { + const {control} = getLabelControl({ + defaultLabelledBy: ['default-id'], + labelledBy: ['user-id'], + labelledByAppend: false, + }); + expect(control.labelledBy()).toEqual(['user-id']); + }); + + it('should return default and user-provided labelledBy if labelledByAppend is true', () => { + const {control} = getLabelControl({ + defaultLabelledBy: ['default-id'], + labelledBy: ['user-id'], + labelledByAppend: true, + }); + expect(control.labelledBy()).toEqual(['default-id', 'user-id']); + }); + + it('should update when label changes from undefined to a string', () => { + const {control, inputs} = getLabelControl({ + defaultLabelledBy: ['default-id'], + }); + expect(control.labelledBy()).toEqual(['default-id']); + inputs.label.set('A wild label appears'); + expect(control.labelledBy()).toEqual([]); + }); + }); + + describe('#describedBy', () => { + it('should return defaultDescribedBy if no user-provided describedBy exists', () => { + const {control} = getLabelControl({defaultDescribedBy: ['default-id']}); + expect(control.describedBy()).toEqual(['default-id']); + }); + + it('should return only user-provided describedBy if describedByAppend is false', () => { + const {control} = getLabelControl({ + defaultDescribedBy: ['default-id'], + describedBy: ['user-id'], + describedByAppend: false, + }); + expect(control.describedBy()).toEqual(['user-id']); + }); + + it('should return default and user-provided describedBy if describedByAppend is true', () => { + const {control} = getLabelControl({ + defaultDescribedBy: ['default-id'], + describedBy: ['user-id'], + describedByAppend: true, + }); + expect(control.describedBy()).toEqual(['default-id', 'user-id']); + }); + + it('should update when describedByAppend changes', () => { + const {control, inputs} = getLabelControl({ + defaultDescribedBy: ['default-id'], + describedBy: ['user-id'], + describedByAppend: false, + }); + expect(control.describedBy()).toEqual(['user-id']); + inputs.describedByAppend.set(true); + expect(control.describedBy()).toEqual(['default-id', 'user-id']); + }); + + it('should not be affected by the label property', () => { + const {control, inputs} = getLabelControl({ + defaultDescribedBy: ['default-id'], + }); + expect(control.describedBy()).toEqual(['default-id']); + inputs.label.set('A wild label appears'); + expect(control.describedBy()).toEqual(['default-id']); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/behaviors/label/label.ts b/src/cdk-experimental/ui-patterns/behaviors/label/label.ts new file mode 100644 index 000000000000..5a2504f5c0e1 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/label/label.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ +import {computed} from '@angular/core'; +import {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-describedby` ids. */ + defaultDescribedBy: SignalLike; +} + +/** Represents the optional inputs for the label control. */ +export interface LabelControlOptionalInputs { + /** The `aria-label`. */ + label?: SignalLike; + + /** The user-provided `aria-labelledby` ids. */ + labelledBy?: SignalLike; + + /** Whether the user-provided `aria-labelledby` should be appended to the default. */ + labelledByAppend?: SignalLike; + + /** The user-provided `aria-describedby` ids. */ + describedBy?: SignalLike; + + /** Whether the user-provided `aria-describedby` should be appended to the default. */ + describedByAppend?: SignalLike; +} + +/** Controls label and description of an element. */ +export class LabelControl { + /** The `aria-label`. */ + readonly label = computed(() => this.inputs.label?.()); + + /** The `aria-labelledby` ids. */ + readonly labelledBy = computed(() => { + // If an aria-label is provided by developers, do not set aria-labelledby because + // if both attributes are set, aria-labelledby will be used. + const label = this.label(); + if (label) { + return []; + } + + const defaultLabelledBy = this.inputs.defaultLabelledBy(); + const labelledBy = this.inputs.labelledBy?.(); + const labelledByAppend = this.inputs.labelledByAppend?.(); + + if (!labelledBy || labelledBy.length === 0) { + return defaultLabelledBy; + } + + if (labelledByAppend) { + return [...defaultLabelledBy, ...labelledBy]; + } + + return labelledBy; + }); + + /** The `aria-describedby` ids. */ + readonly describedBy = computed(() => { + const defaultDescribedBy = this.inputs.defaultDescribedBy(); + const describedBy = this.inputs.describedBy?.(); + const describedByAppend = this.inputs.describedByAppend?.(); + + if (!describedBy || describedBy.length === 0) { + return defaultDescribedBy; + } + + if (describedByAppend) { + return [...defaultDescribedBy, ...describedBy]; + } + + return describedBy; + }); + + constructor(readonly inputs: LabelControlInputs & LabelControlOptionalInputs) {} +} diff --git a/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel b/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel index c404b9c384c7..59c55914fc92 100644 --- a/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel @@ -11,6 +11,7 @@ ts_project( "//:node_modules/@angular/core", "//src/cdk-experimental/ui-patterns/behaviors/event-manager", "//src/cdk-experimental/ui-patterns/behaviors/expansion", + "//src/cdk-experimental/ui-patterns/behaviors/label", "//src/cdk-experimental/ui-patterns/behaviors/list-focus", "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", "//src/cdk-experimental/ui-patterns/behaviors/list-selection", diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts index 0c72fbd2b38f..92cef42f6ab1 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts @@ -167,6 +167,12 @@ describe('Tabs Pattern', () => { expect(tabPatterns[2].tabindex()).toBe(-1); }); + it('should set a tabpanel aria-labelledby pointing to its tab id.', () => { + expect(tabPanelPatterns[0].labelledBy()).toBe('tab-1-id'); + expect(tabPanelPatterns[1].labelledBy()).toBe('tab-2-id'); + expect(tabPanelPatterns[2].labelledBy()).toBe('tab-3-id'); + }); + it('gets a controlled tabpanel id from a tab.', () => { expect(tabPanelPatterns[0].id()).toBe('tabpanel-1-id'); expect(tabPatterns[0].controls()).toBe('tabpanel-1-id'); diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.ts index fd9b98d4a20d..7d586e1fc4ed 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed} from '@angular/core'; +import {computed, signal} from '@angular/core'; import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager'; import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus'; @@ -27,6 +27,7 @@ import { ListExpansion, } from '../behaviors/expansion/expansion'; import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {LabelControl, LabelControlOptionalInputs} from '../behaviors/label/label'; /** The required inputs to tabs. */ export interface TabInputs @@ -96,7 +97,7 @@ export class TabPattern { } /** The required inputs for the tabpanel. */ -export interface TabPanelInputs { +export interface TabPanelInputs extends LabelControlOptionalInputs { id: SignalLike; tab: SignalLike; value: SignalLike; @@ -110,15 +111,30 @@ export class TabPanelPattern { /** A local unique identifier for the tabpanel. */ readonly value: SignalLike; + /** Controls label for this tabpanel. */ + readonly labelManager: LabelControl; + /** Whether the tabpanel is hidden. */ readonly hidden = computed(() => this.inputs.tab()?.expanded() === false); /** The tabindex of this tabpanel. */ readonly tabindex = computed(() => (this.hidden() ? -1 : 0)); + /** The aria-labelledby value for this tabpanel. */ + readonly labelledBy = computed(() => + this.labelManager.labelledBy().length > 0 + ? this.labelManager.labelledBy().join(' ') + : undefined, + ); + constructor(readonly inputs: TabPanelInputs) { this.id = inputs.id; this.value = inputs.value; + this.labelManager = new LabelControl({ + ...inputs, + defaultLabelledBy: computed(() => (this.inputs.tab() ? [this.inputs.tab()!.id()] : [])), + defaultDescribedBy: signal([]), + }); } }