diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index cd68a1d70..96a4667d1 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -25,11 +25,13 @@ jobs: # and should no longer be modified in app/. # Calculator components (report builder, household, charts, etc.) are # still legitimately modified in app/ since calculator-app/ imports from there. + # Note: HomeHeader.tsx and homeHeader/ were ported to website but + # are still actively used by the calculator-app (StandardLayout → + # HomeHeader). They are now de facto calculator components, so + # they're intentionally NOT in this guard list. WEBSITE_BLOCKED=$(echo "$ALL_CHANGED" | grep -E \ -e '^app/src/components/home/' \ -e '^app/src/components/shared/static/' \ - -e '^app/src/components/shared/HomeHeader\.tsx' \ - -e '^app/src/components/homeHeader/' \ -e '^app/src/components/Footer\.tsx' \ -e '^app/src/components/FooterSubscribe\.tsx' \ -e '^app/src/components/blog/BlogPostCard\.tsx' \ diff --git a/CLAUDE.md b/CLAUDE.md index 719a38e56..a766ad8b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,8 @@ The `app/` directory contains the legacy Vite build. Some parts have been ported to Next.js, others are still active. **Ported to website/ (do NOT modify in app/):** -- Website components: `home/`, `shared/static/`, `homeHeader/`, `Footer.tsx`, `FooterSubscribe.tsx`, `blog/BlogPostCard.tsx`, `blog/BlogPostGrid.tsx`, `blog/ResearchFilters.tsx` +- Website components: `home/`, `shared/static/`, `Footer.tsx`, `FooterSubscribe.tsx`, `blog/BlogPostCard.tsx`, `blog/BlogPostGrid.tsx`, `blog/ResearchFilters.tsx` + - Note: `homeHeader/` and `shared/HomeHeader.tsx` were also ported to `website/src/components/Header.tsx`, but the calculator-app still renders `HomeHeader` via `StandardLayout`, so these remain editable in `app/`. Keep the calculator header in sync with the website header when the website one changes. - Website pages: `Home`, `Research`, `Blog`, `Team`, `Supporters`, `Donate`, `Privacy`, `Terms`, `Brand*`, `Citations`, `AppPage` - `vercel.json` (root) — new rewrites go in `website/next.config.ts` diff --git a/app/src/components/homeHeader/MobileMenu.tsx b/app/src/components/homeHeader/MobileMenu.tsx index e23cc37d4..9d62652a3 100644 --- a/app/src/components/homeHeader/MobileMenu.tsx +++ b/app/src/components/homeHeader/MobileMenu.tsx @@ -58,7 +58,7 @@ export default function MobileMenu({ opened, onOpen, onClose, navItems }: Mobile className="tw:flex tw:flex-col" style={{ gap: spacing.xs, paddingLeft: spacing.md }} > - {item.dropdownItems.map((dropdownItem) => ( + {item.dropdownItems.flatMap((dropdownItem) => [ {dropdownItem.label} - - ))} + , + ...(dropdownItem.children ?? []).map((grandchild) => ( + + {grandchild.label} + + )), + ])} ) : ( diff --git a/app/src/components/homeHeader/NavItem.tsx b/app/src/components/homeHeader/NavItem.tsx index 2dd4ecc01..d1b2d1f84 100644 --- a/app/src/components/homeHeader/NavItem.tsx +++ b/app/src/components/homeHeader/NavItem.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { IconChevronDown } from '@tabler/icons-react'; import { AppLink } from '@/components/AppLink'; +import { useAppPathname } from '@/contexts/LocationContext'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, typography } from '@/designTokens'; @@ -8,6 +9,8 @@ export interface DropdownItem { label: string; onClick?: () => void; href?: string; + /** Nested children rendered indented under this item. One level deep only. */ + children?: DropdownItem[]; } export interface NavItemSetup { @@ -22,47 +25,130 @@ interface NavItemProps { setup: NavItemSetup; } +const NAV_ITEM_PADDING_X = 14; +const NAV_UNDERLINE_INSET = 10; +const DROPDOWN_GAP = 10; +const HOVER_OPEN_DELAY_MS = 100; +const HOVER_CLOSE_DELAY_MS = 200; + const navItemStyle: React.CSSProperties = { color: colors.text.inverse, fontWeight: typography.fontWeight.medium, fontSize: '15px', fontFamily: typography.fontFamily.primary, textDecoration: 'none', - padding: '6px 14px', - borderRadius: '6px', - transition: 'background-color 0.15s ease', + padding: `8px ${NAV_ITEM_PADDING_X}px`, letterSpacing: '0.01em', + position: 'relative', }; -const hoverHandlers = { - onMouseEnter: (e: React.MouseEvent) => { - e.currentTarget.style.backgroundColor = 'rgba(255, 255, 255, 0.12)'; - }, - onMouseLeave: (e: React.MouseEvent) => { - e.currentTarget.style.backgroundColor = 'transparent'; - }, -}; +function NavUnderline({ visible }: { visible: boolean }) { + return ( + + ); +} -/** - * Check if href is a relative path (internal) vs absolute URL (external). - */ function isInternalHref(href: string | undefined): href is string { return !!href && href.startsWith('/'); } -/** - * Apple-style dropdown panel with smooth height reveal and content fade. - */ -function AppleDropdown({ +function itemMatches(href: string | undefined, pathname: string): boolean { + if (!href || !isInternalHref(href)) { + return false; + } + return pathname === href || pathname.startsWith(`${href}/`); +} + +function isItemActive(item: NavItemSetup, pathname: string): boolean { + if (item.hasDropdown && item.dropdownItems) { + return item.dropdownItems.some( + (child) => + itemMatches(child.href, pathname) || + (child.children?.some((grand) => itemMatches(grand.href, pathname)) ?? false) + ); + } + return itemMatches(item.href, pathname); +} + +function DropdownRow({ + item, + depth, + index, + visible, + onSelect, +}: { + item: DropdownItem; + depth: number; + index: number; + visible: boolean; + onSelect: (item: DropdownItem) => void; +}) { + const isChild = depth > 0; + return ( + onSelect(item)} + style={{ + display: 'flex', + alignItems: 'center', + width: '100%', + textAlign: 'left', + padding: `${isChild ? 8 : 11}px 16px ${isChild ? 8 : 11}px ${16 + depth * 16}px`, + borderRadius: '10px', + border: 'none', + background: 'transparent', + cursor: 'pointer', + fontSize: isChild ? '13px' : '14px', + fontFamily: typography.fontFamily.primary, + fontWeight: isChild ? typography.fontWeight.medium : typography.fontWeight.semibold, + color: isChild ? colors.primary[700] : colors.primary[800], + transition: `background-color 0.12s ease 0ms, color 0.12s ease 0ms, opacity 0.3s ease ${ + visible ? index * 30 : 0 + }ms`, + opacity: visible ? 1 : 0, + lineHeight: '1.3', + letterSpacing: '-0.01em', + }} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = colors.primary[500]; + e.currentTarget.style.color = colors.text.inverse; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + e.currentTarget.style.color = isChild ? colors.primary[700] : colors.primary[800]; + }} + > + {item.label} + + ); +} + +function DropdownPanel({ items, open, onClose, + onSelect, }: { items: DropdownItem[]; open: boolean; onClose: () => void; + onSelect: (item: DropdownItem) => void; }) { - const nav = useAppNavigate(); const contentRef = useRef(null); const [contentHeight, setContentHeight] = useState(0); const [visible, setVisible] = useState(false); @@ -78,49 +164,39 @@ function AppleDropdown({ } }, [open]); - const handleSelect = useCallback( - (item: DropdownItem) => { - onClose(); - if (item.href) { - if (isInternalHref(item.href)) { - nav.push(item.href); - } else { - window.location.href = item.href; - } - } else if (item.onClick) { - item.onClick(); - } - }, - [nav, onClose] - ); - if (!open && contentHeight === 0) { return null; } + const rows: Array<{ item: DropdownItem; depth: number }> = []; + for (const item of items) { + rows.push({ item, depth: 0 }); + if (item.children) { + for (const child of item.children) { + rows.push({ item: child, depth: 1 }); + } + } + } + + const handleSelect = (item: DropdownItem) => { + onClose(); + onSelect(item); + }; + return ( - <> - {/* Invisible click-away layer (no dim) — sits below the header's z-index */} - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} + - {/* Dropdown panel */} - - {items.map((item, i) => ( - handleSelect(item)} - style={{ - display: 'flex', - alignItems: 'center', - width: '100%', - textAlign: 'left', - padding: '11px 16px', - borderRadius: '10px', - border: 'none', - background: 'transparent', - cursor: 'pointer', - fontSize: '14px', - fontFamily: typography.fontFamily.primary, - fontWeight: typography.fontWeight.semibold, - color: colors.primary[800], - transition: 'background-color 0.12s ease, color 0.12s ease, opacity 0.3s ease', - transitionDelay: visible ? `${i * 50}ms` : '0ms', - opacity: visible ? 1 : 0, - lineHeight: '1.3', - letterSpacing: '-0.01em', - }} - onMouseEnter={(e) => { - e.currentTarget.style.backgroundColor = colors.primary[500]; - e.currentTarget.style.color = colors.text.inverse; - }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = 'transparent'; - e.currentTarget.style.color = colors.primary[800]; - }} - > - {item.label} - + {rows.map(({ item, depth }, i) => ( + ))} - > + ); } -/** - * Reusable navigation item component. - * Can be either a simple link or a dropdown menu. - */ export default function NavItem({ setup }: NavItemProps) { const { label, onClick, href, hasDropdown, dropdownItems } = setup; + const nav = useAppNavigate(); + const pathname = useAppPathname(); const [dropdownOpen, setDropdownOpen] = useState(false); + const [hovered, setHovered] = useState(false); const containerRef = useRef(null); + const openTimerRef = useRef | null>(null); + const closeTimerRef = useRef | null>(null); + + const active = isItemActive(setup, pathname); + + const clearTimers = useCallback(() => { + if (openTimerRef.current) { + clearTimeout(openTimerRef.current); + openTimerRef.current = null; + } + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + + useEffect(() => clearTimers, [clearTimers]); - // Close on click outside useEffect(() => { if (!dropdownOpen) { return; @@ -199,33 +262,69 @@ export default function NavItem({ setup }: NavItemProps) { setDropdownOpen(false); } } - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, [dropdownOpen]); - - // Close on Escape - useEffect(() => { - if (!dropdownOpen) { - return; - } function handleKey(e: KeyboardEvent) { if (e.key === 'Escape') { setDropdownOpen(false); } } + document.addEventListener('mousedown', handleClick); document.addEventListener('keydown', handleKey); - return () => document.removeEventListener('keydown', handleKey); + return () => { + document.removeEventListener('mousedown', handleClick); + document.removeEventListener('keydown', handleKey); + }; }, [dropdownOpen]); + const handleMouseEnter = () => { + setHovered(true); + if (!hasDropdown) { + return; + } + clearTimers(); + openTimerRef.current = setTimeout(() => setDropdownOpen(true), HOVER_OPEN_DELAY_MS); + }; + + const handleMouseLeave = () => { + setHovered(false); + if (!hasDropdown) { + return; + } + clearTimers(); + closeTimerRef.current = setTimeout(() => setDropdownOpen(false), HOVER_CLOSE_DELAY_MS); + }; + + const underlineVisible = active || hovered || dropdownOpen; + + const handleDropdownSelect = (item: DropdownItem) => { + if (item.href) { + if (isInternalHref(item.href)) { + nav.push(item.href); + } else { + window.location.href = item.href; + } + } else if (item.onClick) { + item.onClick(); + } + }; + if (hasDropdown && dropdownItems) { return ( - + { onClick?.(); setDropdownOpen((prev) => !prev); }} + onFocus={() => setHovered(true)} + onBlur={() => setHovered(false)} + aria-expanded={dropdownOpen} + aria-haspopup="true" style={{ ...navItemStyle, background: 'transparent', @@ -235,7 +334,6 @@ export default function NavItem({ setup }: NavItemProps) { alignItems: 'center', gap: '4px', }} - {...hoverHandlers} > {label} + - setDropdownOpen(false)} + onSelect={handleDropdownSelect} /> ); } - // Relative paths use AppLink for SPA behavior if (isInternalHref(href)) { return ( - + setHovered(true)} + onBlur={() => setHovered(false)} + aria-current={active ? 'page' : undefined} + > {label} + ); } - // Absolute URLs use standard anchor tag return ( - + setHovered(true)} + onBlur={() => setHovered(false)} + > {label} + ); } diff --git a/app/src/components/shared/HomeHeader.tsx b/app/src/components/shared/HomeHeader.tsx index 04bd839ca..cfc5c57d1 100644 --- a/app/src/components/shared/HomeHeader.tsx +++ b/app/src/components/shared/HomeHeader.tsx @@ -14,6 +14,11 @@ export default function HeaderNavigation({ navbarOpened, onToggleNavbar }: Heade const [opened, { open, close }] = useDisclosure(false); const { getWebsitePath, countryId } = useWebsitePath(); + // The model explorer, API docs, and Python docs are separate apps served via + // Vercel rewrites — use absolute URLs so the browser performs a full + // navigation across zones rather than routing client-side. + const modelBase = `${WEBSITE_URL}/${countryId}/model`; + const navItems: NavItemSetup[] = [ { label: 'Research', @@ -22,18 +27,42 @@ export default function HeaderNavigation({ navbarOpened, onToggleNavbar }: Heade }, { label: 'Model', - // Always use an absolute URL — the model explorer is a separate app served - // via Vercel rewrite, so React Router must not intercept this link. - href: `${WEBSITE_URL}/${countryId}/model`, - hasDropdown: false, + hasDropdown: true, + dropdownItems: [ + { + label: 'Rules', + href: `${modelBase}/rules`, + children: [ + { label: 'Coverage', href: `${modelBase}/rules/coverage` }, + { label: 'Parameters', href: `${modelBase}/rules/parameters` }, + { label: 'Variables', href: `${modelBase}/rules/variables` }, + ], + }, + { + label: 'Data', + href: `${modelBase}/data`, + children: [ + { label: 'Pipeline', href: `${modelBase}/data/pipeline` }, + { label: 'Calibration', href: `${modelBase}/data/calibration` }, + { label: 'Validation', href: `${modelBase}/data/validation` }, + ], + }, + { + label: 'Behavioral responses', + href: `${modelBase}/behavioral`, + }, + ], }, { label: 'API', - // Always use an absolute URL — the API docs are a separate app served - // via Vercel rewrite, so React Router must not intercept this link. href: `${WEBSITE_URL}/${countryId}/api`, hasDropdown: false, }, + { + label: 'Python', + href: `${WEBSITE_URL}/${countryId}/python`, + hasDropdown: false, + }, { label: 'About', hasDropdown: true, @@ -41,6 +70,7 @@ export default function HeaderNavigation({ navbarOpened, onToggleNavbar }: Heade { label: 'Team', href: getWebsitePath('/team') }, { label: 'Supporters', href: getWebsitePath('/supporters') }, { label: 'Citations', href: getWebsitePath('/citations') }, + { label: 'Events', href: getWebsitePath('/events') }, ], }, { diff --git a/changelog_entry.yaml b/changelog_entry.yaml index ae7eaebae..05a557f39 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -5,3 +5,4 @@ - Add UK fuel duty rise cancellation analysis dashboard at /uk/cancelling-fuel-duty-rise changed: - Proxy UK Python package docs at /uk/python and UK API docs at /uk/api so the country-parameterized "Python" and "API" nav links work on UK pages + - Align the calculator app header (e.g. /us/reports) with the main website header — hover-open dropdowns, per-item underline, Model dropdown with sub-items, Python link, and Events under About