Создание иконок Material font и svg без Angular Material.

Aleksandr Serenko
F.A.F.N.U.R
Published in
7 min readJul 3, 2023

В данной статье хочу поделиться решением по использованию SVG иконок в Angular. Решение взято из Angular Material и позволяет его использовать без явной зависимости от @angular/material и @angular/cdk.

Ранее я уже писал про решение Angular Material — Использование SVG иконок с помощью Angular Material. Однако его не возможно использовать, если нету Angular Material в проекте.

Почему нельзя просто использовать решение из Angular Material?

Это нужно для проектов, которые не используют Angular Material, но в которых охота использовать иконки из Material font, или хотя бы иметь возможность повторно использовать svg иконки.

Почему просто использовать SVG?

Проблемы с SVG нет, проблемы появляются например при использовании Universal, в частности после пререндера, где отдается собранный html. Если вставлять картинки SVG загрузкой с сервера (<image src=".." /> или background-image: url(..)), то у вас непременно что-то будет не загружаться и “прыгать”.

Если встраивать SVG прямо в HTML, то тогда проблем с отображением не будет. Пострадает только размер страницы, но эта умеренная плата за красоту.

Как это работает?

Берется source SVG и записывается в хеш таблицу с определенным ключом. При отрисовке SVG, по ключу отдается итоговый SVG, который будет встраиваться в страницу.

Со стороны Angular’a нужен компонент, который либо использует ключ от Material Icons, либо кастомный ключ, который определен в IconService.

IconService — кастомный сервис, который содержит в себе state, который представляет собой простой объект, ключи которого строки, обозначающие конкретные SVG, а значениями являются исходники SVG. Сервис имеет два метода: add и get. Метод add — регистрирует новый SVG, а метод get возвращает по ключу svg.

Реализация IconService

Для реализации нужно создать новый сервис.

nx g s icon

Так как я продвигаю NX в массы, то все команды приведены для Nx. Если вы используете стандартные Angular CLI, то везде в командах используйте ng вместо nx.

В результате будет сгенерирован следующий сервис:

import { Injectable } from '@angular/core';

@Injectable({
providedIn: 'root'
})
export class IconService {
constructor() { }
}

Итоговый сервис будет выглядеть следующим образом:

import { DOCUMENT } from '@angular/common';
import { inject, Injectable, SecurityContext } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';

@Injectable({
providedIn: 'root',
})
export class IconService {
private readonly state: Record<string, SVGSVGElement | null> = {};
private readonly document = inject(DOCUMENT);
private readonly sanitizer = inject(DomSanitizer, { optional: true });

add(name: string, icon: string): void {
const source = this.sanitizer ? this.sanitizer.sanitize(SecurityContext.HTML, this.sanitizer.bypassSecurityTrustHtml(icon)) : icon;

if (!source) {
console.error(`Not valid icon ${name} with content ${icon}`);

return;
}

this.state[name] = this.getSvg(source);
}

get(name: string): SVGSVGElement | null {
const source = this.state[name] ?? null;

if (!source) {
console.error(`Icon ${name} not found. You should register icon in IconService`);

return null;
}

return source.cloneNode(true) as SVGSVGElement;
}

private getSvg(source: string): SVGSVGElement | null {
const div = this.document.createElement('div');
div.innerHTML = source;
const svg = div.querySelector('svg');

if (!svg) {
console.error('<svg> tag not found');

return null;
}

// Reset SVG styles
svg.removeAttribute('id');
svg.setAttribute('fit', '');
svg.setAttribute('height', '100%');
svg.setAttribute('width', '100%');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
// Disable IE11 default behavior to make SVGs focusable.
svg.setAttribute('focusable', 'false');

return svg;
}
}

Для хранения всех svg’шек создается простой объект:

private readonly state: Record<string, SVGSVGElement | null> = {};

Для работы потребуются два сервиса, которые позволят манипулировать DOM:

private readonly document = inject(DOCUMENT);
private readonly sanitizer = inject(DomSanitizer, { optional: true });

Реализация метода добавления SVG будет следующей:

add(name: string, icon: string): void {
const source = this.sanitizer ? this.sanitizer.sanitize(SecurityContext.HTML, this.sanitizer.bypassSecurityTrustHtml(icon)) : icon;

if (!source) {
console.error(`Not valid icon ${name} with content ${icon}`);

return;
}

this.state[name] = this.getSvg(source);
}

Сначала в зависимости от платформы, проверяется source SVG.

const source = this.sanitizer 
? this.sanitizer.sanitize(SecurityContext.HTML, this.sanitizer.bypassSecurityTrustHtml(icon))
: icon;

Если с исходниками все хорошо, создается SVGSVGElement и сохраняется в state:

this.state[name] = this.getSvg(source);

Приватный метод будет создавать SVG и сбрасывать стили:

private getSvg(source: string): SVGSVGElement | null {
const div = this.document.createElement('div');
div.innerHTML = source;
const svg = div.querySelector('svg');

if (!svg) {
console.error('<svg> tag not found');

return null;
}

// Reset SVG styles
svg.removeAttribute('id');
svg.setAttribute('fit', '');
svg.setAttribute('height', '100%');
svg.setAttribute('width', '100%');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
// Disable IE11 default behavior to make SVGs focusable.
svg.setAttribute('focusable', 'false');

return svg;
}

Сначала создается DIV, потом в него вставляется содержимое SVG.

 const div = this.document.createElement('div');
div.innerHTML = source;
const svg = div.querySelector('svg');

Это сделано для того, что есть какие-то проблемы, если создавать напрямую SVG. Возможно это кусок легаси, который я унаследовал от Angular Material.

Далее сбрасываются стили для SVG. Это необходимо для того чтобы применялись стили от родительских компонентов.

svg.removeAttribute('id');
svg.setAttribute('fit', '');
svg.setAttribute('height', '100%');
svg.setAttribute('width', '100%');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
svg.setAttribute('focusable', 'false');

Последний метод это получение иконки:

get(name: string): SVGSVGElement | null {
const source = this.state[name] ?? null;

if (!source) {
console.error(`Icon ${name} not found. You should register icon in IconService`);

return null;
}

return source.cloneNode(true) as SVGSVGElement;
}

В методе просто идет получение данных из state по ключу. Если элемент есть в state, то тогда возвращается его клон.

Клонирование нужно за тем, чтобы элемент не телепортировался. Если SVG не клонировать, то Angular будет просто перемещать его по структуре DOM.

Реализация IconComponent

Команда для создания нового компонента будет следующей:

nx g с icon --standalone

При генерации компонента используется флаг standalone, так как это позволяет уменьшить boilerplate code самого Angular.

Команда создала пустой компонент:

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
selector: 'app-icon',
templateUrl: './icon.component.html',
styleUrls: ['./icon.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true
})
export class IconComponent {}

Для поддержки Material fonts, добавляются следующие стили в icon.component.scss:

:host {
display: inline-block;
width: 24px;
height: 24px;
user-select: none;
fill: var(--md-sys-color-on-surface, black);

&:not([icon]) {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
white-space: nowrap;
overflow-wrap: normal;
direction: ltr;
font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
}

Стили для иконок являются адаптацией стилей для кнопок иконок из Material 3.

Так как material не подразумевает использования иконок без кнопок, то тут только часть стилей.

Этого достаточно, чтобы уже использовать иконки из шрифта. Достаточно в index.hmtl подключить шрифт.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>App</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons&display=swap" rel="stylesheet" />
</head>
<body>
<app-root></fafn-root>
</body>
</html>

Теперь в AppComponent добавить IconComponent:

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { IconComponent } from './icon.component';

@Component({
selector: 'app-root',
template: `<app-icon>close</app-icon><app-icon>done</app-icon>`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [IconComponent],
})
export class AppComponent {}

Если запустить, то можно увидеть две иконки:

Для добавления кастомных иконок в компонент добавляется Input:

import { ChangeDetectionStrategy, Component, ElementRef, Input } from '@angular/core';

import { IconService } from './icon.service';

@Component({
selector: 'app-icon, [appIcon]',
template: '<ng-content></ng-content>',
styleUrls: ['./icon.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
})
export class IconComponent {
@Input() set icon(icon: string) {
if (icon) {
const svg = this.iconService.get(icon);

if (svg) {
this.elementRef.nativeElement.appendChild(svg);
}
}
}

constructor(private readonly elementRef: ElementRef<HTMLElement>, private readonly iconService: IconService) {}
}

В input передается имя желаемого SVG изображения. Если svg с таким именем зарегистрировано в сервисе, то в компонент вставляется svg source.

Для примера можно вывести иконку VK в AppComponent:

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { IconComponent } from './icon.component';
import { IconService } from './icon.service';

// eslint-disable-next-line max-len
export const vkontakteIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M13.162 18.994c.609 0 .858-.406.851-.915-.031-1.917.714-2.949 2.059-1.604 1.488 1.488 1.796 2.519 3.603 2.519h3.2c.808 0 1.126-.26 1.126-.668 0-.863-1.421-2.386-2.625-3.504-1.686-1.565-1.765-1.602-.313-3.486 1.801-2.339 4.157-5.336 2.073-5.336h-3.981c-.772 0-.828.435-1.103 1.083-.995 2.347-2.886 5.387-3.604 4.922-.751-.485-.407-2.406-.35-5.261.015-.754.011-1.271-1.141-1.539-.629-.145-1.241-.205-1.809-.205-2.273 0-3.841.953-2.95 1.119 1.571.293 1.42 3.692 1.054 5.16-.638 2.556-3.036-2.024-4.035-4.305-.241-.548-.315-.974-1.175-.974h-3.255c-.492 0-.787.16-.787.516 0 .602 2.96 6.72 5.786 9.77 2.756 2.975 5.48 2.708 7.376 2.708z" /></svg>`;

@Component({
selector: 'app-root',
template: `<app-icon>close</app-icon><app-icon icon="vkontakte"></app-icon>`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [IconComponent],
})
export class AppComponent {
constructor(private readonly iconService: IconService) {
this.iconService.add('vkontakte', vkontakteIcon);
}
}

После запуска приложения, будет выведено следующее:

Ссылки

Все исходники находятся на github, в репозитории:

Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать тег — icons-native.

Реализация иконок:

https://github.com/Fafnur/angular-samples/tree/main/libs/ui/icons.

Демо приложение:

https://github.com/Fafnur/angular-samples/blob/main/apps/icons-native/src/app/icons.component.ts

Подписывайтесь на блог, чтобы не пропустить новые статьи про Angular, и веб-разработку. Medium | Telegram| VK |Tw| Ln

--

--

Aleksandr Serenko
F.A.F.N.U.R

Senior Front-end Developer, Angular evangelist, Nx apologist, NodeJS warlock