Во фронтенд-разработке довольно быстро возникает вопрос: как всё оформить удобно, красиво и единообразно? Сначала всё кажется очевидным – документация показывает, как создать базовый building block, компонент, а дальше чередуй ими и жонглируй, как душе угодно. Более того, можно сильно сэкономить время, используя готовые UI-библиотеки, в которые уже вложены десятки человеко-часов. Но, по мере поступления всё новых задач, порой встают вопросы, которые в какой-то момент побуждают к написанию своего собственного UI Kit.
Сначала это может показаться сложным, муторным, ещё и нужно довольно хорошо разбираться в используемом техстеке. У Angular, например, есть репутация громоздкого фреймворка: не самая очевидная документация, не особо широкое сообщество и меньшая популярность по сравнению с React. На деле всё не так страшно. Angular активно изменяется и улучшается, притом, как и раньше, предоставляя всё необходимое для построения реактивных web-приложений.
Я считаю, что разработка собственной библиотеки компонентов на Angular – это не подвиг, совершённый «вопреки», но вполне разумный инженерный выбор, если подойти к этой задаче последовательно.
Эта статья – скорее, обзор и практическое руководство от «зачем» до «как», с примерами и решениями.
А как же готовое?
Может показаться странным вообще заниматься написанием своего UI Kit, когда уже существует множество зрелых и качественных библиотек. Автор явно не страдает синдромом NIH. Действительно, есть из чего выбрать: Taiga UI, PrimeNG, Angular Material от того же Google, наконец.
Так почему же всё-таки может возникнуть необходимость разработать свой собственный UI Kit?
В библиотеке может не оказаться того базового компонента, что нужно многократно переиспользовать. Хотели сэкономить время, но теперь приходится либо создавать Issue/CR/PR авторам и ждать, надеясь на то, что это будет добавлено в принципе, либо же делать самому
Библиотека может не поддерживать или с задержкой внедрять новые возможности фреймворка
Проект может в какой-то момент вовсе перестать развиваться, что будет «костью в горле», когда встанет вопрос о переходе на новые версии Angular
Некоторые библиотеки подтягивают сторонние зависимости, которые могут быть несовместимы с вашим проектом или не устраивать вас по каким-то иным причинам
Компоненты могут не поддерживать какие-то необходимые вам возможности – например, доступность (A11Y) или тёмную тему (Dark Mode)
В некоторых библиотеках (не будет показывать пальцем на PrimeNG) темы и стили компонентов настраиваются только через отдельные инструменты, причём платно. Из-за этого ручная настройка стилей может быть если не невозможной, то весьма трудоёмкой
В какой-то момент вам может потребоваться добавить функциональность в сторонний компонент, который, будучи нерасширяемым, придётся форкать и переписывать на свой лад
Если вы всё же, осознавая эти ограничения, решились на разработку своего UI Kit, то важно понимать, что именно вам требуется и какие подводные камни могут встретиться на пути.
Создание библиотеки и её базовая конфигурация
Что используем?
Есть два основных способа для разработки UI-библиотек на Angular:
Официальный, с помощью Angular CLI. Это предполагает создание Workspace – общего пространства для проектов (аналог Solution в мире .NET). Но если в Workspace есть библиотека и приложение, которое её использует, связать их напрямую будет весьма нетривиально
Использование Nx и его монорепозиториев. Здесь проще управлять зависимостями и связью между библиотеками и приложениями, однако, обратной стороной медали является изучение этого самого Nx
В данной статье мы пойдём по простому и официальному пути – с Angular CLI и отдельным репозиторием под один пакет. Монорепозитории нужны скорее тогда, когда у библиотеки много разных пакетов, публикуемых по отдельности.
Итого:
Используем Angular CLI
Создаём отдельный репозиторий под библиотеку
В нём – Angular Workspace с одним проектом, нашим UI Kit
Библиотека будет публиковаться в NPM Registry (публичном или корпоративном по типу Nexus или Verdaccio)
Генерация и настройка
Обратимся к официальной документации фреймворка и подготовим проект для последней стабильной версии Angular (на момент написания статьи это v19, а выходящая в мае 2025 года v20 ещё будет нуждаться в патчах):
$ npx @angular/cli@19 new ui-repo --no-create-application
$ cd ./ui-repo/
$ npm run ng generate library @my/ui-kit
Теперь библиотека будет доступна как @my/ui-kit
. Название можно изменить позже, если потребуется.
В проекте вы можете заметить не один, но два package.json
: один в корне проекта, а другой в /projects/my/ui-kit/
. Первый относится ко всему Workspace, а второй к самой библиотеке. Любые зависимости устанавливаются обычно, через npm i
, глобально для всего Workspace.
Вот пример содержимого /projects/my/ui-kit/package.json
:
{
"name": "@my/ui-kit",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "^19.2.0",
"@angular/core": "^19.2.0"
},
"dependencies": {
"tslib": "^2.3.0"
},
"sideEffects": false
}
Пояснение:
name
– это под каким именем пакет будет публиковаться в NPM Registryversion
– версия пакета. Её следует обновлять перед каждой публикацией, в противном случае вы получите ошибку, поскольку перезапись существующих артефактов недопустимаpeerDependencies
– с какими версиями@angular/*
библиотек (но и не только) совместим пакетdependencies
– транзитивные зависимости, которые попадут в productionsideEffects
– флаг для сборщиков, вроде WebPack или Vite, чтобы можно было применять tree-shaking
Можно расширить диапазон поддерживаемых версий Angular, чтобы при обновлении версии фреймворка вам не пришлось сначала обновлять её в библиотеке, публиковать её, а потом проводить тоже самое уже для приложения:
{
"name": "@my/ui-kit",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": ">=19.2.0 <21.0.0",
"@angular/core": ">=19.2.0 <21.0.0"
},
"dependencies": {
"tslib": "^2.3.0"
},
"sideEffects": false
}
Теперь библиотека будет совместима с Angular 19.2.0 и до 21.0.0 (не включительно).
Обновление версий пакета
Чтобы было удобнее обновлять версию, добавим скрипты в корневой package.json
:
{
"name": "ui-repo",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build --configuration production @my/ui-kit",
"watch": "ng build --watch --configuration development @my/ui-kit",
"test": "ng test",
"release:patch": "cd ./projects/my/ui-kit/ && npm version patch",
"release:minor": "cd ./projects/my/ui-kit/ && npm version minor",
"release:major": "cd ./projects/my/ui-kit/ && npm version major"
},
"private": true,
"dependencies": {
// ...
},
"devDependencies": {
// ...
}
}
Таким образом, при вызове скриптов у нас будет модифицироваться версия в /projects/my/ui-kit/package.json
согласно semantic versioning:
$ npm run release:patch # 0.0.1 -> 0.0.2
$ npm run release:minor # 0.0.2 -> 0.1.0
$ npm run release:major # 0.1.0 -> 1.0.0
Если вы не хотите, чтобы вместе с этим ставился и тэг на коммите в git-репозитории, добавьте флаг --no-git-tag-version
.
Экспортируемое для использования
Файл public-api.ts
определяет, какие сущности доступны извне для использования. По-умолчанию он имеет следующее содержимое:
/*
* Public API Surface of ui-kit
*/
export * from './lib/ui-kit.service';
export * from './lib/ui-kit.component';
Лучше нам подправить его, чтобы экспортировалось всё из конкретных директорий (компоненты, директивы, сервисы, типы и так далее).
Сперва подкорректируем структуру проекта примерно следующим образом:
/ui-repo/
├── README.md
├── angular.json
├── package-lock.json
├── package.json
├── projects
│ └── my
│ └── ui-kit
│ ├── README.md
│ ├── ng-package.json
│ ├── package.json
│ ├── src
│ │ ├── lib
│ │ │ ├── components
│ │ │ │ ├── index.ts
│ │ │ │ └── ui-kit
│ │ │ │ ├── index.ts
│ │ │ │ ├── ui-kit.component.spec.ts
│ │ │ │ └── ui-kit.component.ts
│ │ │ └── services
│ │ │ ├── index.ts
│ │ │ └── ui-kit
│ │ │ ├── index.ts
│ │ │ ├── ui-kit.service.spec.ts
│ │ │ └── ui-kit.service.ts
│ │ └── public-api.ts
│ ├── tsconfig.lib.json
│ ├── tsconfig.lib.prod.json
│ └── tsconfig.spec.json
└── tsconfig.json
Тогда public-api.ts
станет таким:
/*
* Public API Surface of ui-kit
*/
export * from './lib/components';
export * from './lib/services';
Теперь мы можем начать разрабатывать наш первый компонент.
О компонентах
Standalone vs NgModule
Компоненты – это основа любого современного UI. Они есть в React, Vue и, конечно в Angular. Однако, чтобы раньше использовать компонент, нужно было обязательно его объявить частью NgModule
, без которых нельзя было ступить и шагу. Но с v14 всё упростилось: компонент, директива или пайп могут быть не привязаны к NgModule
(так называемые Standalone). Мы будем использовать этот же подход.
Если вы ранее работали со сторонними UI-библиотками для Angular, то, возможно, замечали: в некоторых каждый компонент подключается через свой NgModule
. Это так называемый «SCAM-паттерн» (Single Component Angular Module). Его придумали, чтобы улучшить работу tree-shaking: подключаешь только то, что нужно, а не один-единственный модуль, который поставляет вообще всё. И размер итоговой сборки уменьшается.
С появлением Standalone-компонентов и необходимость в таком, скажем откровенно, костыле, как SCAM-паттерн, отпала, а компоненты можно использовать без лишних обёрток.
В Angular есть два основных подхода к созданию компонентов. Рассмотрим каждый из них
Компонент со стандартным селектором
Обычный способ: создаём компонент со своими стилями и логикой, а Angular вставляет его в DOM-дерево, как отдельный HTML-элемент.
Например такой компонент:
import { Component } from '@angular/core';
@Component({
selector: 'lib-ui-kit',
template: `
<p>
ui-kit works!
</p>
`,
})
export class UiKitComponent {}
При использовании в шаблоне будет выглядеть так:
<lib-ui-kit>
<p>
ui-kit works!
</p>
</lib-ui-kit>
Компонент с нестандартным селектором/директива
Можно задать поле selector
декоратора @Component
в виде атрибута или псевдокласса, чтобы наш компонент оказался «нанизан» на обычный HTML-элемент, выступающий скелетом (host-элементом).
Например, вот такой компонент:
import { Component } from '@angular/core';
@Component({
selector: '[framed-image]',
templateUrl: './framed-image.component.html',
})
export class FramedImageComponent { ... }
В шаблоне можно применить так:
<div
framed-image="art-deco"
src="somePainting.jpg"
/>
Выглядит, конечно, странно. Но именно так можно добиться максимально семантической вёрстки.
А в чём разница?
Мы привыкли считать, что компонент – это просто HTML-шаблон вместе с code-behind. Но стоит взглянуть в DevTools, то станет ясно: Angular оборачивает каждый компонент в host-элемент, что не всегда удобно.
Рассмотрим пример с Bootstrap Navbar
. Например, вот такой шаблон:
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#">
Home <span class="sr-only">(current)</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
Link
</a>
</li>
</ul>
Решив декомпозировать это на Angular-компоненты, мы можем описать структуру при помощи компонентов Navbar
и Navlink
вот так:
<navbar>
<navlink [active]="true" href="#">
Home <span class="sr-only">(current)</span>
</navlink>
<navlink [active]="false" href="#">
Link
</navlink>
</navbar>
Однако, в DOM-дереве это превратится в:
<navbar>
<ul class="navbar-nav mr-auto">
<navlink>
<li class="nav-item active">
<a class="nav-link" href="#">
Home <span class="sr-only">(current)</span>
</a>
</li>
</navlink>
<navlink>
<li class="nav-item">
<a class="nav-link" href="#">
Link
</a>
</li>
</navlink>
</ul>
</navbar>
По итогу мы получаем лишние уровни вложенности, которые нам мало того, что усложняют стили и ломают семантическую вёрстку, так, вместе с ними, ещё и A11Y. И это ещё полбеды.
По-умолчанию, host-элемент, если он используется как обычный селектор, имеет display: inline
. Это может вызвать неожиданные проблемы, если вам нужно точно рассчитать размер или позицию компонента, например, чтобы показать tooltip. Хоть это не влияет на рендеринг и поведение приложения, но такой host-элемент не совпадает по размеру с содержимым, которое он оборачивает. И наш tooltip по итогу окажется не там, где вы ожидали...
Если сравнить оба подхода:
Подход |
Плюсы |
Минусы |
Нюансы |
---|---|---|---|
Стандартный селектор |
Простота в реализации и отладке |
Лишняя вложенность, нестандартные HTML-элементы |
Рекомендуется задавать свойство `display`, отличное от `inline` |
Явное использование как отдельного HTML-элемента |
Host-элемент может влиять на поведение CSS, семантику и не совпадает по размерам с оборачиваемым контентом |
||
Нестандартный селектор/директива |
Более чистая вёрстка и простые правила CSS |
Усложнение отладки и реализации |
Правила применения можно определять не одним, но множеством селекторов |
Не требует как-то отдельно определять правила для A11Y |
Используется как атрибут для уже существующего элемента HTML, ввиду чего подходит далеко не для всех случаев |
Какой подход выбирать – зависит от решаемой задачи. В данной статье мы рассмотрим компонент, который вряд ли можно реализовать без использования стандартного селектора – иконку.
Создание компонента
Проектирование, разработка, ассеты
Иконки чаще всего делают в виде SVG, изображений или кастомных шрифтов с CSS. Мы выберем SVG – это золотая середина между гибкостью и простотой.
Что нужно от иконок в библиотеке:
Иконки – это SVG
Все иконки имеют квадратные пропорции
При повторном использовании иконка загружается по сети только один раз
Все иконки предустановлены в библиотеку, а не ссылаются на какой-то CDN
Учитывая упомянутое, получим такой пример компонента иконки:
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { IconLoaderService } from '../services';
/**
* SVG-иконка, отображаемая на экране
*/
@Component({
selector: 'lib-ui-icon',
template: `
<svg
[attr.width]="size()"
[attr.height]="size()"
>
<use [attr.href]="iconLocation()"></use>
</svg>
`,
styles: `
:host {
display: flex;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class IconComponent {
/** Имя иконки */
public readonly name = input<string>('');
/** Размер иконки (px). По-умолчанию, `24` */
public readonly size = input<number>(24);
private readonly iconLoader = inject(IconLoaderService);
/** Местоположение иконки */
protected readonly iconLocation = computed<string>(() => {
const iconName = this.name();
return this.iconLoader.loadBuiltInIcon(iconName);
});
}
Чтобы всё заработало, нам необходимо обеспечить подгрузку ассетов и их наличие в итоговой сборке. Angular позволяет включить их в библиотеку: нужно лишь указать путь к ним в файле ng-package.json
.
Для начала уберём сгенерированные при создании библиотеки Angular CLI компонент с сервисом, а также добавим директорию /assets/
. Думаю, вместо того, чтобы подгружать каждый отдельный SVG по сети, а также поставлять их в таком виде в библиотеке, можно их объединить в отдельные спрайты по группам, причём отделим чёрно-белые от цветных.
Тогда в библиотеке структура станет следующей:
/ui-repo/
├── README.md
├── angular.json
├── package-lock.json
├── package.json
├── projects
│ └── my
│ └── ui-kit
│ ├── README.md
│ ├── ng-package.json
│ ├── package.json
│ ├── src
│ │ ├── assets
│ │ │ └── icons
│ │ │ ├── colorful
│ │ │ │ ├── brands.svg
│ │ │ │ └── countries.svg
│ │ │ └── monochrome
│ │ │ ├── brands.svg
│ │ │ └── common.svg
│ │ ├── lib
│ │ │ └── components
│ │ │ ├── icon
│ │ │ │ ├── components
│ │ │ │ │ ├── icon.component.ts
│ │ │ │ │ └── index.ts
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ └── public-api.ts
│ ├── tsconfig.lib.json
│ ├── tsconfig.lib.prod.json
│ └── tsconfig.spec.json
└── tsconfig.json
А файл ng-package.json
из изначально такого:
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist/my/ui-kit",
"lib": {
"entryFile": "src/public-api.ts"
}
}
Станет таким:
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist/my/ui-kit",
"lib": {
"entryFile": "src/public-api.ts"
},
"assets": [
"src/assets"
]
}
Теперь при сборке (npm run build
) ассеты окажутся доступны по пути /dist/my/ui-kit/src/assets/
.
Подключение SVG через спрайты
Спрайты устроены следующим образом – это отдельный SVG-файл, содержащий набор <symbol>
, каждый с уникальным id
. Мы можем сослаться на эти id
и использовать их при помощи <use>
в нашем IconComponent
.
Например, содержимого /monochrome/brands.svg
может иметь следующий вид:
<svg height="0" width="0" xmlns="http://www.w3.org/2000/svg" focusable="false">
<symbol id="mc__brands__telegram" viewBox="0 0 24 24">
<g>
<path fill="currentColor" d="..." />
</g>
</symbol>
<symbol id="mc__brands__google" viewBox="0 0 24 24">
<g>
<path fill="currentColor" d="..." />
</g>
</symbol>
</svg>
Теперь реализуем IconLoaderService
, который загружает нужный спрайт только один раз, вставляет его в DOM и возвращает ссылку на иконку (наверняка, существует реализация и лучше, о чём вы маякнёте в комментариях):
import { inject, Injectable, SecurityContext } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { DomSanitizer } from '@angular/platform-browser';
import { map, Observable, take, tap } from 'rxjs';
import { IconsMeta } from '../types';
/**
* Сервис для подгрузки иконок
*/
@Injectable({ providedIn: 'root' })
export class IconLoaderService {
private readonly httpClient = inject(HttpClient);
private readonly sanitizer = inject(DomSanitizer);
private readonly document = inject(DOCUMENT);
/** Метаданные по цветным иконкам */
private readonly COLORFUL_ICONS_META: IconsMeta = {
prefix: 'clr',
directory: 'colorful',
sprites: [
'brands',
'countries',
],
};
/** Метаданные по монохромным иконкам */
private readonly MONOCHROME_ICONS_META: IconsMeta = {
prefix: 'mc',
directory: 'monochrome',
sprites: [
'brands',
'common',
],
};
/** Разделитель в именах иконок */
private readonly NAME_DELIMITER = '__';
/**
* Загрузить поставляемую библиотекой иконку
*
* @param name - Имя иконки по формуле `<PREFIX>__<SPRITE>__<NAME>`
* @returns Относительный путь к иконке, если имя иконки соответствует формуле; иначе `''`. Наличие самой иконки по пути не проверяется
*/
public loadBuiltInIcon(name: string): string {
const isNameValid = this.isIconNameValid(name);
if (!isNameValid) {
return '';
}
const nameParts = name.split(this.NAME_DELIMITER);
const prefix = nameParts[0];
const sprite = nameParts[1];
let directory = '';
switch (prefix) {
case this.COLORFUL_ICONS_META.prefix: {
directory = this.COLORFUL_ICONS_META.directory;
break;
}
case this.MONOCHROME_ICONS_META.prefix: {
directory = this.MONOCHROME_ICONS_META.directory;
break;
}
default: {
break;
}
}
const iconPath = `#${name}`;
const spriteId = `svg__${prefix}__${sprite}`;
// Проверяем, подгружены ли спрайты в DOM-дерево страницы, откуда их можно потом извлечь.
// В случае отсутствия, подгружаем и размещаем в дереве в отдельных скрытых div-элементах
const spriteBlock = this.document.getElementById(spriteId);
if (!spriteBlock) {
this.loadIconSprite(spriteId, directory, sprite).subscribe();
}
return iconPath;
}
/**
* Загрузить спрайт с группой иконок
*
* @param id - Уникальный идентификатор, под которым будет зарегистрирована загруженная группа иконок
* @param sprite - Имя группы иконок
* @returns `Observable` с сигналом об успешной загрузке спрайта
*/
private loadIconSprite(id: string, directory: string, sprite: string): Observable<void> {
// Располагаем группу иконок в скрытом div, из него же и будет осуществляться подгрузка локально
const spriteBlock = this.document.createElement('div');
spriteBlock.setAttribute('id', id);
spriteBlock.style.height = '0';
spriteBlock.style.width = '0';
this.document.body.insertBefore(spriteBlock, this.document.body.firstChild);
const spriteUrl = `public/icons/${directory}/${sprite}.svg`;
return this.httpClient.get(`${window.location.origin}/${spriteUrl}`, {
headers: new HttpHeaders().set('accept', 'image/svg+xml'),
responseType: 'text'
})
.pipe(
take(1),
tap((response) => {
spriteBlock.innerHTML = this.sanitizer.sanitize(
SecurityContext.HTML,
this.sanitizer.bypassSecurityTrustHtml(response),
) as string;
}),
map(() => undefined),
);
}
/**
* Совпадает ли имя иконки согласно формуле `<PREFIX>__<SPRITE>__<NAME>` с определёнными типами и группами иконок
*
* @param name - Имя иконки
* @returns `true`, если имя совпадает; иначе `false`
*/
private isIconNameValid(name: string): boolean {
const nameParts = name.split(this.NAME_DELIMITER);
// Любая встроенная иконка должна иметь имя из 3-х компонент
if (nameParts.length < 3) {
return false;
}
// Первая компонента должна указывать на тип иконки
const iconType = nameParts[0];
if (iconType !== this.COLORFUL_ICONS_META.prefix && iconType !== this.MONOCHROME_ICONS_META.prefix) {
return false;
}
// Вторая компонента должна быть одной из доступных групп
const iconGroup = nameParts[1];
if (
(iconType === this.COLORFUL_ICONS_META.prefix && !this.COLORFUL_ICONS_META.sprites.includes(iconGroup))
|| (iconType === this.MONOCHROME_ICONS_META.prefix && !this.MONOCHROME_ICONS_META.sprites.includes(iconGroup))
) {
return false;
}
return true;
}
}
Готово. Но неплохо было бы и проверить как оно работает на деле.
Тестирование
Инструменты
При разработке библиотеки нам не обойтись без тестирования. В нашем случае, нам нужны:
Test runner, который можно будет запустить как локально, так и на этапе CI/CD. Вместо безнадёжно устаревшей Karma, с которой ещё и приходится извращаться, воспользуемся Jest
Playground для витрины компонентов и визуального тестирования. Из вариантов: StoryBook и NgDoc. С последним, увы, здесь не получится так просто сделать, потому что он требует отдельное приложение. StoryBook не является более лучшим кандидатом, тем не менее, его будет для нас более, чем достаточно (хоть и придётся бороться с его React-ориентированностью)
Подключаем Jest
Пока официальных и стабильных сборок от команды Angular с новым test runner нет, но не беда. Достаточно воспользоваться jest-preset-angular.
Устанавливаем зависимости:
$ npm uninstall @types/jasmine jasmine-core karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter
$ npm i -D jest jest-preset-angular @types/jest ts-node
Создаём конфигурацию jest.config.ts
в корне, сразу же с code coverage:
import type { Config } from 'jest';
import { createCjsPreset } from 'jest-preset-angular/presets';
const config: Config = {
...createCjsPreset(),
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
rootDir: './projects/my/ui-kit',
collectCoverage: true,
coverageDirectory: '<rootDir>/../../../coverage',
coverageReporters: [
'clover',
'json',
'lcov',
'html'
]
};
export default config;
Файл инициализации окружения /projects/my/ui-kit/jest.setup.ts
:
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv();
// Define global Mocks below
Конфигурацию для Typescript в /projects/my/ui-kit/tsconfig.spec.json
:
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "../../../out-tsc/spec",
"module": "ES2022",
"types": ["jest"]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
А также скрипты для запуска в корневом package.json
для 3-х случаев:
Тестирование локально с однократным запуском (
npm run test
)Тестирование локально с watch-режимом (
npm run test:watch
)Тестирование на сборочном агенте на этапе CI/CD (
npm run test:ci
)
{
"name": "ui-repo",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build --configuration production @my/ui-kit",
"watch": "ng build --watch --configuration development @my/ui-kit",
"test": "jest --maxWorkers=50%",
"test:ci": "jest ---detectOpenHandles",
"test:watch": "jest --watch --detectOpenHandles"
},
"private": true,
"dependencies": {
// ...
},
"devDependencies": {
// ...
}
}
Теперь просто попробуем прогнать самый простой тест следующего содержания:
import { ComponentRef } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { IconComponent } from './icon.component';
describe('Icon', () => {
let component: IconComponent;
let componentRef: ComponentRef<IconComponent>;
let fixture: ComponentFixture<IconComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
IconComponent,
],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
],
}).compileComponents();
fixture = TestBed.createComponent(IconComponent);
component = fixture.componentInstance;
componentRef = fixture.componentRef;
fixture.detectChanges();
})
it('Компонент создаётся', () => {
expect(component).toBeTruthy();
});
});
И результаты:
$ npm run test
> ui-repo@0.0.0 test
> jest --maxWorkers=50%
PASS projects/my/ui-kit/src/lib/components/icon/components/icon.component.spec.ts
Icon
√ Компонент создаётся (72 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.195 s, estimated 9 s
Ran all test suites.
StoryBook
StoryBook далеко не идеальный инструмент из-за своей React-ориентированности, но в роли песочницы для тестирования компонентов и витрины вполне подойдёт.
Установка и конфигурация максимально проста:
$ npm create storybook@latest
Рекомендуется согласится на использование compodoc
– так вы получите автоматическую документацию, составленную из ваших JSDoc-комментариев.
StoryBook нам потребуется также подготовить, чтобы он знал о наших иконках и других ассетах. Для этого отредактируем /projects/my/ui-kit/.storybook/main.ts
следующим образом:
import type { StorybookConfig } from '@storybook/angular';
const config: StorybookConfig = {
stories: [
'../src/**/*.mdx',
'../src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-onboarding',
'@storybook/addon-interactions',
],
framework: {
name: "@storybook/angular",
options: {}
},
staticDirs: [
{
from: '../src/assets',
to: '/public',
}
]
};
export default config;
Для корректной работы Angular-компонентов, требуется определить метаданные, подгружаемые в StoryBook:
import { provideHttpClient } from '@angular/common/http';
import { applicationConfig } from '@storybook/angular';
/**
* Метаданные для настройки конфигурации StoryBook
*/
export const StorybookModuleMeta = [
applicationConfig({
providers: [
provideHttpClient(),
],
}),
];
Остаётся лишь Story. Это такая сущность, которая позволит нам подготовить полигон для тестирования компонента, предварительно передав ему значения input()
. Определим их две: пускай одна будет playground, а другая просто отображает все доступные иконки:
import { StoryObj, Meta } from '@storybook/angular';
import { StorybookModuleMeta } from '../../../../storybook-meta';
import { IconComponent } from '../components';
const meta: Meta<typeof IconComponent> = {
title: 'Components/Icon',
component: IconComponent,
decorators: StorybookModuleMeta,
parameters: {
controls: {
exclude: ['iconLocation'],
},
},
};
export default meta;
type Story = StoryObj<IconComponent>;
type UntypedStory = StoryObj;
export const Playground: Story = {
args: {
name: 'mc__common__cog',
size: 24,
},
};
export const AvailableIcons: UntypedStory = {
render: () => ({
template: `
<div style="display: flex; flex-direction: column; margin: 1rem; width: 400px; height: 400px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 1rem;">
<lib-ui-icon name="mc__brands__telegram" />
<lib-ui-icon name="mc__brands__github" />
<lib-ui-icon name="mc__brands__google" />
<lib-ui-icon name="mc__common__bolt" />
<lib-ui-icon name="mc__common__cog" />
<lib-ui-icon name="mc__common__document" />
</div>
<div style="display: flex; justify-content: space-between">
<lib-ui-icon name="clr__brands__windows" />
<lib-ui-icon name="clr__brands__apple" />
<lib-ui-icon name="clr__brands__android" />
<lib-ui-icon name="clr__countries__germany" />
<lib-ui-icon name="clr__countries__iceland" />
<lib-ui-icon name="clr__countries__russia" />
</div>
</div>
`,
})
}
Можем оценить результаты, запустив StoryBook:
Кастомные настройки
Допустим, нас устраивает текущая реализация нашего компонента. Но что если потребуются немного иные условия, в которых работает приложение – например, для него определён base HREF? В таком случае, жёстко прописанные пути к ассетам, как в IconLoaderService
, уже не подойдут.
Здесь нам поможет InjectionToken. Мы создадим токен с настройками, которые можно передать извне и внедрить их в нужные сервисы и компоненты.
Также определим функцию provideUiKitSettings()
для внедрения нового объекта настроек – по аналогии с provideHttpClient()
и другими:
import { InjectionToken, Provider } from '@angular/core';
/**
* Структура параметров UI Kit
*/
export type UiKitParams = {
/** URL, на котором стартует приложение, если от отличается от `/` */
baseHref: string;
/** Имя директории, в которой доступны приложению ассеты */
assetsDirectory: string;
};
/** Параметры UI Kit, передаваемые извне */
export const UI_KIT_PARAMS = new InjectionToken<UiKitParams>('UiKitParams');
/**
* Предоставить пустую конфигурацию для `@my/ui-kit`
*
* @returns Пустая конфигурация для токена `UI_KIT_PARAMS`
*/
export const provideUiKitEmptySettings = (): Provider => {
const config: UiKitParams = {
baseHref: '',
assetsDirectory: ''
};
return {
provide: UI_KIT_PARAMS,
useValue: config,
};
};
/**
* Предоставить конфигурацию для `@my/ui-kit`
*
* @param config - Конфигурация библиотеки
* @returns Заданная конфигурация для токена `UI_KIT_PARAMS`
*/
export const provideUiKitSettings = (config: UiKitParams): Provider => ({
provide: UI_KIT_PARAMS,
useValue: config,
});
Почему отдельно ещё вынесен и assetsDirectory
? В Angular до v18 ассеты размещались в /assets/
, в более поздних версиях в /public/
. Плюс, в разных проектах пути вообще могут быть совершенно своими, поэтому лучше оставить это настраиваемым.
Применим эти настройки в IconLoaderService
:
import { inject, Injectable, SecurityContext } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { DomSanitizer } from '@angular/platform-browser';
import { map, Observable, take, tap } from 'rxjs';
import { IconsMeta } from '../types';
import { UI_KIT_PARAMS } from '../../../config';
/**
* Сервис для подгрузки иконок
*/
@Injectable({ providedIn: 'root' })
export class IconLoaderService {
// ...
private readonly libParams = inject(UI_KIT_PARAMS);
// ...
/**
* Загрузить спрайт с группой иконок
*
* @param id - Уникальный идентификатор, под которым будет зарегистрирована загруженная группа иконок
* @param sprite - Имя группы иконок
* @returns `Observable` с сигналом об успешной загрузке спрайта
*/
private loadIconSprite(id: string, directory: string, sprite: string): Observable<void> {
// ...
const spriteUrl = `${this.libParams.assetsDirectory}/icons/${directory}/${sprite}.svg`;
const baseHref = this.libParams.baseHref.length > 0
? `${this.libParams.baseHref}/`
: '';
return this.httpClient.get(`${window.location.origin}/${baseHref}${spriteUrl}`, {
headers: new HttpHeaders().set('accept', 'image/svg+xml'),
responseType: 'text'
})
.pipe(
take(1),
tap((response) => {
spriteBlock.innerHTML = this.sanitizer.sanitize(
SecurityContext.HTML,
this.sanitizer.bypassSecurityTrustHtml(response),
) as string;
}),
map(() => undefined),
);
}
}
Не забудьте скорректировать метаданные для StoryBook, прокинув в него нашу provideUiKitSettings()
функцию. Иначе он не найдёт наш InjectionToken
и ранее работавшие Story сломаются. К тестам это также относится, но для них подойдёт функция provideUiKitEmptySettings()
.
Отладка в связке с проектом
Тестовая сборка
После всей проделанной работы было бы неплохо опробовать библиотеку в каком-то приложении. Конечно, её можно опубликовать в NPM Registry и подключить, как обычный пакет, но если вдруг при интеграции всплывёт ошибка, чинить её и выпускать отдельный патч будет не особо удобно. Гораздо лучше отлаживать библиотеку локально – и это несложно.
Способ работает как для проектов с WebPack, так и для связки Vite + ESBuild.
Для начала нам необходимо собрать нашу библиотеку в watch-режиме и добавить на неё symlink. Для этого в разных сессиях терминала нам следует выполнить:
$ npm run watch # Сессия терминала 1: сборка в watch-режиме
$ npm link ./dist/my/ui-kit/ # Сессия терминала 2: создаём symlink на сборку после её завершения
Настройка приложения
Теперь внесём изменения в angular.json
нашего приложения:
Добавим ассеты, записав в
projects.<PROJECT>.architect.build.options.assets
следующее:
{
"glob": "**/*",
"input": "./node_modules/@my/ui-kit/src/assets/",
"output": "public" // Или assets, в зависимости от вашего проекта
}
В
projects.<PROJECT>.architect.build.options
добавим:
"preserveSymlinks": true
Отключим кэш CLI, чтобы HMR работал корректно:
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
// Projects definiton
},
"cli": {
"cache": {
"enabled": false
}
}
}
Связывание
Теперь подключим библиотеку как зависимость:
$ npm link @my/ui-kit
В /node_modules/
у нас появляется ссылка на сборку нашей библиотеки, и при всяком изменении библиотеки, приложение будет подтягивать их и пересобираться.
Далее определим для библиотеки параметры в app.config.ts
:
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { provideUiKitSettings } from '@my/ui-kit';
import { routes } from './app.routes';
import { environment } from '../environments/environment';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(),
provideUiKitSettings({
baseHref: environment.baseHref,
assetsDirectory: environment.assetsDirectory,
})
]
};
И для теста попробуем отрендерить тоже самое, что и для нашей Story со всеми иконками:

Как видно, все ассеты подгрузились корректно, притом, что наш environment
был определён так:
export const environment = {
baseHref: '',
assetsDirectory: 'public',
};
Убедимся в том, что наши настройки передаются и работают корректно. Например, изменим значение baseHref
на accounts
:

Ассеты не подгружаются по причине изменившегося итогового пути, не совпадающего с тем, где они доступны локально. Поведение точно такое, какое мы и ожидали.
Поддержка форм
Angular Forms – это невероятно мощный пакет для работы со стандартными HTML-формами и их централизованной обработке. Мы можем добавить поддержку форм прямо в наши компоненты, если они реализуют интерфейс ControlValueAccessor
.
Несмотря на пугающее название и содержание, здесь всё просто:
writeValue
– вызывается, когда Angular хочет записать значение вFormControl
. Это происходит при измененииngModel
в явном виде, либо через форму, к которой привязанFormControl
registerOnChange
– регистрирует callback, который нужно вызывать при изменении значения пользователем, чтобы уведомить об этом формуregisterOnTouched
– регистрирует callback, чтобы сообщить форме, что пользователь взаимодействовал сFormControl
(например, кликнул или сфокусировался)setDisabledState
– сообщаетFormControl
, нужно ли его отключить
Предположим, мы хотим реализовать трёхпозиционный флажок, checkbox, который будет принимать значения true
, false
и null
. Вот его примерная реализация с поддержкой Angular Forms:
import {
Component,
forwardRef,
signal,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
/**
* Флажок с тремя состояниями: `true`, `false` и `null`
*/
@Component({
selector: '...',
// Необходимо указать для регистрации FormControl
// Это позволит использовать компонент в составе Angular Forms и задействовать [(ngModel)]
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TriCheckboxComponent),
multi: true,
},
],
})
export class TriCheckboxComponent implements ControlValueAccessor {
writeValue(val: boolean | null): void {
this.ngModelValue.set(val);
}
registerOnChange(fn: Function): void {
this.onModelChange = fn;
}
registerOnTouched(fn: Function): void {
this.onModelTouched = fn;
}
setDisabledState(val: boolean): void {
this.disabled.set(val);
}
/** Значение `ngModel` */
private ngModelValue = signal<boolean | null>(null);
/** Значение флажка */
public readonly checkedValue = this.ngModelValue.asReadonly();
/** Отключён ли компонент */
private readonly disabled = signal<boolean>(false);
/** Сообщает форме, что `FormControl` изменил значение (изменяет `ngModel`) */
private onModelChange: Function = () => {};
/** Сообщает форме, что `FormControl` был затронут пользователем (ставит CSS-класс `ng-touched` в форме) */
private onModelTouched: Function = () => {};
/**
* Обработка нажатия на флажок
*
* @param event – Сопутствующее событие
*/
protected clickTriCheckbox(event: Event): void {
event.preventDefault();
if (this.disabled()) {
return;
}
this.toggleState();
this.onModelChange(this.checkedValue());
this.onModelTouched();
}
/**
* Изменить состояние флажка
*/
public toggleState(): void {
// Состояние прогоняется по циклу null-true-false
switch (this.checkedValue()) {
case true: {
this.ngModelValue.set(false);
break;
}
case false: {
this.ngModelValue.set(null);
break;
}
case null: {
this.ngModelValue.set(true);
break;
}
default: {
break;
}
}
}
}
Единственное допущение в части типизации — это обобщённая типизация для callback registerOnTouched
и registerOnChange
.
Тёмная тема (Dark Mode)
Идея
Сегодня тёмная тема – это уже стандарт. Реализуют её примерно одинаково, но сделаем это с учтом последних возможностей CSS, просто и эффективно:
Используем глобальные CSS-переменные, определяющие стили для компонентов и вызываемые через функцию
var()
. Только учитывайте, что если переменной не будет или в её имени будет опечатка, распознать ошибку вы сможете только в runtimeОпределим переменные через CSS-функцию
light-dark()
– это позволяет задать значения для светлой и тёмной темы одновременноС помощью атрибута
theme
на<html>
будем управлять активной темой. Это будет определять значения свойстваcolor-scheme
, что выбирает какое значение использовать для функцииlight-dark()
Создадим сервис, который будет переключать тему и сохранять выбор в
LocalStorage
, чтобы применять её после перезагрузки страницыПри старте приложения на этапе
APP_INITIALIZER
тема будет автоматически подбираться от предпочтений системы (черезprefers-color-scheme
)
Реализация
Пример CSS-стилей выглядит следующим образом:
/* Набор глобальных переменных для применения в компонентах */
:root {
--btn-text-color: light-dark(white, rgb(46, 43, 43));
--btn-background-color: light-dark(rgb(11, 88, 160), rgb(20, 211, 195));
}
/* Светлая цветовая тема: для light-dark() будет применяться первый аргумент */
[theme="light"] {
color-scheme: light;
}
/* Тёмная цветовая тема: для light-dark() будет применяться второй агрумент /
[theme="dark"] {
color-scheme: dark;
}
Применим эти стили в компоненте:
import { Component } from '@angular/core';
/**
* Обычная кнопка
*/
@Component({
selector: 'button[lib-ui-button]',
template: `
<ng-content />
`,
styles: `
:host {
padding: 10px;
border: none;
border-radius: 2px;
color: var(--btn-text-color);
background-color: var(--btn-background-color);
}
`,
})
export class ButtonComponent {
// ...
}
Определим enum
с темами:
/**
* Используемая тема оформления
*/
export enum Theme {
/** Светлая */
Light = 'light',
/** Тёмная */
Dark = 'dark',
}
Далее сервис по переключению тем оформления:
import { inject, Injectable } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { Theme } from '../../enums';
/**
* Сервис по настройке темы оформления
*/
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly document = inject(DOCUMENT);
/** Атрибут в DOM для определения темы оформления */
private readonly THEME_ATTRIBUTE = 'theme';
/** Ключ в `LocalStorage`, где записана выбранная тема */
private readonly STORAGE_KEY = 'ui-theme';
/**
* Инициализировать данные по теме оформления
*/
public initialize(): void {
const currentTheme = localStorage.getItem(this.STORAGE_KEY) as Theme;
if (currentTheme) {
this.applyTheme(currentTheme);
return;
}
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = prefersDark
? Theme.Dark
: Theme.Light;
this.applyTheme(initialTheme);
}
/**
* Установить выбранную тему оформления
*
* @param theme - Тип темы
*/
public setTheme(theme: Theme): void {
this.applyTheme(theme);
}
/**
* Переключить тему оформления
*/
public toggleTheme(): void {
const currentTheme = localStorage.getItem(this.STORAGE_KEY) as Theme ?? Theme.Light;
const newTheme = currentTheme === Theme.Light
? Theme.Dark
: Theme.Light;
this.applyTheme(newTheme);
}
/**
* Применить тему оформления
*
* @param theme - Тип темы
*/
private applyTheme(theme: Theme): void {
this.document.documentElement.removeAttribute(this.THEME_ATTRIBUTE);
this.document.documentElement.setAttribute(this.THEME_ATTRIBUTE, theme);
localStorage.setItem(this.STORAGE_KEY, theme);
}
}
Тестирование
Чтобы убедиться, что темы работают, проверим это в StoryBook:
Добавим вызов инициализации на этапе
APP_INITIALIZER
в мета-конфигурации:
import { provideHttpClient } from '@angular/common/http';
import { inject, provideAppInitializer } from '@angular/core';
import { applicationConfig } from '@storybook/angular';
import { ThemeService } from './lib/services';
import { provideUiKitSettings } from './lib/config';
/**
* Метаданные для настройки конфигурации StoryBook
*/
export const StorybookModuleMeta = [
applicationConfig({
providers: [
provideHttpClient(),
provideUiKitSettings({
baseHref: '',
assetsDirectory: 'public',
}),
provideAppInitializer(() => {
const themeSwitcher = inject(ThemeService);
themeSwitcher.initialize();
}),
],
}),
];
Подключим глобальные стили в
angular.json
. Для этого добавим путь к стилям в разделprojects.@my/ui-kit.storybook.styles
Создадим демонстрационный компонент и Story к нему:
import { Component, inject } from '@angular/core';
import { StoryObj, Meta } from '@storybook/angular';
import { StorybookModuleMeta } from '../../../storybook-meta';
import { ThemeService } from '../../services';
import { ButtonComponent } from './button.component';
@Component({
selector: 'app-demo',
template: `
<button
lib-ui-button
(click)="toggle()"
>
Toggle theme
</button>
`,
imports: [
ButtonComponent,
]
})
class DemoComponent {
private readonly themeSwitcher = inject(ThemeService);
public toggle(): void {
this.themeSwitcher.toggleTheme();
}
}
const meta: Meta<typeof DemoComponent> = {
title: 'Components/Button',
component: DemoComponent,
decorators: StorybookModuleMeta,
};
export default meta;
type Story = StoryObj<DemoComponent>;
export const Playground: Story = {};
Результат:
Доступность (A11Y)
Accessibility… Как много боли в этом слове.
Честно, мне не доводилось ещё встречать людей, которые не забивают на него при разработке web-приложений, особенно, если нет прямого запроса со стороны заказчика. Тесты, тоже многими нелюбимые, должно быть, пишет куда больше людей, чем мучает голову A11Y.
Создавая библиотеку компонентов, нам, порой, приходится нарушать банальную HTML-семантику, влияющую на A11Y: например, заворачивать элементы table
в компонент для переиспользуемости, городить что-то своё ввиду невозможности кастомизировать вид стандартного <input type="checkbox" />
и так далее. Всё это может серьёзно подорвать доступность – как с точки зрения навигации, так и в контексте взаимодействия с экранными читалками, клавиатурой и прочим.
Angular позволяет сохранить относительное удобство и визуальную гибкость без особого ущерба для A11Y. Вот несколько практических рекомендаций:
Используйте нестандартные, атрибутные селекторы, когда это возможно. Так в избегаете лишних обёрток, и не допускаете семантических ловушек (вроде
ul > lib-item > li
)Если создаёте что-то кастомное (например,
<input type="radio" />
), по возможности лучше использовать настоящий HTML-элемент (button
,input
,label
) внутри компонентов. Это упростит взаимодействие с клавиатурой и экранными дикторамиНе забывайте использовать ARIA-атрибуты, если вы изменяете поведение нативных элементов
Придерживайтесь семантической вёрстки (например, страница не должна состоять только из одних
div
со стилями)Тестируйте доступность – через DevTools или пакет A11Y из состава
@angular/cdk
Сборка и публикация
Чтобы опубликовать библиотеку как NPM-пакет вне зависимости от того, куда будет залит конечный артефакт, нужно сделать всего несколько шагов:
Создать файл
.npmrc
с настройками публикацииСобрать библиотеку
Выполнить команду
npm publish
Пример .npmrc
выглядит следующим образом:
//npm-registry.corp.com/:token=abc123xyz
@my:registry=https://npm-registry.corp.com/
Обратите внимание на завершающий /
– без него NPM не сможет правильно понять путь.
Вам в .npmrc
требуется определить адрес сервера, куда будет производиться публикация, а также фактор аутентификации (например, токен, или basic-авторизация).
В итоге нам остаётся только выполнить следующее:
$ npm ci # Устанавливаем зависимости строго по package-lock.json. Файл должен находиться в репозиториии
$ npm run build # Собираем библиотеку
$ npm publish ./dist/my/ui-kit/ # Публикуем
Этот скрипт можно запускать как вручную, так и на сборочном агенте на этапе CI/CD.
Заключение
Мы прошли путь от мотивации до финальной сборки, охватив не только простое создание компонента и стилизацию, но и доступность, тестирование, сборку, публикацию. Всё это не так сложно, как кажется на первый взгляд. Главное – начать, а дальше всё пойдёт куда легче.
Надеюсь, этот материал будет полезен вам в работе.
Happy coding! ?