Библиотека компонентов на Angular: всё совсем не страшно

от автора

Во фронтенд-разработке довольно быстро возникает вопрос: как всё оформить удобно, красиво и единообразно? Сначала всё кажется очевидным – документация показывает, как создать базовый building block, компонент, а дальше чередуй ими и жонглируй, как душе угодно. Более того, можно сильно сэкономить время, используя готовые UI-библиотеки, в которые уже вложены десятки человеко-часов. Но, по мере поступления всё новых задач, порой встают вопросы, которые в какой-то момент побуждают к написанию своего собственного UI Kit.

Сначала это может показаться сложным, муторным, ещё и нужно довольно хорошо разбираться в используемом техстеке. У Angular, например, есть репутация громоздкого фреймворка: не самая очевидная документация, не особо широкое сообщество и меньшая популярность по сравнению с React. На деле всё не так страшно. Angular активно изменяется и улучшается, притом, как и раньше, предоставляя всё необходимое для построения реактивных web-приложений.

Я считаю, что разработка собственной библиотеки компонентов на Angular – это не подвиг, совершённый «вопреки», но вполне разумный инженерный выбор, если подойти к этой задаче последовательно.

Эта статья – скорее, обзор и практическое руководство от «зачем» до «как», с примерами и решениями.

А как же готовое?

Может показаться странным вообще заниматься написанием своего UI Kit, когда уже существует множество зрелых и качественных библиотек. Автор явно не страдает синдромом NIH. Действительно, есть из чего выбрать: Taiga UI, PrimeNG, Angular Material от того же Google, наконец.

Так почему же всё-таки может возникнуть необходимость разработать свой собственный UI Kit?

  1. В библиотеке может не оказаться того базового компонента, что нужно многократно переиспользовать. Хотели сэкономить время, но теперь приходится либо создавать Issue/CR/PR авторам и ждать, надеясь на то, что это будет добавлено в принципе, либо же делать самому

  2. Библиотека может не поддерживать или с задержкой внедрять новые возможности фреймворка

  3. Проект может в какой-то момент вовсе перестать развиваться, что будет «костью в горле», когда встанет вопрос о переходе на новые версии Angular

  4. Некоторые библиотеки подтягивают сторонние зависимости, которые могут быть несовместимы с вашим проектом или не устраивать вас по каким-то иным причинам

  5. Компоненты могут не поддерживать какие-то необходимые вам возможности – например, доступность (A11Y) или тёмную тему (Dark Mode)

  6. В некоторых библиотеках (не будет показывать пальцем на PrimeNG) темы и стили компонентов настраиваются только через отдельные инструменты, причём платно. Из-за этого ручная настройка стилей может быть если не невозможной, то весьма трудоёмкой

  7. В какой-то момент вам может потребоваться добавить функциональность в сторонний компонент, который, будучи нерасширяемым, придётся форкать и переписывать на свой лад

Если вы всё же, осознавая эти ограничения, решились на разработку своего UI Kit, то важно понимать, что именно вам требуется и какие подводные камни могут встретиться на пути.


Создание библиотеки и её базовая конфигурация

Что используем?

Есть два основных способа для разработки UI-библиотек на Angular:

  1. Официальный, с помощью Angular CLI. Это предполагает создание Workspace – общего пространства для проектов (аналог Solution в мире .NET). Но если в Workspace есть библиотека и приложение, которое её использует, связать их напрямую будет весьма нетривиально

  2. Использование 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 } 

Пояснение:

  1. name – это под каким именем пакет будет публиковаться в NPM Registry

  2. version – версия пакета. Её следует обновлять перед каждой публикацией, в противном случае вы получите ошибку, поскольку перезапись существующих артефактов недопустима

  3. peerDependencies – с какими версиями @angular/* библиотек (но и не только) совместим пакет

  4. dependencies – транзитивные зависимости, которые попадут в production

  5. 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 – это золотая середина между гибкостью и простотой.

Что нужно от иконок в библиотеке:

  1. Иконки – это SVG

  2. Все иконки имеют квадратные пропорции

  3. При повторном использовании иконка загружается по сети только один раз

  4. Все иконки предустановлены в библиотеку, а не ссылаются на какой-то 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;   } } 

Готово. Но неплохо было бы и проверить как оно работает на деле.

Тестирование

Инструменты

При разработке библиотеки нам не обойтись без тестирования. В нашем случае, нам нужны:

  1. Test runner, который можно будет запустить как локально, так и на этапе CI/CD. Вместо безнадёжно устаревшей Karma, с которой ещё и приходится извращаться, воспользуемся Jest

  2. 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-х случаев:

  1. Тестирование локально с однократным запуском (npm run test)

  2. Тестирование локально с watch-режимом (npm run test:watch)

  3. Тестирование на сборочном агенте на этапе 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 нашего приложения:

  1. Добавим ассеты, записав в projects.<PROJECT>.architect.build.options.assets следующее:

{   "glob": "**/*",   "input": "./node_modules/@my/ui-kit/src/assets/",   "output": "public" // Или assets, в зависимости от вашего проекта } 
  1. В projects.<PROJECT>.architect.build.options добавим:

"preserveSymlinks": true 
  1. Отключим кэш 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, просто и эффективно:

  1. Используем глобальные CSS-переменные, определяющие стили для компонентов и вызываемые через функцию var(). Только учитывайте, что если переменной не будет или в её имени будет опечатка, распознать ошибку вы сможете только в runtime

  2. Определим переменные через CSS-функцию light-dark() – это позволяет задать значения для светлой и тёмной темы одновременно

  3. С помощью атрибута theme на <html> будем управлять активной темой. Это будет определять значения свойства color-scheme, что выбирает какое значение использовать для функции light-dark()

  4. Создадим сервис, который будет переключать тему и сохранять выбор в LocalStorage, чтобы применять её после перезагрузки страницы

  5. При старте приложения на этапе 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:

  1. Добавим вызов инициализации на этапе 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();       }),     ],   }), ]; 
  1. Подключим глобальные стили в angular.json. Для этого добавим путь к стилям в раздел projects.@my/ui-kit.storybook.styles

  2. Создадим демонстрационный компонент и 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. Вот несколько практических рекомендаций:

  1. Используйте нестандартные, атрибутные селекторы, когда это возможно. Так в избегаете лишних обёрток, и не допускаете семантических ловушек (вроде ul > lib-item > li)

  2. Если создаёте что-то кастомное (например, <input type="radio" />), по возможности лучше использовать настоящий HTML-элемент (button, input, label) внутри компонентов. Это упростит взаимодействие с клавиатурой и экранными дикторами

  3. Не забывайте использовать ARIA-атрибуты, если вы изменяете поведение нативных элементов

  4. Придерживайтесь семантической вёрстки (например, страница не должна состоять только из одних div со стилями)

  5. Тестируйте доступность – через DevTools или пакет A11Y из состава @angular/cdk

Сборка и публикация

Чтобы опубликовать библиотеку как NPM-пакет вне зависимости от того, куда будет залит конечный артефакт, нужно сделать всего несколько шагов:

  1. Создать файл .npmrc с настройками публикации

  2. Собрать библиотеку

  3. Выполнить команду 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/