Во фронтенд-разработке довольно быстро возникает вопрос: как всё оформить удобно, красиво и единообразно? Сначала всё кажется очевидным – документация показывает, как создать базовый 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 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:
$ 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! 🎉
ссылка на оригинал статьи https://habr.com/ru/articles/914156/
Добавить комментарий