diff --git a/components/navigation/NavbarWrapper.tsx b/components/navigation/NavbarWrapper.tsx index f1a6f1d9..3d68d162 100644 --- a/components/navigation/NavbarWrapper.tsx +++ b/components/navigation/NavbarWrapper.tsx @@ -1,6 +1,6 @@ "use client"; -import { NO_NAVBAR_PAGES } from "@/lib/navbar"; +import { isNoNavbarPage } from "@/lib/navbar"; import { usePathname } from "next/navigation"; import React from "react"; @@ -13,7 +13,7 @@ export default function NavbarWrapper({ }) { const pathname = usePathname(); - if (NO_NAVBAR_PAGES.includes(pathname)) { + if (isNoNavbarPage(pathname)) { return <>{noNavbarChildren}; } diff --git a/lib/navbar.ts b/lib/navbar.ts index 10a7a0f8..1f28f4a2 100644 --- a/lib/navbar.ts +++ b/lib/navbar.ts @@ -47,6 +47,7 @@ export const NAVBAR_ITEMS: NavbarItem[] = [ { label: "Events", href: "/events", icon: "calendar" }, ]; +// Entries ending in "/" also match every path under them (see matchesPage). export const NO_NAVBAR_PAGES = [ ORGANIZATION_NOT_FOUND_PATH, "/login", @@ -56,4 +57,19 @@ export const NO_NAVBAR_PAGES = [ "/forgotpassword", ]; +/** + * Whether `pathname` is one of `pages`. An entry ending in "/" matches any + * path under it: "/forms/" matches "/forms/abc" but not "/forms" or + * "/formsabc". Other entries match only the exact path. + */ +export function matchesPage(pathname: string, pages: readonly string[]) { + return pages.some((page) => + page.endsWith("/") ? pathname.startsWith(page) : pathname === page, + ); +} + +export function isNoNavbarPage(pathname: string) { + return matchesPage(pathname, NO_NAVBAR_PAGES); +} + export const MEMBERSHIP_REDIRECT_EXCLUDED_PAGES = NO_NAVBAR_PAGES; diff --git a/styles/globals.css b/styles/globals.css index acbbad1e..cd27ebfb 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -99,6 +99,16 @@ transparent ); + /* Theme tokens that will follow each org's theme (#300). They hold today's + values, so using them changes nothing yet. */ + --color-page-bg: #ffffff; + --color-page-text: var(--color-grey-text-strong); + /* Text on brand-colored surfaces, e.g. a primary button's label. */ + --color-brand-foreground: #ffffff; + --font-display: var(--font-heading); + /* Corner radius for inputs and buttons (Tailwind's `rounded`). */ + --radius-control: 0.25rem; + --text-desktop-display: 48px; --text-desktop-heading-1: 40px; --text-desktop-heading-2: 32px; diff --git a/tests/unit/components/navigation/NavbarWrapper.test.tsx b/tests/unit/components/navigation/NavbarWrapper.test.tsx new file mode 100644 index 00000000..faa6ade1 --- /dev/null +++ b/tests/unit/components/navigation/NavbarWrapper.test.tsx @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import NavbarWrapper from "@/components/navigation/NavbarWrapper"; + +const pathname = vi.hoisted(() => ({ current: "/" })); + +vi.mock("next/navigation", () => ({ + usePathname: () => pathname.current, +})); + +// No real entry is a prefix yet (#299 and #301 add them), so add one. +vi.mock("@/lib/navbar", async (importOriginal) => { + const actual = await importOriginal(); + const pages = [...actual.NO_NAVBAR_PAGES, "/forms/"]; + return { + ...actual, + NO_NAVBAR_PAGES: pages, + isNoNavbarPage: (path: string) => actual.matchesPage(path, pages), + }; +}); + +function renderAt(path: string) { + pathname.current = path; + render( + without navbar

}> +

with navbar

+
, + ); +} + +describe("NavbarWrapper", () => { + afterEach(cleanup); + + it("renders the navbar layout on regular pages", () => { + renderAt("/events"); + expect(screen.getByText("with navbar")).toBeTruthy(); + }); + + it("renders without the navbar on excluded pages", () => { + renderAt("/login"); + expect(screen.getByText("without navbar")).toBeTruthy(); + }); + + it("renders without the navbar under a prefix entry", () => { + renderAt("/forms/camp-application-2027"); + expect(screen.getByText("without navbar")).toBeTruthy(); + }); + + it("renders the navbar on a path that only shares the prefix's letters", () => { + renderAt("/formsabc"); + expect(screen.getByText("with navbar")).toBeTruthy(); + }); +}); diff --git a/tests/unit/lib/navbar.test.ts b/tests/unit/lib/navbar.test.ts new file mode 100644 index 00000000..99c54ced --- /dev/null +++ b/tests/unit/lib/navbar.test.ts @@ -0,0 +1,34 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { isNoNavbarPage, matchesPage, NO_NAVBAR_PAGES } from "@/lib/navbar"; + +describe(matchesPage, () => { + const pages = ["/login", "/forms/"]; + + it("matches any path under an entry ending in /", () => { + expect(matchesPage("/forms/abc", pages)).toBe(true); + expect(matchesPage("/forms/camp-application-2027/edit", pages)).toBe(true); + }); + + it("doesn't match a path that only shares the prefix's letters", () => { + expect(matchesPage("/formsabc", pages)).toBe(false); + expect(matchesPage("/forms", pages)).toBe(false); + }); + + it("matches other entries exactly", () => { + expect(matchesPage("/login", pages)).toBe(true); + expect(matchesPage("/login/help", pages)).toBe(false); + expect(matchesPage("/loginx", pages)).toBe(false); + }); +}); + +describe(isNoNavbarPage, () => { + it.each(NO_NAVBAR_PAGES)("hides the navbar on %s", (page) => { + expect(isNoNavbarPage(page)).toBe(true); + }); + + it("shows the navbar elsewhere", () => { + expect(isNoNavbarPage("/")).toBe(false); + expect(isNoNavbarPage("/events")).toBe(false); + }); +});