Skip to content

feat(cdk-experimental/ui-patterns): add label control #31459

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/cdk-experimental/tabs/tabs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/cdk-experimental/tabs/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
31 changes: 31 additions & 0 deletions src/cdk-experimental/ui-patterns/behaviors/label/BUILD
Original file line number Diff line number Diff line change
@@ -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"],
)
159 changes: 159 additions & 0 deletions src/cdk-experimental/ui-patterns/behaviors/label/label.spec.ts
Original file line number Diff line number Diff line change
@@ -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<LabelControlOptionalInputs>;

// 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']);
});
});
});
85 changes: 85 additions & 0 deletions src/cdk-experimental/ui-patterns/behaviors/label/label.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>;

/** The default `aria-describedby` ids. */
defaultDescribedBy: SignalLike<string[]>;
}

/** Represents the optional inputs for the label control. */
export interface LabelControlOptionalInputs {
/** The `aria-label`. */
label?: SignalLike<string | undefined>;

/** The user-provided `aria-labelledby` ids. */
labelledBy?: SignalLike<string[]>;

/** Whether the user-provided `aria-labelledby` should be appended to the default. */
labelledByAppend?: SignalLike<boolean>;

/** The user-provided `aria-describedby` ids. */
describedBy?: SignalLike<string[]>;

/** Whether the user-provided `aria-describedby` should be appended to the default. */
describedByAppend?: SignalLike<boolean>;
}

/** 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) {}
}
1 change: 1 addition & 0 deletions src/cdk-experimental/ui-patterns/tabs/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
20 changes: 18 additions & 2 deletions src/cdk-experimental/ui-patterns/tabs/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -96,7 +97,7 @@ export class TabPattern {
}

/** The required inputs for the tabpanel. */
export interface TabPanelInputs {
export interface TabPanelInputs extends LabelControlOptionalInputs {
id: SignalLike<string>;
tab: SignalLike<TabPattern | undefined>;
value: SignalLike<string>;
Expand All @@ -110,15 +111,30 @@ export class TabPanelPattern {
/** A local unique identifier for the tabpanel. */
readonly value: SignalLike<string>;

/** 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([]),
});
}
}

Expand Down
Loading