feat(web): group top nav, add mobile drawer and app-wide text size control
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m37s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m18s

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:
2026-07-27 22:16:30 -07:00
co-authored by Claude Opus 5
parent 7df928c3ab
commit 0bf97e6d2c
5 changed files with 787 additions and 161 deletions
+217 -31
View File
@@ -1,11 +1,12 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import { logout, me } from "@/lib/api";
import { AuthContext, can } from "@/lib/abilities";
import { ROLE_LABEL } from "@/lib/labels";
import { FontScaleControl } from "./FontScaleControl";
import type { AuthUser, Ability } from "@/lib/types";
/**
@@ -14,23 +15,66 @@ import type { AuthUser, Ability } from "@/lib/types";
* content. Provides the AuthContext so any page can read the user's
* abilities. Used by every authenticated page.
*/
const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[] = [
{ href: "/inicio", label: "Inicio", exact: true },
{ href: "/clientes", label: "Clientes" },
{ href: "/servicios", label: "Propiedades" },
{ href: "/polizas", label: "Pólizas" },
{ href: "/estado-cuenta", label: "Estado de cuenta" },
// Daily data-entry screen (the legacy "Editor"), so it earns a top-level
// entry rather than living one click inside the Movimientos tab. Hidden from
// VIEWER, who can't capture anyway — the page itself also refuses.
{ href: "/estado-cuenta/lote", label: "Captura", ability: "ledger:create" },
{ href: "/banco", label: "Chequera" },
{ href: "/reportes", label: "Reportes" },
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
type NavLink = { href: string; label: string; ability?: Ability; exact?: boolean };
type NavEntry =
| ({ kind: "link" } & NavLink)
| { kind: "group"; label: string; items: NavLink[] };
/**
* Top nav. Daily screens stay one click away; the movement screens and the
* admin screens are grouped behind menus so the bar doesn't saturate as the
* app grows. A group disappears entirely when the user can't see any of its
* items (gating here is cosmetic — the API enforces every write).
*/
const NAV: NavEntry[] = [
{ kind: "link", href: "/inicio", label: "Inicio", exact: true },
{ kind: "link", href: "/clientes", label: "Clientes" },
{ kind: "link", href: "/polizas", label: "Pólizas" },
{ kind: "link", href: "/servicios", label: "Propiedades" },
{
kind: "group",
label: "Cobranza",
items: [
// Daily data-entry screen (the legacy "Editor"). Hidden from VIEWER, who
// can't capture anyway — the page itself also refuses.
{ href: "/estado-cuenta/lote", label: "Captura", ability: "ledger:create" },
{ href: "/estado-cuenta", label: "Estado de cuenta" },
{ href: "/banco", label: "Chequera" },
],
},
{ kind: "link", href: "/reportes", label: "Reportes" },
{
kind: "group",
label: "Admin",
items: [
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
],
},
];
/** Every nav destination, flattened out of the groups. */
const NAV_LINKS: NavLink[] = NAV.flatMap((entry) =>
entry.kind === "link" ? [entry] : entry.items,
);
/** The nav the given user may see, with empty groups dropped. */
function visibleNav(user: AuthUser | null): NavEntry[] {
const allowed = (item: NavLink) => !item.ability || can(user, item.ability);
const out: NavEntry[] = [];
for (const entry of NAV) {
if (entry.kind === "link") {
if (allowed(entry)) out.push(entry);
continue;
}
const items = entry.items.filter(allowed);
if (items.length > 0) out.push({ ...entry, items });
}
return out;
}
/**
* Which nav entry is highlighted for a path. Longest matching href wins, so a
* nested route (`/estado-cuenta/lote`) highlights its own entry instead of also
@@ -40,7 +84,7 @@ const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[]
function activeHref(pathname: string | null): string | null {
if (!pathname) return null;
let best: string | null = null;
for (const item of NAV) {
for (const item of NAV_LINKS) {
const match = item.exact
? pathname === item.href
: pathname === item.href || pathname.startsWith(`${item.href}/`);
@@ -51,13 +95,89 @@ function activeHref(pathname: string | null): string | null {
return best;
}
/**
* One collapsible group in the desktop bar. Opens on click, closes on outside
* click, Escape, or navigation. The trigger stays highlighted while any of its
* children is the current page.
*/
function NavMenu({
label,
items,
current,
pathname,
}: {
label: string;
items: NavLink[];
current: string | null;
pathname: string | null;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const holdsCurrent = items.some((item) => item.href === current);
useEffect(() => {
setOpen(false);
}, [pathname]);
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]);
return (
<div className="appbar-menu" ref={ref}>
<button
type="button"
className={`appbar-link appbar-menu-trigger${holdsCurrent ? " active" : ""}`}
aria-expanded={open}
aria-haspopup="true"
onClick={() => setOpen((v) => !v)}
>
{label}
<span className="appbar-caret" aria-hidden="true" />
</button>
{open && (
<div className="appbar-dropdown" role="menu">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
role="menuitem"
className={`appbar-dropdown-link${current === item.href ? " active" : ""}`}
aria-current={current === item.href ? "page" : undefined}
onClick={() => setOpen(false)}
>
{item.label}
</Link>
))}
</div>
)}
</div>
);
}
export function AppShell({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<AuthUser | null>(null);
const [checking, setChecking] = useState(true);
const [loggingOut, setLoggingOut] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const current = activeHref(pathname);
const nav = visibleNav(user);
useEffect(() => {
let alive = true;
@@ -76,6 +196,21 @@ export function AppShell({ children }: { children: ReactNode }) {
};
}, [router]);
// Navigating away closes the mobile drawer — the route change is the only
// "done" signal we get from a <Link>.
useEffect(() => {
setDrawerOpen(false);
}, [pathname]);
useEffect(() => {
if (!drawerOpen) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setDrawerOpen(false);
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [drawerOpen]);
async function handleLogout() {
setLoggingOut(true);
try {
@@ -117,24 +252,30 @@ export function AppShell({ children }: { children: ReactNode }) {
</span>
</Link>
<nav className="appbar-nav" aria-label="Principal">
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
(item) => {
const active = current === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`appbar-link${active ? " active" : ""}`}
aria-current={active ? "page" : undefined}
>
{item.label}
</Link>
);
},
{nav.map((entry) =>
entry.kind === "link" ? (
<Link
key={entry.href}
href={entry.href}
className={`appbar-link${current === entry.href ? " active" : ""}`}
aria-current={current === entry.href ? "page" : undefined}
>
{entry.label}
</Link>
) : (
<NavMenu
key={entry.label}
label={entry.label}
items={entry.items}
current={current}
pathname={pathname}
/>
),
)}
</nav>
<span className="appbar-spacer" />
<div className="appbar-user">
<FontScaleControl />
{user && (
<span className="appbar-user-name">
{user.name}
@@ -150,7 +291,52 @@ export function AppShell({ children }: { children: ReactNode }) {
{loggingOut ? "Saliendo…" : "Cerrar sesión"}
</button>
</div>
<button
type="button"
className="appbar-burger"
aria-label={drawerOpen ? "Cerrar menú" : "Abrir menú"}
aria-expanded={drawerOpen}
onClick={() => setDrawerOpen((v) => !v)}
>
<span className={`burger-icon${drawerOpen ? " open" : ""}`} aria-hidden="true" />
</button>
</div>
{drawerOpen && (
<nav className="appbar-drawer" aria-label="Principal (móvil)">
{nav.map((entry) =>
entry.kind === "link" ? (
<Link
key={entry.href}
href={entry.href}
className={`appbar-drawer-link${current === entry.href ? " active" : ""}`}
aria-current={current === entry.href ? "page" : undefined}
>
{entry.label}
</Link>
) : (
<div key={entry.label} className="appbar-drawer-group">
<span className="appbar-drawer-heading">{entry.label}</span>
{entry.items.map((item) => (
<Link
key={item.href}
href={item.href}
className={`appbar-drawer-link${current === item.href ? " active" : ""}`}
aria-current={current === item.href ? "page" : undefined}
>
{item.label}
</Link>
))}
</div>
),
)}
<FontScaleControl variant="inline" />
{user && (
<div className="appbar-drawer-user">
{user.name} · {ROLE_LABEL[user.role]}
</div>
)}
</nav>
)}
</header>
<main className="shell-main">{children}</main>
</AuthContext.Provider>