Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions web/src/components/BottomNav.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import i18n from "../i18n";
import { navGroups, tabEntries } from "../routes/nav";
import { BottomNav } from "./BottomNav";

// BottomNav is rendered from the SAME nav model AppLayout uses (nav.tsx), so
// build real groups/tabs rather than hand-rolled fixtures — that keeps this
// suite honest about what a real role actually sees (#148, #182 Task 7).
function renderBottomNav(route = "/", onLogout = vi.fn()) {
const groups = navGroups("Admin", true);
const tabs = tabEntries(groups);
render(
<MemoryRouter initialEntries={[route]}>
<BottomNav groups={groups} tabs={tabs} onLogout={onLogout} />
</MemoryRouter>,
);
return { groups, tabs, onLogout };
}

// Both the tab bar and (once opened) the More sheet render a <nav> — scope to
// the landmark under test rather than a bare role query, same reasoning as
// AppLayout.test.tsx.
const tabbar = () => within(screen.getByRole("navigation", { name: "Sections" }));

describe("BottomNav", () => {
it("renders the four highest-priority destinations an admin reaches, plus More", () => {
renderBottomNav();
const tabs = tabbar().getAllByRole("link").map((a) => a.textContent);
expect(tabs).toEqual(["Daily entry", "Stock", "Sales", "History"]);
expect(tabbar().getByRole("button", { name: "More" })).toBeInTheDocument();
});

it("opens the More sheet with the full grouped nav and a way to sign out", () => {
renderBottomNav();
expect(screen.queryByRole("dialog")).toBeNull();

fireEvent.click(tabbar().getByRole("button", { name: "More" }));

const sheet = within(screen.getByRole("dialog"));
expect(sheet.getByRole("heading", { name: "Menu" })).toBeInTheDocument();
// The complete map, including admin-only Setup destinations that are
// never tabs.
expect(sheet.getByRole("link", { name: "Grades" })).toBeInTheDocument();
expect(sheet.getByRole("link", { name: "Export" })).toBeInTheDocument();
expect(sheet.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
expect(
within(screen.getByRole("dialog")).getByRole("navigation", { name: "All sections" }),
).toBeInTheDocument();
});

it("closes the More sheet when a destination link is chosen", () => {
renderBottomNav();
fireEvent.click(tabbar().getByRole("button", { name: "More" }));

fireEvent.click(within(screen.getByRole("dialog")).getByRole("link", { name: "Grades" }));
expect(screen.queryByRole("dialog")).toBeNull();
});

it("calls onLogout and closes the sheet when Sign out is chosen", () => {
const { onLogout } = renderBottomNav();
fireEvent.click(tabbar().getByRole("button", { name: "More" }));

fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Sign out" }));
expect(onLogout).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog")).toBeNull();
});

it("marks More as current when the screen is not one of the tabs", () => {
// /grades is an admin overflow route — no tab points at it, so the bar
// would otherwise show nothing active there.
renderBottomNav("/grades");
const more = tabbar().getByRole("button", { name: "More" });
expect(more).toHaveAttribute("aria-current", "page");
expect(more).toHaveClass("active");
});

it("does not mark More current when a tab owns the screen", () => {
renderBottomNav("/stock");
expect(tabbar().getByRole("button", { name: "More" })).not.toHaveAttribute("aria-current");
});

describe("i18n wiring (#182, Task 7)", () => {
// `nav` is English-only (see translations-status.ts), so the English text
// renders identically whether it comes from the catalog or a leftover
// hardcoded literal — asserting English proves nothing. Each test below
// swaps ONE catalog value at runtime (i18n.addResource, the same
// mechanism i18n.test.ts uses for its fallback test) and asserts the
// swapped MARKER renders, which only happens if the component reads the
// catalog at render time.
function withNavOverride(key: string, value: string, run: () => void) {
const original = i18n.getResource("en", "nav", key) as string;
i18n.addResource("en", "nav", key, value);
try {
run();
} finally {
i18n.addResource("en", "nav", key, original);
}
}

it("reads a tab label from the catalog, not a hardcoded literal", () => {
withNavOverride("stock", "NAV-STOCK-MARKER", () => {
renderBottomNav();
expect(tabbar().getByRole("link", { name: "NAV-STOCK-MARKER" })).toBeInTheDocument();
expect(tabbar().queryByRole("link", { name: "Stock" })).not.toBeInTheDocument();
});
});

it("reads the tab bar's landmark aria-label from the catalog", () => {
withNavOverride("tabBarAriaLabel", "NAV-SECTIONS-MARKER", () => {
renderBottomNav();
expect(screen.getByRole("navigation", { name: "NAV-SECTIONS-MARKER" })).toBeInTheDocument();
});
});

it("reads the More button, the sheet's title, and its landmark from the catalog", () => {
const overrides: [string, string][] = [
["moreButton", "NAV-MORE-MARKER"],
["menuTitle", "NAV-MENU-MARKER"],
["allSectionsAriaLabel", "NAV-ALLSECTIONS-MARKER"],
];
const originals = overrides.map(([key]) => i18n.getResource("en", "nav", key) as string);
for (const [key, value] of overrides) i18n.addResource("en", "nav", key, value);
try {
renderBottomNav();
fireEvent.click(tabbar().getByRole("button", { name: "NAV-MORE-MARKER" }));
const sheet = within(screen.getByRole("dialog"));
expect(sheet.getByRole("heading", { name: "NAV-MENU-MARKER" })).toBeInTheDocument();
expect(
within(screen.getByRole("dialog")).getByRole("navigation", { name: "NAV-ALLSECTIONS-MARKER" }),
).toBeInTheDocument();
} finally {
overrides.forEach(([key], i) => i18n.addResource("en", "nav", key, originals[i]));
}
});

it("reads a group heading (More sheet) and Sign out from the catalog", () => {
withNavOverride("groupSetup", "NAV-GROUPSETUP-MARKER", () => {
withNavOverride("signOut", "NAV-SIGNOUT-MARKER", () => {
renderBottomNav();
fireEvent.click(tabbar().getByRole("button", { name: "More" }));
const sheet = within(screen.getByRole("dialog"));
expect(sheet.getByText("NAV-GROUPSETUP-MARKER")).toBeInTheDocument();
expect(sheet.getByRole("button", { name: "NAV-SIGNOUT-MARKER" })).toBeInTheDocument();
});
});
});
});
});
20 changes: 11 additions & 9 deletions web/src/components/BottomNav.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { NavLink, useLocation } from "react-router";
import { LogOut, Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Dialog } from "./Dialog";
import { ThemeToggle } from "./ThemeToggle";
import type { NavEntry, NavGroup } from "../routes/nav";
Expand All @@ -20,6 +21,7 @@ export function BottomNav({
tabs: NavEntry[];
onLogout: () => void;
}) {
const { t } = useTranslation("nav");
const [moreOpen, setMoreOpen] = useState(false);
const { pathname } = useLocation();

Expand All @@ -43,11 +45,11 @@ export function BottomNav({

return (
<>
<nav className="tabbar" aria-label="Sections">
<nav className="tabbar" aria-label={t("tabBarAriaLabel")}>
{tabs.map((e) => (
<NavLink key={e.to} to={e.to} end={e.end} className="tab">
<e.Icon size={ICON} aria-hidden />
<span>{e.label}</span>
<span>{t(e.labelKey)}</span>
</NavLink>
))}
<button
Expand All @@ -59,23 +61,23 @@ export function BottomNav({
onClick={() => setMoreOpen(true)}
>
<Menu size={ICON} aria-hidden />
<span>More</span>
<span>{t("moreButton")}</span>
</button>
</nav>

{/* The full map, so nothing is unreachable — the tabs are shortcuts into
it, not a smaller menu. Dialog gives the focus trap, scroll lock and
bottom-sheet styling for free (#131). */}
<Dialog open={moreOpen} title="Menu" onClose={() => setMoreOpen(false)}>
<nav className="more-nav" aria-label="All sections">
<Dialog open={moreOpen} title={t("menuTitle")} onClose={() => setMoreOpen(false)}>
<nav className="more-nav" aria-label={t("allSectionsAriaLabel")}>
{groups.map((g) => (
<div className="more-group" key={g.label}>
<p className="more-group-label">{g.label}</p>
<div className="more-group" key={g.labelKey}>
<p className="more-group-label">{t(g.labelKey)}</p>
{g.entries.map((e) => (
<NavLink key={e.to} to={e.to} end={e.end}
onClick={() => setMoreOpen(false)}>
<e.Icon size={ICON} aria-hidden />
<span>{e.label}</span>
<span>{t(e.labelKey)}</span>
</NavLink>
))}
</div>
Expand All @@ -84,7 +86,7 @@ export function BottomNav({
<div className="more-foot">
<ThemeToggle iconSize={ICON} />
<button className="link" onClick={() => { setMoreOpen(false); onLogout(); }}>
<LogOut size={ICON} aria-hidden /><span>Sign out</span>
<LogOut size={ICON} aria-hidden /><span>{t("signOut")}</span>
</button>
</div>
</Dialog>
Expand Down
33 changes: 33 additions & 0 deletions web/src/components/Dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { Dialog } from "./Dialog";
import i18n from "../i18n";

function Body() {
return (
Expand Down Expand Up @@ -254,3 +255,35 @@ describe("Dialog focus trap skips controls the browser would not tab to", () =>
expect(screen.getByRole("button", { name: "Close" })).toHaveFocus();
});
});

// ---------------------------------------------------------------------------
// i18n wiring (#182, Task 8, batch B1)
// ---------------------------------------------------------------------------

// `common` is a TRANSLATED namespace (see translations-status.ts), so asserting
// "Close" under the default lng:"en" would pass even if the label were still a
// hardcoded literal (CONTRIBUTING-i18n.md's fallback trap). Swapping the
// catalog value at runtime — the same i18n.addResource technique AppLayout's
// Task 7 wiring tests use — only renders the marker if Dialog actually reads
// the catalog.
describe("Dialog i18n wiring (#182, Task 8)", () => {
async function withCommonOverride(key: string, value: string, run: () => Promise<void>) {
const original = i18n.getResource("en", "common", key) as string;
i18n.addResource("en", "common", key, value);
try {
await run();
} finally {
i18n.addResource("en", "common", key, original);
}
}

it("reads the close button's accessible name from the catalog, not a hardcoded literal", async () => {
const user = userEvent.setup();
await withCommonOverride("close", "CLOSE-MARKER", async () => {
render(<Host />);
await user.click(screen.getByRole("button", { name: "New grade" }));
expect(screen.getByRole("button", { name: "CLOSE-MARKER" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument();
});
});
});
4 changes: 3 additions & 1 deletion web/src/components/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";

// Everything the browser lets you tab to, minus the things that only LOOK
Expand Down Expand Up @@ -63,6 +64,7 @@ interface DialogProps {
// Portalled to <body> so the backdrop covers the whole viewport regardless of
// where it is mounted in the shell grid.
export function Dialog({ open, title, onClose, focusKey, describedBy, children }: DialogProps) {
const { t } = useTranslation("common");
const panelRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const returnFocusTo = useRef<Element | null>(null);
Expand Down Expand Up @@ -172,7 +174,7 @@ export function Dialog({ open, title, onClose, focusKey, describedBy, children }
>
<div className="dialog-head">
<h3 id={titleId}>{title}</h3>
<button type="button" className="link dialog-close" aria-label="Close" onClick={onClose}>
<button type="button" className="link dialog-close" aria-label={t("close")} onClick={onClose}>
<X size={18} aria-hidden />
</button>
</div>
Expand Down
91 changes: 91 additions & 0 deletions web/src/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Link, MemoryRouter, useLocation } from "react-router";
import { ErrorBoundary } from "./ErrorBoundary";
import i18n from "../i18n";

// A child that throws during render — the exact case a boundary exists for.
function Boom({ message = "kaboom" }: { message?: string }): never {
Expand Down Expand Up @@ -216,3 +217,93 @@ describe("ErrorBoundary", () => {
});
});
});

// ---------------------------------------------------------------------------
// i18n wiring (#182, Task 8, batch B1)
// ---------------------------------------------------------------------------

// `errorBoundary` is English-only (not in TRANSLATED_NAMESPACES — see
// translations-status.ts), so under ANY UI language the fallback text is
// identical to what a still-hardcoded literal would render — asserting it,
// even under a non-English locale, would prove nothing (CONTRIBUTING-i18n.md's
// fallback trap). Swap the catalog value at runtime instead, the same
// i18n.addResource technique AppLayout's Task 7 nav wiring tests use, so each
// marker only renders if ErrorFallback actually reads the catalog.
describe("ErrorBoundary i18n wiring (#182, Task 8)", () => {
function withOverride(key: string, value: string, run: () => void) {
const original = i18n.getResource("en", "errorBoundary", key) as string;
i18n.addResource("en", "errorBoundary", key, value);
try {
run();
} finally {
i18n.addResource("en", "errorBoundary", key, original);
}
}

it("reads the fallback heading from the catalog, not a hardcoded literal", () => {
withOverride("title", "TITLE-MARKER", () => {
inRouter(
<ErrorBoundary scope="screen">
<Boom />
</ErrorBoundary>,
);
expect(screen.getByText("TITLE-MARKER")).toBeInTheDocument();
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
});
});

it("reads the screen-scope body text from the catalog", () => {
withOverride("screenBody", "SCREEN-BODY-MARKER", () => {
inRouter(
<ErrorBoundary scope="screen">
<Boom />
</ErrorBoundary>,
);
expect(screen.getByText("SCREEN-BODY-MARKER")).toBeInTheDocument();
});
});

it("reads the app-scope body text from the catalog", () => {
withOverride("appBody", "APP-BODY-MARKER", () => {
render(
<ErrorBoundary scope="app">
<Boom />
</ErrorBoundary>,
);
expect(screen.getByText("APP-BODY-MARKER")).toBeInTheDocument();
});
});

it("reads the reload button's label from the catalog", () => {
withOverride("reload", "RELOAD-MARKER", () => {
inRouter(
<ErrorBoundary scope="screen">
<Boom />
</ErrorBoundary>,
);
expect(screen.getByRole("button", { name: "RELOAD-MARKER" })).toBeInTheDocument();
});
});

it("reads the back-to-dashboard link text from the catalog", () => {
withOverride("backToDashboard", "BACK-MARKER", () => {
inRouter(
<ErrorBoundary scope="screen">
<Boom />
</ErrorBoundary>,
);
expect(screen.getByRole("link", { name: "BACK-MARKER" })).toBeInTheDocument();
});
});

it("reads the error-details summary from the catalog", () => {
withOverride("detailsSummary", "DETAILS-MARKER", () => {
inRouter(
<ErrorBoundary scope="screen">
<Boom />
</ErrorBoundary>,
);
expect(screen.getByText("DETAILS-MARKER")).toBeInTheDocument();
});
});
});
Loading