feat(web): el menú lateral se puede ocultar en pantallas anchas (se recuerda)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 13:23:43 -05:00
1 parent 0b988733f6
commit 047c5a02be
5 files changed
+91 -7

No files matched your search

+1 -1
View File
@@ -29,7 +29,7 @@ Menú `Finanzas`: **Plan de cuentas** (`/finanzas/plan-de-cuentas`) es un maestr
## Criterios de la interfaz
Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~289 kB, 82 kB comprimido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS).
Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~289 kB, 82 kB comprimido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS). El menú lateral se oculta con el botón ☰ (en pantallas anchas gana ancho para formularios y tablas y se recuerda en `localStorage` `erp_sidebar`; en estrechas se abre sobre el contenido); cada módulo además se pliega por su cuenta.
## Comandos
+5 -4
View File
@@ -40,7 +40,6 @@
cursor: pointer;
}
.burger {
display: none;
padding: 2px 8px;
}
.out:hover,
@@ -114,10 +113,12 @@ li a.active {
overflow: auto;
padding: 16px;
}
@media (max-width: 900px) {
.burger {
display: inline-block;
@media (min-width: 901px) {
.side.hidden {
display: none;
}
}
@media (max-width: 900px) {
.user {
display: none;
}
+2 -2
View File
@@ -1,6 +1,6 @@
<div class="app">
<header class="shell">
<button class="burger" type="button" aria-label="Mostrar u ocultar el menú" (click)="sidebarOpen.set(!sidebarOpen())">
<button class="burger" type="button" aria-label="Mostrar u ocultar el menú" (click)="toggleSide()" [attr.aria-expanded]="sideHidden() ? 'false' : 'true'">
☰
</button>
<a class="brand" routerLink="/">Basic ERP <small>{{ session.company() }}</small></a>
@@ -11,7 +11,7 @@
</header>
<div class="body">
<nav class="side" [class.open]="sidebarOpen()" aria-label="Menú principal">
<nav class="side" [class.open]="sidebarOpen()" [class.hidden]="sideHidden()" aria-label="Menú principal">
@for (m of menu.modules(); track m.id) {
<section>
<button class="mod" type="button" [attr.aria-expanded]="isOpen(m.id)" (click)="toggle(m.id)">
@@ -0,0 +1,54 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MainLayout } from './main-layout';
describe('MainLayout: menú lateral', () => {
let fixture: ComponentFixture<MainLayout>;
const side = () => (fixture.nativeElement as HTMLElement).querySelector('nav.side') as HTMLElement;
const burger = () => (fixture.nativeElement as HTMLElement).querySelector('button.burger') as HTMLButtonElement;
const open = async (narrow: boolean) => {
vi.stubGlobal('matchMedia', () => ({ matches: narrow }));
await TestBed.configureTestingModule({ providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
fixture = TestBed.createComponent(MainLayout);
fixture.detectChanges();
TestBed.inject(HttpTestingController).match(() => true).forEach((r) => r.flush({ allowedPaths: [], value: [] }));
fixture.detectChanges();
};
beforeEach(() => {
localStorage.clear();
TestBed.resetTestingModule();
});
it('en pantalla ancha el botón oculta el menú, lo muestra de nuevo y lo recuerda', async () => {
await open(false);
expect(side().classList.contains('hidden')).toBe(false);
burger().click();
fixture.detectChanges();
expect(side().classList.contains('hidden')).toBe(true);
expect(localStorage.getItem('erp_sidebar')).toBe('hidden');
burger().click();
fixture.detectChanges();
expect(side().classList.contains('hidden')).toBe(false);
expect(localStorage.getItem('erp_sidebar')).toBe('shown');
});
it('arranca oculto si así se dejó la última vez', async () => {
localStorage.setItem('erp_sidebar', 'hidden');
await open(false);
expect(side().classList.contains('hidden')).toBe(true);
});
it('en pantalla estrecha el botón abre el menú sobre el contenido sin tocar lo recordado', async () => {
await open(true);
burger().click();
fixture.detectChanges();
expect(side().classList.contains('open')).toBe(true);
expect(side().classList.contains('hidden')).toBe(false);
expect(localStorage.getItem('erp_sidebar')).toBeNull();
});
});
+29
View File
@@ -8,6 +8,17 @@ import { SessionStore } from '../core/auth/session.store';
import { MenuService } from '../core/menu/menu.service';
import { ThemeSwitcher } from '../core/theme/theme-switcher';
const SIDEBAR_KEY = 'erp_sidebar';
/** El mismo punto de corte que el de la hoja de estilos. */
const NARROW = '(max-width: 900px)';
const readHidden = (): boolean => {
try {
return localStorage.getItem(SIDEBAR_KEY) === 'hidden';
} catch {
return false;
}
};
/** Marco de la aplicación: barra superior, menú lateral filtrado por permisos y contenido. */
@Component({
selector: 'sbo-main-layout',
@@ -21,7 +32,10 @@ export class MainLayout {
protected readonly session = inject(SessionStore);
protected readonly menu = inject(MenuService);
/** En pantallas estrechas el menú se abre encima del contenido. */
protected readonly sidebarOpen = signal(false);
/** En pantallas anchas el menú se puede ocultar para ganar ancho; se recuerda entre sesiones. */
protected readonly sideHidden = signal(readHidden());
private readonly segments = inject(AccountSegmentsService);
/** Se instancia aquí para que las celdas de cuenta puedan pedir el código con formato de lo que muestran. */
private readonly accountCodes = inject(AccountCodesService);
@@ -32,6 +46,21 @@ export class MainLayout {
}
private readonly collapsed = signal<ReadonlySet<string>>(new Set());
/** El botón ☰: en pantallas estrechas abre o cierra el menú sobre el contenido; en las anchas lo oculta o lo muestra. */
protected toggleSide(): void {
if (window.matchMedia?.(NARROW).matches) {
this.sidebarOpen.update((open) => !open);
return;
}
const hidden = !this.sideHidden();
this.sideHidden.set(hidden);
try {
localStorage.setItem(SIDEBAR_KEY, hidden ? 'hidden' : 'shown');
} catch {
/* sin almacenamiento: vale para esta sesión */
}
}
protected isOpen(moduleId: string): boolean {
return !this.collapsed().has(moduleId);
}