Heim > Artikel > Web-Frontend > UI KIT-Entwicklung in Angular 18
Lassen Sie uns mehrere Komponenten unserer Anwendung erstellen.
In diesem Fall benötigen wir die folgende Benutzeroberfläche:
Komplexe Elemente verwenden Angular/CDK (Dialog, Akkordeon), um den Prozess zu vereinfachen.
Also fügen wir das Paket hinzu:
yarn add -D @angular/cdk
Und ein bisschen Magie für scss:
{ "inlineStyleLanguage": "scss", "stylePreprocessorOptions": { "includePaths": ["node_modules", "./"] } }
Fügen Sie der Benutzeroberfläche ein neues Utils-Verzeichnis hinzu:
mkdir src/app/ui/utils mkdir src/app/ui/utils/lib echo >src/app/ui/utils/index.ts
In tsconfig.json schreiben wir den Alias:
{ "paths": { "@baf/ui/utils": ["src/app/ui/utils/index.ts"] } }
Lassen Sie uns mehrere Typen intypes.ts definieren:
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;
Sie sind alle optional, enthalten also undefiniert.
Einige gemeinsame Eigenschaften von Komponenten:
Beispielimplementierung von 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 – ein Dienst, der die entsprechende Klasse hinzufügt.
Alternativ können Sie @HostBinding('class.align-center') verwenden oder Regeln über den Host festlegen.
Da Angular es nicht zulässt, Stile in die Direktive aufzunehmen, fügen wir ein Mixin hinzu, das für jede Komponente importiert werden muss.
Erstellen Sie eine Datei align.scss in src/stylesheets:
@mixin make-align() { &.align-left { text-align: left; } &.align-center { text-align: center; } &.align-right { text-align: right; } }
Anwendungsbeispiel:
@use 'src/stylesheets/align' as align; :host { @include align.make-align(); }
Die übrigen Richtlinien sind ähnlich.
Fügen Sie einen Container hinzu und geben Sie einen Alias ein.
mkdir src/app/ui/container mkdir src/app/ui/container/lib echo >src/app/ui/container/index.ts
Generieren Sie die Komponente:
yarn ng g c container
Verschieben Sie es nach src/app/ui/container/lib und bearbeiten Sie 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 {}
Stile hinzufügen:
@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; } } }
Breiten-Mixins aus 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; } }
Wir werden außerdem zwei Anweisungen FluidDirective und MobileDirective erstellen:
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; }, }); }
Fügen Sie einen Titel hinzu und geben Sie einen Alias ein:
mkdir src/app/ui/title mkdir src/app/ui/title/lib echo >src/app/ui/title/index.ts
Generieren Sie die Komponente:
yarn ng g c title
Verschieben Sie es nach src/app/ui/title/lib und bearbeiten Sie 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 {}
Ein paar Stile:
@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(); }
Den Rest machen wir genauso.
Lassen Sie uns mit der Erstellung komplexerer Widgets fortfahren.
Schaltflächen hinzufügen und einen Alias eingeben:
mkdir src/app/ui/buttons mkdir src/app/ui/buttons/lib echo >src/app/ui/buttons/index.ts
Da Schaltflächen in mehreren Typen benötigt werden, werden wir die Grundtypen -ButtonBase und AnchorBase festlegen:
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); } }
Implementierung übernommen aus Angular Material 2.
Ich habe mir Material 3 in Angular noch nicht angesehen und denke, dass es sich nicht lohnt. Die Komplexität der Komponenten ist etwas größer als unendlich.
Wie Sie dem Beispiel entnehmen können, ist der deaktivierte Zustand sowie Fokusbeobachter definiert.
Lassen Sie uns eine einfache Schaltfläche erstellen. In unserem Fall ist dies ein Wrapper über dem Standard.
Vorlage –
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); } }
Добавим компонент для иконок.
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 {}
Так же созданы все остальные иконки:
Реализуем аккордеон:
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; }
Создадим чекбокс.
Отмечу, что оформление я взял из проекта мериалайз
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>({}); }
Реализуем инпут:
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);
Листенеры:
Также подписываемся на изменение состояния:
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:
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); } }
Суть работы следующая:
Методы:
Показанных компонентов достаточно, чтобы перейти к разработке страниц.
Все исходники находятся на github, в репозитории - github.com/Fafnur/buy-and-fly
Демо можно посмотреть здесь - buy-and-fly.fafn.ru/
Мои группы: telegram, medium, vk, x.com, linkedin, site
Das obige ist der detaillierte Inhalt vonUI KIT-Entwicklung in Angular 18. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!