Menu
+ {/* The full nav is taller than a short phone, so the links scroll
+ under the title and close button, which stay put. */}
{navItems.map((item) =>
item.children && item.children.length > 0 ? (
@@ -99,7 +113,8 @@ export function HeaderMobileMenu({
style={{ gap: '8px', paddingLeft: '16px' }}
>
{item.children.map((child) => {
- if (linkComponent) {
+ const childExternal = child.external ?? item.external;
+ if (linkComponent && !childExternal) {
const LinkComp = linkComponent;
return (
{
- e.preventDefault();
- handleNavigate(child.href);
- }}
+ onClick={
+ childExternal
+ ? onClose
+ : (e) => {
+ e.preventDefault();
+ handleNavigate(child.href);
+ }
+ }
style={{
color: 'var(--text-inverse)',
textDecoration: 'none',
@@ -143,7 +162,7 @@ export function HeaderMobileMenu({
) : (
(() => {
- if (linkComponent) {
+ if (linkComponent && !item.external) {
const LinkComp = linkComponent;
return (
{
- e.preventDefault();
- handleNavigate(item.href);
- }
- : undefined
+ item.external
+ ? onClose
+ : onNavigate
+ ? (e: React.MouseEvent) => {
+ e.preventDefault();
+ handleNavigate(item.href);
+ }
+ : undefined
}
style={{
color: 'var(--text-inverse)',
diff --git a/src/layout/header/HeaderNavItem.tsx b/src/layout/header/HeaderNavItem.tsx
index ae112af..54a80e0 100644
--- a/src/layout/header/HeaderNavItem.tsx
+++ b/src/layout/header/HeaderNavItem.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import type { NavItemConfig } from './Header';
@@ -29,6 +29,9 @@ const hoverHandlers = {
},
};
+/** The closest the dropdown panel comes to either side of the viewport. */
+const VIEWPORT_MARGIN = 16;
+
const dropdownItemStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
@@ -67,8 +70,47 @@ function AppleDropdown({
linkComponent?: React.ElementType;
}) {
const contentRef = useRef(null);
+ const panelRef = useRef(null);
const [contentHeight, setContentHeight] = useState(0);
const [visible, setVisible] = useState(false);
+ // How far to move the panel sideways from centred under its trigger, in
+ // the panel's own CSS pixels.
+ const [shift, setShift] = useState(0);
+
+ // A panel wider than its trigger, under an item near the edge, would run
+ // off the viewport and add a horizontal scrollbar, so keep it on screen,
+ // placing it again whenever the window resizes or scrolls while it's open.
+ useLayoutEffect(() => {
+ const panel = panelRef.current;
+ const trigger = panel?.parentElement;
+ if (!open || !panel || !trigger) return;
+ function place() {
+ const { width } = panel!.getBoundingClientRect();
+ if (width === 0) return; // Not laid out.
+ // Where the panel sits centred under its trigger, whatever its shift.
+ const box = trigger!.getBoundingClientRect();
+ const left = (box.left + box.right - width) / 2;
+ const right = left + width;
+ const viewportWidth = document.documentElement.clientWidth;
+ let next = 0;
+ if (right > viewportWidth - VIEWPORT_MARGIN) {
+ next = viewportWidth - VIEWPORT_MARGIN - right;
+ }
+ if (left + next < VIEWPORT_MARGIN) {
+ next = VIEWPORT_MARGIN - left;
+ }
+ // Under a scaled ancestor, the panel's CSS pixels aren't the viewport's.
+ const scale = panel!.offsetWidth > 0 ? width / panel!.offsetWidth : 1;
+ setShift(next / scale);
+ }
+ place();
+ window.addEventListener('resize', place);
+ window.addEventListener('scroll', place, { capture: true, passive: true });
+ return () => {
+ window.removeEventListener('resize', place);
+ window.removeEventListener('scroll', place, { capture: true });
+ };
+ }, [open]);
useEffect(() => {
if (open && contentRef.current) {
@@ -100,15 +142,23 @@ function AppleDropdown({
/>
{/* Dropdown panel */}
) => {
onClose();
- if (onNavigate) {
+ if (onNavigate && !item.external) {
e.preventDefault();
onNavigate(item.href);
}
};
- if (linkComponent) {
+ if (linkComponent && !item.external) {
const LinkComp = linkComponent;
return (
(null);
@@ -256,7 +306,10 @@ export function HeaderNavItem({
/>
({
+ ...child,
+ external: child.external ?? external,
+ }))}
open={dropdownOpen}
onClose={() => setDropdownOpen(false)}
onNavigate={onNavigate}
@@ -267,7 +320,7 @@ export function HeaderNavItem({
}
// Simple link item
- if (linkComponent) {
+ if (linkComponent && !external) {
const LinkComp = linkComponent;
return (
{
e.preventDefault();
onNavigate(href);
diff --git a/src/layout/header/index.ts b/src/layout/header/index.ts
index a14f46c..0aed440 100644
--- a/src/layout/header/index.ts
+++ b/src/layout/header/index.ts
@@ -1,5 +1,10 @@
export { Header } from './Header';
-export type { HeaderProps, NavItemConfig, CountryConfig } from './Header';
+export type {
+ HeaderProps,
+ NavItemConfig,
+ NavItemChildConfig,
+ CountryConfig,
+} from './Header';
export { HeaderContent } from './HeaderContent';
export { HeaderLogo } from './HeaderLogo';
export { HeaderNav } from './HeaderNav';
diff --git a/src/layout/index.ts b/src/layout/index.ts
index 3cffa2e..0ba9eac 100644
--- a/src/layout/index.ts
+++ b/src/layout/index.ts
@@ -24,17 +24,27 @@ export {
} from './PolicyEngineShell';
export {
DEFAULT_POLICYENGINE_BASE_URL,
+ getPolicyEngineAppUrl,
getPolicyEngineCountrySwitchUrl,
getPolicyEngineCountryUrl,
getPolicyEngineFooterLinks,
getPolicyEngineNavItems,
+ getPolicyEngineTools,
getPolicyEngineUrl,
policyEngineCountries,
type PolicyEngineCountryId,
+ type PolicyEngineToolLink,
} from './PolicyEngineSiteConfig';
+export {
+ DEFAULT_POLICYENGINE_APP_URL,
+ POLICYENGINE_APP_LABEL,
+ policyEngineTools,
+ type PolicyEngineTool,
+} from './PolicyEngineTools';
export {
Header,
type HeaderProps,
type NavItemConfig,
+ type NavItemChildConfig,
type CountryConfig,
} from './header';
diff --git a/src/primitives/Sheet.tsx b/src/primitives/Sheet.tsx
index bdb976e..a494ea3 100644
--- a/src/primitives/Sheet.tsx
+++ b/src/primitives/Sheet.tsx
@@ -40,14 +40,20 @@ function SheetContent({
children,
side = 'right',
showCloseButton = true,
+ overlayClassName,
+ overlayStyle,
...props
}: React.ComponentProps & {
side?: 'top' | 'right' | 'bottom' | 'left';
showCloseButton?: boolean;
+ /** Class names for the dimmed overlay behind the sheet. */
+ overlayClassName?: string;
+ /** Style overrides for the dimmed overlay behind the sheet. */
+ overlayStyle?: React.CSSProperties;
}) {
return (
-
+
[0]["appUrl"];
+ shellAppUrlProp: Parameters[0]["appUrl"];
+ toolLink: PolicyEngineToolLink | undefined;
+ sheetOverlayStyle: Parameters[0]["overlayStyle"];
+} = {
+ external: true,
+ row: { label: "CliffWatch", href: "/us/cliffwatch", external: true },
+ tool: policyEngineTools.us?.[0],
+ mainApp: {
+ label: POLICYENGINE_APP_LABEL,
+ href: `${DEFAULT_POLICYENGINE_APP_URL}/us`,
+ external: true,
+ },
+ appUrlProp: DEFAULT_POLICYENGINE_APP_URL,
+ shellAppUrlProp: DEFAULT_POLICYENGINE_APP_URL,
+ toolLink: getPolicyEngineTools("uk")[0],
+ sheetOverlayStyle: { zIndex: 1001 },
+};
+
// Touch each binding so unused-import settings don't strip them.
export type _SmokeTest =
| typeof Accordion
@@ -142,6 +181,10 @@ export type _SmokeTest =
| typeof Footer
| typeof Stack
| typeof Group
+ | typeof PolicyEngineHeader
+ | typeof getPolicyEngineNavItems
+ | typeof getPolicyEngineTools
+ | typeof getPolicyEngineAppUrl
| typeof CurrencyInput
| typeof NumberInput
| typeof DataTable
diff --git a/tests/layout/PolicyEngineSiteConfig.test.ts b/tests/layout/PolicyEngineSiteConfig.test.ts
new file mode 100644
index 0000000..72ac79a
--- /dev/null
+++ b/tests/layout/PolicyEngineSiteConfig.test.ts
@@ -0,0 +1,364 @@
+import { describe, expect, it } from 'vitest';
+import fc from 'fast-check';
+import type { NavItemConfig } from '../../src/layout/header';
+import {
+ DEFAULT_POLICYENGINE_APP_URL,
+ POLICYENGINE_APP_LABEL,
+ policyEngineTools,
+} from '../../src/layout/PolicyEngineTools';
+import {
+ DEFAULT_POLICYENGINE_BASE_URL,
+ getPolicyEngineAppUrl,
+ getPolicyEngineNavItems,
+ getPolicyEngineTools,
+ getPolicyEngineUrl,
+} from '../../src/layout/PolicyEngineSiteConfig';
+
+/**
+ * The nav as it was in 0.12.1, before the Tools menu. Adding Tools must leave
+ * every other item, and its position relative to the others, unchanged.
+ */
+function navItemsWithoutTools(country: string, baseUrl: string): NavItemConfig[] {
+ const navItems: NavItemConfig[] = [
+ { label: 'Research', href: getPolicyEngineUrl(country, 'research', baseUrl) },
+ { label: 'Model', href: getPolicyEngineUrl(country, 'model', baseUrl) },
+ { label: 'API', href: getPolicyEngineUrl(country, 'api', baseUrl) },
+ {
+ label: 'About',
+ href: getPolicyEngineUrl(country, 'team', baseUrl),
+ children: [
+ {
+ label: 'Team',
+ href: getPolicyEngineUrl(country, 'team', baseUrl),
+ description: 'Meet the PolicyEngine team',
+ },
+ {
+ label: 'Supporters',
+ href: getPolicyEngineUrl(country, 'supporters', baseUrl),
+ description: 'Organizations supporting PolicyEngine',
+ },
+ {
+ label: 'Citations',
+ href: getPolicyEngineUrl(country, 'citations', baseUrl),
+ description: 'How to cite PolicyEngine',
+ },
+ ],
+ },
+ { label: 'Donate', href: getPolicyEngineUrl(country, 'donate', baseUrl) },
+ ];
+ if (country === 'us') {
+ navItems.splice(3, 0, {
+ label: 'Python',
+ href: getPolicyEngineUrl(country, 'python', baseUrl),
+ });
+ }
+ return navItems;
+}
+
+function toolsItem(navItems: NavItemConfig[]) {
+ return navItems.find((item) => item.label === 'Tools');
+}
+
+/**
+ * Invariants, for every country C, base URL B and app URL A:
+ * 1. The nav has a Tools item exactly when C has tools (the US and the UK),
+ * once, right after Research.
+ * 2. Every other item is as it was in 0.12.1.
+ * 3. Tools lists the main app at `/C` first, then exactly C's tools in
+ * label order, each at `/C/`. Every row is a full page load.
+ * 4. The OBBBA explorer and the bill tracker are research, not tools, and
+ * the UK menu never links to `/uk/cliffwatch`, the US app's path.
+ * 5. Calls are deterministic and return fresh objects; the shared data is
+ * frozen.
+ */
+const countries = fc.oneof(
+ fc.constantFrom('us', 'uk'),
+ // Other countries, including keys every object inherits.
+ fc.constantFrom('ca', 'ng', 'il', '', 'US', 'constructor', '__proto__', 'toString'),
+ fc.string(),
+);
+
+// Origins as consumers pass them: scheme, host, optional port, any number of
+// trailing slashes (which the site config strips).
+const origins = fc
+ .record({
+ scheme: fc.constantFrom('http', 'https'),
+ host: fc.domain(),
+ port: fc.option(fc.integer({ min: 1, max: 65535 }), { nil: undefined }),
+ slashes: fc.integer({ min: 0, max: 3 }),
+ })
+ .map(
+ ({ scheme, host, port, slashes }) =>
+ `${scheme}://${host}${port === undefined ? '' : `:${port}`}${'/'.repeat(slashes)}`,
+ );
+
+const US_TOOLS = [
+ ['CliffWatch', 'cliffwatch'],
+ ['Coverage Compass', 'coverage-compass'],
+ ['GiveCalc', 'givecalc'],
+ ['Marriage calculator', 'marriage'],
+ ['QBI deduction calculator', 'qbi-calculator'],
+ ['SPM threshold calculator', 'spm-calculator'],
+ ['TANF calculator', 'tanf-calculator'],
+];
+
+const UK_TOOLS = [
+ ['Marriage calculator', 'marriage'],
+ ['Student loan calculator', 'uk-student-loan-calculator'],
+ ['UK CliffWatch', 'uk-cliff-watch'],
+];
+
+function expectedToolsItem(country: string, tools: string[][]): NavItemConfig {
+ const mainApp = `https://app.policyengine.org/${country}`;
+ return {
+ label: 'Tools',
+ href: mainApp,
+ external: true,
+ children: [
+ { label: 'PolicyEngine app', href: mainApp, external: true },
+ ...tools.map(([label, slug]) => ({
+ label,
+ href: `https://policyengine.org/${country}/${slug}`,
+ external: true,
+ })),
+ ],
+ };
+}
+
+describe('PolicyEngine Tools menu', () => {
+ it('lists the main app, then the US tools by label, right after Research', () => {
+ const navItems = getPolicyEngineNavItems('us');
+
+ expect(navItems.map((item) => item.label)).toEqual([
+ 'Research',
+ 'Tools',
+ 'Model',
+ 'API',
+ 'Python',
+ 'About',
+ 'Donate',
+ ]);
+ expect(toolsItem(navItems)).toEqual(expectedToolsItem('us', US_TOOLS));
+ });
+
+ it('lists the main app, then the UK tools by label, with UK CliffWatch at /uk/uk-cliff-watch', () => {
+ const navItems = getPolicyEngineNavItems('uk');
+
+ expect(navItems.map((item) => item.label)).toEqual([
+ 'Research',
+ 'Tools',
+ 'Model',
+ 'API',
+ 'About',
+ 'Donate',
+ ]);
+ expect(toolsItem(navItems)).toEqual(expectedToolsItem('uk', UK_TOOLS));
+ });
+
+ it.each(['ca', 'ng', 'il', 'US', '', 'constructor', '__proto__'])(
+ 'has no Tools menu for country %j',
+ (country) => {
+ expect(toolsItem(getPolicyEngineNavItems(country))).toBeUndefined();
+ expect(getPolicyEngineNavItems(country)).toEqual(
+ navItemsWithoutTools(country, DEFAULT_POLICYENGINE_BASE_URL),
+ );
+ expect(getPolicyEngineTools(country)).toEqual([]);
+ },
+ );
+
+ it('builds links on a custom base URL and app URL', () => {
+ expect(
+ getPolicyEngineTools('uk', 'https://staging.policyengine.org//')[2],
+ ).toEqual({
+ slug: 'uk-cliff-watch',
+ label: 'UK CliffWatch',
+ href: 'https://staging.policyengine.org/uk/uk-cliff-watch',
+ });
+ expect(getPolicyEngineAppUrl('uk', 'https://app.staging.policyengine.org/')).toBe(
+ 'https://app.staging.policyengine.org/uk',
+ );
+ const tools = toolsItem(
+ getPolicyEngineNavItems(
+ 'us',
+ 'https://staging.policyengine.org',
+ 'https://app.staging.policyengine.org',
+ ),
+ );
+ expect(tools?.href).toBe('https://app.staging.policyengine.org/us');
+ expect(tools?.children?.[0]).toEqual({
+ label: 'PolicyEngine app',
+ href: 'https://app.staging.policyengine.org/us',
+ external: true,
+ });
+ expect(tools?.children?.[1].href).toBe(
+ 'https://staging.policyengine.org/us/cliffwatch',
+ );
+ });
+
+ it('defaults to the US on policyengine.org and app.policyengine.org', () => {
+ expect(getPolicyEngineTools()).toEqual(
+ getPolicyEngineTools('us', DEFAULT_POLICYENGINE_BASE_URL),
+ );
+ expect(getPolicyEngineAppUrl()).toBe('https://app.policyengine.org/us');
+ expect(DEFAULT_POLICYENGINE_APP_URL).toBe('https://app.policyengine.org');
+ expect(POLICYENGINE_APP_LABEL).toBe('PolicyEngine app');
+ expect(getPolicyEngineNavItems('uk')).toEqual(
+ getPolicyEngineNavItems(
+ 'uk',
+ DEFAULT_POLICYENGINE_BASE_URL,
+ DEFAULT_POLICYENGINE_APP_URL,
+ ),
+ );
+ });
+
+ it('freezes the shared data so one header cannot change another', () => {
+ expect(Object.isFrozen(policyEngineTools)).toBe(true);
+ for (const tools of Object.values(policyEngineTools)) {
+ expect(Object.isFrozen(tools)).toBe(true);
+ for (const tool of tools ?? []) {
+ expect(Object.isFrozen(tool)).toBe(true);
+ }
+ }
+ });
+
+ it('keeps every country’s tools well formed and sorted by label', () => {
+ expect(Object.keys(policyEngineTools).sort()).toEqual(['uk', 'us']);
+ for (const [country, tools = []] of Object.entries(policyEngineTools)) {
+ expect(tools.length, country).toBeGreaterThan(0);
+ for (const tool of tools) {
+ expect(tool.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
+ expect(tool.label.trim()).not.toBe('');
+ expect(tool.label).not.toBe(POLICYENGINE_APP_LABEL);
+ }
+ expect(new Set(tools.map((tool) => tool.slug)).size).toBe(tools.length);
+ for (let i = 1; i < tools.length; i++) {
+ expect(
+ tools[i - 1].label.localeCompare(tools[i].label, 'en'),
+ `${country}: ${tools[i - 1].label} before ${tools[i].label}`,
+ ).toBeLessThan(0);
+ }
+ }
+ });
+});
+
+describe('PolicyEngine Tools menu properties', () => {
+ it('appears only for a country with tools, and only right after Research', () => {
+ fc.assert(
+ fc.property(countries, origins, origins, (country, baseUrl, appUrl) => {
+ const navItems = getPolicyEngineNavItems(country, baseUrl, appUrl);
+ const hasTools = country === 'us' || country === 'uk';
+
+ expect(navItems.filter((item) => item.label === 'Tools')).toHaveLength(
+ hasTools ? 1 : 0,
+ );
+ expect(navItems[0].label).toBe('Research');
+ if (hasTools) {
+ expect(navItems[1].label).toBe('Tools');
+ }
+ }),
+ );
+ });
+
+ it('leaves every other nav item as it was before Tools', () => {
+ fc.assert(
+ fc.property(countries, origins, origins, (country, baseUrl, appUrl) => {
+ expect(
+ getPolicyEngineNavItems(country, baseUrl, appUrl).filter(
+ (item) => item.label !== 'Tools',
+ ),
+ ).toEqual(navItemsWithoutTools(country, baseUrl));
+ }),
+ );
+ });
+
+ it('lists the main app, then exactly the country’s tools by label, each a full page load', () => {
+ fc.assert(
+ fc.property(
+ fc.constantFrom('us', 'uk'),
+ origins,
+ origins,
+ (country, baseUrl, appUrl) => {
+ const base = baseUrl.replace(/\/+$/, '');
+ const app = appUrl.replace(/\/+$/, '');
+ const tools = toolsItem(getPolicyEngineNavItems(country, baseUrl, appUrl));
+ const [mainApp, ...rows] = tools?.children ?? [];
+ const expectedTools = policyEngineTools[country]!;
+
+ expect(tools?.external).toBe(true);
+ expect(mainApp).toEqual({
+ label: 'PolicyEngine app',
+ href: `${app}/${country}`,
+ external: true,
+ });
+ expect(tools?.href).toBe(mainApp.href);
+ expect(rows.map((row) => row.label)).toEqual(
+ expectedTools.map((tool) => tool.label),
+ );
+ rows.forEach((row, i) => {
+ expect(row.external).toBe(true);
+ expect(row.href).toBe(`${base}/${country}/${expectedTools[i].slug}`);
+ const url = new URL(row.href);
+ expect(url.origin).toBe(new URL(base).origin);
+ expect(url.pathname).toBe(`/${country}/${expectedTools[i].slug}`);
+ expect(url.search).toBe('');
+ if (i > 0) {
+ expect(rows[i - 1].label.localeCompare(row.label, 'en')).toBeLessThan(0);
+ }
+ });
+ const labels = tools!.children!.map((row) => row.label);
+ expect(new Set(labels).size).toBe(labels.length);
+ expect(new Set(rows.map((row) => row.href)).size).toBe(rows.length);
+ },
+ ),
+ );
+ });
+
+ it('leaves out research and never sends the UK menu to /uk/cliffwatch', () => {
+ fc.assert(
+ fc.property(countries, origins, (country, baseUrl) => {
+ const paths = (
+ toolsItem(getPolicyEngineNavItems(country, baseUrl))?.children ?? []
+ )
+ .slice(1)
+ .map((row) => new URL(row.href).pathname);
+
+ for (const research of ['obbba-households', 'bill-tracker']) {
+ expect(paths).not.toContain(`/${country}/${research}`);
+ }
+ expect(paths.some((path) => /^\/uk\/cliffwatch(\/|$)/.test(path))).toBe(
+ false,
+ );
+ if (country === 'uk') {
+ expect(paths).toContain('/uk/uk-cliff-watch');
+ }
+ }),
+ );
+ });
+
+ it('returns fresh, equal results on every call', () => {
+ fc.assert(
+ fc.property(countries, origins, origins, (country, baseUrl, appUrl) => {
+ const first = getPolicyEngineTools(country, baseUrl);
+ const second = getPolicyEngineTools(country, baseUrl);
+ expect(second).toEqual(first);
+
+ for (const tool of first) {
+ tool.slug = 'mutated';
+ tool.href = 'mutated';
+ }
+ expect(getPolicyEngineTools(country, baseUrl)).toEqual(second);
+
+ const navItems = getPolicyEngineNavItems(country, baseUrl, appUrl);
+ const expected = getPolicyEngineNavItems(country, baseUrl, appUrl);
+ expect(navItems).toEqual(expected);
+ for (const item of navItems) {
+ item.href = 'mutated';
+ for (const row of item.children ?? []) {
+ row.href = 'mutated';
+ }
+ }
+ expect(getPolicyEngineNavItems(country, baseUrl, appUrl)).toEqual(expected);
+ }),
+ );
+ });
+});
diff --git a/tests/layout/PolicyEngineToolsNav.test.tsx b/tests/layout/PolicyEngineToolsNav.test.tsx
new file mode 100644
index 0000000..c60c471
--- /dev/null
+++ b/tests/layout/PolicyEngineToolsNav.test.tsx
@@ -0,0 +1,643 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Header } from '../../src/layout/header';
+import { PolicyEngineHeader } from '../../src/layout/PolicyEngineHeader';
+import { PolicyEngineShell } from '../../src/layout/PolicyEngineShell';
+
+/** A client-side link: marks what it renders so tests can tell it apart. */
+function ClientLink({
+ children,
+ href,
+ to: _to,
+ ...props
+}: React.AnchorHTMLAttributes & { href: string; to?: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// Records whether the header cancelled each click, then cancels it so jsdom
+// doesn't try to navigate. Listeners on document run after React's handlers.
+let clickWasPrevented: boolean[] = [];
+function recordClick(event: MouseEvent) {
+ if ((event.target as Element | null)?.closest('a')) {
+ clickWasPrevented.push(event.defaultPrevented);
+ event.preventDefault();
+ }
+}
+
+beforeEach(() => {
+ clickWasPrevented = [];
+ document.addEventListener('click', recordClick);
+});
+
+afterEach(() => {
+ document.removeEventListener('click', recordClick);
+});
+
+async function openDesktopMenu(label: string) {
+ await userEvent.click(screen.getByRole('button', { name: label }));
+}
+
+async function openMobileMenu() {
+ await userEvent.click(screen.getByRole('button', { name: 'Toggle navigation' }));
+ return screen.getByRole('dialog');
+}
+
+/** Each link in a mobile menu group as [text, href, routed by the app]. */
+function mobileGroupRows(menu: HTMLElement, label: string) {
+ const rows = within(menu).getByText(label).nextElementSibling as HTMLElement;
+ return within(rows)
+ .getAllByRole('link')
+ .map((link) => [
+ link.textContent,
+ link.getAttribute('href'),
+ link.hasAttribute('data-client-link'),
+ ]);
+}
+
+const US_ROWS = [
+ ['PolicyEngine app', 'https://app.policyengine.org/us'],
+ ['CliffWatch', 'https://policyengine.org/us/cliffwatch'],
+ ['Coverage Compass', 'https://policyengine.org/us/coverage-compass'],
+ ['GiveCalc', 'https://policyengine.org/us/givecalc'],
+ ['Marriage calculator', 'https://policyengine.org/us/marriage'],
+ ['QBI deduction calculator', 'https://policyengine.org/us/qbi-calculator'],
+ ['SPM threshold calculator', 'https://policyengine.org/us/spm-calculator'],
+ ['TANF calculator', 'https://policyengine.org/us/tanf-calculator'],
+];
+
+describe('PolicyEngineHeader Tools menu', () => {
+ it('opens the main app and the US tools as plain links even with a router link and onNavigate', async () => {
+ const onNavigate = vi.fn();
+ render(
+ ,
+ );
+
+ await openDesktopMenu('Tools');
+ const panel = screen
+ .getByRole('link', { name: 'CliffWatch' })
+ .closest('div[style*="position: absolute"]')!;
+ expect(
+ within(panel)
+ .getAllByRole('link')
+ .map((link) => [link.textContent, link.getAttribute('href')]),
+ ).toEqual(US_ROWS);
+ for (const link of within(panel).getAllByRole('link')) {
+ expect(link).not.toHaveAttribute('data-client-link');
+ }
+
+ await userEvent.click(screen.getByRole('link', { name: 'QBI deduction calculator' }));
+ expect(onNavigate).not.toHaveBeenCalled();
+ expect(clickWasPrevented).toEqual([false]);
+ // The click also closes the dropdown, for a browser that keeps the page
+ // (a middle click, or a click the browser refuses to follow).
+ await waitFor(() =>
+ expect(screen.queryByRole('link', { name: 'PolicyEngine app' })).toBeNull(),
+ );
+ });
+
+ it('still routes the other dropdowns through the link component and onNavigate', async () => {
+ const onNavigate = vi.fn();
+ render(
+ ,
+ );
+
+ await openDesktopMenu('About');
+ const team = screen.getByRole('link', { name: /Team/ });
+ expect(team).toHaveAttribute('data-client-link');
+
+ await userEvent.click(team);
+ expect(onNavigate).toHaveBeenCalledWith('https://policyengine.org/us/team');
+ expect(clickWasPrevented).toEqual([true]);
+ });
+
+ it('sizes the dropdown to its longest row so labels stay on one line', async () => {
+ render();
+
+ await openDesktopMenu('Tools');
+ const panel = screen
+ .getByRole('link', { name: 'QBI deduction calculator' })
+ .closest('div[style*="position: absolute"]');
+ expect(panel).not.toBeNull();
+ expect(panel!.contains(screen.getByRole('link', { name: 'CliffWatch' }))).toBe(
+ true,
+ );
+ expect(panel!.style.width).toBe('max-content');
+ expect(panel!.style.minWidth).toBe('220px');
+ expect(panel!.style.maxWidth).toBe('min(28rem, calc(100vw - 32px))');
+ });
+
+ it('lists the main app and the UK tools for the UK', async () => {
+ render();
+
+ await openDesktopMenu('Tools');
+ const panel = screen
+ .getByRole('link', { name: 'UK CliffWatch' })
+ .closest('div[style*="position: absolute"]')!;
+ expect(
+ within(panel)
+ .getAllByRole('link')
+ .map((link) => [link.textContent, link.getAttribute('href')]),
+ ).toEqual([
+ ['PolicyEngine app', 'https://app.policyengine.org/uk'],
+ ['Marriage calculator', 'https://policyengine.org/uk/marriage'],
+ ['Student loan calculator', 'https://policyengine.org/uk/uk-student-loan-calculator'],
+ ['UK CliffWatch', 'https://policyengine.org/uk/uk-cliff-watch'],
+ ]);
+ expect(screen.queryByRole('link', { name: 'CliffWatch' })).toBeNull();
+ for (const link of document.querySelectorAll('a')) {
+ expect(link.getAttribute('href')).not.toMatch(/\/uk\/cliffwatch(\/|\?|$)/);
+ }
+ });
+
+ it('links the main app on the appUrl it is given', async () => {
+ render(
+ ,
+ );
+
+ await openDesktopMenu('Tools');
+ expect(screen.getByRole('link', { name: 'PolicyEngine app' })).toHaveAttribute(
+ 'href',
+ 'https://app.staging.policyengine.org/uk',
+ );
+ expect(screen.getByRole('link', { name: 'UK CliffWatch' })).toHaveAttribute(
+ 'href',
+ 'https://staging.policyengine.org/uk/uk-cliff-watch',
+ );
+ });
+
+ it('passes the shell appUrl to the header', async () => {
+ const { unmount } = render(Tool content);
+ await openDesktopMenu('Tools');
+ expect(screen.getByRole('link', { name: 'PolicyEngine app' })).toHaveAttribute(
+ 'href',
+ 'https://app.policyengine.org/uk',
+ );
+ unmount();
+
+ render(
+
+ Tool content
+ ,
+ );
+
+ await openDesktopMenu('Tools');
+ expect(screen.getByRole('link', { name: 'PolicyEngine app' })).toHaveAttribute(
+ 'href',
+ 'https://app.staging.policyengine.org/us',
+ );
+ expect(screen.getByRole('link', { name: 'CliffWatch' })).toHaveAttribute(
+ 'href',
+ 'https://staging.policyengine.org/us/cliffwatch',
+ );
+ });
+
+ it('has no Tools menu for another country', async () => {
+ render();
+
+ expect(screen.queryByRole('button', { name: 'Tools' })).toBeNull();
+ const menu = await openMobileMenu();
+ expect(within(menu).queryByText('Tools')).toBeNull();
+ expect(within(menu).getByText('Research')).toBeInTheDocument();
+ });
+
+ it('lists the main app and the tools as plain links in the mobile menu', async () => {
+ const onNavigate = vi.fn();
+ render(
+ ,
+ );
+
+ const menu = await openMobileMenu();
+ expect(mobileGroupRows(menu, 'Tools')).toEqual(
+ US_ROWS.map(([label, href]) => [label, href, false]),
+ );
+ expect(within(menu).getByRole('link', { name: 'Team' })).toHaveAttribute(
+ 'data-client-link',
+ );
+
+ await userEvent.click(within(menu).getByRole('link', { name: 'CliffWatch' }));
+ expect(onNavigate).not.toHaveBeenCalled();
+ expect(clickWasPrevented).toEqual([false]);
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('lists the UK tools in order in the mobile menu', async () => {
+ render();
+
+ const menu = await openMobileMenu();
+ expect(mobileGroupRows(menu, 'Tools')).toEqual([
+ ['PolicyEngine app', 'https://app.policyengine.org/uk', false],
+ ['Marriage calculator', 'https://policyengine.org/uk/marriage', false],
+ [
+ 'Student loan calculator',
+ 'https://policyengine.org/uk/uk-student-loan-calculator',
+ false,
+ ],
+ ['UK CliffWatch', 'https://policyengine.org/uk/uk-cliff-watch', false],
+ ]);
+ });
+
+ it('scrolls the mobile links under a fixed title, above the sticky header', async () => {
+ render();
+
+ const headerZ = Number(
+ document.querySelector('div[style*="position: sticky"]')!.style.zIndex,
+ );
+ expect(headerZ).toBe(1000);
+ const menu = await openMobileMenu();
+ expect(Number(menu.style.zIndex)).toBeGreaterThan(headerZ);
+ const overlay = document.querySelector('[data-slot="sheet-overlay"]')!;
+ expect(Number(overlay.style.zIndex)).toBeGreaterThan(headerZ);
+
+ // The sheet itself doesn't scroll; the list under its title does. jsdom
+ // has no layout, so this checks the wiring: a column the list fills.
+ expect(menu.style.overflowY).toBe('');
+ expect(menu).toHaveClass('flex', 'flex-col', 'h-full');
+ const list = within(menu)
+ .getByRole('link', { name: 'Donate' })
+ .closest('div[style*="overflow-y: auto"]')!;
+ expect(list).not.toBeNull();
+ expect(menu.contains(list)).toBe(true);
+ expect(parseFloat(list.style.minHeight)).toBe(0);
+ expect(list.style.flex).toMatch(/^1( |$)/);
+ expect(list.contains(within(menu).getByText('Menu'))).toBe(false);
+ expect(list.contains(within(menu).getByRole('button', { name: 'Close' }))).toBe(
+ false,
+ );
+ });
+
+ it('leaves mobile tool links to the browser when there is no router', async () => {
+ const onNavigate = vi.fn();
+ render();
+
+ const menu = await openMobileMenu();
+ await userEvent.click(within(menu).getByRole('link', { name: 'UK CliffWatch' }));
+ expect(onNavigate).not.toHaveBeenCalled();
+ expect(clickWasPrevented).toEqual([false]);
+
+ const menuAgain = await openMobileMenu();
+ await userEvent.click(within(menuAgain).getByRole('link', { name: 'Team' }));
+ expect(onNavigate).toHaveBeenCalledWith('https://policyengine.org/uk/team');
+ expect(clickWasPrevented).toEqual([false, true]);
+ });
+});
+
+describe('Header dropdown position', () => {
+ // Not jsdom's window.innerWidth (1024), which counts the scrollbar.
+ let viewportWidth = 1000;
+ // The centre of the Tools trigger, and the panel's width, in viewport px.
+ let triggerCentre = 0;
+ let panelWidth = 0;
+ // The panel's width in its own CSS px is panelWidth / scale.
+ let scale = 1;
+
+ /** The shift the panel's `left` applies, in its own CSS px. */
+ function shiftOf(element: HTMLElement) {
+ const match = element.style.left.match(/^calc\(50% ([+-]) (-?[\d.]+)px\)$/);
+ if (!match) {
+ expect(element.style.left).toBe('50%');
+ return 0;
+ }
+ return (match[1] === '-' ? -1 : 1) * Number(match[2]);
+ }
+
+ function isPanel(element: Element): element is HTMLElement {
+ return (
+ element instanceof HTMLElement &&
+ element.style.position === 'absolute' &&
+ element.style.width === 'max-content'
+ );
+ }
+
+ beforeEach(() => {
+ viewportWidth = 1000;
+ scale = 1;
+ Object.defineProperty(document.documentElement, 'clientWidth', {
+ configurable: true,
+ get: () => viewportWidth,
+ });
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(
+ function (this: Element) {
+ if (isPanel(this)) {
+ const left = triggerCentre - panelWidth / 2 + shiftOf(this) * scale;
+ return new DOMRect(left, 50, panelWidth, 100);
+ }
+ // The trigger's box, which the panel hangs from.
+ if ([...this.children].some(isPanel)) {
+ return new DOMRect(triggerCentre - 40, 0, 80, 40);
+ }
+ return new DOMRect(0, 0, 0, 0);
+ },
+ );
+ vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(
+ function (this: HTMLElement) {
+ return isPanel(this) ? panelWidth / scale : 0;
+ },
+ );
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ delete (document.documentElement as { clientWidth?: number }).clientWidth;
+ });
+
+ function panel() {
+ return screen
+ .getByRole('link', { name: 'CliffWatch' })
+ .closest('div[style*="position: absolute"]')!;
+ }
+
+ async function closeMenu() {
+ await userEvent.keyboard('{Escape}');
+ await waitFor(() =>
+ expect(screen.queryByRole('link', { name: 'CliffWatch' })).toBeNull(),
+ );
+ }
+
+ it('stays centred under its trigger when it fits', async () => {
+ triggerCentre = 550;
+ panelWidth = 300;
+ render();
+
+ await openDesktopMenu('Tools');
+ expect(panel().style.left).toBe('50%');
+ // Sideways placement stays out of the animated transform, and `left`
+ // doesn't animate, so the panel never slides past the viewport's edge.
+ expect(panel().style.transform).toMatch(/^translateX\(-50%\) translateY\((0|-8px)\)$/);
+ expect(panel().style.transition).toContain('transform');
+ expect(panel().style.transition).not.toMatch(/(^|,)\s*(left|all)\b/);
+ });
+
+ it('leaves a panel it cannot measure centred', async () => {
+ // Laid out, a panel here would move right; unmeasured, it shouldn't.
+ triggerCentre = 0;
+ panelWidth = 0;
+ render();
+
+ await openDesktopMenu('Tools');
+ expect(panel().style.left).toBe('50%');
+ });
+
+ it('moves left to keep its right edge 16px inside the viewport', async () => {
+ // Centred, the panel would span 900 to 1200.
+ triggerCentre = 1050;
+ panelWidth = 300;
+ render();
+
+ await openDesktopMenu('Tools');
+ expect(shiftOf(panel())).toBe(viewportWidth - 16 - 1200);
+ expect(panel().getBoundingClientRect().right).toBe(viewportWidth - 16);
+ });
+
+ it('moves right to keep its left edge 16px inside the viewport', async () => {
+ triggerCentre = 100;
+ panelWidth = 300;
+ render();
+
+ await openDesktopMenu('Tools');
+ expect(shiftOf(panel())).toBe(66);
+ expect(panel().getBoundingClientRect().left).toBe(16);
+ });
+
+ it('keeps the left edge on screen when the panel is wider than the viewport', async () => {
+ triggerCentre = 510;
+ panelWidth = 1100;
+ render();
+
+ await openDesktopMenu('Tools');
+ expect(shiftOf(panel())).toBe(56);
+ });
+
+ it('moves in its own pixels under a scaled ancestor', async () => {
+ triggerCentre = 1050;
+ panelWidth = 300;
+ scale = 0.8;
+ render();
+
+ await openDesktopMenu('Tools');
+ expect(shiftOf(panel())).toBe(-216 / 0.8);
+ expect(panel().getBoundingClientRect().right).toBeCloseTo(viewportWidth - 16);
+ });
+
+ it('places itself again when the window resizes while it is open', async () => {
+ triggerCentre = 1050;
+ panelWidth = 300;
+ render();
+
+ await openDesktopMenu('Tools');
+ expect(shiftOf(panel())).toBe(-216);
+
+ // The same window: the panel stays where it is.
+ fireEvent(window, new Event('resize'));
+ expect(shiftOf(panel())).toBe(-216);
+
+ viewportWidth = 900;
+ fireEvent(window, new Event('resize'));
+ expect(shiftOf(panel())).toBe(900 - 16 - 1200);
+
+ viewportWidth = 1400;
+ fireEvent(window, new Event('resize'));
+ expect(panel().style.left).toBe('50%');
+ });
+
+ it('places itself again when a container scrolls while it is open', async () => {
+ triggerCentre = 550;
+ panelWidth = 300;
+ render();
+
+ await openDesktopMenu('Tools');
+ expect(panel().style.left).toBe('50%');
+
+ triggerCentre = 1050;
+ fireEvent.scroll(document.body);
+ expect(shiftOf(panel())).toBe(-216);
+ });
+
+ it('stops listening once it closes', async () => {
+ triggerCentre = 550;
+ panelWidth = 300;
+ render();
+ const add = vi.spyOn(window, 'addEventListener');
+ const remove = vi.spyOn(window, 'removeEventListener');
+
+ await openDesktopMenu('Tools');
+ await closeMenu();
+ const placed = add.mock.calls.filter(([, listener]) => {
+ return typeof listener === 'function' && listener.name === 'place';
+ });
+ expect(placed.map(([type]) => type).sort()).toEqual(['resize', 'scroll']);
+ for (const [type, listener, options] of placed) {
+ expect(remove).toHaveBeenCalledWith(
+ type,
+ listener,
+ ...(type === 'scroll' ? [expect.objectContaining({ capture: true })] : []),
+ );
+ expect(options === undefined || type === 'scroll').toBe(true);
+ }
+
+ // Reopened, it measures afresh.
+ triggerCentre = 1050;
+ await openDesktopMenu('Tools');
+ expect(shiftOf(panel())).toBe(-216);
+ await closeMenu();
+ triggerCentre = 550;
+ await openDesktopMenu('Tools');
+ expect(panel().style.left).toBe('50%');
+ });
+});
+
+describe('Header external dropdowns', () => {
+ const navItems = [
+ {
+ label: 'Zones',
+ href: '/zones',
+ external: true,
+ children: [
+ { label: 'Zone row', href: 'https://example.org/zone' },
+ { label: 'Local row', href: '/local', external: false },
+ ],
+ },
+ {
+ label: 'Mixed',
+ href: '/mixed',
+ children: [
+ { label: 'Routed row', href: '/routed' },
+ { label: 'Other zone row', href: 'https://example.org/other', external: true },
+ ],
+ },
+ ];
+
+ it('passes the item flag to rows on desktop unless a row sets its own', async () => {
+ const onNavigate = vi.fn();
+ render(
+ ,
+ );
+
+ await openDesktopMenu('Zones');
+ expect(screen.getByRole('link', { name: 'Zone row' })).not.toHaveAttribute(
+ 'data-client-link',
+ );
+ expect(screen.getByRole('link', { name: 'Local row' })).toHaveAttribute(
+ 'data-client-link',
+ );
+ await userEvent.click(screen.getByRole('link', { name: 'Zone row' }));
+ expect(onNavigate).not.toHaveBeenCalled();
+
+ await openDesktopMenu('Mixed');
+ expect(screen.getByRole('link', { name: 'Routed row' })).toHaveAttribute(
+ 'data-client-link',
+ );
+ await userEvent.click(screen.getByRole('link', { name: 'Other zone row' }));
+ expect(onNavigate).not.toHaveBeenCalled();
+ expect(clickWasPrevented).toEqual([false, false]);
+ });
+
+ it('passes the item flag to rows on mobile unless a row sets its own', async () => {
+ const { unmount } = render(
+ ,
+ );
+ const routedMenu = await openMobileMenu();
+ expect(
+ within(routedMenu).getByRole('link', { name: 'Zone row' }),
+ ).not.toHaveAttribute('data-client-link');
+ expect(
+ within(routedMenu).getByRole('link', { name: 'Local row' }),
+ ).toHaveAttribute('data-client-link');
+ expect(
+ within(routedMenu).getByRole('link', { name: 'Routed row' }),
+ ).toHaveAttribute('data-client-link');
+ expect(
+ within(routedMenu).getByRole('link', { name: 'Other zone row' }),
+ ).not.toHaveAttribute('data-client-link');
+ unmount();
+
+ const onNavigate = vi.fn();
+ render();
+ for (const name of ['Zone row', 'Other zone row']) {
+ const menu = await openMobileMenu();
+ await userEvent.click(within(menu).getByRole('link', { name }));
+ expect(screen.queryByRole('dialog')).toBeNull();
+ }
+ expect(onNavigate).not.toHaveBeenCalled();
+
+ const menu = await openMobileMenu();
+ await userEvent.click(within(menu).getByRole('link', { name: 'Local row' }));
+ expect(onNavigate).toHaveBeenCalledWith('/local');
+ expect(clickWasPrevented).toEqual([false, false, true]);
+ });
+});
+
+describe('Header external items', () => {
+ const navItems = [
+ { label: 'Zone page', href: 'https://example.org/zone', external: true },
+ { label: 'Local page', href: '/local' },
+ ];
+
+ it('renders a top-level external item as a plain link on desktop', async () => {
+ const onNavigate = vi.fn();
+ render(
+ ,
+ );
+
+ const nav = document.querySelector('.hidden.lg\\:flex')!;
+ const zone = within(nav).getByRole('link', { name: 'Zone page' });
+ expect(zone).not.toHaveAttribute('data-client-link');
+ expect(within(nav).getByRole('link', { name: 'Local page' })).toHaveAttribute(
+ 'data-client-link',
+ );
+
+ await userEvent.click(zone);
+ expect(onNavigate).not.toHaveBeenCalled();
+ expect(clickWasPrevented).toEqual([false]);
+ });
+
+ it('renders a top-level external item as a plain link on mobile', async () => {
+ const { unmount } = render(
+ ,
+ );
+ const routedMenu = await openMobileMenu();
+ expect(
+ within(routedMenu).getByRole('link', { name: 'Zone page' }),
+ ).not.toHaveAttribute('data-client-link');
+ expect(
+ within(routedMenu).getByRole('link', { name: 'Local page' }),
+ ).toHaveAttribute('data-client-link');
+ unmount();
+
+ const onNavigate = vi.fn();
+ render();
+
+ const menu = await openMobileMenu();
+ await userEvent.click(within(menu).getByRole('link', { name: 'Zone page' }));
+ expect(onNavigate).not.toHaveBeenCalled();
+
+ const menuAgain = await openMobileMenu();
+ await userEvent.click(within(menuAgain).getByRole('link', { name: 'Local page' }));
+ expect(onNavigate).toHaveBeenCalledWith('/local');
+ expect(clickWasPrevented).toEqual([false, true]);
+ });
+});
diff --git a/tests/layout/toolsMenuParity.network.test.ts b/tests/layout/toolsMenuParity.network.test.ts
new file mode 100644
index 0000000..65ec5df
--- /dev/null
+++ b/tests/layout/toolsMenuParity.network.test.ts
@@ -0,0 +1,124 @@
+// @vitest-environment node
+import { describe, expect, it } from 'vitest';
+import {
+ DEFAULT_POLICYENGINE_APP_URL,
+ POLICYENGINE_APP_LABEL,
+ policyEngineTools,
+} from '../../src/layout/PolicyEngineTools';
+
+/**
+ * policyengine.org builds its Tools menu from app-v2's apps.json: the main
+ * app, then every entry with a `toolsMenu` label, by country, sorted by
+ * label (app/src/utils/toolsMenu.ts and website/src/lib/toolsMenu.ts).
+ * ui-kit can't import apps.json, so src/layout/PolicyEngineTools.ts keeps a
+ * copy, and this test applies the same rule to app-v2's main branch and
+ * compares.
+ *
+ * It reads over the network, so `bun run test` skips it.
+ * .github/workflows/tools-menu-parity.yml runs it when the copy changes and
+ * weekly, to catch a change on policyengine.org while nothing changes here.
+ *
+ * What it can't see:
+ * - It applies its own copy of the rule rather than running app-v2's code.
+ * If app-v2 changes the rule itself (another filter, a different sort),
+ * this test can pass while the menus differ. app-v2's own tests
+ * (website/src/__tests__/lib/toolsMenu.test.ts) pin its side of the rule.
+ * - It reads CALCULATOR_URL's default in the code. A deployment that sets
+ * NEXT_PUBLIC_CALCULATOR_URL or VITE_CALCULATOR_URL links the main app
+ * somewhere else, and this test can't tell.
+ */
+const APP_V2 =
+ 'https://raw.githubusercontent.com/PolicyEngine/policyengine-app-v2/main';
+const APPS_JSON_URL = `${APP_V2}/app/src/data/apps/apps.json`;
+// The calculator's header and the website's header each name the main app
+// and give its URL, so check both.
+const APP_TOOLS_MENU_URL = `${APP_V2}/app/src/utils/toolsMenu.ts`;
+const APP_CONSTANTS_URL = `${APP_V2}/app/src/constants.ts`;
+const WEBSITE_TOOLS_MENU_URL = `${APP_V2}/website/src/lib/toolsMenu.ts`;
+const MAIN_APP_LABEL_PATTERN = /MAIN_APP_LABEL\s*=\s*['"]([^'"]+)['"]/;
+// `[^;]*?` spans the line break in the website's two-line declaration.
+const CALCULATOR_URL_PATTERN = /CALCULATOR_URL\s*=[^;]*?\|\|\s*['"]([^'"]+)['"]/;
+
+/** GET `url`, retrying once if the connection fails (not on an HTTP error). */
+async function fetchText(url: string): Promise {
+ let response: Response;
+ try {
+ response = await fetch(url, { signal: AbortSignal.timeout(8_000) });
+ } catch {
+ response = await fetch(url, { signal: AbortSignal.timeout(8_000) });
+ }
+ if (!response.ok) {
+ throw new Error(`GET ${url}: ${response.status}`);
+ }
+ return response.text();
+}
+
+/** The first capture of `pattern` in app-v2's `url`, or a readable failure. */
+async function readFromSource(url: string, pattern: RegExp): Promise {
+ const match = (await fetchText(url)).match(pattern);
+ if (!match) {
+ throw new Error(`${url} no longer matches ${pattern}; update this test`);
+ }
+ return match[1];
+}
+
+interface AppEntry {
+ slug: string;
+ countryId: string;
+ toolsMenu?: { label: string } | null;
+}
+
+/** app-v2's rule (getToolsMenuEntries) applied to its apps.json. */
+function toolsByCountry(apps: AppEntry[]) {
+ const byCountry: Record = {};
+ for (const app of apps) {
+ if (!app.toolsMenu) continue;
+ if (typeof app.toolsMenu.label !== 'string') {
+ throw new Error(`apps.json ${app.countryId}/${app.slug}: toolsMenu has no label`);
+ }
+ (byCountry[app.countryId] ??= []).push({
+ slug: app.slug,
+ label: app.toolsMenu.label,
+ });
+ }
+ for (const tools of Object.values(byCountry)) {
+ tools.sort((a, b) => a.label.localeCompare(b.label, 'en'));
+ }
+ return byCountry;
+}
+
+describe('Tools menu parity with policyengine.org', () => {
+ it('lists the same tools as app-v2’s apps.json, in the same order', async () => {
+ const text = await fetchText(APPS_JSON_URL);
+ let apps: unknown;
+ try {
+ apps = JSON.parse(text);
+ } catch (error) {
+ throw new Error(
+ `${APPS_JSON_URL} is not JSON (${String(error)}): ${text.slice(0, 200)}`,
+ );
+ }
+ expect(Array.isArray(apps), `${APPS_JSON_URL} is not an array`).toBe(true);
+
+ expect(
+ policyEngineTools,
+ 'src/layout/PolicyEngineTools.ts differs from the toolsMenu entries in ' +
+ 'app-v2’s app/src/data/apps/apps.json; update the copy so zone apps ' +
+ 'and policyengine.org show the same Tools menu',
+ ).toStrictEqual(toolsByCountry(apps as AppEntry[]));
+ }, 20_000);
+
+ it.each([
+ ['the calculator', APP_TOOLS_MENU_URL, APP_CONSTANTS_URL],
+ ['the website', WEBSITE_TOOLS_MENU_URL, WEBSITE_TOOLS_MENU_URL],
+ ])('names and links the main app as %s’s header does', async (_, labelUrl, appUrl) => {
+ expect(
+ POLICYENGINE_APP_LABEL,
+ `POLICYENGINE_APP_LABEL differs from MAIN_APP_LABEL in ${labelUrl}`,
+ ).toBe(await readFromSource(labelUrl, MAIN_APP_LABEL_PATTERN));
+ expect(
+ DEFAULT_POLICYENGINE_APP_URL,
+ `DEFAULT_POLICYENGINE_APP_URL differs from CALCULATOR_URL’s default in ${appUrl}`,
+ ).toBe(await readFromSource(appUrl, CALCULATOR_URL_PATTERN));
+ }, 35_000);
+});
diff --git a/tests/primitives/Sheet.test.tsx b/tests/primitives/Sheet.test.tsx
new file mode 100644
index 0000000..60139f6
--- /dev/null
+++ b/tests/primitives/Sheet.test.tsx
@@ -0,0 +1,46 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { Sheet, SheetContent, SheetDescription, SheetTitle } from '../../src/primitives/Sheet';
+
+function overlay() {
+ return document.querySelector('[data-slot="sheet-overlay"]')!;
+}
+
+describe('SheetContent', () => {
+ it('styles its overlay without passing the overlay props to the sheet', () => {
+ render(
+
+
+ Title
+ Description
+
+ ,
+ );
+
+ expect(overlay()).toHaveClass('custom-overlay', 'fixed', 'inset-0', 'bg-black/50');
+ expect(overlay().style.zIndex).toBe('1234');
+ const sheet = screen.getByRole('dialog');
+ expect(sheet.style.zIndex).toBe('1235');
+ expect(sheet).not.toHaveClass('custom-overlay');
+ expect(sheet).not.toHaveAttribute('overlayclassname');
+ expect(sheet).not.toHaveAttribute('overlaystyle');
+ });
+
+ it('leaves the overlay as it was without them', () => {
+ render(
+
+
+ Title
+ Description
+
+ ,
+ );
+
+ expect(overlay()).toHaveClass('fixed', 'inset-0', 'z-50', 'bg-black/50');
+ expect(overlay().getAttribute('style') ?? '').not.toMatch(/z-index/);
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index 03b2d3e..615b51b 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,6 +1,6 @@
import { resolve } from 'path';
import react from '@vitejs/plugin-react';
-import { defineConfig } from 'vitest/config';
+import { configDefaults, defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
@@ -13,5 +13,10 @@ export default defineConfig({
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
+ // *.network.test.ts files read other repositories over the internet, so
+ // `bun run test` skips them; `bun run test:network` runs them.
+ exclude: process.env.VITEST_NETWORK
+ ? configDefaults.exclude
+ : [...configDefaults.exclude, '**/*.network.test.ts'],
},
});