From 2fc9051f861a469f6435d303f701c18fe340b684 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Wed, 15 Jul 2026 03:30:05 +0400 Subject: [PATCH] feat(design-system): add reusable Input primitive ControlValueAccessor-based, standalone, OnPush. Sizes sm/md/lg, states default/error/success. Colors/radius/spacing/transitions consumed via existing tenant CSS vars (--border-color, --primary-color, --error-color, --success-color, etc) with fallbacks - compatible with bootstrap JSON theming and Project Editor live preview. --- src/app/shared/ui/input/input.component.html | 16 ++++ src/app/shared/ui/input/input.component.scss | 81 ++++++++++++++++++++ src/app/shared/ui/input/input.component.ts | 64 ++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 src/app/shared/ui/input/input.component.html create mode 100644 src/app/shared/ui/input/input.component.scss create mode 100644 src/app/shared/ui/input/input.component.ts diff --git a/src/app/shared/ui/input/input.component.html b/src/app/shared/ui/input/input.component.html new file mode 100644 index 0000000..78fc576 --- /dev/null +++ b/src/app/shared/ui/input/input.component.html @@ -0,0 +1,16 @@ + diff --git a/src/app/shared/ui/input/input.component.scss b/src/app/shared/ui/input/input.component.scss new file mode 100644 index 0000000..5460d3d --- /dev/null +++ b/src/app/shared/ui/input/input.component.scss @@ -0,0 +1,81 @@ +:host { + display: inline-block; +} + +:host.app-input-host--full-width { + display: block; + width: 100%; +} + +.app-input { + width: 100%; + border: 1px solid var(--border-color, #e4e4e7); + border-radius: var(--radius-md, 6px); + background: var(--bg-primary, #fff); + color: var(--text-primary, #1f322d); + font-family: inherit; + line-height: 1; + transition: border-color var(--transition-fast, 120ms ease), + box-shadow var(--transition-fast, 120ms ease); + + &::placeholder { + color: var(--text-secondary, #6b7280); + } + + &:hover:not(:disabled) { + border-color: var(--primary-color, #2f6e5d); + } + + &:focus-visible { + outline: none; + border-color: var(--primary-color, #2f6e5d); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #2f6e5d) 25%, transparent); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } +} + +.app-input--sm { + height: 32px; + padding: 0 var(--space-sm, 0.5rem); + font-size: 0.8125rem; +} + +.app-input--md { + height: 40px; + padding: 0 var(--space-md, 1rem); + font-size: 0.9375rem; +} + +.app-input--lg { + height: 48px; + padding: 0 var(--space-lg, 1.5rem); + font-size: 1rem; +} + +.app-input--error { + border-color: var(--error-color, #c0392b); + + &:focus-visible { + border-color: var(--error-color, #c0392b); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--error-color, #c0392b) 25%, transparent); + } +} + +.app-input--success { + border-color: var(--success-color, #2e7d32); + + &:focus-visible { + border-color: var(--success-color, #2e7d32); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--success-color, #2e7d32) 25%, transparent); + } +} + +@media (prefers-reduced-motion: reduce) { + .app-input { + transition: none; + } +} diff --git a/src/app/shared/ui/input/input.component.ts b/src/app/shared/ui/input/input.component.ts new file mode 100644 index 0000000..69708aa --- /dev/null +++ b/src/app/shared/ui/input/input.component.ts @@ -0,0 +1,64 @@ +import { ChangeDetectionStrategy, Component, forwardRef, input } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +export type InputSize = 'sm' | 'md' | 'lg'; +export type InputState = 'default' | 'error' | 'success'; + +@Component({ + selector: 'app-input', + standalone: true, + templateUrl: './input.component.html', + styleUrl: './input.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => InputComponent), + multi: true + } + ], + host: { + '[class.app-input-host--full-width]': 'fullWidth()' + } +}) +export class InputComponent implements ControlValueAccessor { + readonly type = input<'text' | 'email' | 'password' | 'number' | 'search' | 'tel' | 'url'>('text'); + readonly size = input('md'); + readonly state = input('default'); + readonly placeholder = input(''); + readonly disabled = input(false); + readonly fullWidth = input(false); + readonly ariaLabel = input(null); + + protected value: string | number = ''; + protected isDisabled = false; + + private onChange: (value: string | number) => void = () => {}; + private onTouched: () => void = () => {}; + + writeValue(value: string | number): void { + this.value = value ?? ''; + } + + registerOnChange(fn: (value: string | number) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.isDisabled = isDisabled; + } + + protected handleInput(event: Event): void { + const target = event.target as HTMLInputElement; + this.value = target.value; + this.onChange(this.value); + } + + protected handleBlur(): void { + this.onTouched(); + } +}