Skip to content

Commit

Permalink
feat(material/icon): SEO friendly ligature icons
Browse files Browse the repository at this point in the history
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
  • Loading branch information
PowerKiKi committed Apr 2, 2022
1 parent b6e3b41 commit f54262f
Show file tree
Hide file tree
Showing 9 changed files with 129 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,24 @@ describe('IconHarnessExample', () => {

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

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

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

it('should get whether an icon is inline', async () => {
const icons = await loader.getAllHarnesses(MatIconHarness);
const inlineStates = await parallel(() => icons.map(icon => icon.isInline()));
expect(inlineStates).toEqual([false, false, true]);
expect(inlineStates).toEqual([false, false, true, false]);
});
});
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<mat-icon aria-hidden="false" aria-label="Example home icon">home</mat-icon>
<mat-icon aria-hidden="false" aria-label="Example home icon" fontIcon="home"></mat-icon>
7 changes: 6 additions & 1 deletion src/dev-app/icon/icon-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@
</p>

<p>
Ligature from Material Icons font:
Ligature from Material Icons font by attribute:
<mat-icon fontIcon="home"></mat-icon>
</p>

<p>
Ligature from Material Icons font by content:
<mat-icon>home</mat-icon>
</p>

Expand Down
27 changes: 20 additions & 7 deletions src/material/icon/icon-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ import {
Inject,
Injectable,
InjectionToken,
OnDestroy,
Optional,
SecurityContext,
SkipSelf,
OnDestroy,
} from '@angular/core';
import {DomSanitizer, SafeResourceUrl, SafeHtml} from '@angular/platform-browser';
import {DomSanitizer, SafeHtml, SafeResourceUrl} from '@angular/platform-browser';
import {forkJoin, Observable, of as observableOf, throwError as observableThrow} from 'rxjs';
import {catchError, finalize, map, share, tap} from 'rxjs/operators';
import {TrustedHTML, trustedHTMLFromString} from './trusted-types';
Expand Down Expand Up @@ -149,7 +149,7 @@ export class MatIconRegistry implements OnDestroy {
* specified. The default 'material-icons' value assumes that the material icon font has been
* loaded as described at http://google.github.io/material-design-icons/#icon-font-for-the-web
*/
private _defaultFontSetClass = ['material-icons'];
private _defaultFontSetClass = ['material-icons', 'mat-ligature-font'];

constructor(
@Optional() private _httpClient: HttpClient,
Expand Down Expand Up @@ -281,15 +281,28 @@ export class MatIconRegistry implements OnDestroy {
}

/**
* Defines an alias for a CSS class name to be used for icon fonts. Creating an matIcon
* Defines an alias for CSS class names to be used for icon fonts. Creating an matIcon
* component with the alias as the fontSet input will cause the class name to be applied
* to the `<mat-icon>` element.
*
* If the registered font is a ligature font, then don't forget to also include the special
* class `mat-ligature-font` to allow the usage via attribute. So register like this:
*
* ```ts
* iconRegistry.registerFontClassAlias('f1', 'font1 mat-ligature-font');
* ```
*
* And use like this:
*
* ```html
* <mat-icon fontSet="f1" fontIcon="home"></mat-icon>
* ```
*
* @param alias Alias for the font.
* @param className Class name override to be used instead of the alias.
* @param classNames Class names override to be used instead of the alias.
*/
registerFontClassAlias(alias: string, className: string = alias): this {
this._fontCssClassesByAlias.set(alias, className);
registerFontClassAlias(alias: string, classNames: string = alias): this {
this._fontCssClassesByAlias.set(alias, classNames);
return this;
}

Expand Down
4 changes: 4 additions & 0 deletions src/material/icon/icon.scss
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ $size: 24px !default;
line-height: inherit;
width: inherit;
}

&.mat-ligature-font[fontIcon]::before {
content: attr(fontIcon);
}
}

// Icons that will be mirrored in RTL.
Expand Down
77 changes: 72 additions & 5 deletions src/material/icon/icon.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe('MatIcon', () => {
declarations: [
IconWithColor,
IconWithLigature,
IconWithLigatureByAttribute,
IconWithCustomFontCss,
IconFromSvgName,
IconWithAriaHiddenFalse,
Expand Down Expand Up @@ -125,6 +126,7 @@ describe('MatIcon', () => {
fixture.detectChanges();
expect(sortedClassNames(matIconElement)).toEqual([
'mat-icon',
'mat-ligature-font',
'mat-primary',
'material-icons',
'notranslate',
Expand All @@ -143,6 +145,7 @@ describe('MatIcon', () => {
expect(sortedClassNames(matIconElement)).toEqual([
'mat-icon',
'mat-icon-no-color',
'mat-ligature-font',
'material-icons',
'notranslate',
]);
Expand Down Expand Up @@ -179,7 +182,7 @@ describe('MatIcon', () => {
});

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

const testComponent = fixture.componentInstance;
Expand All @@ -189,13 +192,14 @@ describe('MatIcon', () => {
expect(sortedClassNames(matIconElement)).toEqual([
'mat-icon',
'mat-icon-no-color',
'mat-ligature-font',
'material-icons',
'notranslate',
]);
});

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

const fixture = TestBed.createComponent(IconWithLigature);

Expand All @@ -206,6 +210,7 @@ describe('MatIcon', () => {
expect(sortedClassNames(matIconElement)).toEqual([
'mat-icon',
'mat-icon-no-color',
'mat-ligature-font',
'myfont',
'notranslate',
]);
Expand All @@ -223,7 +228,7 @@ describe('MatIcon', () => {
});

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

let fixture = TestBed.createComponent(IconWithLigature);

Expand All @@ -234,6 +239,62 @@ describe('MatIcon', () => {
expect(sortedClassNames(matIconElement)).toEqual([
'mat-icon',
'mat-icon-no-color',
'mat-ligature-font',
'myfont',
'myfont-48x48',
'notranslate',
]);
});
});

describe('Ligature icons by attribute', () => {
it('should add material-icons and mat-ligature-font class by default', () => {
const fixture = TestBed.createComponent(IconWithLigatureByAttribute);

const testComponent = fixture.componentInstance;
const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
testComponent.iconName = 'home';
fixture.detectChanges();
expect(sortedClassNames(matIconElement)).toEqual([
'mat-icon',
'mat-icon-no-color',
'mat-ligature-font',
'material-icons',
'notranslate',
]);
});

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

const fixture = TestBed.createComponent(IconWithLigatureByAttribute);

const testComponent = fixture.componentInstance;
const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
testComponent.iconName = 'home';
fixture.detectChanges();
expect(sortedClassNames(matIconElement)).toEqual([
'mat-icon',
'mat-icon-no-color',
'mat-ligature-font',
'myfont',
'notranslate',
]);
});

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

let fixture = TestBed.createComponent(IconWithLigatureByAttribute);

const testComponent = fixture.componentInstance;
const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
testComponent.iconName = 'home';
fixture.detectChanges();
expect(sortedClassNames(matIconElement)).toEqual([
'mat-icon',
'mat-icon-no-color',
'mat-ligature-font',
'myfont',
'myfont-48x48',
'notranslate',
Expand Down Expand Up @@ -1042,17 +1103,18 @@ describe('MatIcon', () => {
it('should handle values with extraneous spaces being passed in to `fontIcon`', () => {
const fixture = TestBed.createComponent(IconWithCustomFontCss);
const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
fixture.componentInstance.fontSet = 'f1';

expect(() => {
fixture.componentInstance.fontIcon = 'font icon';
fixture.detectChanges();
}).not.toThrow();

expect(sortedClassNames(matIconElement)).toEqual([
'f1',
'font',
'mat-icon',
'mat-icon-no-color',
'material-icons',
'notranslate',
]);

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

expect(sortedClassNames(matIconElement)).toEqual([
'changed',
'f1',
'mat-icon',
'mat-icon-no-color',
'material-icons',
'notranslate',
]);
});
Expand Down Expand Up @@ -1311,6 +1373,11 @@ class IconWithLigature {
iconName = '';
}

@Component({template: `<mat-icon [fontIcon]="iconName"></mat-icon>`})
class IconWithLigatureByAttribute {
iconName = '';
}

@Component({template: `<mat-icon [color]="iconColor">{{iconName}}</mat-icon>`})
class IconWithColor {
iconName = '';
Expand Down
18 changes: 13 additions & 5 deletions src/material/icon/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,18 @@ const funcIriPattern = /^url\(['"]?#(.*?)['"]?\)$/;
* `<mat-icon svgIcon="left-arrow"></mat-icon>
* <mat-icon svgIcon="animals:cat"></mat-icon>`
*
* - Use a font ligature as an icon by putting the ligature text in the content of the `<mat-icon>`
* component. By default the Material icons font is used as described at
* - Use a font ligature as an icon by putting the ligature text in the `fontIcon` attribute or the
* content of the `<mat-icon>` component. If you register a custom font class, don't forget to also
* include the special class `mat-ligature-font`. It is recommended to use the attribute alternative
* to prevent the ligature text to be selectable and to appear in search engine results.
* By default, the Material icons font is used as described at
* http://google.github.io/material-design-icons/#icon-font-for-the-web. You can specify an
* alternate font by setting the fontSet input to either the CSS class to apply to use the
* desired font, or to an alias previously registered with MatIconRegistry.registerFontClassAlias.
* Examples:
* `<mat-icon>home</mat-icon>
* `<mat-icon fontIcon="home"></mat-icon>
* <mat-icon>home</mat-icon>
* <mat-icon fontSet="myfont" fontIcon="sun"></mat-icon>
* <mat-icon fontSet="myfont">sun</mat-icon>`
*
* - Specify a font glyph to be included via CSS rules by setting the fontSet input to specify the
Expand Down Expand Up @@ -359,15 +364,18 @@ export class MatIcon extends _MatIconBase implements OnInit, AfterViewChecked, C
const elem: HTMLElement = this._elementRef.nativeElement;
const fontSetClasses = (
this.fontSet
? [this._iconRegistry.classNameForFontAlias(this.fontSet)]
? this._iconRegistry.classNameForFontAlias(this.fontSet).split(/ +/)
: this._iconRegistry.getDefaultFontSetClass()
).filter(className => className.length > 0);

this._previousFontSetClass.forEach(className => elem.classList.remove(className));
fontSetClasses.forEach(className => elem.classList.add(className));
this._previousFontSetClass = fontSetClasses;

if (this.fontIcon !== this._previousFontIconClass) {
if (
this.fontIcon !== this._previousFontIconClass &&
!fontSetClasses.includes('mat-ligature-font')
) {
if (this._previousFontIconClass) {
elem.classList.remove(this._previousFontIconClass);
}
Expand Down
15 changes: 8 additions & 7 deletions src/material/icon/testing/shared.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function runHarnessTests(

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

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

expect(svgIcons.length).toBe(1);
expect(fontIcons.length).toBe(2);
expect(fontIcons.length).toBe(3);
});

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

expect(regexFilterResults.length).toBe(1);
expect(stringFilterResults.length).toBe(1);
expect(nullFilterResults.length).toBe(1);
expect(nullFilterResults.length).toBe(2);
});

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

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

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

it('should get whether an icon is inline', async () => {
const icons = await loader.getAllHarnesses(iconHarness);
const inlineStates = await parallel(() => icons.map(icon => icon.isInline()));
expect(inlineStates).toEqual([false, false, true]);
expect(inlineStates).toEqual([false, false, true, false]);
});
}

Expand All @@ -102,6 +102,7 @@ export function runHarnessTests(
<mat-icon fontSet="fontIcons" fontIcon="fontIcon"></mat-icon>
<mat-icon svgIcon="svgIcons:svgIcon"></mat-icon>
<mat-icon inline>ligature_icon</mat-icon>
<mat-icon fontIcon="ligature_icon_by_attribute"></mat-icon>
`,
})
class IconHarnessTest {}
Loading

0 comments on commit f54262f

Please sign in to comment.