191 lines
5.8 KiB
TypeScript
191 lines
5.8 KiB
TypeScript
import { ChangeDetectionStrategy, Component, computed, input, model, signal } from '@angular/core';
|
|||
|
|
import { formatNumber, parseDecimal } from '../../core/format';
|
||
|
|
import { ControlBase } from './control-base';
|
||
|
|
|
||
|
|
@Component({
|
||
|
|
selector: 'sbo-input',
|
||
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||
|
|
template: `
|
||
|
|
<input
|
||
|
|
class="sbo-in"
|
||
|
|
type="text"
|
||
|
|
autocomplete="off"
|
||
|
|
[id]="id"
|
||
|
|
[value]="value()"
|
||
|
|
[readOnly]="readOnly()"
|
||
|
|
[disabled]="disabled()"
|
||
|
|
[required]="required()"
|
||
|
|
[attr.maxlength]="maxlength()"
|
||
|
|
[placeholder]="placeholder()"
|
||
|
|
[attr.aria-label]="ariaLabel() ?? null"
|
||
|
|
[attr.aria-invalid]="invalid() ? 'true' : null"
|
||
|
|
[attr.aria-describedby]="describedBy()"
|
||
|
|
(input)="value.set($any($event.target).value)"
|
||
|
|
/>
|
||
|
|
`,
|
||
|
|
styles: ':host { display: block; }',
|
||
|
|
})
|
||
|
|
export class TextInput extends ControlBase {
|
||
|
|
readonly value = model('');
|
||
|
|
readonly maxlength = input<number>();
|
||
|
|
readonly placeholder = input('');
|
||
|
|
}
|
||
|
|
|
||
|
|
@Component({
|
||
|
|
selector: 'sbo-textarea',
|
||
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||
|
|
template: `
|
||
|
|
<textarea
|
||
|
|
class="sbo-ta"
|
||
|
|
[id]="id"
|
||
|
|
[value]="value()"
|
||
|
|
[readOnly]="readOnly()"
|
||
|
|
[disabled]="disabled()"
|
||
|
|
[required]="required()"
|
||
|
|
[attr.maxlength]="maxlength()"
|
||
|
|
[placeholder]="placeholder()"
|
||
|
|
[rows]="rows()"
|
||
|
|
[attr.aria-label]="ariaLabel() ?? null"
|
||
|
|
[attr.aria-invalid]="invalid() ? 'true' : null"
|
||
|
|
[attr.aria-describedby]="describedBy()"
|
||
|
|
(input)="value.set($any($event.target).value)"
|
||
|
|
></textarea>
|
||
|
|
`,
|
||
|
|
styles: ':host { display: block; }',
|
||
|
|
})
|
||
|
|
export class TextareaInput extends ControlBase {
|
||
|
|
readonly value = model('');
|
||
|
|
readonly maxlength = input<number>();
|
||
|
|
readonly placeholder = input('');
|
||
|
|
readonly rows = input(3);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Número o importe. Fuera de foco se muestra formateado (`1,234.50`); al editar se
|
||
|
|
* escribe en texto libre y se interpreta al salir (acepta `12,5` y `1,234.50`).
|
||
|
|
*/
|
||
|
|
@Component({
|
||
|
|
selector: 'sbo-number',
|
||
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||
|
|
template: `
|
||
|
|
<input
|
||
|
|
class="sbo-in num"
|
||
|
|
type="text"
|
||
|
|
inputmode="decimal"
|
||
|
|
autocomplete="off"
|
||
|
|
[id]="id"
|
||
|
|
[value]="display()"
|
||
|
|
[readOnly]="readOnly()"
|
||
|
|
[disabled]="disabled()"
|
||
|
|
[required]="required()"
|
||
|
|
[attr.aria-label]="ariaLabel() ?? null"
|
||
|
|
[attr.aria-invalid]="invalid() ? 'true' : null"
|
||
|
|
[attr.aria-describedby]="describedBy()"
|
||
|
|
(focus)="startEditing($event)"
|
||
|
|
(input)="draft.set($any($event.target).value)"
|
||
|
|
(blur)="finishEditing()"
|
||
|
|
/>
|
||
|
|
`,
|
||
|
|
styles: ':host { display: block; }',
|
||
|
|
})
|
||
|
|
export class NumberInput extends ControlBase {
|
||
|
|
readonly value = model<number | null>(null);
|
||
|
|
/** Decimales fijos al mostrar y redondear (importes: 2). Sin él: hasta 3, sin ceros sobrantes. */
|
||
|
|
readonly decimals = input<number>();
|
||
|
|
|
||
|
|
protected readonly editing = signal(false);
|
||
|
|
protected readonly draft = signal('');
|
||
|
|
protected readonly display = computed(() =>
|
||
|
|
this.editing() ? this.draft() : formatNumber(this.value(), this.decimals()),
|
||
|
|
);
|
||
|
|
|
||
|
|
protected startEditing(event: FocusEvent): void {
|
||
|
|
if (this.readOnly()) return;
|
||
|
|
const v = this.value();
|
||
|
|
this.draft.set(v === null ? '' : String(v));
|
||
|
|
this.editing.set(true);
|
||
|
|
// se selecciona el contenido una vez que el valor editable reemplazó al formateado
|
||
|
|
const input = event.target as HTMLInputElement;
|
||
|
|
queueMicrotask(() => input.select());
|
||
|
|
}
|
||
|
|
|
||
|
|
protected finishEditing(): void {
|
||
|
|
if (!this.editing()) return;
|
||
|
|
this.editing.set(false);
|
||
|
|
const text = this.draft().trim();
|
||
|
|
if (text === '') {
|
||
|
|
this.value.set(null);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const parsed = parseDecimal(text);
|
||
|
|
if (parsed === null) return; // no es un número: se conserva el valor anterior
|
||
|
|
const d = this.decimals();
|
||
|
|
this.value.set(d === undefined ? parsed : Math.round((parsed + Number.EPSILON) * 10 ** d) / 10 ** d);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Fecha como texto ISO `yyyy-MM-dd` (el formato del Service Layer). Selector nativo: sin librerías. */
|
||
|
|
@Component({
|
||
|
|
selector: 'sbo-date',
|
||
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||
|
|
template: `
|
||
|
|
<input
|
||
|
|
class="sbo-in"
|
||
|
|
type="date"
|
||
|
|
[id]="id"
|
||
|
|
[value]="value()"
|
||
|
|
[readOnly]="readOnly()"
|
||
|
|
[disabled]="disabled()"
|
||
|
|
[required]="required()"
|
||
|
|
[attr.aria-label]="ariaLabel() ?? null"
|
||
|
|
[attr.aria-invalid]="invalid() ? 'true' : null"
|
||
|
|
[attr.aria-describedby]="describedBy()"
|
||
|
|
(input)="value.set($any($event.target).value)"
|
||
|
|
/>
|
||
|
|
`,
|
||
|
|
styles: ':host { display: block; }',
|
||
|
|
})
|
||
|
|
export class DateInput extends ControlBase {
|
||
|
|
readonly value = model('');
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface SelectOption {
|
||
|
|
value: string | number;
|
||
|
|
label: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
@Component({
|
||
|
|
selector: 'sbo-select',
|
||
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||
|
|
template: `
|
||
|
|
<select
|
||
|
|
class="sbo-sel"
|
||
|
|
[id]="id"
|
||
|
|
[disabled]="disabled() || readOnly()"
|
||
|
|
[required]="required()"
|
||
|
|
[attr.aria-label]="ariaLabel() ?? null"
|
||
|
|
[attr.aria-invalid]="invalid() ? 'true' : null"
|
||
|
|
[attr.aria-describedby]="describedBy()"
|
||
|
|
(change)="choose($any($event.target).value)"
|
||
|
|
>
|
||
|
|
@if (placeholder() !== undefined) {
|
||
|
|
<option value="" [selected]="value() === null">{{ placeholder() }}</option>
|
||
|
|
}
|
||
|
|
@for (o of options(); track o.value) {
|
||
|
|
<option [value]="o.value" [selected]="o.value === value()">{{ o.label }}</option>
|
||
|
|
}
|
||
|
|
</select>
|
||
|
|
`,
|
||
|
|
styles: ':host { display: block; }',
|
||
|
|
})
|
||
|
|
export class SelectInput extends ControlBase {
|
||
|
|
readonly options = input.required<readonly SelectOption[]>();
|
||
|
|
readonly value = model<string | number | null>(null);
|
||
|
|
/** Opción vacía inicial (ej. "— Seleccione —"); sin él, no se ofrece. */
|
||
|
|
readonly placeholder = input<string>();
|
||
|
|
|
||
|
|
protected choose(raw: string): void {
|
||
|
|
const option = this.options().find((o) => String(o.value) === raw);
|
||
|
|
this.value.set(option ? option.value : null);
|
||
|
|
}
|
||
|
|
}
|