Estado de la barra lateral

This commit is contained in:
David Arranz 2026-10-09 10:23:55 +02:00
parent f54ce79309
commit 22b2d1612f
5 changed files with 125 additions and 3 deletions

View File

@ -185,6 +185,13 @@ de ayuda también para compañías y subcatálogos. La navegación se desplaza p
separado, manteniendo Configuración y el control de anchura en el pie visible.
En móvil se conserva la navegación horizontal existente.
El plegado se conserva entre recargas y sesiones mediante la clave
`metis-desk:sidebar-collapsed` de `localStorage`, compartida por las sesiones de
ese navegador y origen, no por cuenta ni mediante el servidor. Solo `true` inicia
la barra plegada; valores ausentes o inválidos mantienen el estado desplegado.
Se guarda desde la acción de plegar/desplegar. Si el navegador bloquea la lectura
o la escritura, la barra sigue funcionando en memoria. No cambia el layout móvil.
El menú expandido y su pie comparten márgenes horizontales de 20px mediante
`--spacing-shell-sidebar-inset` y la utilidad `px-shell-sidebar-inset`. El header global usa
`bg-shell-header` (#FFFFFF), con un borde inferior `border-shell-border`, sobre el contenido #F7F9FB.

View File

@ -10,7 +10,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workspace/shared/workspace-routes.test.ts"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts"
},
"dependencies": {
"@metis/core": "workspace:*",

View File

@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { readSidebarCollapsed, saveSidebarCollapsed } from "./sidebar-preference";
function withBrowser(
browser: { readonly localStorage: Pick<Storage, "getItem" | "setItem"> },
verify: () => void
) {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "window");
Object.defineProperty(globalThis, "window", { value: browser, configurable: true });
try {
verify();
} finally {
if (descriptor) {
Object.defineProperty(globalThis, "window", descriptor);
} else {
Reflect.deleteProperty(globalThis, "window");
}
}
}
test("la barra comienza desplegada sin navegador o sin una preferencia válida", () => {
assert.equal(readSidebarCollapsed(), false);
assert.doesNotThrow(() => saveSidebarCollapsed(true));
for (const value of [null, "false", "TRUE", "1", "invalid", ""]) {
withBrowser({ localStorage: { getItem: () => value, setItem: () => undefined } }, () => {
assert.equal(readSidebarCollapsed(), false);
});
}
withBrowser({ localStorage: { getItem: () => "true", setItem: () => undefined } }, () => {
assert.equal(readSidebarCollapsed(), true);
});
});
test("plegar y desplegar persisten la preferencia sin modificar otras claves", () => {
const values = new Map([["another-preference", "keep"]]);
const localStorage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
};
withBrowser({ localStorage }, () => {
saveSidebarCollapsed(true);
assert.equal(readSidebarCollapsed(), true);
saveSidebarCollapsed(false);
assert.equal(readSidebarCollapsed(), false);
assert.equal(values.get("another-preference"), "keep");
assert.equal(values.size, 2);
});
withBrowser({ localStorage }, () => {
assert.equal(readSidebarCollapsed(), false);
});
});
test("un fallo de lectura, escritura o acceso a localStorage no bloquea la barra", () => {
withBrowser(
{
localStorage: {
getItem: () => {
throw new Error("Storage unavailable");
},
setItem: () => {
throw new Error("Quota exceeded");
},
},
},
() => {
assert.equal(readSidebarCollapsed(), false);
assert.doesNotThrow(() => saveSidebarCollapsed(true));
}
);
withBrowser(
{
get localStorage(): never {
throw new Error("Storage access denied");
},
},
() => {
assert.equal(readSidebarCollapsed(), false);
assert.doesNotThrow(() => saveSidebarCollapsed(false));
}
);
});

View File

@ -0,0 +1,24 @@
const SIDEBAR_COLLAPSED_KEY = "metis-desk:sidebar-collapsed";
export function readSidebarCollapsed(): boolean {
if (typeof window === "undefined") {
return false;
}
try {
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true";
} catch {
// Es una preferencia opcional: si el navegador bloquea el almacenamiento, empezar desplegado.
return false;
}
}
export function saveSidebarCollapsed(collapsed: boolean): void {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(collapsed));
} catch {
// La barra sigue funcionando en memoria aunque no se pueda persistir la preferencia.
}
}

View File

@ -18,6 +18,7 @@ import { CompanyAvatar } from "../../../companies";
import { WorkspaceNavigationButton } from "../../shared";
import { companySectionHref, workspaceHref } from "../../shared/helpers";
import type { GlobalCatalog, WorkspaceLocation } from "../../shared/types";
import { readSidebarCollapsed, saveSidebarCollapsed } from "../helpers/sidebar-preference";
export function WorkspaceSidebar({
companies,
@ -35,7 +36,12 @@ export function WorkspaceSidebar({
const { t: resourceTranslation } = useTranslation("resources");
const { t: assetTranslation } = useTranslation("assets");
const { t: accessTranslation } = useTranslation("access");
const [collapsed, setCollapsed] = useState(false);
const [collapsed, setCollapsed] = useState(readSidebarCollapsed);
const toggleCollapsed = () => {
const nextCollapsed = !collapsed;
setCollapsed(nextCollapsed);
saveSidebarCollapsed(nextCollapsed);
};
return (
<aside
className="group/sidebar flex min-h-0 w-full grow-0 shrink-0 basis-auto flex-col overflow-hidden bg-[linear-gradient(180deg,var(--color-shell-sidebar-top),var(--color-shell-sidebar)_45%,var(--color-shell-sidebar-bottom))] text-shell-sidebar-foreground shell:w-shell-sidebar shell:basis-shell-sidebar shell:data-[collapsed=true]:w-shell-sidebar-collapsed shell:data-[collapsed=true]:basis-shell-sidebar-collapsed"
@ -198,7 +204,7 @@ export function WorkspaceSidebar({
aria-expanded={!collapsed}
aria-label={coreTranslation(collapsed ? "shell.expandSidebar" : "shell.collapseSidebar")}
className="hidden shell:inline-flex [&[aria-expanded=true]>svg]:rotate-180"
onClick={() => setCollapsed((value) => !value)}
onClick={toggleCollapsed}
title={coreTranslation(collapsed ? "shell.expandSidebar" : "shell.collapseSidebar")}
>
<ChevronRightIcon aria-hidden="true" className="size-5 shell:size-6" />