"일꾼이 일을 잘하려면 먼저 도구를 갈고 닦아야 한다." - 공자, 『논어』.
첫 장 > 프로그램 작성 > Angular 18의 UI KIT 개발

Angular 18의 UI KIT 개발

2024-11-08에 게시됨
검색:741

Разработка UI KIT на Angular 18

Создадим несколько компонентов нашего приложения.

В данном случае, нам нужны следующие 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(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: '',
  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, 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: '',
  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, а также наблюдатели за фокусом.

Создадим простую кнопку. В нашем случае это обертка над стандартной.

Шаблон - .

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);
  }
}

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

Пример использования на иконке домой:


  

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();
}

Шаблон:


  @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;
}

Checkbox

Создадим чекбокс.

Отмечу, что оформление я взял из проекта мериалайз

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({});
}

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 {}
@for (option of data() | async; track option.id; let index = $index) { }
.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

릴리스 선언문 이 글은 https://dev.to/fafnur/razrabotka-ui-kit-na-angular-18-m9?1 에서 복제되었습니다. 침해 내용이 있는 경우, [email protected]으로 연락하여 삭제하시기 바랍니다.
최신 튜토리얼 더>

부인 성명: 제공된 모든 리소스는 부분적으로 인터넷에서 가져온 것입니다. 귀하의 저작권이나 기타 권리 및 이익이 침해된 경우 자세한 이유를 설명하고 저작권 또는 권리 및 이익에 대한 증거를 제공한 후 이메일([email protected])로 보내주십시오. 최대한 빨리 처리해 드리겠습니다.

Copyright© 2022 湘ICP备2022001581号-3