diff --git a/apps/web/README.md b/apps/web/README.md index b889f7a..89e664a 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -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-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. diff --git a/apps/web/src/app/shared/list/data-table.css b/apps/web/src/app/shared/list/data-table.css index 02724f7..e07aabd 100644 --- a/apps/web/src/app/shared/list/data-table.css +++ b/apps/web/src/app/shared/list/data-table.css @@ -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); -} diff --git a/apps/web/src/app/shared/list/data-table.html b/apps/web/src/app/shared/list/data-table.html index 73f254c..4299007 100644 --- a/apps/web/src/app/shared/list/data-table.html +++ b/apps/web/src/app/shared/list/data-table.html @@ -44,7 +44,7 @@ @for (c of columns(); track c.key) { @if (tone(row, c); as t) { - {{ cell(row, c) }} + {{ cell(row, c) }} } @else { {{ cell(row, c) }} } diff --git a/apps/web/src/app/shared/list/data-table.ts b/apps/web/src/app/shared/list/data-table.ts index 473d02d..46804b2 100644 --- a/apps/web/src/app/shared/list/data-table.ts +++ b/apps/web/src/app/shared/list/data-table.ts @@ -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', }) diff --git a/apps/web/src/app/shared/list/list.types.ts b/apps/web/src/app/shared/list/list.types.ts index 4419a54..1ed3359 100644 --- a/apps/web/src/app/shared/list/list.types.ts +++ b/apps/web/src/app/shared/list/list.types.ts @@ -1,7 +1,7 @@ import { formatDate, formatNumber } from '../../core/format'; export type ListRow = Record; -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. */ diff --git a/apps/web/src/app/shared/window/badge.ts b/apps/web/src/app/shared/window/badge.ts new file mode 100644 index 0000000..dd4d9c9 --- /dev/null +++ b/apps/web/src/app/shared/window/badge.ts @@ -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: ``, + 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('muted'); +} diff --git a/apps/web/src/app/shared/window/icon.ts b/apps/web/src/app/shared/window/icon.ts new file mode 100644 index 0000000..07179ef --- /dev/null +++ b/apps/web/src/app/shared/window/icon.ts @@ -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: ` + + `, + styles: ` + :host { + display: inline-flex; + } + svg { + width: 16px; + height: 16px; + flex: none; + } + `, +}) +export class Icon { + readonly name = input.required(); +} diff --git a/apps/web/src/app/shared/window/index.ts b/apps/web/src/app/shared/window/index.ts new file mode 100644 index 0000000..70d6e00 --- /dev/null +++ b/apps/web/src/app/shared/window/index.ts @@ -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'; diff --git a/apps/web/src/app/shared/window/menu-button.ts b/apps/web/src/app/shared/window/menu-button.ts new file mode 100644 index 0000000..6ad2d08 --- /dev/null +++ b/apps/web/src/app/shared/window/menu-button.ts @@ -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: ` + + @if (open()) { + + } + `, + 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(); + readonly items = input.required(); + readonly disabled = input(false); + readonly direction = input<'up' | 'down'>('up'); + readonly picked = output(); + + protected readonly open = signal(false); + private readonly host = inject>(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); + } +} diff --git a/apps/web/src/app/shared/window/tabs.ts b/apps/web/src/app/shared/window/tabs.ts new file mode 100644 index 0000000..87927a1 --- /dev/null +++ b/apps/web/src/app/shared/window/tabs.ts @@ -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: `
`, + styles: ':host { display: block; } [hidden] { display: none; }', +}) +export class Tab { + readonly label = input.required(); + 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: ` +
+ @for (t of tabs(); track t.tabId; let i = $index) { + + } +
+
+ `, + 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()); + } +} diff --git a/apps/web/src/app/shared/window/toolbar.ts b/apps/web/src/app/shared/window/toolbar.ts new file mode 100644 index 0000000..5dd7a14 --- /dev/null +++ b/apps/web/src/app/shared/window/toolbar.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +/** + * Barra de herramientas del documento (Nueva, Buscar, navegación...). Los botones son + * ` + + + + +

Contenido del formulario

+ + +
+ `, +}) +class WindowHost { + badge = signal({ label: 'Nueva', tone: 'info' }); + status = signal({ kind: 'info', text: 'Elija un cliente para empezar.' }); +} + +describe('SboWindow + Toolbar', () => { + let fixture: ComponentFixture; + let el: HTMLElement; + const q = (s: string) => el.querySelector(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: ` + + + + + + `, +}) +class TabsHost { + selected = signal(0); +} + +describe('Tabs', () => { + let fixture: ComponentFixture; + let el: HTMLElement; + const tabs = () => [...el.querySelectorAll('[role=tab]')]; + const panels = () => [...el.querySelectorAll('[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: ` + + + `, +}) +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; + let el: HTMLElement; + const trigger = () => el.querySelector('sbo-menu-button > button')!; + const items = () => [...el.querySelectorAll('[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('#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); + }); +}); diff --git a/apps/web/src/app/shared/window/window.ts b/apps/web/src/app/shared/window/window.ts new file mode 100644 index 0000000..bf2fba8 --- /dev/null +++ b/apps/web/src/app/shared/window/window.ts @@ -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 = { 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(); + readonly badge = input(); + readonly status = input({ kind: 'info', text: '' }); + + protected readonly glyph = computed(() => GLYPH[this.status().kind]); +} diff --git a/apps/web/src/styles/forms.css b/apps/web/src/styles/forms.css index 53e6d5d..c7b3494 100644 --- a/apps/web/src/styles/forms.css +++ b/apps/web/src/styles/forms.css @@ -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); +}