'use client'; import React, { useEffect, useRef, useState } from 'react'; import Link from '../../embed-shims/next-link'; import { HeaderConfig, NavigationItem } from '../../types/navigation'; import { cn } from '../../utils'; import { Menu01Icon, XmarkIcon } from '../icons-v2-generated'; import { Button } from '../ui/button'; import { HeaderButton } from './header-button'; import { MingoAiButton } from './mingo-ai-button'; import { MOBILE_NAV_PANEL_ID } from './mobile-nav-panel'; import { TicketAlertsButton } from './ticket-alerts-button'; import { TopNavigation } from './top-navigation'; export interface HeaderProps { config: HeaderConfig; platform?: string; } // Re-export from types for convenience export type { HeaderConfig } from '../../types/navigation'; // Top-level nav-link typography (Figma 2936-6815): the compact h6 step // (DM Sans 500, 14/20) with 24px icons. Overrides the `font="regular"` h4 // label and the Button base's 20px svg cap via cn()'s tailwind-merge. const NAV_ITEM_CLASSES = 'text-h6 [&_svg]:h-6 [&_svg]:w-6'; export function Header({ config, platform }: HeaderProps) { const [show, setShow] = useState(true); const [lastScrollY, setLastScrollY] = useState(0); const [openDropdowns, setOpenDropdowns] = useState>({}); const dropdownRefs = useRef>({}); const triggerRefs = useRef>({}); // Handle click outside and escape key for custom dropdown useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as HTMLElement; if (!target) return; // Check if click is outside all dropdowns const isOutsideAllDropdowns = Object.keys(openDropdowns).every(id => { const dropdown = dropdownRefs.current[id]; const trigger = triggerRefs.current[id]; if (!dropdown || !trigger) return true; return !dropdown.contains(target) && !trigger.contains(target); }); if (isOutsideAllDropdowns) { setOpenDropdowns({}); } }; const handleEscapeKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { setOpenDropdowns({}); } }; // Only add listeners if any dropdown is open const hasOpenDropdowns = Object.values(openDropdowns).some(Boolean); if (hasOpenDropdowns) { document.addEventListener('mousedown', handleClickOutside); document.addEventListener('keydown', handleEscapeKey); } return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscapeKey); }; }, [openDropdowns]); // Force close all dropdowns and cleanup on unmount useEffect(() => { return () => { // Close all dropdowns before unmounting to prevent focus errors setOpenDropdowns({}); // Clear any stored refs dropdownRefs.current = {}; triggerRefs.current = {}; }; }, []); useEffect(() => { // Only add scroll listener if autoHide is enabled if (!config.autoHide) { setShow(true); // Always show header when autoHide is disabled return; } const handleScroll = () => { const currentScrollY = window.scrollY; setLastScrollY(prevScrollY => { // Determine if we should show or hide the header const shouldHide = currentScrollY > prevScrollY && currentScrollY > 50; const shouldShow = currentScrollY < prevScrollY || currentScrollY <= 10; if (shouldHide) { setShow(false); } else if (shouldShow) { setShow(true); } return currentScrollY; }); }; window.addEventListener('scroll', handleScroll, { passive: true }); return () => { window.removeEventListener('scroll', handleScroll); }; }, [config.autoHide]); const renderNavigationItem = (item: NavigationItem) => { // If custom element provided, use it if (item.element) { return {item.element}; } // If it has children, render as custom dropdown if (item.children && item.children.length > 0) { const isOpen = openDropdowns[item.id] || false; return (
{/* Always render dropdown in DOM so crawlers see child links; toggle visibility via CSS + `inert`. Why `inert` (not `aria-hidden`): - `aria-hidden=true` HIDES from screen readers but DOES NOT remove focusable descendants from the tab order. If a child retains focus (e.g., the dropdown closes while a child was hovered/focused), the browser correctly flags "Blocked aria-hidden on an element because its descendant retained focus" — a real WAI-ARIA violation. - `inert` is the HTML-standard attribute that does BOTH: removes from a11y tree + blocks focus + prevents click + removes from tab order. Native React 19 support. */}
{ dropdownRefs.current[item.id] = el; }} inert={!isOpen} className={cn( 'absolute top-full left-0 mt-1', item.dropdownClassName ? '' : 'bg-ods-card border border-ods-border', 'rounded-lg shadow-xl z-[9999]', item.id === 'community' ? 'min-w-[240px]' : 'min-w-[220px]', 'transition-opacity duration-150', isOpen ? 'opacity-100 visible pointer-events-auto' : 'opacity-0 invisible pointer-events-none', item.dropdownClassName || '', )} >
{item.children.map((child, index) => ( ))}
{item.dropdownContent && ( <> {item.showDropdownDivider !== false &&
}
{item.dropdownContent}
)}
); } // Regular navigation item if (item.href || item.onClick) { return ( ); } // Button with onClick return ( ); }; const hasNav = !!config.navigation && config.navigation.items.length > 0; const hasCta = !!config.actions?.right?.length || !!(config.actions?.persistent && config.actions.persistent.length > 0); return (
{/* Unified ODS top-navigation shell (Figma 2797-5978): 48px mobile / 56px md+, cell model with per-cell dividers. NOTE: no `backdrop-blur` anywhere in this bar. Every platform ships an OPAQUE header background (`backgroundColor` resolves to an opaque ODS token), so a backdrop-filter would blur a backdrop that is then fully painted over: zero visual effect, but the browser still re-rasterizes the strip behind this sticky bar every scroll frame and content flickers as it passes under the header. If a consumer ever wants a translucent "glass" header, add the blur together with a translucent `backgroundColor` deliberately. */} {/* Length-guarded: platform configs often pass `left: []`, and an empty array is truthy — without the guard the cell would render as a bare divider + padding. */} {/* No padding on the wrapper: leading cells (HeaderButton-based admin toggles) size themselves to the bar's square cell. */} {!!config.actions?.left?.length && (
{config.actions.left}
)} {/* Mobile/tablet menu toggle — a leading cell per the ODS spec (banded with the nav breakpoint `lg` so the desktop nav and the toggle never co-show). */} {config.mobile?.enabled && ( { config.mobile?.onToggle?.(); }} isActive={config.mobile?.isOpen ?? false} aria-label={config.mobile?.isOpen ? 'Close menu' : 'Open menu'} aria-expanded={config.mobile?.isOpen ?? false} // Conditional: the panel unmounts when closed, so an unconditional // reference would dangle (axe aria-valid-attr-value). aria-controls={config.mobile?.isOpen ? MOBILE_NAV_PANEL_ID : undefined} icon={ config.mobile?.isOpen ? config.mobile?.closeIcon || : config.mobile?.menuIcon || } /> )} } logo={ {config.logo.element} } // Big-bar rule (Figma 2936-6812): 24px fixed left inset on the logo // zone at every breakpoint — the same 24px also reads as the gap // between a leading cell (burger / admin toggle) and the logo. logoClassName="pl-[var(--spacing-system-lf)] md:pl-[var(--spacing-system-lf)] lg:pl-[var(--spacing-system-lf)]" center={ hasNav ? ( ) : undefined } centerClassName={cn( config.navigation?.position === 'left' && 'justify-start', config.navigation?.position === 'right' && 'justify-end pr-[var(--spacing-system-m)]', )} cta={ hasCta ? ( <> {/* Desktop actions — banded with the nav/burger breakpoint (lg) so the desktop right-cluster and the mobile toggle never co-show. */} {!!config.actions?.right?.length && (
{config.actions.right}
)} {config.actions?.persistent && config.actions.persistent.length > 0 && (
{config.actions.persistent}
)} ) : undefined } ctaClassName="gap-3" sideActions={ config.tickets || config.mingo?.enabled ? ( <> {/* Support-ticket alerts cell — before Mingo, flush cell row. Attention-only: renders nothing unless there are unread replies (and the host mounted ). */} {config.tickets && ( )} {config.mingo?.enabled && ( )} ) : undefined } />
); }