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 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 angular#23195
Fixes angular#23183
Fixes google/material-design-icons#498
  • Loading branch information
PowerKiKi committed Apr 1, 2022
1 parent b6e3b41 commit 32e8b53
Show file tree
Hide file tree
Showing 9 changed files with 151 additions and 64 deletions.
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<mat-icon fontSet="fontIcons" fontIcon="fontIcon"></mat-icon>
<mat-icon svgIcon="svgIcons:svgIcon"></mat-icon>
<mat-icon inline>ligature_icon</mat-icon>
<mat-icon inline icon="ligature_icon_by_attribute"></mat-icon>
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, true]);
});
});
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" icon="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 icon="home"></mat-icon>
</p>

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

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;
}

&[icon]::before {
content: attr(icon);
}
}

// Icons that will be mirrored in RTL.
Expand Down
148 changes: 105 additions & 43 deletions src/material/icon/icon.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import {inject, waitForAsync, fakeAsync, tick, TestBed} from '@angular/core/testing';
import {SafeResourceUrl, DomSanitizer, SafeHtml} from '@angular/platform-browser';
import {fakeAsync, inject, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {DomSanitizer, SafeHtml, SafeResourceUrl} from '@angular/platform-browser';
import {
HttpClientTestingModule,
HttpTestingController,
TestRequest,
} from '@angular/common/http/testing';
import {Component, ErrorHandler, Provider, Type, ViewChild} from '@angular/core';
import {MAT_ICON_DEFAULT_OPTIONS, MAT_ICON_LOCATION, MatIconModule} from './index';
import {MatIconRegistry, getMatIconNoHttpProviderError} from './icon-registry';
import {getMatIconNoHttpProviderError, MatIconRegistry} from './icon-registry';
import {FAKE_SVGS} from './fake-svgs';
import {wrappedErrorMessage} from '../../cdk/testing/private';
import {MatIcon} from './icon';
Expand Down Expand Up @@ -58,40 +58,43 @@ describe('MatIcon', () => {
let fakePath: string;
let errorHandler: jasmine.SpyObj<ErrorHandler>;

beforeEach(waitForAsync(() => {
// The $ prefix tells Karma not to try to process the
// request so that we don't get warnings in our logs.
fakePath = '/$fake-path';
errorHandler = jasmine.createSpyObj('errorHandler', ['handleError']);

TestBed.configureTestingModule({
imports: [HttpClientTestingModule, MatIconModule],
declarations: [
IconWithColor,
IconWithLigature,
IconWithCustomFontCss,
IconFromSvgName,
IconWithAriaHiddenFalse,
IconWithBindingAndNgIf,
InlineIcon,
SvgIconWithUserContent,
IconWithLigatureAndSvgBinding,
BlankIcon,
],
providers: [
{
provide: MAT_ICON_LOCATION,
useValue: {getPathname: () => fakePath},
},
{
provide: ErrorHandler,
useValue: errorHandler,
},
],
});
beforeEach(
waitForAsync(() => {
// The $ prefix tells Karma not to try to process the
// request so that we don't get warnings in our logs.
fakePath = '/$fake-path';
errorHandler = jasmine.createSpyObj('errorHandler', ['handleError']);

TestBed.configureTestingModule({
imports: [HttpClientTestingModule, MatIconModule],
declarations: [
IconWithColor,
IconWithLigature,
IconWithLigatureByAttribute,
IconWithCustomFontCss,
IconFromSvgName,
IconWithAriaHiddenFalse,
IconWithBindingAndNgIf,
InlineIcon,
SvgIconWithUserContent,
IconWithLigatureAndSvgBinding,
BlankIcon,
],
providers: [
{
provide: MAT_ICON_LOCATION,
useValue: {getPathname: () => fakePath},
},
{
provide: ErrorHandler,
useValue: errorHandler,
},
],
});

TestBed.compileComponents();
}));
TestBed.compileComponents();
}),
);

let iconRegistry: MatIconRegistry;
let http: HttpTestingController;
Expand Down Expand Up @@ -241,6 +244,58 @@ describe('MatIcon', () => {
});
});

describe('Ligature icons by attribute', () => {
it('should add material-icons 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',
'material-icons',
'notranslate',
]);
});

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

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',
'myfont',
'notranslate',
]);
});

it('should be able to provide multiple alternate icon set classes', () => {
iconRegistry.setDefaultFontSetClass('myfont', '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',
'myfont',
'myfont-48x48',
'notranslate',
]);
});
});

describe('Icons from URLs', () => {
it('should register icon URLs by name', fakeAsync(() => {
iconRegistry.addSvgIcon('fluffy', trustUrl('cat.svg'));
Expand Down Expand Up @@ -1213,14 +1268,16 @@ describe('MatIcon without HttpClientModule', () => {
let iconRegistry: MatIconRegistry;
let sanitizer: DomSanitizer;

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [MatIconModule],
declarations: [IconFromSvgName],
});
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
imports: [MatIconModule],
declarations: [IconFromSvgName],
});

TestBed.compileComponents();
}));
TestBed.compileComponents();
}),
);

beforeEach(inject([MatIconRegistry, DomSanitizer], (mir: MatIconRegistry, ds: DomSanitizer) => {
iconRegistry = mir;
Expand Down Expand Up @@ -1311,6 +1368,11 @@ class IconWithLigature {
iconName = '';
}

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

@Component({template: `<mat-icon [color]="iconColor">{{iconName}}</mat-icon>`})
class IconWithColor {
iconName = '';
Expand Down
27 changes: 20 additions & 7 deletions src/material/icon/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
Optional,
ViewEncapsulation,
} from '@angular/core';
import {CanColor, ThemePalette, mixinColor} from '@angular/material/core';
import {CanColor, mixinColor, ThemePalette} from '@angular/material/core';
import {Subscription} from 'rxjs';
import {take} from 'rxjs/operators';

Expand Down Expand Up @@ -114,13 +114,16 @@ 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 `icon` attribute or the
* content of the `<mat-icon>` component. 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 icon="home"></mat-icon>
* <mat-icon fontSet="myfont" icon="sun"></mat-icon>
* <mat-icon>home</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 All @@ -140,7 +143,7 @@ const funcIriPattern = /^url\(['"]?#(.*?)['"]?\)$/;
'role': 'img',
'class': 'mat-icon notranslate',
'[attr.data-mat-icon-type]': '_usingFontIcon() ? "font" : "svg"',
'[attr.data-mat-icon-name]': '_svgName || fontIcon',
'[attr.data-mat-icon-name]': '_svgName || fontIcon || icon',
'[attr.data-mat-icon-namespace]': '_svgNamespace || fontSet',
'[class.mat-icon-inline]': 'inline',
'[class.mat-icon-no-color]': 'color !== "primary" && color !== "accent" && color !== "warn"',
Expand All @@ -162,6 +165,13 @@ export class MatIcon extends _MatIconBase implements OnInit, AfterViewChecked, C
}
private _inline: boolean = false;

/**
* Name of an icon within a font set that use ligatures, such as the
* [Material icons font](http://google.github.io/material-design-icons/#icon-font-for-the-web).
*/
@Input()
icon: string;

/** Name of the icon in the SVG icon set. */
@Input()
get svgIcon(): string {
Expand Down Expand Up @@ -194,7 +204,10 @@ export class MatIcon extends _MatIconBase implements OnInit, AfterViewChecked, C
}
private _fontSet: string;

/** Name of an icon within a font set. */
/**
* Name of an icon within a font set that use CSS class for each icon glyph, such as
* [FontAwesome](https://fortawesome.github.io/Font-Awesome/examples/).
*/
@Input()
get fontIcon(): string {
return this._fontIcon;
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, true]);
});
}

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 inline icon="ligature_icon_by_attribute"></mat-icon>
`,
})
class IconHarnessTest {}
3 changes: 2 additions & 1 deletion tools/public_api_guard/material/icon.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export class MatIcon extends _MatIconBase implements OnInit, AfterViewChecked, C
set fontIcon(value: string);
get fontSet(): string;
set fontSet(value: string);
icon: string;
get inline(): boolean;
set inline(inline: BooleanInput);
// (undocumented)
Expand All @@ -88,7 +89,7 @@ export class MatIcon extends _MatIconBase implements OnInit, AfterViewChecked, C
// (undocumented)
_usingFontIcon(): boolean;
// (undocumented)
static ɵcmp: i0.ɵɵComponentDeclaration<MatIcon, "mat-icon", ["matIcon"], { "color": "color"; "inline": "inline"; "svgIcon": "svgIcon"; "fontSet": "fontSet"; "fontIcon": "fontIcon"; }, {}, never, ["*"]>;
static ɵcmp: i0.ɵɵComponentDeclaration<MatIcon, "mat-icon", ["matIcon"], { "color": "color"; "inline": "inline"; "icon": "icon"; "svgIcon": "svgIcon"; "fontSet": "fontSet"; "fontIcon": "fontIcon"; }, {}, never, ["*"]>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<MatIcon, [null, null, { attribute: "aria-hidden"; }, null, null, { optional: true; }]>;
}
Expand Down

0 comments on commit 32e8b53

Please sign in to comment.