diff --git a/.github/workflows/tools-menu-parity.yml b/.github/workflows/tools-menu-parity.yml new file mode 100644 index 0000000..abef03a --- /dev/null +++ b/.github/workflows/tools-menu-parity.yml @@ -0,0 +1,72 @@ +name: Tools menu parity + +# The header's Tools menu copies the toolsMenu entries in policyengine.org's +# apps.json; see tests/layout/toolsMenuParity.network.test.ts. This runs when +# the copy or its check changes here, and weekly to catch a change on +# policyengine.org while nothing changes here. A failure outside a pull +# request opens an issue, or comments on the one already open. +on: + pull_request: + paths: + - src/layout/PolicyEngineTools.ts + - tests/layout/toolsMenuParity.network.test.ts + - .github/workflows/tools-menu-parity.yml + # How the test runs: the test:network script and the vitest exclude. + - package.json + - vitest.config.ts + push: + branches: [main] + paths: + - src/layout/PolicyEngineTools.ts + - tests/layout/toolsMenuParity.network.test.ts + - .github/workflows/tools-menu-parity.yml + # How the test runs: the test:network script and the vitest exclude. + - package.json + - vitest.config.ts + schedule: + - cron: "0 14 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + parity: + name: Tools menu parity + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Compare with policyengine.org's apps.json + run: bun run test:network + + - name: Open or update the drift issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + title="Tools menu differs from policyengine.org" + body="The Tools menu parity check failed: $RUN_URL + + src/layout/PolicyEngineTools.ts no longer matches the toolsMenu entries in app/src/data/apps/apps.json in PolicyEngine/policyengine-app-v2, or the main app's label or URL changed there, or those files could not be read. Update the copy here to match, so zone apps and policyengine.org show the same Tools menu." + number=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "in:title \"$title\"" --json number,title \ + --jq "map(select(.title == \"$title\")) | .[0].number // empty") + if [ -n "$number" ]; then + gh issue comment "$number" --repo "$GITHUB_REPOSITORY" --body "Still failing: $RUN_URL" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body "$body" + fi diff --git a/changelog.d/tools-nav-menu.added.md b/changelog.d/tools-nav-menu.added.md new file mode 100644 index 0000000..7708c0d --- /dev/null +++ b/changelog.d/tools-nav-menu.added.md @@ -0,0 +1 @@ +Add a Tools menu to the PolicyEngine header, right after Research, built by the same rule as policyengine.org's: the PolicyEngine app first, then every calculator that works for any household under current law, sorted by label. The US menu lists CliffWatch, Coverage Compass, GiveCalc, the marriage calculator, the QBI deduction calculator, the SPM threshold calculator and the TANF calculator; the UK menu lists the marriage calculator, the student loan calculator and UK CliffWatch; other countries get no menu. Every row is a full page load, because the PolicyEngine app is on its own domain and each tool runs as its own app on policyengine.org. New exports: `policyEngineTools`, `getPolicyEngineTools`, `getPolicyEngineAppUrl`, `POLICYENGINE_APP_LABEL`, `DEFAULT_POLICYENGINE_APP_URL` and the `PolicyEngineTool`, `PolicyEngineToolLink` and `NavItemChildConfig` types. `getPolicyEngineNavItems` takes an optional app URL as its third argument, and `PolicyEngineHeader` and `PolicyEngineShell` an `appUrl` prop. `SheetContent` takes `overlayClassName` and `overlayStyle` for its dimmed overlay. `NavItemConfig` items and their dropdown rows take a new `external` flag that renders a plain link, bypassing `linkComponent` and `onNavigate`; rows inherit it from their item unless they set their own. diff --git a/changelog.d/tools-nav-menu.changed.md b/changelog.d/tools-nav-menu.changed.md new file mode 100644 index 0000000..ca80f90 --- /dev/null +++ b/changelog.d/tools-nav-menu.changed.md @@ -0,0 +1 @@ +Header dropdowns now widen to fit their longest row, up to 28rem or the viewport width less 32px, instead of wrapping every row at 220px. A dropdown that would run past either edge of the viewport moves sideways to stay 16px inside it. diff --git a/changelog.d/tools-nav-menu.fixed.md b/changelog.d/tools-nav-menu.fixed.md new file mode 100644 index 0000000..b608921 --- /dev/null +++ b/changelog.d/tools-nav-menu.fixed.md @@ -0,0 +1 @@ +The mobile header menu now scrolls its links when they are taller than the screen, so Donate and other low items stay reachable on short phones, while the menu's title and close button stay in place. The menu and its dimmed overlay now sit above the sticky header instead of under it. diff --git a/demo/Demo.tsx b/demo/Demo.tsx index 31b66cc..7ccd9f5 100644 --- a/demo/Demo.tsx +++ b/demo/Demo.tsx @@ -100,6 +100,7 @@ import { SegmentedControl } from '../src/primitives/SegmentedControl'; // Layout import { DashboardShell } from '../src/layout/DashboardShell'; import { Header } from '../src/layout/header'; +import { PolicyEngineHeader } from '../src/layout/PolicyEngineHeader'; import { SidebarLayout } from '../src/layout/SidebarLayout'; import { SingleColumnLayout } from '../src/layout/SingleColumnLayout'; import { InputPanel } from '../src/layout/InputPanel'; @@ -1292,6 +1293,18 @@ export function Demo() { + + {/* No overflow-hidden here: it would clip the dropdowns. */} +
+ +
+
+ diff --git a/package.json b/package.json index 155dbe8..38803cc 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "prebuild": "tsx scripts/generate-css.ts", "build": "vite build && mv dist/ui-kit.css dist/styles.css && tsc -p tsconfig.build.json --emitDeclarationOnly", "test": "vitest run", + "test:network": "VITEST_NETWORK=1 vitest run .network.test", "test:watch": "vitest", "typecheck": "tsc --noEmit", "lint": "tsc --noEmit", @@ -139,6 +140,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", + "fast-check": "^4.10.2", "jsdom": "^25.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/src/layout/PolicyEngineHeader.tsx b/src/layout/PolicyEngineHeader.tsx index 4b20f71..a04a3b5 100644 --- a/src/layout/PolicyEngineHeader.tsx +++ b/src/layout/PolicyEngineHeader.tsx @@ -1,6 +1,7 @@ 'use client'; import { Header, type CountryConfig, type HeaderProps, type NavItemConfig } from './header'; +import { DEFAULT_POLICYENGINE_APP_URL } from './PolicyEngineTools'; import { DEFAULT_POLICYENGINE_BASE_URL, getPolicyEngineCountrySwitchUrl, @@ -17,6 +18,8 @@ export interface PolicyEngineHeaderProps > { country?: PolicyEngineCountryId; baseUrl?: string; + /** The main app's origin, which the Tools menu's first row links to. */ + appUrl?: string; navItems?: NavItemConfig[]; countries?: CountryConfig[]; logoHref?: string; @@ -26,6 +29,7 @@ export interface PolicyEngineHeaderProps export function PolicyEngineHeader({ country = 'us', baseUrl = DEFAULT_POLICYENGINE_BASE_URL, + appUrl = DEFAULT_POLICYENGINE_APP_URL, navItems, countries = policyEngineCountries, logoHref, @@ -45,7 +49,7 @@ export function PolicyEngineHeader({ return (
; + headerProps?: Omit; footerProps?: Omit; className?: string; mainClassName?: string; @@ -21,6 +24,7 @@ export function PolicyEngineShell({ children, country = 'us', baseUrl = DEFAULT_POLICYENGINE_BASE_URL, + appUrl = DEFAULT_POLICYENGINE_APP_URL, showFooter = true, headerProps, footerProps, @@ -29,7 +33,12 @@ export function PolicyEngineShell({ }: PolicyEngineShellProps) { return (
- +
{children}
{showFooter && ( diff --git a/src/layout/PolicyEngineSiteConfig.ts b/src/layout/PolicyEngineSiteConfig.ts index 17c1ade..9b91e92 100644 --- a/src/layout/PolicyEngineSiteConfig.ts +++ b/src/layout/PolicyEngineSiteConfig.ts @@ -1,5 +1,10 @@ import type { CountryConfig, NavItemConfig } from './header'; import type { FooterProps } from './Footer'; +import { + DEFAULT_POLICYENGINE_APP_URL, + POLICYENGINE_APP_LABEL, + policyEngineTools, +} from './PolicyEngineTools'; export type PolicyEngineCountryId = 'us' | 'uk' | (string & {}); @@ -59,15 +64,75 @@ export function getPolicyEngineUrl( return `${getPolicyEngineCountryUrl(country, baseUrl)}${normalizedPath}`; } +/** A tool with its absolute link. */ +export interface PolicyEngineToolLink { + slug: string; + label: string; + href: string; +} + +/** A country's tools, sorted by label, with absolute links, or none. */ +export function getPolicyEngineTools( + country: PolicyEngineCountryId = 'us', + baseUrl = DEFAULT_POLICYENGINE_BASE_URL, +): PolicyEngineToolLink[] { + // Own keys only, so `__proto__` or `constructor` never finds a list. + // (Object.hasOwn would do, but Safari added it only in 15.4.) + const tools = Object.prototype.hasOwnProperty.call(policyEngineTools, country) + ? (policyEngineTools[country] ?? []) + : []; + return tools.map((tool) => ({ + ...tool, + href: getPolicyEngineUrl(country, tool.slug, baseUrl), + })); +} + +/** The main app for a country. */ +export function getPolicyEngineAppUrl( + country: PolicyEngineCountryId = 'us', + appUrl = DEFAULT_POLICYENGINE_APP_URL, +) { + return `${normalizeBaseUrl(appUrl)}/${country}`; +} + +function getPolicyEngineToolsNavItem( + country: PolicyEngineCountryId, + baseUrl: string, + appUrl: string, +): NavItemConfig | null { + const tools = getPolicyEngineTools(country, baseUrl); + // The main app never stands alone in the menu. + if (tools.length === 0) { + return null; + } + + const mainAppHref = getPolicyEngineAppUrl(country, appUrl); + // The main app is on another domain and each tool is its own zone, so no + // app rendering this header can reach them with a client-side transition: + // every row is a full page load. + return { + label: 'Tools', + href: mainAppHref, + external: true, + children: [ + { label: POLICYENGINE_APP_LABEL, href: mainAppHref, external: true }, + ...tools.map(({ label, href }) => ({ label, href, external: true })), + ], + }; +} + export function getPolicyEngineNavItems( country: PolicyEngineCountryId = 'us', baseUrl = DEFAULT_POLICYENGINE_BASE_URL, + appUrl = DEFAULT_POLICYENGINE_APP_URL, ): NavItemConfig[] { - const navItems: NavItemConfig[] = [ + const toolsNavItem = getPolicyEngineToolsNavItem(country, baseUrl, appUrl); + return [ { label: 'Research', href: getPolicyEngineUrl(country, 'research', baseUrl), }, + ...(toolsNavItem ? [toolsNavItem] : []), { label: 'Model', href: getPolicyEngineUrl(country, 'model', baseUrl), @@ -76,6 +141,14 @@ export function getPolicyEngineNavItems( label: 'API', href: getPolicyEngineUrl(country, 'api', baseUrl), }, + ...(country === 'us' + ? [ + { + label: 'Python', + href: getPolicyEngineUrl(country, 'python', baseUrl), + }, + ] + : []), { label: 'About', href: getPolicyEngineUrl(country, 'team', baseUrl), @@ -102,15 +175,6 @@ export function getPolicyEngineNavItems( href: getPolicyEngineUrl(country, 'donate', baseUrl), }, ]; - - if (country === 'us') { - navItems.splice(3, 0, { - label: 'Python', - href: getPolicyEngineUrl(country, 'python', baseUrl), - }); - } - - return navItems; } export function getPolicyEngineFooterLinks( diff --git a/src/layout/PolicyEngineTools.ts b/src/layout/PolicyEngineTools.ts new file mode 100644 index 0000000..01f1913 --- /dev/null +++ b/src/layout/PolicyEngineTools.ts @@ -0,0 +1,58 @@ +/** + * A tool listed in the PolicyEngine header's Tools menu: a calculator that + * works for any household under current law. Research about one policy, bill + * or event stays out of the menu, however interactive it is. + * + * Each tool is its own zone on policyengine.org at `//`, so a + * link to one must be a full page load, never a client-side transition. + */ +export interface PolicyEngineTool { + /** Path segment after the country: the tool lives at `//`. */ + readonly slug: string; + readonly label: string; +} + +/** The Tools menu's first row, which links to the main app. */ +export const POLICYENGINE_APP_LABEL = 'PolicyEngine app'; + +/** Where the main app lives; it serves `/`. */ +export const DEFAULT_POLICYENGINE_APP_URL = 'https://app.policyengine.org'; + +function freezeTools( + tools: PolicyEngineTool[], +): readonly PolicyEngineTool[] { + return Object.freeze(tools.map((tool) => Object.freeze({ ...tool }))); +} + +/** + * Each country's tools, sorted by label. A country with no entry has no + * Tools menu. + * + * policyengine.org builds the same menu from the `toolsMenu` labels in + * PolicyEngine/policyengine-app-v2's `app/src/data/apps/apps.json`, which + * ui-kit can't import. `tests/layout/toolsMenuParity.network.test.ts` fails + * when this copy, the main app's label or its URL differ from app-v2's, so + * change them when apps.json changes. The data is frozen because every + * header in a bundle shares it. + * + * This export may move: the plan is for app-v2 and ui-kit to read one list, + * after which this copy goes. + */ +export const policyEngineTools: Readonly< + Partial> +> = Object.freeze({ + us: freezeTools([ + { slug: 'cliffwatch', label: 'CliffWatch' }, + { slug: 'coverage-compass', label: 'Coverage Compass' }, + { slug: 'givecalc', label: 'GiveCalc' }, + { slug: 'marriage', label: 'Marriage calculator' }, + { slug: 'qbi-calculator', label: 'QBI deduction calculator' }, + { slug: 'spm-calculator', label: 'SPM threshold calculator' }, + { slug: 'tanf-calculator', label: 'TANF calculator' }, + ]), + uk: freezeTools([ + { slug: 'marriage', label: 'Marriage calculator' }, + { slug: 'uk-student-loan-calculator', label: 'Student loan calculator' }, + { slug: 'uk-cliff-watch', label: 'UK CliffWatch' }, + ]), +}); diff --git a/src/layout/header/Header.tsx b/src/layout/header/Header.tsx index 5684383..7910e67 100644 --- a/src/layout/header/Header.tsx +++ b/src/layout/header/Header.tsx @@ -2,10 +2,26 @@ import { forwardRef, useState } from 'react'; import { cn } from '../../utils/cn'; import { HeaderContent } from './HeaderContent'; +/** A row in a nav item's dropdown. */ +export interface NavItemChildConfig { + label: string; + href: string; + description?: string; + /** Overrides the parent item's `external` for this row. */ + external?: boolean; +} + export interface NavItemConfig { label: string; href: string; - children?: { label: string; href: string; description?: string }[]; + /** + * Render a plain `` that the browser follows as a full page load in + * the same tab, skipping `linkComponent` and `onNavigate`. Use it for a + * page served by another zone, which a client-side router can't reach. + * Dropdown rows inherit it unless they set their own. + */ + external?: boolean; + children?: NavItemChildConfig[]; } export interface CountryConfig { @@ -23,9 +39,15 @@ export interface HeaderProps { currentCountry?: string; /** Callback when a country is selected */ onCountryChange?: (id: string) => void; - /** Navigation callback — called when any nav link is clicked */ + /** + * Navigation callback — called when a nav link is clicked, except + * `external` items, which the browser follows itself + */ onNavigate?: (href: string) => void; - /** Custom link component (e.g. React Router Link, Next.js Link) */ + /** + * Custom link component (e.g. React Router Link, Next.js Link), used for + * every nav link except `external` items + */ linkComponent?: React.ElementType; /** Logo image source URL */ logoSrc?: string; diff --git a/src/layout/header/HeaderMobileMenu.tsx b/src/layout/header/HeaderMobileMenu.tsx index 1e87759..10a0801 100644 --- a/src/layout/header/HeaderMobileMenu.tsx +++ b/src/layout/header/HeaderMobileMenu.tsx @@ -70,14 +70,28 @@ export function HeaderMobileMenu({ 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'], }, });