Skip to content

Commit dea2122

Browse files
committed
fix(material/autocomplete): add missing aria-label for autocomplete panel
1 parent 0b2d150 commit dea2122

File tree

7 files changed

+129
-5
lines changed

7 files changed

+129
-5
lines changed

src/material-experimental/mdc-autocomplete/autocomplete.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
role="listbox"
55
[id]="id"
66
[ngClass]="_classList"
7+
[attr.aria-label]="ariaLabel || null"
8+
[attr.aria-labelledby]="_getPanelAriaLabelledby()"
79
#panel>
810
<ng-content></ng-content>
911
</div>

src/material-experimental/mdc-autocomplete/autocomplete.spec.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1471,6 +1471,52 @@ describe('MDC-based MatAutocomplete', () => {
14711471
.toEqual('listbox', 'Expected role of the panel to be listbox.');
14721472
});
14731473

1474+
it('should point the aria-labelledby of the panel to the field label', () => {
1475+
fixture.componentInstance.trigger.openPanel();
1476+
fixture.detectChanges();
1477+
1478+
const panel =
1479+
fixture.debugElement.query(By.css('.mat-mdc-autocomplete-panel'))!.nativeElement;
1480+
const labelId = fixture.nativeElement.querySelector('label').id;
1481+
expect(panel.getAttribute('aria-labelledby')).toBe(labelId);
1482+
expect(panel.hasAttribute('aria-label')).toBe(false);
1483+
});
1484+
1485+
it('should add a custom aria-labelledby to the panel', () => {
1486+
fixture.componentInstance.ariaLabelledby = 'myLabelId';
1487+
fixture.componentInstance.trigger.openPanel();
1488+
fixture.detectChanges();
1489+
1490+
const panel =
1491+
fixture.debugElement.query(By.css('.mat-mdc-autocomplete-panel'))!.nativeElement;
1492+
const labelId = fixture.nativeElement.querySelector('label').id;
1493+
expect(panel.getAttribute('aria-labelledby')).toBe(`${labelId} myLabelId`);
1494+
expect(panel.hasAttribute('aria-label')).toBe(false);
1495+
});
1496+
1497+
it('should clear aria-labelledby from the panel if an aria-label is set', () => {
1498+
fixture.componentInstance.ariaLabel = 'My label';
1499+
fixture.componentInstance.trigger.openPanel();
1500+
fixture.detectChanges();
1501+
1502+
const panel =
1503+
fixture.debugElement.query(By.css('.mat-mdc-autocomplete-panel'))!.nativeElement;
1504+
expect(panel.getAttribute('aria-label')).toBe('My label');
1505+
expect(panel.hasAttribute('aria-labelledby')).toBe(false);
1506+
});
1507+
1508+
it('should support setting a custom aria-label', () => {
1509+
fixture.componentInstance.ariaLabel = 'Custom Label';
1510+
fixture.componentInstance.trigger.openPanel();
1511+
fixture.detectChanges();
1512+
1513+
const panel =
1514+
fixture.debugElement.query(By.css('.mat-mdc-autocomplete-panel'))!.nativeElement;
1515+
1516+
expect(panel.getAttribute('aria-label')).toEqual('Custom Label');
1517+
expect(panel.hasAttribute('aria-labelledby')).toBe(false);
1518+
});
1519+
14741520
it('should set aria-autocomplete to list', () => {
14751521
expect(input.getAttribute('aria-autocomplete'))
14761522
.toEqual('list', 'Expected aria-autocomplete attribute to equal list.');
@@ -2682,6 +2728,7 @@ describe('MDC-based MatAutocomplete', () => {
26822728

26832729
const SIMPLE_AUTOCOMPLETE_TEMPLATE = `
26842730
<mat-form-field [floatLabel]="floatLabel" [style.width.px]="width">
2731+
<mat-label>State</mat-label>
26852732
<input
26862733
matInput
26872734
placeholder="State"
@@ -2692,7 +2739,8 @@ const SIMPLE_AUTOCOMPLETE_TEMPLATE = `
26922739
</mat-form-field>
26932740
26942741
<mat-autocomplete [class]="panelClass" #auto="matAutocomplete" [displayWith]="displayFn"
2695-
[disableRipple]="disableRipple" (opened)="openedSpy()" (closed)="closedSpy()">
2742+
[disableRipple]="disableRipple" [aria-label]="ariaLabel" [aria-labelledby]="ariaLabelledby"
2743+
(opened)="openedSpy()" (closed)="closedSpy()">
26962744
<mat-option
26972745
*ngFor="let state of filteredStates"
26982746
[value]="state"
@@ -2712,6 +2760,8 @@ class SimpleAutocomplete implements OnDestroy {
27122760
width: number;
27132761
disableRipple = false;
27142762
autocompleteDisabled = false;
2763+
ariaLabel: string;
2764+
ariaLabelledby: string;
27152765
panelClass = 'class-one class-two';
27162766
openedSpy = jasmine.createSpy('autocomplete opened spy');
27172767
closedSpy = jasmine.createSpy('autocomplete closed spy');

src/material-experimental/mdc-autocomplete/autocomplete.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ import {
3131
exportAs: 'matAutocomplete',
3232
inputs: ['disableRipple'],
3333
host: {
34-
'class': 'mat-mdc-autocomplete'
34+
'class': 'mat-mdc-autocomplete',
35+
'[attr.aria-label]': 'ariaLabel || null',
3536
},
3637
providers: [
3738
{provide: MAT_OPTION_PARENT_COMPONENT, useExisting: MatAutocomplete}

src/material/autocomplete/autocomplete-trigger.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,7 @@ export abstract class _MatAutocompleteTriggerBase implements ControlValueAccesso
632632

633633
this.autocomplete._setVisibility();
634634
this.autocomplete._isOpen = this._overlayAttached = true;
635+
this.autocomplete._formFieldId = this._formField?._labelId;
635636

636637
// We need to do an extra `panelOpen` check in here, because the
637638
// autocomplete won't be shown if there are no options.
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
<ng-template>
2-
<div class="mat-autocomplete-panel" role="listbox" [id]="id" [ngClass]="_classList" #panel>
2+
<div class="mat-autocomplete-panel"
3+
role="listbox" [id]="id"
4+
[attr.aria-label]="ariaLabel || null"
5+
[attr.aria-labelledby]="_getPanelAriaLabelledby()"
6+
[ngClass]="_classList"
7+
#panel>
38
<ng-content></ng-content>
49
</div>
510
</ng-template>

src/material/autocomplete/autocomplete.spec.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1467,6 +1467,48 @@ describe('MatAutocomplete', () => {
14671467
.toEqual('listbox', 'Expected role of the panel to be listbox.');
14681468
});
14691469

1470+
it('should point the aria-labelledby of the panel to the field label', () => {
1471+
fixture.componentInstance.trigger.openPanel();
1472+
fixture.detectChanges();
1473+
1474+
const panel = fixture.debugElement.query(By.css('.mat-autocomplete-panel'))!.nativeElement;
1475+
const labelId = fixture.nativeElement.querySelector('.mat-form-field-label').id;
1476+
expect(panel.getAttribute('aria-labelledby')).toBe(labelId);
1477+
expect(panel.hasAttribute('aria-label')).toBe(false);
1478+
});
1479+
1480+
it('should add a custom aria-labelledby to the panel', () => {
1481+
fixture.componentInstance.ariaLabelledby = 'myLabelId';
1482+
fixture.componentInstance.trigger.openPanel();
1483+
fixture.detectChanges();
1484+
1485+
const panel = fixture.debugElement.query(By.css('.mat-autocomplete-panel'))!.nativeElement;
1486+
const labelId = fixture.nativeElement.querySelector('.mat-form-field-label').id;
1487+
expect(panel.getAttribute('aria-labelledby')).toBe(`${labelId} myLabelId`);
1488+
expect(panel.hasAttribute('aria-label')).toBe(false);
1489+
});
1490+
1491+
it('should clear aria-labelledby from the panel if an aria-label is set', () => {
1492+
fixture.componentInstance.ariaLabel = 'My label';
1493+
fixture.componentInstance.trigger.openPanel();
1494+
fixture.detectChanges();
1495+
1496+
const panel = fixture.debugElement.query(By.css('.mat-autocomplete-panel'))!.nativeElement;
1497+
expect(panel.getAttribute('aria-label')).toBe('My label');
1498+
expect(panel.hasAttribute('aria-labelledby')).toBe(false);
1499+
});
1500+
1501+
it('should support setting a custom aria-label', () => {
1502+
fixture.componentInstance.ariaLabel = 'Custom Label';
1503+
fixture.componentInstance.trigger.openPanel();
1504+
fixture.detectChanges();
1505+
1506+
const panel = fixture.debugElement.query(By.css('.mat-autocomplete-panel'))!.nativeElement;
1507+
1508+
expect(panel.getAttribute('aria-label')).toEqual('Custom Label');
1509+
expect(panel.hasAttribute('aria-labelledby')).toBe(false);
1510+
});
1511+
14701512
it('should set aria-autocomplete to list', () => {
14711513
expect(input.getAttribute('aria-autocomplete'))
14721514
.toEqual('list', 'Expected aria-autocomplete attribute to equal list.');
@@ -2701,7 +2743,8 @@ const SIMPLE_AUTOCOMPLETE_TEMPLATE = `
27012743
</mat-form-field>
27022744
27032745
<mat-autocomplete [class]="panelClass" #auto="matAutocomplete" [displayWith]="displayFn"
2704-
[disableRipple]="disableRipple" (opened)="openedSpy()" (closed)="closedSpy()">
2746+
[disableRipple]="disableRipple" [aria-label]="ariaLabel" [aria-labelledby]="ariaLabelledby"
2747+
(opened)="openedSpy()" (closed)="closedSpy()">
27052748
<mat-option
27062749
*ngFor="let state of filteredStates"
27072750
[value]="state"
@@ -2721,6 +2764,8 @@ class SimpleAutocomplete implements OnDestroy {
27212764
width: number;
27222765
disableRipple = false;
27232766
autocompleteDisabled = false;
2767+
ariaLabel: string;
2768+
ariaLabelledby: string;
27242769
panelClass = 'class-one class-two';
27252770
openedSpy = jasmine.createSpy('autocomplete opened spy');
27262771
closedSpy = jasmine.createSpy('autocomplete closed spy');

src/material/autocomplete/autocomplete.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp
114114
get isOpen(): boolean { return this._isOpen && this.showPanel; }
115115
_isOpen: boolean = false;
116116

117+
_formFieldId: string;
118+
117119
// The @ViewChild query for TemplateRef here needs to be static because some code paths
118120
// lead to the overlay being created before change detection has finished for this component.
119121
// Notably, another component may trigger `focus` on the autocomplete-trigger.
@@ -130,6 +132,12 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp
130132
/** @docs-private */
131133
abstract optionGroups: QueryList<_MatOptgroupBase>;
132134

135+
/** Aria label of the select. If not specified, the placeholder will be used as label. */
136+
@Input('aria-label') ariaLabel: string = '';
137+
138+
/** Input that can be used to specify the `aria-labelledby` attribute. */
139+
@Input('aria-labelledby') ariaLabelledby: string;
140+
133141
/** Function that maps an option's control value to its display value in the trigger. */
134142
@Input() displayWith: ((value: any) => string) | null = null;
135143

@@ -238,6 +246,17 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp
238246
this.optionSelected.emit(event);
239247
}
240248

249+
/** Gets the aria-labelledby for the autocomplete panel. */
250+
_getPanelAriaLabelledby(): string | null {
251+
if (this.ariaLabel) {
252+
return null;
253+
}
254+
255+
const labelId = this?._formFieldId ?? '';
256+
return this.ariaLabelledby ? labelId + ' ' + this.ariaLabelledby : labelId;
257+
}
258+
259+
241260
/** Sets the autocomplete visibility classes on a classlist based on the panel is visible. */
242261
private _setVisibilityClasses(classList: {[key: string]: boolean}) {
243262
classList[this._visibleClass] = this.showPanel;
@@ -257,7 +276,8 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp
257276
exportAs: 'matAutocomplete',
258277
inputs: ['disableRipple'],
259278
host: {
260-
'class': 'mat-autocomplete'
279+
'class': 'mat-autocomplete',
280+
'[attr.aria-label]': 'ariaLabel || null',
261281
},
262282
providers: [
263283
{provide: MAT_OPTION_PARENT_COMPONENT, useExisting: MatAutocomplete}

0 commit comments

Comments
 (0)