diff --git a/src/components/layout/PEHeader.tsx b/src/components/layout/PEHeader.tsx index 556d8c8..a272f33 100644 --- a/src/components/layout/PEHeader.tsx +++ b/src/components/layout/PEHeader.tsx @@ -1,13 +1,42 @@ -import { useState, useRef, useEffect } from 'react'; -import { useMediaQuery } from '../../hooks/useMediaQuery'; -import { IconMenu2, IconChevronDown, IconWorld, IconX } from '@tabler/icons-react'; +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { usePathname } from 'next/navigation'; +import { + IconChevronDown, + IconMenu2, + IconWorld, + IconX, +} from '@tabler/icons-react'; import { colors, spacing, typography } from '@policyengine/ui-kit/legacy/tokens'; +import { useMediaQuery } from '../../hooks/useMediaQuery'; +import { + appPathFromPublicPath, + publicBasePrefixFromPath, +} from '../../hooks/usePublicBasePrefix'; import type { Country } from '../../hooks/useCountry'; interface PEHeaderProps { country: Country; } +interface DropdownItem { + label: string; + href: string; + /** True if href points to a different zone (we always use plain here + * because this header lives inside the Model multizone). */ + external?: boolean; + /** One level of nested children, rendered indented under the parent. */ + children?: DropdownItem[]; +} + +interface NavItemSetup { + label: string; + href?: string; + hasDropdown?: boolean; + dropdownItems?: DropdownItem[]; +} + const COUNTRIES = [ { id: 'us', label: 'United States' }, { id: 'uk', label: 'United Kingdom' }, @@ -16,55 +45,180 @@ const COUNTRIES = [ const PE_LOGO_URL = 'https://raw.githubusercontent.com/PolicyEngine/policyengine-app-v2/main/app/public/assets/logos/policyengine/white.svg'; +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 ( + { + 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, align = 'center', }: { - items: { label: string; href: string; bold?: boolean }[]; + items: DropdownItem[]; open: boolean; onClose: () => void; align?: 'center' | 'right'; @@ -88,28 +242,34 @@ function AppleDropdown({ const positionStyle: React.CSSProperties = align === 'right' - ? { right: 0, transform: visible ? 'translateY(0)' : 'translateY(-8px)' } - : { - left: '50%', - transform: visible - ? 'translateX(-50%) translateY(0)' - : 'translateX(-50%) translateY(-8px)', - }; + ? { right: 0 } + : { left: '50%', transform: 'translateX(-50%)' }; + + // Flatten one level of children for the cascading reveal + 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 ( - <> - {/* Click-away layer */} - {/* Click-away handler */} -
+
- {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} - {item.bold && ( - - )} - + {rows.map(({ item, depth }, i) => ( + ))}
- +
); } -export default function PEHeader({ country }: PEHeaderProps) { - const [aboutOpen, setAboutOpen] = useState(false); - const [countryOpen, setCountryOpen] = useState(false); - const [mobileSheetOpen, setMobileSheetOpen] = useState(false); - // SSR-safe responsive flag: returns `true` on the server (matches the - // initial server-rendered desktop layout) and snaps to the real - // viewport after mount via `useSyncExternalStore`. - const isDesktop = useMediaQuery('(min-width: 1024px)', true); - const aboutRef = useRef(null); - const countryRef = useRef(null); +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 NAV_ITEMS = getNavItems(country); + 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 dropdowns on outside click useEffect(() => { + if (!dropdownOpen) return; function handleClick(e: MouseEvent) { - if (aboutRef.current && !aboutRef.current.contains(e.target as Node)) setAboutOpen(false); - if (countryRef.current && !countryRef.current.contains(e.target as Node)) setCountryOpen(false); + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setDropdownOpen(false); + } } function handleKey(e: KeyboardEvent) { - if (e.key === 'Escape') { - setAboutOpen(false); - setCountryOpen(false); + if (e.key === 'Escape') setDropdownOpen(false); + } + document.addEventListener('mousedown', handleClick); + document.addEventListener('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; + + if (hasDropdown && dropdownItems) { + return ( +
+ + setDropdownOpen(false)} + /> +
+ ); + } + + if (href) { + return ( + setHovered(true)} + onBlur={() => setHovered(false)} + aria-current={active ? 'page' : undefined} + > + {label} + + + ); + } + + return null; +} + +function CountrySelector({ country }: { country: Country }) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false); } } + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false); + } document.addEventListener('mousedown', handleClick); document.addEventListener('keydown', handleKey); return () => { document.removeEventListener('mousedown', handleClick); document.removeEventListener('keydown', handleKey); }; - }, []); + }, [open]); - const countryItems = COUNTRIES.map((c) => ({ + const items: DropdownItem[] = COUNTRIES.map((c) => ({ label: c.label, href: `https://policyengine.org/${c.id}/model`, - bold: c.id === country, })); - const globeButtonStyle: React.CSSProperties = { - background: 'transparent', - border: 'none', - cursor: 'pointer', - padding: '6px', - borderRadius: '6px', - transition: 'background-color 0.15s ease', - lineHeight: 1, - display: 'flex', - alignItems: 'center', - }; + return ( +
+ + setOpen(false)} + align="right" + /> + {/* Active country is implicit (this header lives inside that zone) */} + +
+ ); +} + +export default function PEHeader({ country }: PEHeaderProps) { + const pathname = usePathname() || '/'; + const isDesktop = useMediaQuery('(min-width: 1024px)', true); + const [mobileSheetOpen, setMobileSheetOpen] = useState(false); + + // Compose the public URL (e.g. /us/model/rules/coverage) so dropdown + // active-state checks compare apples to apples. + const basePrefix = publicBasePrefixFromPath(pathname); + const publicPath = basePrefix + ? `${basePrefix}${appPathFromPublicPath(pathname).replace(/^\/$/, '')}` + : `/${country}/model${pathname.replace(/^\/$/, '')}`; + + const NAV_ITEMS = getNavItems(country); return (
-
+
{/* Left: Logo + Desktop Nav */}
- PolicyEngine + PolicyEngine - {/* Desktop nav */} {isDesktop && ( -
{/* Right side */} {isDesktop ? ( - /* Desktop: Country selector */ -
- - setCountryOpen(false)} - align="right" - /> -
+ ) : ( - /* Mobile: Country selector + hamburger */ -
-
- - setCountryOpen(false)} - align="right" - /> -
+
+
-
+
{NAV_ITEMS.map((item) => - item.hasDropdown ? ( + item.hasDropdown && item.dropdownItems ? (
{item.label} -
- {item.items!.map((sub) => ( +
+ {item.dropdownItems.flatMap((sub) => [ {sub.label} - - ))} + , + ...(sub.children ?? []).map((grand) => ( + + {grand.label} + + )), + ])}
) : ( @@ -427,7 +722,6 @@ export default function PEHeader({ country }: PEHeaderProps) { textDecoration: 'none', fontWeight: typography.fontWeight.medium, fontSize: typography.fontSize.sm, - fontFamily: typography.fontFamily.primary, display: 'block', }} >