feat(web): campos de formulario SBO reutilizables
- sbo-field: fila etiqueta + control, con obligatorio (*), flecha dorada, etiqueta enlazable y mensaje de error. - sbo-text-field, sbo-textarea-field, sbo-number-field, sbo-date-field, sbo-select-field y sbo-lookup-field, con enlace bidireccional por señales ([(value)]) y entradas comunes (label, required, readonly, disabled, error). - sbo-number-field: formateado fuera de foco (1,234.50), acepta 12,5 y 1,234.50 al escribir, redondea a los decimales indicados y conserva el valor si el texto no es un número (parseDecimal). - sbo-select-field devuelve el valor con su tipo original (número o texto). - sbo-lookup-field: botón "…" y F4 abren sbo-value-help (VhLookup); elegir rellena el campo, emite la fila y devuelve el foco; escribir el código a mano sigue siendo válido. - Estilos globales de formulario (forms.css) tomados del mockup, con ancho de etiqueta configurable (--label-w). - Verificado en Chrome contra SAP real, en claro y oscuro: cliente elegido de la lista rellena código, nombre y RUC; edición de números; selects. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3d5a81e806
commit
aa2c483723
16 files changed
+917
-1
No files matched your search
@@ -73,3 +73,33 @@ columns: VhColumn[] = [
|
|||||||
- `type`: `text` (defecto), `number`, `currency` (2 decimales), `date` (dd/MM/yyyy); `format: (row) => string` para algo a medida.
|
- `type`: `text` (defecto), `number`, `currency` (2 decimales), `date` (dd/MM/yyyy); `format: (row) => string` para algo a medida.
|
||||||
- Teclado: Enter en la búsqueda, Enter sobre una fila, Esc para cerrar.
|
- Teclado: Enter en la búsqueda, Enter sobre una fila, Esc para cerrar.
|
||||||
- Cada apertura reinicia búsqueda, página y selección. Las peticiones obsoletas se cancelan solas (`httpResource`).
|
- Cada apertura reinicia búsqueda, página y selección. Las peticiones obsoletas se cancelan solas (`httpResource`).
|
||||||
|
|
||||||
|
### 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)]`.
|
||||||
|
|
||||||
|
| 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
|
||||||
|
<div class="sbo-hdr">
|
||||||
|
<div class="sbo-col">
|
||||||
|
<sbo-lookup-field label="Cliente" required [(value)]="cardCode" [lookup]="bp" (picked)="onBp($event)" />
|
||||||
|
<sbo-text-field label="Nombre" readonly [value]="cardName()" />
|
||||||
|
</div>
|
||||||
|
<div class="sbo-col">
|
||||||
|
<sbo-date-field label="Fecha" [(value)]="docDate" />
|
||||||
|
<sbo-number-field label="Importe" [decimals]="2" [(value)]="total" />
|
||||||
|
</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).
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { formatDate, formatNumber } from './format';
|
import { formatDate, formatNumber, parseDecimal } from './format';
|
||||||
|
|
||||||
describe('formatNumber', () => {
|
describe('formatNumber', () => {
|
||||||
it('importes con decimales fijos y miles', () => {
|
it('importes con decimales fijos y miles', () => {
|
||||||
@@ -24,3 +24,17 @@ describe('formatDate', () => {
|
|||||||
for (const v of [null, undefined, '', 'ayer', 20260105]) expect(formatDate(v)).toBe('');
|
for (const v of [null, undefined, '', 'ayer', 20260105]) expect(formatDate(v)).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseDecimal', () => {
|
||||||
|
it('acepta punto decimal, coma decimal y coma de miles', () => {
|
||||||
|
expect(parseDecimal('1234.5')).toBe(1234.5);
|
||||||
|
expect(parseDecimal('1,234.50')).toBe(1234.5);
|
||||||
|
expect(parseDecimal('12,5')).toBe(12.5);
|
||||||
|
expect(parseDecimal('1,234,567')).toBe(1234567);
|
||||||
|
expect(parseDecimal(' -7 ')).toBe(-7);
|
||||||
|
expect(parseDecimal('S/ 99.90')).toBe(99.9);
|
||||||
|
});
|
||||||
|
it('lo que no es número da null', () => {
|
||||||
|
for (const v of ['', ' ', 'abc', '1.2.3', '--']) expect(parseDecimal(v), v).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,3 +22,17 @@ export function formatDate(value: unknown): string {
|
|||||||
const m = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
|
const m = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
|
||||||
return m ? `${m[3]}/${m[2]}/${m[1]}` : '';
|
return m ? `${m[3]}/${m[2]}/${m[1]}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpreta lo que escribe el usuario: `1,234.50` (coma de miles) o `12,5` (coma decimal).
|
||||||
|
* Devuelve `null` si no es un número.
|
||||||
|
*/
|
||||||
|
export function parseDecimal(text: string): number | null {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t) return null;
|
||||||
|
const normalized = /,\d{3}(\D|$)/.test(t) ? t.replace(/,/g, '') : t.replace(/,/g, '.');
|
||||||
|
const cleaned = normalized.replace(/[^\d.-]/g, '');
|
||||||
|
if (!/^-?\d*\.?\d+$|^-?\d+\.$/.test(cleaned)) return null;
|
||||||
|
const n = Number.parseFloat(cleaned);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Directive, booleanAttribute, input } from '@angular/core';
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
/** Entradas comunes de todos los campos de formulario. */
|
||||||
|
@Directive()
|
||||||
|
export abstract class FieldBase {
|
||||||
|
readonly label = input.required<string>();
|
||||||
|
readonly required = input(false, { transform: booleanAttribute });
|
||||||
|
readonly readOnly = input(false, { alias: 'readonly', transform: booleanAttribute });
|
||||||
|
readonly disabled = input(false, { transform: booleanAttribute });
|
||||||
|
readonly error = input<string>();
|
||||||
|
|
||||||
|
protected readonly id = `sbo-f-${++nextId}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { VhLookup } from '../value-help/value-help.types';
|
||||||
|
import { DateField } from './date-field';
|
||||||
|
import { LookupField } from './lookup-field';
|
||||||
|
import { NumberField } from './number-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>) => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve));
|
||||||
|
fixture.detectChanges();
|
||||||
|
};
|
||||||
|
const typeInto = (input: HTMLInputElement | HTMLTextAreaElement, value: string) => {
|
||||||
|
input.value = value;
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SboField', () => {
|
||||||
|
it('muestra etiqueta, obligatorio, flecha dorada y error enlazados al control', async () => {
|
||||||
|
await TestBed.configureTestingModule({ imports: [SboField] }).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(SboField);
|
||||||
|
fixture.componentRef.setInput('label', 'Cliente');
|
||||||
|
fixture.componentRef.setInput('for', 'x1');
|
||||||
|
fixture.componentRef.setInput('required', true);
|
||||||
|
fixture.componentRef.setInput('action', true);
|
||||||
|
fixture.componentRef.setInput('error', 'Obligatorio');
|
||||||
|
await tick(fixture);
|
||||||
|
const label = fixture.nativeElement.querySelector('label') as HTMLLabelElement;
|
||||||
|
expect(label.textContent?.trim()).toBe('Cliente');
|
||||||
|
expect(label.htmlFor).toBe('x1');
|
||||||
|
expect(label.classList.contains('req')).toBe(true);
|
||||||
|
expect(label.classList.contains('ga')).toBe(true);
|
||||||
|
expect(fixture.nativeElement.querySelector('.err').textContent).toContain('Obligatorio');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TextField', () => {
|
||||||
|
let fixture: ComponentFixture<TextField>;
|
||||||
|
const input = () => fixture.nativeElement.querySelector('input') as HTMLInputElement;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({ imports: [TextField] }).compileComponents();
|
||||||
|
fixture = TestBed.createComponent(TextField);
|
||||||
|
fixture.componentRef.setInput('label', 'Nombre');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refleja el valor y lo actualiza al escribir (enlace bidireccional)', async () => {
|
||||||
|
fixture.componentRef.setInput('value', 'ACME');
|
||||||
|
await tick(fixture);
|
||||||
|
expect(input().value).toBe('ACME');
|
||||||
|
typeInto(input(), 'Nuevo');
|
||||||
|
expect(fixture.componentInstance.value()).toBe('Nuevo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la etiqueta apunta al control', async () => {
|
||||||
|
await tick(fixture);
|
||||||
|
expect(fixture.nativeElement.querySelector('label').htmlFor).toBe(input().id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('solo lectura, maxlength, obligatorio y error', async () => {
|
||||||
|
fixture.componentRef.setInput('readonly', true);
|
||||||
|
fixture.componentRef.setInput('maxlength', 20);
|
||||||
|
fixture.componentRef.setInput('required', true);
|
||||||
|
fixture.componentRef.setInput('error', 'Falta');
|
||||||
|
await tick(fixture);
|
||||||
|
expect(input().readOnly).toBe(true);
|
||||||
|
expect(input().getAttribute('maxlength')).toBe('20');
|
||||||
|
expect(input().required).toBe(true);
|
||||||
|
expect(input().getAttribute('aria-invalid')).toBe('true');
|
||||||
|
expect(input().getAttribute('aria-describedby')).toBe(`${input().id}-err`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cada campo tiene un id distinto', async () => {
|
||||||
|
const other = TestBed.createComponent(TextField);
|
||||||
|
other.componentRef.setInput('label', 'Otro');
|
||||||
|
await tick(fixture);
|
||||||
|
await tick(other);
|
||||||
|
expect(input().id).not.toBe((other.nativeElement.querySelector('input') as HTMLInputElement).id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TextareaField', () => {
|
||||||
|
it('actualiza el valor y respeta solo lectura', async () => {
|
||||||
|
await TestBed.configureTestingModule({ imports: [TextareaField] }).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(TextareaField);
|
||||||
|
fixture.componentRef.setInput('label', 'Comentarios');
|
||||||
|
fixture.componentRef.setInput('value', 'hola');
|
||||||
|
await tick(fixture);
|
||||||
|
const ta = fixture.nativeElement.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
expect(ta.value).toBe('hola');
|
||||||
|
typeInto(ta, 'adiós');
|
||||||
|
expect(fixture.componentInstance.value()).toBe('adiós');
|
||||||
|
fixture.componentRef.setInput('readonly', true);
|
||||||
|
await tick(fixture);
|
||||||
|
expect(ta.readOnly).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NumberField', () => {
|
||||||
|
let fixture: ComponentFixture<NumberField>;
|
||||||
|
const input = () => fixture.nativeElement.querySelector('input') as HTMLInputElement;
|
||||||
|
const edit = async (text: string) => {
|
||||||
|
input().dispatchEvent(new Event('focus'));
|
||||||
|
await tick(fixture);
|
||||||
|
typeInto(input(), text);
|
||||||
|
input().dispatchEvent(new Event('blur'));
|
||||||
|
await tick(fixture);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({ imports: [NumberField] }).compileComponents();
|
||||||
|
fixture = TestBed.createComponent(NumberField);
|
||||||
|
fixture.componentRef.setInput('label', 'Importe');
|
||||||
|
fixture.componentRef.setInput('decimals', 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fuera de foco muestra el valor formateado; al editar, el valor crudo', async () => {
|
||||||
|
fixture.componentRef.setInput('value', 1234.5);
|
||||||
|
await tick(fixture);
|
||||||
|
expect(input().value).toBe('1,234.50');
|
||||||
|
input().dispatchEvent(new Event('focus'));
|
||||||
|
await tick(fixture);
|
||||||
|
expect(input().value).toBe('1234.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interpreta coma decimal, coma de miles y redondea a los decimales indicados', async () => {
|
||||||
|
await edit('12,5');
|
||||||
|
expect(fixture.componentInstance.value()).toBe(12.5);
|
||||||
|
await edit('1,234.567');
|
||||||
|
expect(fixture.componentInstance.value()).toBe(1234.57);
|
||||||
|
expect(input().value).toBe('1,234.57');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vacío da null y un texto inválido conserva el valor anterior', async () => {
|
||||||
|
fixture.componentRef.setInput('value', 50);
|
||||||
|
await tick(fixture);
|
||||||
|
await edit('abc');
|
||||||
|
expect(fixture.componentInstance.value()).toBe(50);
|
||||||
|
await edit('');
|
||||||
|
expect(fixture.componentInstance.value()).toBeNull();
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('en solo lectura no entra en edición', async () => {
|
||||||
|
fixture.componentRef.setInput('value', 7);
|
||||||
|
fixture.componentRef.setInput('readonly', true);
|
||||||
|
await tick(fixture);
|
||||||
|
input().dispatchEvent(new Event('focus'));
|
||||||
|
await tick(fixture);
|
||||||
|
expect(input().value).toBe('7.00');
|
||||||
|
expect(input().readOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sin decimales fijos no agrega ceros', async () => {
|
||||||
|
fixture.componentRef.setInput('decimals', undefined);
|
||||||
|
fixture.componentRef.setInput('value', 12);
|
||||||
|
await tick(fixture);
|
||||||
|
expect(input().value).toBe('12');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DateField', () => {
|
||||||
|
it('lee y escribe fechas ISO', async () => {
|
||||||
|
await TestBed.configureTestingModule({ imports: [DateField] }).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(DateField);
|
||||||
|
fixture.componentRef.setInput('label', 'Fecha');
|
||||||
|
fixture.componentRef.setInput('value', '2026-03-05');
|
||||||
|
await tick(fixture);
|
||||||
|
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
|
||||||
|
expect(input.type).toBe('date');
|
||||||
|
expect(input.value).toBe('2026-03-05');
|
||||||
|
typeInto(input, '2026-12-31');
|
||||||
|
expect(fixture.componentInstance.value()).toBe('2026-12-31');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SelectField', () => {
|
||||||
|
let fixture: ComponentFixture<SelectField>;
|
||||||
|
const select = () => fixture.nativeElement.querySelector('select') as HTMLSelectElement;
|
||||||
|
const choose = async (value: string) => {
|
||||||
|
select().value = value;
|
||||||
|
select().dispatchEvent(new Event('change'));
|
||||||
|
await tick(fixture);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({ imports: [SelectField] }).compileComponents();
|
||||||
|
fixture = TestBed.createComponent(SelectField);
|
||||||
|
fixture.componentRef.setInput('label', 'Condición de pago');
|
||||||
|
fixture.componentRef.setInput('options', [
|
||||||
|
{ value: -1, label: 'Contado' },
|
||||||
|
{ value: 1, label: 'Crédito 30 días' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('muestra las opciones y marca la elegida', async () => {
|
||||||
|
fixture.componentRef.setInput('value', 1);
|
||||||
|
await tick(fixture);
|
||||||
|
expect([...select().options].map((o) => o.textContent?.trim())).toEqual(['Contado', 'Crédito 30 días']);
|
||||||
|
expect(select().value).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('devuelve el valor con su tipo original (número, no texto)', async () => {
|
||||||
|
await tick(fixture);
|
||||||
|
await choose('-1');
|
||||||
|
expect(fixture.componentInstance.value()).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('con placeholder ofrece la opción vacía que da null', async () => {
|
||||||
|
fixture.componentRef.setInput('placeholder', '— Seleccione —');
|
||||||
|
fixture.componentRef.setInput('value', 1);
|
||||||
|
await tick(fixture);
|
||||||
|
expect(select().options[0].textContent?.trim()).toBe('— Seleccione —');
|
||||||
|
await choose('');
|
||||||
|
expect(fixture.componentInstance.value()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('solo lectura lo deshabilita', async () => {
|
||||||
|
fixture.componentRef.setInput('readonly', true);
|
||||||
|
await tick(fixture);
|
||||||
|
expect(select().disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LookupField', () => {
|
||||||
|
const lookup: VhLookup = {
|
||||||
|
title: 'Socios de negocio',
|
||||||
|
resource: 'BusinessPartners',
|
||||||
|
columns: [
|
||||||
|
{ key: 'CardCode', title: 'Código' },
|
||||||
|
{ key: 'CardName', title: 'Nombre' },
|
||||||
|
],
|
||||||
|
baseFilter: "CardType eq 'cCustomer'",
|
||||||
|
};
|
||||||
|
let fixture: ComponentFixture<LookupField>;
|
||||||
|
let ctrl: HttpTestingController;
|
||||||
|
const input = () => fixture.nativeElement.querySelector('input.sbo-in') as HTMLInputElement;
|
||||||
|
const button = () => fixture.nativeElement.querySelector('.sbo-pick button') as HTMLButtonElement;
|
||||||
|
const dialog = () => fixture.nativeElement.querySelector('.dlg') as HTMLElement | null;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [LookupField],
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
}).compileComponents();
|
||||||
|
ctrl = TestBed.inject(HttpTestingController);
|
||||||
|
fixture = TestBed.createComponent(LookupField);
|
||||||
|
fixture.componentRef.setInput('label', 'Cliente');
|
||||||
|
fixture.componentRef.setInput('lookup', lookup);
|
||||||
|
await tick(fixture);
|
||||||
|
});
|
||||||
|
|
||||||
|
const openAndReply = async (rows: object[]) => {
|
||||||
|
button().click();
|
||||||
|
await tick(fixture);
|
||||||
|
ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners').flush({ value: rows, 'odata.count': String(rows.length) });
|
||||||
|
await tick(fixture);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('tiene la flecha dorada y el botón "…" cerrado por defecto', () => {
|
||||||
|
expect(fixture.nativeElement.querySelector('label.ga')).toBeTruthy();
|
||||||
|
expect(button().textContent?.trim()).toBe('…');
|
||||||
|
expect(dialog()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escribir el código a mano actualiza el valor sin abrir la lista', async () => {
|
||||||
|
typeInto(input(), 'C0001');
|
||||||
|
expect(fixture.componentInstance.value()).toBe('C0001');
|
||||||
|
expect(dialog()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('elegir de la lista rellena el valor, emite la fila y cierra', async () => {
|
||||||
|
const picked = vi.fn();
|
||||||
|
fixture.componentInstance.picked.subscribe(picked);
|
||||||
|
await openAndReply([
|
||||||
|
{ CardCode: 'C1', CardName: 'ACME' },
|
||||||
|
{ CardCode: 'C2', CardName: 'Beta' },
|
||||||
|
]);
|
||||||
|
expect(dialog()).toBeTruthy();
|
||||||
|
(fixture.nativeElement.querySelectorAll('tbody tr')[1] as HTMLElement).click();
|
||||||
|
await tick(fixture);
|
||||||
|
expect(fixture.componentInstance.value()).toBe('C2');
|
||||||
|
expect(picked).toHaveBeenCalledWith({ CardCode: 'C2', CardName: 'Beta' });
|
||||||
|
expect(dialog()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('usa valueKey si se indica otro campo', async () => {
|
||||||
|
fixture.componentRef.setInput('lookup', { ...lookup, valueKey: 'CardName' });
|
||||||
|
await openAndReply([{ CardCode: 'C1', CardName: 'ACME' }]);
|
||||||
|
(fixture.nativeElement.querySelector('tbody tr') as HTMLElement).click();
|
||||||
|
await tick(fixture);
|
||||||
|
expect(fixture.componentInstance.value()).toBe('ACME');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F4 abre la lista y al cerrarla el foco vuelve al campo', async () => {
|
||||||
|
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'F4', cancelable: true }));
|
||||||
|
await tick(fixture);
|
||||||
|
ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners').flush({ value: [], 'odata.count': '0' });
|
||||||
|
await tick(fixture);
|
||||||
|
expect(dialog()).toBeTruthy();
|
||||||
|
dialog()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
await tick(fixture);
|
||||||
|
expect(dialog()).toBeNull();
|
||||||
|
expect(document.activeElement === input() || fixture.nativeElement.contains(document.activeElement)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('en solo lectura el botón está deshabilitado y F4 no abre', async () => {
|
||||||
|
fixture.componentRef.setInput('readonly', true);
|
||||||
|
await tick(fixture);
|
||||||
|
expect(button().disabled).toBe(true);
|
||||||
|
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'F4' }));
|
||||||
|
await tick(fixture);
|
||||||
|
expect(dialog()).toBeNull();
|
||||||
|
ctrl.expectNone('/api/sl/BusinessPartners');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la etiqueta enlazable emite linkClick', async () => {
|
||||||
|
const link = vi.fn();
|
||||||
|
fixture.componentRef.setInput('linkable', true);
|
||||||
|
fixture.componentInstance.linkClick.subscribe(link);
|
||||||
|
await tick(fixture);
|
||||||
|
(fixture.nativeElement.querySelector('label') as HTMLElement).click();
|
||||||
|
expect(link).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { DateField } from './date-field';
|
||||||
|
export { LookupField } from './lookup-field';
|
||||||
|
export { NumberField } from './number-field';
|
||||||
|
export { SboField } from './sbo-field';
|
||||||
|
export { type SelectOption, SelectField } from './select-field';
|
||||||
|
export { TextField } from './text-field';
|
||||||
|
export { TextareaField } from './textarea-field';
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
type ElementRef,
|
||||||
|
booleanAttribute,
|
||||||
|
input,
|
||||||
|
model,
|
||||||
|
output,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { ValueHelp } from '../value-help/value-help';
|
||||||
|
import type { VhLookup, VhRow } from '../value-help/value-help.types';
|
||||||
|
import { FieldBase } from './field-base';
|
||||||
|
import { SboField } from './sbo-field';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Campo con botón "…" que abre una lista de valores (flecha dorada de SAP).
|
||||||
|
* Escribir el código a mano también es válido; elegir de la lista lo rellena y emite `picked`.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'sbo-lookup-field',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [SboField, ValueHelp],
|
||||||
|
template: `
|
||||||
|
<sbo-field
|
||||||
|
[label]="label()"
|
||||||
|
[for]="id"
|
||||||
|
[required]="required()"
|
||||||
|
[error]="error()"
|
||||||
|
action
|
||||||
|
[linkable]="linkable()"
|
||||||
|
(labelClick)="linkClick.emit()"
|
||||||
|
>
|
||||||
|
<div class="sbo-pick">
|
||||||
|
<input
|
||||||
|
#box
|
||||||
|
class="sbo-in"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
[id]="id"
|
||||||
|
[value]="value()"
|
||||||
|
[readOnly]="readOnly()"
|
||||||
|
[disabled]="disabled()"
|
||||||
|
[required]="required()"
|
||||||
|
[attr.aria-invalid]="error() ? 'true' : null"
|
||||||
|
(input)="value.set($any($event.target).value)"
|
||||||
|
(keydown.f4)="$event.preventDefault(); openList()"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Lista de valores (F4)"
|
||||||
|
[attr.aria-label]="'Abrir la lista de ' + label()"
|
||||||
|
[disabled]="readOnly() || disabled()"
|
||||||
|
(click)="openList()"
|
||||||
|
>
|
||||||
|
…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</sbo-field>
|
||||||
|
<sbo-value-help
|
||||||
|
[open]="listOpen()"
|
||||||
|
[title]="lookup().title"
|
||||||
|
[resource]="lookup().resource"
|
||||||
|
[columns]="lookup().columns"
|
||||||
|
[baseFilter]="lookup().baseFilter"
|
||||||
|
[orderby]="lookup().orderby"
|
||||||
|
(selected)="onPicked($event)"
|
||||||
|
(closed)="closeList()"
|
||||||
|
/>
|
||||||
|
`,
|
||||||
|
styles: ':host { display: block; }',
|
||||||
|
})
|
||||||
|
export class LookupField extends FieldBase {
|
||||||
|
readonly value = model('');
|
||||||
|
readonly lookup = input.required<VhLookup>();
|
||||||
|
/** La etiqueta abre el objeto enlazado (evento `linkClick`). */
|
||||||
|
readonly linkable = input(false, { transform: booleanAttribute });
|
||||||
|
|
||||||
|
readonly picked = output<VhRow>();
|
||||||
|
readonly linkClick = output<void>();
|
||||||
|
|
||||||
|
protected readonly listOpen = signal(false);
|
||||||
|
private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box');
|
||||||
|
|
||||||
|
protected openList(): void {
|
||||||
|
if (!this.readOnly() && !this.disabled()) this.listOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected closeList(): void {
|
||||||
|
this.listOpen.set(false);
|
||||||
|
this.box().nativeElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onPicked(rows: VhRow[]): void {
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return;
|
||||||
|
const key = this.lookup().valueKey ?? this.lookup().columns[0]?.key ?? '';
|
||||||
|
this.value.set(String(row[key] ?? ''));
|
||||||
|
this.picked.emit(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, booleanAttribute, input, output } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fila de formulario SBO: etiqueta a la izquierda y el control proyectado a la derecha.
|
||||||
|
* `action` pinta la flecha dorada de SAP (campo que enlaza a otro objeto o a una lista).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'sbo-field',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<div class="f">
|
||||||
|
<label
|
||||||
|
[attr.for]="for()"
|
||||||
|
[class.req]="required()"
|
||||||
|
[class.ga]="action()"
|
||||||
|
[class.link]="linkable()"
|
||||||
|
(click)="labelClick.emit()"
|
||||||
|
>{{ label() }}</label
|
||||||
|
>
|
||||||
|
<div class="ctl">
|
||||||
|
<ng-content />
|
||||||
|
@if (error()) {
|
||||||
|
<span class="err" role="alert" [id]="for() + '-err'">{{ error() }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.f {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--label-w, 165px) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
padding-top: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
label.req::after {
|
||||||
|
content: ' *';
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
label.ga {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 14px;
|
||||||
|
}
|
||||||
|
label.ga::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border-left: 8px solid var(--gold);
|
||||||
|
border-top: 5px solid transparent;
|
||||||
|
border-bottom: 5px solid transparent;
|
||||||
|
}
|
||||||
|
label.link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
label.link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.ctl {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.err {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class SboField {
|
||||||
|
readonly label = input.required<string>();
|
||||||
|
/** `id` del control, para enlazar la etiqueta. */
|
||||||
|
readonly for = input<string>();
|
||||||
|
readonly required = input(false, { transform: booleanAttribute });
|
||||||
|
readonly action = input(false, { transform: booleanAttribute });
|
||||||
|
/** La etiqueta es un enlace (ej. abrir el socio de negocio). */
|
||||||
|
readonly linkable = input(false, { transform: booleanAttribute });
|
||||||
|
readonly error = input<string>();
|
||||||
|
|
||||||
|
readonly labelClick = output<void>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -17,3 +17,14 @@ export interface VhColumn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const isSearchable = (c: VhColumn): boolean => c.searchable ?? (c.type ?? 'text') === 'text';
|
export const isSearchable = (c: VhColumn): boolean => c.searchable ?? (c.type ?? 'text') === 'text';
|
||||||
|
|
||||||
|
/** Configuración de una lista de valores para un campo de búsqueda (`sbo-lookup-field`). */
|
||||||
|
export interface VhLookup {
|
||||||
|
title: string;
|
||||||
|
resource: string;
|
||||||
|
columns: readonly VhColumn[];
|
||||||
|
baseFilter?: string;
|
||||||
|
orderby?: string;
|
||||||
|
/** Campo de la fila elegida que se escribe en el campo; por defecto, la primera columna. */
|
||||||
|
valueKey?: string;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
@import './styles/themes/light.css';
|
@import './styles/themes/light.css';
|
||||||
@import './styles/themes/dark.css';
|
@import './styles/themes/dark.css';
|
||||||
@import './styles/base.css';
|
@import './styles/base.css';
|
||||||
|
@import './styles/forms.css';
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* Controles de formulario estilo SBO (tomados del mockup). Globales y sin JS:
|
||||||
|
* los componentes de shared/form los usan, y también sirven para HTML plano.
|
||||||
|
*/
|
||||||
|
.sbo-in,
|
||||||
|
.sbo-sel,
|
||||||
|
.sbo-ta {
|
||||||
|
height: 23px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid var(--field-b);
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--field);
|
||||||
|
}
|
||||||
|
.sbo-ta {
|
||||||
|
height: auto;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.sbo-in.num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.sbo-in[readonly],
|
||||||
|
.sbo-in:disabled,
|
||||||
|
.sbo-sel:disabled,
|
||||||
|
.sbo-ta[readonly],
|
||||||
|
.sbo-ta:disabled {
|
||||||
|
background: var(--field-ro);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.sbo-in:focus,
|
||||||
|
.sbo-sel:focus,
|
||||||
|
.sbo-ta:focus {
|
||||||
|
border-color: var(--focus);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 1px var(--focus);
|
||||||
|
}
|
||||||
|
.sbo-in[aria-invalid='true'],
|
||||||
|
.sbo-sel[aria-invalid='true'],
|
||||||
|
.sbo-ta[aria-invalid='true'] {
|
||||||
|
border-color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Campo con botón "…" (elegir de la lista) */
|
||||||
|
.sbo-pick {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 26px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.sbo-pick button {
|
||||||
|
height: 23px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--field-b);
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--btn);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sbo-pick button:hover:not(:disabled) {
|
||||||
|
background: var(--btn-h);
|
||||||
|
}
|
||||||
|
.sbo-pick button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cabecera de formulario en dos columnas */
|
||||||
|
.sbo-hdr {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
|
||||||
|
gap: 8px 48px;
|
||||||
|
}
|
||||||
|
.sbo-col {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
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) {
|
||||||
|
.sbo-hdr {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user