File

src/app/components/header/header.component.ts

Description

CNS Website header component

Metadata

Index

Properties
Methods
Inputs

Constructor

constructor()

Initialize the header

Inputs

menuOptions
Default value : MENUS

Navigation options to display on the header

Methods

closeMenu
closeMenu(menu?: Menu | "mobile")

Closes any active menu

Parameters :
Name Type Optional
menu Menu | "mobile" Yes
Returns : void
isMenuActive
isMenuActive(menu: Menu | "mobile")

Determine whether the specified menu is open

Parameters :
Name Type Optional Description
menu Menu | "mobile" No

The menu to check

Returns : boolean

true if the menu is open, false otherwise

toggleMenu
toggleMenu(menu: Menu | "mobile")

Toggles a menu open or close

Parameters :
Name Type Optional Description
menu Menu | "mobile" No

Menu to toggle

Returns : void

Properties

Protected Readonly desktopMenuMaxHeight
Type : unknown
Default value : computed(() => `calc(100vh - ${this.menuOffsetPx()}px - 16px)`)

Desktop menu max height

Protected Readonly desktopMenuPositions
Type : unknown
Default value : DESKTOP_MENU_POSITIONS

Overlay positions for the desktop menu

Protected Readonly isMobile
Type : unknown
Default value : watchBreakpoint(Breakpoints.Mobile)

Whether the screen is currently mobile sized

Protected Readonly menuOffsetPx
Type : unknown
Default value : signal<number>(0)

Offset from top to the menu. Used to calculate menu heights and max heights

Protected Readonly mobileMenuBlockScroll
Type : unknown
Default value : inject(Overlay).scrollStrategies.block()

Blocking overlay scroll strategy

Protected Readonly mobileMenuHeight
Type : unknown
Default value : computed(() => `calc(100vh - ${this.menuOffsetPx()}px)`)

Mobile menu height. Fills the entire screen

Protected Readonly mobileMenuPositions
Type : unknown
Default value : MOBILE_MENU_POSITIONS

Overlay positions for the mobile menu

Protected Readonly sidebarStore
Type : unknown
Default value : inject(SidebarStore)

Sidebar store for managing sidebar state

import { CdkConnectedOverlay, ConnectedPosition, Overlay, OverlayModule } from '@angular/cdk/overlay';
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  ElementRef,
  inject,
  input,
  signal,
  viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatIconModule } from '@angular/material/icon';
import { EventType } from '@angular/router';
import { Breakpoints, watchBreakpoint } from '@hra-ui/cdk/breakpoints';
import { HraCommonModule } from '@hra-ui/common';
import { injectRouter, RouterExtModule } from '@hra-ui/common/router-ext';
import { ButtonsModule } from '@hra-ui/design-system/buttons';
import { InlineSVGModule } from 'ng-inline-svg-2';
import { explicitEffect } from 'ngxtension/explicit-effect';
import { filter } from 'rxjs';
import { SidebarStore } from '../../state/sidebar/sidebar.store';
import { MegaMenuComponent } from './mega-menu/mega-menu.component';
import { MobileMenuComponent } from './mobile-menu/mobile-menu.component';
import { MENUS } from './static-data/parsed';
import { Menu } from './types/menus.schema';

/** Position of the mobile menu overlay */
const MOBILE_MENU_POSITIONS: ConnectedPosition[] = [
  { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'top' },
];
/** Position of the desktop menu overlay */
const DESKTOP_MENU_POSITIONS: ConnectedPosition[] = [
  { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetX: -16, offsetY: 16 },
];

/**
 * CNS Website header component
 */
@Component({
  selector: 'cns-header',
  imports: [
    HraCommonModule,
    RouterExtModule,
    OverlayModule,
    MatIconModule,
    ButtonsModule,
    InlineSVGModule,
    MobileMenuComponent,
    MegaMenuComponent,
  ],
  templateUrl: './header.component.html',
  styleUrl: './header.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HeaderComponent {
  /** Navigation options to display on the header */
  readonly menuOptions = input(MENUS);

  /** Whether the screen is currently mobile sized */
  protected readonly isMobile = watchBreakpoint(Breakpoints.Mobile);
  /** Reference to this component's html element */
  private readonly elementRef = inject<ElementRef<Element>>(ElementRef);

  /** Sidebar store for managing sidebar state */
  protected readonly sidebarStore = inject(SidebarStore);

  /** Overlay positions for the mobile menu */
  protected readonly mobileMenuPositions = MOBILE_MENU_POSITIONS;
  /** Overlay positions for the desktop menu */
  protected readonly desktopMenuPositions = DESKTOP_MENU_POSITIONS;
  /** Blocking overlay scroll strategy */
  protected readonly mobileMenuBlockScroll = inject(Overlay).scrollStrategies.block();
  /** Offset from top to the menu. Used to calculate menu heights and max heights */
  protected readonly menuOffsetPx = signal<number>(0);
  /** Mobile menu height. Fills the entire screen */
  protected readonly mobileMenuHeight = computed(() => `calc(100vh - ${this.menuOffsetPx()}px)`);
  /** Desktop menu max height */
  protected readonly desktopMenuMaxHeight = computed(() => `calc(100vh - ${this.menuOffsetPx()}px - 16px)`);
  /** Mobile menu overlay origin */
  private readonly mobileMenuOrigin = viewChild.required('mobileMenuOrigin', { read: ElementRef });
  /** Desktop menu overlay origin */
  private readonly desktopMenuOrigin = viewChild.required('desktopMenuOrigin', { read: ElementRef });
  /** Reference to the mobile overlay */
  private readonly mobileMenuOverlay = viewChild('mobileMenuOverlay', { read: CdkConnectedOverlay });
  /** Currently open menu or undefined */
  private readonly activeMenu = signal<Menu | 'mobile' | undefined>(undefined);

  /** Initialize the header */
  constructor() {
    effect((cleanup) => {
      if (this.activeMenu() !== undefined) {
        const observer = this.attachResizeObserver();
        cleanup(() => observer.disconnect());
      }
    });

    explicitEffect([this.menuOffsetPx], () => this.updateMenuPositions(), { defer: true });

    injectRouter({ optional: true })
      ?.events.pipe(
        takeUntilDestroyed(),
        filter((navigationEvent) =>
          [EventType.NavigationEnd, EventType.NavigationSkipped].includes(navigationEvent.type),
        ),
      )
      .subscribe(() => this.closeMenu());
  }

  /**
   * Determine whether the specified menu is open
   *
   * @param menu The menu to check
   * @returns true if the menu is open, false otherwise
   */
  isMenuActive(menu: Menu | 'mobile'): boolean {
    return this.activeMenu() === menu;
  }

  /**
   * Toggles a menu open or close
   *
   * @param menu Menu to toggle
   */
  toggleMenu(menu: Menu | 'mobile'): void {
    this.activeMenu.update((current) => (menu !== current ? menu : undefined));
  }

  /**
   * Closes any active menu
   */
  closeMenu(menu?: Menu | 'mobile'): void {
    this.activeMenu.update((current) => (menu !== undefined && current !== menu ? current : undefined));
  }

  /**
   * Creates and attaches a resize observer that updates the menu offset
   * whenever the header size changes
   *
   * @returns The resize observer
   */
  private attachResizeObserver(): ResizeObserver {
    const observer = new ResizeObserver(() => this.updateMenuOffset());
    observer.observe(this.elementRef.nativeElement, { box: 'border-box' });
    this.updateMenuOffset();
    return observer;
  }

  /**
   * Computes the bounding box for the menu's overlay origin element
   *
   * @returns The computed bounding box
   */
  private getMenuOriginBbox(): DOMRect {
    const origin = this.isMobile() ? this.mobileMenuOrigin() : this.desktopMenuOrigin();
    return (origin.nativeElement as Element).getBoundingClientRect();
  }

  /**
   * Updates the menu offset based on the overlay origin's bounding box
   */
  private updateMenuOffset(): void {
    const { bottom } = this.getMenuOriginBbox();
    this.menuOffsetPx.set(bottom);
  }

  /**
   * Notify menu overlays of position changes
   */
  private updateMenuPositions(): void {
    /* istanbul ignore next */
    this.mobileMenuOverlay()?.overlayRef?.updatePosition();
  }
}
<header hraFeature="header" class="header" cdkOverlayOrigin data-testid="header" #mobileMenuOrigin="cdkOverlayOrigin">
  <div hraFeature="navigation" class="header-content" #desktopMenuOrigin>
    @if (sidebarStore.hasSidebar()) {
      <button mat-icon-button aria-label="Toggle sidebar menu" (click)="sidebarStore.toggle()">
        <mat-icon>tune</mat-icon>
      </button>
    }
    <a
      class="logo"
      hraFeature="logo"
      hraClickEvent
      aria-label="Visit CNS"
      hraLink="https://cns.iu.edu/"
      aria-label="Visit CNS home page"
      [inlineSVG]="'assets/cns_header_logo.svg' | assetUrl"
    >
    </a>

    <div class="filler"></div>

    @if (isMobile()) {
      <button
        hraFeature="menu-toggle"
        mat-icon-button
        aria-label="Open the main navigation menu"
        [hraClickEvent]="{ action: 'toggle-menu', isMobile: isMobile(), currentState: isMenuActive('mobile') }"
        (click)="toggleMenu('mobile')"
      >
        <mat-icon>menu</mat-icon>
      </button>
    } @else {
      @for (category of menuOptions().options; track $index) {
        @if (category.type === 'menu') {
          <hra-navigation-category-toggle
            hraClickEvent
            hraHoverEvent
            cdkOverlayOrigin
            class="navigation-menu"
            [hraFeature]="category.id | slugify"
            [toggled]="isMenuActive(category)"
            (toggledChange)="toggleMenu(category)"
          >
            {{ category.label }}
          </hra-navigation-category-toggle>

          <ng-template
            cdkConnectedOverlay
            cdkConnectedOverlayHasBackdrop
            cdkConnectedOverlayBackdropClass="menu-open-backdrop"
            cdkConnectedOverlayLockPosition="true"
            cdkConnectedOverlayPush="true"
            [cdkConnectedOverlayOpen]="isMenuActive(category)"
            [cdkConnectedOverlayOrigin]="desktopMenuOrigin"
            [cdkConnectedOverlayPositions]="desktopMenuPositions"
            (overlayOutsideClick)="closeMenu(category)"
            (detach)="closeMenu(category)"
          >
            <cns-mega-menu [menu]="category" />
          </ng-template>
        } @else {
          <a
            mat-button
            hraSecondaryButton
            class="item-label"
            [hraFeature]="category.label | slugify"
            [hraLink]="category.url"
            [hraLinkExternal]="category.external"
          >
            {{ category.label }}
          </a>
        }
      }
    }

    @if (isMobile()) {
      <ng-template
        cdkConnectedOverlay
        cdkConnectedOverlayDisposeOnNavigation="true"
        cdkConnectedOverlayHasBackdrop="false"
        cdkConnectedOverlayLockPosition="true"
        cdkConnectedOverlayHeight="100%"
        cdkConnectedOverlayWidth="100%"
        [cdkConnectedOverlayOpen]="isMenuActive('mobile')"
        [cdkConnectedOverlayOrigin]="mobileMenuOrigin"
        [cdkConnectedOverlayPositions]="mobileMenuPositions"
        [cdkConnectedOverlayScrollStrategy]="mobileMenuBlockScroll"
        (detach)="closeMenu()"
        #mobileMenuOverlay
      >
        <cns-mobile-menu hraFeature="mobile-menu" [menuOptions]="menuOptions()" (closeMenu)="toggleMenu('mobile')" />
      </ng-template>
    }
  </div>
</header>
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""