Skip to content

Commit 50a6d6c

Browse files
authored
feat(cdk-experimental/ui-patterns): add label control (#31459)
1 parent 2dbb045 commit 50a6d6c

File tree

8 files changed

+200
-1
lines changed

8 files changed

+200
-1
lines changed

src/cdk-experimental/tabs/tabs.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,12 @@ describe('CdkTabs', () => {
234234
expect(tabPanelElements[2].getAttribute('tabindex')).toBe('-1');
235235
});
236236

237+
it('should have aria-labelledby pointing to its tab id', () => {
238+
expect(tabPanelElements[0].getAttribute('aria-labelledby')).toBe(tabElements[0].id);
239+
expect(tabPanelElements[1].getAttribute('aria-labelledby')).toBe(tabElements[1].id);
240+
expect(tabPanelElements[2].getAttribute('aria-labelledby')).toBe(tabElements[2].id);
241+
});
242+
237243
it('should have inert attribute when hidden and not when visible', () => {
238244
updateTabs({selectedTab: 'tab1'});
239245
expect(tabPanelElements[0].hasAttribute('inert')).toBe(false);

src/cdk-experimental/tabs/tabs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ export class CdkTab implements HasElement, OnInit, OnDestroy {
292292
'[attr.id]': 'pattern.id()',
293293
'[attr.tabindex]': 'pattern.tabindex()',
294294
'[attr.inert]': 'pattern.hidden() ? true : null',
295+
'[attr.aria-labelledby]': 'pattern.labelledBy()',
295296
},
296297
hostDirectives: [
297298
{
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_project(
6+
name = "label",
7+
srcs = [
8+
"label.ts",
9+
],
10+
deps = [
11+
"//:node_modules/@angular/core",
12+
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
13+
],
14+
)
15+
16+
ts_project(
17+
name = "unit_test_sources",
18+
testonly = True,
19+
srcs = [
20+
"label.spec.ts",
21+
],
22+
deps = [
23+
":label",
24+
"//:node_modules/@angular/core",
25+
],
26+
)
27+
28+
ng_web_test_suite(
29+
name = "unit_tests",
30+
deps = [":unit_test_sources"],
31+
)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {signal, WritableSignal} from '@angular/core';
10+
import {LabelControl, LabelControlInputs, LabelControlOptionalInputs} from './label';
11+
12+
// This is a helper type for the initial values passed to the setup function.
13+
type TestInputs = Partial<{
14+
label: string | undefined;
15+
defaultLabelledBy: string[];
16+
labelledBy: string[];
17+
}>;
18+
19+
type TestLabelControlInputs = LabelControlInputs & Required<LabelControlOptionalInputs>;
20+
21+
// This is a helper type to make all properties of LabelControlInputs writable signals.
22+
type WritableLabelControlInputs = {
23+
[K in keyof TestLabelControlInputs]: WritableSignal<
24+
TestLabelControlInputs[K] extends {(): infer T} ? T : never
25+
>;
26+
};
27+
28+
function getLabelControl(initialValues: TestInputs = {}): {
29+
control: LabelControl;
30+
inputs: WritableLabelControlInputs;
31+
} {
32+
const inputs: WritableLabelControlInputs = {
33+
defaultLabelledBy: signal(initialValues.defaultLabelledBy ?? []),
34+
label: signal(initialValues.label),
35+
labelledBy: signal(initialValues.labelledBy ?? []),
36+
};
37+
38+
const control = new LabelControl(inputs);
39+
40+
return {control, inputs};
41+
}
42+
43+
describe('LabelControl', () => {
44+
describe('#label', () => {
45+
it('should return the user-provided label', () => {
46+
const {control} = getLabelControl({label: 'My Label'});
47+
expect(control.label()).toBe('My Label');
48+
});
49+
50+
it('should return undefined if no label is provided', () => {
51+
const {control} = getLabelControl();
52+
expect(control.label()).toBeUndefined();
53+
});
54+
55+
it('should update when the input signal changes', () => {
56+
const {control, inputs} = getLabelControl({label: 'Initial Label'});
57+
expect(control.label()).toBe('Initial Label');
58+
59+
inputs.label.set('Updated Label');
60+
expect(control.label()).toBe('Updated Label');
61+
});
62+
});
63+
64+
describe('#labelledBy', () => {
65+
it('should return user-provided labelledBy even if a label is provided', () => {
66+
const {control} = getLabelControl({
67+
label: 'My Label',
68+
defaultLabelledBy: ['default-id'],
69+
labelledBy: ['user-id'],
70+
});
71+
expect(control.labelledBy()).toEqual(['user-id']);
72+
});
73+
74+
it('should return defaultLabelledBy if no user-provided labelledBy exists', () => {
75+
const {control} = getLabelControl({defaultLabelledBy: ['default-id']});
76+
expect(control.labelledBy()).toEqual(['default-id']);
77+
});
78+
79+
it('should update when label changes from undefined to a string', () => {
80+
const {control, inputs} = getLabelControl({
81+
defaultLabelledBy: ['default-id'],
82+
});
83+
expect(control.labelledBy()).toEqual(['default-id']);
84+
inputs.label.set('A wild label appears');
85+
expect(control.labelledBy()).toEqual([]);
86+
});
87+
});
88+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
import {computed} from '@angular/core';
9+
import {SignalLike} from '../signal-like/signal-like';
10+
11+
/** Represents the required inputs for the label control. */
12+
export interface LabelControlInputs {
13+
/** The default `aria-labelledby` ids. */
14+
defaultLabelledBy: SignalLike<string[]>;
15+
}
16+
17+
/** Represents the optional inputs for the label control. */
18+
export interface LabelControlOptionalInputs {
19+
/** The `aria-label`. */
20+
label?: SignalLike<string | undefined>;
21+
22+
/** The user-provided `aria-labelledby` ids. */
23+
labelledBy?: SignalLike<string[]>;
24+
}
25+
26+
/** Controls label and description of an element. */
27+
export class LabelControl {
28+
/** The `aria-label`. */
29+
readonly label = computed(() => this.inputs.label?.());
30+
31+
/** The `aria-labelledby` ids. */
32+
readonly labelledBy = computed(() => {
33+
const label = this.label();
34+
const labelledBy = this.inputs.labelledBy?.();
35+
const defaultLabelledBy = this.inputs.defaultLabelledBy();
36+
37+
if (labelledBy && labelledBy.length > 0) {
38+
return labelledBy;
39+
}
40+
41+
// If an aria-label is provided by developers, do not set aria-labelledby with the
42+
// defaultLabelledBy value because if both attributes are set, aria-labelledby will be used.
43+
if (label) {
44+
return [];
45+
}
46+
47+
return defaultLabelledBy;
48+
});
49+
50+
constructor(readonly inputs: LabelControlInputs & LabelControlOptionalInputs) {}
51+
}

src/cdk-experimental/ui-patterns/tabs/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ ts_project(
1111
"//:node_modules/@angular/core",
1212
"//src/cdk-experimental/ui-patterns/behaviors/event-manager",
1313
"//src/cdk-experimental/ui-patterns/behaviors/expansion",
14+
"//src/cdk-experimental/ui-patterns/behaviors/label",
1415
"//src/cdk-experimental/ui-patterns/behaviors/list-focus",
1516
"//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
1617
"//src/cdk-experimental/ui-patterns/behaviors/list-selection",

src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,12 @@ describe('Tabs Pattern', () => {
167167
expect(tabPatterns[2].tabindex()).toBe(-1);
168168
});
169169

170+
it('should set a tabpanel aria-labelledby pointing to its tab id.', () => {
171+
expect(tabPanelPatterns[0].labelledBy()).toBe('tab-1-id');
172+
expect(tabPanelPatterns[1].labelledBy()).toBe('tab-2-id');
173+
expect(tabPanelPatterns[2].labelledBy()).toBe('tab-3-id');
174+
});
175+
170176
it('gets a controlled tabpanel id from a tab.', () => {
171177
expect(tabPanelPatterns[0].id()).toBe('tabpanel-1-id');
172178
expect(tabPatterns[0].controls()).toBe('tabpanel-1-id');

src/cdk-experimental/ui-patterns/tabs/tabs.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
ListExpansion,
2828
} from '../behaviors/expansion/expansion';
2929
import {SignalLike} from '../behaviors/signal-like/signal-like';
30+
import {LabelControl, LabelControlOptionalInputs} from '../behaviors/label/label';
3031

3132
/** The required inputs to tabs. */
3233
export interface TabInputs
@@ -96,7 +97,7 @@ export class TabPattern {
9697
}
9798

9899
/** The required inputs for the tabpanel. */
99-
export interface TabPanelInputs {
100+
export interface TabPanelInputs extends LabelControlOptionalInputs {
100101
id: SignalLike<string>;
101102
tab: SignalLike<TabPattern | undefined>;
102103
value: SignalLike<string>;
@@ -110,15 +111,29 @@ export class TabPanelPattern {
110111
/** A local unique identifier for the tabpanel. */
111112
readonly value: SignalLike<string>;
112113

114+
/** Controls label for this tabpanel. */
115+
readonly labelManager: LabelControl;
116+
113117
/** Whether the tabpanel is hidden. */
114118
readonly hidden = computed(() => this.inputs.tab()?.expanded() === false);
115119

116120
/** The tabindex of this tabpanel. */
117121
readonly tabindex = computed(() => (this.hidden() ? -1 : 0));
118122

123+
/** The aria-labelledby value for this tabpanel. */
124+
readonly labelledBy = computed(() =>
125+
this.labelManager.labelledBy().length > 0
126+
? this.labelManager.labelledBy().join(' ')
127+
: undefined,
128+
);
129+
119130
constructor(readonly inputs: TabPanelInputs) {
120131
this.id = inputs.id;
121132
this.value = inputs.value;
133+
this.labelManager = new LabelControl({
134+
...inputs,
135+
defaultLabelledBy: computed(() => (this.inputs.tab() ? [this.inputs.tab()!.id()] : [])),
136+
});
122137
}
123138
}
124139

0 commit comments

Comments
 (0)