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
@@ -11,7 +11,7 @@ ERP base para SAP Business One (Service Layer). Monorepo npm workspaces:
|
|||||||
|
|
||||||
- Solo campos **estándar** de SAP Business One. Ningún campo de usuario (`U_*`) salvo `U_WEB_USER` / `U_WEB_PASS` de `EmployeesInfo`, usados para el login web.
|
- Solo campos **estándar** de SAP Business One. Ningún campo de usuario (`U_*`) salvo `U_WEB_USER` / `U_WEB_PASS` de `EmployeesInfo`, usados para el login web.
|
||||||
- Los formularios se construyen con componentes reutilizables y configuración declarativa; no se duplica markup por documento.
|
- Los formularios se construyen con componentes reutilizables y configuración declarativa; no se duplica markup por documento.
|
||||||
- Los colores y el aspecto salen de variables CSS (temas). Tema por defecto: claro, con la paleta de `tools/Cotizador Aberio · Oferta de ventas.html`.
|
- Los colores y el aspecto salen de variables CSS (temas). Tema por defecto: claro, con la paleta de `tools/oferta_venta.html` (mockup de ejemplo: tiene algunos campos no estándar, pero se conserva la idea: filas de etiqueta + controles, máx. 2 columnas).
|
||||||
|
|
||||||
## Criterios de la interfaz
|
## Criterios de la interfaz
|
||||||
|
|
||||||
|
|||||||
+21
-15
@@ -76,30 +76,36 @@ columns: VhColumn[] = [
|
|||||||
|
|
||||||
### Campos de formulario (`shared/form`)
|
### Campos de formulario (`shared/form`)
|
||||||
|
|
||||||
Cada campo trae su etiqueta (fila SBO: etiqueta a la izquierda, control a la derecha) y se enlaza con señales: `[(value)]`.
|
Una **fila** (`sbo-field`) es: etiqueta + uno o varios controles en la misma línea. La cabecera de un formulario tiene **como máximo 2 columnas** (`.sbo-hdr` con dos `.sbo-col`). Las combinaciones del mockup (Cliente = input + "…", Canal / Zona = dos mitades, Serie + Número = 72 px + resto, select + número + botón) salen de combinar controles:
|
||||||
|
|
||||||
| Componente | Valor | Notas |
|
|
||||||
|---|---|---|
|
|
||||||
| `sbo-text-field` | `string` | `maxlength`, `placeholder` |
|
|
||||||
| `sbo-textarea-field` | `string` | `rows` |
|
|
||||||
| `sbo-number-field` | `number \| null` | `decimals`; muestra `1,234.50` fuera de foco y acepta `12,5` o `1,234.50` al escribir |
|
|
||||||
| `sbo-date-field` | `string` ISO `yyyy-MM-dd` | selector nativo |
|
|
||||||
| `sbo-select-field` | `string \| number \| null` | `[options]`, `placeholder`; devuelve el tipo original |
|
|
||||||
| `sbo-lookup-field` | `string` | flecha dorada + botón "…" / F4 que abre `sbo-value-help` según `[lookup]` (`VhLookup`); emite `(picked)` con la fila; `linkable` + `(linkClick)` |
|
|
||||||
|
|
||||||
Comunes: `label`, `required`, `readonly`, `disabled`, `error` (borde rojo + mensaje). `sbo-field` es la fila vacía para proyectar cualquier otro control.
|
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<div class="sbo-hdr">
|
<div class="sbo-hdr">
|
||||||
<div class="sbo-col">
|
<div class="sbo-col">
|
||||||
<sbo-lookup-field label="Cliente" required [(value)]="cardCode" [lookup]="bp" (picked)="onBp($event)" />
|
<sbo-lookup-field label="Cliente" required [(value)]="cardCode" [lookup]="bp" (picked)="onBp($event)" />
|
||||||
<sbo-text-field label="Nombre" readonly [value]="cardName()" />
|
<sbo-text-field label="Nombre" readonly [value]="cardName()" />
|
||||||
|
<sbo-field label="Canal / Zona">
|
||||||
|
<sbo-input readonly ariaLabel="Canal" [value]="canal()" />
|
||||||
|
<sbo-input readonly ariaLabel="Zona" [value]="zona()" />
|
||||||
|
</sbo-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="sbo-col">
|
<div class="sbo-col">
|
||||||
<sbo-date-field label="Fecha" [(value)]="docDate" />
|
<sbo-field label="Número">
|
||||||
<sbo-number-field label="Importe" [decimals]="2" [(value)]="total" />
|
<sbo-input width="72px" readonly ariaLabel="Serie" value="COT" />
|
||||||
|
<sbo-input readonly ariaLabel="Número correlativo" [value]="numero()" />
|
||||||
|
</sbo-field>
|
||||||
|
<sbo-field label="Moneda / Importe">
|
||||||
|
<sbo-select width="90px" ariaLabel="Moneda" [options]="monedas" [(value)]="moneda" />
|
||||||
|
<sbo-number [decimals]="2" ariaLabel="Importe" [(value)]="importe" />
|
||||||
|
<button type="button" class="sbo-btn">…</button>
|
||||||
|
</sbo-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
Estilos globales en `styles/forms.css` (`.sbo-in`, `.sbo-sel`, `.sbo-ta`, `.sbo-pick`, `.sbo-hdr`, `.sbo-col`, `.sbo-pair`). El ancho de etiqueta se ajusta con `--label-w` (165 px por defecto).
|
**Controles combinables** (sin etiqueta, dentro de `sbo-field`; ocupan el espacio sobrante o un `width` fijo): `sbo-input`, `sbo-textarea`, `sbo-number`, `sbo-date`, `sbo-select` y el botón nativo `<button class="sbo-btn">`. Todos con enlace por señales `[(value)]`, `readonly`, `disabled`, `required`, `width` y `ariaLabel` (obligatorio en los que no son el primero de la fila: el primero toma el `id` de la etiqueta). El `error` de la fila marca todos sus controles (borde rojo + mensaje, `aria-invalid`/`aria-describedby`).
|
||||||
|
|
||||||
|
**Atajos con etiqueta** (un control por fila): `sbo-text-field`, `sbo-textarea-field`, `sbo-number-field`, `sbo-date-field`, `sbo-select-field` y `sbo-lookup-field` (flecha dorada + botón "…" / F4 que abre `sbo-value-help` según `[lookup]` (`VhLookup`), emite `(picked)` con la fila; `linkable` + `(linkClick)`). Comunes: `label`, `required`, `readonly`, `disabled`, `error`.
|
||||||
|
|
||||||
|
Detalles: `sbo-number` muestra `1,234.50` fuera de foco y acepta `12,5` o `1,234.50` al escribir (`decimals` redondea); `sbo-date` usa texto ISO `yyyy-MM-dd`; `sbo-select` devuelve el valor con su tipo original.
|
||||||
|
|
||||||
|
Estilos globales en `styles/forms.css` (`.sbo-in`, `.sbo-sel`, `.sbo-ta`, `.sbo-btn`, `.sbo-group`, `.sbo-hdr`, `.sbo-col`). El ancho de etiqueta se ajusta con `--label-w` (165 px por defecto).
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Directive, ElementRef, booleanAttribute, computed, inject, input } from '@angular/core';
|
||||||
|
import { SboField } from './sbo-field';
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base de los controles sin etiqueta que se combinan dentro de un `sbo-field`.
|
||||||
|
* Por defecto ocupan el espacio sobrante de la fila; con `width` toman un ancho fijo.
|
||||||
|
*/
|
||||||
|
@Directive({
|
||||||
|
host: {
|
||||||
|
'[style.flex]': "width() ? '0 0 ' + width() : '1 1 0'",
|
||||||
|
'[style.min-width]': "'0'",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export abstract class ControlBase {
|
||||||
|
protected readonly field = inject(SboField, { optional: true });
|
||||||
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
|
||||||
|
readonly readOnly = input(false, { alias: 'readonly', transform: booleanAttribute });
|
||||||
|
readonly disabled = input(false, { transform: booleanAttribute });
|
||||||
|
readonly required = input(false, { transform: booleanAttribute });
|
||||||
|
/** Ancho fijo (ej. `72px`) para controles estrechos como la serie; sin él, reparte el espacio. */
|
||||||
|
readonly width = input<string>();
|
||||||
|
/** Obligatorio en controles que no son el primero de la fila (el primero usa la etiqueta). */
|
||||||
|
readonly ariaLabel = input<string>();
|
||||||
|
|
||||||
|
protected readonly id = this.field?.claimId() ?? `sbo-c-${++nextId}`;
|
||||||
|
protected readonly invalid = computed(() => !!this.field?.error());
|
||||||
|
protected readonly describedBy = computed(() => (this.invalid() ? (this.field?.errorId ?? null) : null));
|
||||||
|
|
||||||
|
focus(): void {
|
||||||
|
this.host.nativeElement.querySelector<HTMLElement>('input,select,textarea')?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component, model } from '@angular/core';
|
|
||||||
import { FieldBase } from './field-base';
|
|
||||||
import { SboField } from './sbo-field';
|
|
||||||
|
|
||||||
/** Fecha como texto ISO `yyyy-MM-dd` (el formato que usa el Service Layer). Selector nativo: sin librerías. */
|
|
||||||
@Component({
|
|
||||||
selector: 'sbo-date-field',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
imports: [SboField],
|
|
||||||
template: `
|
|
||||||
<sbo-field [label]="label()" [for]="id" [required]="required()" [error]="error()">
|
|
||||||
<input
|
|
||||||
class="sbo-in"
|
|
||||||
type="date"
|
|
||||||
[id]="id"
|
|
||||||
[value]="value()"
|
|
||||||
[readOnly]="readOnly()"
|
|
||||||
[disabled]="disabled()"
|
|
||||||
[required]="required()"
|
|
||||||
[attr.aria-invalid]="error() ? 'true' : null"
|
|
||||||
(input)="value.set($any($event.target).value)"
|
|
||||||
/>
|
|
||||||
</sbo-field>
|
|
||||||
`,
|
|
||||||
styles: ':host { display: block; }',
|
|
||||||
})
|
|
||||||
export class DateField extends FieldBase {
|
|
||||||
readonly value = model('');
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Directive, booleanAttribute, input } from '@angular/core';
|
import { Directive, booleanAttribute, input } from '@angular/core';
|
||||||
|
|
||||||
let nextId = 0;
|
/** Entradas comunes de los campos con etiqueta (un solo control por fila). */
|
||||||
|
|
||||||
/** Entradas comunes de todos los campos de formulario. */
|
|
||||||
@Directive()
|
@Directive()
|
||||||
export abstract class FieldBase {
|
export abstract class FieldBase {
|
||||||
readonly label = input.required<string>();
|
readonly label = input.required<string>();
|
||||||
@@ -10,6 +8,4 @@ export abstract class FieldBase {
|
|||||||
readonly readOnly = input(false, { alias: 'readonly', transform: booleanAttribute });
|
readonly readOnly = input(false, { alias: 'readonly', transform: booleanAttribute });
|
||||||
readonly disabled = input(false, { transform: booleanAttribute });
|
readonly disabled = input(false, { transform: booleanAttribute });
|
||||||
readonly error = input<string>();
|
readonly error = input<string>();
|
||||||
|
|
||||||
protected readonly id = `sbo-f-${++nextId}`;
|
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,11 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common
|
|||||||
import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { VhLookup } from '../value-help/value-help.types';
|
import type { VhLookup } from '../value-help/value-help.types';
|
||||||
import { DateField } from './date-field';
|
import { Component, signal } from '@angular/core';
|
||||||
|
import { DateInput, NumberInput, SelectInput, TextInput } from './controls';
|
||||||
|
import { DateField, NumberField, SelectField, TextField, TextareaField } from './fields';
|
||||||
import { LookupField } from './lookup-field';
|
import { LookupField } from './lookup-field';
|
||||||
import { NumberField } from './number-field';
|
|
||||||
import { SboField } from './sbo-field';
|
import { SboField } from './sbo-field';
|
||||||
import { SelectField } from './select-field';
|
|
||||||
import { TextField } from './text-field';
|
|
||||||
import { TextareaField } from './textarea-field';
|
|
||||||
|
|
||||||
const tick = async <T>(fixture: ComponentFixture<T>) => {
|
const tick = async <T>(fixture: ComponentFixture<T>) => {
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -26,14 +24,13 @@ describe('SboField', () => {
|
|||||||
await TestBed.configureTestingModule({ imports: [SboField] }).compileComponents();
|
await TestBed.configureTestingModule({ imports: [SboField] }).compileComponents();
|
||||||
const fixture = TestBed.createComponent(SboField);
|
const fixture = TestBed.createComponent(SboField);
|
||||||
fixture.componentRef.setInput('label', 'Cliente');
|
fixture.componentRef.setInput('label', 'Cliente');
|
||||||
fixture.componentRef.setInput('for', 'x1');
|
|
||||||
fixture.componentRef.setInput('required', true);
|
fixture.componentRef.setInput('required', true);
|
||||||
fixture.componentRef.setInput('action', true);
|
fixture.componentRef.setInput('action', true);
|
||||||
fixture.componentRef.setInput('error', 'Obligatorio');
|
fixture.componentRef.setInput('error', 'Obligatorio');
|
||||||
await tick(fixture);
|
await tick(fixture);
|
||||||
const label = fixture.nativeElement.querySelector('label') as HTMLLabelElement;
|
const label = fixture.nativeElement.querySelector('label') as HTMLLabelElement;
|
||||||
expect(label.textContent?.trim()).toBe('Cliente');
|
expect(label.textContent?.trim()).toBe('Cliente');
|
||||||
expect(label.htmlFor).toBe('x1');
|
expect(label.htmlFor).toMatch(/^sbo-f-\d+$/);
|
||||||
expect(label.classList.contains('req')).toBe(true);
|
expect(label.classList.contains('req')).toBe(true);
|
||||||
expect(label.classList.contains('ga')).toBe(true);
|
expect(label.classList.contains('ga')).toBe(true);
|
||||||
expect(fixture.nativeElement.querySelector('.err').textContent).toContain('Obligatorio');
|
expect(fixture.nativeElement.querySelector('.err').textContent).toContain('Obligatorio');
|
||||||
@@ -241,7 +238,7 @@ describe('LookupField', () => {
|
|||||||
let fixture: ComponentFixture<LookupField>;
|
let fixture: ComponentFixture<LookupField>;
|
||||||
let ctrl: HttpTestingController;
|
let ctrl: HttpTestingController;
|
||||||
const input = () => fixture.nativeElement.querySelector('input.sbo-in') as HTMLInputElement;
|
const input = () => fixture.nativeElement.querySelector('input.sbo-in') as HTMLInputElement;
|
||||||
const button = () => fixture.nativeElement.querySelector('.sbo-pick button') as HTMLButtonElement;
|
const button = () => fixture.nativeElement.querySelector('.sbo-group button') as HTMLButtonElement;
|
||||||
const dialog = () => fixture.nativeElement.querySelector('.dlg') as HTMLElement | null;
|
const dialog = () => fixture.nativeElement.querySelector('.dlg') as HTMLElement | null;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -299,7 +296,7 @@ describe('LookupField', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('F4 abre la lista y al cerrarla el foco vuelve al campo', async () => {
|
it('F4 abre la lista y al cerrarla el foco vuelve al campo', async () => {
|
||||||
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'F4', cancelable: true }));
|
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'F4', cancelable: true, bubbles: true }));
|
||||||
await tick(fixture);
|
await tick(fixture);
|
||||||
ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners').flush({ value: [], 'odata.count': '0' });
|
ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners').flush({ value: [], 'odata.count': '0' });
|
||||||
await tick(fixture);
|
await tick(fixture);
|
||||||
@@ -314,7 +311,7 @@ describe('LookupField', () => {
|
|||||||
fixture.componentRef.setInput('readonly', true);
|
fixture.componentRef.setInput('readonly', true);
|
||||||
await tick(fixture);
|
await tick(fixture);
|
||||||
expect(button().disabled).toBe(true);
|
expect(button().disabled).toBe(true);
|
||||||
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'F4' }));
|
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'F4', bubbles: true }));
|
||||||
await tick(fixture);
|
await tick(fixture);
|
||||||
expect(dialog()).toBeNull();
|
expect(dialog()).toBeNull();
|
||||||
ctrl.expectNone('/api/sl/BusinessPartners');
|
ctrl.expectNone('/api/sl/BusinessPartners');
|
||||||
@@ -329,3 +326,100 @@ describe('LookupField', () => {
|
|||||||
expect(link).toHaveBeenCalledTimes(1);
|
expect(link).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filas con varios controles, como en el mockup (Cliente = input + botón, Número = serie + número...).
|
||||||
|
@Component({
|
||||||
|
imports: [SboField, TextInput, NumberInput, DateInput, SelectInput],
|
||||||
|
template: `
|
||||||
|
<sbo-field label="Número" error="Serie inválida">
|
||||||
|
<sbo-input width="72px" readonly ariaLabel="Serie" [value]="serie()" />
|
||||||
|
<sbo-input readonly ariaLabel="Número correlativo" [value]="numero()" />
|
||||||
|
</sbo-field>
|
||||||
|
<sbo-field label="Canal / Zona">
|
||||||
|
<sbo-input readonly ariaLabel="Canal" value="Constructora" />
|
||||||
|
<sbo-input readonly ariaLabel="Zona" value="Lima Sur" />
|
||||||
|
</sbo-field>
|
||||||
|
<sbo-field label="Moneda e importe">
|
||||||
|
<sbo-select width="90px" ariaLabel="Moneda" [options]="monedas" [(value)]="moneda" />
|
||||||
|
<sbo-number [decimals]="2" ariaLabel="Importe" [(value)]="importe" />
|
||||||
|
<button type="button" class="sbo-btn" (click)="clicks.set(clicks() + 1)">…</button>
|
||||||
|
</sbo-field>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
class RowsHost {
|
||||||
|
serie = signal('COT');
|
||||||
|
numero = signal('000123');
|
||||||
|
monedas = [
|
||||||
|
{ value: 'PEN', label: 'S/' },
|
||||||
|
{ value: 'USD', label: 'US$' },
|
||||||
|
];
|
||||||
|
moneda = signal<string | number | null>('PEN');
|
||||||
|
importe = signal<number | null>(10);
|
||||||
|
clicks = signal(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('filas con varios controles', () => {
|
||||||
|
let fixture: ComponentFixture<RowsHost>;
|
||||||
|
const fields = () => [...fixture.nativeElement.querySelectorAll('sbo-field')] as HTMLElement[];
|
||||||
|
const controls = (field: HTMLElement) => [...field.querySelectorAll('input,select')] as (HTMLInputElement | HTMLSelectElement)[];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({ imports: [RowsHost] }).compileComponents();
|
||||||
|
fixture = TestBed.createComponent(RowsHost);
|
||||||
|
await tick(fixture);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('los controles de una fila comparten línea', () => {
|
||||||
|
for (const f of fields()) expect(f.querySelector('.sbo-group')).toBeTruthy();
|
||||||
|
expect(controls(fields()[1])).toHaveLength(2);
|
||||||
|
expect(controls(fields()[2])).toHaveLength(2);
|
||||||
|
expect(fields()[2].querySelectorAll('button')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un control con width toma ancho fijo y el resto reparte el espacio', () => {
|
||||||
|
const [serie, numero] = [...fields()[0].querySelectorAll('sbo-input')] as HTMLElement[];
|
||||||
|
expect(serie.style.flex).toBe('0 0 72px');
|
||||||
|
expect(numero.style.flex).toContain('1 1 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la etiqueta apunta solo al primer control; los demás se nombran con ariaLabel', () => {
|
||||||
|
const [serie, numero] = controls(fields()[0]);
|
||||||
|
expect((fields()[0].querySelector('label') as HTMLLabelElement).htmlFor).toBe(serie.id);
|
||||||
|
expect(serie.getAttribute('aria-label')).toBe('Serie');
|
||||||
|
expect(numero.getAttribute('aria-label')).toBe('Número correlativo');
|
||||||
|
expect(numero.id).not.toBe(serie.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('el error de la fila marca todos sus controles y los describe', () => {
|
||||||
|
for (const c of controls(fields()[0])) {
|
||||||
|
expect(c.getAttribute('aria-invalid')).toBe('true');
|
||||||
|
expect(c.getAttribute('aria-describedby')).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(fields()[0].querySelector('.err')?.textContent).toContain('Serie inválida');
|
||||||
|
expect(controls(fields()[1])[0].hasAttribute('aria-invalid')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mezcla select + número + botón con enlace bidireccional independiente', async () => {
|
||||||
|
const [select, importe] = controls(fields()[2]);
|
||||||
|
(select as HTMLSelectElement).value = 'USD';
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
importe.dispatchEvent(new Event('focus'));
|
||||||
|
await tick(fixture);
|
||||||
|
typeInto(importe as HTMLInputElement, '99,5');
|
||||||
|
importe.dispatchEvent(new Event('blur'));
|
||||||
|
fields()[2].querySelector('button')!.click();
|
||||||
|
await tick(fixture);
|
||||||
|
expect(fixture.componentInstance.moneda()).toBe('USD');
|
||||||
|
expect(fixture.componentInstance.importe()).toBe(99.5);
|
||||||
|
expect(fixture.componentInstance.clicks()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un control fuera de un sbo-field también funciona (con su propio id)', async () => {
|
||||||
|
const solo = TestBed.createComponent(TextInput);
|
||||||
|
solo.componentRef.setInput('value', 'x');
|
||||||
|
await tick(solo);
|
||||||
|
const input = solo.nativeElement.querySelector('input') as HTMLInputElement;
|
||||||
|
expect(input.id).toMatch(/^sbo-c-\d+$/);
|
||||||
|
expect(input.value).toBe('x');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
|
||||||
|
import { FieldBase } from './field-base';
|
||||||
|
import { DateInput, NumberInput, SelectInput, type SelectOption, TextInput, TextareaInput } from './controls';
|
||||||
|
import { SboField } from './sbo-field';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Campos con etiqueta de un solo control: atajo de `<sbo-field>` + el control correspondiente.
|
||||||
|
* Para filas con varios controles (input + botón, serie + número...) usar `sbo-field` directamente.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'sbo-text-field',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [SboField, TextInput],
|
||||||
|
template: `
|
||||||
|
<sbo-field [label]="label()" [required]="required()" [error]="error()">
|
||||||
|
<sbo-input
|
||||||
|
[(value)]="value"
|
||||||
|
[readonly]="readOnly()"
|
||||||
|
[disabled]="disabled()"
|
||||||
|
[required]="required()"
|
||||||
|
[maxlength]="maxlength()"
|
||||||
|
[placeholder]="placeholder()"
|
||||||
|
/>
|
||||||
|
</sbo-field>
|
||||||
|
`,
|
||||||
|
styles: ':host { display: block; }',
|
||||||
|
})
|
||||||
|
export class TextField extends FieldBase {
|
||||||
|
readonly value = model('');
|
||||||
|
readonly maxlength = input<number>();
|
||||||
|
readonly placeholder = input('');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'sbo-textarea-field',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [SboField, TextareaInput],
|
||||||
|
template: `
|
||||||
|
<sbo-field [label]="label()" [required]="required()" [error]="error()">
|
||||||
|
<sbo-textarea
|
||||||
|
[(value)]="value"
|
||||||
|
[readonly]="readOnly()"
|
||||||
|
[disabled]="disabled()"
|
||||||
|
[required]="required()"
|
||||||
|
[maxlength]="maxlength()"
|
||||||
|
[placeholder]="placeholder()"
|
||||||
|
[rows]="rows()"
|
||||||
|
/>
|
||||||
|
</sbo-field>
|
||||||
|
`,
|
||||||
|
styles: ':host { display: block; }',
|
||||||
|
})
|
||||||
|
export class TextareaField extends FieldBase {
|
||||||
|
readonly value = model('');
|
||||||
|
readonly maxlength = input<number>();
|
||||||
|
readonly placeholder = input('');
|
||||||
|
readonly rows = input(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'sbo-number-field',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [SboField, NumberInput],
|
||||||
|
template: `
|
||||||
|
<sbo-field [label]="label()" [required]="required()" [error]="error()">
|
||||||
|
<sbo-number
|
||||||
|
[(value)]="value"
|
||||||
|
[decimals]="decimals()"
|
||||||
|
[readonly]="readOnly()"
|
||||||
|
[disabled]="disabled()"
|
||||||
|
[required]="required()"
|
||||||
|
/>
|
||||||
|
</sbo-field>
|
||||||
|
`,
|
||||||
|
styles: ':host { display: block; }',
|
||||||
|
})
|
||||||
|
export class NumberField extends FieldBase {
|
||||||
|
readonly value = model<number | null>(null);
|
||||||
|
readonly decimals = input<number>();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'sbo-date-field',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [SboField, DateInput],
|
||||||
|
template: `
|
||||||
|
<sbo-field [label]="label()" [required]="required()" [error]="error()">
|
||||||
|
<sbo-date [(value)]="value" [readonly]="readOnly()" [disabled]="disabled()" [required]="required()" />
|
||||||
|
</sbo-field>
|
||||||
|
`,
|
||||||
|
styles: ':host { display: block; }',
|
||||||
|
})
|
||||||
|
export class DateField extends FieldBase {
|
||||||
|
readonly value = model('');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'sbo-select-field',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [SboField, SelectInput],
|
||||||
|
template: `
|
||||||
|
<sbo-field [label]="label()" [required]="required()" [error]="error()">
|
||||||
|
<sbo-select
|
||||||
|
[(value)]="value"
|
||||||
|
[options]="options()"
|
||||||
|
[placeholder]="placeholder()"
|
||||||
|
[readonly]="readOnly()"
|
||||||
|
[disabled]="disabled()"
|
||||||
|
[required]="required()"
|
||||||
|
/>
|
||||||
|
</sbo-field>
|
||||||
|
`,
|
||||||
|
styles: ':host { display: block; }',
|
||||||
|
})
|
||||||
|
export class SelectField extends FieldBase {
|
||||||
|
readonly options = input.required<readonly SelectOption[]>();
|
||||||
|
readonly value = model<string | number | null>(null);
|
||||||
|
readonly placeholder = input<string>();
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
export { DateField } from './date-field';
|
// Fila y controles combinables
|
||||||
export { LookupField } from './lookup-field';
|
export { DateInput, NumberInput, type SelectOption, SelectInput, TextInput, TextareaInput } from './controls';
|
||||||
export { NumberField } from './number-field';
|
|
||||||
export { SboField } from './sbo-field';
|
export { SboField } from './sbo-field';
|
||||||
export { type SelectOption, SelectField } from './select-field';
|
// Atajos con etiqueta (un control por fila)
|
||||||
export { TextField } from './text-field';
|
export { DateField, NumberField, SelectField, TextField, TextareaField } from './fields';
|
||||||
export { TextareaField } from './textarea-field';
|
export { LookupField } from './lookup-field';
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
type ElementRef,
|
|
||||||
booleanAttribute,
|
booleanAttribute,
|
||||||
input,
|
input,
|
||||||
model,
|
model,
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { ValueHelp } from '../value-help/value-help';
|
import { ValueHelp } from '../value-help/value-help';
|
||||||
import type { VhLookup, VhRow } from '../value-help/value-help.types';
|
import type { VhLookup, VhRow } from '../value-help/value-help.types';
|
||||||
|
import { TextInput } from './controls';
|
||||||
import { FieldBase } from './field-base';
|
import { FieldBase } from './field-base';
|
||||||
import { SboField } from './sbo-field';
|
import { SboField } from './sbo-field';
|
||||||
|
|
||||||
@@ -21,42 +21,34 @@ import { SboField } from './sbo-field';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'sbo-lookup-field',
|
selector: 'sbo-lookup-field',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [SboField, ValueHelp],
|
imports: [SboField, TextInput, ValueHelp],
|
||||||
template: `
|
template: `
|
||||||
<sbo-field
|
<sbo-field
|
||||||
[label]="label()"
|
[label]="label()"
|
||||||
[for]="id"
|
|
||||||
[required]="required()"
|
[required]="required()"
|
||||||
[error]="error()"
|
[error]="error()"
|
||||||
action
|
action
|
||||||
[linkable]="linkable()"
|
[linkable]="linkable()"
|
||||||
(labelClick)="linkClick.emit()"
|
(labelClick)="linkClick.emit()"
|
||||||
>
|
>
|
||||||
<div class="sbo-pick">
|
<sbo-input
|
||||||
<input
|
#box
|
||||||
#box
|
[(value)]="value"
|
||||||
class="sbo-in"
|
[readonly]="readOnly()"
|
||||||
type="text"
|
[disabled]="disabled()"
|
||||||
autocomplete="off"
|
[required]="required()"
|
||||||
[id]="id"
|
(keydown.f4)="$event.preventDefault(); openList()"
|
||||||
[value]="value()"
|
/>
|
||||||
[readOnly]="readOnly()"
|
<button
|
||||||
[disabled]="disabled()"
|
type="button"
|
||||||
[required]="required()"
|
class="sbo-btn"
|
||||||
[attr.aria-invalid]="error() ? 'true' : null"
|
title="Lista de valores (F4)"
|
||||||
(input)="value.set($any($event.target).value)"
|
[attr.aria-label]="'Abrir la lista de ' + label()"
|
||||||
(keydown.f4)="$event.preventDefault(); openList()"
|
[disabled]="readOnly() || disabled()"
|
||||||
/>
|
(click)="openList()"
|
||||||
<button
|
>
|
||||||
type="button"
|
…
|
||||||
title="Lista de valores (F4)"
|
</button>
|
||||||
[attr.aria-label]="'Abrir la lista de ' + label()"
|
|
||||||
[disabled]="readOnly() || disabled()"
|
|
||||||
(click)="openList()"
|
|
||||||
>
|
|
||||||
…
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</sbo-field>
|
</sbo-field>
|
||||||
<sbo-value-help
|
<sbo-value-help
|
||||||
[open]="listOpen()"
|
[open]="listOpen()"
|
||||||
@@ -81,7 +73,7 @@ export class LookupField extends FieldBase {
|
|||||||
readonly linkClick = output<void>();
|
readonly linkClick = output<void>();
|
||||||
|
|
||||||
protected readonly listOpen = signal(false);
|
protected readonly listOpen = signal(false);
|
||||||
private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box');
|
private readonly box = viewChild.required<TextInput>('box');
|
||||||
|
|
||||||
protected openList(): void {
|
protected openList(): void {
|
||||||
if (!this.readOnly() && !this.disabled()) this.listOpen.set(true);
|
if (!this.readOnly() && !this.disabled()) this.listOpen.set(true);
|
||||||
@@ -89,7 +81,7 @@ export class LookupField extends FieldBase {
|
|||||||
|
|
||||||
protected closeList(): void {
|
protected closeList(): void {
|
||||||
this.listOpen.set(false);
|
this.listOpen.set(false);
|
||||||
this.box().nativeElement.focus();
|
this.box().focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onPicked(rows: VhRow[]): void {
|
protected onPicked(rows: VhRow[]): void {
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, input, model, signal } from '@angular/core';
|
|
||||||
import { formatNumber, parseDecimal } from '../../core/format';
|
|
||||||
import { FieldBase } from './field-base';
|
|
||||||
import { SboField } from './sbo-field';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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-field',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
imports: [SboField],
|
|
||||||
template: `
|
|
||||||
<sbo-field [label]="label()" [for]="id" [required]="required()" [error]="error()">
|
|
||||||
<input
|
|
||||||
class="sbo-in num"
|
|
||||||
type="text"
|
|
||||||
inputmode="decimal"
|
|
||||||
autocomplete="off"
|
|
||||||
[id]="id"
|
|
||||||
[value]="display()"
|
|
||||||
[readOnly]="readOnly()"
|
|
||||||
[disabled]="disabled()"
|
|
||||||
[required]="required()"
|
|
||||||
[attr.aria-invalid]="error() ? 'true' : null"
|
|
||||||
(focus)="startEditing($event)"
|
|
||||||
(input)="draft.set($any($event.target).value)"
|
|
||||||
(blur)="finishEditing()"
|
|
||||||
/>
|
|
||||||
</sbo-field>
|
|
||||||
`,
|
|
||||||
styles: ':host { display: block; }',
|
|
||||||
})
|
|
||||||
export class NumberField extends FieldBase {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { ChangeDetectionStrategy, Component, booleanAttribute, input, output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, booleanAttribute, input, output } from '@angular/core';
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fila de formulario SBO: etiqueta a la izquierda y el control proyectado a la derecha.
|
* Fila de formulario SBO: etiqueta a la izquierda y, a la derecha, uno o varios
|
||||||
|
* controles en una misma línea (input + botón, select + botón, serie + número...).
|
||||||
* `action` pinta la flecha dorada de SAP (campo que enlaza a otro objeto o a una lista).
|
* `action` pinta la flecha dorada de SAP (campo que enlaza a otro objeto o a una lista).
|
||||||
|
*
|
||||||
|
* El primer control de la fila toma el `id` al que apunta la etiqueta (ver `claimId`).
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'sbo-field',
|
selector: 'sbo-field',
|
||||||
@@ -10,7 +15,7 @@ import { ChangeDetectionStrategy, Component, booleanAttribute, input, output } f
|
|||||||
template: `
|
template: `
|
||||||
<div class="f">
|
<div class="f">
|
||||||
<label
|
<label
|
||||||
[attr.for]="for()"
|
[attr.for]="controlId"
|
||||||
[class.req]="required()"
|
[class.req]="required()"
|
||||||
[class.ga]="action()"
|
[class.ga]="action()"
|
||||||
[class.link]="linkable()"
|
[class.link]="linkable()"
|
||||||
@@ -18,9 +23,9 @@ import { ChangeDetectionStrategy, Component, booleanAttribute, input, output } f
|
|||||||
>{{ label() }}</label
|
>{{ label() }}</label
|
||||||
>
|
>
|
||||||
<div class="ctl">
|
<div class="ctl">
|
||||||
<ng-content />
|
<div class="sbo-group"><ng-content /></div>
|
||||||
@if (error()) {
|
@if (error()) {
|
||||||
<span class="err" role="alert" [id]="for() + '-err'">{{ error() }}</span>
|
<span class="err" role="alert" [id]="errorId">{{ error() }}</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,8 +84,6 @@ import { ChangeDetectionStrategy, Component, booleanAttribute, input, output } f
|
|||||||
})
|
})
|
||||||
export class SboField {
|
export class SboField {
|
||||||
readonly label = input.required<string>();
|
readonly label = input.required<string>();
|
||||||
/** `id` del control, para enlazar la etiqueta. */
|
|
||||||
readonly for = input<string>();
|
|
||||||
readonly required = input(false, { transform: booleanAttribute });
|
readonly required = input(false, { transform: booleanAttribute });
|
||||||
readonly action = input(false, { transform: booleanAttribute });
|
readonly action = input(false, { transform: booleanAttribute });
|
||||||
/** La etiqueta es un enlace (ej. abrir el socio de negocio). */
|
/** La etiqueta es un enlace (ej. abrir el socio de negocio). */
|
||||||
@@ -88,4 +91,15 @@ export class SboField {
|
|||||||
readonly error = input<string>();
|
readonly error = input<string>();
|
||||||
|
|
||||||
readonly labelClick = output<void>();
|
readonly labelClick = output<void>();
|
||||||
|
|
||||||
|
readonly controlId = `sbo-f-${++nextId}`;
|
||||||
|
readonly errorId = `${this.controlId}-err`;
|
||||||
|
private claimed = false;
|
||||||
|
|
||||||
|
/** El primer control de la fila recibe el id de la etiqueta; los demás deben llevar su `ariaLabel`. */
|
||||||
|
claimId(): string | null {
|
||||||
|
if (this.claimed) return null;
|
||||||
|
this.claimed = true;
|
||||||
|
return this.controlId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
|
|
||||||
import { FieldBase } from './field-base';
|
|
||||||
import { SboField } from './sbo-field';
|
|
||||||
|
|
||||||
export interface SelectOption {
|
|
||||||
value: string | number;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'sbo-select-field',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
imports: [SboField],
|
|
||||||
template: `
|
|
||||||
<sbo-field [label]="label()" [for]="id" [required]="required()" [error]="error()">
|
|
||||||
<select
|
|
||||||
class="sbo-sel"
|
|
||||||
[id]="id"
|
|
||||||
[disabled]="disabled() || readOnly()"
|
|
||||||
[required]="required()"
|
|
||||||
[attr.aria-invalid]="error() ? 'true' : null"
|
|
||||||
(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>
|
|
||||||
</sbo-field>
|
|
||||||
`,
|
|
||||||
styles: ':host { display: block; }',
|
|
||||||
})
|
|
||||||
export class SelectField extends FieldBase {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
|
|
||||||
import { FieldBase } from './field-base';
|
|
||||||
import { SboField } from './sbo-field';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'sbo-text-field',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
imports: [SboField],
|
|
||||||
template: `
|
|
||||||
<sbo-field [label]="label()" [for]="id" [required]="required()" [error]="error()">
|
|
||||||
<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-invalid]="error() ? 'true' : null"
|
|
||||||
[attr.aria-describedby]="error() ? id + '-err' : null"
|
|
||||||
(input)="value.set($any($event.target).value)"
|
|
||||||
/>
|
|
||||||
</sbo-field>
|
|
||||||
`,
|
|
||||||
styles: ':host { display: block; }',
|
|
||||||
})
|
|
||||||
export class TextField extends FieldBase {
|
|
||||||
readonly value = model('');
|
|
||||||
readonly maxlength = input<number>();
|
|
||||||
readonly placeholder = input('');
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
|
|
||||||
import { FieldBase } from './field-base';
|
|
||||||
import { SboField } from './sbo-field';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'sbo-textarea-field',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
imports: [SboField],
|
|
||||||
template: `
|
|
||||||
<sbo-field [label]="label()" [for]="id" [required]="required()" [error]="error()">
|
|
||||||
<textarea
|
|
||||||
class="sbo-ta"
|
|
||||||
[id]="id"
|
|
||||||
[value]="value()"
|
|
||||||
[readOnly]="readOnly()"
|
|
||||||
[disabled]="disabled()"
|
|
||||||
[required]="required()"
|
|
||||||
[attr.maxlength]="maxlength()"
|
|
||||||
[placeholder]="placeholder()"
|
|
||||||
[rows]="rows()"
|
|
||||||
[attr.aria-invalid]="error() ? 'true' : null"
|
|
||||||
(input)="value.set($any($event.target).value)"
|
|
||||||
></textarea>
|
|
||||||
</sbo-field>
|
|
||||||
`,
|
|
||||||
styles: ':host { display: block; }',
|
|
||||||
})
|
|
||||||
export class TextareaField extends FieldBase {
|
|
||||||
readonly value = model('');
|
|
||||||
readonly maxlength = input<number>();
|
|
||||||
readonly placeholder = input('');
|
|
||||||
readonly rows = input(3);
|
|
||||||
}
|
|
||||||
@@ -43,24 +43,26 @@
|
|||||||
border-color: var(--err);
|
border-color: var(--err);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Campo con botón "…" (elegir de la lista) */
|
/* Controles de una misma fila (dentro de sbo-field): input + botón, serie + número... */
|
||||||
.sbo-pick {
|
.sbo-group {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: minmax(0, 1fr) 26px;
|
gap: 4px;
|
||||||
gap: 2px;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
.sbo-pick button {
|
.sbo-btn {
|
||||||
|
flex: none;
|
||||||
height: 23px;
|
height: 23px;
|
||||||
padding: 0;
|
min-width: 26px;
|
||||||
|
padding: 0 6px;
|
||||||
border: 1px solid var(--field-b);
|
border: 1px solid var(--field-b);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: var(--btn);
|
background: var(--btn);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.sbo-pick button:hover:not(:disabled) {
|
.sbo-btn:hover:not(:disabled) {
|
||||||
background: var(--btn-h);
|
background: var(--btn-h);
|
||||||
}
|
}
|
||||||
.sbo-pick button:disabled {
|
.sbo-btn:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
@@ -76,12 +78,6 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
/* Dos controles en una fila (ej. serie + número) */
|
|
||||||
.sbo-pair {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 72px minmax(0, 1fr);
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.sbo-hdr {
|
.sbo-hdr {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in new issue
Block a user