File

libs/common/frame/src/frame/frame.component.ts

Implements

OnInit AfterViewInit OnDestroy

Metadata

Index

Properties
Methods
Inputs
HostBindings
Accessors

Constructor

constructor(channel: CHANNEL, frameLayoutService: TalyFrameLayoutService, sidebarService: TalyFrameSidebarService, pageDataService: TalyPageDataService, frameNavigationService: TalyFrameNavigationService, el: ElementRef, renderer: Renderer2)
Parameters :
Name Type Optional
channel CHANNEL No
frameLayoutService TalyFrameLayoutService No
sidebarService TalyFrameSidebarService No
pageDataService TalyPageDataService No
frameNavigationService TalyFrameNavigationService No
el ElementRef No
renderer Renderer2 No

Inputs

centered
Type : boolean
chromeless
Type : boolean
Default value : false
footerConfig
Type : FooterConfiguration
headerLogoLinkUrl
Type : string
logoSrc
Type : string
navigationConfig
Type : NavigationConfig
offerCodeStateKey
Type : string
sidebar
Type : boolean
Default value : false
spinner
Type : boolean
Default value : false
stageConfig
Type : StageConfiguration
title
Type : string

HostBindings

class.collapsed-navigation
Type : boolean
Default value : false
class.has-navigation
Type : boolean
class.is-stacked
Type : boolean
Default value : false
class.no-header
Type : any

Methods

disableBackgroundScrolling
disableBackgroundScrolling()
Returns : void
onResize
onResize(value: number)
Parameters :
Name Type Optional
value number No
Returns : void

Properties

actionsBackgroundColor
Type : string | undefined
Default value : 'transparent'
collapsedNavigation
Default value : false
Decorators :
@HostBinding('class.collapsed-navigation')
errorMessage
Type : string | undefined
Public Optional footerTemplate
Type : TemplateRef<FooterConfiguration>
Decorators :
@ContentChild('footerTemplate', {static: true, read: TemplateRef})
Public frameLayoutService
Type : TalyFrameLayoutService
Public frameNavigationService
Type : TalyFrameNavigationService
hasBannerContent
Default value : false
hasJumpNavigationMenu
Default value : false
hasStage$
Type : Observable<boolean>
Public Optional headerTemplate
Type : TemplateRef<>
Decorators :
@ContentChild('headerTemplate', {static: true, read: TemplateRef})
isRetailChannel
Type : boolean
offerCode$
Type : Observable<string | undefined> | undefined
Public sidebarService
Type : TalyFrameSidebarService
stackedLayout
Default value : false
Decorators :
@HostBinding('class.is-stacked')
useNewVerticalSpacing
Default value : inject(USE_NEW_VERTICAL_SPACING)

Accessors

isHeaderHidden
getisHeaderHidden()
centered
getcentered()
setcentered(value: boolean)
Parameters :
Name Type Optional
value boolean No
Returns : void
hasNavigation
gethasNavigation()
import { AclService } from '@allianz/taly-acl/angular';
import { WEB_COMPONENT_ID } from '@allianz/taly-common/web-components';
import {
  CHANNEL,
  CHANNEL_TOKEN,
  TalyPageDataService,
  TalyStickyService,
  USE_NEW_VERTICAL_SPACING
} from '@allianz/taly-core';
import { FooterConfiguration, StageConfiguration } from '@allianz/taly-core/schemas';
import {
  AfterViewInit,
  Component,
  ContentChild,
  DestroyRef,
  ElementRef,
  HostBinding,
  Inject,
  inject,
  Input,
  NgZone,
  OnDestroy,
  OnInit,
  Renderer2,
  TemplateRef,
  ViewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { filter, map, skip, take, type Observable } from 'rxjs';
import { NavigationConfig } from '../frame-parts/navigation/model';
import { TalyFrameBannerService } from '../services/banner.service';
import { TalyFrameLayoutService } from '../services/frame-layout.service';
import { TalyFrameSidebarService } from '../services/sidebar.service';
import { TalyFrameNavigationService } from '../services/taly-frame-navigation-service';

@Component({
  selector: 'taly-frame',
  templateUrl: './frame.component.html',
  styleUrls: ['./frame.component.scss'],
  standalone: false
})
export class FrameComponent implements OnInit, AfterViewInit, OnDestroy {
  @Input()
  chromeless = false;

  @HostBinding('class.no-header')
  get isHeaderHidden() {
    return this.chromeless || this.aclService.isHidden('taly-frame-header');
  }

  @Input() title?: string;
  @Input() logoSrc?: string;

  private _centered = false;

  @Input()
  @HostBinding('class.is-centered')
  set centered(value: boolean) {
    this._centered = value;
  }

  get centered() {
    return this._centered || !this.hasNavigation;
  }

  @HostBinding('class.is-stacked')
  stackedLayout = false;

  @HostBinding('class.collapsed-navigation')
  collapsedNavigation = false;

  @Input()
  @HostBinding('class.has-sidebar')
  sidebar = false;

  @HostBinding('class.has-navigation')
  get hasNavigation() {
    if (
      this.navigationConfig?.sections?.size === 0 ||
      this.pageDataService.pageData?.navigation?.hidden
    ) {
      return false;
    }

    return true;
  }

  @Input() spinner = false;

  @Input() headerLogoLinkUrl?: string;

  @Input() footerConfig?: FooterConfiguration;
  @ContentChild('footerTemplate', { static: true, read: TemplateRef })
  public footerTemplate?: TemplateRef<FooterConfiguration>;

  @Input() stageConfig?: StageConfiguration;

  @Input() navigationConfig?: NavigationConfig;

  @Input() offerCodeStateKey?: string;

  @ContentChild('headerTemplate', { static: true, read: TemplateRef })
  public headerTemplate?: TemplateRef<unknown>;

  hasStage$!: Observable<boolean>;
  offerCode$: Observable<string | undefined> | undefined;
  actionsBackgroundColor: string | undefined = 'transparent';
  errorMessage: string | undefined;
  hasBannerContent = false;
  hasJumpNavigationMenu = false;

  private domChanges!: MutationObserver;
  private lastNotificationsDisplayedTotal = 0;
  isRetailChannel: boolean;

  @ViewChild('frameContent', { read: ElementRef }) private frameContent!: ElementRef<HTMLElement>;
  @ViewChild('frameHeader', { read: ElementRef }) private frameHeader:
    | ElementRef<HTMLElement>
    | undefined;
  @ViewChild('frameBanner', { read: ElementRef }) private frameBanner!: ElementRef<HTMLElement>;

  private destroyRef = inject(DestroyRef);
  private aclService = inject(AclService);
  private bannerService = inject(TalyFrameBannerService);
  private stickyService = inject(TalyStickyService);
  useNewVerticalSpacing = inject(USE_NEW_VERTICAL_SPACING);
  private webComponentId = inject(WEB_COMPONENT_ID, { optional: true });

  private router = inject(Router);
  protected initialRoutingDone = false;

  private ngZone = inject(NgZone);

  constructor(
    @Inject(CHANNEL_TOKEN) channel: CHANNEL,
    public frameLayoutService: TalyFrameLayoutService,
    public sidebarService: TalyFrameSidebarService,
    private pageDataService: TalyPageDataService,
    public frameNavigationService: TalyFrameNavigationService,
    private el: ElementRef,
    private renderer: Renderer2
  ) {
    this.isRetailChannel = channel === CHANNEL.RETAIL;

    this.router.events
      .pipe(
        filter((event) => event instanceof NavigationEnd),
        take(1)
      )
      .subscribe(() => {
        this.initialRoutingDone = true;
      });
  }

  ngOnInit(): void {
    this.setSubscriptions();
    this.setOfferCode();
    this.disableBackgroundScrolling();
    this.setJumpNavigationMenuFlag();
  }

  private setJumpNavigationMenuFlag() {
    this.hasJumpNavigationMenu = Boolean(
      this.navigationConfig?.jumpNavigationMenu &&
        this.navigationConfig?.jumpNavigationMenu.items.length > 0
    );
  }

  onResize(value: number) {
    this.el.nativeElement.style.setProperty('--navigation-width', `${value}px`);
  }

  private setOfferCode() {
    if (this.offerCodeStateKey) {
      this.offerCode$ = this.frameNavigationService.getOfferCode$(this.offerCodeStateKey);
    }
  }

  private setSubscriptions() {
    this.frameLayoutService.isStackedLayoutObservable
      .pipe(filter(() => !this.hasJumpNavigationMenu))
      .subscribe((value) => (this.stackedLayout = value));
    this.frameLayoutService.collapseNavigationUser$.subscribe(
      (value) => (this.collapsedNavigation = value)
    );
    this.sidebarService.setVisibility(this.sidebar);
    this.sidebarService.show$.subscribe((value) => (this.sidebar = value));

    this.hasStage$ = this.pageDataService.pageData$.pipe(
      takeUntilDestroyed(this.destroyRef),
      map((pageData) => Object.keys(pageData.stage ?? {}).length > 0)
    );

    this.bannerService.template$
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe((template) => (this.hasBannerContent = Boolean(template)));

    this.pageDataService.pageId$
      .pipe(
        takeUntilDestroyed(this.destroyRef),
        // in case of a web component:
        // skip the first emitted page ID to avoid focusing something.
        // Focusing automatically scrolls the focused element into view.
        // We don't want that behavior for web components.
        // See https://github.developer.allianz.io/ilt/taly-workspace/issues/2546
        skip(this.webComponentId ? 1 : 0)
      )
      .subscribe(() => setTimeout(() => this.setFocus()));
  }

  ngAfterViewInit(): void {
    this.listenToDomChanges();
    this.stickyService.addStickyElement(this.frameBanner);
    if (!this.isRetailChannel && this.frameHeader) {
      this.stickyService.addStickyElement(this.frameHeader);
    }
  }

  disableBackgroundScrolling(): void {
    if (this.spinner) {
      this.frameNavigationService.applicationBusy$
        .pipe(takeUntilDestroyed(this.destroyRef))
        .subscribe((isApplicationBusy: boolean) => {
          const overflowValue = isApplicationBusy ? 'hidden' : 'auto';
          this.renderer.setStyle(document.body, 'overflow', overflowValue);
        });
    }
  }

  private listenToDomChanges(): void {
    const hasContent = (section: HTMLElement) =>
      Boolean(section.querySelector('.building-block-element'));

    this.ngZone.runOutsideAngular(() => {
      this.domChanges = new MutationObserver(() => {
        this.manageFirstVisibleContentAreaSection();
        this.scrollToNotificationsIfNecessary();

        const allSections: HTMLElement[] = Array.from(
          this.frameContent.nativeElement.querySelectorAll('.section-wrapper')
        );

        const allPanels: HTMLElement[] = Array.from(
          this.frameContent.nativeElement.querySelectorAll('.panel-wrapper')
        );

        [...allSections, ...allPanels].forEach((item) => {
          item.classList.toggle('is-empty', !hasContent(item));
        });

        if (this.isRetailChannel && !this.useNewVerticalSpacing) {
          const lastVisibleSection: HTMLElement | undefined = allSections
            .reverse()
            .find(hasContent);

          this.actionsBackgroundColor =
            lastVisibleSection?.style?.getPropertyValue('--background-color') || 'transparent';
        }
      });
    });

    this.domChanges.observe(this.frameContent.nativeElement, {
      subtree: true,
      childList: true
    });
  }

  private manageFirstVisibleContentAreaSection(): void {
    const hasContent = (section: HTMLElement) =>
      Boolean(section.querySelector('.building-block-element'));

    const allContentAreaSections: HTMLElement[] = Array.from(
      this.frameContent.nativeElement.querySelectorAll('.section')
    );

    allContentAreaSections.forEach(function resetPreviousFirstContentAreaSection(item) {
      item.classList.remove('first-with-content');
    });

    const firstVisibleSection: HTMLElement | undefined = allContentAreaSections.find(hasContent);
    firstVisibleSection?.classList.add('first-with-content');
  }

  private scrollToNotificationsIfNecessary(): void {
    const currentNotificationsDisplayedTotal = Array.from(
      this.frameContent.nativeElement.querySelectorAll('.js-notification-content')
    ).length;

    if (currentNotificationsDisplayedTotal > this.lastNotificationsDisplayedTotal) {
      const scrollMargin = this.stickyService.getStickyElementHeights();
      this.renderer.setStyle(
        this.frameContent.nativeElement,
        'scroll-margin-top',
        `${scrollMargin}px`
      );
      this.frameContent.nativeElement.scrollIntoView({ behavior: 'smooth' });
    }
    this.lastNotificationsDisplayedTotal = currentNotificationsDisplayedTotal;
  }

  private setFocus() {
    const hasStage = this.pageDataService.pageData?.stage?.showAsStage;
    if (hasStage) {
      const stage = document.querySelector<HTMLElement>('.js-stage-headline');
      stage?.focus();
      return;
    }

    const pageTitle = document.querySelector<HTMLElement>('.js-page-headline');
    if (pageTitle) {
      pageTitle.focus();
      return;
    }

    if (this.hasBannerContent) {
      const banner = document.querySelector<HTMLElement>('.js-banner');
      banner?.focus();
      return;
    }

    const overarchingDetails = document.querySelector<HTMLElement>('.js-overarching-details');
    if (overarchingDetails) {
      overarchingDetails.focus();
      return;
    }

    document.getElementById('frameContent')?.focus();
  }

  ngOnDestroy(): void {
    this.domChanges?.disconnect();
  }
}
<div class="grid-container">
  @if (!chromeless) {
  <frame-header
    #frameHeader
    data-testid="frame-header"
    [title]="title"
    [logoSrc]="logoSrc"
    class="header"
    [headerTemplate]="headerTemplate"
    [headerLogoLinkUrl]="headerLogoLinkUrl"
  >
    <ng-content select="[talyFrameHeaderActions]"></ng-content>
  </frame-header>
  } @if (spinner && (frameNavigationService.applicationBusy$ | async)) {
  <taly-spinner
    class="spinner"
    data-testid="taly-spinner"
    [label]="frameNavigationService.applicationBusyMessage$ | async"
  >
  </taly-spinner>
  } @if (hasStage$ | async) {
  <frame-stage
    class="stage"
    data-testid="stage"
    [stageConfig]="stageConfig"
    [centered]="centered"
  ></frame-stage>
  }

  <frame-banner
    #frameBanner
    class="banner js-banner"
    tabindex="-1"
    data-testid="frame-banner"
  ></frame-banner>

  @if (initialRoutingDone) {
  <div class="navigation" role="navigation">
    <frame-navigation
      [navigationConfig]="navigationConfig"
      [hasJumpNavigationMenu]="hasJumpNavigationMenu"
      (resizeEvent)="onResize($event)"
      data-testid="frame-navigation"
    ></frame-navigation>
  </div>
  }

  <frame-content
    class="content"
    role="main"
    data-testid="frame-content"
    #frameContent
    id="frameContent"
    tabindex="-1"
  >
    <ng-content talyFrameContent></ng-content>

    @if (initialRoutingDone) {
    <frame-actions
      talyFrameActions
      class="actions"
      [ngStyle]="{ backgroundColor: actionsBackgroundColor }"
      data-testid="frame-actions"
    ></frame-actions>
    } @if (isRetailChannel && offerCode$ && (offerCode$ | async)) {
    <frame-offer-code
      talyFrameOfferCode
      [offerCode$]="offerCode$"
      class="offer-code"
      data-testid="offer-code"
    ></frame-offer-code>
    }

    <frame-small-print talyFrameSmallPrint class="small-print"></frame-small-print>
  </frame-content>

  <div class="sidebar">
    <frame-sidebar></frame-sidebar>
  </div>

  @if (!chromeless) {
  <frame-footer class="footer" [footerConfig]="footerConfig" [footerTemplate]="footerTemplate">
  </frame-footer>
  }
</div>

./frame.component.scss

@use '../../styles/breakpoints.scss' as *;
@use '../../styles/spacing.scss' as *;
@use '../../styles/navigation.scss' as *;

$sidebar-width: 350px;

:host {
  display: block;
  margin: auto;
  max-width: 100%;
  height: 100%;
}

.grid-container {
  display: grid;
  min-height: 100%;

  .header {
    grid-area: header;
  }

  .navigation {
    grid-area: navigation;
  }

  .footer {
    grid-area: footer;
  }

  .stage {
    grid-area: stage;
  }

  .banner {
    grid-area: banner;
    position: sticky;
    top: var(--header-height, 0);
    z-index: 3;
    outline: none;
  }

  .content {
    grid-area: content;
    z-index: 1;

    &:focus {
      outline: none;
    }
  }

  .actions,
  .small-print {
    display: block;
  }

  .sidebar {
    grid-area: sidebar;
    position: fixed;
    top: var(--header-height);
    right: 0;
    overflow-y: auto;
    height: calc(100vh - var(--header-height));
    width: $sidebar-width;
  }

  .spinner {
    display: flex;
    top: 0;
    left: 0;
    height: 100vh;
    width: 100vw;
    position: fixed;
    z-index: 10000;
  }
}

/*
 * Tool for initial creation:  https://grid.layoutit.com/
 */

:host:not(.is-stacked) {
  --navigation-width: #{$navigation-width};
  // NDBX nx-header height
  --header-height: 60px;

  &.no-header {
    --header-height: 0;
  }

  &:not(.has-sidebar) .sidebar {
    display: none;
  }

  &:not(.has-navigation) .navigation {
    display: none;
  }

  &.collapsed-navigation {
    --navigation-width: #{$navigation-collapsed-width};
  }

  .grid-container {
    grid-template-rows: min-content min-content min-content 1fr min-content;
  }

  .actions,
  .offer-code,
  .small-print {
    @include frame-spacing;
  }

  .header {
    position: sticky;
    top: 0;
    z-index: 1;
  }

  .content,
  .footer {
    z-index: 0;
  }

  .offer-code,
  .footer {
    max-width: 100%;
    width: var(--grid-max-width);
  }

  &.is-centered {
    .footer {
      margin: 0 auto;
    }
  }

  &.has-navigation {
    &.has-sidebar .grid-container {
      grid-template-columns: var(--navigation-width) minmax(0, 1fr) $sidebar-width;
      grid-template-areas:
        'header     header      header'
        'navigation stage       sidebar'
        'navigation banner      sidebar'
        'navigation content     sidebar'
        'navigation footer      sidebar';
    }

    &:not(.has-sidebar) .grid-container {
      grid-template-columns: var(--navigation-width) minmax(0, 1fr);
      grid-template-areas:
        'header     header'
        'navigation stage'
        'navigation banner'
        'navigation content'
        'navigation footer';
    }
  }

  &:not(.has-navigation) {
    &.has-sidebar .grid-container {
      grid-template-columns: minmax(0, 1fr) $sidebar-width;
      grid-template-areas:
        'header      header'
        'stage       sidebar'
        'banner      sidebar'
        'content     sidebar'
        'footer      sidebar';
    }
  }

  &:not(.has-sidebar) .grid-container {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      'header'
      'stage'
      'banner'
      'content'
      'footer';
  }
}

:host(.is-stacked) {
  .grid-container {
    grid-template-columns: minmax(320px, 1fr);

    .actions,
    .navigation,
    .offer-code,
    .small-print {
      @include frame-spacing;
    }
  }

  &:not(.has-navigation) {
    .grid-container {
      grid-template-rows: min-content min-content min-content auto min-content;
      grid-template-areas:
        'header'
        'stage'
        'banner'
        'content'
        'footer';
    }

    .navigation {
      display: none;
    }
  }

  &.has-navigation .grid-container {
    grid-template-rows: min-content min-content min-content min-content auto min-content;
    grid-template-areas:
      'header'
      'stage'
      'banner'
      'navigation'
      'content'
      'footer';

    .navigation {
      padding-top: 16px;
      @media (min-width: $breakpoint-m) {
        padding-top: 32px;
      }
    }
  }

  .sidebar {
    display: none;
  }

  .header,
  .navigation,
  .offer-code,
  .footer {
    max-width: 100%;
    width: var(--grid-max-width);
    margin-left: auto;
    margin-right: auto;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""