Skip to content

Commit bdbf32a

Browse files
committedApr 2, 2025·
fix(material/tabs): allow ID to be set on tab (#30768)
Fixes a long-standing issue where users weren't able to assign an ID to a tab. Fixes #4136. (cherry picked from commit 387313f)
1 parent f154d49 commit bdbf32a

File tree

5 files changed

+68
-18
lines changed

5 files changed

+68
-18
lines changed
 

‎goldens/material/tabs/index.api.md

+4-3
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export class MatTab implements OnInit, OnChanges, OnDestroy {
152152
_closestTabGroup: any;
153153
get content(): TemplatePortal | null;
154154
disabled: boolean;
155+
id: string | null;
155156
_implicitContent: TemplateRef<any>;
156157
isActive: boolean;
157158
labelClass: string | string[];
@@ -170,7 +171,7 @@ export class MatTab implements OnInit, OnChanges, OnDestroy {
170171
set templateLabel(value: MatTabLabel);
171172
textLabel: string;
172173
// (undocumented)
173-
static ɵcmp: i0.ɵɵComponentDeclaration<MatTab, "mat-tab", ["matTab"], { "disabled": { "alias": "disabled"; "required": false; }; "textLabel": { "alias": "label"; "required": false; }; "ariaLabel": { "alias": "aria-label"; "required": false; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; }; "labelClass": { "alias": "labelClass"; "required": false; }; "bodyClass": { "alias": "bodyClass"; "required": false; }; }, {}, ["templateLabel", "_explicitContent"], ["*"], true, never>;
174+
static ɵcmp: i0.ɵɵComponentDeclaration<MatTab, "mat-tab", ["matTab"], { "disabled": { "alias": "disabled"; "required": false; }; "textLabel": { "alias": "label"; "required": false; }; "ariaLabel": { "alias": "aria-label"; "required": false; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; }; "labelClass": { "alias": "labelClass"; "required": false; }; "bodyClass": { "alias": "bodyClass"; "required": false; }; "id": { "alias": "id"; "required": false; }; }, {}, ["templateLabel", "_explicitContent"], ["*"], true, never>;
174175
// (undocumented)
175176
static ɵfac: i0.ɵɵFactoryDeclaration<MatTab, never>;
176177
}
@@ -267,9 +268,9 @@ export class MatTabGroup implements AfterViewInit, AfterContentInit, AfterConten
267268
// (undocumented)
268269
_focusChanged(index: number): void;
269270
focusTab(index: number): void;
270-
_getTabContentId(i: number): string;
271+
_getTabContentId(index: number): string;
271272
_getTabIndex(index: number): number;
272-
_getTabLabelId(i: number): string;
273+
_getTabLabelId(tab: MatTab, index: number): string;
273274
_handleClick(tab: MatTab, tabHeader: MatTabGroupBaseHeader, index: number): void;
274275
headerPosition: MatTabHeaderPosition;
275276
protected _isServer: boolean;

‎src/material/tabs/tab-group.html

+10-10
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,26 @@
77
(indexFocused)="_focusChanged($event)"
88
(selectFocusedIndex)="selectedIndex = $event">
99

10-
@for (tab of _tabs; track tab; let i = $index) {
10+
@for (tab of _tabs; track tab) {
1111
<div class="mdc-tab mat-mdc-tab mat-focus-indicator"
1212
#tabNode
1313
role="tab"
1414
matTabLabelWrapper
1515
cdkMonitorElementFocus
16-
[id]="_getTabLabelId(i)"
17-
[attr.tabIndex]="_getTabIndex(i)"
18-
[attr.aria-posinset]="i + 1"
16+
[id]="_getTabLabelId(tab, $index)"
17+
[attr.tabIndex]="_getTabIndex($index)"
18+
[attr.aria-posinset]="$index + 1"
1919
[attr.aria-setsize]="_tabs.length"
20-
[attr.aria-controls]="_getTabContentId(i)"
21-
[attr.aria-selected]="selectedIndex === i"
20+
[attr.aria-controls]="_getTabContentId($index)"
21+
[attr.aria-selected]="selectedIndex === $index"
2222
[attr.aria-label]="tab.ariaLabel || null"
2323
[attr.aria-labelledby]="(!tab.ariaLabel && tab.ariaLabelledby) ? tab.ariaLabelledby : null"
24-
[class.mdc-tab--active]="selectedIndex === i"
24+
[class.mdc-tab--active]="selectedIndex === $index"
2525
[class]="tab.labelClass"
2626
[disabled]="tab.disabled"
2727
[fitInkBarToContent]="fitInkBarToContent"
28-
(click)="_handleClick(tab, tabHeader, i)"
29-
(cdkFocusChange)="_tabFocusChanged($event, i)">
28+
(click)="_handleClick(tab, tabHeader, $index)"
29+
(cdkFocusChange)="_tabFocusChanged($event, $index)">
3030
<span class="mdc-tab__ripple"></span>
3131

3232
<!-- Needs to be a separate element, because we can't put
@@ -71,7 +71,7 @@
7171
<mat-tab-body role="tabpanel"
7272
[id]="_getTabContentId($index)"
7373
[attr.tabindex]="(contentTabIndex != null && selectedIndex === $index) ? contentTabIndex : null"
74-
[attr.aria-labelledby]="_getTabLabelId($index)"
74+
[attr.aria-labelledby]="_getTabLabelId(tab, $index)"
7575
[attr.aria-hidden]="selectedIndex !== $index"
7676
[class]="tab.bodyClass"
7777
[content]="tab.content!"

‎src/material/tabs/tab-group.spec.ts

+41-1
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,45 @@ describe('MatTabGroup', () => {
442442
fixture.detectChanges();
443443
expect(tabList.hasAttribute('aria-labelledby')).toBe(false);
444444
}));
445+
446+
it('should set IDs on individual tabs and use them to label the tab bodies', () => {
447+
fixture.detectChanges();
448+
const tabs = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('.mat-mdc-tab'));
449+
const bodies = Array.from<HTMLElement>(
450+
fixture.nativeElement.querySelectorAll('mat-tab-body'),
451+
);
452+
453+
expect(tabs.length).toBe(3);
454+
expect(bodies.length).toBe(3);
455+
expect(tabs.every(tab => !!tab.getAttribute('id')))
456+
.withContext('All tabs should have IDs')
457+
.toBe(true);
458+
expect(
459+
bodies.every((body, index) => {
460+
const attr = body.getAttribute('aria-labelledby');
461+
return !!attr && tabs[index].getAttribute('id') === attr;
462+
}),
463+
)
464+
.withContext('All tab bodies should be labelled')
465+
.toBe(true);
466+
});
467+
468+
it('should be able to set a custom ID for a tab', () => {
469+
fixture.detectChanges();
470+
const tab = fixture.nativeElement.querySelectorAll('.mat-mdc-tab')[1] as HTMLElement;
471+
const body = fixture.nativeElement.querySelectorAll('mat-tab-body')[1] as HTMLElement;
472+
473+
expect(tab.getAttribute('id')).toBeTruthy();
474+
expect(tab.getAttribute('id')).not.toBe('foo');
475+
expect(body.getAttribute('aria-labelledby')).toBeTruthy();
476+
expect(body.getAttribute('aria-labelledby')).toBe(tab.getAttribute('id'));
477+
478+
fixture.componentInstance.secondTabId = 'foo';
479+
fixture.changeDetectorRef.markForCheck();
480+
fixture.detectChanges();
481+
expect(tab.getAttribute('id')).toBe('foo');
482+
expect(body.getAttribute('aria-labelledby')).toBe('foo');
483+
});
445484
});
446485

447486
describe('aria labelling', () => {
@@ -1236,7 +1275,7 @@ describe('MatTabGroup labels aligned with a config', () => {
12361275
<ng-template mat-tab-label>Tab One</ng-template>
12371276
Tab one content
12381277
</mat-tab>
1239-
<mat-tab>
1278+
<mat-tab [id]="secondTabId">
12401279
<ng-template mat-tab-label>Tab Two</ng-template>
12411280
<span>Tab </span><span>two</span><span>content</span>
12421281
</mat-tab>
@@ -1259,6 +1298,7 @@ class SimpleTabsTestApp {
12591298
headerPosition: MatTabHeaderPosition = 'above';
12601299
ariaLabel: string;
12611300
ariaLabelledby: string;
1301+
secondTabId: string | null = null;
12621302
handleFocus(event: any) {
12631303
this.focusEvent = event;
12641304
}

‎src/material/tabs/tab-group.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -498,13 +498,13 @@ export class MatTabGroup
498498
}
499499

500500
/** Returns a unique id for each tab label element */
501-
_getTabLabelId(i: number): string {
502-
return `${this._groupId}-label-${i}`;
501+
_getTabLabelId(tab: MatTab, index: number): string {
502+
return tab.id || `${this._groupId}-label-${index}`;
503503
}
504504

505505
/** Returns a unique id for each tab content element */
506-
_getTabContentId(i: number): string {
507-
return `${this._groupId}-content-${i}`;
506+
_getTabContentId(index: number): string {
507+
return `${this._groupId}-content-${index}`;
508508
}
509509

510510
/**

‎src/material/tabs/tab.ts

+9
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ export const MAT_TAB_GROUP = new InjectionToken<any>('MAT_TAB_GROUP');
5151
// This element will be rendered on the server in order to support hydration.
5252
// Hide it so it doesn't cause a layout shift when it's removed on the client.
5353
'hidden': '',
54+
55+
// Clear any custom IDs from the tab since they'll be forwarded to the actual tab.
56+
'[attr.id]': 'null',
5457
},
5558
})
5659
export class MatTab implements OnInit, OnChanges, OnDestroy {
@@ -99,6 +102,12 @@ export class MatTab implements OnInit, OnChanges, OnDestroy {
99102
/** Classes to be passed to the tab mat-tab-body container. */
100103
@Input() bodyClass: string | string[];
101104

105+
/**
106+
* Custom ID for the tab, overriding the auto-generated one by Material.
107+
* Note that when using this input, it's your responsibility to ensure that the ID is unique.
108+
*/
109+
@Input() id: string | null = null;
110+
102111
/** Portal that will be the hosted content of the tab */
103112
private _contentPortal: TemplatePortal | null = null;
104113

0 commit comments

Comments
 (0)
Please sign in to comment.