Интеграция CSS-фреймворков в Angular: советы, которые вы не знали

от автора

Вы создали новое Angular‑приложение, подключили популярный CSS‑фреймворк, но вместо ожидаемого вау‑эффекта столкнулись с проблемами: стили выглядят не так, как хотелось, валидация форм работает странно, а некоторые элементы вообще не реагируют на изменения состояния. Знакомо? Это типичная ситуация, когда CSS‑фреймворки интегрируются без учета особенностей Angular.

Эта статья поможет вам разобраться, почему возникают такие трудности, и покажет, как правильно интегрировать CSS‑фреймворки в Angular. Мы рассмотрим ключевые проблемы, разберем их решения и реализуем стильное, реактивное поле ввода с применением лучших практик Angular.

Зачем вообще интегрировать сторонний CSS‑фреймворк?

Есть несколько весомых причин, почему стоит использовать CSS‑фреймворки:

  • Скорость разработки — вы сможете быстрее создавать новые функции приложения, не тратя время на оформление и стилизацию.

  • Качество кода — не все разработчики в совершенстве владеют CSS, а ошибки в стилях и несовместимость между браузерами случаются часто.

  • Чувство вкуса — далеко не каждый программист обладает творческими навыками.

Если в вашей команде нет дизайнера и разработчиков, которые следят за единым стилем интерфейса, то использование CSS‑фреймворка — лучший выбор.

Можно ли использовать что‑то «из коробки» Angular?

Да, можно, и это Angular Material.

Хорош ли он?

В целом, да, но с оговорками. Если ваше приложение не следует стилистике Material Design, то Angular Material вам вряд ли подойдет.

Однако есть вещь, которая точно заслуживает внимания — @angular/cdk, который станет вашим лучшим помощником в работе с Angular.

Какие сложности могут возникнуть при интеграции CSS‑фреймворка?

Основные проблемы можно свести к трем ключевым аспектам:

  1. Инкапсуляция стилей. Angular использует инкапсуляцию, в результате которой потомки не получают требуемых CSS правил.

  2. Работа с состояниями. Нужно динамически добавлять и убирать классы в зависимости от условий, что требует активного вмешательства через TypeScript.

  3. Стили для JavaScript‑компонентов. Многие фреймворки полагаются на JavaScript для работы сложных элементов — автозаполнение, попапы, тултипы и другие. Эти компоненты трудно интегрировать в Angular, так как зачастую, реализацию из CSS фреймворка использовать невозможно.

Цель статьи

Мы покажем, как решить вышеописанные трудности, интегрируя Materialize в Angular-приложение. На конкретном примере вы узнаете, как создать компонент текстового поля, который будет полностью интегрирован с реактивными формами и сохранит стиль и динамику Materialize.

Проблемы прямой интеграции CSS-фреймворков в Angular

Почему нельзя напрямую подключить стили и использовать выбранный CSS‑фреймворк?

Такой подход таит в себе ряд трудностей:

  1. Отсутствие реактивности. CSS‑фреймворки — это набор готовых классов, которые нужно вручную добавлять или удалять для изменения состояния элементов. Например, при валидации формы, чтобы выделить поле с ошибкой красным, вы должны самостоятельно добавлять класс error, подписываясь на события и управляя состоянием через javascript.

  2. Проблемы с инкапсуляцией стилей. Angular по умолчанию использует инкапсуляцию, изолируя стили компонентов. Это мешает наследованию CSS‑правил, особенно если они завязаны на DOM‑структуру. Чтобы решить эту проблему, приходится использовать специальные селекторы, такие как:host и:host‑context. Использование::ng‑deep для отключения инкапсуляции не рекомендуется, так как он помечен как устаревший (deprecated).

  3. Коллизия имен. Если в проекте используются несколько CSS‑фреймворков, возможны конфликты имен классов или селекторов, что приведет к неправильному отображению интерфейса.

  4. Ограничения работы со сторонним JavaScript. Многие сложные компоненты, такие как автозаполнение, попапы или тултипы, требуют использования JavaScript. Однако такие решения из CSS‑фреймворков часто не работают в Angular из‑за особенностей его архитектуры и работы с DOM.

  5. Глобальные стили и их побочные эффекты. Прямая интеграция подразумевает использование глобальных стилей, что может привести к нежелательным побочным эффектам, особенно при включенном серверном рендеринге (SSR).

Почему нужна полноценная интеграция?

Полноценная интеграция позволяет:

  • Реализовать элементы управления с учетом реактивности Angular.

  • Избежать конфликтов имен и проблем с инкапсуляцией.

  • Корректно работать с JavaScript‑компонентами фреймворка.

  • Минимизировать побочные эффекты от глобальных стилей.

Давайте рассмотрим, как интегрировать CSS‑фреймворк Materialize в Angular на конкретном примере.

Установка и настройка Angular-приложения

Если вы знакомы с Angular, переходите к следующей главе. Данная глава нужна больше для того, чтобы показать какие изменения были сделаны до начала интеграции. Все исходники находятся на github, в репозитории — https://github.com/Fafnur/angular-materialize

Давайте создадим новое приложение с помощью команды:

npx -p @angular/cli ng new angular-materialize
Создание нового приложения Angular

Создание нового приложения Angular

Удалим вендоры:

rm -rf node_modules rm package-lock.json

Сделаем yarn по умолчанию:

yarn set version stable yarn config set nodeLinker node-modules

Исключим из репозитория генерируемые файлы в .gitignore:

# Custom .yarn .angular *.patch .husky/* junit.xml /junit .env package-lock.json yarn.lock .nx src/i18n/source.xlf

Добавим eslint:

yarn ng add @angular-eslint/schematics

Добавим несколько плагинов:

yarn add -D eslint-config-prettier eslint-plugin-import eslint-plugin-simple-import-sort

Настроим правила:

// @ts-check const eslint = require('@eslint/js'); const tseslint = require('typescript-eslint'); const angular = require('angular-eslint'); const eslintPluginSimpleImportSort = require('eslint-plugin-simple-import-sort'); const eslintPluginImport = require('eslint-plugin-import'); const eslintConfigPrettier = require('eslint-config-prettier');  module.exports = tseslint.config(   {     ignores: ['**/dist', '**/public'],   },   {     files: ['**/*.ts'],     extends: [eslint.configs.recommended, ...tseslint.configs.recommended, ...tseslint.configs.stylistic, ...angular.configs.tsRecommended],     processor: angular.processInlineTemplates,     plugins: {       'simple-import-sort': eslintPluginSimpleImportSort,       import: eslintPluginImport,     },     rules: {       '@typescript-eslint/naming-convention': [         'error',         {           selector: 'default',           format: ['camelCase'],           leadingUnderscore: 'allow',           trailingUnderscore: 'allow',         },         {           selector: 'variable',           format: ['camelCase', 'UPPER_CASE'],           leadingUnderscore: 'allow',           trailingUnderscore: 'allow',         },         {           selector: 'typeLike',           format: ['PascalCase'],         },         {           selector: 'enumMember',           format: ['PascalCase'],         },         {           selector: 'property',           format: null,           filter: {             regex: '^(host)$',             match: false,           },         },       ],       complexity: 'error',       'max-len': ['error', { code: 140 }],       'no-new-wrappers': 'error',       'no-throw-literal': 'error',       'sort-imports': 'off',       'import/no-unresolved': 'off',       'import/named': 'off',       'import/first': 'off',       'simple-import-sort/exports': 'error',       'simple-import-sort/imports': [         'error',         {           groups: [['^\\u0000'], ['^@?(?!amz)\\w'], ['^@amz?\\w'], ['^\\w'], ['^[^.]'], ['^\\.']],         },       ],       'import/newline-after-import': 'error',       'import/no-duplicates': 'error',       '@typescript-eslint/consistent-type-definitions': 'error',       'no-shadow': 'off',       '@typescript-eslint/no-shadow': 'error',       'no-invalid-this': 'off',       '@typescript-eslint/no-invalid-this': ['warn'],       '@angular-eslint/no-host-metadata-property': 'off',       'no-extra-semi': 'off',       '@angular-eslint/directive-selector': [         'error',         {           type: 'attribute',           prefix: 'app',           style: 'camelCase',         },       ],       '@angular-eslint/component-selector': [         'error',         {           type: 'element',           prefix: 'app',           style: 'kebab-case',         },       ],       '@typescript-eslint/consistent-type-imports': 'error',     },   },   {     files: ['**/*.html'],     extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],     rules: {},   },   eslintConfigPrettier, );

Добавим prettier:

yarn add -D prettier

Определим правила в .prettierrc.json:

{   "bracketSpacing": true,   "printWidth": 140,   "semi": true,   "singleQuote": true,   "tabWidth": 2,   "useTabs": false }

Исключим все, что не должно форматироваться в .prettierignore:

# Add files here to ignore them from prettier formatting /dist /coverage .angular

В IDE в настройках prettier — **/*.{js,ts,jsx,tsx,vue,astro,scss,css,html,json}.

Перенесем все зависимости в devDependencies.

Добавим @angular/cdk:

yarn add -D @angular/cdk

Так как статья на русском, добавим локализацию:

yarn ng add @angular/localize
Добавление локализации в приложение Angular

Добавление локализации в приложение Angular

Укажем локаль в angular.json:

    "i18n": {         "sourceLocale": "en-US",         "locales": {           "ru": {             "translation": "src/i18n/messages.xlf",             "baseHref": ""           }         }       },       "architect": {         "build": {           "builder": "@angular-devkit/build-angular:application",           "options": {             "localize": ["ru"], 

Создадим файл локализации src/i18n/messages.xlf:

<?xml version="1.0" encoding="UTF-8" ?> <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">   <file source-language="en-US" datatype="plaintext" original="ng2.template">     <body>     </body>   </file> </xliff>

Изменим boilerplate из AppComponent:

import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RouterOutlet } from '@angular/router';  @Component({   selector: 'app-root',   imports: [RouterOutlet],   template: '<router-outlet/>',   changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent {}

Удалим шаблон и файл стилей:

rm src/app/app.component.html rm src/app/app.component.scss

Запустим приложение:

yarn ng serve 
Чистое приложение

Чистое приложение

Создадим домашнюю страницу.

Сначала добавим правила для генерации схематик в angular.json:

{   "$schema": "./node_modules/@angular/cli/lib/config/schema.json",   "version": 1,   "newProjectRoot": "projects",   "projects": {     "angular-materialize": {       "projectType": "application",       "schematics": {         "@schematics/angular:component": {           "style": "scss",           "changeDetection": "OnPush",           "skipTests": true         }       },

Запустим команду создания нового компонента:

yarn ng g c home-page

Чуть изменим структуру:

mkdir src/app/home mkdir src/app/home/page mkdir src/app/home/page/lib echo >src/app/home/page/index.ts

Перенесем созданный страницу в src/app/home/page.

Добавим экспорт в src/app/home/page/index.ts:

import { HomePageComponent } from './lib/home-page.component'; export default { HomePageComponent };

Добавим алиас в tsconfig.json:

    "baseUrl": ".",     "paths": {       "@amz/home/page": ["src/app/home/page/index.ts"],     } 

И добавим standalore strict:

"angularCompilerOptions": {   …   "strictStandalone": true } 

Подключим нашу страницу в app.routes.ts:

import type { Route } from '@angular/router';  export const routes: Route[] = [   {     path: '',     loadComponent: () => import('@amz/home/page'),   }, ]; 

Запустим проект:

Добавили главную страницу

Добавили главную страницу

Прямая интеграция Materialize

Перейдем к интеграции. Сначала разрешим использовать сторонние CSS из node_modules и научим сборщик находить связанные файлы CSS:

В angular.json установим правила для препроцессора SCSS:

   "architect": {         "build": {           "builder": "@angular-devkit/build-angular:application",           "options": {                …               "stylePreprocessorOptions": {                 "includePaths": ["node_modules", "./"]                }, 

Добавим в проект materialize:

yarn add -D @materializecss/materialize

Так как Material Design использует шрифт Roboto, подключим его в index.html:

  <link rel="preconnect" href="https://fonts.googleapis.com" />   <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />   <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet" />

Теперь можно подключить materialize в styles.scss:

@import '@materializecss/materialize/dist/css/materialize.min';

Запустим проект:

Подключили Materialize

Подключили Materialize

Как видно из примера, что подтянулись все переменные и стили.

Для нового проекта это не страшно, но если у вас уже есть другое оформление, то это может быть проблемой.

Отметим, что import помечен как устаревший подход:

Давайте проверим как работает фреймворк. Возьмем input и добавим на главную:

<form class="row" style="gap: 1em;">   <div class="s12 m6 input-field">     <input id="first_name" type="text" placeholder=" " maxlength="20">     <label for="first_name">First Name</label>     <span class="supporting-text">Supporting Text</span>   </div>    <div class="s12 m6 input-field outlined">     <input id="last_name" type="text" placeholder=" " maxlength="20">     <label for="last_name">Last Name</label>     <!--<span class="supporting-text">Supporting Text</span>-->   </div>    <div class="s12 m6 input-field">     <input id="disabled" type="text" placeholder=" " value="I am not editable" disabled>     <label for="disabled">Disabled</label>   </div>    <div class="s12 m6 input-field outlined">     <input id="disabled" type="text" placeholder=" " value="Not editable too" disabled>     <label for="disabled">Disabled</label>   </div>    <div class="s12 m6 input-field">     <div class="prefix"><i class="material-icons">place</i></div>     <div class="suffix"><i class="material-icons">gps_fixed</i></div>     <input id="inp-location" type="text" placeholder=" " value="Planet Earth">     <label for="inp-location">Location</label>   </div>    <div class="s12 m6 input-field outlined error" maxlength="20">     <div class="prefix"><i class="material-icons">bubble_chart</i></div>     <div class="suffix"><i class="material-icons">error</i></div>     <input id="inp-error" type="text" placeholder=" " value="$%/'#sdf">     <label for="inp-error">Failing Input</label>     <span class="supporting-text">Invalid characters! Please use 0-9 only.</span>   </div> </form> 

И добавим контейнер, который отцентруем по центру:

.container {   max-width: 1200px;   margin: 0 auto; }

Обновим страницу:

Text input из Materialize

Text input из Materialize

Все работает отлично, кроме иконок. Нужно добавить шрифт.

Вставим в index.html:

<link href="https://fonts.googleapis.com/icon?family=Material+Icons&display=swap" rel="stylesheet" />
Иконки теперь отображаются

Иконки теперь отображаются

Если пощелкать по контролам, то можно убедиться, что валидация только на классах.

Давайте теперь попробуем добавить немного реактивности.

Реализация реактивного текстового поля с Materialize

Создадим новый компонент:

yarn ng g c input-direct

Перенесем его в src/app/home/page/lib.

В качестве шаблона зададим input из примера:

<div class="input-field">   <input id="first_name" type="text" placeholder=" " maxlength="20" />   <label for="first_name">First Name</label>   <span class="supporting-text">Supporting Text</span> </div>

Компонент подключим в HomePageComponent:

import { ChangeDetectionStrategy, Component } from '@angular/core';  import { InputDirectComponent } from './input-direct/input-direct.component';  @Component({   selector: 'app-home-page',   imports: [InputDirectComponent],   templateUrl: './home-page.component.html',   styleUrl: './home-page.component.scss',   changeDetection: ChangeDetectionStrategy.OnPush, }) export class HomePageComponent {}

И выведем его в home-page.component.html:

<div class="container">   <form class="row" style="gap: 1em">     <app-input-direct class="s12 m6" />   </form> </div>
Вывели текстовый инпут

Вывели текстовый инпут

Добавим реактивности.

Для этого создадим форму, затем передадим контрол в компонент и реализуем требуемую логику с показом ошибок.

Сначала создадим форму в HomePageComponent:

import { ChangeDetectionStrategy, Component } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms';  import { InputDirectComponent } from './input-direct/input-direct.component';  @Component({   selector: 'app-home-page',   imports: [InputDirectComponent],   templateUrl: './home-page.component.html',   styleUrl: './home-page.component.scss',   changeDetection: ChangeDetectionStrategy.OnPush, }) export class HomePageComponent {   readonly form = new FormGroup({     email: new FormControl<string>('', {       nonNullable: true,       validators: [Validators.required, Validators.email],     }),   }); }  

В InputDirectComponent добавим свойство control:

export class InputDirectComponent {   readonly control = input.required<UntypedFormControl>(); }

Передадим в шаблоне HomePageComponent:

<app-input-direct class="s12 m6" [control]="form.controls.email" />

Импортируем ReactiveFormsModule в InputDirectComponent:

mport { ChangeDetectionStrategy, Component, input } from '@angular/core'; import type { UntypedFormControl } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';  @Component({   selector: 'app-input-direct',   imports: [ReactiveFormsModule],   templateUrl: './input-direct.component.html',   styleUrl: './input-direct.component.scss',   changeDetection: ChangeDetectionStrategy.OnPush, }) export class InputDirectComponent {   readonly control = input.required<UntypedFormControl>(); } 

Свяжем FormControl с inputnput:

<input id="first_name" type="text" placeholder=" " maxlength="20" [formControl]="control()" />

Убедимся, что данные теперь передаются в форму:

export class HomePageComponent implements OnInit {   private readonly destroyRef = inject(DestroyRef);    readonly form = new FormGroup({     email: new FormControl<string>('', {       nonNullable: true,       validators: [Validators.required, Validators.email],     }),   });    ngOnInit(): void {     this.form.valueChanges.pipe(tap(console.log), takeUntilDestroyed(this.destroyRef)).subscribe();   } } 
Связь формы и компонента

Связь формы и компонента

Реализуем отображение ошибок:

<div class="input-field" [class.error]="control().touched && control().errors">

Как видно на скриншоте, есть проблемы с отображением подсказки.

Поправим это в input-direct.component.scss:

.error {   background-color: transparent; }
Корректное отображение ошибки

Корректное отображение ошибки

Так как мы знаем, какие ошибки могут быть, выведем корректные сообщения.

Для этого в шаблоне input-direct.component.html:

<div class="input-field" [class.error]="control().touched && control().errors">   <input id="first_name" type="text" placeholder=" " maxlength="20" [formControl]="control()" />   <label for="first_name">First Name</label>    @if (control().touched && control().errors) {     <span class="supporting-text">       @if (control().hasError('required')) {         Обязательное поле       } @else if (control().hasError('email')) {         Некорректный email       } @else {         Неизвестная ошибка       }     </span>   } @else {     <span class="supporting-text">Введите свой email</span>   } </div>
Вывод корректной ошибки

Вывод корректной ошибки

Введем невалидный emai:

Невалидный email

Невалидный email

Укажем корректное значение для email:

Валидный email

Валидный email

Осталось добавить переключение оформления инпута.

Создадим новый type:

type InputMode = 'default' | 'outlined';

В компоненте определим новое свойство mode:

  readonly mode = input<InputMode>('default');

В шаблоне выведем выбранный тип:

<div class="input-field" [class.error]="control().touched && control().errors" [class.outlined]="mode() === 'outlined'">

На странице формы выберем outlined:

Выбираем стиль для текстового поля

Выбираем стиль для текстового поля

Откроем браузер:

Текстовое поле с обводкой

Текстовое поле с обводкой

Проверим валидацию:

Ошибка с обводкой

Ошибка с обводкой

Все отлично работает.

Вот так мы напрямую интегрировали text input, который уже можно использовать в проекте.

Разделение логики ввода на компоненты

Однако такое решение имеет один существенный недостаток — это отсутствие привязки компонентов и стилей. Это приводит к увеличению размера итогового компонента, так как его невозможно переиспользовать.

Данную проблему можно решить с помощью декомпозиции компонента. Если разбить монолитный компонент на несколько маленьких, то это позволит существенно уменьшить размер приложения.

Создадим новый компонент:

yarn ng g c input-field

Переместим его в src/app/home/page/lib.

Также для каждого элемента создадим свой компонент:

yarn ng g c input yarn ng g c input-prefix yarn ng g c input-suffix yarn ng g c input-label yarn ng g c input-hint yarn ng g c input-character-counter

Переместим все в src/app/home/page/lib/input-field.

Начнем с InputComponent.

Один из главных секретов при интеграции CSS фреймворков — это создание оберточных компонентов над нативными. 

Трюк заключается в том, что мы будем использовать компонент как директиву Angular:

selector: 'input[appInput][formControl],input[appInput][formControlName]'

Это позволяет  манипулировать элементами средствами Angular, а также оставляет возможности нативного использования.

import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { NgControl } from '@angular/forms';  @Component({   selector:   // eslint-disable-next-line @angular-eslint/component-selector  'input[appInput][formControl],input[appInput][formControlName],textarea[appInput][formControl],textarea[appInput][formControlName]',   template: '<ng-content/>',   changeDetection: ChangeDetectionStrategy.OnPush }) export class InputComponent {   readonly ngControl = inject(NgControl); }

В данном случае компонент будет использоваться как обертка над input:

<input [formControl]="control()" appInput />

Теперь рассмотрим обертку — InputFieldComponent:

import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { ChangeDetectionStrategy, Component, contentChild,input } from '@angular/core';  import { InputComponent } from './input/input.component';  type InputMode = 'default' | 'outlined';  type CoerceBoolean = boolean | string | undefined | null;  @Component({   selector: 'app-input-field',   template: '<ng-content/>',   changeDetection: ChangeDetectionStrategy.OnPush,   host: {      class: 'input-field',     '[class.inline]': `inline()`,     '[class.outlined]': `mode() === 'outlined'`,     '[class.error]': 'input().ngControl.touched && input().ngControl.errors',   }, }) export class InputFieldComponent {   readonly inline = input<CoerceBoolean, CoerceBoolean>(false, { transform: coerceBooleanProperty });   readonly mode = input<InputMode>('default');   readonly input = contentChild.required<InputComponent>(InputComponent); } 

Класс для элемента задается с помощью host.

Как и в прошлой реализации добавляем свойства на mode, а также добавили свойство inline, которое будет задавать inline-block для компонента.

Далее компоненты для вставок слева и справа в инпутах — InputPrefixComponent:

@Component({   selector: 'app-input-prefix',   template: '<ng-content/>',   changeDetection: ChangeDetectionStrategy.OnPush,   host: {     class: 'prefix',   }, }) export class InputPrefixComponent {} 
@Component({   selector: 'app-input-suffix',   template: '<ng-content/>',   changeDetection: ChangeDetectionStrategy.OnPush,   host: {     class: 'suffix',   }, }) export class InputSuffixComponent {}

Самый бесполезный — InputLabelComponent:

@Component({   // eslint-disable-next-line @angular-eslint/component-selector   selector: 'label[appInputLabel]',   template: '<ng-content/>',   styleUrl: './input-label.component.scss',   changeDetection: ChangeDetectionStrategy.OnPush, }) export class InputLabelComponent {}

Подсказки — InputHintComponent:

@Component({   selector: 'app-input-hint',   template: '<ng-content/>',   changeDetection: ChangeDetectionStrategy.OnPush,   host: {     class: 'supporting-text',   }, }) export class InputHintComponent {}

Счетчик количества символов:

import { ChangeDetectionStrategy, Component } from '@angular/core';  @Component({   selector: 'app-input-character-counter',   template: '<ng-content/>',   changeDetection: ChangeDetectionStrategy.OnPush,   host: {     class: 'character-counter',   }, }) export class InputCharacterCounterComponent {}

Добавим еще одно поле code в HomePageComponent:

  readonly form = new FormGroup({     email: new FormControl<string>('', {       nonNullable: true,       validators: [Validators.required, Validators.email],     }),     code: new FormControl<string>('', {       nonNullable: true,       validators: [Validators.required, Validators.minLength(4)],     }),   }); 

Создадим компонент для code:

yarn ng g c home-code

Подключим input field:

import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import type { FormControl } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';  import { InputComponent } from '../input-field/input.component'; import { InputCharacterCounterComponent } from '../input-field/input-character-counter.component'; import { InputFieldComponent } from '../input-field/input-field.component'; import { InputHintComponent } from '../input-field/input-hint.component'; import { InputLabelComponent } from '../input-field/input-label.component'; import { InputPrefixComponent } from '../input-field/input-prefix.component'; import { InputSuffixComponent } from '../input-field/input-suffix.component';  @Component({   selector: 'app-home-code',   imports: [     ReactiveFormsModule,     InputFieldComponent,     InputLabelComponent,     InputComponent,     InputHintComponent,     InputCharacterCounterComponent,     InputSuffixComponent,     InputPrefixComponent,   ],   templateUrl: './home-code.component.html',   changeDetection: ChangeDetectionStrategy.OnPush, }) export class HomeCodeComponent {   readonly control = input.required<FormControl<string>>(); }

Выведем input:

<app-input-field mode="outlined">   <app-input-prefix><i class="material-icons">place</i></app-input-prefix>   <app-input-suffix><i class="material-icons">gps_fixed</i></app-input-suffix>   <input id="first_name" [formControl]="control()" type="text" placeholder=" " maxlength="4" appInput />   <label for="first_name" appInputLabel>First Name</label>   <app-input-hint>Supporting Text</app-input-hint>   <app-input-character-counter>{{ control().value.length }}/4</app-input-character-counter> </app-input-field>

Подключим и выведем в форме HomeCodeComponent:

<div class="container">   <form class="row" style="gap: 1em">     <div class="s12 m6">       <p>         <app-input-direct [control]="form.controls.email" mode="outlined" />       </p>       <p>         <app-home-code [control]="form.controls.code" />       </p>     </div>   </form> </div>

Запустим проект:

Вывод декомпозированного компонента

Вывод декомпозированного компонента

Добавим обработку ошибок:

  @if (control().touched && control().errors) {     <app-input-hint>       @if (control().hasError('required')) {         Обязательное поле       } @else if (control().hasError('minlength')) {         Код должен содержать 4 цифры       } @else {         Неизвестная ошибка       }     </app-input-hint>   } @else if (control().invalid) {     <app-input-hint>Введите код</app-input-hint>   } 
Корректное отображение ошибок

Корректное отображение ошибок

Как можно убедиться, все отлично работает.

Инкапсуляция стилей

Декомпозиция хороша для оптимизации, но не решает проблемы с глобальными стилями. Как бы вы не разбивали компоненты, вы не можете контролировать названия классов, их структуру и вложенность. Да и вряд ли вам нужен весь CSS фреймворк в проекте. 

Поэтому можно взять все самое лучшее и использовать только это. Давайте избавимся от глобальных стилей и создадим независимые, инкапсулированные компоненты. 

Так как мы избавляемся от глобальных стилей, то это ломает предыдущую реализацию. Для того чтобы вы смогли посмотреть как оно работает, я добавил tag к текущей ветке — global-styles.

Когда будете смотреть реализацию, просто переключитесь на данный тег — global-styles.

Так как в Angular нету дефолтных стилей, давайте добавим normalize.css. Но так как последнему коммиту уже больше 6 лет, добавим обновленную версию.

normalize.css

normalize.css

Создадим новый файл в src/stylesheets/normalize.scss:

@mixin init() {   *,   *::before,   *::after {     box-sizing: border-box;   }    html {     -moz-text-size-adjust: none;     -webkit-text-size-adjust: none;     text-size-adjust: none;   }    blockquote {     margin: 0;     padding: 1rem;   }    h1 {     margin-block-start: 1.45rem;     margin-block-end: 1.45rem;   }    h2 {     margin-block-start: 1.25rem;     margin-block-end: 1.25rem;   }    h3 {     margin-block-start: 1.175rem;     margin-block-end: 1.175rem;   }    h4 {     margin-block-start: 1.15rem;     margin-block-end: 1.15rem;   }    figure {     margin: 0;   }    p {     margin-block-start: 1rem;     margin-block-end: 1rem;   }    ul[role='list'],   ol[role='list'] {     list-style: none;   }    body {     margin: 0;     min-height: 100vh;     line-height: 1.5;     font-family:       Arial,       ui-sans-serif,       system-ui,       -apple-system,       BlinkMacSystemFont,       sans-serif;     font-size: 16px;   }    h1,   h2,   h3,   h4,   button,   input,   label {     line-height: 1.1;   }    h1,   h2,   h3,   h4 {     text-wrap: balance;   }    a:not([class]) {     text-decoration-skip-ink: auto;     color: currentColor;   }    img,   picture {     max-width: 100%;     display: block;   }    input,   button,   textarea,   select {     font: inherit;   }    textarea:not([rows]) {     min-height: 10rem;   }    :target {     scroll-margin-block: 5ex;   } } 

Добавим в styles.scss:

@use './stylesheets/normalize' as normalize;  @include normalize.init();

Откроем проект Materialize и посмотрим, что объявлено в materialize.scss:

В самом верху файла можно увидеть классическую структуру токенов и модулей из Material Design 3.

@import "components/tokens.module"; @import "components/theme.module"; //@import "components/_theme_variables"; @import "components/colors.module"; @import "components/typography.module";

Так как это персонализированные константы и утилиты, добавим их к себе в проект.

Отмечу, что все содержимое обернем в @mixin init() {}. Это необходимо чтобы не ругался компилятор.

Создадим файлы:

colors.module.scss:

@mixin init() {   .primary {     background-color: var(--md-sys-color-primary);   }   .primary-text {     color: var(--md-sys-color-primary);   }   .on-primary {     background-color: var(--md-sys-color-on-primary);   }   .on-primary-text {     color: var(--md-sys-color-on-primary);   }   .primary-container {     background-color: var(--md-sys-color-primary-container);   }   .primary-container-text {     color: var(--md-sys-color-primary-container);   }   .on-primary-container {     background-color: var(--md-sys-color-on-primary-container);   }   .on-primary-container-text {     color: var(--md-sys-color-on-primary-container);   }    .secondary {     background-color: var(--md-sys-color-secondary);   }   .secondary-text {     color: var(--md-sys-color-secondary);   }   .on-secondary {     background-color: var(--md-sys-color-on-secondary);   }   .on-secondary-text {     color: var(--md-sys-color-on-secondary);   }   .secondary-container {     background-color: var(--md-sys-color-secondary-container);   }   .secondary-container-text {     color: var(--md-sys-color-secondary-container);   }   .on-secondary-container {     background-color: var(--md-sys-color-on-secondary-container);   }   .on-secondary-container-text {     color: var(--md-sys-color-on-secondary-container);   }    .tertiary {     background-color: var(--md-sys-color-tertiary);   }   .tertiary-text {     color: var(--md-sys-color-tertiary);   }   .on-tertiary {     background-color: var(--md-sys-color-on-tertiary);   }   .on-tertiary-text {     color: var(--md-sys-color-on-tertiary);   }   .tertiary-container {     background-color: var(--md-sys-color-tertiary-container);   }   .tertiary-container-text {     color: var(--md-sys-color-tertiary-container);   }   .on-tertiary-container {     background-color: var(--md-sys-color-on-tertiary-container);   }   .on-tertiary-container-text {     color: var(--md-sys-color-on-tertiary-container);   }    .error {     background-color: var(--md-sys-color-error);   }   .error-text {     color: var(--md-sys-color-error);   }   .on-error {     background-color: var(--md-sys-color-on-error);   }   .on-error-text {     color: var(--md-sys-color-on-error);   }   .error-container {     background-color: var(--md-sys-color-error-container);   }   .error-container-text {     color: var(--md-sys-color-error-container);   }   .on-error-container {     background-color: var(--md-sys-color-on-error-container);   }   .on-error-container-text {     color: var(--md-sys-color-on-error-container);   }    .background {     background-color: var(--md-sys-color-background);   }   .background-text {     color: var(--md-sys-color-background);   }   .on-background {     background-color: var(--md-sys-color-on-background);   }   .on-background-text {     color: var(--md-sys-color-on-background);   }    .surface {     background-color: var(--md-sys-color-surface);   }   .surface-text {     color: var(--md-sys-color-surface);   }   .on-surface {     background-color: var(--md-sys-color-on-surface);   }   .on-surface-text {     color: var(--md-sys-color-on-surface);   }    .surface-variant {     background-color: var(--md-sys-color-surface-variant);   }   .surface-variant-text {     color: var(--md-sys-color-surface-variant);   }   .on-surface-variant {     background-color: var(--md-sys-color-on-surface-variant);   }   .on-surface-variant-text {     color: var(--md-sys-color-on-surface-variant);   }    .outline {     background-color: var(--md-sys-color-outline);   }   .outline-text {     color: var(--md-sys-color-outline);   }    .inverse-on-surface {     background-color: var(--md-sys-color-inverse-on-surface);   }   .inverse-on-surface-text {     color: var(--md-sys-color-inverse-on-surface);   }    .inverse-surface {     background-color: var(--md-sys-color-inverse-surface);   }   .inverse-surface-text {     color: var(--md-sys-color-inverse-surface);   }    .inverse-primary {     background-color: var(--md-sys-color-inverse-primary);   }   .inverse-primary-text {     color: var(--md-sys-color-inverse-primary);   }    .shadow {     background-color: var(--md-sys-color-shadow);   }   .shadow-text {     color: var(--md-sys-color-shadow);   }    .surface-tint {     background-color: var(--md-sys-color-surface-tint);   }   .surface-tint-text {     color: var(--md-sys-color-surface-tint);   }    .outline-variant {     background-color: var(--md-sys-color-outline-variant);   }   .outline-variant-text {     color: var(--md-sys-color-outline-variant);   }    .scrim {     background-color: var(--md-sys-color-scrim);   }   .scrim-text {     color: var(--md-sys-color-scrim);   } } 

theme.module.scss:

@mixin init() {   /* System Defaults */   :root,   :host {     color-scheme: light;     --md-sys-color-primary: var(--md-sys-color-primary-light);     --md-sys-color-on-primary: var(--md-sys-color-on-primary-light);     --md-sys-color-primary-container: var(--md-sys-color-primary-container-light);     --md-sys-color-on-primary-container: var(--md-sys-color-on-primary-container-light);     --md-sys-color-secondary: var(--md-sys-color-secondary-light);     --md-sys-color-on-secondary: var(--md-sys-color-on-secondary-light);     --md-sys-color-secondary-container: var(--md-sys-color-secondary-container-light);     --md-sys-color-on-secondary-container: var(--md-sys-color-on-secondary-container-light);     --md-sys-color-tertiary: var(--md-sys-color-tertiary-light);     --md-sys-color-on-tertiary: var(--md-sys-color-on-tertiary-light);     --md-sys-color-tertiary-container: var(--md-sys-color-tertiary-container-light);     --md-sys-color-on-tertiary-container: var(--md-sys-color-on-tertiary-container-light);     --md-sys-color-error: var(--md-sys-color-error-light);     --md-sys-color-on-error: var(--md-sys-color-on-error-light);     --md-sys-color-error-container: var(--md-sys-color-error-container-light);     --md-sys-color-on-error-container: var(--md-sys-color-on-error-container-light);     --md-sys-color-outline: var(--md-sys-color-outline-light);     --md-sys-color-background: var(--md-sys-color-background-light);     --md-sys-color-on-background: var(--md-sys-color-on-background-light);     --md-sys-color-surface: var(--md-sys-color-surface-light);     --md-sys-color-on-surface: var(--md-sys-color-on-surface-light);     --md-sys-color-surface-variant: var(--md-sys-color-surface-variant-light);     --md-sys-color-on-surface-variant: var(--md-sys-color-on-surface-variant-light);     --md-sys-color-inverse-surface: var(--md-sys-color-inverse-surface-light);     --md-sys-color-inverse-on-surface: var(--md-sys-color-inverse-on-surface-light);     --md-sys-color-inverse-primary: var(--md-sys-color-inverse-primary-light);     --md-sys-color-shadow: var(--md-sys-color-shadow-light);     --md-sys-color-surface-tint: var(--md-sys-color-surface-tint-light);     --md-sys-color-outline-variant: var(--md-sys-color-outline-variant-light);     --md-sys-color-scrim: var(--md-sys-color-scrim-light);   }    @media (prefers-color-scheme: dark) {     :root,     :host {       color-scheme: dark;       --md-sys-color-primary: var(--md-sys-color-primary-dark);       --md-sys-color-on-primary: var(--md-sys-color-on-primary-dark);       --md-sys-color-primary-container: var(--md-sys-color-primary-container-dark);       --md-sys-color-on-primary-container: var(--md-sys-color-on-primary-container-dark);       --md-sys-color-secondary: var(--md-sys-color-secondary-dark);       --md-sys-color-on-secondary: var(--md-sys-color-on-secondary-dark);       --md-sys-color-secondary-container: var(--md-sys-color-secondary-container-dark);       --md-sys-color-on-secondary-container: var(--md-sys-color-on-secondary-container-dark);       --md-sys-color-tertiary: var(--md-sys-color-tertiary-dark);       --md-sys-color-on-tertiary: var(--md-sys-color-on-tertiary-dark);       --md-sys-color-tertiary-container: var(--md-sys-color-tertiary-container-dark);       --md-sys-color-on-tertiary-container: var(--md-sys-color-on-tertiary-container-dark);       --md-sys-color-error: var(--md-sys-color-error-dark);       --md-sys-color-on-error: var(--md-sys-color-on-error-dark);       --md-sys-color-error-container: var(--md-sys-color-error-container-dark);       --md-sys-color-on-error-container: var(--md-sys-color-on-error-container-dark);       --md-sys-color-outline: var(--md-sys-color-outline-dark);       --md-sys-color-background: var(--md-sys-color-background-dark);       --md-sys-color-on-background: var(--md-sys-color-on-background-dark);       --md-sys-color-surface: var(--md-sys-color-surface-dark);       --md-sys-color-on-surface: var(--md-sys-color-on-surface-dark);       --md-sys-color-surface-variant: var(--md-sys-color-surface-variant-dark);       --md-sys-color-on-surface-variant: var(--md-sys-color-on-surface-variant-dark);       --md-sys-color-inverse-surface: var(--md-sys-color-inverse-surface-dark);       --md-sys-color-inverse-on-surface: var(--md-sys-color-inverse-on-surface-dark);       --md-sys-color-inverse-primary: var(--md-sys-color-inverse-primary-dark);       --md-sys-color-shadow: var(--md-sys-color-shadow-dark);       --md-sys-color-surface-tint: var(--md-sys-color-surface-tint-dark);       --md-sys-color-outline-variant: var(--md-sys-color-outline-variant-dark);       --md-sys-color-scrim: var(--md-sys-color-scrim-dark);     }   }    /* ===================================================================== Themes */    :root[theme='light'] {     color-scheme: light;     --md-sys-color-primary: var(--md-sys-color-primary-light);     --md-sys-color-on-primary: var(--md-sys-color-on-primary-light);     --md-sys-color-primary-container: var(--md-sys-color-primary-container-light);     --md-sys-color-on-primary-container: var(--md-sys-color-on-primary-container-light);     --md-sys-color-secondary: var(--md-sys-color-secondary-light);     --md-sys-color-on-secondary: var(--md-sys-color-on-secondary-light);     --md-sys-color-secondary-container: var(--md-sys-color-secondary-container-light);     --md-sys-color-on-secondary-container: var(--md-sys-color-on-secondary-container-light);     --md-sys-color-tertiary: var(--md-sys-color-tertiary-light);     --md-sys-color-on-tertiary: var(--md-sys-color-on-tertiary-light);     --md-sys-color-tertiary-container: var(--md-sys-color-tertiary-container-light);     --md-sys-color-on-tertiary-container: var(--md-sys-color-on-tertiary-container-light);     --md-sys-color-error: var(--md-sys-color-error-light);     --md-sys-color-on-error: var(--md-sys-color-on-error-light);     --md-sys-color-error-container: var(--md-sys-color-error-container-light);     --md-sys-color-on-error-container: var(--md-sys-color-on-error-container-light);     --md-sys-color-outline: var(--md-sys-color-outline-light);     --md-sys-color-background: var(--md-sys-color-background-light);     --md-sys-color-on-background: var(--md-sys-color-on-background-light);     --md-sys-color-surface: var(--md-sys-color-surface-light);     --md-sys-color-on-surface: var(--md-sys-color-on-surface-light);     --md-sys-color-surface-variant: var(--md-sys-color-surface-variant-light);     --md-sys-color-on-surface-variant: var(--md-sys-color-on-surface-variant-light);     --md-sys-color-inverse-surface: var(--md-sys-color-inverse-surface-light);     --md-sys-color-inverse-on-surface: var(--md-sys-color-inverse-on-surface-light);     --md-sys-color-inverse-primary: var(--md-sys-color-inverse-primary-light);     --md-sys-color-shadow: var(--md-sys-color-shadow-light);     --md-sys-color-surface-tint: var(--md-sys-color-surface-tint-light);     --md-sys-color-outline-variant: var(--md-sys-color-outline-variant-light);     --md-sys-color-scrim: var(--md-sys-color-scrim-light);   }    :root[theme='dark'] {     color-scheme: dark;     --md-sys-color-primary: var(--md-sys-color-primary-dark);     --md-sys-color-on-primary: var(--md-sys-color-on-primary-dark);     --md-sys-color-primary-container: var(--md-sys-color-primary-container-dark);     --md-sys-color-on-primary-container: var(--md-sys-color-on-primary-container-dark);     --md-sys-color-secondary: var(--md-sys-color-secondary-dark);     --md-sys-color-on-secondary: var(--md-sys-color-on-secondary-dark);     --md-sys-color-secondary-container: var(--md-sys-color-secondary-container-dark);     --md-sys-color-on-secondary-container: var(--md-sys-color-on-secondary-container-dark);     --md-sys-color-tertiary: var(--md-sys-color-tertiary-dark);     --md-sys-color-on-tertiary: var(--md-sys-color-on-tertiary-dark);     --md-sys-color-tertiary-container: var(--md-sys-color-tertiary-container-dark);     --md-sys-color-on-tertiary-container: var(--md-sys-color-on-tertiary-container-dark);     --md-sys-color-error: var(--md-sys-color-error-dark);     --md-sys-color-on-error: var(--md-sys-color-on-error-dark);     --md-sys-color-error-container: var(--md-sys-color-error-container-dark);     --md-sys-color-on-error-container: var(--md-sys-color-on-error-container-dark);     --md-sys-color-outline: var(--md-sys-color-outline-dark);     --md-sys-color-background: var(--md-sys-color-background-dark);     --md-sys-color-on-background: var(--md-sys-color-on-background-dark);     --md-sys-color-surface: var(--md-sys-color-surface-dark);     --md-sys-color-on-surface: var(--md-sys-color-on-surface-dark);     --md-sys-color-surface-variant: var(--md-sys-color-surface-variant-dark);     --md-sys-color-on-surface-variant: var(--md-sys-color-on-surface-variant-dark);     --md-sys-color-inverse-surface: var(--md-sys-color-inverse-surface-dark);     --md-sys-color-inverse-on-surface: var(--md-sys-color-inverse-on-surface-dark);     --md-sys-color-inverse-primary: var(--md-sys-color-inverse-primary-dark);     --md-sys-color-shadow: var(--md-sys-color-shadow-dark);     --md-sys-color-surface-tint: var(--md-sys-color-surface-tint-dark);     --md-sys-color-outline-variant: var(--md-sys-color-outline-variant-dark);     --md-sys-color-scrim: var(--md-sys-color-scrim-dark);   } } 

tokens.module.scss:

@mixin init() {   :root {     --md-source: #006495;     /* primary */     --md-ref-palette-primary0: #000000;     --md-ref-palette-primary10: #001e30;     --md-ref-palette-primary20: #003450;     --md-ref-palette-primary25: #003f60;     --md-ref-palette-primary30: #004b71;     --md-ref-palette-primary35: #005783;     --md-ref-palette-primary40: #006495;     --md-ref-palette-primary50: #0f7eb8;     --md-ref-palette-primary60: #3d98d4;     --md-ref-palette-primary70: #5db3f0;     --md-ref-palette-primary80: #8fcdff;     --md-ref-palette-primary90: #cbe6ff;     --md-ref-palette-primary95: #e6f2ff;     --md-ref-palette-primary98: #f7f9ff;     --md-ref-palette-primary99: #fcfcff;     --md-ref-palette-primary100: #ffffff;     /* secondary */     --md-ref-palette-secondary0: #000000;     --md-ref-palette-secondary10: #0d1d29;     --md-ref-palette-secondary20: #22323f;     --md-ref-palette-secondary25: #2d3d4b;     --md-ref-palette-secondary30: #394856;     --md-ref-palette-secondary35: #445462;     --md-ref-palette-secondary40: #50606f;     --md-ref-palette-secondary50: #697988;     --md-ref-palette-secondary60: #8293a2;     --md-ref-palette-secondary70: #9dadbd;     --md-ref-palette-secondary80: #b8c8d9;     --md-ref-palette-secondary90: #d4e4f6;     --md-ref-palette-secondary95: #e6f2ff;     --md-ref-palette-secondary98: #f7f9ff;     --md-ref-palette-secondary99: #fcfcff;     --md-ref-palette-secondary100: #ffffff;     /* tertiary */     --md-ref-palette-tertiary0: #000000;     --md-ref-palette-tertiary10: #211634;     --md-ref-palette-tertiary20: #362b4a;     --md-ref-palette-tertiary25: #423656;     --md-ref-palette-tertiary30: #4d4162;     --md-ref-palette-tertiary35: #594c6e;     --md-ref-palette-tertiary40: #66587b;     --md-ref-palette-tertiary50: #7f7195;     --md-ref-palette-tertiary60: #998ab0;     --md-ref-palette-tertiary70: #b4a4cb;     --md-ref-palette-tertiary80: #d0bfe7;     --md-ref-palette-tertiary90: #ecdcff;     --md-ref-palette-tertiary95: #f7edff;     --md-ref-palette-tertiary98: #fef7ff;     --md-ref-palette-tertiary99: #fffbff;     --md-ref-palette-tertiary100: #ffffff;     /* neutral */     --md-ref-palette-neutral0: #000000;     --md-ref-palette-neutral10: #1a1c1e;     --md-ref-palette-neutral20: #2e3133;     --md-ref-palette-neutral25: #3a3c3e;     --md-ref-palette-neutral30: #454749;     --md-ref-palette-neutral35: #515255;     --md-ref-palette-neutral40: #5d5e61;     --md-ref-palette-neutral50: #76777a;     --md-ref-palette-neutral60: #8f9194;     --md-ref-palette-neutral70: #aaabae;     --md-ref-palette-neutral80: #c6c6c9;     --md-ref-palette-neutral90: #e2e2e5;     --md-ref-palette-neutral95: #f0f0f3;     --md-ref-palette-neutral98: #f9f9fc;     --md-ref-palette-neutral99: #fcfcff;     --md-ref-palette-neutral100: #ffffff;     /* neutral-variant */     --md-ref-palette-neutral-variant0: #000000;     --md-ref-palette-neutral-variant10: #161c22;     --md-ref-palette-neutral-variant20: #2b3137;     --md-ref-palette-neutral-variant25: #363c42;     --md-ref-palette-neutral-variant30: #41474d;     --md-ref-palette-neutral-variant35: #4d5359;     --md-ref-palette-neutral-variant40: #595f65;     --md-ref-palette-neutral-variant50: #72787e;     --md-ref-palette-neutral-variant60: #8b9198;     --md-ref-palette-neutral-variant70: #a6acb3;     --md-ref-palette-neutral-variant80: #c1c7ce;     --md-ref-palette-neutral-variant90: #dee3ea;     --md-ref-palette-neutral-variant95: #ecf1f9;     --md-ref-palette-neutral-variant98: #f7f9ff;     --md-ref-palette-neutral-variant99: #fcfcff;     --md-ref-palette-neutral-variant100: #ffffff;     /* error */     --md-ref-palette-error0: #000000;     --md-ref-palette-error10: #410002;     --md-ref-palette-error20: #690005;     --md-ref-palette-error25: #7e0007;     --md-ref-palette-error30: #93000a;     --md-ref-palette-error35: #a80710;     --md-ref-palette-error40: #ba1a1a;     --md-ref-palette-error50: #de3730;     --md-ref-palette-error60: #ff5449;     --md-ref-palette-error70: #ff897d;     --md-ref-palette-error80: #ffb4ab;     --md-ref-palette-error90: #ffdad6;     --md-ref-palette-error95: #ffedea;     --md-ref-palette-error98: #fff8f7;     --md-ref-palette-error99: #fffbff;     --md-ref-palette-error100: #ffffff;     /* light */     --md-sys-color-primary-light: #006495;     --md-sys-color-on-primary-light: #ffffff;     --md-sys-color-primary-container-light: #cbe6ff;     --md-sys-color-on-primary-container-light: #001e30;     --md-sys-color-secondary-light: #50606f;     --md-sys-color-on-secondary-light: #ffffff;     --md-sys-color-secondary-container-light: #d4e4f6;     --md-sys-color-on-secondary-container-light: #0d1d29;     --md-sys-color-tertiary-light: #66587b;     --md-sys-color-on-tertiary-light: #ffffff;     --md-sys-color-tertiary-container-light: #ecdcff;     --md-sys-color-on-tertiary-container-light: #211634;     --md-sys-color-error-light: #ba1a1a;     --md-sys-color-error-container-light: #ffdad6;     --md-sys-color-on-error-light: #ffffff;     --md-sys-color-on-error-container-light: #410002;     --md-sys-color-background-light: #fcfcff;     --md-sys-color-on-background-light: #1a1c1e;     --md-sys-color-surface-light: #fcfcff;     --md-sys-color-on-surface-light: #1a1c1e;     --md-sys-color-surface-variant-light: #dee3ea;     --md-sys-color-on-surface-variant-light: #41474d;     --md-sys-color-outline-light: #72787e;     --md-sys-color-inverse-on-surface-light: #f0f0f3;     --md-sys-color-inverse-surface-light: #2e3133;     --md-sys-color-inverse-primary-light: #8fcdff;     --md-sys-color-shadow-light: #000000;     --md-sys-color-surface-tint-light: #006495;     --md-sys-color-outline-variant-light: #c1c7ce;     --md-sys-color-scrim-light: #000000;     /* dark */     --md-sys-color-primary-dark: #8fcdff;     --md-sys-color-on-primary-dark: #003450;     --md-sys-color-primary-container-dark: #004b71;     --md-sys-color-on-primary-container-dark: #cbe6ff;     --md-sys-color-secondary-dark: #b8c8d9;     --md-sys-color-on-secondary-dark: #22323f;     --md-sys-color-secondary-container-dark: #394856;     --md-sys-color-on-secondary-container-dark: #d4e4f6;     --md-sys-color-tertiary-dark: #d0bfe7;     --md-sys-color-on-tertiary-dark: #362b4a;     --md-sys-color-tertiary-container-dark: #4d4162;     --md-sys-color-on-tertiary-container-dark: #ecdcff;     --md-sys-color-error-dark: #ffb4ab;     --md-sys-color-error-container-dark: #93000a;     --md-sys-color-on-error-dark: #690005;     --md-sys-color-on-error-container-dark: #ffdad6;     --md-sys-color-background-dark: #1a1c1e;     --md-sys-color-on-background-dark: #e2e2e5;     --md-sys-color-surface-dark: #1a1c1e;     --md-sys-color-on-surface-dark: #e2e2e5;     --md-sys-color-surface-variant-dark: #41474d;     --md-sys-color-on-surface-variant-dark: #c1c7ce;     --md-sys-color-outline-dark: #8b9198;     --md-sys-color-inverse-on-surface-dark: #1a1c1e;     --md-sys-color-inverse-surface-dark: #e2e2e5;     --md-sys-color-inverse-primary-dark: #006495;     --md-sys-color-shadow-dark: #000000;     --md-sys-color-surface-tint-dark: #8fcdff;     --md-sys-color-outline-variant-dark: #41474d;     --md-sys-color-scrim-dark: #000000;     /* display - large */     --md-sys-typescale-display-large-font-family-name: Roboto;     --md-sys-typescale-display-large-font-family-style: Regular;     --md-sys-typescale-display-large-font-weight: 400px;     --md-sys-typescale-display-large-font-size: 57px;     --md-sys-typescale-display-large-line-height: 64px;     --md-sys-typescale-display-large-letter-spacing: -0.25px;     /* display - medium */     --md-sys-typescale-display-medium-font-family-name: Roboto;     --md-sys-typescale-display-medium-font-family-style: Regular;     --md-sys-typescale-display-medium-font-weight: 400px;     --md-sys-typescale-display-medium-font-size: 45px;     --md-sys-typescale-display-medium-line-height: 52px;     --md-sys-typescale-display-medium-letter-spacing: 0px;     /* display - small */     --md-sys-typescale-display-small-font-family-name: Roboto;     --md-sys-typescale-display-small-font-family-style: Regular;     --md-sys-typescale-display-small-font-weight: 400px;     --md-sys-typescale-display-small-font-size: 36px;     --md-sys-typescale-display-small-line-height: 44px;     --md-sys-typescale-display-small-letter-spacing: 0px;     /* headline - large */     --md-sys-typescale-headline-large-font-family-name: Roboto;     --md-sys-typescale-headline-large-font-family-style: Regular;     --md-sys-typescale-headline-large-font-weight: 400px;     --md-sys-typescale-headline-large-font-size: 32px;     --md-sys-typescale-headline-large-line-height: 40px;     --md-sys-typescale-headline-large-letter-spacing: 0px;     /* headline - medium */     --md-sys-typescale-headline-medium-font-family-name: Roboto;     --md-sys-typescale-headline-medium-font-family-style: Regular;     --md-sys-typescale-headline-medium-font-weight: 400px;     --md-sys-typescale-headline-medium-font-size: 28px;     --md-sys-typescale-headline-medium-line-height: 36px;     --md-sys-typescale-headline-medium-letter-spacing: 0px;     /* headline - small */     --md-sys-typescale-headline-small-font-family-name: Roboto;     --md-sys-typescale-headline-small-font-family-style: Regular;     --md-sys-typescale-headline-small-font-weight: 400px;     --md-sys-typescale-headline-small-font-size: 24px;     --md-sys-typescale-headline-small-line-height: 32px;     --md-sys-typescale-headline-small-letter-spacing: 0px;     /* body - large */     --md-sys-typescale-body-large-font-family-name: Roboto;     --md-sys-typescale-body-large-font-family-style: Regular;     --md-sys-typescale-body-large-font-weight: 400px;     --md-sys-typescale-body-large-font-size: 16px;     --md-sys-typescale-body-large-line-height: 24px;     --md-sys-typescale-body-large-letter-spacing: 0.5px;     /* body - medium */     --md-sys-typescale-body-medium-font-family-name: Roboto;     --md-sys-typescale-body-medium-font-family-style: Regular;     --md-sys-typescale-body-medium-font-weight: 400px;     --md-sys-typescale-body-medium-font-size: 14px;     --md-sys-typescale-body-medium-line-height: 20px;     --md-sys-typescale-body-medium-letter-spacing: 0.25px;     /* body - small */     --md-sys-typescale-body-small-font-family-name: Roboto;     --md-sys-typescale-body-small-font-family-style: Regular;     --md-sys-typescale-body-small-font-weight: 400px;     --md-sys-typescale-body-small-font-size: 12px;     --md-sys-typescale-body-small-line-height: 16px;     --md-sys-typescale-body-small-letter-spacing: 0.4px;     /* label - large */     --md-sys-typescale-label-large-font-family-name: Roboto;     --md-sys-typescale-label-large-font-family-style: Medium;     --md-sys-typescale-label-large-font-weight: 500px;     --md-sys-typescale-label-large-font-size: 14px;     --md-sys-typescale-label-large-line-height: 20px;     --md-sys-typescale-label-large-letter-spacing: 0.1px;     /* label - medium */     --md-sys-typescale-label-medium-font-family-name: Roboto;     --md-sys-typescale-label-medium-font-family-style: Medium;     --md-sys-typescale-label-medium-font-weight: 500px;     --md-sys-typescale-label-medium-font-size: 12px;     --md-sys-typescale-label-medium-line-height: 16px;     --md-sys-typescale-label-medium-letter-spacing: 0.5px;     /* label - small */     --md-sys-typescale-label-small-font-family-name: Roboto;     --md-sys-typescale-label-small-font-family-style: Medium;     --md-sys-typescale-label-small-font-weight: 500px;     --md-sys-typescale-label-small-font-size: 11px;     --md-sys-typescale-label-small-line-height: 16px;     --md-sys-typescale-label-small-letter-spacing: 0.5px;     /* title - large */     --md-sys-typescale-title-large-font-family-name: Roboto;     --md-sys-typescale-title-large-font-family-style: Regular;     --md-sys-typescale-title-large-font-weight: 400px;     --md-sys-typescale-title-large-font-size: 22px;     --md-sys-typescale-title-large-line-height: 28px;     --md-sys-typescale-title-large-letter-spacing: 0px;     /* title - medium */     --md-sys-typescale-title-medium-font-family-name: Roboto;     --md-sys-typescale-title-medium-font-family-style: Medium;     --md-sys-typescale-title-medium-font-weight: 500px;     --md-sys-typescale-title-medium-font-size: 16px;     --md-sys-typescale-title-medium-line-height: 24px;     --md-sys-typescale-title-medium-letter-spacing: 0.15px;     /* title - small */     --md-sys-typescale-title-small-font-family-name: Roboto;     --md-sys-typescale-title-small-font-family-style: Medium;     --md-sys-typescale-title-small-font-weight: 500px;     --md-sys-typescale-title-small-font-size: 14px;     --md-sys-typescale-title-small-line-height: 20px;     --md-sys-typescale-title-small-letter-spacing: 0.1px;   } } 

typography.module.scss:

@mixin init() {   .display-large {     font-family: var(--md-sys-typescale-display-large-font-family-name);     font-style: var(--md-sys-typescale-display-large-font-family-style);     font-weight: var(--md-sys-typescale-display-large-font-weight);     font-size: var(--md-sys-typescale-display-large-font-size);     letter-spacing: var(--md-sys-typescale-display-large-tracking);     line-height: var(--md-sys-typescale-display-large-height);     text-transform: var(--md-sys-typescale-display-large-text-transform);     text-decoration: var(--md-sys-typescale-display-large-text-decoration);   }   .display-medium {     font-family: var(--md-sys-typescale-display-medium-font-family-name);     font-style: var(--md-sys-typescale-display-medium-font-family-style);     font-weight: var(--md-sys-typescale-display-medium-font-weight);     font-size: var(--md-sys-typescale-display-medium-font-size);     letter-spacing: var(--md-sys-typescale-display-medium-tracking);     line-height: var(--md-sys-typescale-display-medium-height);     text-transform: var(--md-sys-typescale-display-medium-text-transform);     text-decoration: var(--md-sys-typescale-display-medium-text-decoration);   }   .display-small {     font-family: var(--md-sys-typescale-display-small-font-family-name);     font-style: var(--md-sys-typescale-display-small-font-family-style);     font-weight: var(--md-sys-typescale-display-small-font-weight);     font-size: var(--md-sys-typescale-display-small-font-size);     letter-spacing: var(--md-sys-typescale-display-small-tracking);     line-height: var(--md-sys-typescale-display-small-height);     text-transform: var(--md-sys-typescale-display-small-text-transform);     text-decoration: var(--md-sys-typescale-display-small-text-decoration);   }   .headline-large {     font-family: var(--md-sys-typescale-headline-large-font-family-name);     font-style: var(--md-sys-typescale-headline-large-font-family-style);     font-weight: var(--md-sys-typescale-headline-large-font-weight);     font-size: var(--md-sys-typescale-headline-large-font-size);     letter-spacing: var(--md-sys-typescale-headline-large-tracking);     line-height: var(--md-sys-typescale-headline-large-height);     text-transform: var(--md-sys-typescale-headline-large-text-transform);     text-decoration: var(--md-sys-typescale-headline-large-text-decoration);   }   .headline-medium {     font-family: var(--md-sys-typescale-headline-medium-font-family-name);     font-style: var(--md-sys-typescale-headline-medium-font-family-style);     font-weight: var(--md-sys-typescale-headline-medium-font-weight);     font-size: var(--md-sys-typescale-headline-medium-font-size);     letter-spacing: var(--md-sys-typescale-headline-medium-tracking);     line-height: var(--md-sys-typescale-headline-medium-height);     text-transform: var(--md-sys-typescale-headline-medium-text-transform);     text-decoration: var(--md-sys-typescale-headline-medium-text-decoration);   }   .headline-small {     font-family: var(--md-sys-typescale-headline-small-font-family-name);     font-style: var(--md-sys-typescale-headline-small-font-family-style);     font-weight: var(--md-sys-typescale-headline-small-font-weight);     font-size: var(--md-sys-typescale-headline-small-font-size);     letter-spacing: var(--md-sys-typescale-headline-small-tracking);     line-height: var(--md-sys-typescale-headline-small-height);     text-transform: var(--md-sys-typescale-headline-small-text-transform);     text-decoration: var(--md-sys-typescale-headline-small-text-decoration);   }   .body-large {     font-family: var(--md-sys-typescale-body-large-font-family-name);     font-style: var(--md-sys-typescale-body-large-font-family-style);     font-weight: var(--md-sys-typescale-body-large-font-weight);     font-size: var(--md-sys-typescale-body-large-font-size);     letter-spacing: var(--md-sys-typescale-body-large-tracking);     line-height: var(--md-sys-typescale-body-large-height);     text-transform: var(--md-sys-typescale-body-large-text-transform);     text-decoration: var(--md-sys-typescale-body-large-text-decoration);   }   .body-medium {     font-family: var(--md-sys-typescale-body-medium-font-family-name);     font-style: var(--md-sys-typescale-body-medium-font-family-style);     font-weight: var(--md-sys-typescale-body-medium-font-weight);     font-size: var(--md-sys-typescale-body-medium-font-size);     letter-spacing: var(--md-sys-typescale-body-medium-tracking);     line-height: var(--md-sys-typescale-body-medium-height);     text-transform: var(--md-sys-typescale-body-medium-text-transform);     text-decoration: var(--md-sys-typescale-body-medium-text-decoration);   }   .body-small {     font-family: var(--md-sys-typescale-body-small-font-family-name);     font-style: var(--md-sys-typescale-body-small-font-family-style);     font-weight: var(--md-sys-typescale-body-small-font-weight);     font-size: var(--md-sys-typescale-body-small-font-size);     letter-spacing: var(--md-sys-typescale-body-small-tracking);     line-height: var(--md-sys-typescale-body-small-height);     text-transform: var(--md-sys-typescale-body-small-text-transform);     text-decoration: var(--md-sys-typescale-body-small-text-decoration);   }   .label-large {     font-family: var(--md-sys-typescale-label-large-font-family-name);     font-style: var(--md-sys-typescale-label-large-font-family-style);     font-weight: var(--md-sys-typescale-label-large-font-weight);     font-size: var(--md-sys-typescale-label-large-font-size);     letter-spacing: var(--md-sys-typescale-label-large-tracking);     line-height: var(--md-sys-typescale-label-large-height);     text-transform: var(--md-sys-typescale-label-large-text-transform);     text-decoration: var(--md-sys-typescale-label-large-text-decoration);   }   .label-medium {     font-family: var(--md-sys-typescale-label-medium-font-family-name);     font-style: var(--md-sys-typescale-label-medium-font-family-style);     font-weight: var(--md-sys-typescale-label-medium-font-weight);     font-size: var(--md-sys-typescale-label-medium-font-size);     letter-spacing: var(--md-sys-typescale-label-medium-tracking);     line-height: var(--md-sys-typescale-label-medium-height);     text-transform: var(--md-sys-typescale-label-medium-text-transform);     text-decoration: var(--md-sys-typescale-label-medium-text-decoration);   }   .label-small {     font-family: var(--md-sys-typescale-label-small-font-family-name);     font-style: var(--md-sys-typescale-label-small-font-family-style);     font-weight: var(--md-sys-typescale-label-small-font-weight);     font-size: var(--md-sys-typescale-label-small-font-size);     letter-spacing: var(--md-sys-typescale-label-small-tracking);     line-height: var(--md-sys-typescale-label-small-height);     text-transform: var(--md-sys-typescale-label-small-text-transform);     text-decoration: var(--md-sys-typescale-label-small-text-decoration);   }   .title-large {     font-family: var(--md-sys-typescale-title-large-font-family-name);     font-style: var(--md-sys-typescale-title-large-font-family-style);     font-weight: var(--md-sys-typescale-title-large-font-weight);     font-size: var(--md-sys-typescale-title-large-font-size);     letter-spacing: var(--md-sys-typescale-title-large-tracking);     line-height: var(--md-sys-typescale-title-large-height);     text-transform: var(--md-sys-typescale-title-large-text-transform);     text-decoration: var(--md-sys-typescale-title-large-text-decoration);   }   .title-medium {     font-family: var(--md-sys-typescale-title-medium-font-family-name);     font-style: var(--md-sys-typescale-title-medium-font-family-style);     font-weight: var(--md-sys-typescale-title-medium-font-weight);     font-size: var(--md-sys-typescale-title-medium-font-size);     letter-spacing: var(--md-sys-typescale-title-medium-tracking);     line-height: var(--md-sys-typescale-title-medium-height);     text-transform: var(--md-sys-typescale-title-medium-text-transform);     text-decoration: var(--md-sys-typescale-title-medium-text-decoration);   }   .title-small {     font-family: var(--md-sys-typescale-title-small-font-family-name);     font-style: var(--md-sys-typescale-title-small-font-family-style);     font-weight: var(--md-sys-typescale-title-small-font-weight);     font-size: var(--md-sys-typescale-title-small-font-size);     letter-spacing: var(--md-sys-typescale-title-small-tracking);     line-height: var(--md-sys-typescale-title-small-height);     text-transform: var(--md-sys-typescale-title-small-text-transform);     text-decoration: var(--md-sys-typescale-title-small-text-decoration);   } } 

Подключим в styles.scss:

@use './stylesheets/colors.module' as colors; @use './stylesheets/tokens.module' as tokens; @use './stylesheets/theme.module' as theme; @use './stylesheets/typography.module' as typography;  @include tokens.init(); @include theme.init(); @include typography.init(); @include colors.init();

Основные константы были импортированы, теперь можно создавать UI элементы.

Продублируем src/app/home/page/lib/input-field и назовем просто input.

Теперь осталось самое сложное правильно задать стили. Начнем с того, что просто добавим все стили в input-field:

/* Style Placeholders */ ::placeholder {   color: var(--md-sys-color-on-surface-variant); }  /* Text inputs */ input:not([type]):not(.browser-default), input[type=text]:not(.browser-default), input[type=password]:not(.browser-default), input[type=email]:not(.browser-default), input[type=url]:not(.browser-default), input[type=time]:not(.browser-default), input[type=date]:not(.browser-default), input[type=datetime]:not(.browser-default), input[type=datetime-local]:not(.browser-default), input[type=month]:not(.browser-default), input[type=tel]:not(.browser-default), input[type=number]:not(.browser-default), input[type=search]:not(.browser-default), textarea.materialize-textarea {   outline: none;   color: var(--md-sys-color-on-background);   width: 100%;   font-size: $md_sys_typescale_body-large_size; //16px; // => 16 dp   height: 56px; // 56dp }  %invalid-input-style {   border-bottom: 2px solid var(--md-sys-color-error);   box-shadow: 0 1px 0 0 var(--md-sys-color-error); } %hidden-text > span {   display: none } %custom-error-message {   content: attr(data-error);   color: var(--md-sys-color-error); }  .input-field {   --input-color: var(--md-sys-color-primary);    position: relative;   clear: both;    // Default    input, textarea {     box-sizing: border-box; /* https://stackoverflow.com/questions/1377719/padding-within-inputs-breaks-width-100*/      padding: 0 16px;     padding-top: 20px;      background-color: var(--md-sys-color-surface);      border: none; // reset     border-radius: 4px; // md.sys.shape.corner.extra-small.top     border-bottom: 1px solid var(--md-sys-color-on-surface-variant);     border-bottom-left-radius: 0;     border-bottom-right-radius: 0;      &:focus:not([readonly]) {       border-bottom: 2px solid var(--input-color);       padding-top: 20px + 1px; // add border-width     }      &:disabled, &[readonly="readonly"] {       color: rgba(var(--md_sys_color_on-surface), 0.38);       border-color: rgba(var(--md_sys_color_on-surface), 0.12);       background-color: rgba(var(--md_sys_color_on-surface), 0.04);     }      // Label     &:focus:not([readonly]) + label {       color: var(--input-color);     }     &:focus:not([readonly]) + label,     &:not([placeholder=' ']) + label,     &:not(:placeholder-shown) + label {       //font-size: 12px; // md.sys.typescale.body-small.size       // https://stackoverflow.com/questions/34717492/css-transition-font-size-avoid-jittering-wiggling       transform: scale(calc(12 / 16));       top: 8px;     }      &:disabled + label, &[readonly="readonly"] + label {       color: rgba(var(--md_sys_color_on-surface), 0.38);     }      // Hide helper text on data message     &.invalid ~ .supporting-text[data-error] {       @extend %hidden-text;     }      // Invalid Input Style     &.invalid {       @extend %invalid-input-style;     }      // Custom Error message     &.invalid ~ .supporting-text:after {       @extend %custom-error-message;     }      &.invalid ~ label,     &:focus.invalid ~ label {       color: var(--md-sys-color-error);     }   }    input::placeholder {     user-select: none;   }    & > label {     color: var(--md-sys-color-on-surface-variant);     user-select: none;     font-size: 16px;     position: absolute;     left: 16px;     top: 16px;     cursor: text;     transform-origin: top left;     transition:       left 0.2s ease-out,       top 0.2s ease-out,       transform 0.2s ease-out     ;   }    // Sub-Infos    .supporting-text {     color: var(--md-sys-color-on-surface-variant);     font-size: 12px;     padding: 0 16px;     margin-top: 4px;   }    .character-counter {     color: var(--md-sys-color-on-surface-variant);     font-size: 12px;     float: right;     padding: 0 16px;     margin-top: 4px;   }    .prefix {     position: absolute;     left: 12px;     top: 16px;     user-select: none;     display: flex;     align-self: center;   }    .suffix {     position: absolute;     right: 12px;     top: 16px;     user-select: none;   }    .prefix ~ input, .prefix ~ textarea {     padding-left: calc(12px + 24px + 16px);   }   .suffix ~ input, .suffix ~ textarea {     padding-right: calc(12px + 24px + 16px);   }   .prefix ~ label {     left: calc(12px + 24px + 16px);   }    // Outlined    &.outlined {      input, textarea {       padding-top: 0;        background-color: var(--md-sys-color-background);        border: 1px solid var(--md-sys-color-on-surface-variant);       border-radius: 4px; // md.sys.shape.corner.extra-small        &:focus:not([readonly]) {         border: 2px solid var(--input-color);         padding-top: 0;         margin-left: -1px; // subtract border-width        }       // Label       &:focus:not([readonly]) + label {         color: var(--input-color);       }       &:focus:not([readonly]) + label,       &:not([placeholder=' ']) + label,       &:not(:placeholder-shown) + label {         top: -8px;         left: 16px;         margin-left: -4px;         padding: 0 4px;         background-color: var(--md-sys-color-background);       }        &:disabled, &[readonly="readonly"] {         color: rgba(var(--md_sys_color_on-surface), 0.38);         border-color: rgba(var(--md_sys_color_on-surface), 0.12);       }      }   }    // Error   &.error {     input, textarea {       border-color: var(--md-sys-color-error);     }     input:focus:not([readonly]), textarea:focus:not([readonly]) {       border-color: var(--md-sys-color-error);     }     input:focus:not([readonly]) + label, textarea:focus:not([readonly]) + label {       color: var(--md-sys-color-error);     }     label {       color: var(--md-sys-color-error);     }     .supporting-text {       color: var(--md-sys-color-error);     }     .suffix {       color: var(--md-sys-color-error);     }   } }  /* Search Field */ .searchbar {   .prefix {     position: absolute;     //left: 12px;     padding-left: 1rem;     top: 0;     user-select: none;     display: flex;     align-self: center;   }    & > input {     border-width: 0;     background-color: transparent;     padding-left: 3rem;   } } .searchbar.has-sidebar {   margin-left: 0;   @media #{$large-and-up} {     margin-left: 300px;   } }  /* .input-field input[type=search] {   display: block;   line-height: inherit;    .nav-wrapper & {     height: inherit;     padding-left: 4rem;     width: calc(100% - 4rem);     border: 0;     box-shadow: none;   }   &:focus:not(.browser-default) {     border: 0;     box-shadow: none;   }   & + .label-icon {     transform: none;     left: 1rem;   } } */  /* Textarea */ // Default textarea textarea {   width: 100%;   height: 3rem;   background-color: transparent;    &.materialize-textarea {     padding-top: 26px !important;     padding-bottom: 4px !important;     line-height: normal;     overflow-y: hidden; /* prevents scroll bar flash */     resize: none;     min-height: 3rem;     box-sizing: border-box;   } }  // For textarea autoresize .hiddendiv {   visibility: hidden;   white-space: pre-wrap;   word-wrap: break-word;   overflow-wrap: break-word; /* future version of deprecated 'word-wrap' */   padding-top: 1.2rem; /* prevents text jump on Enter keypress */    // Reduces repaints   position: absolute;   top: 0;   z-index: -1; }  /* Autocomplete Items */ .autocomplete-content {   li {     .highlight { color: var(--md-sys-color-on-background); }     img {       height: $dropdown-item-height - 10;       width: $dropdown-item-height - 10;       margin: 5px 15px;     }   } }  /* Datepicker date input fields */ .datepicker-date-input {   position: relative;   text-indent: -9999px;    &::after {     display: block;     position: absolute;     top: 1.10rem;     content: attr(data-date);     color: var(--input-color);     text-indent: 0;   }    &:focus-visible {     text-indent: 0;   }    &:focus-visible:after {     text-indent: -9999px;   } }

Подключим компонент в HomeCodeComponent и удаляем предыдущие компоненты:

import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import type { FormControl } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';  import { InputComponent } from '../input/input/input.component'; import { InputCharacterCounterComponent } from '../input/input-character-counter/input-character-counter.component'; import { InputFieldComponent } from '../input/input-field.component'; import { InputHintComponent } from '../input/input-hint/input-hint.component'; import { InputLabelComponent } from '../input/input-label/input-label.component'; import { InputPrefixComponent } from '../input/input-prefix/input-prefix.component'; import { InputSuffixComponent } from '../input/input-suffix/input-suffix.component';  @Component({   selector: 'app-home-code',   imports: [     ReactiveFormsModule,     InputFieldComponent,     InputLabelComponent,     InputComponent,     InputHintComponent,     InputCharacterCounterComponent,     InputSuffixComponent,     InputPrefixComponent,   ],   templateUrl: './home-code.component.html',   changeDetection: ChangeDetectionStrategy.OnPush, }) export class HomeCodeComponent {   readonly control = input.required<FormControl<string>>(); }

Запустим проект:

Ошибка компиляции

Ошибка компиляции

Увидим ошибку стилей — заменим $md_sys_typescale_body-large_size на 16px.

Также компилятор ругается на $large-and-up. Просто комментируем этот блок:

//.searchbar.has-sidebar { //  margin-left: 0; //  @media #{$large-and-up} { //    margin-left: 300px; //  } //}

Также заменим $dropdown-item-height на 50px.

Проект успешно запустился

Проект успешно запустился

Откроем браузер:

Видим, что не все стили отображаются корректно.

Отключим инкапсуляцию — encapsulation: ViewEncapsulation.None

Обновляем страницу:

Укажем фиксированную ширину:

.input-field {   display: block; }

Идеально, все работает как задумано.

Осталось сделать так, чтобы компоненты использовали инкапсуляцию.

Второй секрет интеграции CSS-фреймворков заключается в том, что вы сначала воспроизводите реализацию без инкапсуляции, убеждаясь в том, что все корректно, а уже затем, маленькими шагами начинаете кусками переносить код в нужные компоненты. Делаем это от родителя к его потомкам.

Начнем с нашего компонента input-field:

Берем этот блок и переносим в input/input.component.scss.

Так как InputComponent это обертка над нативным инпутом, то наш код трансформируется в следующий:

:host {   &:not([type]):not(.browser-default),   &[type='text']:not(.browser-default),   &[type='password']:not(.browser-default),   &[type='email']:not(.browser-default),   &[type='url']:not(.browser-default),   &[type='time']:not(.browser-default),   &[type='date']:not(.browser-default),   &[type='datetime']:not(.browser-default),   &[type='datetime-local']:not(.browser-default),   &[type='month']:not(.browser-default),   &[type='tel']:not(.browser-default),   &[type='number']:not(.browser-default),   &[type='search']:not(.browser-default),   &.materialize-textarea {     outline: none;     color: var(--md-sys-color-on-background);     width: 100%;     font-size: 16px; // => 16 dp     height: 56px; // 56dp   } } 

Дальше по аналогии, все что мы можем просто переносим.

  .supporting-text {     color: var(--md-sys-color-on-surface-variant);     font-size: 12px;     padding: 0 16px;     margin-top: 4px;   }

Переносим это в  input-hint.component.scss:

:host {   color: var(--md-sys-color-on-surface-variant);   font-size: 12px;   padding: 0 16px;   margin-top: 4px; }

После того, как простая часть была интегрирована, переходим к связанным селекторам.

.input-field {   input,   textarea {     // Label     &:focus:not([readonly]) + label {       color: var(--input-color);     }   } } 

В примере видим, что input и label связаны между собой. Для того чтобы это работало с инкапсуляцией мы переносим эти стили в LabelComponent:

.app-input:focus:not([readonly]) + :host {   color: var(--md-sys-color-primary); }

Так как мы знаем, что LabelComponent это обертка над нативным элементом, то  + label превращается в  + :host.

Аналогично перенесем все стили, которые можем.

Последними остаются состояния. Возьмем в качестве примера валидацию. Она реализована с помощью добавления класса error.

.input-field {   // Error   &.error {     input,     textarea {       border-color: var(--md-sys-color-error);     }   } }

Для того чтобы потомки видели изменения родителя необходимо использовать :host-context(). Добавим правила для input.component.scss:

:host {   :host-context(.error) {     border-color: var(--md-sys-color-error);      &:focus:not([readonly]) {       border-color: var(--md-sys-color-error);     }   } }

Таким способом можно перенести все состояния.

Итоговые компоненты будут выглядеть следующим образом:

input-field.component.scss:

:host {   display: block;   position: relative;   clear: both;    &.is-inline {     display: inline-block;   } }

input-suffix.component.scss:

:host {   position: absolute;   right: 12px;   top: 16px;   user-select: none; }  :host-context(.is-error) {   color: var(--md-sys-color-error); }

input-prefix.component.scss:

:host {   position: absolute;   left: 12px;   top: 16px;   user-select: none;   display: flex;   align-self: center; }

input-label.component.scss:

:host {   color: var(--md-sys-color-on-surface-variant);   user-select: none;   font-size: 16px;   position: absolute;   left: 16px;   top: 16px;   cursor: text;   transform-origin: top left;   transition:     left 0.2s ease-out,     top 0.2s ease-out,     transform 0.2s ease-out; }  // Label .app-input:focus:not([readonly]) + :host {   color: var(--md-sys-color-primary); } .app-input:focus:not([readonly]) + :host, .app-input:not([placeholder=' ']) + :host, .app-input:not(:placeholder-shown) + :host {   //font-size: 12px; // md.sys.typescale.body-small.size   // https://stackoverflow.com/questions/34717492/css-transition-font-size-avoid-jittering-wiggling   transform: scale(calc(12 / 16));   top: 8px; }  .app-input:disabled + :host, .app-input[readonly='readonly'] + :host {   color: rgba(var(--md_sys_color_on-surface), 0.38); }  .app-input-prefix ~ :host {   left: calc(12px + 24px + 16px); }  // is-outlined .app-input.is-outlined:focus:not([readonly]) + :host {   color: var(--md-sys-color-primary); }  :host-context(.is-outlined) .app-input:focus:not([readonly]) + :host, :host-context(.is-outlined) .app-input:not([placeholder=' ']) + :host, :host-context(.is-outlined) .app-input:not(:placeholder-shown) + :host {   top: -8px;   left: 16px;   margin-left: -4px;   padding: 0 4px;   background-color: var(--md-sys-color-background); }  .app-input.is-invalid ~ :host, .app-input:focus.is-invalid ~ :host {   color: var(--md-sys-color-error); }  :host-context(.is-error) {   color: var(--md-sys-color-error); }  :host-context(.is-error input:focus:not([readonly])) + :host {   color: var(--md-sys-color-error); }

input-hint.component.scss:

:host {   color: var(--md-sys-color-on-surface-variant);   font-size: 12px;   padding: 0 16px;   margin-top: 4px; }  .app-input.is-invalid ~ :host:after {   content: attr(data-error);   color: var(--md-sys-color-error); }  :host-context(.is-error) {   color: var(--md-sys-color-error); }

input-character-counter.component.scss:

:host {   color: var(--md-sys-color-on-surface-variant);   font-size: 12px;   float: right;   padding: 0 16px;   margin-top: 4px; }  // Hide helper text on data message .app-input.is-invalid ~ :host {   display: none; } 

input.component.scss:

:host {   &:not([type]):not(.browser-default),   &[type='text']:not(.browser-default),   &[type='password']:not(.browser-default),   &[type='email']:not(.browser-default),   &[type='url']:not(.browser-default),   &[type='time']:not(.browser-default),   &[type='date']:not(.browser-default),   &[type='datetime']:not(.browser-default),   &[type='datetime-local']:not(.browser-default),   &[type='month']:not(.browser-default),   &[type='tel']:not(.browser-default),   &[type='number']:not(.browser-default),   &[type='search']:not(.browser-default),   &.materialize-textarea {     outline: none;     color: var(--md-sys-color-on-background);     width: 100%;     font-size: 16px; // => 16 dp     height: 56px; // 56dp   }    box-sizing: border-box; /* https://stackoverflow.com/questions/1377719/padding-within-inputs-breaks-width-100*/   padding: 20px 16px 0 16px;   background-color: var(--md-sys-color-surface);   border: none; // reset   border-radius: 4px 4px 0 0; // md.sys.shape.corner.extra-small.top   border-bottom: 1px solid var(--md-sys-color-on-surface-variant);    &:focus:not([readonly]) {     border-bottom: 2px solid var(--md-sys-color-primary);     padding-top: 20px + 1px; // add border-width   }    &:disabled,   &[readonly='readonly'] {     color: rgba(var(--md_sys_color_on-surface), 0.38);     border-color: rgba(var(--md_sys_color_on-surface), 0.12);     background-color: rgba(var(--md_sys_color_on-surface), 0.04);   }    &:not(.app-textarea)::placeholder {     user-select: none;   }    :host-context(.is-outlined) {     padding-top: 0;     background-color: var(--md-sys-color-background);     border: 1px solid var(--md-sys-color-on-surface-variant);     border-radius: 4px; // md.sys.shape.corner.extra-small      &:focus:not([readonly]) {       border: 2px solid var(--md-sys-color-primary);       padding-top: 0;       margin-left: -1px; // subtract border-width     }      &:disabled,     &[readonly='readonly'] {       color: rgba(var(--md_sys_color_on-surface), 0.38);       border-color: rgba(var(--md_sys_color_on-surface), 0.12);     }   }    // Invalid Input Style   &.is-invalid {     border-bottom: 2px solid var(--md-sys-color-error);     box-shadow: 0 1px 0 0 var(--md-sys-color-error);   }    :host-context(.is-error) {     border-color: var(--md-sys-color-error);      &:focus:not([readonly]) {       border-color: var(--md-sys-color-error);     }   } }  .app-input-prefix ~ :host {   padding-left: calc(12px + 24px + 16px); }  .app-input-suffix ~ :host {   padding-right: calc(12px + 24px + 16px); }  .app-textarea {   width: 100%;   height: 3rem;   background-color: transparent;    &.materialize-textarea {     padding-top: 26px !important;     padding-bottom: 4px !important;     line-height: normal;     overflow-y: hidden; /* prevents scroll bar flash */     resize: none;     min-height: 3rem;     box-sizing: border-box;   } }

Убираем инкапсуляцию из компонента.

import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { ChangeDetectionStrategy, Component, contentChild, input } from '@angular/core';  import type { CoerceBoolean, InputMode } from '@amz/core';  import { InputComponent } from './input/input.component';  @Component({   selector: 'app-input-field',   template: '<ng-content/>',   styleUrl: './input-field.component.scss',   changeDetection: ChangeDetectionStrategy.OnPush,   host: {     class: 'app-input-field',     '[class.is-inline]': `inline()`,     '[class.is-outlined]': `mode() === 'outlined'`,     '[class.is-error]': 'input().ngControl.touched && input().ngControl.errors',   }, }) export class InputFieldComponent {   readonly inline = input<CoerceBoolean, CoerceBoolean>(false, { transform: coerceBooleanProperty });    readonly mode = input<InputMode>('default');    readonly input = contentChild.required<InputComponent>(InputComponent); }

Запускаем браузер:

Как видно на скриншоте, наш компонент полностью инкапсулирован. Нет зависимости от глобальных стилей, а все состояния корректно отображаются.

Полная инкапсуляция

Полная инкапсуляция

Заключение

Поздравляю, вы дочитали до конца! Давайте подведем итоги и резюмируем ключевые моменты статьи.

В самом начале мы обсудили основные проблемы, возникающие при интеграции CSS-фреймворков в Angular. Для Angular наиболее значимыми из них являются:

  • Инкапсуляция стилей. Особенность Angular, изолирующая стили внутри компонентов, что требует дополнительных усилий для работы с вложенными элементами.

  • Работа с состояниями. Необходимость вручную добавлять или убирать классы для управления стилями в зависимости от состояния.

  • Стили для JavaScript-компонентов. Ограничения Angular, которые могут мешать корректной работе сложных элементов интерфейса.

Чтобы продемонстрировать эти проблемы и пути их решения, мы создали новое Angular-приложение, настроили окружение для удобной работы и подключили CSS-фреймворк Materialize. Сначала мы выполнили простую интеграцию текстового поля, что позволило выявить основные трудности.

Затем, используя лучшие практики, мы показали, как избежать проблем, связанных с монолитностью. Мы разделили логику текстового поля на несколько атомарных компонентов, что улучшило читаемость, масштабируемость и повторное использование кода.

Далее мы углубились в тему инкапсуляции стилей. Мы разобрали, как перенести стили из CSS-фреймворка в Angular, используя пошаговый подход. В процессе мы применили два полезных лайфхака:

  1. Использование компонентов-оберток. Поскольку Angular-компоненты являются специальным видом директив, их можно использовать для применения стилей и логики к нативным элементам. Это позволяет сохранить преимущества компонентов и одновременно обеспечить доступ к DOM-элементам.

  2. Интеграция с отключенной инкапсуляцией. На начальном этапе мы отключили инкапсуляцию стилей, чтобы сократить время разработки и упростить интеграцию.

В итоге мы пришли к следующим выводам:

  • Проблемы интеграции CSS-фреймворков решаемы с правильным подходом.

  • Materialize может быть успешно использован в Angular, если уделить внимание настройке стилей и логике взаимодействия.

  • Разделение логики на атомарные компоненты значительно улучшает масштабируемость и поддерживаемость кода.

Теперь вы знаете, как интегрировать CSS-фреймворки как напрямую, так и с учетом особенностей Angular. Эти знания позволят вам создавать более функциональные и эстетичные приложения.

Если у вас был опыт интеграции других CSS-фреймворков в Angular, напишите об этом в комментариях. Какие решения вам удалось успешно реализовать, а что оказалось сложным для переноса? Ваши кейсы могут быть полезны другим читателям!

Ссылки

Все исходники находятся на github, в репозитории — https://github.com/Fafnur/angular-materialize


ссылка на оригинал статьи https://habr.com/ru/articles/870192/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *