Создадим несколько компонентов нашего приложения.
В данном случае, нам нужны следующие UI:
Сложные элементы будут использовать 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:
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(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 и пропишем алиас.
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: '', 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(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(undefined, { alias: 'bafMobile', transform: (value) => { this.extraClassService.patch('mobile-no-gutter', coerceBooleanProperty(value)); return value; }, }); }
Добавим 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: '', 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 и пропишем алиас:
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, а также наблюдатели за фокусом.
Создадим простую кнопку. В нашем случае это обертка над стандартной.
Шаблон -
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: '', 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: ' ', 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.
Макет:
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); } }
Добавим компонент для иконок.
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
Пример использования на иконке домой:
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 {}
Так же созданы все остальные иконки:
Реализуем аккордеон:
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(); }
Шаблон:
@for (item of items(); track item.title; let index = $index) { } @if (accordionItem.expanded) {} @else { } {{ item.title }} {{ item.description }}
Стили:
.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; }
Создадим чекбокс.
Отмечу, что оформление я взял из проекта мериалайз
mkdir src/app/ui/checkbox mkdir src/app/ui/checkbox/lib echo >src/app/ui/checkbox/index.ts
Запускаем команду:
yarn ng g c checkbox
Разметка:
Украду немного стилей из 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>(); readonly options = 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: '', styleUrl: './input.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'baf-input', }, }) export class InputComponent { readonly elementRef: ElementRef = 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
Разметка и стили:
: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); readonly display = input.required ({ 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>('INPUT_MASK_VALUES'); const DEFAULT_INPUT_MASK_VALUES: Record = { 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 ); 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 ({ alias: 'bafInputMask' }); readonly maskFrom = input (DEFAULT_MASK_FN, { alias: 'bafInputMaskFrom' }); readonly maskTo = input (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 ; 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 {}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: '[bafInputPrefix]', standalone: true, host: { class: 'input-prefix', '[style.margin-left]': '"12px"', }, }) export class InputPrefixDirective {}import { Directive } from '@angular/core'; @Directive({ selector: '[bafInputSuffix]', standalone: true, host: { class: 'baf-input-suffix', '[style.margin-right]': '"12px"', }, }) export class InputSuffixDirective {}
Логика работы достаточно проста:import { Directive } from '@angular/core'; @Directive({ selector: '[bafInputPrefix]', standalone: true, host: { class: 'input-prefix', '[style.margin-left]': '"12px"', }, }) export class InputPrefixDirective {}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= inject(ElementRef); readonly renderer = inject(Renderer2); readonly label = contentChild (LabelComponent); readonly input = contentChild.required (InputComponent); private isDisabled = false; ngAfterViewInit(): void { const input = this.input(); if (!input) { console.warn('Input[baf-input] not found. Add child in '); 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 является потомком, ищем его после рендера и добавляем обработчики:import { Directive } from '@angular/core'; @Directive({ selector: '[bafInputPrefix]', standalone: true, host: { class: 'input-prefix', '[style.margin-left]': '"12px"', }, }) export class InputPrefixDirective {}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.
Также подписываемся на изменение состояния:import { Directive } from '@angular/core'; @Directive({ selector: '[bafInputPrefix]', standalone: true, host: { class: 'input-prefix', '[style.margin-left]': '"12px"', }, }) export class InputPrefixDirective {}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:import { Directive } from '@angular/core'; @Directive({ selector: '[bafInputPrefix]', standalone: true, host: { class: 'input-prefix', '[style.margin-left]': '"12px"', }, }) export class InputPrefixDirective {}mkdir src/app/ui/autocomplete mkdir src/app/ui/autocomplete/lib echo >src/app/ui/autocomplete/index.ts
Выполним команду:import { Directive } from '@angular/core'; @Directive({ selector: '[bafInputPrefix]', standalone: true, host: { class: 'input-prefix', '[style.margin-left]': '"12px"', }, }) export class InputPrefixDirective {}yarn ng g c autocomplete
Разметка и стили:import { Directive } from '@angular/core'; @Directive({ selector: '[bafInputPrefix]', standalone: true, host: { class: 'input-prefix', '[style.margin-left]': '"12px"', }, }) export class InputPrefixDirective {} .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); } }.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 { Directive } from '@angular/core'; @Directive({ selector: '[bafInputPrefix]', standalone: true, host: { class: 'input-prefix', '[style.margin-left]': '"12px"', }, }) export class InputPrefixDirective {}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& { 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 >(); readonly options = input.required (); readonly data = input.required >(); readonly changed = output (); readonly opened = output(); readonly closed = output(); readonly input: Signal > = viewChild.required('input', { read: ElementRef }); readonly open = signal (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/
Мои группы: telegram, medium, vk, x.com, linkedin, site
Haftungsausschluss: Alle bereitgestellten Ressourcen stammen teilweise aus dem Internet. Wenn eine Verletzung Ihres Urheberrechts oder anderer Rechte und Interessen vorliegt, erläutern Sie bitte die detaillierten Gründe und legen Sie einen Nachweis des Urheberrechts oder Ihrer Rechte und Interessen vor und senden Sie ihn dann an die E-Mail-Adresse: [email protected] Wir werden die Angelegenheit so schnell wie möglich für Sie erledigen.
Copyright© 2022 湘ICP备2022001581号-3