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();
}
}
@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;
}
}