import { html, css } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { USWDSBaseComponent } from '../../utils/base-component.js'; import { initializeHeader } from './usa-header-behavior.js'; // Import official USWDS compiled CSS import '../../styles/styles.css'; // Import usa-search component import '../search/index.js'; // Import usa-language-selector component import '../language-selector/index.js'; export interface NavItem { label: string; href?: string; current?: boolean; submenu?: NavItem[]; megamenu?: MegamenuColumn[]; } export interface MegamenuColumn { links: NavItem[]; } export interface SecondaryLink { label: string; href: string; } /** * USA Header Web Component * * Minimal wrapper around USWDS header functionality. * Uses USWDS-mirrored behavior pattern for 100% behavioral parity. * * @element usa-header * @fires nav-click - Dispatched when a navigation item is clicked * @fires mobile-menu-toggle - Dispatched when mobile menu is toggled * * @see README.mdx - Complete API documentation, usage examples, and implementation notes * @see CHANGELOG.mdx - Component version history and breaking changes * @see TESTING.mdx - Testing documentation and coverage reports * * @uswds-js-reference https://github.com/uswds/uswds/tree/develop/packages/usa-header/src/index.js * @uswds-css-reference https://github.com/uswds/uswds/tree/develop/packages/usa-header/src/styles/_usa-header.scss * @uswds-docs https://designsystem.digital.gov/components/header/ * @uswds-guidance https://designsystem.digital.gov/components/header/#guidance * @uswds-accessibility https://designsystem.digital.gov/components/header/#accessibility */ @customElement('usa-header') export class USAHeader extends USWDSBaseComponent { // CRITICAL: Light DOM implementation for USWDS compatibility protected override createRenderRoot() { return this; } static override styles = css` :host { display: block; margin-top: 1rem; } `; @property({ type: String }) logoText = ''; @property({ type: String }) logoHref = '/'; @property({ type: String }) logoImageSrc = ''; @property({ type: String }) logoImageAlt = ''; @property({ type: Array }) navItems: NavItem[] = []; @property({ type: Array }) secondaryLinks: SecondaryLink[] = []; // Store slotted content for Light DOM compatibility private slottedContent: string = ''; @property({ type: Boolean, reflect: true }) extended = false; @property({ type: Boolean, reflect: true }) showSearch = false; @property({ type: String }) searchPlaceholder = 'Search'; @property({ type: Boolean, reflect: true }) mobileMenuOpen = false; // Store cleanup function from behavior private cleanup?: () => void; override connectedCallback() { super.connectedCallback(); this.setAttribute('data-web-component-managed', 'true'); // Capture any initial content before render if (this.childNodes.length > 0 && this.navItems.length === 0) { this.slottedContent = this.innerHTML; this.innerHTML = ''; } } override disconnectedCallback() { super.disconnectedCallback(); this.cleanup?.(); } override async firstUpdated(changedProperties: Map) { // ARCHITECTURE: Script Tag Pattern // USWDS is loaded globally via script tag in .storybook/preview-head.html // Components just render HTML - USWDS enhances automatically via window.USWDS // ARCHITECTURE: USWDS-Mirrored Behavior Pattern // Uses dedicated behavior file (usa-header-behavior.ts) that replicates USWDS source exactly super.firstUpdated(changedProperties); // Wait for DOM to be fully rendered await this.updateComplete; await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); // Initialize using mirrored USWDS behavior this.cleanup = initializeHeader(this); } override shouldUpdate(changedProperties: Map): boolean { // Protect USWDS transformations from re-rendering after enhancement const componentElement = this.querySelector('.usa-header'); const hasEnhancedElements = componentElement?.querySelector('.usa-header__button') || componentElement?.querySelector('.usa-header__wrapper') || componentElement?.querySelector('.usa-header__list'); if (hasEnhancedElements) { // Only allow critical property updates that need DOM changes const criticalProps = ['disabled', 'required', 'readonly', 'value', 'error', 'placeholder']; const hasCriticalChange = Array.from(changedProperties.keys()).some(prop => criticalProps.includes(prop as string) ); if (!hasCriticalChange) { return false; // Preserve USWDS transformation } } return super.shouldUpdate(changedProperties); } override updated(changedProperties: Map) { super.updated(changedProperties); // Apply captured content using DOM manipulation (avoids directive compatibility issues) this.applySlottedContent(); } private applySlottedContent() { if (this.slottedContent && this.navItems.length === 0) { const slotElement = this.querySelector('slot'); if (slotElement) { slotElement.innerHTML = this.slottedContent; } } } /** * Handle mobile menu toggle */ private handleMobileMenuToggle = (event: Event) => { event.preventDefault(); this.mobileMenuOpen = !this.mobileMenuOpen; this.dispatchEvent( new CustomEvent('mobile-menu-toggle', { detail: { open: this.mobileMenuOpen }, bubbles: true, composed: true, }) ); }; /** * Handle mobile menu close */ private handleMobileMenuClose = (event: Event) => { event.preventDefault(); this.mobileMenuOpen = false; this.dispatchEvent( new CustomEvent('mobile-menu-toggle', { detail: { open: this.mobileMenuOpen }, bubbles: true, composed: true, }) ); }; /** * Handle navigation item clicks */ private handleNavClick = (_event: Event, item: NavItem) => { this.dispatchEvent( new CustomEvent('nav-click', { detail: { label: item.label, href: item.href, }, bubbles: true, composed: true, }) ); }; /** * Handle submenu toggle */ private handleSubmenuToggle = (event: Event) => { event.preventDefault(); const button = event.target as HTMLButtonElement; const submenu = button.nextElementSibling as HTMLElement | null; const isExpanded = button.getAttribute('aria-expanded') === 'true'; // Return early if submenu doesn't exist if (!submenu) { return; } // Close all other submenus const allButtons = this.querySelectorAll('.usa-accordion__button'); const allSubmenus = this.querySelectorAll('.usa-nav__submenu'); allButtons.forEach((btn) => { if (btn !== button) { btn.setAttribute('aria-expanded', 'false'); } }); allSubmenus.forEach((menu) => { if (menu !== submenu) { menu.setAttribute('hidden', 'true'); } }); // Toggle current submenu button.setAttribute('aria-expanded', String(!isExpanded)); if (isExpanded) { submenu.setAttribute('hidden', 'true'); } else { submenu.removeAttribute('hidden'); } }; /** * Handle search form submission from usa-search component */ private handleSearch = (event: CustomEvent) => { // usa-search component already dispatches a 'search-submit' event with { query, form } // We just need to re-dispatch it as 'header-search' for backwards compatibility this.dispatchEvent( new CustomEvent('header-search', { detail: { query: event.detail.query }, bubbles: true, composed: true, }) ); }; private renderLogo() { return html` `; } private renderSecondaryLinks() { if (!this.secondaryLinks || this.secondaryLinks.length === 0) { return html``; } return html` ${this.secondaryLinks.map( (link) => html`
  • ${link.label}
  • ` )} `; } private renderNavItem(item: NavItem): any { const hasMegamenu = item.megamenu && item.megamenu.length > 0; const hasSubmenu = item.submenu && item.submenu.length > 0; // Megamenu (multi-column grid layout) if (hasMegamenu) { const submenuId = `nav-${Math.random().toString(36).substring(2, 11)}`; return html`
  • `; } // Regular submenu (simple list) if (hasSubmenu) { const submenuId = `nav-${Math.random().toString(36).substring(2, 11)}`; return html`
  • `; } // Simple link (no submenu) return html`
  • ${item.label}
  • `; } private renderBasicHeader() { return html`
    ${this.renderLogo()}
    `; } private renderExtendedHeader() { return html`
    ${this.renderLogo()}
    `; } override render() { const headerClasses = [ 'usa-header', this.extended ? 'usa-header--extended' : 'usa-header--basic', ] .filter(Boolean) .join(' '); return html` Skip to main content `; } }