Создадим несколько компонентов нашего приложения.
В данном случае, нам нужны следующие UI
:
-
accordion
— аккордеон; -
autocomplete
—input
с автозаполнением; -
buttons
— кнопки; -
cards
— карточки; -
checkbox
— чекбоксы; -
container
- центрирует контент; -
datepicker
— выбор даты; -
dialog
— модальные окна; -
headline
— промо текст; -
icons
— набор svg иконок; -
input
— инпуты; -
label
— лейблы; -
layout
— лейаут; -
nav
— меню; -
section
— задача фона секциям в контенте; -
title
— заголовки.
Сложные элементы будут использовать angular/cdk
(dialog
, accordion
), чтобы упростить костылизацию.
Поэтому добавим пакет:
yarn add -D @angular/cdk
И немного магии для scss
:
{
"inlineStyleLanguage": "scss",
"stylePreprocessorOptions": {
"includePaths": ["node_modules", "./"]
}
}
Создание утилит
Добавим новый каталог utils
в ui
:
mkdir src/app/ui/utils
mkdir src/app/ui/utils/lib
echo >src/app/ui/utils/index.ts
В tsconfig.json
пропишем алиас:
{
"paths": {
"@baf/ui/utils": ["src/app/ui/utils/index.ts"]
}
}
Определим несколько типов в types.ts
:
export type ButtonMode = 'primary' | 'secondary' | 'tertiary' | undefined;
export type Size = 'small' | 'medium' | 'large' | undefined;
export type Align = 'left' | 'center' | 'right' | undefined;
export type ExtraSize = 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | undefined;
export type Width = 'max' | 'initial' | undefined;
Все они опциональные, поэтому содержат undefined
.
Некоторые общие свойства компонентов:
-
align
- центрирование слева, справа; -
disabled
- выключенное состояние (актуально для форм); -
size
,extra-size
- размер текста маленький, средний и большой -
mode
- типы кнопок; -
width
- ширина элемента.
AlignDirective
Пример реализации align
:
import { Directive, inject, input } from '@angular/core';
import { ExtraClassService, toClass } from '@baf/core';
import type { Align } from './types';
@Directive({
selector: '[bafAlign]',
standalone: true,
providers: [ExtraClassService],
})
export class AlignDirective {
private readonly extraClassService = inject(ExtraClassService);
readonly align = input<Align, Align>(undefined, {
alias: 'bafAlign',
transform: (value) => {
this.extraClassService.update('align', toClass(value, 'align'));
return value;
},
});
}
ExtraClassService
- сервис, который добавляет соответствующий класс.
Как альтернатива - можно использовать @HostBinding('class.align-center')
или задавать правила через host
.
Так как angular
в directive
не позволяет подключать стили, добавим миксин, который необходимо импортировать для каждого компонента.
Создадим файл align.scss
в src/stylesheets
:
@mixin make-align() {
&.align-left {
text-align: left;
}
&.align-center {
text-align: center;
}
&.align-right {
text-align: right;
}
}
Пример использования:
@use 'src/stylesheets/align' as align;
:host {
@include align.make-align();
}
Остальные директивы аналогичны.
Container
Добавим container
и пропишем алиас.
mkdir src/app/ui/container
mkdir src/app/ui/container/lib
echo >src/app/ui/container/index.ts
Генерируем компонент:
yarn ng g c container
Переносим его в src/app/ui/container/lib
и отредактируем ContainerComponent
:
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AlignDirective } from '@baf/ui/utils';
import { FluidDirective } from './fluid.directive';
import { MobileDirective } from './mobile.directive';
@Component({
selector: 'baf-container',
standalone: true,
imports: [RouterOutlet],
template: '<ng-content/>',
styleUrl: './container.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'baf-container',
},
hostDirectives: [
{
directive: FluidDirective,
inputs: ['bafFluid'],
},
{
directive: MobileDirective,
inputs: ['bafMobile'],
},
{
directive: AlignDirective,
inputs: ['bafAlign'],
},
],
})
export class ContainerComponent {}
Добавим стили:
@use 'src/stylesheets/align' as align;
@use 'src/stylesheets/device' as device;
:host {
display: flex;
flex-direction: column;
margin-left: auto;
margin-right: auto;
width: 100%;
&.fluid {
max-width: 100%;
}
&:not(.mobile-no-gutter) {
padding-left: 1rem;
padding-right: 1rem;
}
@include align.make-align();
@include device.media-tablet-portrait() {
&:not(.fluid) {
max-width: 788px;
}
}
@include device.media-tablet-landscape() {
&:not(.fluid) {
max-width: 928px;
}
}
@include device.media-web-portrait() {
&:not(.fluid) {
max-width: 808px;
}
}
@include device.media-web-landscape() {
&:not(.fluid) {
max-width: 1200px;
}
}
}
Миксины на ширину взяты из material
:
@mixin media-handset() {
@media (max-width: 599.98px) and (orientation: portrait), (max-width: 959.98px) and (orientation: landscape) {
@content;
}
}
@mixin media-handset-up() {
@media (min-width: 0) and (orientation: portrait), (min-width: 0) and (orientation: landscape) {
@content;
}
}
@mixin media-handset-portrait() {
@media (max-width: 599.98px) and (orientation: portrait) {
@content;
}
}
@mixin media-handset-landscape() {
@media (max-width: 959.98px) and (orientation: landscape) {
@content;
}
}
@mixin media-tablet() {
@media (min-width: 600px) and (max-width: 839.98px) and (orientation: portrait),
(min-width: 960px) and (max-width: 1279.98px) and (orientation: landscape) {
@content;
}
}
@mixin media-tablet-up() {
@media (min-width: 600px) and (orientation: portrait), (min-width: 960px) and (orientation: landscape) {
@content;
}
}
@mixin media-tablet-landscape() {
@media (min-width: 960px) and (max-width: 1279.98px) and (orientation: landscape) {
@content;
}
}
@mixin media-tablet-portrait() {
@media (min-width: 600px) and (max-width: 839.98px) and (orientation: portrait) {
@content;
}
}
@mixin media-web() {
@media (min-width: 840px) and (orientation: portrait), (min-width: 1280px) and (orientation: landscape) {
@content;
}
}
@mixin media-web-up() {
@media (min-width: 840px) and (orientation: portrait), (min-width: 1280px) and (orientation: landscape) {
@content;
}
}
@mixin media-web-portrait() {
@media (min-width: 840px) and (orientation: portrait) {
@content;
}
}
@mixin media-web-landscape() {
@media (min-width: 1280px) and (orientation: landscape) {
@content;
}
}
Также создадим две директивы FluidDirective
и MobileDirective
:
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { Directive, inject, input } from '@angular/core';
import type { CoerceBoolean } from '@baf/core';
import { ExtraClassService } from '@baf/core';
@Directive({
selector: 'baf-container[bafFluid]',
standalone: true,
providers: [ExtraClassService],
})
export class FluidDirective {
private readonly extraClassService = inject(ExtraClassService);
readonly fluid = input<CoerceBoolean, CoerceBoolean>(undefined, {
alias: 'bafFluid',
transform: (value) => {
this.extraClassService.patch('fluid', coerceBooleanProperty(value));
return value;
},
});
}
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { Directive, inject, input } from '@angular/core';
import type { CoerceBoolean } from '@baf/core';
import { ExtraClassService } from '@baf/core';
@Directive({
selector: 'baf-container[bafMobile]',
standalone: true,
providers: [ExtraClassService],
})
export class MobileDirective {
private readonly extraClassService = inject(ExtraClassService);
readonly mobile = input<CoerceBoolean, CoerceBoolean>(undefined, {
alias: 'bafMobile',
transform: (value) => {
this.extraClassService.patch('mobile-no-gutter', coerceBooleanProperty(value));
return value;
},
});
}
Title, Label, Headline, Section и Card
Добавим title
и пропишем алиас:
mkdir src/app/ui/title
mkdir src/app/ui/title/lib
echo >src/app/ui/title/index.ts
Генерируем компонент:
yarn ng g c title
Переносим его в src/app/ui/title/lib
и отредактируем TitleComponent
:
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { AlignDirective, SizeDirective } from '@baf/ui/utils';
@Component({
selector: 'baf-title,[baf-title],[bafTitle]',
standalone: true,
template: '<ng-content/>',
styleUrl: './title.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'baf-title',
},
hostDirectives: [
{
directive: SizeDirective,
inputs: ['bafSize'],
},
{
directive: AlignDirective,
inputs: ['bafAlign'],
},
],
})
export class TitleComponent {}
Немного стилей:
@use 'src/stylesheets/align' as align;
@use 'src/stylesheets/size' as size;
@use 'src/stylesheets/typography' as typography;
:host {
@include size.make-size() using ($size) {
@if $size == small {
@include typography.title-small();
} @else if $size == medium {
@include typography.title-medium();
} @else if $size == large {
@include typography.title-large();
}
}
@include align.make-align();
}
Остальное делаем аналогично.
Buttons
Перейдем к созданию более сложных виджетов.
Добавим buttons
и пропишем алиас:
mkdir src/app/ui/buttons
mkdir src/app/ui/buttons/lib
echo >src/app/ui/buttons/index.ts
Так как кнопки нужны в нескольких видах, то зададим базовые типы -ButtonBase
и AnchorBase
:
import type { FocusOrigin } from '@angular/cdk/a11y';
import { FocusMonitor } from '@angular/cdk/a11y';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core';
import { Directive, ElementRef, inject, NgZone } from '@angular/core';
@Directive()
export class ButtonBase implements AfterViewInit, OnDestroy {
protected readonly elementRef = inject(ElementRef);
private isDisabled = false;
private readonly focusMonitor = inject(FocusMonitor);
get disabled(): boolean {
return this.isDisabled;
}
set disabled(value: string | boolean | null | undefined) {
const disabled = coerceBooleanProperty(value);
if (disabled !== this.isDisabled) {
this.isDisabled = disabled;
}
}
ngAfterViewInit() {
this.focusMonitor.monitor(this.elementRef, true);
}
ngOnDestroy() {
this.focusMonitor.stopMonitoring(this.elementRef);
}
focus(origin: FocusOrigin = 'program', options?: FocusOptions): void {
if (origin) {
this.focusMonitor.focusVia(this.elementRef.nativeElement, origin, options);
} else {
this.elementRef.nativeElement.focus(options);
}
}
}
@Directive()
export class AnchorBase extends ButtonBase implements OnInit, OnDestroy {
private readonly ngZone = inject(NgZone);
protected readonly haltDisabledEvents = (event: Event) => {
if (this.disabled) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
ngOnInit(): void {
this.ngZone.runOutsideAngular(() => {
this.elementRef.nativeElement.addEventListener('click', this.haltDisabledEvents);
});
}
override ngOnDestroy(): void {
super.ngOnDestroy();
this.elementRef.nativeElement.removeEventListener('click', this.haltDisabledEvents);
}
}
Реализация взята из Angular Material 2.
Material 3 в Angular я не смотрел и думаю не стоит. Сложность компонентов чуть больше бесконечности.
Как видно из примера, определено состояние disabled
, а также наблюдатели за фокусом.
Создадим простую кнопку. В нашем случае это обертка над стандартной.
Шаблон - <ng-content />
.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { DisabledDirective, ExtraSizeDirective, ModeDirective, WidthDirective } from '@baf/ui/utils';
import { AnchorBase, ButtonBase } from '../base/button-base';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'button[baf-button]',
standalone: true,
template: '<ng-content />',
styleUrl: './button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'baf-button',
},
hostDirectives: [
{
directive: ModeDirective,
inputs: ['bafMode'],
},
{
directive: ExtraSizeDirective,
inputs: ['bafSize'],
},
{
directive: DisabledDirective,
inputs: ['disabled'],
},
{
directive: WidthDirective,
inputs: ['bafWidth'],
},
],
})
export class ButtonComponent extends ButtonBase {}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'a[baf-button]',
standalone: true,
template: '<ng-content />',
styleUrls: ['./button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'baf-button',
},
hostDirectives: [
{
directive: ModeDirective,
inputs: ['bafMode'],
},
{
directive: ExtraSizeDirective,
inputs: ['bafSize'],
},
{
directive: DisabledDirective,
inputs: ['disabled'],
},
{
directive: WidthDirective,
inputs: ['bafWidth'],
},
],
})
export class AnchorComponent extends AnchorBase {}
В компоненте определены общие директивы, в которые и вынесена вся логика.
Немного SCSS
:
@use 'src/stylesheets/button' as button;
@use 'src/stylesheets/width' as width;
:host {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 0.5rem 1.5rem;
border: none;
box-shadow: none;
border-radius: 3px;
cursor: pointer;
text-decoration: none;
&.mode-primary {
@include button.mode(--md-sys-color-primary-container, --md-sys-color-on-primary, --md-sys-color-primary);
}
&.mode-secondary {
@include button.mode(--md-sys-color-secondary-container, --md-sys-color-on-secondary, --md-sys-color-secondary);
}
&.mode-tertiary {
@include button.mode(--md-sys-color-tertiary-container, --md-sys-color-on-tertiary, --md-sys-color-tertiary);
}
@include button.disabled();
@include button.sizes();
@include width.make-width();
}
Теперь создадим icon-button
.
Макет:
<span class="icon-content">
<ng-content />
</span>
<span class="state-layer"></span>
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { DisabledDirective, ExtraSizeDirective, ModeDirective } from '@baf/ui/utils';
import { AnchorBase, ButtonBase } from '../base/button-base';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'button[baf-icon-button]',
standalone: true,
templateUrl: './icon-button.component.html',
styleUrl: './icon-button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'baf-icon-button',
},
hostDirectives: [
{
directive: ModeDirective,
inputs: ['bafMode'],
},
{
directive: ExtraSizeDirective,
inputs: ['bafSize'],
},
{
directive: DisabledDirective,
inputs: ['disabled'],
},
],
})
export class IconButtonComponent extends ButtonBase {}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'a[baf-icon-button]',
standalone: true,
templateUrl: './icon-button.component.html',
styleUrl: './icon-button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'baf-icon-button',
},
hostDirectives: [
{
directive: ModeDirective,
inputs: ['bafMode'],
},
{
directive: ExtraSizeDirective,
inputs: ['bafSize'],
},
{
directive: DisabledDirective,
inputs: ['disabled'],
},
],
})
export class IconAnchorComponent extends AnchorBase {}
Стилизуем кнопки:
:host {
display: inline-flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
justify-content: center;
height: 48px;
width: 48px;
padding: 4px;
border: none;
z-index: 0;
gap: 8px;
white-space: nowrap;
user-select: none;
background-color: transparent;
text-decoration: none;
cursor: pointer;
border-radius: var(--md-sys-shape-corner-full);
position: relative;
}
.state-layer {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
right: 0;
bottom: 0;
display: block;
z-index: 1;
opacity: 0;
border-radius: inherit;
}
.icon-content {
display: flex;
align-items: center;
justify-content: center;
color: var(--md-sys-color-on-surface-variant);
fill: var(--md-sys-color-on-surface-variant);
line-height: 1;
:host:hover & {
color: var(--md-sys-color-on-surface);
fill: var(--md-sys-color-on-surface);
}
}
Icons
Добавим компонент для иконок.
mkdir src/app/ui/icons
mkdir src/app/ui/icons/lib
echo >src/app/ui/icons/index.ts
Запускаем команду:
yarn ng g c icon
Переносим его в src/app/ui/title/lib
и отредактируем IconComponent
:
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'svg[baf-icon]',
standalone: true,
imports: [],
templateUrl: './icon.component.html',
styleUrl: './icon.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class IconComponent {}
Стили:
src/app/ui/icons/lib/icon/icon.component.scss
Пример использования на иконке домой:
<svg baf-icon xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px">
<path d="M240-200h120v-240h240v240h120v-360L480-740 240-560v360Zm-80 80v-480l320-240 320 240v480H520v-240h-80v240H160Zm320-350Z" />
</svg>
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { IconComponent } from '../icon/icon.component';
@Component({
selector: 'baf-icon-home',
standalone: true,
imports: [IconComponent],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HomeComponent {}
Так же созданы все остальные иконки:
-
arrow-down
- стрелка вниз; -
arrow-up
- стрелка вверх; -
chevron-left
- стрелка влево; -
chevron-right
- стрелка вправо; -
home
- дом; -
logo
- лого; -
star
- звезда; -
sync-alt
- рефреш.
Accordion
Реализуем аккордеон:
mkdir src/app/ui/accordion
mkdir src/app/ui/accordion/lib
echo >src/app/ui/accordion/index.ts
Запускаем команду:
yarn ng g c accordion
Добавим интерфейс:
export interface AccordionItem {
readonly title: string;
readonly description: string;
}
В компоненте будем выводить список элементов:
import { CdkAccordionModule } from '@angular/cdk/accordion';
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { ArrowDownComponent, ArrowUpComponent } from '@baf/ui/icons';
export interface AccordionItem {
readonly title: string;
readonly description: string;
}
@Component({
selector: 'baf-accordion',
standalone: true,
imports: [CdkAccordionModule, ArrowDownComponent, ArrowUpComponent],
templateUrl: './accordion.component.html',
styleUrl: './accordion.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AccordionComponent {
readonly items = input.required<AccordionItem[]>();
}
Шаблон:
<cdk-accordion>
@for (item of items(); track item.title; let index = $index) {
<cdk-accordion-item
class="accordion"
#accordionItem="cdkAccordionItem"
role="button"
tabindex="0"
[attr.id]="'accordion-header-' + index"
[attr.aria-expanded]="accordionItem.expanded"
[attr.aria-controls]="'accordion-body-' + index"
>
<div class="accordion-header" (click)="accordionItem.toggle()">
@if (accordionItem.expanded) {
<baf-arrow-down />
} @else {
<baf-arrow-up />
}
<span> {{ item.title }} </span>
</div>
<div
class="accordion-body"
role="region"
[style.display]="accordionItem.expanded ? '' : 'none'"
[attr.id]="'accordion-body-' + index"
[attr.aria-labelledby]="'accordion-header-' + index"
>
{{ item.description }}
</div>
</cdk-accordion-item>
}
</cdk-accordion>
Стили:
.accordion {
display: block;
&:not(:last-child) {
border-bottom: 1px solid var(--md-sys-color-surface-variant);
padding-bottom: 1rem;
margin-bottom: 1rem;
}
}
.accordion-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
cursor: pointer;
line-height: 1;
user-select: none;
}
.accordion-body {
padding: 1rem 1rem 0 2rem;
}
Checkbox
Создадим чекбокс.
Отмечу, что оформление я взял из проекта мериалайз
mkdir src/app/ui/checkbox
mkdir src/app/ui/checkbox/lib
echo >src/app/ui/checkbox/index.ts
Запускаем команду:
yarn ng g c checkbox
Разметка:
<label>
<input type="checkbox" [name]="options().name ?? ''" [formControl]="control()" />
<span><ng-content /></span>
</label>
Украду немного стилей из Material CSS:
[type='checkbox']:not(:checked),
[type='checkbox']:checked {
position: absolute;
opacity: 0;
pointer-events: none;
}
[type='checkbox']:checked {
+ span:before {
top: -4px;
left: -5px;
width: 12px;
height: 22px;
border-top: 2px solid transparent;
border-left: 2px solid transparent;
border-right: 2px solid var(--md-sys-color-primary);
border-bottom: 2px solid var(--md-sys-color-primary);
transform: rotate(40deg);
backface-visibility: hidden;
transform-origin: 100% 100%;
}
&:disabled + span:before {
border-right: 2px solid var(--md-sys-color-shadow);
border-bottom: 2px solid var(--md-sys-color-shadow);
}
}
[type='checkbox'] {
+ span {
position: relative;
padding-left: 35px;
cursor: pointer;
display: inline-block;
height: 25px;
line-height: 25px;
font-size: 1rem;
user-select: none;
}
&:not(:checked):disabled + span:before {
border: none;
background-color: var(--md-sys-color-shadow);
}
// General
+ span:after {
border-radius: 2px;
}
+ span:before,
+ span:after {
content: '';
left: 0;
position: absolute;
/* .1s delay is for check animation */
transition:
border 0.25s,
background-color 0.25s,
width 0.2s 0.1s,
height 0.2s 0.1s,
top 0.2s 0.1s,
left 0.2s 0.1s;
z-index: 1;
}
// Unchecked style
&:not(:checked) + span:before {
width: 0;
height: 0;
border: 3px solid transparent;
left: 6px;
top: 10px;
transform: rotateZ(37deg);
transform-origin: 100% 100%;
}
&:not(:checked) + span:after {
height: 20px;
width: 20px;
background-color: transparent;
border: 2px solid var(--md-sys-color-outline);
top: 0;
z-index: 0;
}
// Checked style
&:checked {
+ span:before {
top: 0;
left: 1px;
width: 8px;
height: 13px;
border-top: 2px solid transparent;
border-left: 2px solid transparent;
border-right: 2px solid var(--md-sys-color-on-primary);
border-bottom: 2px solid var(--md-sys-color-on-primary);
transform: rotateZ(37deg);
transform-origin: 100% 100%;
}
+ span:after {
top: 0;
width: 20px;
height: 20px;
border: 2px solid var(--md-sys-color-primary);
background-color: var(--md-sys-color-primary);
z-index: 0;
}
}
// Disabled style
&:disabled:not(:checked) + span:before {
background-color: transparent;
border: 2px solid transparent;
}
&:disabled:not(:checked) + span:after {
border-color: transparent;
background-color: var(--md-sys-color-outline);
}
&:disabled:checked + span:before {
background-color: transparent;
}
&:disabled:checked + span:after {
background-color: var(--md-sys-color-outline);
border-color: var(--md-sys-color-outline);
}
}
Сам компонент:
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import type { FormControl } from '@angular/forms';
import { ReactiveFormsModule } from '@angular/forms';
export interface CheckboxOptions {
readonly [key: string]: unknown;
readonly name?: string;
}
@Component({
selector: 'baf-checkbox',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './checkbox.component.html',
styleUrl: './checkbox.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CheckboxComponent {
readonly control = input.required<FormControl<boolean>>();
readonly options = input<CheckboxOptions>({});
}
Input
Реализуем инпут:
mkdir src/app/ui/input
mkdir src/app/ui/input/lib
echo >src/app/ui/input/index.ts
Выполним инструкцию:
yarn ng g c input
InputComponent
будет оберткой над input
.
import { ChangeDetectionStrategy, Component, ElementRef, inject } from '@angular/core';
import { NgControl } from '@angular/forms';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'input[baf-input]',
standalone: true,
imports: [],
template: '<ng-content/>',
styleUrl: './input.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'baf-input',
},
})
export class InputComponent {
readonly elementRef: ElementRef<HTMLInputElement> = inject(ElementRef);
readonly ngControl = inject(NgControl);
}
Добавим SCSS:
:host {
display: block;
background-color: transparent;
height: 100%;
width: 100%;
padding: 0;
border: none;
outline: none;
&:hover,
&:focus,
&:active {
outline: none;
}
&::placeholder {
color: var(--md-sys-color-on-surface-variant);
}
:host-context(.is-invalid) {
color: var(--md-sys-color-error);
}
}
Перенесем концепты из mat-form-field
:
yarn ng g c input-control
Разметка и стили:
<div class="input-container">
<ng-content select="[baf-input-prefix]" />
<div class="input-box">
<ng-content select="label[baf-label],baf-label" />
<div class="input">
<ng-content select="input[baf-input],baf-input" />
</div>
</div>
<ng-content select="[baf-input-suffix]" />
</div>
<ng-content />
:host {
display: flex;
flex-direction: column;
position: relative;
width: 100%;
&.is-disabled {
cursor: not-allowed;
pointer-events: none;
color: rgba(var(--md-sys-color-on-surface-rgba), 0.38);
.input {
color: rgba(var(--md-sys-color-on-surface-rgba), 0.38);
}
}
&.is-pressed,
&.is-value {
.input {
opacity: 1;
}
}
}
.input-box {
position: relative;
margin: 0 16px;
justify-content: center;
height: 100%;
flex-grow: 1;
}
.input-container {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
background-color: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
border-radius: var(--md-sys-shape-corner-extra-small-top);
height: 3rem;
}
.input {
opacity: 0;
height: 100%;
width: 100%;
position: relative;
z-index: 2;
transition: opacity 0.1s;
padding: 12px 0 0 0;
}
Возможно можно и разбить на несколько дочерних компонентов, но и так получается достаточно сложно.
Используемые вспомогательные директивы для префиксов и прочего.
src/app/ui/input/lib/input-display.directive.ts:
import { Directive, ElementRef, forwardRef, inject, input } from '@angular/core';
import type { ControlValueAccessor } from '@angular/forms';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import type { ChangeFn, DisplayFn, TouchedFn } from '@baf/core';
@Directive({
selector: 'input[formControlName][bafInputDisplay],input[formControl][bafInputDisplay]',
standalone: true,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => InputDisplayDirective),
multi: true,
},
],
host: {
'(blur)': 'onTouched()',
'(input)': 'onInput($event)',
},
})
export class InputDisplayDirective implements ControlValueAccessor {
private readonly elementRef = inject(ElementRef<HTMLInputElement>);
readonly display = input.required<DisplayFn>({ alias: 'bafInputDisplay' });
onChange!: ChangeFn;
onTouched!: TouchedFn;
registerOnChange(fn: ChangeFn): void {
this.onChange = fn;
}
registerOnTouched(fn: TouchedFn): void {
this.onTouched = fn;
}
writeValue(value: unknown): void {
this.elementRef.nativeElement.value = this.display()(value);
}
onInput(event: Event): void {
const { value } = event.target as HTMLInputElement;
this.elementRef.nativeElement.value = this.display()(value);
this.onChange(value);
}
}
src/app/ui/input/lib/input-mask.directive.ts:
import type { OnInit } from '@angular/core';
import { Directive, ElementRef, forwardRef, inject, InjectionToken, input } from '@angular/core';
import type { ControlValueAccessor } from '@angular/forms';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import type { ChangeFn, MaskFn, TouchedFn } from '@baf/core';
export const INPUT_MASK_VALUES = new InjectionToken<Record<string, RegExp>>('INPUT_MASK_VALUES');
const DEFAULT_INPUT_MASK_VALUES: Record<string, RegExp> = { 0: /[0-9]/, a: /[a-z]/, A: /[A-Z]/, B: /[a-zA-Z]/ };
export const DEFAULT_MASK_FN: MaskFn = (value) => value;
@Directive({
selector: 'input[formControlName][bafInputMask],input[formControl][bafInputMask]',
standalone: true,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => InputMaskDirective),
multi: true,
},
],
host: {
'(blur)': 'onTouched()',
'(input)': 'onInput($event)',
},
})
export class InputMaskDirective implements ControlValueAccessor, OnInit {
private readonly maskValues = inject(INPUT_MASK_VALUES, { optional: true }) ?? DEFAULT_INPUT_MASK_VALUES;
private readonly elementRef = inject(ElementRef<HTMLInputElement>);
private lastValue?: string;
private readonly maskFormats = `(${Object.keys(this.maskValues)
.map((key) => {
const regexStr = this.maskValues[key].toString();
return regexStr.substring(1, regexStr.length - 1);
})
.join('|')})`;
readonly mask = input.required<string>({ alias: 'bafInputMask' });
readonly maskFrom = input<MaskFn>(DEFAULT_MASK_FN, { alias: 'bafInputMaskFrom' });
readonly maskTo = input<MaskFn>(DEFAULT_MASK_FN, { alias: 'bafInputMaskTo' });
onChange!: ChangeFn;
onTouched!: TouchedFn;
registerOnChange(fn: ChangeFn): void {
this.onChange = fn;
}
registerOnTouched(fn: TouchedFn): void {
this.onTouched = fn;
}
writeValue(value: string | undefined | null): void {
this.elementRef.nativeElement.value = this.getMaskedValue(this.maskTo()(value));
}
onInput(event: Event): void {
const { value } = event.target as HTMLInputElement;
const masked = this.getMaskedValue(value);
this.elementRef.nativeElement.value = masked;
this.onChange(this.maskFrom()(masked));
}
ngOnInit(): void {
if (!this.mask()) {
console.warn(`Property mask should not be empty for input:`, this.elementRef.nativeElement);
}
}
getMaskedValue(value: string | undefined | null): string | undefined | null {
if (!this.mask() || !value || value === this.lastValue) {
return value;
}
const masked = this.valueToFormat(value, this.mask(), this.lastValue ? this.lastValue.length > value.length : false, this.lastValue);
this.lastValue = masked;
return masked;
}
/**
* @see https://gist.github.com/rami-alloush/3ee792fd0647b73de5f863a2719c78c6
*/
private valueToFormat(value: string, format: string, goingBack?: boolean, prevValue?: string): string {
let maskedValue = '';
const unmaskedValue = value.replace(' ', '').match(new RegExp(this.maskFormats, 'g'))?.join('') ?? '';
const formats = new RegExp(this.maskFormats);
const isLastCharFormatter = !formats.test(value[value.length - 1]);
const isPrevLastCharFormatter = prevValue && !formats.test(prevValue[prevValue.length - 1]);
let formatOffset = 0;
for (let index = 0, max = Math.min(unmaskedValue.length, format.length); index < max; ++index) {
const valueChar = unmaskedValue[index];
let formatChar = format[formatOffset + index];
let formatRegex = this.maskValues[formatChar];
if (formatChar && !formatRegex) {
maskedValue += formatChar;
formatChar = format[++formatOffset + index];
formatRegex = this.maskValues[formatChar];
}
if (valueChar && formatRegex) {
if (formatRegex && formatRegex.test(valueChar)) {
maskedValue += valueChar;
} else {
break;
}
}
const nextFormatChar = format[formatOffset + index + 1];
const nextFormatRegex = this.maskValues[nextFormatChar];
const isLastIteration = index === max - 1;
if (isLastIteration && nextFormatChar && !nextFormatRegex) {
if (!isLastCharFormatter && goingBack) {
if (prevValue && !isPrevLastCharFormatter) {
continue;
}
maskedValue = maskedValue.substring(0, formatOffset + index);
} else {
maskedValue += nextFormatChar;
}
}
}
return maskedValue;
}
}
src/app/ui/input/lib/input-prefix.directive.ts:
import { Directive } from '@angular/core';
@Directive({
selector: '[bafInputPrefix]',
standalone: true,
host: {
class: 'input-prefix',
'[style.margin-left]': '"12px"',
},
})
export class InputPrefixDirective {}
src/app/ui/input/lib/input-suffix.directive.ts:
import { Directive } from '@angular/core';
@Directive({
selector: '[bafInputSuffix]',
standalone: true,
host: {
class: 'baf-input-suffix',
'[style.margin-right]': '"12px"',
},
})
export class InputSuffixDirective {}
Логика работы достаточно проста:
import type { AfterViewInit, OnDestroy } from '@angular/core';
import { ChangeDetectionStrategy, Component, contentChild, DestroyRef, ElementRef, inject, Renderer2 } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import type { FormControlStatus } from '@angular/forms';
import { TouchedChangeEvent } from '@angular/forms';
import { filter, startWith, tap } from 'rxjs';
import { LabelComponent } from '@baf/ui/label';
import { InputComponent } from './input.component';
@Component({
selector: 'baf-input-control',
templateUrl: './input-control.component.html',
styleUrls: ['./input-control.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
host: {
class: 'baf-input-control',
},
})
export class InputControlComponent implements AfterViewInit, OnDestroy {
readonly destroyRef = inject(DestroyRef);
readonly elementRef: ElementRef<HTMLInputElement> = inject(ElementRef);
readonly renderer = inject(Renderer2);
readonly label = contentChild<LabelComponent>(LabelComponent);
readonly input = contentChild.required<InputComponent>(InputComponent);
private isDisabled = false;
ngAfterViewInit(): void {
const input = this.input();
if (!input) {
console.warn('Input[baf-input] not found. Add child <input baf-input /> in <baf-input-control></baf-input-control>');
return;
}
input.elementRef.nativeElement.addEventListener('click', this.onFocusin);
input.elementRef.nativeElement.addEventListener('focusout', this.onFocusout);
input.elementRef.nativeElement.addEventListener('input', this.onInput);
input.elementRef.nativeElement.addEventListener('change', this.onInput);
this.onInput({ target: input.elementRef.nativeElement });
input.ngControl.control?.events
.pipe(
filter((event) => event instanceof TouchedChangeEvent),
tap(() => this.check()),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
input.ngControl.valueChanges
?.pipe(
tap(() => {
if (!input.ngControl.value && this.elementRef.nativeElement.classList.contains('is-value')) {
this.renderer.removeClass(this.elementRef.nativeElement, 'is-value');
}
this.onInput({ target: input.elementRef.nativeElement });
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
input.ngControl.statusChanges
?.pipe(
startWith(input.ngControl.status),
tap((status: FormControlStatus) => {
this.isDisabled = status === 'DISABLED';
this.disable();
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
ngOnDestroy(): void {
const input = this.input();
if (!input) {
return;
}
input.elementRef.nativeElement.removeEventListener('click', this.onFocusin);
input.elementRef.nativeElement.removeEventListener('focusout', this.onFocusout);
input.elementRef.nativeElement.removeEventListener('input', this.onInput);
input.elementRef.nativeElement.removeEventListener('change', this.onInput);
}
private onFocusin = () => {
if (!this.isDisabled) {
this.renderer.addClass(this.elementRef.nativeElement, 'is-pressed');
}
};
private onFocusout = () => {
if (!this.isDisabled) {
this.renderer.removeClass(this.elementRef.nativeElement, 'is-pressed');
}
this.check();
};
private onInput = (event: Event | { target: HTMLInputElement }) => {
if (!this.isDisabled) {
const target = event.target as HTMLInputElement;
if (target.value?.length > 0) {
this.renderer.addClass(this.elementRef.nativeElement, 'is-value');
} else {
this.renderer.removeClass(this.elementRef.nativeElement, 'is-value');
}
this.check();
}
};
private disable(): void {
if (this.isDisabled) {
this.renderer.addClass(this.elementRef.nativeElement, 'is-disabled');
} else {
this.renderer.removeClass(this.elementRef.nativeElement, 'is-disabled');
}
}
private check(): void {
if (this.input().ngControl.touched) {
if (this.input().ngControl.errors) {
this.renderer.addClass(this.elementRef.nativeElement, 'is-invalid');
} else {
this.renderer.removeClass(this.elementRef.nativeElement, 'is-invalid');
}
}
}
}
Так как input является потомком, ищем его после рендера и добавляем обработчики:
input.elementRef.nativeElement.addEventListener('click', this.onFocusin);
input.elementRef.nativeElement.addEventListener('focusout', this.onFocusout);
input.elementRef.nativeElement.addEventListener('input', this.onInput);
input.elementRef.nativeElement.addEventListener('change', this.onInput);
Листенеры:
-
onFocusin
- фосус; -
onFocusout
- блюр; -
onInput
- ввод значения вinput
.
Также подписываемся на изменение состояния:
input.ngControl.control?.events
.pipe(
filter((event) => event instanceof TouchedChangeEvent),
tap(() => this.check()),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
input.ngControl.valueChanges
?.pipe(
tap(() => {
if (!input.ngControl.value && this.elementRef.nativeElement.classList.contains('is-value')) {
this.renderer.removeClass(this.elementRef.nativeElement, 'is-value');
}
this.onInput({ target: input.elementRef.nativeElement });
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
input.ngControl.statusChanges
?.pipe(
startWith(input.ngControl.status),
tap((status: FormControlStatus) => {
this.isDisabled = status === 'DISABLED';
this.disable();
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
Autocomplete
Реализуем autocomplete
:
mkdir src/app/ui/autocomplete
mkdir src/app/ui/autocomplete/lib
echo >src/app/ui/autocomplete/index.ts
Выполним команду:
yarn ng g c autocomplete
Разметка и стили:
<baf-input-control cdkOverlayOrigin #trigger="cdkOverlayOrigin">
<label baf-label [attr.for]="options().id">{{ options().label }}</label>
<input
#input
baf-input
type="text"
[bafInputDisplay]="options().inputDisplayFn"
[id]="options().id"
[formControl]="control()"
[placeholder]="options().placeholder ?? ''"
(click)="onOpen()"
(input)="onInput($event)"
/>
</baf-input-control>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="open()"
[cdkConnectedOverlayWidth]="width"
[cdkConnectedOverlayOffsetY]="1"
(overlayOutsideClick)="onClose()"
>
<div class="autocomplete-overlay">
@for (option of data() | async; track option.id; let index = $index) {
<a class="autocomplete-option" [innerHTML]="options().displayFn(option, index)" (click)="onSelect(option)"></a>
}
</div>
</ng-template>
.autocomplete-overlay {
background-color: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
width: 100%;
padding: 1rem;
border-radius: 0.25rem;
box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.autocomplete-option {
text-decoration: none;
padding: 0.5rem;
color: var(--md-sys-color-on-surface-variant);
cursor: pointer;
&:not(:last-child) {
border-bottom: 1px solid var(--md-sys-color-surface);
}
&:hover {
color: var(--md-sys-color-primary-container);
}
}
Логика компонента:
import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay';
import { AsyncPipe, NgForOf } from '@angular/common';
import type { Signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, ElementRef, input, output, signal, viewChild } from '@angular/core';
import type { FormControl } from '@angular/forms';
import { ReactiveFormsModule } from '@angular/forms';
import type { Observable } from 'rxjs';
import { take, tap } from 'rxjs';
import type { DisplayFn } from '@baf/core';
import { InputComponent, InputControlComponent, InputDisplayDirective } from '@baf/ui/input';
import { LabelComponent } from '@baf/ui/label';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AutocompleteVariant = Record<string, any> & { readonly id: number | string };
export interface AutocompleteOptions {
readonly label: string;
readonly placeholder?: string;
readonly id: string;
readonly key: string;
readonly displayFn: DisplayFn;
readonly inputDisplayFn: DisplayFn;
}
@Component({
selector: 'baf-autocomplete',
standalone: true,
imports: [
ReactiveFormsModule,
CdkConnectedOverlay,
CdkOverlayOrigin,
InputComponent,
NgForOf,
AsyncPipe,
InputControlComponent,
InputDisplayDirective,
LabelComponent,
],
templateUrl: './autocomplete.component.html',
styleUrl: './autocomplete.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'baf-input-control',
},
})
export class AutocompleteComponent {
readonly control = input.required<FormControl<string | AutocompleteVariant>>();
readonly options = input.required<AutocompleteOptions>();
readonly data = input.required<Observable<AutocompleteVariant[]>>();
readonly changed = output<string>();
readonly opened = output();
readonly closed = output();
readonly input: Signal<ElementRef<HTMLInputElement>> = viewChild.required('input', { read: ElementRef<HTMLInputElement> });
readonly open = signal<boolean>(false);
get width(): string {
return this.input().nativeElement.clientWidth > 200 ? `${this.input().nativeElement.clientWidth}px` : '200px';
}
onOpen(): void {
if (!this.open()) {
this.open.set(true);
this.opened.emit();
}
}
onClose(): void {
this.closed.emit();
this.open.set(false);
this.data()
.pipe(
take(1),
tap((options) => {
if (
options.length &&
this.control().value &&
(typeof this.control().value === 'string' || JSON.stringify(this.control().value) !== JSON.stringify(options[0]))
) {
this.control().patchValue(options[0], { emitEvent: false });
}
}),
)
.subscribe();
}
onInput(event: Event): void {
this.changed.emit((event.target as HTMLInputElement).value);
}
onSelect(option: AutocompleteVariant): void {
this.control().patchValue(option, { emitEvent: false });
this.closed.emit();
this.open.set(false);
}
}
Суть работы следующая:
- при клике на поле показать выпадающее окно;
- при вводе значений, вывести подсказки.
Методы:
-
onOpen
- показать окно; -
onInput
- ввод значения; -
onSelect
- выбор подсказки; -
onClose
- событие закрытия.
Показанных компонентов достаточно, чтобы перейти к разработке страниц.
Ссылки
Все исходники находятся на github, в репозитории - github.com/Fafnur/buy-and-fly
Демо можно посмотреть здесь - buy-and-fly.fafn.ru/
Top comments (0)