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:
1 parent
0b988733f6
commit
047c5a02be
5 files changed
+91
-7
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user