diff --git a/website/src/components/Header.tsx b/website/src/components/Header.tsx index 490bfa0cb..d78fe676c 100644 --- a/website/src/components/Header.tsx +++ b/website/src/components/Header.tsx @@ -18,6 +18,11 @@ const PolicyEngineLogo = "/assets/logos/policyengine/white.svg"; interface DropdownItem { label: string; href: string; + /** Use a plain instead of next/link — required for paths that resolve + * via a Vercel rewrite to a separate zone (e.g. Model sub-pages). */ + external?: boolean; + /** Nested children rendered indented under this item. One level deep only. */ + children?: DropdownItem[]; } interface NavItemSetup { @@ -33,29 +38,119 @@ const COUNTRIES = [ { id: "uk", label: "United Kingdom" }, ]; +const NAV_ITEM_PADDING_X = 14; +const NAV_UNDERLINE_INSET = 10; // how far in from each side the underline starts +const DROPDOWN_GAP = 10; // visual gap between trigger and panel — bridged for hover + 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 ( + so the browser + // actually crosses zones — Next.js Link would try a client-side + // transition that the website zone can't fulfil. + const Tag = item.external ? "a" : Link; + const isChild = depth > 0; + return ( + { + 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, @@ -83,26 +178,22 @@ function DropdownPanel({ if (!open && contentHeight === 0) return null; return ( - <> - {/* Click-away layer */} -
+
- {items.map((item, i) => ( - { - 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} - - ))} + {(() => { + // Flatten one level of children so the cascading reveal animation + // (transitionDelay scaled by index) treats every row uniformly. + 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 }); + } + } + } + return rows.map(({ item, depth }, i) => ( + + )); + })()}
- +
); } // --- NavItem --- -function NavItem({ setup }: { setup: NavItemSetup }) { +const HOVER_OPEN_DELAY_MS = 100; +const HOVER_CLOSE_DELAY_MS = 200; + +function NavItem({ setup, active }: { setup: NavItemSetup; active: boolean }) { const { label, href, hasDropdown, dropdownItems } = setup; 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 clearTimers = useCallback(() => { + if (openTimerRef.current) { + clearTimeout(openTimerRef.current); + openTimerRef.current = null; + } + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + useEffect(() => clearTimers, [clearTimers]); + + // Click-outside + Escape close (only relevant when dropdown is open) useEffect(() => { if (!dropdownOpen) return; function handleClick(e: MouseEvent) { @@ -190,12 +287,43 @@ function NavItem({ setup }: { setup: NavItemSetup }) { }; }, [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; + if (hasDropdown && dropdownItems) { return ( -
+
to avoid Next.js RSC prefetch 404s const Tag = setup.external ? "a" : Link; return ( - + setHovered(true)} + onBlur={() => setHovered(false)} + aria-current={active ? "page" : undefined} + > {label} + ); } @@ -315,7 +452,12 @@ function CountrySelector() { display: "flex", alignItems: "center", }} - {...hoverHandlers} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.12)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = "transparent"; + }} > @@ -377,9 +519,9 @@ function CountrySelector() { ? typography.fontWeight.bold : 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", + transition: `background-color 0.12s ease 0ms, color 0.12s ease 0ms, opacity 0.3s ease ${ + visible ? i * 50 : 0 + }ms`, opacity: visible ? 1 : 0, lineHeight: "1.3", letterSpacing: "-0.01em", @@ -441,6 +583,35 @@ function MobileNavLink({ ); } +function MobileDropdownLink({ + item, + depth, + onClose, +}: { + item: DropdownItem; + depth: number; + onClose: () => void; +}) { + const Tag = item.external ? "a" : Link; + return ( + 0 ? 0.85 : 1, + }} + > + {item.label} + + ); +} + function MobileMenu({ open, onClose, @@ -551,22 +722,22 @@ function MobileMenu({ paddingLeft: spacing.md, }} > - {item.dropdownItems.map((dropdownItem) => ( - [ + - {dropdownItem.label} - - ))} + item={dropdownItem} + depth={0} + onClose={onClose} + />, + ...(dropdownItem.children ?? []).map((grandchild) => ( + + )), + ])}
) : ( @@ -583,15 +754,65 @@ function MobileMenu({ export default function Header() { const countryId = useCountryId(); + const pathname = usePathname(); const [mobileOpen, setMobileOpen] = useState(false); const navItems: NavItemSetup[] = [ { label: "Research", href: `/${countryId}/research`, hasDropdown: false }, { label: "Model", - href: `/${countryId}/model`, - hasDropdown: false, - external: true, + hasDropdown: true, + dropdownItems: [ + { + label: "Rules", + href: `/${countryId}/model/rules`, + external: true, + children: [ + { + label: "Coverage", + href: `/${countryId}/model/rules/coverage`, + external: true, + }, + { + label: "Parameters", + href: `/${countryId}/model/rules/parameters`, + external: true, + }, + { + label: "Variables", + href: `/${countryId}/model/rules/variables`, + external: true, + }, + ], + }, + { + label: "Data", + href: `/${countryId}/model/data`, + external: true, + children: [ + { + label: "Pipeline", + href: `/${countryId}/model/data/pipeline`, + external: true, + }, + { + label: "Calibration", + href: `/${countryId}/model/data/calibration`, + external: true, + }, + { + label: "Validation", + href: `/${countryId}/model/data/validation`, + external: true, + }, + ], + }, + { + label: "Behavioral responses", + href: `/${countryId}/model/behavioral`, + external: true, + }, + ], }, { label: "API", @@ -652,7 +873,9 @@ export default function Header() { display: "flex", alignItems: "center", cursor: "pointer", - marginRight: spacing.md, + // Wider gap than the 24px between nav items so the logo reads + // as a distinct anchor rather than another menu entry. + marginRight: "40px", }} > {navItems.map((item) => ( - + ))}