refactor(web): filas de formulario con varios controles combinables
El mockup muestra que una fila es etiqueta + uno o varios controles en la misma línea (Cliente = input + "...", Canal / Zona = dos mitades, Serie + Número = 72 px + resto, select + número + botón) y que la cabecera tiene a lo sumo 2 columnas. Los campos de un solo control no lo permitían. - Controles sin etiqueta combinables dentro de sbo-field: sbo-input, sbo-textarea, sbo-number, sbo-date, sbo-select y el botón .sbo-btn; ocupan el espacio sobrante o un width fijo. - El primer control de la fila toma el id de la etiqueta (claimId); los demás se nombran con ariaLabel. El error de la fila marca todos sus controles (aria-invalid / aria-describedby). - Los campos con etiqueta (sbo-text-field, sbo-number-field, ..., sbo-lookup-field) pasan a ser atajos de un solo control con la misma API. - CSS global: .sbo-group y .sbo-btn reemplazan a .sbo-pick/.sbo-pair. - CLAUDE.md apunta al mockup renombrado (tools/oferta_venta.html). - Verificado en Chrome contra SAP real, con una cabecera que reproduce las filas compuestas del mockup. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa2c483723
commit
68caf401a7
16 files changed
+531
-298
No files matched your search
@@ -0,0 +1,191 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user