Во фронтенд-разработке довольно быстро возникает вопрос: как всё оформить удобно, красиво и единообразно? Сначала всё кажется очевидным – документация показывает, как создать базовый 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в явном виде, либо через форму, к которой привязанFormControlregisterOnChange– регистрирует 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! ?