feat(web): group top nav, add mobile drawer and app-wide text size control
The appbar had grown to 11 flat links with no responsive behaviour, and overflowed below ~1100px. Nav is now 7 top-level entries: Inicio, Clientes, Pólizas, Propiedades, Reportes stay one click away, while the movement screens (Captura, Estado de cuenta, Chequera) and the admin screens (Catálogos, Usuarios, Operaciones) collapse into "Cobranza" and "Admin" dropdowns. Groups are ability-filtered and disappear entirely when the user can see none of their items, so VIEWER never renders an empty Admin menu. activeHref now scans the flattened link list, and a group trigger highlights while one of its children is current. Below 980px the nav collapses to a burger drawer that lists every group expanded, closing on navigation and on Escape. Text size is user-adjustable app-wide. Every font-size in globals.css is converted from px to rem (mechanically, 133 declarations) and the root size becomes calc(100% * var(--ui-scale)), so one variable on <html> rescales the whole UI. The preference persists in localStorage and is applied by a pre-hydration script in layout.tsx to avoid a flash at the default size; the Aa control lives in the appbar and, as a segmented row, in the drawer. Spacing stays in px by design, which is why 1.3 is the largest preset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
UI_SCALES,
|
||||
applyUiScale,
|
||||
readUiScale,
|
||||
saveUiScale,
|
||||
} from "@/lib/ui-scale";
|
||||
|
||||
/**
|
||||
* Text-size picker. Writes --ui-scale on <html>; because every font-size in
|
||||
* globals.css is in rem, that rescales the whole app at once.
|
||||
*
|
||||
* `variant="menu"` is the compact appbar popover; `variant="inline"` is the
|
||||
* flat row used inside the mobile drawer, where a popover inside a popover
|
||||
* would be awkward.
|
||||
*/
|
||||
export function FontScaleControl({
|
||||
variant = "menu",
|
||||
}: {
|
||||
variant?: "menu" | "inline";
|
||||
}) {
|
||||
const [scale, setScale] = useState<number | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Read after mount only: localStorage doesn't exist during SSR, and rendering
|
||||
// a guessed value would mismatch the pre-hydration script's.
|
||||
useEffect(() => {
|
||||
setScale(readUiScale());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function choose(value: number) {
|
||||
setScale(value);
|
||||
applyUiScale(value);
|
||||
saveUiScale(value);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const options = UI_SCALES.map((option) => ({
|
||||
...option,
|
||||
selected: scale !== null && Math.abs(scale - option.value) < 0.001,
|
||||
}));
|
||||
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<div className="scale-inline" role="radiogroup" aria-label="Tamaño de texto">
|
||||
<span className="appbar-drawer-heading">Tamaño de texto</span>
|
||||
<div className="scale-inline-options">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={option.selected}
|
||||
className={`scale-chip${option.selected ? " active" : ""}`}
|
||||
style={{ fontSize: `${option.value}em` }}
|
||||
onClick={() => choose(option.value)}
|
||||
>
|
||||
{option.short}
|
||||
<span className="sr-only"> {option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="appbar-menu" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className="appbar-scale-trigger"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
aria-label="Tamaño de texto"
|
||||
title="Tamaño de texto"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
A<span className="appbar-scale-big">A</span>
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="appbar-dropdown appbar-dropdown-right" role="menu">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={option.selected}
|
||||
className={`appbar-dropdown-link scale-option${option.selected ? " active" : ""}`}
|
||||
onClick={() => choose(option.value)}
|
||||
>
|
||||
<span style={{ fontSize: `${option.value}em` }}>{option.short}</span>
|
||||
<span className="scale-option-label">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user