Skip to content

Commit 38b5e5d

Browse files
committed
fix(icon): remove automatic aria labelling and add a11y guidance
The automatic application of aria-label for md-icon, in hindsight, was not a great idea. Neither the ligature string nor the SVG filename are likely to be meaningful descriptions. Even in cases where they *are*, the icon may be used in such a way that the application of a label is still the wrong thing. On top of that, adding `role="img"` is usually not the right approach for an icon, as that role typically refers to *content* images, rather than icons that are part of the UI. Ultimately, it is up to the application developer to add the appropriate meaning for icons based on how they're used. To this end, we add guidance to the documentation for what to do in different situations.
1 parent c946631 commit 38b5e5d

File tree

3 files changed

+36
-153
lines changed

3 files changed

+36
-153
lines changed

src/lib/icon/icon.md

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ Some fonts are designed to show icons by using
1515
"home" as a home image. To use a ligature icon, put its text in the content of the `md-icon`
1616
component.
1717

18-
By default the
19-
[Material icons font](http://google.github.io/material-design-icons/#icon-font-for-the-web) is used.
18+
By default, `<md-icon>` expects the
19+
[Material icons font](http://google.github.io/material-design-icons/#icon-font-for-the-web).
2020
(You will still need to include the HTML to load the font and its CSS, as described in the link).
2121
You can specify a different font by setting the `fontSet` input to either the CSS class to apply to
2222
use the desired font, or to an alias previously registered with
@@ -80,9 +80,32 @@ match the current theme's colors using the `color` attribute. This can be change
8080

8181
### Accessibility
8282

83-
If an `aria-label` attribute is set on the `md-icon` element, its value will be used as-is. If not,
84-
the md-icon component will attempt to set the aria-label value from one of these sources:
85-
* The `alt` attribute
86-
* The `fontIcon` input
87-
* The name of the icon from the `svgIcon` input (not including any namespace)
88-
* The text content of the component (for ligature icons)
83+
Similar to an `<img>` element, an icon alone does not convey any useful information for a
84+
screen-reader user. The user of `<md-icon>` must provide additional information pertaining to how
85+
the icon is used.
86+
87+
In thinking about accessibility, it is useful to place icon use into one of three categories:
88+
1. **Decorative**: the icon conveys no real semantic meaning and is purely cosmetic.
89+
2. **Interactive**: a user will click or otherwise interact with the icon to perform some action.
90+
3. **Indicator**: the icon is not interactive, but it conveys some information, such as a status.
91+
92+
#### Decorative icons
93+
When the icon is puely cosmetic and conveys no real semantic meaning, the `<md-icon>` element
94+
should be marked with `aria-hidden="true"`.
95+
96+
#### Interactive icons
97+
Icons alone are not interactive elements for screen-reader users; when the user would interact with
98+
some icon on the page, a more appropriate element should "own" the interaction:
99+
* The `<md-icon>` element should be a child of a `<button>` or `<a>` element.
100+
* The `<md-icon>` element should be marked with `aria-hidden="true"`.
101+
* The parent `<button>` or `<a>` should either have a meaningful label provided either through
102+
direct text content, `aria-label`, or `aria-labelledby`.
103+
104+
#### Indicator icons
105+
When the presence of an icon communicates some information to the user, that information must also
106+
be made available to screen-readers. The most straightforward way to do this is to
107+
1. Mark the `<md-icon>` as `aria-hidden="true"`
108+
2. Add a `<span>` as an adjacent sibling to the `<md-icon>` element with text that conveys the same
109+
information as the icon.
110+
3. Add the `cdk-visually-hidden` class to the `<span>`. This will make the message invisible
111+
on-screen but still available to screen-reader users.

src/lib/icon/icon.spec.ts

Lines changed: 2 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ describe('MdIcon', () => {
4141
declarations: [
4242
MdIconColorTestApp,
4343
MdIconLigatureTestApp,
44-
MdIconLigatureWithAriaBindingTestApp,
4544
MdIconCustomFontCssTestApp,
4645
MdIconFromSvgNameTestApp,
4746
],
@@ -121,15 +120,12 @@ describe('MdIcon', () => {
121120
fixture.detectChanges();
122121
svgElement = verifyAndGetSingleSvgChild(mdIconElement);
123122
verifyPathChildElement(svgElement, 'woof');
124-
// The aria label should be taken from the icon name.
125-
expect(mdIconElement.getAttribute('aria-label')).toBe('fido');
126123

127124
// Change the icon, and the SVG element should be replaced.
128125
testComponent.iconName = 'fluffy';
129126
fixture.detectChanges();
130127
svgElement = verifyAndGetSingleSvgChild(mdIconElement);
131128
verifyPathChildElement(svgElement, 'meow');
132-
expect(mdIconElement.getAttribute('aria-label')).toBe('fluffy');
133129

134130
expect(httpRequestUrls).toEqual(['dog.svg', 'cat.svg']);
135131
// Using an icon from a previously loaded URL should not cause another HTTP request.
@@ -181,8 +177,6 @@ describe('MdIcon', () => {
181177
expect(svgChild.tagName.toLowerCase()).toBe('g');
182178
expect(svgChild.getAttribute('id')).toBe('pig');
183179
verifyPathChildElement(svgChild, 'oink');
184-
// The aria label should be taken from the icon name (without the icon set portion).
185-
expect(mdIconElement.getAttribute('aria-label')).toBe('pig');
186180

187181
// Change the icon, and the SVG element should be replaced.
188182
testComponent.iconName = 'farm:cow';
@@ -193,7 +187,6 @@ describe('MdIcon', () => {
193187
expect(svgChild.tagName.toLowerCase()).toBe('g');
194188
expect(svgChild.getAttribute('id')).toBe('cow');
195189
verifyPathChildElement(svgChild, 'moo');
196-
expect(mdIconElement.getAttribute('aria-label')).toBe('cow');
197190
});
198191

199192
it('should allow multiple icon sets in a namespace', () => {
@@ -218,8 +211,6 @@ describe('MdIcon', () => {
218211
expect(svgChild.getAttribute('id')).toBe('pig');
219212
expect(svgChild.childNodes.length).toBe(1);
220213
verifyPathChildElement(svgChild, 'oink');
221-
// The aria label should be taken from the icon name (without the namespace).
222-
expect(mdIconElement.getAttribute('aria-label')).toBe('pig');
223214

224215
// Both icon sets registered in the 'farm' namespace should have been fetched.
225216
expect(httpRequestUrls.sort()).toEqual(['farm-set-1.svg', 'farm-set-2.svg']);
@@ -236,7 +227,6 @@ describe('MdIcon', () => {
236227
expect(svgChild.getAttribute('id')).toBe('cow');
237228
expect(svgChild.childNodes.length).toBe(1);
238229
verifyPathChildElement(svgChild, 'moo moo');
239-
expect(mdIconElement.getAttribute('aria-label')).toBe('cow');
240230
expect(httpRequestUrls.sort()).toEqual(['farm-set-1.svg', 'farm-set-2.svg']);
241231
});
242232

@@ -255,7 +245,6 @@ describe('MdIcon', () => {
255245
// directly and not wrapped in an outer <svg> tag like the <g> elements in other sets.
256246
svgElement = verifyAndGetSingleSvgChild(mdIconElement);
257247
verifyPathChildElement(svgElement, 'left');
258-
expect(mdIconElement.getAttribute('aria-label')).toBe('left-arrow');
259248
});
260249

261250
it('should return unmodified copies of icons from icon sets', () => {
@@ -302,89 +291,16 @@ describe('MdIcon', () => {
302291
testComponent.fontIcon = 'house';
303292
fixture.detectChanges();
304293
expect(sortedClassNames(mdIconElement)).toEqual(['font1', 'house', 'mat-icon']);
305-
expect(mdIconElement.getAttribute('aria-label')).toBe('house');
306294

307295
testComponent.fontSet = 'f2';
308296
testComponent.fontIcon = 'igloo';
309297
fixture.detectChanges();
310298
expect(sortedClassNames(mdIconElement)).toEqual(['f2', 'igloo', 'mat-icon']);
311-
expect(mdIconElement.getAttribute('aria-label')).toBe('igloo');
312299

313300
testComponent.fontSet = 'f3';
314301
testComponent.fontIcon = 'tent';
315302
fixture.detectChanges();
316303
expect(sortedClassNames(mdIconElement)).toEqual(['f3', 'mat-icon', 'tent']);
317-
expect(mdIconElement.getAttribute('aria-label')).toBe('tent');
318-
});
319-
});
320-
321-
describe('aria label', () => {
322-
it('should set aria label from text content if not specified', () => {
323-
let fixture = TestBed.createComponent(MdIconLigatureTestApp);
324-
325-
const testComponent = fixture.componentInstance;
326-
const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon');
327-
testComponent.iconName = 'home';
328-
329-
fixture.detectChanges();
330-
expect(mdIconElement.getAttribute('aria-label')).toBe('home');
331-
332-
testComponent.iconName = 'hand';
333-
fixture.detectChanges();
334-
expect(mdIconElement.getAttribute('aria-label')).toBe('hand');
335-
});
336-
337-
it('should not set aria label unless it actually changed', () => {
338-
let fixture = TestBed.createComponent(MdIconLigatureTestApp);
339-
340-
const testComponent = fixture.componentInstance;
341-
const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon');
342-
testComponent.iconName = 'home';
343-
344-
fixture.detectChanges();
345-
expect(mdIconElement.getAttribute('aria-label')).toBe('home');
346-
347-
mdIconElement.removeAttribute('aria-label');
348-
fixture.detectChanges();
349-
expect(mdIconElement.getAttribute('aria-label')).toBeFalsy();
350-
});
351-
352-
it('should use alt tag if aria label is not specified', () => {
353-
let fixture = TestBed.createComponent(MdIconLigatureWithAriaBindingTestApp);
354-
355-
const testComponent = fixture.componentInstance;
356-
const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon');
357-
testComponent.iconName = 'home';
358-
testComponent.altText = 'castle';
359-
fixture.detectChanges();
360-
expect(mdIconElement.getAttribute('aria-label')).toBe('castle');
361-
362-
testComponent.ariaLabel = 'house';
363-
fixture.detectChanges();
364-
expect(mdIconElement.getAttribute('aria-label')).toBe('house');
365-
});
366-
367-
it('should use provided aria label rather than icon name', () => {
368-
let fixture = TestBed.createComponent(MdIconLigatureWithAriaBindingTestApp);
369-
370-
const testComponent = fixture.componentInstance;
371-
const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon');
372-
testComponent.iconName = 'home';
373-
testComponent.ariaLabel = 'house';
374-
fixture.detectChanges();
375-
expect(mdIconElement.getAttribute('aria-label')).toBe('house');
376-
});
377-
378-
it('should use provided aria label rather than font icon', () => {
379-
let fixture = TestBed.createComponent(MdIconCustomFontCssTestApp);
380-
381-
const testComponent = fixture.componentInstance;
382-
const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon');
383-
testComponent.fontSet = 'f1';
384-
testComponent.fontIcon = 'house';
385-
testComponent.ariaLabel = 'home';
386-
fixture.detectChanges();
387-
expect(mdIconElement.getAttribute('aria-label')).toBe('home');
388304
});
389305
});
390306

@@ -431,35 +347,24 @@ describe('MdIcon without HttpModule', () => {
431347
/** Test components that contain an MdIcon. */
432348
@Component({template: `<md-icon>{{iconName}}</md-icon>`})
433349
class MdIconLigatureTestApp {
434-
ariaLabel: string = null;
435350
iconName = '';
436351
}
437352

438353
@Component({template: `<md-icon [color]="iconColor">{{iconName}}</md-icon>`})
439354
class MdIconColorTestApp {
440-
ariaLabel: string = null;
441355
iconName = '';
442356
iconColor = 'primary';
443357
}
444358

445-
@Component({template: `<md-icon [aria-label]="ariaLabel" [alt]="altText">{{iconName}}</md-icon>`})
446-
class MdIconLigatureWithAriaBindingTestApp {
447-
altText: string = '';
448-
ariaLabel: string = null;
449-
iconName = '';
450-
}
451-
452359
@Component({
453-
template: `<md-icon [fontSet]="fontSet" [fontIcon]="fontIcon" [aria-label]="ariaLabel"></md-icon>`
360+
template: `<md-icon [fontSet]="fontSet" [fontIcon]="fontIcon"></md-icon>`
454361
})
455362
class MdIconCustomFontCssTestApp {
456-
ariaLabel: string = null;
457363
fontSet = '';
458364
fontIcon = '';
459365
}
460366

461-
@Component({template: `<md-icon [svgIcon]="iconName" [aria-label]="ariaLabel"></md-icon>`})
367+
@Component({template: `<md-icon [svgIcon]="iconName"></md-icon>`})
462368
class MdIconFromSvgNameTestApp {
463-
ariaLabel: string = null;
464369
iconName = '';
465370
}

src/lib/icon/icon.ts

Lines changed: 3 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,12 @@ import {MdIconRegistry} from './icon-registry';
5353
styleUrls: ['icon.css'],
5454
host: {
5555
'role': 'img',
56-
'[class.mat-icon]': 'true',
56+
'class': 'mat-icon'
5757
},
5858
encapsulation: ViewEncapsulation.None,
5959
changeDetection: ChangeDetectionStrategy.OnPush,
6060
})
61-
export class MdIcon implements OnChanges, OnInit, AfterViewChecked {
61+
export class MdIcon implements OnChanges, OnInit {
6262
private _color: string;
6363

6464
/** Name of the icon in the SVG icon set. */
@@ -70,20 +70,13 @@ export class MdIcon implements OnChanges, OnInit, AfterViewChecked {
7070
/** Name of an icon within a font set. */
7171
@Input() fontIcon: string;
7272

73-
/** Alt label to be used for accessibility. */
74-
@Input() alt: string;
75-
76-
/** Screenreader label for the icon. */
77-
@Input('aria-label') hostAriaLabel: string = '';
78-
7973
/** Color of the icon. */
8074
@Input()
8175
get color(): string { return this._color; }
8276
set color(value: string) { this._updateColor(value); }
8377

8478
private _previousFontSetClass: string;
8579
private _previousFontIconClass: string;
86-
private _previousAriaLabel: string;
8780

8881
constructor(
8982
private _elementRef: ElementRef,
@@ -135,7 +128,7 @@ export class MdIcon implements OnChanges, OnInit, AfterViewChecked {
135128
}
136129
}
137130

138-
ngOnChanges(changes: { [propertyName: string]: SimpleChange }) {
131+
ngOnChanges(changes: {[propertyName: string]: SimpleChange}) {
139132
const changedInputs = Object.keys(changes);
140133
// Only update the inline SVG icon if the inputs changed, to avoid unnecessary DOM operations.
141134
if (changedInputs.indexOf('svgIcon') != -1 || changedInputs.indexOf('svgSrc') != -1) {
@@ -149,7 +142,6 @@ export class MdIcon implements OnChanges, OnInit, AfterViewChecked {
149142
if (this._usingFontIcon()) {
150143
this._updateFontIconClasses();
151144
}
152-
this._updateAriaLabel();
153145
}
154146

155147
ngOnInit() {
@@ -160,43 +152,6 @@ export class MdIcon implements OnChanges, OnInit, AfterViewChecked {
160152
}
161153
}
162154

163-
ngAfterViewChecked() {
164-
// Update aria label here because it may depend on the projected text content.
165-
// (e.g. <md-icon>home</md-icon> should use 'home').
166-
this._updateAriaLabel();
167-
}
168-
169-
private _updateAriaLabel() {
170-
const ariaLabel = this._getAriaLabel();
171-
if (ariaLabel && ariaLabel !== this._previousAriaLabel) {
172-
this._previousAriaLabel = ariaLabel;
173-
this._renderer.setAttribute(this._elementRef.nativeElement, 'aria-label', ariaLabel);
174-
}
175-
}
176-
177-
private _getAriaLabel() {
178-
// If the parent provided an aria-label attribute value, use it as-is. Otherwise look for a
179-
// reasonable value from the alt attribute, font icon name, SVG icon name, or (for ligatures)
180-
// the text content of the directive.
181-
const label =
182-
this.hostAriaLabel ||
183-
this.alt ||
184-
this.fontIcon ||
185-
this._splitIconName(this.svgIcon)[1];
186-
if (label) {
187-
return label;
188-
}
189-
// The "content" of an SVG icon is not a useful label.
190-
if (this._usingFontIcon()) {
191-
const text = this._elementRef.nativeElement.textContent;
192-
if (text) {
193-
return text;
194-
}
195-
}
196-
// TODO: Warn here in dev mode.
197-
return null;
198-
}
199-
200155
private _usingFontIcon(): boolean {
201156
return !this.svgIcon;
202157
}

0 commit comments

Comments
 (0)