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:
1 parent
fad66abec4
commit
26f0649da9
16 files changed
+768
-25
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user