Skip to content

Commit 73fee5b

Browse files
committed
feat(cdk-experimental/ui-patterns): add label control
1 parent 464adf0 commit 73fee5b

File tree

8 files changed

+307
-2
lines changed

8 files changed

+307
-2
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: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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+
labelledByAppend: boolean;
18+
defaultDescribedBy: string[];
19+
describedBy: string[];
20+
describedByAppend: boolean;
21+
}>;
22+
23+
type TestLabelControlInputs = LabelControlInputs & Required<LabelControlOptionalInputs>;
24+
25+
// This is a helper type to make all properties of LabelControlInputs writable signals.
26+
type WritableLabelControlInputs = {
27+
[K in keyof TestLabelControlInputs]: WritableSignal<
28+
TestLabelControlInputs[K] extends {(): infer T} ? T : never
29+
>;
30+
};
31+
32+
function getLabelControl(initialValues: TestInputs = {}): {
33+
control: LabelControl;
34+
inputs: WritableLabelControlInputs;
35+
} {
36+
const inputs: WritableLabelControlInputs = {
37+
defaultLabelledBy: signal(initialValues.defaultLabelledBy ?? []),
38+
defaultDescribedBy: signal(initialValues.defaultDescribedBy ?? []),
39+
label: signal(initialValues.label),
40+
labelledBy: signal(initialValues.labelledBy ?? []),
41+
labelledByAppend: signal(initialValues.labelledByAppend ?? false),
42+
describedBy: signal(initialValues.describedBy ?? []),
43+
describedByAppend: signal(initialValues.describedByAppend ?? false),
44+
};
45+
46+
const control = new LabelControl(inputs);
47+
48+
return {control, inputs};
49+
}
50+
51+
describe('LabelControl', () => {
52+
describe('#label', () => {
53+
it('should return the user-provided label', () => {
54+
const {control} = getLabelControl({label: 'My Label'});
55+
expect(control.label()).toBe('My Label');
56+
});
57+
58+
it('should return undefined if no label is provided', () => {
59+
const {control} = getLabelControl();
60+
expect(control.label()).toBeUndefined();
61+
});
62+
63+
it('should update when the input signal changes', () => {
64+
const {control, inputs} = getLabelControl({label: 'Initial Label'});
65+
expect(control.label()).toBe('Initial Label');
66+
67+
inputs.label.set('Updated Label');
68+
expect(control.label()).toBe('Updated Label');
69+
});
70+
});
71+
72+
describe('#labelledBy', () => {
73+
it('should return an empty array if a label is provided', () => {
74+
const {control} = getLabelControl({
75+
label: 'My Label',
76+
defaultLabelledBy: ['default-id'],
77+
labelledBy: ['user-id'],
78+
});
79+
expect(control.labelledBy()).toEqual([]);
80+
});
81+
82+
it('should return defaultLabelledBy if no user-provided labelledBy exists', () => {
83+
const {control} = getLabelControl({defaultLabelledBy: ['default-id']});
84+
expect(control.labelledBy()).toEqual(['default-id']);
85+
});
86+
87+
it('should return only user-provided labelledBy if labelledByAppend is false', () => {
88+
const {control} = getLabelControl({
89+
defaultLabelledBy: ['default-id'],
90+
labelledBy: ['user-id'],
91+
labelledByAppend: false,
92+
});
93+
expect(control.labelledBy()).toEqual(['user-id']);
94+
});
95+
96+
it('should return default and user-provided labelledBy if labelledByAppend is true', () => {
97+
const {control} = getLabelControl({
98+
defaultLabelledBy: ['default-id'],
99+
labelledBy: ['user-id'],
100+
labelledByAppend: true,
101+
});
102+
expect(control.labelledBy()).toEqual(['default-id', 'user-id']);
103+
});
104+
105+
it('should update when label changes from undefined to a string', () => {
106+
const {control, inputs} = getLabelControl({
107+
defaultLabelledBy: ['default-id'],
108+
});
109+
expect(control.labelledBy()).toEqual(['default-id']);
110+
inputs.label.set('A wild label appears');
111+
expect(control.labelledBy()).toEqual([]);
112+
});
113+
});
114+
115+
describe('#describedBy', () => {
116+
it('should return defaultDescribedBy if no user-provided describedBy exists', () => {
117+
const {control} = getLabelControl({defaultDescribedBy: ['default-id']});
118+
expect(control.describedBy()).toEqual(['default-id']);
119+
});
120+
121+
it('should return only user-provided describedBy if describedByAppend is false', () => {
122+
const {control} = getLabelControl({
123+
defaultDescribedBy: ['default-id'],
124+
describedBy: ['user-id'],
125+
describedByAppend: false,
126+
});
127+
expect(control.describedBy()).toEqual(['user-id']);
128+
});
129+
130+
it('should return default and user-provided describedBy if describedByAppend is true', () => {
131+
const {control} = getLabelControl({
132+
defaultDescribedBy: ['default-id'],
133+
describedBy: ['user-id'],
134+
describedByAppend: true,
135+
});
136+
expect(control.describedBy()).toEqual(['default-id', 'user-id']);
137+
});
138+
139+
it('should update when describedByAppend changes', () => {
140+
const {control, inputs} = getLabelControl({
141+
defaultDescribedBy: ['default-id'],
142+
describedBy: ['user-id'],
143+
describedByAppend: false,
144+
});
145+
expect(control.describedBy()).toEqual(['user-id']);
146+
inputs.describedByAppend.set(true);
147+
expect(control.describedBy()).toEqual(['default-id', 'user-id']);
148+
});
149+
150+
it('should not be affected by the label property', () => {
151+
const {control, inputs} = getLabelControl({
152+
defaultDescribedBy: ['default-id'],
153+
});
154+
expect(control.describedBy()).toEqual(['default-id']);
155+
inputs.label.set('A wild label appears');
156+
expect(control.describedBy()).toEqual(['default-id']);
157+
});
158+
});
159+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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+
/** The default `aria-describedby` ids. */
17+
defaultDescribedBy: SignalLike<string[]>;
18+
}
19+
20+
/** Represents the optional inputs for the label control. */
21+
export interface LabelControlOptionalInputs {
22+
/** The `aria-label`. */
23+
label?: SignalLike<string | undefined>;
24+
25+
/** The user-provided `aria-labelledby` ids. */
26+
labelledBy?: SignalLike<string[]>;
27+
28+
/** Whether the user-provided `aria-labelledby` should be appended to the default. */
29+
labelledByAppend?: SignalLike<boolean>;
30+
31+
/** The user-provided `aria-describedby` ids. */
32+
describedBy?: SignalLike<string[]>;
33+
34+
/** Whether the user-provided `aria-describedby` should be appended to the default. */
35+
describedByAppend?: SignalLike<boolean>;
36+
}
37+
38+
/** Controls label and description of an element. */
39+
export class LabelControl {
40+
/** The `aria-label`. */
41+
readonly label = computed(() => this.inputs.label?.());
42+
43+
/** The `aria-labelledby` ids. */
44+
readonly labelledBy = computed(() => {
45+
// If an aria-label is provided by developers, do not set aria-labelledby because
46+
// if both attributes are set, aria-labelledby will be used.
47+
const label = this.label();
48+
if (label) {
49+
return [];
50+
}
51+
52+
const defaultLabelledBy = this.inputs.defaultLabelledBy();
53+
const labelledBy = this.inputs.labelledBy?.();
54+
const labelledByAppend = this.inputs.labelledByAppend?.();
55+
56+
if (!labelledBy || labelledBy.length === 0) {
57+
return defaultLabelledBy;
58+
}
59+
60+
if (labelledByAppend) {
61+
return [...defaultLabelledBy, ...labelledBy];
62+
}
63+
64+
return labelledBy;
65+
});
66+
67+
/** The `aria-describedby` ids. */
68+
readonly describedBy = computed(() => {
69+
const defaultDescribedBy = this.inputs.defaultDescribedBy();
70+
const describedBy = this.inputs.describedBy?.();
71+
const describedByAppend = this.inputs.describedByAppend?.();
72+
73+
if (!describedBy || describedBy.length === 0) {
74+
return defaultDescribedBy;
75+
}
76+
77+
if (describedByAppend) {
78+
return [...defaultDescribedBy, ...describedBy];
79+
}
80+
81+
return describedBy;
82+
});
83+
84+
constructor(readonly inputs: LabelControlInputs & LabelControlOptionalInputs) {}
85+
}

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: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {computed} from '@angular/core';
9+
import {computed, signal} from '@angular/core';
1010
import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager';
1111
import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager';
1212
import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus';
@@ -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,30 @@ 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+
defaultDescribedBy: signal([]),
137+
});
122138
}
123139
}
124140

0 commit comments

Comments
 (0)