Angular 18 UI KIT の開発
アプリケーションのいくつかのコンポーネントを作成しましょう。
この場合、次の UI が必要です:
- アコーディオン - アコーディオン;
- オートコンプリート — オートコンプリートを使用して入力;
- ボタン - ボタン;
- カード - カード;
- チェックボックス - チェックボックス;
- コンテナ - コンテンツを中央に配置します。
- datepicker - 日付ピッカー;
- ダイアログ - モーダル ウィンドウ;
- 見出し - プロモーション テキスト;
- アイコン — SVG アイコンのセット;
- input - 入力;
- ラベル - ラベル;
- レイアウト - レイアウト;
- ナビ - メニュー;
- セクション — コンテンツ内のセクションのバックグラウンド タスク;
- タイトル - 見出し。
複雑な要素は、プロセスを簡素化するために angular/cdk (ダイアログ、アコーディオン) を使用します。
パッケージを追加しましょう:
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;
これらはすべてオプションであるため、未定義が含まれます。
コンポーネントのいくつかの共通プロパティ:
- 整列 - 左、右を中央揃え;
- 無効 - 無効な状態 (フォームに関連);
- サイズ、特大サイズ - テキスト サイズ 小、中、大
- モード - ボタンの種類;
- width - 要素の幅。
アラインディレクティブ
align の実装例:
import { Directive, inject, input } from '@angular/core'; import { ExtraClassService, toClass } from '@baf/core'; import type { Align } from './types'; @Directive({ selector: '[bafAlign]', standalone: true, providers: [ExtraClassService], }) export class AlignDirective { private readonly extraClassService = inject(ExtraClassService); readonly align = input<Align, Align>(undefined, { alias: 'bafAlign', transform: (value) => { this.extraClassService.update('align', toClass(value, 'align')); return value; }, }); }
ExtraClassService - 対応するクラスを追加するサービス。
代わりに、@HostBinding('class.align-center') を使用するか、ホストを通じてルールを設定できます。
Angular ではディレクティブにスタイルを含めることができないため、コンポーネントごとにインポートする必要があるミックスインを追加しましょう。
src/stylesheets にファイル align.scss を作成します:
@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(); }
残りのディレクティブも同様です。
容器
コンテナを追加し、エイリアスを入力します。
mkdir src/app/ui/container mkdir src/app/ui/container/lib echo >src/app/ui/container/index.ts
コンポーネントを生成します:
yarn ng g c container
それを src/app/ui/container/lib に移動し、ContainerComponent を編集します:
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { AlignDirective } from '@baf/ui/utils'; import { FluidDirective } from './fluid.directive'; import { MobileDirective } from './mobile.directive'; @Component({ selector: 'baf-container', standalone: true, imports: [RouterOutlet], template: '<ng-content/>', styleUrl: './container.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'baf-container', }, hostDirectives: [ { directive: FluidDirective, inputs: ['bafFluid'], }, { directive: MobileDirective, inputs: ['bafMobile'], }, { directive: AlignDirective, inputs: ['bafAlign'], }, ], }) export class ContainerComponent {}
スタイルを追加します:
@use 'src/stylesheets/align' as align; @use 'src/stylesheets/device' as device; :host { display: flex; flex-direction: column; margin-left: auto; margin-right: auto; width: 100%; &.fluid { max-width: 100%; } &:not(.mobile-no-gutter) { padding-left: 1rem; padding-right: 1rem; } @include align.make-align(); @include device.media-tablet-portrait() { &:not(.fluid) { max-width: 788px; } } @include device.media-tablet-landscape() { &:not(.fluid) { max-width: 928px; } } @include device.media-web-portrait() { &:not(.fluid) { max-width: 808px; } } @include device.media-web-landscape() { &:not(.fluid) { max-width: 1200px; } } }
マテリアルから取得された幅ミックスイン:
@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; } }
2 つのディレクティブ FluidDirective と MobileDirective も作成します。
import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { Directive, inject, input } from '@angular/core'; import type { CoerceBoolean } from '@baf/core'; import { ExtraClassService } from '@baf/core'; @Directive({ selector: 'baf-container[bafFluid]', standalone: true, providers: [ExtraClassService], }) export class FluidDirective { private readonly extraClassService = inject(ExtraClassService); readonly fluid = input<CoerceBoolean, CoerceBoolean>(undefined, { alias: 'bafFluid', transform: (value) => { this.extraClassService.patch('fluid', coerceBooleanProperty(value)); return value; }, }); }
import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { Directive, inject, input } from '@angular/core'; import type { CoerceBoolean } from '@baf/core'; import { ExtraClassService } from '@baf/core'; @Directive({ selector: 'baf-container[bafMobile]', standalone: true, providers: [ExtraClassService], }) export class MobileDirective { private readonly extraClassService = inject(ExtraClassService); readonly mobile = input<CoerceBoolean, CoerceBoolean>(undefined, { alias: 'bafMobile', transform: (value) => { this.extraClassService.patch('mobile-no-gutter', coerceBooleanProperty(value)); return value; }, }); }
タイトル、ラベル、見出し、セクション、カード
タイトルを追加し、エイリアスを入力します:
mkdir src/app/ui/title mkdir src/app/ui/title/lib echo >src/app/ui/title/index.ts
コンポーネントを生成します:
yarn ng g c title
それを src/app/ui/title/lib に移動し、TitleComponent を編集します:
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { AlignDirective, SizeDirective } from '@baf/ui/utils'; @Component({ selector: 'baf-title,[baf-title],[bafTitle]', standalone: true, template: '<ng-content/>', styleUrl: './title.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'baf-title', }, hostDirectives: [ { directive: SizeDirective, inputs: ['bafSize'], }, { directive: AlignDirective, inputs: ['bafAlign'], }, ], }) export class TitleComponent {}
いくつかのスタイル:
@use 'src/stylesheets/align' as align; @use 'src/stylesheets/size' as size; @use 'src/stylesheets/typography' as typography; :host { @include size.make-size() using ($size) { @if $size == small { @include typography.title-small(); } @else if $size == medium { @include typography.title-medium(); } @else if $size == large { @include typography.title-large(); } } @include align.make-align(); }
残りも同様に行います。
ボタン
さらに複雑なウィジェットの作成に進みましょう。
ボタンを追加し、エイリアスを入力します:
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 マテリアル 2 から取得した実装。
私は Angular のマテリアル 3 を見ていないので、見る価値がないと思います。コンポーネントの複雑さは無限大よりわずかに大きくなります。
例からわかるように、無効な状態とフォーカス オブザーバーが定義されています。
簡単なボタンを作成してみましょう。私たちの場合、これは標準のラッパーです。
テンプレート -
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 {} </p> <p>В компоненте определены общие директивы, в которые и вынесена вся логика. </p> <p>Немного SCSS:<br> </p> <pre class="brush:php;toolbar:false">@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
以上がAngular 18 UI KIT の開発の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

ホットAIツール

Undresser.AI Undress
リアルなヌード写真を作成する AI 搭載アプリ

AI Clothes Remover
写真から衣服を削除するオンライン AI ツール。

Undress AI Tool
脱衣画像を無料で

Clothoff.io
AI衣類リムーバー

Video Face Swap
完全無料の AI 顔交換ツールを使用して、あらゆるビデオの顔を簡単に交換できます。

人気の記事

ホットツール

メモ帳++7.3.1
使いやすく無料のコードエディター

SublimeText3 中国語版
中国語版、とても使いやすい

ゼンドスタジオ 13.0.1
強力な PHP 統合開発環境

ドリームウィーバー CS6
ビジュアル Web 開発ツール

SublimeText3 Mac版
神レベルのコード編集ソフト(SublimeText3)

ホットトピック











Pythonは、スムーズな学習曲線と簡潔な構文を備えた初心者により適しています。 JavaScriptは、急な学習曲線と柔軟な構文を備えたフロントエンド開発に適しています。 1。Python構文は直感的で、データサイエンスやバックエンド開発に適しています。 2。JavaScriptは柔軟で、フロントエンドおよびサーバー側のプログラミングで広く使用されています。

Web開発におけるJavaScriptの主な用途には、クライアントの相互作用、フォーム検証、非同期通信が含まれます。 1)DOM操作による動的なコンテンツの更新とユーザーインタラクション。 2)ユーザーエクスペリエンスを改善するためにデータを提出する前に、クライアントの検証が実行されます。 3)サーバーとのリフレッシュレス通信は、AJAXテクノロジーを通じて達成されます。

現実世界でのJavaScriptのアプリケーションには、フロントエンドとバックエンドの開発が含まれます。 1)DOM操作とイベント処理を含むTODOリストアプリケーションを構築して、フロントエンドアプリケーションを表示します。 2)node.jsを介してRestfulapiを構築し、バックエンドアプリケーションをデモンストレーションします。

JavaScriptエンジンが内部的にどのように機能するかを理解することは、開発者にとってより効率的なコードの作成とパフォーマンスのボトルネックと最適化戦略の理解に役立つためです。 1)エンジンのワークフローには、3つの段階が含まれます。解析、コンパイル、実行。 2)実行プロセス中、エンジンはインラインキャッシュや非表示クラスなどの動的最適化を実行します。 3)ベストプラクティスには、グローバル変数の避け、ループの最適化、constとletsの使用、閉鎖の過度の使用の回避が含まれます。

PythonとJavaScriptには、コミュニティ、ライブラリ、リソースの観点から、独自の利点と短所があります。 1)Pythonコミュニティはフレンドリーで初心者に適していますが、フロントエンドの開発リソースはJavaScriptほど豊富ではありません。 2)Pythonはデータサイエンスおよび機械学習ライブラリで強力ですが、JavaScriptはフロントエンド開発ライブラリとフレームワークで優れています。 3)どちらも豊富な学習リソースを持っていますが、Pythonは公式文書から始めるのに適していますが、JavaScriptはMDNWebDocsにより優れています。選択は、プロジェクトのニーズと個人的な関心に基づいている必要があります。

開発環境におけるPythonとJavaScriptの両方の選択が重要です。 1)Pythonの開発環境には、Pycharm、Jupyternotebook、Anacondaが含まれます。これらは、データサイエンスと迅速なプロトタイピングに適しています。 2)JavaScriptの開発環境には、フロントエンドおよびバックエンド開発に適したnode.js、vscode、およびwebpackが含まれます。プロジェクトのニーズに応じて適切なツールを選択すると、開発効率とプロジェクトの成功率が向上する可能性があります。

CとCは、主に通訳者とJITコンパイラを実装するために使用されるJavaScriptエンジンで重要な役割を果たします。 1)cは、JavaScriptソースコードを解析し、抽象的な構文ツリーを生成するために使用されます。 2)Cは、Bytecodeの生成と実行を担当します。 3)Cは、JITコンパイラを実装し、実行時にホットスポットコードを最適化およびコンパイルし、JavaScriptの実行効率を大幅に改善します。

Pythonはデータサイエンスと自動化により適していますが、JavaScriptはフロントエンドとフルスタックの開発により適しています。 1. Pythonは、データ処理とモデリングのためにNumpyやPandasなどのライブラリを使用して、データサイエンスと機械学習でうまく機能します。 2。Pythonは、自動化とスクリプトにおいて簡潔で効率的です。 3. JavaScriptはフロントエンド開発に不可欠であり、動的なWebページと単一ページアプリケーションの構築に使用されます。 4. JavaScriptは、node.jsを通じてバックエンド開発において役割を果たし、フルスタック開発をサポートします。
