Skip to content

Commit 8ee56a5

Browse files
authored
fix(material/autocomplete): add missing aria-label for autocomplete panel (#20892)
* fix(material/autocomplete): add missing aria-label for autocomplete panel * fix(material/autocomplete): pass in form field id in ng-template
1 parent c68791d commit 8ee56a5

File tree

7 files changed

+131
-7
lines changed

7 files changed

+131
-7
lines changed

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
<ng-template>
1+
<ng-template let-formFieldId="id">
22
<div
33
class="mat-mdc-autocomplete-panel mdc-menu-surface mdc-menu-surface--open"
44
role="listbox"
55
[id]="id"
66
[ngClass]="_classList"
7+
[attr.aria-label]="ariaLabel || null"
8+
[attr.aria-labelledby]="_getPanelAriaLabelledby(formFieldId)"
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/autocomplete/autocomplete-trigger.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,7 +591,9 @@ export abstract class _MatAutocompleteTriggerBase implements ControlValueAccesso
591591
let overlayRef = this._overlayRef;
592592

593593
if (!overlayRef) {
594-
this._portal = new TemplatePortal(this.autocomplete.template, this._viewContainerRef);
594+
this._portal = new TemplatePortal(this.autocomplete.template,
595+
this._viewContainerRef,
596+
{id: this._formField?._labelId});
595597
overlayRef = this._overlay.create(this._getOverlayConfig());
596598
this._overlayRef = overlayRef;
597599

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
<ng-template>
2-
<div class="mat-autocomplete-panel" role="listbox" [id]="id" [ngClass]="_classList" #panel>
1+
<ng-template let-formFieldId="id">
2+
<div class="mat-autocomplete-panel"
3+
role="listbox"
4+
[id]="id"
5+
[attr.aria-label]="ariaLabel || null"
6+
[attr.aria-labelledby]="_getPanelAriaLabelledby(formFieldId)"
7+
[ngClass]="_classList"
8+
#panel>
39
<ng-content></ng-content>
410
</div>
511
</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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp
131131
/** @docs-private */
132132
abstract optionGroups: QueryList<_MatOptgroupBase>;
133133

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

@@ -251,6 +257,16 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp
251257
this.optionSelected.emit(event);
252258
}
253259

260+
/** Gets the aria-labelledby for the autocomplete panel. */
261+
_getPanelAriaLabelledby(labelId: string): string | null {
262+
if (this.ariaLabel) {
263+
return null;
264+
}
265+
266+
return this.ariaLabelledby ? labelId + ' ' + this.ariaLabelledby : labelId;
267+
}
268+
269+
254270
/** Sets the autocomplete visibility classes on a classlist based on the panel is visible. */
255271
private _setVisibilityClasses(classList: {[key: string]: boolean}) {
256272
classList[this._visibleClass] = this.showPanel;

tools/public_api_guard/material/autocomplete.d.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export declare abstract class _MatAutocompleteBase extends _MatAutocompleteMixin
66
_isOpen: boolean;
77
_keyManager: ActiveDescendantKeyManager<_MatOptionBase>;
88
protected abstract _visibleClass: string;
9+
ariaLabel: string;
10+
ariaLabelledby: string;
911
get autoActiveFirstOption(): boolean;
1012
set autoActiveFirstOption(value: boolean);
1113
set classList(value: string | string[]);
@@ -25,14 +27,15 @@ export declare abstract class _MatAutocompleteBase extends _MatAutocompleteMixin
2527
template: TemplateRef<any>;
2628
constructor(_changeDetectorRef: ChangeDetectorRef, _elementRef: ElementRef<HTMLElement>, defaults: MatAutocompleteDefaultOptions, platform?: Platform);
2729
_emitSelectEvent(option: _MatOptionBase): void;
30+
_getPanelAriaLabelledby(labelId: string): string | null;
2831
_getScrollTop(): number;
2932
_setScrollTop(scrollTop: number): void;
3033
_setVisibility(): void;
3134
ngAfterContentInit(): void;
3235
ngOnDestroy(): void;
3336
static ngAcceptInputType_autoActiveFirstOption: BooleanInput;
3437
static ngAcceptInputType_disableRipple: BooleanInput;
35-
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>;
3639
static ɵfac: i0.ɵɵFactoryDef<_MatAutocompleteBase, never>;
3740
}
3841

0 commit comments

Comments
 (0)