Вы создали новое 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