File

libs/ui/navigation/src/lib/nav/navigation.component.ts

Description

Define the expected response from the TsNavigationComponent emitter

Index

Properties

Properties

action
action: literal type
Type : literal type

The selected item

event
event: MouseEvent
Type : MouseEvent

The mouse click event

import {
  AfterViewInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ElementRef,
  EventEmitter,
  HostListener,
  Input,
  OnInit,
  Output,
  QueryList,
  ViewChild,
  ViewChildren,
  ViewEncapsulation,
} from '@angular/core';
import { faCaretDown } from '@fortawesome/pro-solid-svg-icons/faCaretDown';
import { BehaviorSubject } from 'rxjs';

import { groupBy } from '@terminus/fe-utilities';

/**
 * Define the user object interface
 */
export interface TsUser {
  /**
   * The user's full name
   */
  fullName: string;
}

/**
 * Base allowed keys for an item passed to the {@link TsNavigationComponent}
 */
export interface NavigationItemBase {
  /**
   * The value to use as the item text
   */
  name: string;
  /**
   * Define if the item should only be allowed in the secondary navigation
   */
  // TODO: API change for clarity
  alwaysHidden: boolean;
  /**
   * Define if the item is disabled
   */
  isDisabled?: boolean;
  /**
   * Define if the item is for admin functionality only
   */
  // TODO: API change for clarity. Should likely be something more general that defines how the link is treated rather than where it takes
  // the user.
  isForAdmin?: boolean;
}

/**
 * Link specific keys for an item passed to the {@link TsNavigationComponent}
 */
export interface TsNavigationLinkItem extends NavigationItemBase {
  /**
   * The destination for items with a 'navigate' action. Single strings are used for external
   * locations while an array of strings are used for routerLinks
   */
  destination: string | string[];
  /**
   * Whether this link should navigate via the router or standard href
   */
  isExternal?: boolean;
}

/**
 * Action specific keys for an item passed to the {@link TsNavigationComponent}
 */
export interface TsNavigationActionItem extends NavigationItemBase {
  /**
   * The action to emit upon interaction
   */
  action: {
    type: string;
  };
}

/**
 * Determine if a navigation item is a {@link TsNavigationLinkItem}
 *
 * @param x - The item to check
 * @returns True if the item is a TsNavigationLinkItem
 */
export const isLinkItem =
  (x: TsNavigationLinkItem | TsNavigationActionItem): x is TsNavigationLinkItem => !!(x as TsNavigationLinkItem).destination;

/**
 * Define the allowed keys and types for an item passed to the {@link TsNavigationComponent}
 */
export type TsNavigationItem = TsNavigationLinkItem | TsNavigationActionItem;

/**
 * Define the expected response from the {@link TsNavigationComponent} emitter
 */
export interface TsNavigationPayload {
  /**
   * The mouse click event
   */
  event: MouseEvent;

  /**
   * The selected item
   */
  action: {
    type: string;
  };
}

const DEFAULT_USER_NAME_MAX_LENGTH = 20;
const DEFAULT_WELCOME_MESSAGE_MAX_LENGTH = 20;

/**
 * A responsive, horizontal navigation.
 *
 * @example
 * <ts-navigation
 *              [items]="navigationItems$ | async"
 *              [user]="currentUser$ | async"
 *              userNameLength="20"
 *              welcomeMessage="Hi!"
 *              welcomeMsgLength="25"
 *              (action)="myMethod($event)"
 * ></ts-navigation>
 *
 * <example-url>https://getterminus.github.io/ui-demos-release/components/navigation</example-url>
 */
@Component({
  selector: 'ts-navigation',
  templateUrl: './navigation.component.html',
  styleUrls: ['./navigation.component.scss'],
  host: { class: 'ts-navigation' },
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush,
  exportAs: 'tsNavigation',
})
export class TsNavigationComponent implements OnInit, AfterViewInit {
  /**
   * Store a pristine copy of the navigation items
   */
  private pristineItems!: TsNavigationItem[];

  /**
   * Getter to return the available navigation width
   *
   * @returns The available navigation space
   */
  private get availableSpace(): number {
    const NAV_WIDTH_BUFFER = 10;

    return this.visibleItemsList.nativeElement.offsetWidth - NAV_WIDTH_BUFFER;
  }

  /**
   * Define an array of widths at which to break the navigation
   */
  private breakWidths: number[] = [];

  /**
   * Define the list of hidden items
   */
  public hiddenItems: BehaviorSubject<TsNavigationItem[]> = new BehaviorSubject([] as TsNavigationItem[]);

  /**
   * Define the dropdown icon
   */
  public iconDropDown = faCaretDown;

  /**
   * Getter to return the user's full name if it exists
   *
   * @returns The user's full name
   */
  public get usersFullName(): string | null {
    const userExists = !!this.user;
    const nameExists = userExists && (this.user.fullName.length > 0);

    return (userExists && nameExists) ? this.user.fullName : null;
  }

  /**
   * The collection of visible navigation items
   */
  public visibleItems = new BehaviorSubject<TsNavigationItem[]>([]);

  /**
   * Getter to return the count of visible items
   *
   * @returns The number of visible items
   */
  public get visibleItemsLength(): number {
    return this.visibleItems.getValue().length;
  }

  /**
   * Accept the array of navigation items and trigger setup
   *
   * @param value
   */
  @Input()
  public set items(value: TsNavigationItem[]) {
    // Filter out disabled items
    this.pristineItems = value.filter((item: TsNavigationItem) => !item.isDisabled);
    this.setUpInitialArrays(this.pristineItems);
    this.generateBreakWidths();
    this.updateLists();
  }

  /**
   * Accept the user data
   */
  @Input()
  public user!: TsUser;

  /**
   * Define the user name length
   */
  @Input()
  public userNameLength = DEFAULT_USER_NAME_MAX_LENGTH;

  /**
   * Define the welcome message
   */
  @Input()
  public welcomeMessage = 'Welcome';

  /**
   * Define the welcome message length
   */
  @Input()
  public welcomeMsgLength = DEFAULT_WELCOME_MESSAGE_MAX_LENGTH;

  /**
   * Element reference for visible list items
   */
  @ViewChild('visibleItemsList', { static: true })
  public visibleItemsList!: ElementRef;

  /**
   * Query list of all elements from the visible items list
   */
  @ViewChildren('visibleLinkElement')
  public visibleLinkElement!: QueryList<ElementRef>;

  /**
   * Emit the click event with the {@link TsNavigationPayload}
   */
  @Output()
  public readonly action: EventEmitter<TsNavigationPayload> = new EventEmitter();

  /**
   * Trigger a layout update when the window resizes
   */
  @HostListener('window:resize')
  public onResize(): void {
    this.updateLists();
  }

  constructor(private changeDetectorRef: ChangeDetectorRef) {}

  /**
   * Set up initial link groups
   */
  public ngOnInit(): void {
    this.setUpInitialArrays(this.pristineItems);
  }

  /**
   * Trigger initial layout update after the view initializes
   */
  public ngAfterViewInit(): void {
    this.generateBreakWidths();
    this.updateLists();
    this.changeDetectorRef.detectChanges();
  }

  /**
   * Generate the array of breakWidths
   */
  private generateBreakWidths(): void {
    let totalSpace = 0;
    this.breakWidths.length = 0;

    // Loop through the visible links
    this.visibleLinkElement.forEach((item: ElementRef) => {
      // Tally up the total space
      totalSpace += item.nativeElement.offsetWidth;
      // Add the total space as a breakpoint
      this.breakWidths.push(totalSpace);
    });
  }

  /**
   * Clone the nav items and split into the initially visible/hidden lists
   *
   * @param items - The complete list of navigation items
   */
  private setUpInitialArrays(items: TsNavigationItem[]): void {
    // Clone the items and define the external flag for links only
    const allItems = items.map(i => {
      const item: TsNavigationItem = { ...i };
      if (isLinkItem(item)) {
        item.isExternal = this.isExternalLink(item.destination);
      }
      return item;
    });

    // Create an object with the arrays separated
    const splitArrays = groupBy(allItems, 'alwaysHidden');

    // Push the separated arrays
    this.visibleItems.next(splitArrays.false);
    this.hiddenItems.next(splitArrays.true);

    this.changeDetectorRef.detectChanges();
  }

  /**
   * Move items between the two lists as required by the available space
   */
  private updateLists(): void {
    const requiredSpace = this.breakWidths[this.visibleItemsLength - 1];

    // If there is not enough space
    if (requiredSpace > this.availableSpace) {
      // Pull the last link out of the visible array
      const currentVisible: TsNavigationItem[] = this.visibleItems.getValue();
      const itemToMove = currentVisible.pop();
      const updatedHiddenArray: TsNavigationItem[] = this.hiddenItems.getValue();

      // If an item was found, add it to the beginning of the hidden items array
      // istanbul ignore else
      if (itemToMove) {
        updatedHiddenArray.unshift(itemToMove);
      }

      // Push out the updated value
      this.hiddenItems.next(updatedHiddenArray);

      // Trigger another layout check
      this.updateLists();
    } else if (this.availableSpace > this.breakWidths[this.visibleItemsLength]) {
      // Else, if there is more than enough space

      // Pull the first item from the hidden array
      const currentHidden = this.hiddenItems.getValue();
      const itemToMove = currentHidden.shift();
      const updatedVisibleArray: TsNavigationItem[] = this.visibleItems.getValue();

      // If an item was found, add it to the beginning of the hidden items array
      // istanbul ignore else
      if (itemToMove) {
        updatedVisibleArray.push(itemToMove);
      }

      // Add it to the end of the visible array
      this.visibleItems.next(updatedVisibleArray);
    }

    this.changeDetectorRef.detectChanges();
  }

  /**
   * If the destination is a string and begins with `http`
   *
   * @param destination - The destination to check
   * @returns Boolean determining if the link is external
   */
  public isExternalLink(destination: string | string[]): boolean {
    return destination.indexOf('http') >= 0;
  }

  /**
   * Function for tracking for-loops changes
   *
   * @param index - The item index
   * @returns The unique ID
   */
  public trackByFn(index): number {
    return index;
  }
}

result-matching ""

    No results matching ""