Вы создали новое Angular‑приложение, подключили популярный CSS‑фреймворк, но вместо ожидаемого вау‑эффекта столкнулись с проблемами: стили выглядят не так, как хотелось, валидация форм работает странно, а некоторые элементы вообще не реагируют на изменения состояния. Знакомо? Это типичная ситуация, когда CSS‑фреймворки интегрируются без учета особенностей Angular.
Эта статья поможет вам разобраться, почему возникают такие трудности, и покажет, как правильно интегрировать CSS‑фреймворки в Angular. Мы рассмотрим ключевые проблемы, разберем их решения и реализуем стильное, реактивное поле ввода с применением лучших практик Angular.
Зачем вообще интегрировать сторонний CSS‑фреймворк?
Есть несколько весомых причин, почему стоит использовать CSS‑фреймворки:
-
Скорость разработки — вы сможете быстрее создавать новые функции приложения, не тратя время на оформление и стилизацию.
-
Качество кода — не все разработчики в совершенстве владеют CSS, а ошибки в стилях и несовместимость между браузерами случаются часто.
-
Чувство вкуса — далеко не каждый программист обладает творческими навыками.
Если в вашей команде нет дизайнера и разработчиков, которые следят за единым стилем интерфейса, то использование CSS‑фреймворка — лучший выбор.
Можно ли использовать что‑то «из коробки» Angular?
Да, можно, и это Angular Material.
Хорош ли он?
В целом, да, но с оговорками. Если ваше приложение не следует стилистике Material Design, то Angular Material вам вряд ли подойдет.
Однако есть вещь, которая точно заслуживает внимания — @angular/cdk, который станет вашим лучшим помощником в работе с Angular.
Какие сложности могут возникнуть при интеграции CSS‑фреймворка?
Основные проблемы можно свести к трем ключевым аспектам:
-
Инкапсуляция стилей. Angular использует инкапсуляцию, в результате которой потомки не получают требуемых CSS правил.
-
Работа с состояниями. Нужно динамически добавлять и убирать классы в зависимости от условий, что требует активного вмешательства через TypeScript.
-
Стили для JavaScript‑компонентов. Многие фреймворки полагаются на JavaScript для работы сложных элементов — автозаполнение, попапы, тултипы и другие. Эти компоненты трудно интегрировать в Angular, так как зачастую, реализацию из CSS фреймворка использовать невозможно.
Цель статьи
Мы покажем, как решить вышеописанные трудности, интегрируя Materialize в Angular-приложение. На конкретном примере вы узнаете, как создать компонент текстового поля, который будет полностью интегрирован с реактивными формами и сохранит стиль и динамику Materialize.
Проблемы прямой интеграции CSS-фреймворков в Angular
Почему нельзя напрямую подключить стили и использовать выбранный CSS‑фреймворк?
Такой подход таит в себе ряд трудностей:
-
Отсутствие реактивности. CSS‑фреймворки — это набор готовых классов, которые нужно вручную добавлять или удалять для изменения состояния элементов. Например, при валидации формы, чтобы выделить поле с ошибкой красным, вы должны самостоятельно добавлять класс error, подписываясь на события и управляя состоянием через javascript.
-
Проблемы с инкапсуляцией стилей. Angular по умолчанию использует инкапсуляцию, изолируя стили компонентов. Это мешает наследованию CSS‑правил, особенно если они завязаны на DOM‑структуру. Чтобы решить эту проблему, приходится использовать специальные селекторы, такие как:host и:host‑context. Использование::ng‑deep для отключения инкапсуляции не рекомендуется, так как он помечен как устаревший (deprecated).
-
Коллизия имен. Если в проекте используются несколько CSS‑фреймворков, возможны конфликты имен классов или селекторов, что приведет к неправильному отображению интерфейса.
-
Ограничения работы со сторонним JavaScript. Многие сложные компоненты, такие как автозаполнение, попапы или тултипы, требуют использования JavaScript. Однако такие решения из CSS‑фреймворков часто не работают в Angular из‑за особенностей его архитектуры и работы с DOM.
-
Глобальные стили и их побочные эффекты. Прямая интеграция подразумевает использование глобальных стилей, что может привести к нежелательным побочным эффектам, особенно при включенном серверном рендеринге (SSR).
Почему нужна полноценная интеграция?
Полноценная интеграция позволяет:
-
Реализовать элементы управления с учетом реактивности Angular.
-
Избежать конфликтов имен и проблем с инкапсуляцией.
-
Корректно работать с JavaScript‑компонентами фреймворка.
-
Минимизировать побочные эффекты от глобальных стилей.
Давайте рассмотрим, как интегрировать CSS‑фреймворк Materialize в Angular на конкретном примере.
Установка и настройка Angular-приложения
Если вы знакомы с Angular, переходите к следующей главе. Данная глава нужна больше для того, чтобы показать какие изменения были сделаны до начала интеграции. Все исходники находятся на github, в репозитории — https://github.com/Fafnur/angular-materialize
Давайте создадим новое приложение с помощью команды:
npx -p @angular/cli ng new angular-materialize
Удалим вендоры:
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.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';
Запустим проект:
Как видно из примера, что подтянулись все переменные и стили.
Для нового проекта это не страшно, но если у вас уже есть другое оформление, то это может быть проблемой.
Отметим, что 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; }
Обновим страницу:
Все работает отлично, кроме иконок. Нужно добавить шрифт.
Вставим в 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 с nput:
<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:
Осталось добавить переключение оформления инпута.
Создадим новый 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 лет, добавим обновленную версию.
Создадим новый файл в 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, используя пошаговый подход. В процессе мы применили два полезных лайфхака:
-
Использование компонентов-оберток. Поскольку Angular-компоненты являются специальным видом директив, их можно использовать для применения стилей и логики к нативным элементам. Это позволяет сохранить преимущества компонентов и одновременно обеспечить доступ к DOM-элементам.
-
Интеграция с отключенной инкапсуляцией. На начальном этапе мы отключили инкапсуляцию стилей, чтобы сократить время разработки и упростить интеграцию.
В итоге мы пришли к следующим выводам:
-
Проблемы интеграции CSS-фреймворков решаемы с правильным подходом.
-
Materialize может быть успешно использован в Angular, если уделить внимание настройке стилей и логике взаимодействия.
-
Разделение логики на атомарные компоненты значительно улучшает масштабируемость и поддерживаемость кода.
Теперь вы знаете, как интегрировать CSS-фреймворки как напрямую, так и с учетом особенностей Angular. Эти знания позволят вам создавать более функциональные и эстетичные приложения.
Если у вас был опыт интеграции других CSS-фреймворков в Angular, напишите об этом в комментариях. Какие решения вам удалось успешно реализовать, а что оказалось сложным для переноса? Ваши кейсы могут быть полезны другим читателям!
Ссылки
Все исходники находятся на github, в репозитории — https://github.com/Fafnur/angular-materialize
ссылка на оригинал статьи https://habr.com/ru/articles/870192/
Добавить комментарий