🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal, computed, inject, OnInit, OnDestroy, HostListener } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { RouterModule } from '@angular/router';
|
|
import { Subject, takeUntil } from 'rxjs';
|
|
import { faBars, faXmark } from '@fortawesome/free-solid-svg-icons';
|
|
import { LandingHeaderConfig, NavigationItem, LogoConfig } from '../../interfaces/navigation.interfaces';
|
|
import { CTAButton } from '../../interfaces/shared.interfaces';
|
|
import { ButtonComponent, ContainerComponent, FlexComponent, IconButtonComponent } from 'ui-essentials';
|
|
|
|
@Component({
|
|
selector: 'ui-lp-header',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
RouterModule,
|
|
ButtonComponent,
|
|
ContainerComponent,
|
|
FlexComponent,
|
|
IconButtonComponent
|
|
],
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
encapsulation: ViewEncapsulation.None,
|
|
template: `
|
|
<header
|
|
class="ui-lp-header"
|
|
[class.ui-lp-header--transparent]="config().transparent"
|
|
[class.ui-lp-header--sticky]="config().sticky"
|
|
[class.ui-lp-header--scrolled]="isScrolled()"
|
|
[class.ui-lp-header--mobile-menu-open]="mobileMenuOpen()"
|
|
[class.ui-lp-header--theme-dark]="config().theme === 'dark'"
|
|
[attr.aria-label]="'Main navigation'">
|
|
|
|
<ui-container [size]="config().size || 'xl'" [padding]="'md'">
|
|
<ui-flex [justify]="'between'" [align]="'center'" class="ui-lp-header__content">
|
|
|
|
<!-- Logo Section -->
|
|
<div class="ui-lp-header__logo-section">
|
|
@if (config().logo.imageUrl) {
|
|
<a
|
|
[href]="config().logo.url || '/'"
|
|
[routerLink]="config().logo.url || '/'"
|
|
class="ui-lp-header__logo-link">
|
|
<img
|
|
[src]="config().logo.imageUrl"
|
|
[alt]="config().logo.text || 'Logo'"
|
|
[width]="config().logo.width || 120"
|
|
[height]="config().logo.height || 40"
|
|
class="ui-lp-header__logo-image">
|
|
</a>
|
|
} @else if (config().logo.text) {
|
|
<a
|
|
[href]="config().logo.url || '/'"
|
|
[routerLink]="config().logo.url || '/'"
|
|
class="ui-lp-header__logo-text">
|
|
{{ config().logo.text }}
|
|
</a>
|
|
}
|
|
</div>
|
|
|
|
<!-- Desktop Navigation -->
|
|
<nav class="ui-lp-header__nav ui-lp-header__nav--desktop" [attr.aria-label]="'Primary navigation'">
|
|
<ul class="ui-lp-header__nav-list">
|
|
@for (item of config().navigation; track item.id) {
|
|
<li class="ui-lp-header__nav-item">
|
|
@if (item.children && item.children.length > 0) {
|
|
<!-- Dropdown Menu Item -->
|
|
<button
|
|
class="ui-lp-header__nav-link ui-lp-header__nav-link--dropdown"
|
|
[attr.aria-expanded]="false"
|
|
[attr.aria-haspopup]="'true'"
|
|
(click)="toggleDropdown(item.id)"
|
|
type="button">
|
|
{{ item.label }}
|
|
<span class="ui-lp-header__nav-arrow" aria-hidden="true">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
<path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"/>
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
|
|
@if (openDropdown() === item.id) {
|
|
<div class="ui-lp-header__dropdown" role="menu">
|
|
@for (child of item.children; track child.id) {
|
|
<a
|
|
[href]="child.url"
|
|
[routerLink]="child.route"
|
|
[target]="child.target || '_self'"
|
|
class="ui-lp-header__dropdown-item"
|
|
role="menuitem"
|
|
(click)="handleNavClick(child)">
|
|
@if (child.icon) {
|
|
<span class="ui-lp-header__nav-icon">{{ child.icon }}</span>
|
|
}
|
|
{{ child.label }}
|
|
@if (child.badge) {
|
|
<span class="ui-lp-header__nav-badge">{{ child.badge }}</span>
|
|
}
|
|
</a>
|
|
}
|
|
</div>
|
|
}
|
|
} @else {
|
|
<!-- Regular Menu Item -->
|
|
<a
|
|
[href]="item.url"
|
|
[routerLink]="item.route"
|
|
[target]="item.target || '_self'"
|
|
class="ui-lp-header__nav-link"
|
|
(click)="handleNavClick(item)">
|
|
@if (item.icon) {
|
|
<span class="ui-lp-header__nav-icon">{{ item.icon }}</span>
|
|
}
|
|
{{ item.label }}
|
|
@if (item.badge) {
|
|
<span class="ui-lp-header__nav-badge">{{ item.badge }}</span>
|
|
}
|
|
</a>
|
|
}
|
|
</li>
|
|
}
|
|
</ul>
|
|
</nav>
|
|
|
|
<!-- Actions Section -->
|
|
<div class="ui-lp-header__actions">
|
|
@if (config().ctaButton) {
|
|
<ui-button
|
|
[variant]="config().ctaButton!.variant"
|
|
[size]="config().ctaButton!.size || 'medium'"
|
|
class="ui-lp-header__cta-button"
|
|
(clicked)="handleCTAClick()">
|
|
@if (config().ctaButton!.icon) {
|
|
<span class="ui-lp-header__cta-icon">{{ config().ctaButton!.icon }}</span>
|
|
}
|
|
{{ config().ctaButton!.text }}
|
|
</ui-button>
|
|
}
|
|
|
|
<!-- Mobile Menu Toggle -->
|
|
@if (config().showMobileMenu !== false) {
|
|
<ui-icon-button
|
|
[icon]="mobileMenuOpen() ? faXmark : faBars"
|
|
[size]="'medium'"
|
|
class="ui-lp-header__mobile-toggle"
|
|
[attr.aria-expanded]="mobileMenuOpen()"
|
|
[attr.aria-label]="mobileMenuOpen() ? 'Close menu' : 'Open menu'"
|
|
(clicked)="toggleMobileMenu()">
|
|
</ui-icon-button>
|
|
}
|
|
</div>
|
|
</ui-flex>
|
|
</ui-container>
|
|
|
|
<!-- Mobile Navigation -->
|
|
@if (mobileMenuOpen() && config().showMobileMenu !== false) {
|
|
<nav class="ui-lp-header__nav ui-lp-header__nav--mobile" [attr.aria-label]="'Mobile navigation'">
|
|
<ui-container [size]="config().size || 'xl'" [padding]="'md'">
|
|
<ul class="ui-lp-header__mobile-list">
|
|
@for (item of config().navigation; track item.id) {
|
|
<li class="ui-lp-header__mobile-item">
|
|
@if (item.children && item.children.length > 0) {
|
|
<!-- Mobile Dropdown -->
|
|
<button
|
|
class="ui-lp-header__mobile-link ui-lp-header__mobile-link--dropdown"
|
|
[attr.aria-expanded]="openMobileDropdown() === item.id"
|
|
(click)="toggleMobileDropdown(item.id)"
|
|
type="button">
|
|
{{ item.label }}
|
|
</button>
|
|
|
|
@if (openMobileDropdown() === item.id) {
|
|
<ul class="ui-lp-header__mobile-submenu">
|
|
@for (child of item.children; track child.id) {
|
|
<li>
|
|
<a
|
|
[href]="child.url"
|
|
[routerLink]="child.route"
|
|
[target]="child.target || '_self'"
|
|
class="ui-lp-header__mobile-sublink"
|
|
(click)="handleNavClick(child)">
|
|
{{ child.label }}
|
|
</a>
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
} @else {
|
|
<!-- Regular Mobile Item -->
|
|
<a
|
|
[href]="item.url"
|
|
[routerLink]="item.route"
|
|
[target]="item.target || '_self'"
|
|
class="ui-lp-header__mobile-link"
|
|
(click)="handleNavClick(item)">
|
|
{{ item.label }}
|
|
</a>
|
|
}
|
|
</li>
|
|
}
|
|
|
|
@if (config().ctaButton) {
|
|
<li class="ui-lp-header__mobile-cta">
|
|
<ui-button
|
|
[variant]="config().ctaButton!.variant"
|
|
[size]="'large'"
|
|
[fullWidth]="true"
|
|
(clicked)="handleCTAClick()">
|
|
{{ config().ctaButton!.text }}
|
|
</ui-button>
|
|
</li>
|
|
}
|
|
</ul>
|
|
</ui-container>
|
|
</nav>
|
|
}
|
|
|
|
<!-- Mobile Menu Backdrop -->
|
|
@if (mobileMenuOpen()) {
|
|
<div
|
|
class="ui-lp-header__backdrop"
|
|
(click)="closeMobileMenu()"
|
|
aria-hidden="true">
|
|
</div>
|
|
}
|
|
</header>
|
|
`,
|
|
styleUrl: './landing-header.component.scss'
|
|
})
|
|
export class LandingHeaderComponent implements OnInit, OnDestroy {
|
|
private destroy$ = new Subject<void>();
|
|
|
|
// FontAwesome icons
|
|
faXmark = faXmark;
|
|
faBars = faBars;
|
|
|
|
config = signal<LandingHeaderConfig>({
|
|
logo: { text: 'Logo' },
|
|
navigation: [],
|
|
transparent: false,
|
|
sticky: true,
|
|
showMobileMenu: true,
|
|
size: 'xl',
|
|
theme: 'light'
|
|
});
|
|
|
|
isScrolled = signal<boolean>(false);
|
|
mobileMenuOpen = signal<boolean>(false);
|
|
openDropdown = signal<string | null>(null);
|
|
openMobileDropdown = signal<string | null>(null);
|
|
|
|
@Input() set configuration(value: LandingHeaderConfig) {
|
|
this.config.set(value);
|
|
}
|
|
|
|
@Output() navigationClicked = new EventEmitter<NavigationItem>();
|
|
@Output() ctaClicked = new EventEmitter<CTAButton>();
|
|
|
|
ngOnInit(): void {
|
|
if (this.config().sticky) {
|
|
this.setupScrollListener();
|
|
}
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
}
|
|
|
|
@HostListener('window:scroll', [])
|
|
onWindowScroll(): void {
|
|
if (this.config().sticky) {
|
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
this.isScrolled.set(scrollTop > 10);
|
|
}
|
|
}
|
|
|
|
@HostListener('document:click', ['$event'])
|
|
onDocumentClick(event: Event): void {
|
|
const target = event.target as HTMLElement;
|
|
if (!target.closest('.ui-lp-header__nav-link--dropdown')) {
|
|
this.openDropdown.set(null);
|
|
}
|
|
}
|
|
|
|
@HostListener('window:resize', [])
|
|
onWindowResize(): void {
|
|
if (window.innerWidth > 768) {
|
|
this.mobileMenuOpen.set(false);
|
|
}
|
|
}
|
|
|
|
private setupScrollListener(): void {
|
|
// Initial scroll check
|
|
this.onWindowScroll();
|
|
}
|
|
|
|
toggleMobileMenu(): void {
|
|
this.mobileMenuOpen.set(!this.mobileMenuOpen());
|
|
this.openMobileDropdown.set(null);
|
|
|
|
// Prevent body scroll when mobile menu is open
|
|
document.body.style.overflow = this.mobileMenuOpen() ? 'hidden' : '';
|
|
}
|
|
|
|
closeMobileMenu(): void {
|
|
this.mobileMenuOpen.set(false);
|
|
this.openMobileDropdown.set(null);
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
toggleDropdown(itemId: string): void {
|
|
this.openDropdown.set(this.openDropdown() === itemId ? null : itemId);
|
|
}
|
|
|
|
toggleMobileDropdown(itemId: string): void {
|
|
this.openMobileDropdown.set(this.openMobileDropdown() === itemId ? null : itemId);
|
|
}
|
|
|
|
handleNavClick(item: NavigationItem): void {
|
|
if (item.action) {
|
|
item.action();
|
|
}
|
|
this.navigationClicked.emit(item);
|
|
this.closeMobileMenu();
|
|
this.openDropdown.set(null);
|
|
}
|
|
|
|
handleCTAClick(): void {
|
|
const cta = this.config().ctaButton;
|
|
if (cta) {
|
|
cta.action();
|
|
this.ctaClicked.emit(cta);
|
|
}
|
|
}
|
|
} |