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

Комментарии (0)