UI KIT development in Angular 18
Let's create several components of our application.
In this case, we need the following UI:
- accordion - accordion;
- autocomplete — input with autocomplete;
- buttons - buttons;
- cards - cards;
- checkbox - checkboxes;
- container - centers the content;
- datepicker - date picker;
- dialog - modal windows;
- headline - promo text;
- icons — set of svg icons;
- input - inputs;
- label - labels;
- layout - layout;
- nav - menu;
- section — background task for sections in the content;
- title - headings.
Complex elements will use angular/cdk (dialog, accordion) to simplify the process.
So let's add the package:
yarn add -D @angular/cdk
And a little magic for scss:
{ "inlineStyleLanguage": "scss", "stylePreprocessorOptions": { "includePaths": ["node_modules", "./"] } }
Creating utilities
Add a new utils directory to ui:
mkdir src/app/ui/utils mkdir src/app/ui/utils/lib echo >src/app/ui/utils/index.ts
In tsconfig.json we will write the alias:
{ "paths": { "@baf/ui/utils": ["src/app/ui/utils/index.ts"] } }
Let's define several types in 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;
They are all optional, so they contain undefined.
Some common properties of components:
- align - centering left, right;
- disabled - disabled state (relevant for forms);
- size, extra-size - text size small, medium and large
- mode - types of buttons;
- width - element width.
AlignDirective
Example implementation of 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 - a service that adds the corresponding class.
As an alternative, you can use @HostBinding('class.align-center') or set rules through the host.
Since angular does not allow you to include styles in the directive, let's add a mixin that needs to be imported for each component.
Create a file align.scss in src/stylesheets:
@mixin make-align() { &.align-left { text-align: left; } &.align-center { text-align: center; } &.align-right { text-align: right; } }
Usage example:
@use 'src/stylesheets/align' as align; :host { @include align.make-align(); }
The remaining directives are similar.
Container
Add a container and enter an alias.
mkdir src/app/ui/container mkdir src/app/ui/container/lib echo >src/app/ui/container/index.ts
Generate the component:
yarn ng g c container
Move it to src/app/ui/container/lib and edit 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 {}
Add styles:
@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; } } }
Width mixins taken from 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; } }
We will also create two directives FluidDirective and 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 and Card
Add a title and enter an alias:
mkdir src/app/ui/title mkdir src/app/ui/title/lib echo >src/app/ui/title/index.ts
Generate the component:
yarn ng g c title
Move it to src/app/ui/title/lib and edit 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 {}
A few styles:
@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(); }
We do the rest in the same way.
Buttons
Let's move on to creating more complex widgets.
Add buttons and enter an alias:
mkdir src/app/ui/buttons mkdir src/app/ui/buttons/lib echo >src/app/ui/buttons/index.ts
Since buttons are needed in several types, we will set the basic types -ButtonBase and 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); } }
Implementation taken from Angular Material 2.
I haven’t looked at Material 3 in Angular and I don’t think it’s worth it. The complexity of the components is slightly greater than infinity.
As you can see from the example, the disabled state is defined, as well as focus observers.
Let's create a simple button. In our case, this is a wrapper over the standard one.
Template -
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/
Мои группы: telegram, medium, vk, x.com, linkedin, site
The above is the detailed content of UI KIT development in Angular 18. For more information, please follow other related articles on the PHP Chinese website!

Hot AI Tools

Undress AI Tool
Undress images for free

Undresser.AI Undress
AI-powered app for creating realistic nude photos

AI Clothes Remover
Online AI tool for removing clothes from photos.

Clothoff.io
AI clothes remover

Video Face Swap
Swap faces in any video effortlessly with our completely free AI face swap tool!

Hot Article

Hot Tools

Notepad++7.3.1
Easy-to-use and free code editor

SublimeText3 Chinese version
Chinese version, very easy to use

Zend Studio 13.0.1
Powerful PHP integrated development environment

Dreamweaver CS6
Visual web development tools

SublimeText3 Mac version
God-level code editing software (SublimeText3)

Hot Topics

PlacingtagsatthebottomofablogpostorwebpageservespracticalpurposesforSEO,userexperience,anddesign.1.IthelpswithSEObyallowingsearchenginestoaccesskeyword-relevanttagswithoutclutteringthemaincontent.2.Itimprovesuserexperiencebykeepingthefocusonthearticl

The following points should be noted when processing dates and time in JavaScript: 1. There are many ways to create Date objects. It is recommended to use ISO format strings to ensure compatibility; 2. Get and set time information can be obtained and set methods, and note that the month starts from 0; 3. Manually formatting dates requires strings, and third-party libraries can also be used; 4. It is recommended to use libraries that support time zones, such as Luxon. Mastering these key points can effectively avoid common mistakes.

Event capture and bubble are two stages of event propagation in DOM. Capture is from the top layer to the target element, and bubble is from the target element to the top layer. 1. Event capture is implemented by setting the useCapture parameter of addEventListener to true; 2. Event bubble is the default behavior, useCapture is set to false or omitted; 3. Event propagation can be used to prevent event propagation; 4. Event bubbling supports event delegation to improve dynamic content processing efficiency; 5. Capture can be used to intercept events in advance, such as logging or error processing. Understanding these two phases helps to accurately control the timing and how JavaScript responds to user operations.

The main difference between ES module and CommonJS is the loading method and usage scenario. 1.CommonJS is synchronously loaded, suitable for Node.js server-side environment; 2.ES module is asynchronously loaded, suitable for network environments such as browsers; 3. Syntax, ES module uses import/export and must be located in the top-level scope, while CommonJS uses require/module.exports, which can be called dynamically at runtime; 4.CommonJS is widely used in old versions of Node.js and libraries that rely on it such as Express, while ES modules are suitable for modern front-end frameworks and Node.jsv14; 5. Although it can be mixed, it can easily cause problems.

JavaScript's garbage collection mechanism automatically manages memory through a tag-clearing algorithm to reduce the risk of memory leakage. The engine traverses and marks the active object from the root object, and unmarked is treated as garbage and cleared. For example, when the object is no longer referenced (such as setting the variable to null), it will be released in the next round of recycling. Common causes of memory leaks include: ① Uncleared timers or event listeners; ② References to external variables in closures; ③ Global variables continue to hold a large amount of data. The V8 engine optimizes recycling efficiency through strategies such as generational recycling, incremental marking, parallel/concurrent recycling, and reduces the main thread blocking time. During development, unnecessary global references should be avoided and object associations should be promptly decorated to improve performance and stability.

There are three common ways to initiate HTTP requests in Node.js: use built-in modules, axios, and node-fetch. 1. Use the built-in http/https module without dependencies, which is suitable for basic scenarios, but requires manual processing of data stitching and error monitoring, such as using https.get() to obtain data or send POST requests through .write(); 2.axios is a third-party library based on Promise. It has concise syntax and powerful functions, supports async/await, automatic JSON conversion, interceptor, etc. It is recommended to simplify asynchronous request operations; 3.node-fetch provides a style similar to browser fetch, based on Promise and simple syntax

The difference between var, let and const is scope, promotion and repeated declarations. 1.var is the function scope, with variable promotion, allowing repeated declarations; 2.let is the block-level scope, with temporary dead zones, and repeated declarations are not allowed; 3.const is also the block-level scope, and must be assigned immediately, and cannot be reassigned, but the internal value of the reference type can be modified. Use const first, use let when changing variables, and avoid using var.

The main reasons for slow operation of DOM are the high cost of rearrangement and redrawing and low access efficiency. Optimization methods include: 1. Reduce the number of accesses and cache read values; 2. Batch read and write operations; 3. Merge and modify, use document fragments or hidden elements; 4. Avoid layout jitter and centrally handle read and write; 5. Use framework or requestAnimationFrame asynchronous update.
