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.
|
separado, manteniendo Configuración y el control de anchura en el pie visible.
|
||||||
En móvil se conserva la navegación horizontal existente.
|
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
|
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
|
`--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.
|
`bg-shell-header` (#FFFFFF), con un borde inferior `border-shell-border`, sobre el contenido #F7F9FB.
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"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": {
|
"dependencies": {
|
||||||
"@metis/core": "workspace:*",
|
"@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 { WorkspaceNavigationButton } from "../../shared";
|
||||||
import { companySectionHref, workspaceHref } from "../../shared/helpers";
|
import { companySectionHref, workspaceHref } from "../../shared/helpers";
|
||||||
import type { GlobalCatalog, WorkspaceLocation } from "../../shared/types";
|
import type { GlobalCatalog, WorkspaceLocation } from "../../shared/types";
|
||||||
|
import { readSidebarCollapsed, saveSidebarCollapsed } from "../helpers/sidebar-preference";
|
||||||
|
|
||||||
export function WorkspaceSidebar({
|
export function WorkspaceSidebar({
|
||||||
companies,
|
companies,
|
||||||
@ -35,7 +36,12 @@ export function WorkspaceSidebar({
|
|||||||
const { t: resourceTranslation } = useTranslation("resources");
|
const { t: resourceTranslation } = useTranslation("resources");
|
||||||
const { t: assetTranslation } = useTranslation("assets");
|
const { t: assetTranslation } = useTranslation("assets");
|
||||||
const { t: accessTranslation } = useTranslation("access");
|
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 (
|
return (
|
||||||
<aside
|
<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"
|
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-expanded={!collapsed}
|
||||||
aria-label={coreTranslation(collapsed ? "shell.expandSidebar" : "shell.collapseSidebar")}
|
aria-label={coreTranslation(collapsed ? "shell.expandSidebar" : "shell.collapseSidebar")}
|
||||||
className="hidden shell:inline-flex [&[aria-expanded=true]>svg]:rotate-180"
|
className="hidden shell:inline-flex [&[aria-expanded=true]>svg]:rotate-180"
|
||||||
onClick={() => setCollapsed((value) => !value)}
|
onClick={toggleCollapsed}
|
||||||
title={coreTranslation(collapsed ? "shell.expandSidebar" : "shell.collapseSidebar")}
|
title={coreTranslation(collapsed ? "shell.expandSidebar" : "shell.collapseSidebar")}
|
||||||
>
|
>
|
||||||
<ChevronRightIcon aria-hidden="true" className="size-5 shell:size-6" />
|
<ChevronRightIcon aria-hidden="true" className="size-5 shell:size-6" />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user