Estado de la barra lateral
This commit is contained in:
parent
f54ce79309
commit
22b2d1612f
@ -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.
|
||||
|
||||
@ -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:*",
|
||||
|
||||
@ -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));
|
||||
}
|
||||
);
|
||||
});
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
@ -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" />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user