- PVSM.RU - https://www.pvsm.ru -
Во фронтенд-разработке довольно быстро возникает вопрос: как всё оформить удобно, красиво и единообразно? Сначала всё кажется очевидным – документация показывает, как создать базовый building block, компонент, а дальше чередуй ими и жонглируй, как душе угодно. Более того, можно сильно сэкономить время, используя готовые UI-библиотеки, в которые уже вложены десятки человеко-часов. Но, по мере поступления всё новых задач, порой встают вопросы, которые в какой-то момент побуждают к написанию своего собственного UI Kit.
Сначала это может показаться сложным, муторным, ещё и нужно довольно хорошо разбираться в используемом техстеке. У Angular, например, есть репутация громоздкого фреймворка: не самая очевидная документация, не особо широкое сообщество и меньшая популярность по сравнению с React. На деле всё не так страшно. Angular активно изменяется и улучшается, притом, как и раньше, предоставляя всё необходимое для построения реактивных web-приложений.
Я считаю, что разработка собственной библиотеки компонентов на Angular – это не подвиг, совершённый «вопреки», но вполне разумный инженерный выбор, если подойти к этой задаче последовательно.
Эта статья – скорее, обзор и практическое руководство от «зачем» до «как», с примерами и решениями.
Может показаться странным вообще заниматься написанием своего UI Kit, когда уже существует множество зрелых и качественных библиотек. Автор явно не страдает синдромом NIH. Действительно, есть из чего выбрать: Taiga UI [1], PrimeNG [2], Angular Material [3] от того же Google, наконец.
Так почему же всё-таки может возникнуть необходимость разработать свой собственный UI Kit?
В библиотеке может не оказаться того базового компонента, что нужно многократно переиспользовать. Хотели сэкономить время, но теперь приходится либо создавать Issue/CR/PR авторам и ждать, надеясь на то, что это будет добавлено в принципе, либо же делать самому
Библиотека может не поддерживать или с задержкой внедрять новые возможности фреймворка
Проект может в какой-то момент вовсе перестать развиваться, что будет «костью в горле», когда встанет вопрос о переходе на новые версии Angular
Некоторые библиотеки подтягивают сторонние зависимости, которые могут быть несовместимы с вашим проектом или не устраивать вас по каким-то иным причинам
Компоненты могут не поддерживать какие-то необходимые вам возможности – например, доступность (A11Y) или тёмную тему (Dark Mode)
В некоторых библиотеках (не будет показывать пальцем на PrimeNG) темы и стили компонентов настраиваются только через отдельные инструменты, причём платно. Из-за этого ручная настройка стилей может быть если не невозможной, то весьма трудоёмкой
В какой-то момент вам может потребоваться добавить функциональность в сторонний компонент, который, будучи нерасширяемым, придётся форкать и переписывать на свой лад
Если вы всё же, осознавая эти ограничения, решились на разработку своего UI Kit, то важно понимать, что именно вам требуется и какие подводные камни могут встретиться на пути.
Есть два основных способа для разработки UI-библиотек на Angular:
Официальный, с помощью Angular CLI. Это предполагает создание Workspace – общего пространства для проектов (аналог Solution в мире .NET). Но если в Workspace есть библиотека и приложение, которое её использует, связать их напрямую будет весьма нетривиально
Использование Nx [4] и его монорепозиториев. Здесь проще управлять зависимостями и связью между библиотеками и приложениями, однако, обратной стороной медали является изучение этого самого Nx
В данной статье мы пойдём по простому и официальному пути – с Angular CLI и отдельным репозиторием под один пакет. Монорепозитории нужны скорее тогда, когда у библиотеки много разных пакетов, публикуемых по отдельности.
Итого:
Используем Angular CLI
Создаём отдельный репозиторий под библиотеку
В нём – Angular Workspace с одним проектом, нашим UI Kit
Библиотека будет публиковаться в NPM Registry (публичном или корпоративном по типу Nexus или Verdaccio)
Обратимся к официальной документации фреймворка [5] и подготовим проект для последней стабильной версии 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 Registry
version – версия пакета. Её следует обновлять перед каждой публикацией, в противном случае вы получите ошибку, поскольку перезапись существующих артефактов недопустима
peerDependencies – с какими версиями @angular/* библиотек (но и не только) совместим пакет
dependencies – транзитивные зависимости, которые попадут в production
sideEffects – флаг для сборщиков, вроде 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 [6]:
$ 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';
Теперь мы можем начать разрабатывать наш первый компонент.
Компоненты – это основа любого современного UI. Они есть в React, Vue и, конечно в Angular. Однако, чтобы раньше использовать компонент, нужно было обязательно его объявить частью NgModule, без которых нельзя было ступить и шагу. Но с v14 всё упростилось: компонент, директива или пайп могут быть не привязаны к NgModule (так называемые Standalone). Мы будем использовать этот же подход.
Если вы ранее работали со сторонними UI-библиотками для Angular, то, возможно, замечали: в некоторых каждый компонент подключается через свой NgModule. Это так называемый «SCAM-паттерн [7]» (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-файл, содержащий набор <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 [8] и NgDoc [9]. С последним, увы, здесь не получится так просто сделать, потому что он требует отдельное приложение. StoryBook не является более лучшим кандидатом, тем не менее, его будет для нас более, чем достаточно (хоть и придётся бороться с его React-ориентированностью)
Пока официальных и стабильных сборок от команды Angular с новым test runner нет, но не беда. Достаточно воспользоваться jest-preset-angular [10].
Устанавливаем зависимости:
$ 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 далеко не идеальный инструмент из-за своей 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 [11]. Мы создадим токен с настройками, которые можно передать извне и внедрить их в нужные сервисы и компоненты.
Также определим функцию 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.
Сегодня тёмная тема – это уже стандарт. Реализуют её примерно одинаково, но сделаем это с учтом последних возможностей 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 = {};
Результат:
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! 🎉
Автор: rohen
Источник [12]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/uikit/421267
Ссылки в тексте:
[1] Taiga UI: https://taiga-ui.dev/
[2] PrimeNG: https://primeng.org/
[3] Angular Material: https://material.angular.dev/
[4] Nx: https://nx.dev/
[5] официальной документации фреймворка: https://angular.dev/tools/libraries/creating-libraries
[6] semantic versioning: https://semver.org/
[7] SCAM-паттерн: https://sandroroth.com/blog/angular-shared-scam/
[8] StoryBook: https://storybook.js.org/
[9] NgDoc: https://ng-doc.com/
[10] jest-preset-angular: https://thymikee.github.io/jest-preset-angular/
[11] InjectionToken: https://angular.dev/api/core/InjectionToken
[12] Источник: https://habr.com/ru/articles/914156/?utm_source=habrahabr&utm_medium=rss&utm_campaign=914156
Нажмите здесь для печати.