From 7c350b18d1f379cb9fc1459940171a17d1b0313f Mon Sep 17 00:00:00 2001 From: RobertAKARobin Date: Tue, 22 Mar 2022 12:02:04 -0500 Subject: [PATCH] feat(material/select): allow user-defined aria-describedby --- .../mdc-select/select.spec.ts | 22 ++++++++++++++++++- .../mdc-select/select.ts | 1 - src/material/select/select.spec.ts | 22 ++++++++++++++++++- src/material/select/select.ts | 16 +++++++++----- tools/public_api_guard/material/select.md | 4 ++-- 5 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/material-experimental/mdc-select/select.spec.ts b/src/material-experimental/mdc-select/select.spec.ts index 246493586229..f921749377da 100644 --- a/src/material-experimental/mdc-select/select.spec.ts +++ b/src/material-experimental/mdc-select/select.spec.ts @@ -222,6 +222,22 @@ describe('MDC-based MatSelect', () => { expect(select.getAttribute('tabindex')).toEqual('0'); })); + it('should set `aria-describedby` to the id of the mat-hint', fakeAsync(() => { + expect(select.getAttribute('aria-describedby')).toBeNull(); + + fixture.componentInstance.hint = 'test'; + fixture.detectChanges(); + const hint = fixture.debugElement.query(By.css('mat-hint')).nativeElement; + expect(select.getAttribute('aria-describedby')).toBe(hint.getAttribute('id')); + expect(select.getAttribute('aria-describedby')).toMatch(/^mat-mdc-hint-\d+$/); + })); + + it('should support user binding to `aria-describedby`', fakeAsync(() => { + fixture.componentInstance.ariaDescribedBy = 'test'; + fixture.detectChanges(); + expect(select.getAttribute('aria-describedby')).toBe('test'); + })); + it('should be able to override the tabindex', fakeAsync(() => { fixture.componentInstance.tabIndexOverride = 3; fixture.detectChanges(); @@ -4223,13 +4239,15 @@ describe('MDC-based MatSelect', () => { Select a food {{ food.viewValue }} + {{ hint }}
`, @@ -4250,7 +4268,9 @@ class BasicSelect { heightAbove = 0; heightBelow = 0; hasLabel = true; + hint: string; tabIndexOverride: number; + ariaDescribedBy: string; ariaLabel: string; ariaLabelledby: string; panelClass = ['custom-one', 'custom-two']; diff --git a/src/material-experimental/mdc-select/select.ts b/src/material-experimental/mdc-select/select.ts index 0c67c796cd05..2256f06c9bee 100644 --- a/src/material-experimental/mdc-select/select.ts +++ b/src/material-experimental/mdc-select/select.ts @@ -72,7 +72,6 @@ export class MatSelectTrigger {} '[attr.aria-required]': 'required.toString()', '[attr.aria-disabled]': 'disabled.toString()', '[attr.aria-invalid]': 'errorState', - '[attr.aria-describedby]': '_ariaDescribedby || null', '[attr.aria-activedescendant]': '_getAriaActiveDescendant()', '[class.mat-mdc-select-disabled]': 'disabled', '[class.mat-mdc-select-invalid]': 'errorState', diff --git a/src/material/select/select.spec.ts b/src/material/select/select.spec.ts index 11c25bebadd9..d393c2a9933d 100644 --- a/src/material/select/select.spec.ts +++ b/src/material/select/select.spec.ts @@ -298,6 +298,22 @@ describe('MatSelect', () => { expect(select.getAttribute('aria-labelledby')?.trim()).toBe(valueId); }); + it('should set `aria-describedby` to the id of the mat-hint', fakeAsync(() => { + expect(select.getAttribute('aria-describedby')).toBeNull(); + + fixture.componentInstance.hint = 'test'; + fixture.detectChanges(); + const hint = fixture.debugElement.query(By.css('.mat-hint')).nativeElement; + expect(select.getAttribute('aria-describedby')).toBe(hint.getAttribute('id')); + expect(select.getAttribute('aria-describedby')).toMatch(/^mat-hint-\d+$/); + })); + + it('should support user binding to `aria-describedby`', fakeAsync(() => { + fixture.componentInstance.ariaDescribedBy = 'test'; + fixture.detectChanges(); + expect(select.getAttribute('aria-describedby')).toBe('test'); + })); + it('should select options via the UP/DOWN arrow keys on a closed select', fakeAsync(() => { const formControl = fixture.componentInstance.control; const options = fixture.componentInstance.options.toArray(); @@ -5186,13 +5202,15 @@ describe('MatSelect', () => {
{{ food.viewValue }} + {{ hint }}
`, @@ -5212,7 +5230,9 @@ class BasicSelect { isRequired: boolean; heightAbove = 0; heightBelow = 0; + hint: string; tabIndexOverride: number; + ariaDescribedBy: string; ariaLabel: string; ariaLabelledby: string; panelClass = ['custom-one', 'custom-two']; diff --git a/src/material/select/select.ts b/src/material/select/select.ts index fd0241d767d6..6ff99d1b019a 100644 --- a/src/material/select/select.ts +++ b/src/material/select/select.ts @@ -303,8 +303,11 @@ export abstract class _MatSelectBase /** Emits whenever the component is destroyed. */ protected readonly _destroy = new Subject(); - /** The aria-describedby attribute on the select for improved a11y. */ - _ariaDescribedby: string; + /** + * Implemented as part of MatFormFieldControl. + * @docs-private + */ + @Input('aria-describedby') userAriaDescribedBy: string; /** Deals with the selection logic. */ _selectionModel: SelectionModel; @@ -611,7 +614,7 @@ export abstract class _MatSelectBase ngOnChanges(changes: SimpleChanges) { // Updating the disabled state is handled by `mixinDisabled`, but we need to additionally let // the parent form field know to run change detection when the disabled state changes. - if (changes['disabled']) { + if (changes['disabled'] || changes['userAriaDescribedBy']) { this.stateChanges.next(); } @@ -1146,7 +1149,11 @@ export abstract class _MatSelectBase * @docs-private */ setDescribedByIds(ids: string[]) { - this._ariaDescribedby = ids.join(' '); + if (ids.length) { + this._elementRef.nativeElement.setAttribute('aria-describedby', ids.join(' ')); + } else { + this._elementRef.nativeElement.removeAttribute('aria-describedby'); + } } /** @@ -1191,7 +1198,6 @@ export abstract class _MatSelectBase '[attr.aria-required]': 'required.toString()', '[attr.aria-disabled]': 'disabled.toString()', '[attr.aria-invalid]': 'errorState', - '[attr.aria-describedby]': '_ariaDescribedby || null', '[attr.aria-activedescendant]': '_getAriaActiveDescendant()', '[class.mat-select-disabled]': 'disabled', '[class.mat-select-invalid]': 'errorState', diff --git a/tools/public_api_guard/material/select.md b/tools/public_api_guard/material/select.md index a21247cc2fc1..d9c0cea39ae3 100644 --- a/tools/public_api_guard/material/select.md +++ b/tools/public_api_guard/material/select.md @@ -114,7 +114,6 @@ export const matSelectAnimations: { // @public export abstract class _MatSelectBase extends _MatSelectMixinBase implements AfterContentInit, OnChanges, OnDestroy, OnInit, DoCheck, ControlValueAccessor, CanDisable, HasTabIndex, MatFormFieldControl, CanUpdateErrorState, CanDisableRipple { constructor(_viewportRuler: ViewportRuler, _changeDetectorRef: ChangeDetectorRef, _ngZone: NgZone, _defaultErrorStateMatcher: ErrorStateMatcher, elementRef: ElementRef, _dir: Directionality, _parentForm: NgForm, _parentFormGroup: FormGroupDirective, _parentFormField: MatFormField, ngControl: NgControl, tabIndex: string, scrollStrategyFactory: any, _liveAnnouncer: LiveAnnouncer, _defaultOptions?: MatSelectConfig | undefined); - _ariaDescribedby: string; ariaLabel: string; ariaLabelledby: string; protected _canOpen(): boolean; @@ -203,6 +202,7 @@ export abstract class _MatSelectBase extends _MatSelectMixinBase implements A get triggerValue(): string; get typeaheadDebounceInterval(): number; set typeaheadDebounceInterval(value: NumberInput); + userAriaDescribedBy: string; get value(): any; set value(newValue: any); readonly valueChange: EventEmitter; @@ -211,7 +211,7 @@ export abstract class _MatSelectBase extends _MatSelectMixinBase implements A protected _viewportRuler: ViewportRuler; writeValue(value: any): void; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration<_MatSelectBase, never, never, { "panelClass": "panelClass"; "placeholder": "placeholder"; "required": "required"; "multiple": "multiple"; "disableOptionCentering": "disableOptionCentering"; "compareWith": "compareWith"; "value": "value"; "ariaLabel": "aria-label"; "ariaLabelledby": "aria-labelledby"; "errorStateMatcher": "errorStateMatcher"; "typeaheadDebounceInterval": "typeaheadDebounceInterval"; "sortComparator": "sortComparator"; "id": "id"; }, { "openedChange": "openedChange"; "_openedStream": "opened"; "_closedStream": "closed"; "selectionChange": "selectionChange"; "valueChange": "valueChange"; }, never>; + static ɵdir: i0.ɵɵDirectiveDeclaration<_MatSelectBase, never, never, { "userAriaDescribedBy": "aria-describedby"; "panelClass": "panelClass"; "placeholder": "placeholder"; "required": "required"; "multiple": "multiple"; "disableOptionCentering": "disableOptionCentering"; "compareWith": "compareWith"; "value": "value"; "ariaLabel": "aria-label"; "ariaLabelledby": "aria-labelledby"; "errorStateMatcher": "errorStateMatcher"; "typeaheadDebounceInterval": "typeaheadDebounceInterval"; "sortComparator": "sortComparator"; "id": "id"; }, { "openedChange": "openedChange"; "_openedStream": "opened"; "_closedStream": "closed"; "selectionChange": "selectionChange"; "valueChange": "valueChange"; }, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration<_MatSelectBase, [null, null, null, null, null, { optional: true; }, { optional: true; }, { optional: true; }, { optional: true; }, { optional: true; self: true; }, { attribute: "tabindex"; }, null, null, { optional: true; }]>; }