Skip to content

Commit f54262f

Browse files
committed
feat(material/icon): SEO friendly ligature icons
The new way to use ligature icons, via the `fontIcon` attribute, allows to hide the font name from search engine results. Otherwise, the font name, which was never intended to be read by any end-users, would appear in the middle of legit sentences in search results, thus making the search result very confusing to read. New recommended usage is: ```diff - <mat-icon>home</mat-icon> + <mat-icon fontIcon="home"></mat-icon> ``` To also enable this for custom font, include the special `mat-ligature-font` class when registering the font alias. So like this: ```ts iconRegistry.registerFontClassAlias('f1', 'font1 mat-ligature-font'); ``` And use like this: ```html <mat-icon fontSet="f1" fontIcon="home"></mat-icon> ``` Fixes #23195 Fixes #23183 Fixes google/material-design-icons#498
1 parent b6e3b41 commit f54262f

File tree

9 files changed

+129
-31
lines changed

9 files changed

+129
-31
lines changed

src/components-examples/material/icon/icon-harness/icon-harness-example.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,24 +32,24 @@ describe('IconHarnessExample', () => {
3232

3333
it('should load all icon harnesses', async () => {
3434
const icons = await loader.getAllHarnesses(MatIconHarness);
35-
expect(icons.length).toBe(3);
35+
expect(icons.length).toBe(4);
3636
});
3737

3838
it('should get the name of an icon', async () => {
3939
const icons = await loader.getAllHarnesses(MatIconHarness);
4040
const names = await parallel(() => icons.map(icon => icon.getName()));
41-
expect(names).toEqual(['fontIcon', 'svgIcon', 'ligature_icon']);
41+
expect(names).toEqual(['fontIcon', 'svgIcon', 'ligature_icon', 'ligature_icon_by_attribute']);
4242
});
4343

4444
it('should get the namespace of an icon', async () => {
4545
const icons = await loader.getAllHarnesses(MatIconHarness);
4646
const namespaces = await parallel(() => icons.map(icon => icon.getNamespace()));
47-
expect(namespaces).toEqual(['fontIcons', 'svgIcons', null]);
47+
expect(namespaces).toEqual(['fontIcons', 'svgIcons', null, null]);
4848
});
4949

5050
it('should get whether an icon is inline', async () => {
5151
const icons = await loader.getAllHarnesses(MatIconHarness);
5252
const inlineStates = await parallel(() => icons.map(icon => icon.isInline()));
53-
expect(inlineStates).toEqual([false, false, true]);
53+
expect(inlineStates).toEqual([false, false, true, false]);
5454
});
5555
});
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<mat-icon aria-hidden="false" aria-label="Example home icon">home</mat-icon>
1+
<mat-icon aria-hidden="false" aria-label="Example home icon" fontIcon="home"></mat-icon>

src/dev-app/icon/icon-demo.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@
3838
</p>
3939

4040
<p>
41-
Ligature from Material Icons font:
41+
Ligature from Material Icons font by attribute:
42+
<mat-icon fontIcon="home"></mat-icon>
43+
</p>
44+
45+
<p>
46+
Ligature from Material Icons font by content:
4247
<mat-icon>home</mat-icon>
4348
</p>
4449

src/material/icon/icon-registry.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ import {
1313
Inject,
1414
Injectable,
1515
InjectionToken,
16+
OnDestroy,
1617
Optional,
1718
SecurityContext,
1819
SkipSelf,
19-
OnDestroy,
2020
} from '@angular/core';
21-
import {DomSanitizer, SafeResourceUrl, SafeHtml} from '@angular/platform-browser';
21+
import {DomSanitizer, SafeHtml, SafeResourceUrl} from '@angular/platform-browser';
2222
import {forkJoin, Observable, of as observableOf, throwError as observableThrow} from 'rxjs';
2323
import {catchError, finalize, map, share, tap} from 'rxjs/operators';
2424
import {TrustedHTML, trustedHTMLFromString} from './trusted-types';
@@ -149,7 +149,7 @@ export class MatIconRegistry implements OnDestroy {
149149
* specified. The default 'material-icons' value assumes that the material icon font has been
150150
* loaded as described at http://google.github.io/material-design-icons/#icon-font-for-the-web
151151
*/
152-
private _defaultFontSetClass = ['material-icons'];
152+
private _defaultFontSetClass = ['material-icons', 'mat-ligature-font'];
153153

154154
constructor(
155155
@Optional() private _httpClient: HttpClient,
@@ -281,15 +281,28 @@ export class MatIconRegistry implements OnDestroy {
281281
}
282282

283283
/**
284-
* Defines an alias for a CSS class name to be used for icon fonts. Creating an matIcon
284+
* Defines an alias for CSS class names to be used for icon fonts. Creating an matIcon
285285
* component with the alias as the fontSet input will cause the class name to be applied
286286
* to the `<mat-icon>` element.
287287
*
288+
* If the registered font is a ligature font, then don't forget to also include the special
289+
* class `mat-ligature-font` to allow the usage via attribute. So register like this:
290+
*
291+
* ```ts
292+
* iconRegistry.registerFontClassAlias('f1', 'font1 mat-ligature-font');
293+
* ```
294+
*
295+
* And use like this:
296+
*
297+
* ```html
298+
* <mat-icon fontSet="f1" fontIcon="home"></mat-icon>
299+
* ```
300+
*
288301
* @param alias Alias for the font.
289-
* @param className Class name override to be used instead of the alias.
302+
* @param classNames Class names override to be used instead of the alias.
290303
*/
291-
registerFontClassAlias(alias: string, className: string = alias): this {
292-
this._fontCssClassesByAlias.set(alias, className);
304+
registerFontClassAlias(alias: string, classNames: string = alias): this {
305+
this._fontCssClassesByAlias.set(alias, classNames);
293306
return this;
294307
}
295308

src/material/icon/icon.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ $size: 24px !default;
2121
line-height: inherit;
2222
width: inherit;
2323
}
24+
25+
&.mat-ligature-font[fontIcon]::before {
26+
content: attr(fontIcon);
27+
}
2428
}
2529

2630
// Icons that will be mirrored in RTL.

src/material/icon/icon.spec.ts

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ describe('MatIcon', () => {
6969
declarations: [
7070
IconWithColor,
7171
IconWithLigature,
72+
IconWithLigatureByAttribute,
7273
IconWithCustomFontCss,
7374
IconFromSvgName,
7475
IconWithAriaHiddenFalse,
@@ -125,6 +126,7 @@ describe('MatIcon', () => {
125126
fixture.detectChanges();
126127
expect(sortedClassNames(matIconElement)).toEqual([
127128
'mat-icon',
129+
'mat-ligature-font',
128130
'mat-primary',
129131
'material-icons',
130132
'notranslate',
@@ -143,6 +145,7 @@ describe('MatIcon', () => {
143145
expect(sortedClassNames(matIconElement)).toEqual([
144146
'mat-icon',
145147
'mat-icon-no-color',
148+
'mat-ligature-font',
146149
'material-icons',
147150
'notranslate',
148151
]);
@@ -179,7 +182,7 @@ describe('MatIcon', () => {
179182
});
180183

181184
describe('Ligature icons', () => {
182-
it('should add material-icons class by default', () => {
185+
it('should add material-icons and mat-ligature-font class by default', () => {
183186
const fixture = TestBed.createComponent(IconWithLigature);
184187

185188
const testComponent = fixture.componentInstance;
@@ -189,13 +192,14 @@ describe('MatIcon', () => {
189192
expect(sortedClassNames(matIconElement)).toEqual([
190193
'mat-icon',
191194
'mat-icon-no-color',
195+
'mat-ligature-font',
192196
'material-icons',
193197
'notranslate',
194198
]);
195199
});
196200

197201
it('should use alternate icon font if set', () => {
198-
iconRegistry.setDefaultFontSetClass('myfont');
202+
iconRegistry.setDefaultFontSetClass('myfont', 'mat-ligature-font');
199203

200204
const fixture = TestBed.createComponent(IconWithLigature);
201205

@@ -206,6 +210,7 @@ describe('MatIcon', () => {
206210
expect(sortedClassNames(matIconElement)).toEqual([
207211
'mat-icon',
208212
'mat-icon-no-color',
213+
'mat-ligature-font',
209214
'myfont',
210215
'notranslate',
211216
]);
@@ -223,7 +228,7 @@ describe('MatIcon', () => {
223228
});
224229

225230
it('should be able to provide multiple alternate icon set classes', () => {
226-
iconRegistry.setDefaultFontSetClass('myfont', 'myfont-48x48');
231+
iconRegistry.setDefaultFontSetClass('myfont', 'mat-ligature-font', 'myfont-48x48');
227232

228233
let fixture = TestBed.createComponent(IconWithLigature);
229234

@@ -234,6 +239,62 @@ describe('MatIcon', () => {
234239
expect(sortedClassNames(matIconElement)).toEqual([
235240
'mat-icon',
236241
'mat-icon-no-color',
242+
'mat-ligature-font',
243+
'myfont',
244+
'myfont-48x48',
245+
'notranslate',
246+
]);
247+
});
248+
});
249+
250+
describe('Ligature icons by attribute', () => {
251+
it('should add material-icons and mat-ligature-font class by default', () => {
252+
const fixture = TestBed.createComponent(IconWithLigatureByAttribute);
253+
254+
const testComponent = fixture.componentInstance;
255+
const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
256+
testComponent.iconName = 'home';
257+
fixture.detectChanges();
258+
expect(sortedClassNames(matIconElement)).toEqual([
259+
'mat-icon',
260+
'mat-icon-no-color',
261+
'mat-ligature-font',
262+
'material-icons',
263+
'notranslate',
264+
]);
265+
});
266+
267+
it('should use alternate icon font if set', () => {
268+
iconRegistry.setDefaultFontSetClass('myfont', 'mat-ligature-font');
269+
270+
const fixture = TestBed.createComponent(IconWithLigatureByAttribute);
271+
272+
const testComponent = fixture.componentInstance;
273+
const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
274+
testComponent.iconName = 'home';
275+
fixture.detectChanges();
276+
expect(sortedClassNames(matIconElement)).toEqual([
277+
'mat-icon',
278+
'mat-icon-no-color',
279+
'mat-ligature-font',
280+
'myfont',
281+
'notranslate',
282+
]);
283+
});
284+
285+
it('should be able to provide multiple alternate icon set classes', () => {
286+
iconRegistry.setDefaultFontSetClass('myfont', 'mat-ligature-font', 'myfont-48x48');
287+
288+
let fixture = TestBed.createComponent(IconWithLigatureByAttribute);
289+
290+
const testComponent = fixture.componentInstance;
291+
const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
292+
testComponent.iconName = 'home';
293+
fixture.detectChanges();
294+
expect(sortedClassNames(matIconElement)).toEqual([
295+
'mat-icon',
296+
'mat-icon-no-color',
297+
'mat-ligature-font',
237298
'myfont',
238299
'myfont-48x48',
239300
'notranslate',
@@ -1042,17 +1103,18 @@ describe('MatIcon', () => {
10421103
it('should handle values with extraneous spaces being passed in to `fontIcon`', () => {
10431104
const fixture = TestBed.createComponent(IconWithCustomFontCss);
10441105
const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
1106+
fixture.componentInstance.fontSet = 'f1';
10451107

10461108
expect(() => {
10471109
fixture.componentInstance.fontIcon = 'font icon';
10481110
fixture.detectChanges();
10491111
}).not.toThrow();
10501112

10511113
expect(sortedClassNames(matIconElement)).toEqual([
1114+
'f1',
10521115
'font',
10531116
'mat-icon',
10541117
'mat-icon-no-color',
1055-
'material-icons',
10561118
'notranslate',
10571119
]);
10581120

@@ -1063,9 +1125,9 @@ describe('MatIcon', () => {
10631125

10641126
expect(sortedClassNames(matIconElement)).toEqual([
10651127
'changed',
1128+
'f1',
10661129
'mat-icon',
10671130
'mat-icon-no-color',
1068-
'material-icons',
10691131
'notranslate',
10701132
]);
10711133
});
@@ -1311,6 +1373,11 @@ class IconWithLigature {
13111373
iconName = '';
13121374
}
13131375

1376+
@Component({template: `<mat-icon [fontIcon]="iconName"></mat-icon>`})
1377+
class IconWithLigatureByAttribute {
1378+
iconName = '';
1379+
}
1380+
13141381
@Component({template: `<mat-icon [color]="iconColor">{{iconName}}</mat-icon>`})
13151382
class IconWithColor {
13161383
iconName = '';

src/material/icon/icon.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,18 @@ const funcIriPattern = /^url\(['"]?#(.*?)['"]?\)$/;
114114
* `<mat-icon svgIcon="left-arrow"></mat-icon>
115115
* <mat-icon svgIcon="animals:cat"></mat-icon>`
116116
*
117-
* - Use a font ligature as an icon by putting the ligature text in the content of the `<mat-icon>`
118-
* component. By default the Material icons font is used as described at
117+
* - Use a font ligature as an icon by putting the ligature text in the `fontIcon` attribute or the
118+
* content of the `<mat-icon>` component. If you register a custom font class, don't forget to also
119+
* include the special class `mat-ligature-font`. It is recommended to use the attribute alternative
120+
* to prevent the ligature text to be selectable and to appear in search engine results.
121+
* By default, the Material icons font is used as described at
119122
* http://google.github.io/material-design-icons/#icon-font-for-the-web. You can specify an
120123
* alternate font by setting the fontSet input to either the CSS class to apply to use the
121124
* desired font, or to an alias previously registered with MatIconRegistry.registerFontClassAlias.
122125
* Examples:
123-
* `<mat-icon>home</mat-icon>
126+
* `<mat-icon fontIcon="home"></mat-icon>
127+
* <mat-icon>home</mat-icon>
128+
* <mat-icon fontSet="myfont" fontIcon="sun"></mat-icon>
124129
* <mat-icon fontSet="myfont">sun</mat-icon>`
125130
*
126131
* - Specify a font glyph to be included via CSS rules by setting the fontSet input to specify the
@@ -359,15 +364,18 @@ export class MatIcon extends _MatIconBase implements OnInit, AfterViewChecked, C
359364
const elem: HTMLElement = this._elementRef.nativeElement;
360365
const fontSetClasses = (
361366
this.fontSet
362-
? [this._iconRegistry.classNameForFontAlias(this.fontSet)]
367+
? this._iconRegistry.classNameForFontAlias(this.fontSet).split(/ +/)
363368
: this._iconRegistry.getDefaultFontSetClass()
364369
).filter(className => className.length > 0);
365370

366371
this._previousFontSetClass.forEach(className => elem.classList.remove(className));
367372
fontSetClasses.forEach(className => elem.classList.add(className));
368373
this._previousFontSetClass = fontSetClasses;
369374

370-
if (this.fontIcon !== this._previousFontIconClass) {
375+
if (
376+
this.fontIcon !== this._previousFontIconClass &&
377+
!fontSetClasses.includes('mat-ligature-font')
378+
) {
371379
if (this._previousFontIconClass) {
372380
elem.classList.remove(this._previousFontIconClass);
373381
}

src/material/icon/testing/shared.spec.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function runHarnessTests(
3737

3838
it('should load all icon harnesses', async () => {
3939
const icons = await loader.getAllHarnesses(iconHarness);
40-
expect(icons.length).toBe(3);
40+
expect(icons.length).toBe(4);
4141
});
4242

4343
it('should filter icon harnesses based on their type', async () => {
@@ -47,7 +47,7 @@ export function runHarnessTests(
4747
]);
4848

4949
expect(svgIcons.length).toBe(1);
50-
expect(fontIcons.length).toBe(2);
50+
expect(fontIcons.length).toBe(3);
5151
});
5252

5353
it('should filter icon harnesses based on their name', async () => {
@@ -69,31 +69,31 @@ export function runHarnessTests(
6969

7070
expect(regexFilterResults.length).toBe(1);
7171
expect(stringFilterResults.length).toBe(1);
72-
expect(nullFilterResults.length).toBe(1);
72+
expect(nullFilterResults.length).toBe(2);
7373
});
7474

7575
it('should get the type of each icon', async () => {
7676
const icons = await loader.getAllHarnesses(iconHarness);
7777
const types = await parallel(() => icons.map(icon => icon.getType()));
78-
expect(types).toEqual([IconType.FONT, IconType.SVG, IconType.FONT]);
78+
expect(types).toEqual([IconType.FONT, IconType.SVG, IconType.FONT, IconType.FONT]);
7979
});
8080

8181
it('should get the name of an icon', async () => {
8282
const icons = await loader.getAllHarnesses(iconHarness);
8383
const names = await parallel(() => icons.map(icon => icon.getName()));
84-
expect(names).toEqual(['fontIcon', 'svgIcon', 'ligature_icon']);
84+
expect(names).toEqual(['fontIcon', 'svgIcon', 'ligature_icon', 'ligature_icon_by_attribute']);
8585
});
8686

8787
it('should get the namespace of an icon', async () => {
8888
const icons = await loader.getAllHarnesses(iconHarness);
8989
const namespaces = await parallel(() => icons.map(icon => icon.getNamespace()));
90-
expect(namespaces).toEqual(['fontIcons', 'svgIcons', null]);
90+
expect(namespaces).toEqual(['fontIcons', 'svgIcons', null, null]);
9191
});
9292

9393
it('should get whether an icon is inline', async () => {
9494
const icons = await loader.getAllHarnesses(iconHarness);
9595
const inlineStates = await parallel(() => icons.map(icon => icon.isInline()));
96-
expect(inlineStates).toEqual([false, false, true]);
96+
expect(inlineStates).toEqual([false, false, true, false]);
9797
});
9898
}
9999

@@ -102,6 +102,7 @@ export function runHarnessTests(
102102
<mat-icon fontSet="fontIcons" fontIcon="fontIcon"></mat-icon>
103103
<mat-icon svgIcon="svgIcons:svgIcon"></mat-icon>
104104
<mat-icon inline>ligature_icon</mat-icon>
105+
<mat-icon fontIcon="ligature_icon_by_attribute"></mat-icon>
105106
`,
106107
})
107108
class IconHarnessTest {}

0 commit comments

Comments
 (0)