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.
This commit is contained in:
sdarbinyan
2026-07-15 03:30:05 +04:00
parent 4e63ff97bb
commit 2fc9051f86
3 changed files with 161 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
<input
class="app-input"
[class.app-input--sm]="size() === 'sm'"
[class.app-input--md]="size() === 'md'"
[class.app-input--lg]="size() === 'lg'"
[class.app-input--error]="state() === 'error'"
[class.app-input--success]="state() === 'success'"
[type]="type()"
[placeholder]="placeholder()"
[disabled]="isDisabled || disabled()"
[attr.aria-label]="ariaLabel()"
[attr.aria-invalid]="state() === 'error' || null"
[value]="value"
(input)="handleInput($event)"
(blur)="handleBlur()"
/>

View File

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

View File

@@ -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<InputSize>('md');
readonly state = input<InputState>('default');
readonly placeholder = input('');
readonly disabled = input(false);
readonly fullWidth = input(false);
readonly ariaLabel = input<string | null>(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();
}
}