Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions src/material-experimental/mdc-core/option/optgroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@
import {Component, ViewEncapsulation, ChangeDetectionStrategy} from '@angular/core';
import {_MatOptgroupBase, MAT_OPTGROUP} from '@angular/material/core';

// Notes on the accessibility pattern used for `mat-optgroup`.
// The option group has two different "modes": regular and inert. The regular mode uses the
// recommended a11y pattern which has `role="group"` on the group element with `aria-labelledby`
// pointing to the label. This works for `mat-select`, but it seems to hit a bug for autocomplete
// under VoiceOver where the group doesn't get read out at all. The bug appears to be that if
// there's __any__ a11y-related attribute on the group (e.g. `role` or `aria-labelledby`),
// VoiceOver on Safari won't read it out.
// We've introduced the `inert` mode as a workaround. Under this mode, all a11y attributes are
// removed from the group, and we get the screen reader to read out the group label by mirroring it
// inside an invisible element in the option. This is sub-optimal, because the screen reader will
// repeat the group label on each navigation, whereas the default pattern only reads the group when
// the user enters a new group. The following alternate approaches were considered:
// 1. Reading out the group label using the `LiveAnnouncer` solves the problem, but we can't control
// when the text will be read out so sometimes it comes in too late or never if the user
// navigates quickly.
// 2. `<mat-option aria-describedby="groupLabel"` - This works on Safari, but VoiceOver in Chrome
// won't read out the description at all.
// 3. `<mat-option aria-labelledby="optionLabel groupLabel"` - This works on Chrome, but Safari
// doesn't read out the text at all. Furthermore, on

/**
* Component that is used to group instances of `mat-option`.
Expand All @@ -23,9 +42,9 @@ import {_MatOptgroupBase, MAT_OPTGROUP} from '@angular/material/core';
styleUrls: ['optgroup.css'],
host: {
'class': 'mat-mdc-optgroup',
'role': 'group',
'[attr.aria-disabled]': 'disabled.toString()',
'[attr.aria-labelledby]': '_labelId',
'[attr.role]': '_inert ? null : "group"',
'[attr.aria-disabled]': '_inert ? null : disabled.toString()',
'[attr.aria-labelledby]': '_inert ? null : _labelId',
},
providers: [
{provide: MAT_OPTGROUP, useExisting: MatOptgroup}
Expand Down
3 changes: 3 additions & 0 deletions src/material-experimental/mdc-core/option/option.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

<span class="mdc-list-item__text"><ng-content></ng-content></span>

<!-- See a11y notes inside optgroup.ts for context behind this element. -->
<span class="cdk-visually-hidden" *ngIf="group && group._inert">({{ group.label }})</span>

<div class="mat-mdc-option-ripple" mat-ripple
[matRippleTrigger]="_getHostElement()"
[matRippleDisabled]="disabled || disableRipple">
Expand Down
45 changes: 44 additions & 1 deletion src/material-experimental/mdc-core/option/option.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
dispatchEvent,
} from '@angular/cdk/testing/private';
import {SPACE, ENTER} from '@angular/cdk/keycodes';
import {MatOption, MatOptionModule} from './index';
import {MatOption, MatOptionModule, MAT_OPTION_PARENT_COMPONENT} from './index';

describe('MatOption component', () => {

Expand Down Expand Up @@ -197,6 +197,38 @@ describe('MatOption component', () => {
expect(optionNativeElement.classList.contains('mat-mdc-focus-indicator')).toBe(true);
});

describe('inside inert group', () => {
let fixture: ComponentFixture<InsideGroup>;

beforeEach(waitForAsync(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [MatOptionModule],
declarations: [InsideGroup],
providers: [{
provide: MAT_OPTION_PARENT_COMPONENT,
useValue: {inertGroups: true}
}]
}).compileComponents();

fixture = TestBed.createComponent(InsideGroup);
fixture.detectChanges();
}));

it('should remove all accessibility-related attributes from the group', () => {
const group: HTMLElement = fixture.nativeElement.querySelector('mat-optgroup');
expect(group.hasAttribute('role')).toBe(false);
expect(group.hasAttribute('aria-disabled')).toBe(false);
expect(group.hasAttribute('aria-labelledby')).toBe(false);
});

it('should mirror the group label inside the option', () => {
const option: HTMLElement = fixture.nativeElement.querySelector('mat-option');
expect(option.textContent?.trim()).toBe('Option(Group)');
});
});


});

@Component({
Expand All @@ -206,3 +238,14 @@ class BasicOption {
disabled: boolean;
id: string;
}


@Component({
template: `
<mat-optgroup label="Group">
<mat-option>Option</mat-option>
</mat-optgroup>
`
})
class InsideGroup {
}
5 changes: 4 additions & 1 deletion src/material-experimental/mdc-core/testing/option-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export class MatOptionHarness extends ComponentHarness {
/** Selector used to locate option instances. */
static hostSelector = '.mat-mdc-option';

/** Element containing the option's text. */
private _text = this.locatorFor('.mdc-list-item__text');

/**
* Gets a `HarnessPredicate` that can be used to search for a `MatOptionsHarness` that meets
* certain criteria.
Expand All @@ -37,7 +40,7 @@ export class MatOptionHarness extends ComponentHarness {

/** Gets the option's label text. */
async getText(): Promise<string> {
return (await this.host()).text();
return (await this._text()).text();
}

/** Gets whether the option is disabled. */
Expand Down
15 changes: 14 additions & 1 deletion src/material/autocomplete/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import {ActiveDescendantKeyManager} from '@angular/cdk/a11y';
import {BooleanInput, coerceBooleanProperty, coerceStringArray} from '@angular/cdk/coercion';
import {Platform} from '@angular/cdk/platform';
import {
AfterContentInit,
ChangeDetectionStrategy,
Expand Down Expand Up @@ -187,12 +188,24 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp
/** Unique ID to be used by autocomplete trigger's "aria-owns" property. */
id: string = `mat-autocomplete-${_uniqueAutocompleteIdCounter++}`;

/**
* Tells any descendant `mat-optgroup` to use the inert a11y pattern.
* @docs-private
*/
readonly inertGroups: boolean;

constructor(
private _changeDetectorRef: ChangeDetectorRef,
private _elementRef: ElementRef<HTMLElement>,
@Inject(MAT_AUTOCOMPLETE_DEFAULT_OPTIONS) defaults: MatAutocompleteDefaultOptions) {
@Inject(MAT_AUTOCOMPLETE_DEFAULT_OPTIONS) defaults: MatAutocompleteDefaultOptions,
platform?: Platform) {
super();

// TODO(crisbeto): the problem that the `inertGroups` option resolves is only present on
// Safari using VoiceOver. We should occasionally check back to see whether the bug
// wasn't resolved in VoiceOver, and if it has, we can remove this and the `inertGroups`
// option altogether.
this.inertGroups = platform?.SAFARI || false;
this._autoActiveFirstOption = !!defaults.autoActiveFirstOption;
}

Expand Down
1 change: 1 addition & 0 deletions src/material/core/option/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ export class MatOptionModule {}

export * from './option';
export * from './optgroup';
export * from './option-parent';
36 changes: 32 additions & 4 deletions src/material/core/option/optgroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,30 @@ import {
InjectionToken,
Input,
ViewEncapsulation,
Directive
Directive, Inject, Optional
} from '@angular/core';
import {CanDisable, CanDisableCtor, mixinDisabled} from '../common-behaviors/disabled';
import {MatOptionParentComponent, MAT_OPTION_PARENT_COMPONENT} from './option-parent';

// Notes on the accessibility pattern used for `mat-optgroup`.
// The option group has two different "modes": regular and inert. The regular mode uses the
// recommended a11y pattern which has `role="group"` on the group element with `aria-labelledby`
// pointing to the label. This works for `mat-select`, but it seems to hit a bug for autocomplete
// under VoiceOver where the group doesn't get read out at all. The bug appears to be that if
// there's __any__ a11y-related attribute on the group (e.g. `role` or `aria-labelledby`),
// VoiceOver on Safari won't read it out.
// We've introduced the `inert` mode as a workaround. Under this mode, all a11y attributes are
// removed from the group, and we get the screen reader to read out the group label by mirroring it
// inside an invisible element in the option. This is sub-optimal, because the screen reader will
// repeat the group label on each navigation, whereas the default pattern only reads the group when
// the user enters a new group. The following alternate approaches were considered:
// 1. Reading out the group label using the `LiveAnnouncer` solves the problem, but we can't control
// when the text will be read out so sometimes it comes in too late or never if the user
// navigates quickly.
// 2. `<mat-option aria-describedby="groupLabel"` - This works on Safari, but VoiceOver in Chrome
// won't read out the description at all.
// 3. `<mat-option aria-labelledby="optionLabel groupLabel"` - This works on Chrome, but Safari
// doesn't read out the text at all. Furthermore, on

// Boilerplate for applying mixins to MatOptgroup.
/** @docs-private */
Expand All @@ -35,6 +55,14 @@ export class _MatOptgroupBase extends _MatOptgroupMixinBase implements CanDisabl
/** Unique id for the underlying label. */
_labelId: string = `mat-optgroup-label-${_uniqueOptgroupIdCounter++}`;

/** Whether the group is in inert a11y mode. */
_inert: boolean;

constructor(@Inject(MAT_OPTION_PARENT_COMPONENT) @Optional() parent?: MatOptionParentComponent) {
super();
this._inert = parent?.inertGroups ?? false;
}

static ngAcceptInputType_disabled: BooleanInput;
}

Expand All @@ -58,10 +86,10 @@ export const MAT_OPTGROUP = new InjectionToken<MatOptgroup>('MatOptgroup');
styleUrls: ['optgroup.css'],
host: {
'class': 'mat-optgroup',
'role': 'group',
'[attr.role]': '_inert ? null : "group"',
'[attr.aria-disabled]': '_inert ? null : disabled.toString()',
'[attr.aria-labelledby]': '_inert ? null : _labelId',
'[class.mat-optgroup-disabled]': 'disabled',
'[attr.aria-disabled]': 'disabled.toString()',
'[attr.aria-labelledby]': '_labelId',
},
providers: [{provide: MAT_OPTGROUP, useExisting: MatOptgroup}],
})
Expand Down
27 changes: 27 additions & 0 deletions src/material/core/option/option-parent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {InjectionToken} from '@angular/core';

/**
* Describes a parent component that manages a list of options.
* Contains properties that the options can inherit.
* @docs-private
*/
export interface MatOptionParentComponent {
disableRipple?: boolean;
multiple?: boolean;
inertGroups?: boolean;
}

/**
* Injection token used to provide the parent component to options.
*/
export const MAT_OPTION_PARENT_COMPONENT =
new InjectionToken<MatOptionParentComponent>('MAT_OPTION_PARENT_COMPONENT');

3 changes: 3 additions & 0 deletions src/material/core/option/option.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

<span class="mat-option-text"><ng-content></ng-content></span>

<!-- See a11y notes inside optgroup.ts for context behind this element. -->
<span class="cdk-visually-hidden" *ngIf="group && group._inert">({{ group.label }})</span>

<div class="mat-option-ripple" mat-ripple
[matRippleTrigger]="_getHostElement()"
[matRippleDisabled]="disabled || disableRipple">
Expand Down
43 changes: 42 additions & 1 deletion src/material/core/option/option.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
dispatchEvent,
} from '@angular/cdk/testing/private';
import {SPACE, ENTER} from '@angular/cdk/keycodes';
import {MatOption, MatOptionModule} from './index';
import {MatOption, MatOptionModule, MAT_OPTION_PARENT_COMPONENT} from './index';

describe('MatOption component', () => {

Expand Down Expand Up @@ -196,6 +196,37 @@ describe('MatOption component', () => {
expect(optionNativeElement.classList.contains('mat-focus-indicator')).toBe(true);
});

describe('inside inert group', () => {
let fixture: ComponentFixture<InsideGroup>;

beforeEach(waitForAsync(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [MatOptionModule],
declarations: [InsideGroup],
providers: [{
provide: MAT_OPTION_PARENT_COMPONENT,
useValue: {inertGroups: true}
}]
}).compileComponents();

fixture = TestBed.createComponent(InsideGroup);
fixture.detectChanges();
}));

it('should remove all accessibility-related attributes from the group', () => {
const group: HTMLElement = fixture.nativeElement.querySelector('mat-optgroup');
expect(group.hasAttribute('role')).toBe(false);
expect(group.hasAttribute('aria-disabled')).toBe(false);
expect(group.hasAttribute('aria-labelledby')).toBe(false);
});

it('should mirror the group label inside the option', () => {
const option: HTMLElement = fixture.nativeElement.querySelector('mat-option');
expect(option.textContent?.trim()).toBe('Option(Group)');
});
});

});

@Component({
Expand All @@ -205,3 +236,13 @@ class BasicOption {
disabled: boolean;
id: string;
}

@Component({
template: `
<mat-optgroup label="Group">
<mat-option>Option</mat-option>
</mat-optgroup>
`
})
class InsideGroup {
}
19 changes: 1 addition & 18 deletions src/material/core/option/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
ElementRef,
EventEmitter,
Inject,
InjectionToken,
Input,
OnDestroy,
Optional,
Expand All @@ -28,6 +27,7 @@ import {
import {FocusOptions, FocusableOption, FocusOrigin} from '@angular/cdk/a11y';
import {Subject} from 'rxjs';
import {MatOptgroup, _MatOptgroupBase, MAT_OPTGROUP} from './optgroup';
import {MatOptionParentComponent, MAT_OPTION_PARENT_COMPONENT} from './option-parent';

/**
* Option IDs need to be unique across components, so this counter exists outside of
Expand All @@ -44,23 +44,6 @@ export class MatOptionSelectionChange {
public isUserInput = false) { }
}

/**
* Describes a parent component that manages a list of options.
* Contains properties that the options can inherit.
* @docs-private
*/
export interface MatOptionParentComponent {
disableRipple?: boolean;
multiple?: boolean;
}

/**
* Injection token used to provide the parent component to options.
*/
export const MAT_OPTION_PARENT_COMPONENT =
new InjectionToken<MatOptionParentComponent>('MAT_OPTION_PARENT_COMPONENT');


@Directive()
export class _MatOptionBase implements FocusableOption, AfterViewChecked, OnDestroy {
private _selected = false;
Expand Down
5 changes: 4 additions & 1 deletion src/material/core/testing/option-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export class MatOptionHarness extends ComponentHarness {
/** Selector used to locate option instances. */
static hostSelector = '.mat-option';

/** Element containing the option's text. */
private _text = this.locatorFor('.mat-option-text');

/**
* Gets a `HarnessPredicate` that can be used to search for a `MatOptionsHarness` that meets
* certain criteria.
Expand All @@ -37,7 +40,7 @@ export class MatOptionHarness extends ComponentHarness {

/** Gets the option's label text. */
async getText(): Promise<string> {
return (await this.host()).text();
return (await this._text()).text();
}

/** Gets whether the option is disabled. */
Expand Down
Loading