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:
16
src/app/shared/ui/input/input.component.html
Normal file
16
src/app/shared/ui/input/input.component.html
Normal 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()"
|
||||
/>
|
||||
81
src/app/shared/ui/input/input.component.scss
Normal file
81
src/app/shared/ui/input/input.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
64
src/app/shared/ui/input/input.component.ts
Normal file
64
src/app/shared/ui/input/input.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user