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`
this.handleNavClick(e, item)}"
>
${item.label}
`;
}
private renderBasicHeader() {
return html`
`;
}
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
`;
}
}