Skip to content

Commit 4b54d56

Browse files
committed
feat(material/icon): SEO friendly ligature icons
The new way to use ligature icons, via a dedicated 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>my-icon-name</mat-icon> + <mat-icon icon="my-icon-name"></mat-icon> ``` Fixes #23195 Fixes #23183 Fixes google/material-design-icons#498
1 parent b6e3b41 commit 4b54d56

File tree

9 files changed

+151
-64
lines changed

9 files changed

+151
-64
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
<mat-icon fontSet="fontIcons" fontIcon="fontIcon"></mat-icon>
22
<mat-icon svgIcon="svgIcons:svgIcon"></mat-icon>
33
<mat-icon inline>ligature_icon</mat-icon>
4+
<mat-icon inline icon="ligature_icon_by_attribute"></mat-icon>

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, true]);
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" icon="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 icon="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.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+
&[icon]::before {
26+
content: attr(icon);
27+
}
2428
}
2529

2630
// Icons that will be mirrored in RTL.

src/material/icon/icon.spec.ts

Lines changed: 105 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import {inject, waitForAsync, fakeAsync, tick, TestBed} from '@angular/core/testing';
2-
import {SafeResourceUrl, DomSanitizer, SafeHtml} from '@angular/platform-browser';
1+
import {fakeAsync, inject, TestBed, tick, waitForAsync} from '@angular/core/testing';
2+
import {DomSanitizer, SafeHtml, SafeResourceUrl} from '@angular/platform-browser';
33
import {
44
HttpClientTestingModule,
55
HttpTestingController,
66
TestRequest,
77
} from '@angular/common/http/testing';
88
import {Component, ErrorHandler, Provider, Type, ViewChild} from '@angular/core';
99
import {MAT_ICON_DEFAULT_OPTIONS, MAT_ICON_LOCATION, MatIconModule} from './index';
10-
import {MatIconRegistry, getMatIconNoHttpProviderError} from './icon-registry';
10+
import {getMatIconNoHttpProviderError, MatIconRegistry} from './icon-registry';
1111
import {FAKE_SVGS} from './fake-svgs';
1212
import {wrappedErrorMessage} from '../../cdk/testing/private';
1313
import {MatIcon} from './icon';
@@ -58,40 +58,43 @@ describe('MatIcon', () => {
5858
let fakePath: string;
5959
let errorHandler: jasmine.SpyObj<ErrorHandler>;
6060

61-
beforeEach(waitForAsync(() => {
62-
// The $ prefix tells Karma not to try to process the
63-
// request so that we don't get warnings in our logs.
64-
fakePath = '/$fake-path';
65-
errorHandler = jasmine.createSpyObj('errorHandler', ['handleError']);
66-
67-
TestBed.configureTestingModule({
68-
imports: [HttpClientTestingModule, MatIconModule],
69-
declarations: [
70-
IconWithColor,
71-
IconWithLigature,
72-
IconWithCustomFontCss,
73-
IconFromSvgName,
74-
IconWithAriaHiddenFalse,
75-
IconWithBindingAndNgIf,
76-
InlineIcon,
77-
SvgIconWithUserContent,
78-
IconWithLigatureAndSvgBinding,
79-
BlankIcon,
80-
],
81-
providers: [
82-
{
83-
provide: MAT_ICON_LOCATION,
84-
useValue: {getPathname: () => fakePath},
85-
},
86-
{
87-
provide: ErrorHandler,
88-
useValue: errorHandler,
89-
},
90-
],
91-
});
61+
beforeEach(
62+
waitForAsync(() => {
63+
// The $ prefix tells Karma not to try to process the
64+
// request so that we don't get warnings in our logs.
65+
fakePath = '/$fake-path';
66+
errorHandler = jasmine.createSpyObj('errorHandler', ['handleError']);
67+
68+
TestBed.configureTestingModule({
69+
imports: [HttpClientTestingModule, MatIconModule],
70+
declarations: [
71+
IconWithColor,
72+
IconWithLigature,
73+
IconWithLigatureByAttribute,
74+
IconWithCustomFontCss,
75+
IconFromSvgName,
76+
IconWithAriaHiddenFalse,
77+
IconWithBindingAndNgIf,
78+
InlineIcon,
79+
SvgIconWithUserContent,
80+
IconWithLigatureAndSvgBinding,
81+
BlankIcon,
82+
],
83+
providers: [
84+
{
85+
provide: MAT_ICON_LOCATION,
86+
useValue: {getPathname: () => fakePath},
87+
},
88+
{
89+
provide: ErrorHandler,
90+
useValue: errorHandler,
91+
},
92+
],
93+
});
9294

93-
TestBed.compileComponents();
94-
}));
95+
TestBed.compileComponents();
96+
}),
97+
);
9598

9699
let iconRegistry: MatIconRegistry;
97100
let http: HttpTestingController;
@@ -241,6 +244,58 @@ describe('MatIcon', () => {
241244
});
242245
});
243246

247+
describe('Ligature icons by attribute', () => {
248+
it('should add material-icons class by default', () => {
249+
const fixture = TestBed.createComponent(IconWithLigatureByAttribute);
250+
251+
const testComponent = fixture.componentInstance;
252+
const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
253+
testComponent.iconName = 'home';
254+
fixture.detectChanges();
255+
expect(sortedClassNames(matIconElement)).toEqual([
256+
'mat-icon',
257+
'mat-icon-no-color',
258+
'material-icons',
259+
'notranslate',
260+
]);
261+
});
262+
263+
it('should use alternate icon font if set', () => {
264+
iconRegistry.setDefaultFontSetClass('myfont');
265+
266+
const fixture = TestBed.createComponent(IconWithLigatureByAttribute);
267+
268+
const testComponent = fixture.componentInstance;
269+
const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
270+
testComponent.iconName = 'home';
271+
fixture.detectChanges();
272+
expect(sortedClassNames(matIconElement)).toEqual([
273+
'mat-icon',
274+
'mat-icon-no-color',
275+
'myfont',
276+
'notranslate',
277+
]);
278+
});
279+
280+
it('should be able to provide multiple alternate icon set classes', () => {
281+
iconRegistry.setDefaultFontSetClass('myfont', 'myfont-48x48');
282+
283+
let fixture = TestBed.createComponent(IconWithLigatureByAttribute);
284+
285+
const testComponent = fixture.componentInstance;
286+
const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
287+
testComponent.iconName = 'home';
288+
fixture.detectChanges();
289+
expect(sortedClassNames(matIconElement)).toEqual([
290+
'mat-icon',
291+
'mat-icon-no-color',
292+
'myfont',
293+
'myfont-48x48',
294+
'notranslate',
295+
]);
296+
});
297+
});
298+
244299
describe('Icons from URLs', () => {
245300
it('should register icon URLs by name', fakeAsync(() => {
246301
iconRegistry.addSvgIcon('fluffy', trustUrl('cat.svg'));
@@ -1213,14 +1268,16 @@ describe('MatIcon without HttpClientModule', () => {
12131268
let iconRegistry: MatIconRegistry;
12141269
let sanitizer: DomSanitizer;
12151270

1216-
beforeEach(waitForAsync(() => {
1217-
TestBed.configureTestingModule({
1218-
imports: [MatIconModule],
1219-
declarations: [IconFromSvgName],
1220-
});
1271+
beforeEach(
1272+
waitForAsync(() => {
1273+
TestBed.configureTestingModule({
1274+
imports: [MatIconModule],
1275+
declarations: [IconFromSvgName],
1276+
});
12211277

1222-
TestBed.compileComponents();
1223-
}));
1278+
TestBed.compileComponents();
1279+
}),
1280+
);
12241281

12251282
beforeEach(inject([MatIconRegistry, DomSanitizer], (mir: MatIconRegistry, ds: DomSanitizer) => {
12261283
iconRegistry = mir;
@@ -1311,6 +1368,11 @@ class IconWithLigature {
13111368
iconName = '';
13121369
}
13131370

1371+
@Component({template: `<mat-icon [icon]="iconName"></mat-icon>`})
1372+
class IconWithLigatureByAttribute {
1373+
iconName = '';
1374+
}
1375+
13141376
@Component({template: `<mat-icon [color]="iconColor">{{iconName}}</mat-icon>`})
13151377
class IconWithColor {
13161378
iconName = '';

src/material/icon/icon.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
Optional,
2525
ViewEncapsulation,
2626
} from '@angular/core';
27-
import {CanColor, ThemePalette, mixinColor} from '@angular/material/core';
27+
import {CanColor, mixinColor, ThemePalette} from '@angular/material/core';
2828
import {Subscription} from 'rxjs';
2929
import {take} from 'rxjs/operators';
3030

@@ -114,13 +114,16 @@ 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 `icon` attribute or the
118+
* content of the `<mat-icon>` component. It is recommended to use the attribute alternative
119+
* to prevent the ligature text to be selectable and to appear in search engine results.
120+
* By default the Material icons font is used as described at
119121
* http://google.github.io/material-design-icons/#icon-font-for-the-web. You can specify an
120122
* alternate font by setting the fontSet input to either the CSS class to apply to use the
121123
* desired font, or to an alias previously registered with MatIconRegistry.registerFontClassAlias.
122-
* Examples:
123-
* `<mat-icon>home</mat-icon>
124+
* `<mat-icon icon="home"></mat-icon>
125+
* <mat-icon fontSet="myfont" icon="sun"></mat-icon>
126+
* <mat-icon>home</mat-icon>
124127
* <mat-icon fontSet="myfont">sun</mat-icon>`
125128
*
126129
* - Specify a font glyph to be included via CSS rules by setting the fontSet input to specify the
@@ -140,7 +143,7 @@ const funcIriPattern = /^url\(['"]?#(.*?)['"]?\)$/;
140143
'role': 'img',
141144
'class': 'mat-icon notranslate',
142145
'[attr.data-mat-icon-type]': '_usingFontIcon() ? "font" : "svg"',
143-
'[attr.data-mat-icon-name]': '_svgName || fontIcon',
146+
'[attr.data-mat-icon-name]': '_svgName || fontIcon || icon',
144147
'[attr.data-mat-icon-namespace]': '_svgNamespace || fontSet',
145148
'[class.mat-icon-inline]': 'inline',
146149
'[class.mat-icon-no-color]': 'color !== "primary" && color !== "accent" && color !== "warn"',
@@ -162,6 +165,13 @@ export class MatIcon extends _MatIconBase implements OnInit, AfterViewChecked, C
162165
}
163166
private _inline: boolean = false;
164167

168+
/**
169+
* Name of an icon within a font set that use ligatures, such as the
170+
* [Material icons font](http://google.github.io/material-design-icons/#icon-font-for-the-web).
171+
*/
172+
@Input()
173+
icon: string;
174+
165175
/** Name of the icon in the SVG icon set. */
166176
@Input()
167177
get svgIcon(): string {
@@ -194,7 +204,10 @@ export class MatIcon extends _MatIconBase implements OnInit, AfterViewChecked, C
194204
}
195205
private _fontSet: string;
196206

197-
/** Name of an icon within a font set. */
207+
/**
208+
* Name of an icon within a font set that use CSS class for each icon glyph, such as
209+
* [FontAwesome](https://fortawesome.github.io/Font-Awesome/examples/).
210+
*/
198211
@Input()
199212
get fontIcon(): string {
200213
return this._fontIcon;

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, true]);
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 inline icon="ligature_icon_by_attribute"></mat-icon>
105106
`,
106107
})
107108
class IconHarnessTest {}

tools/public_api_guard/material/icon.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export class MatIcon extends _MatIconBase implements OnInit, AfterViewChecked, C
7171
set fontIcon(value: string);
7272
get fontSet(): string;
7373
set fontSet(value: string);
74+
icon: string;
7475
get inline(): boolean;
7576
set inline(inline: BooleanInput);
7677
// (undocumented)
@@ -88,7 +89,7 @@ export class MatIcon extends _MatIconBase implements OnInit, AfterViewChecked, C
8889
// (undocumented)
8990
_usingFontIcon(): boolean;
9091
// (undocumented)
91-
static ɵcmp: i0.ɵɵComponentDeclaration<MatIcon, "mat-icon", ["matIcon"], { "color": "color"; "inline": "inline"; "svgIcon": "svgIcon"; "fontSet": "fontSet"; "fontIcon": "fontIcon"; }, {}, never, ["*"]>;
92+
static ɵcmp: i0.ɵɵComponentDeclaration<MatIcon, "mat-icon", ["matIcon"], { "color": "color"; "inline": "inline"; "icon": "icon"; "svgIcon": "svgIcon"; "fontSet": "fontSet"; "fontIcon": "fontIcon"; }, {}, never, ["*"]>;
9293
// (undocumented)
9394
static ɵfac: i0.ɵɵFactoryDeclaration<MatIcon, [null, null, { attribute: "aria-hidden"; }, null, null, { optional: true; }]>;
9495
}

0 commit comments

Comments
 (0)