Skip to content

Commit 1f26c2f

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

File tree

8 files changed

+135
-6
lines changed

8 files changed

+135
-6
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._formFieldLabelId = 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: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp
114114
get isOpen(): boolean { return this._isOpen && this.showPanel; }
115115
_isOpen: boolean = false;
116116

117+
/** Label id of form field the autocomplete is associated with. */
118+
_formFieldLabelId: string;
119+
117120
// The @ViewChild query for TemplateRef here needs to be static because some code paths
118121
// lead to the overlay being created before change detection has finished for this component.
119122
// Notably, another component may trigger `focus` on the autocomplete-trigger.
@@ -130,6 +133,12 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp
130133
/** @docs-private */
131134
abstract optionGroups: QueryList<_MatOptgroupBase>;
132135

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

@@ -238,6 +247,17 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp
238247
this.optionSelected.emit(event);
239248
}
240249

250+
/** Gets the aria-labelledby for the autocomplete panel. */
251+
_getPanelAriaLabelledby(): string | null {
252+
if (this.ariaLabel) {
253+
return null;
254+
}
255+
256+
const labelId = this?._formFieldLabelId ?? '';
257+
return this.ariaLabelledby ? labelId + ' ' + this.ariaLabelledby : labelId;
258+
}
259+
260+
241261
/** Sets the autocomplete visibility classes on a classlist based on the panel is visible. */
242262
private _setVisibilityClasses(classList: {[key: string]: boolean}) {
243263
classList[this._visibleClass] = this.showPanel;
@@ -257,7 +277,8 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp
257277
exportAs: 'matAutocomplete',
258278
inputs: ['disableRipple'],
259279
host: {
260-
'class': 'mat-autocomplete'
280+
'class': 'mat-autocomplete',
281+
'[attr.aria-label]': 'ariaLabel || null',
261282
},
262283
providers: [
263284
{provide: MAT_OPTION_PARENT_COMPONENT, useExisting: MatAutocomplete}

tools/public_api_guard/material/autocomplete.d.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ export declare abstract class _MatAutocompleteBase extends _MatAutocompleteMixin
22
_classList: {
33
[key: string]: boolean;
44
};
5+
_formFieldLabelId: string;
56
protected abstract _hiddenClass: string;
67
_isOpen: boolean;
78
_keyManager: ActiveDescendantKeyManager<_MatOptionBase>;
89
protected abstract _visibleClass: string;
10+
ariaLabel: string;
11+
ariaLabelledby: string;
912
get autoActiveFirstOption(): boolean;
1013
set autoActiveFirstOption(value: boolean);
1114
set classList(value: string | string[]);
@@ -24,14 +27,15 @@ export declare abstract class _MatAutocompleteBase extends _MatAutocompleteMixin
2427
template: TemplateRef<any>;
2528
constructor(_changeDetectorRef: ChangeDetectorRef, _elementRef: ElementRef<HTMLElement>, defaults: MatAutocompleteDefaultOptions);
2629
_emitSelectEvent(option: _MatOptionBase): void;
30+
_getPanelAriaLabelledby(): string | null;
2731
_getScrollTop(): number;
2832
_setScrollTop(scrollTop: number): void;
2933
_setVisibility(): void;
3034
ngAfterContentInit(): void;
3135
ngOnDestroy(): void;
3236
static ngAcceptInputType_autoActiveFirstOption: BooleanInput;
3337
static ngAcceptInputType_disableRipple: BooleanInput;
34-
static ɵdir: i0.ɵɵDirectiveDefWithMeta<_MatAutocompleteBase, never, never, { "displayWith": "displayWith"; "autoActiveFirstOption": "autoActiveFirstOption"; "panelWidth": "panelWidth"; "classList": "class"; }, { "optionSelected": "optionSelected"; "opened": "opened"; "closed": "closed"; "optionActivated": "optionActivated"; }, never>;
38+
static ɵdir: i0.ɵɵDirectiveDefWithMeta<_MatAutocompleteBase, never, never, { "ariaLabel": "aria-label"; "ariaLabelledby": "aria-labelledby"; "displayWith": "displayWith"; "autoActiveFirstOption": "autoActiveFirstOption"; "panelWidth": "panelWidth"; "classList": "class"; }, { "optionSelected": "optionSelected"; "opened": "opened"; "closed": "closed"; "optionActivated": "optionActivated"; }, never>;
3539
static ɵfac: i0.ɵɵFactoryDef<_MatAutocompleteBase, never>;
3640
}
3741

0 commit comments

Comments
 (0)