feat(web): ventana de formulario SBO (toolbar, título con estado, pestañas, menús y barra de estado)

- sbo-window: título con insignia de estado, cuerpo, zona de acciones
  (sboActions / sboActionsEnd) y barra de estado ok / warn / err / info con
  role="status".
- sbo-toolbar con botones .tb y sbo-icon (SVG en línea).
- sbo-tabs / sbo-tab: paneles ocultos conservan su estado, [(selected)],
  flechas izquierda/derecha con foco, roles y aria-controls.
- sbo-menu-button: lista de acciones ("Copiar de/a") que abre hacia arriba o
  abajo; Esc y clic fuera la cierran; opciones deshabilitables.
- sbo-badge compartido con las listas (data-table lo usa); tono 'info'.
- Verificado en Chrome contra el mockup oferta_venta.html: misma estructura,
  pestañas por clic y teclado, menú hacia arriba, estados ok/err.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 20:09:41 -05:00
1 parent fad66abec4
commit 26f0649da9
16 files changed
+768 -25

No files matched your search

+32
View File
@@ -135,3 +135,35 @@ Lista con **barra de filtros** y **búsqueda por columna**, igual que el mini-er
- Entradas: `title`, `resource`, `columns`, `filters`, `extraFields`, `baseFilter`, `orderby`, `pageSize` (50), `createLabel`; salidas `(open)` y `(create)`.
El estado de búsqueda/paginación (`core/sl/sl-list.ts`) y la tabla (`sbo-data-table`) son compartidos con `sbo-value-help`.
### Ventana de formulario (`shared/window`)
Marco de las pantallas `nuevo` y `:docEntry`, con la estructura del mockup:
```html
<sbo-toolbar aria-label="Acciones del documento">
<button class="tb" type="button"><sbo-icon name="new" />Nueva</button>
<button class="tb" type="button"><sbo-icon name="find" />Buscar</button>
<span class="tb-sep"></span>
<button class="tb" type="button" aria-label="Primero"><sbo-icon name="first" /></button>
<!-- prev · next · last · print -->
</sbo-toolbar>
<sbo-window title="Oferta de ventas" [badge]="{ label: 'Nueva', tone: 'info' }" [status]="status()">
<div class="sbo-hdr"><!-- filas de campos --></div>
<sbo-tabs>
<sbo-tab label="Contenido">…</sbo-tab>
<sbo-tab label="Logística">…</sbo-tab>
</sbo-tabs>
<button sboActions class="btn main" type="button">Crear</button>
<button sboActions class="btn" type="button">Cancelar</button>
<sbo-menu-button sboActionsEnd label="Copiar de" [items]="items" (picked)="copyFrom($event)" />
</sbo-window>
```
- `sbo-window`: título, insignia de estado (`tone`: `info` Nueva, `ok` Abierta, `muted` Cerrada, `err` Cancelada, `warn`), cuerpo, zona de acciones (`sboActions` a la izquierda, `sboActionsEnd` a la derecha) y **barra de estado** (`{ kind: 'info' | 'ok' | 'warn' | 'err', text }`, con `role="status"`).
- `sbo-toolbar` + botones `.tb` + `sbo-icon` (iconos SVG en línea: `new find first prev next last print`).
- `sbo-tabs` / `sbo-tab`: los paneles ocultos siguen en el DOM (conservan el estado); `[(selected)]`; flechas ←/→.
- `sbo-menu-button`: lista de acciones que se abre hacia arriba (o `direction="down"`); se cierra con Esc o clic fuera; opciones `disabled`.
- `sbo-badge`: la misma insignia que usan las listas.
@@ -75,26 +75,3 @@ tr.none td {
width: 30px;
text-align: center;
}
.badge {
display: inline-block;
padding: 0 8px;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
font-size: 11.5px;
color: var(--quiet);
}
.tone-ok {
color: var(--ok);
border-color: var(--ok);
background: var(--ok-bg);
}
.tone-warn {
color: var(--warn);
border-color: var(--warn);
background: var(--warn-bg);
}
.tone-err {
color: var(--err);
border-color: var(--err);
background: var(--err-bg);
}
+1 -1
View File
@@ -44,7 +44,7 @@
@for (c of columns(); track c.key) {
<td [style.text-align]="align(c)" [class.mono]="c.mono">
@if (tone(row, c); as t) {
<span class="badge" [class]="'badge tone-' + t">{{ cell(row, c) }}</span>
<sbo-badge [tone]="t">{{ cell(row, c) }}</sbo-badge>
} @else {
{{ cell(row, c) }}
}
@@ -1,4 +1,5 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { Badge } from '../window/badge';
import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type Tone } from './list.types';
/**
@@ -8,6 +9,7 @@ import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type To
@Component({
selector: 'sbo-data-table',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Badge],
templateUrl: './data-table.html',
styleUrl: './data-table.css',
})
+1 -1
View File
@@ -1,7 +1,7 @@
import { formatDate, formatNumber } from '../../core/format';
export type ListRow = Record<string, unknown>;
export type Tone = 'ok' | 'warn' | 'err' | 'muted';
export type Tone = 'ok' | 'warn' | 'err' | 'info' | 'muted';
export interface ListColumn {
/** Campo del Service Layer; también se usa en `$select` y, si es buscable, en el filtro. */
+42
View File
@@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import type { Tone } from '../list/list.types';
/** Insignia de estado (Abierto, Cerrado, Cancelado, Nueva...) con el color de su tono. */
@Component({
selector: 'sbo-badge',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<span class="badge" [class]="'badge tone-' + tone()"><ng-content /></span>`,
styles: `
.badge {
display: inline-block;
padding: 0 8px;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
font-size: 11.5px;
color: var(--quiet);
white-space: nowrap;
}
.tone-ok {
color: var(--ok);
border-color: var(--ok);
background: var(--ok-bg);
}
.tone-warn {
color: var(--warn);
border-color: var(--warn);
background: var(--warn-bg);
}
.tone-err {
color: var(--err);
border-color: var(--err);
background: var(--err-bg);
}
.tone-info {
color: var(--focus);
border-color: var(--focus);
}
`,
})
export class Badge {
readonly tone = input<Tone>('muted');
}
+52
View File
@@ -0,0 +1,52 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
export type IconName = 'new' | 'find' | 'first' | 'prev' | 'next' | 'last' | 'print';
/** Iconos lineales de la barra de herramientas (SVG en línea, sin fuentes ni librerías). */
@Component({
selector: 'sbo-icon',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" aria-hidden="true">
@switch (name()) {
@case ('new') {
<path d="M3.5 1.5h6l3 3v10h-9z" />
<path d="M9.5 1.5v3h3M8 7v5M5.5 9.5h5" />
}
@case ('find') {
<circle cx="7" cy="7" r="4.5" />
<path d="M10.5 10.5l4 4" />
}
@case ('first') {
<path d="M4 3v10M12 3L7 8l5 5" />
}
@case ('prev') {
<path d="M10 3L5 8l5 5" />
}
@case ('next') {
<path d="M6 3l5 5-5 5" />
}
@case ('last') {
<path d="M12 3v10M4 3l5 5-5 5" />
}
@case ('print') {
<path d="M4 6V1.5h8V6M4 12H2.5V6.5h11V12H12" />
<path d="M4.5 9.5h7v5h-7z" />
}
}
</svg>
`,
styles: `
:host {
display: inline-flex;
}
svg {
width: 16px;
height: 16px;
flex: none;
}
`,
})
export class Icon {
readonly name = input.required<IconName>();
}
+6
View File
@@ -0,0 +1,6 @@
export { Badge } from './badge';
export { Icon, type IconName } from './icon';
export { type MenuItem, MenuButton } from './menu-button';
export { Tab, Tabs } from './tabs';
export { Toolbar } from './toolbar';
export { type StatusKind, type StatusMessage, type WindowBadge, SboWindow } from './window';
@@ -0,0 +1,90 @@
import { ChangeDetectionStrategy, Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core';
export interface MenuItem {
id: string;
label: string;
disabled?: boolean;
}
/** Botón que despliega una lista de acciones (ej. "Copiar de", "Copiar a"). Se cierra con Esc o al hacer clic fuera. */
@Component({
selector: 'sbo-menu-button',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button
type="button"
class="btn"
aria-haspopup="menu"
[attr.aria-expanded]="open()"
[disabled]="disabled()"
(click)="open.set(!open())"
>
{{ label() }}
</button>
@if (open()) {
<div class="list" [class.up]="direction() === 'up'" role="menu" (keydown.escape)="open.set(false)">
@for (item of items(); track item.id) {
<button type="button" role="menuitem" [disabled]="item.disabled" (click)="choose(item)">{{ item.label }}</button>
}
</div>
}
`,
styles: `
:host {
position: relative;
display: inline-block;
}
.list {
position: absolute;
right: 0;
z-index: 20;
min-width: 250px;
background: var(--panel);
border: 1px solid var(--line);
box-shadow: var(--shadow-pop);
}
.list:not(.up) {
top: 30px;
}
.list.up {
bottom: 30px;
}
.list button {
display: block;
width: 100%;
padding: 7px 12px;
background: none;
border: none;
text-align: left;
cursor: pointer;
}
.list button:hover:not(:disabled) {
background: var(--row-sel);
}
.list button:disabled {
opacity: 0.45;
cursor: default;
}
`,
})
export class MenuButton {
readonly label = input.required<string>();
readonly items = input.required<readonly MenuItem[]>();
readonly disabled = input(false);
readonly direction = input<'up' | 'down'>('up');
readonly picked = output<string>();
protected readonly open = signal(false);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
@HostListener('document:click', ['$event'])
protected onDocumentClick(event: Event): void {
if (this.open() && !this.host.nativeElement.contains(event.target as Node)) this.open.set(false);
}
protected choose(item: MenuItem): void {
if (item.disabled) return;
this.open.set(false);
this.picked.emit(item.id);
}
}
+102
View File
@@ -0,0 +1,102 @@
import { ChangeDetectionStrategy, Component, contentChildren, effect, input, model, signal } from '@angular/core';
let nextTabId = 0;
/** Una pestaña: su contenido se mantiene en el DOM (oculto) para conservar el estado del formulario. */
@Component({
selector: 'sbo-tab',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<div role="tabpanel" [id]="panelId" [attr.aria-labelledby]="tabId" [hidden]="!active()"><ng-content /></div>`,
styles: ':host { display: block; } [hidden] { display: none; }',
})
export class Tab {
readonly label = input.required<string>();
readonly tabId = `sbo-tab-${++nextTabId}`;
readonly panelId = `${this.tabId}-panel`;
readonly active = signal(false);
}
/** Pestañas del formulario (Contenido, Logística, Finanzas...). Flechas ←/→ para moverse entre ellas. */
@Component({
selector: 'sbo-tabs',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="tabs" role="tablist" [attr.aria-label]="ariaLabel()">
@for (t of tabs(); track t.tabId; let i = $index) {
<button
type="button"
class="tab"
role="tab"
[id]="t.tabId"
[attr.aria-selected]="i === selected()"
[attr.aria-controls]="t.panelId"
[attr.tabindex]="i === selected() ? 0 : -1"
(click)="select(i)"
(keydown.arrowRight)="move(1)"
(keydown.arrowLeft)="move(-1)"
>
{{ t.label() }}
</button>
}
</div>
<div class="panels"><ng-content /></div>
`,
styles: `
:host {
display: block;
margin-top: 14px;
}
.tabs {
display: flex;
gap: 2px;
padding-left: 4px;
border-bottom: 1px solid var(--line);
overflow-x: auto;
}
.tab {
margin-bottom: -1px;
padding: 4px 16px;
border: 1px solid var(--line);
border-bottom: none;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background: var(--grid-head);
white-space: nowrap;
cursor: pointer;
}
.tab[aria-selected='true'] {
padding-bottom: 5px;
background: var(--panel);
font-weight: 600;
}
.panels {
padding: 10px;
border: 1px solid var(--line);
border-top: none;
background: var(--panel);
}
`,
})
export class Tabs {
readonly ariaLabel = input('Secciones');
readonly selected = model(0);
protected readonly tabs = contentChildren(Tab);
constructor() {
effect(() => {
const current = this.selected();
this.tabs().forEach((t, i) => t.active.set(i === current));
});
}
protected select(index: number): void {
this.selected.set(index);
}
protected move(step: number): void {
const count = this.tabs().length;
if (count === 0) return;
const next = (this.selected() + step + count) % count;
this.selected.set(next);
queueMicrotask(() => document.getElementById(this.tabs()[next].tabId)?.focus());
}
}
+25
View File
@@ -0,0 +1,25 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
/**
* Barra de herramientas del documento (Nueva, Buscar, navegación...). Los botones son
* `<button class="tb">` con un `<sbo-icon>`; `<span class="tb-sep">` separa grupos.
*/
@Component({
selector: 'sbo-toolbar',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { role: 'toolbar' },
template: '<ng-content />',
styles: `
:host {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 4px 12px;
background: var(--panel);
border: 1px solid var(--line);
border-bottom-width: 0;
}
`,
})
export class Toolbar {}
+66
View File
@@ -0,0 +1,66 @@
:host {
display: block;
}
.win {
background: var(--win);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
box-shadow: 0 1px 0 var(--line-soft);
}
.title {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
h1 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.body {
padding: 12px 16px;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 16px;
}
.start,
.end {
display: flex;
align-items: center;
gap: 8px;
}
.end {
margin-left: auto;
}
.status {
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 16px;
border-top: 1px solid var(--line);
background: var(--panel);
font-size: 12.5px;
}
.status.ok {
background: var(--ok-bg);
color: var(--ok);
}
.status.warn {
background: var(--warn-bg);
color: var(--warn);
}
.status.err {
background: var(--err-bg);
color: var(--err);
}
.ic {
font-weight: 700;
}
@@ -0,0 +1,21 @@
<section class="win">
<header class="title">
<h1>{{ title() }}</h1>
@if (badge(); as b) {
<sbo-badge [tone]="b.tone">{{ b.label }}</sbo-badge>
}
</header>
<div class="body">
<ng-content />
<div class="actions">
<div class="start"><ng-content select="[sboActions]" /></div>
<div class="end"><ng-content select="[sboActionsEnd]" /></div>
</div>
</div>
<div class="status" [class]="'status ' + status().kind" role="status" aria-live="polite">
<span class="ic" aria-hidden="true">{{ glyph() }}</span>
<span>{{ status().text }}</span>
</div>
</section>
@@ -0,0 +1,263 @@
import { Component, signal } from '@angular/core';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Badge } from './badge';
import { Icon, type IconName } from './icon';
import { MenuButton, type MenuItem } from './menu-button';
import { Tab, Tabs } from './tabs';
import { Toolbar } from './toolbar';
import { type StatusMessage, SboWindow, type WindowBadge } from './window';
const flush = async <T>(f: ComponentFixture<T>) => {
f.detectChanges();
await f.whenStable();
f.detectChanges();
};
describe('Badge', () => {
it('muestra el contenido con la clase de su tono', async () => {
const f = TestBed.createComponent(Badge);
f.componentRef.setInput('tone', 'ok');
await flush(f);
expect(f.nativeElement.querySelector('.badge.tone-ok')).toBeTruthy();
});
});
describe('Icon', () => {
it.each<[IconName, string]>([
['new', 'path'],
['find', 'circle'],
['first', 'path'],
['prev', 'path'],
['next', 'path'],
['last', 'path'],
['print', 'path'],
])('dibuja el icono %s', async (name, shape) => {
const f = TestBed.createComponent(Icon);
f.componentRef.setInput('name', name);
await flush(f);
const svg = f.nativeElement.querySelector('svg') as SVGElement;
expect(svg.getAttribute('aria-hidden')).toBe('true');
expect(svg.querySelector(shape)).toBeTruthy();
});
});
@Component({
imports: [SboWindow, Toolbar, Icon],
template: `
<sbo-toolbar>
<button class="tb" type="button"><sbo-icon name="new" />Nueva</button>
<span class="tb-sep"></span>
<button class="tb" type="button">Buscar</button>
</sbo-toolbar>
<sbo-window title="Oferta de ventas" [badge]="badge()" [status]="status()">
<p id="cuerpo">Contenido del formulario</p>
<button sboActions id="crear" class="btn main" type="button">Crear</button>
<button sboActionsEnd id="pdf" class="btn" type="button">PDF</button>
</sbo-window>
`,
})
class WindowHost {
badge = signal<WindowBadge | undefined>({ label: 'Nueva', tone: 'info' });
status = signal<StatusMessage>({ kind: 'info', text: 'Elija un cliente para empezar.' });
}
describe('SboWindow + Toolbar', () => {
let fixture: ComponentFixture<WindowHost>;
let el: HTMLElement;
const q = (s: string) => el.querySelector<HTMLElement>(s)!;
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [WindowHost] }).compileComponents();
fixture = TestBed.createComponent(WindowHost);
el = fixture.nativeElement;
await flush(fixture);
});
it('muestra el título, la insignia de estado y el contenido', () => {
expect(q('sbo-window h1').textContent).toBe('Oferta de ventas');
expect(q('sbo-window .badge.tone-info').textContent?.trim()).toBe('Nueva');
expect(q('#cuerpo').textContent).toBe('Contenido del formulario');
});
it('sin insignia no la dibuja', async () => {
fixture.componentInstance.badge.set(undefined);
await flush(fixture);
expect(el.querySelector('sbo-badge')).toBeNull();
});
it('las acciones van a su zona: inicio a la izquierda, fin a la derecha', () => {
expect(q('.actions .start #crear')).toBeTruthy();
expect(q('.actions .end #pdf')).toBeTruthy();
});
it.each([
['ok', '✓'],
['warn', '!'],
['err', '✕'],
['info', 'ℹ'],
] as const)('la barra de estado %s tiene su color y símbolo', async (kind, glyph) => {
fixture.componentInstance.status.set({ kind, text: 'Mensaje' });
await flush(fixture);
const bar = q('.status');
expect(bar.classList.contains(kind)).toBe(true);
expect(bar.getAttribute('role')).toBe('status');
expect(bar.getAttribute('aria-live')).toBe('polite');
expect(q('.status .ic').textContent).toBe(glyph);
expect(bar.textContent).toContain('Mensaje');
});
it('la barra de herramientas tiene rol toolbar, botones con icono y separador', () => {
expect(q('sbo-toolbar').getAttribute('role')).toBe('toolbar');
expect(el.querySelectorAll('sbo-toolbar .tb')).toHaveLength(2);
expect(q('sbo-toolbar .tb sbo-icon svg')).toBeTruthy();
expect(q('sbo-toolbar .tb-sep')).toBeTruthy();
});
});
@Component({
imports: [Tabs, Tab],
template: `
<sbo-tabs [(selected)]="selected">
<sbo-tab label="Contenido"><input id="in-cont" /></sbo-tab>
<sbo-tab label="Logística"><input id="in-log" /></sbo-tab>
<sbo-tab label="Finanzas"><input id="in-fin" /></sbo-tab>
</sbo-tabs>
`,
})
class TabsHost {
selected = signal(0);
}
describe('Tabs', () => {
let fixture: ComponentFixture<TabsHost>;
let el: HTMLElement;
const tabs = () => [...el.querySelectorAll<HTMLButtonElement>('[role=tab]')];
const panels = () => [...el.querySelectorAll<HTMLElement>('[role=tabpanel]')];
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [TabsHost] }).compileComponents();
fixture = TestBed.createComponent(TabsHost);
el = fixture.nativeElement;
await flush(fixture);
});
it('muestra las pestañas y solo el panel de la seleccionada', () => {
expect(tabs().map((t) => t.textContent?.trim())).toEqual(['Contenido', 'Logística', 'Finanzas']);
expect(tabs().map((t) => t.getAttribute('aria-selected'))).toEqual(['true', 'false', 'false']);
expect(panels().map((p) => p.hidden)).toEqual([false, true, true]);
});
it('los paneles ocultos siguen en el DOM (conservan su estado)', () => {
expect(el.querySelector('#in-log')).toBeTruthy();
expect(el.querySelector('#in-fin')).toBeTruthy();
});
it('un clic cambia de pestaña y actualiza el enlace [(selected)]', async () => {
tabs()[2].click();
await flush(fixture);
expect(fixture.componentInstance.selected()).toBe(2);
expect(panels().map((p) => p.hidden)).toEqual([true, true, false]);
expect(tabs()[2].getAttribute('aria-selected')).toBe('true');
});
it('cada pestaña apunta a su panel y solo la activa entra en el orden de tabulación', () => {
expect(tabs()[0].getAttribute('aria-controls')).toBe(panels()[0].id);
expect(panels()[1].getAttribute('aria-labelledby')).toBe(tabs()[1].id);
expect(tabs().map((t) => t.getAttribute('tabindex'))).toEqual(['0', '-1', '-1']);
});
it('las flechas ←/→ se mueven entre pestañas, dando la vuelta', async () => {
tabs()[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
await flush(fixture);
expect(fixture.componentInstance.selected()).toBe(2);
tabs()[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
await flush(fixture);
expect(fixture.componentInstance.selected()).toBe(0);
});
it('puede arrancar en otra pestaña', async () => {
const f = TestBed.createComponent(TabsHost);
f.componentInstance.selected.set(1);
await flush(f);
expect([...f.nativeElement.querySelectorAll('[role=tabpanel]')].map((p) => (p as HTMLElement).hidden)).toEqual([true, false, true]);
});
});
@Component({
imports: [MenuButton],
template: `
<button id="afuera" type="button">fuera</button>
<sbo-menu-button label="Copiar de" [items]="items" [direction]="dir()" (picked)="picked($event)" />
`,
})
class MenuHost {
items: MenuItem[] = [
{ id: 'last', label: 'Última cotización del cliente' },
{ id: 'any', label: 'Cotización registrada…' },
{ id: 'off', label: 'No disponible', disabled: true },
];
dir = signal<'up' | 'down'>('up');
picked = vi.fn();
}
describe('MenuButton', () => {
let fixture: ComponentFixture<MenuHost>;
let el: HTMLElement;
const trigger = () => el.querySelector<HTMLButtonElement>('sbo-menu-button > button')!;
const items = () => [...el.querySelectorAll<HTMLButtonElement>('[role=menuitem]')];
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [MenuHost] }).compileComponents();
fixture = TestBed.createComponent(MenuHost);
el = fixture.nativeElement;
await flush(fixture);
});
it('cerrado no muestra la lista; un clic la abre y marca aria-expanded', async () => {
expect(items()).toHaveLength(0);
trigger().click();
await flush(fixture);
expect(items().map((i) => i.textContent?.trim())).toEqual(['Última cotización del cliente', 'Cotización registrada…', 'No disponible']);
expect(trigger().getAttribute('aria-expanded')).toBe('true');
expect(el.querySelector('[role=menu]')?.classList.contains('up')).toBe(true);
});
it('elegir una opción emite su id y cierra', async () => {
trigger().click();
await flush(fixture);
items()[1].click();
await flush(fixture);
expect(fixture.componentInstance.picked).toHaveBeenCalledWith('any');
expect(items()).toHaveLength(0);
});
it('una opción deshabilitada no se puede elegir', async () => {
trigger().click();
await flush(fixture);
expect(items()[2].disabled).toBe(true);
items()[2].click();
expect(fixture.componentInstance.picked).not.toHaveBeenCalled();
});
it('se cierra con Esc y al hacer clic fuera', async () => {
trigger().click();
await flush(fixture);
el.querySelector('[role=menu]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await flush(fixture);
expect(items()).toHaveLength(0);
trigger().click();
await flush(fixture);
el.querySelector<HTMLElement>('#afuera')!.click();
await flush(fixture);
expect(items()).toHaveLength(0);
});
it('puede abrirse hacia abajo', async () => {
fixture.componentInstance.dir.set('down');
trigger().click();
await flush(fixture);
expect(el.querySelector('[role=menu]')?.classList.contains('up')).toBe(false);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import type { Tone } from '../list/list.types';
import { Badge } from './badge';
export type StatusKind = 'info' | 'ok' | 'warn' | 'err';
export interface StatusMessage {
kind: StatusKind;
text: string;
}
export interface WindowBadge {
label: string;
tone: Tone;
}
const GLYPH: Record<StatusKind, string> = { info: 'ℹ', ok: '✓', warn: '!', err: '✕' };
/**
* Ventana de formulario SBO: título con insignia de estado, cuerpo, zona de acciones y barra de estado.
*
* Contenido: el cuerpo va como contenido normal; las acciones con `sboActions` (izquierda) y
* `sboActionsEnd` (derecha, ej. "Copiar de/a").
*/
@Component({
selector: 'sbo-window',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Badge],
templateUrl: './window.html',
styleUrl: './window.css',
})
export class SboWindow {
readonly title = input.required<string>();
readonly badge = input<WindowBadge>();
readonly status = input<StatusMessage>({ kind: 'info', text: '' });
protected readonly glyph = computed(() => GLYPH[this.status().kind]);
}
+27
View File
@@ -107,3 +107,30 @@
border-color: var(--focus);
color: var(--focus);
}
/* Botones de la barra de herramientas (dentro de sbo-toolbar) */
.tb {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-lg);
color: var(--ink);
cursor: pointer;
}
.tb:hover:not(:disabled) {
background: var(--btn-h);
border-color: var(--line);
}
.tb:disabled {
opacity: 0.4;
cursor: default;
}
.tb-sep {
width: 1px;
height: 20px;
margin: 0 6px;
background: var(--line);
}