diff --git a/src/layout/appconfigurator.ts b/src/layout/appconfigurator.ts index e347b31..9ba1a87 100644 --- a/src/layout/appconfigurator.ts +++ b/src/layout/appconfigurator.ts @@ -11,7 +11,7 @@ import { InputSwitchModule } from 'primeng/inputswitch'; import { RadioButtonModule } from 'primeng/radiobutton'; import { SelectButton } from 'primeng/selectbutton'; import { ToggleSwitchModule } from 'primeng/toggleswitch'; -import { AppConfigService } from '@/src/service/appconfigservice'; +import { LayoutService } from '@/src/service/applayoutservice'; const presets = { Aura, @@ -21,6 +21,7 @@ const presets = { @Component({ selector: 'app-configurator', standalone: true, + imports: [CommonModule, FormsModule, InputSwitchModule, ButtonModule, RadioButtonModule, SelectButton, ToggleSwitchModule], template: `
@@ -48,7 +49,7 @@ const presets = { type="button" [title]="surface.name" (click)="updateColors($event, 'surface', surface)" - [ngClass]="{ 'active-color': selectedSurfaceColor() ? selectedSurfaceColor() === surface.name : configService.appState().darkTheme ? surface.name === 'zinc' : surface.name === 'slate' }" + [ngClass]="{ 'active-color': selectedSurfaceColor() ? selectedSurfaceColor() === surface.name : layoutService.layoutConfig().darkTheme ? surface.name === 'zinc' : surface.name === 'slate' }" [style]="{ 'background-color': surface.name === 'noir' ? 'var(--text-color)' : surface?.palette['500'] }" @@ -62,17 +63,9 @@ const presets = {
-
-
- Ripple - -
-
-
-
- RTL - -
+
+ Ripple +
@@ -80,7 +73,6 @@ const presets = { host: { class: 'config-panel hidden' }, - imports: [CommonModule, FormsModule, InputSwitchModule, ButtonModule, RadioButtonModule, SelectButton, ToggleSwitchModule] }) export class AppConfigurator { get ripple() { @@ -91,20 +83,16 @@ export class AppConfigurator { this.config.ripple.set(value); } - get isRTL() { - return this.configService.appState().RTL; - } - config: PrimeNG = inject(PrimeNG); - configService: AppConfigService = inject(AppConfigService); + layoutService: LayoutService = inject(LayoutService); platformId = inject(PLATFORM_ID); presets = Object.keys(presets); onRTLChange(value: boolean) { - this.configService.appState.update((state) => ({ ...state, RTL: value })); + this.layoutService.layoutConfig.update((state) => ({ ...state, RTL: value })); if (!(document as any).startViewTransition) { this.toggleRTL(value); return; @@ -125,8 +113,8 @@ export class AppConfigurator { ngOnInit() { if (isPlatformBrowser(this.platformId)) { - this.onPresetChange(this.configService.appState().preset); - this.toggleRTL(this.configService.appState().RTL); + this.onPresetChange(this.layoutService.layoutConfig().preset); + } } @@ -270,15 +258,15 @@ export class AppConfigurator { ]; selectedPrimaryColor = computed(() => { - return this.configService.appState().primary; + return this.layoutService.layoutConfig().primary; }); - selectedSurfaceColor = computed(() => this.configService.appState().surface); + selectedSurfaceColor = computed(() => this.layoutService.layoutConfig().surface); - selectedPreset = computed(() => this.configService.appState().preset); + selectedPreset = computed(() => this.layoutService.layoutConfig().preset); primaryColors = computed(() => { - const presetPalette = presets[this.configService.appState().preset].primitive; + const presetPalette = presets[this.layoutService.layoutConfig().preset].primitive; const colors = ['emerald', 'green', 'lime', 'orange', 'amber', 'yellow', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose']; const palettes = [{ name: 'noir', palette: {} }]; @@ -344,7 +332,7 @@ export class AppConfigurator { } }; } else { - if (this.configService.appState().preset === 'Nora') { + if (this.layoutService.layoutConfig().preset === 'Nora') { return { semantic: { primary: color.palette, @@ -380,7 +368,7 @@ export class AppConfigurator { } } }; - } else if (this.configService.appState().preset === 'Material') { + } else if (this.layoutService.layoutConfig().preset === 'Material') { return { semantic: { primary: color.palette, @@ -458,9 +446,9 @@ export class AppConfigurator { updateColors(event: any, type: string, color: any) { if (type === 'primary') { - this.configService.appState.update((state) => ({ ...state, primary: color.name })); + this.layoutService.layoutConfig.update((state) => ({ ...state, primary: color.name })); } else if (type === 'surface') { - this.configService.appState.update((state) => ({ ...state, surface: color.name })); + this.layoutService.layoutConfig.update((state) => ({ ...state, surface: color.name })); } this.applyTheme(type, color); @@ -476,10 +464,10 @@ export class AppConfigurator { } onPresetChange(event: any) { - this.configService.appState.update((state) => ({ ...state, preset: event })); + this.layoutService.layoutConfig.update((state) => ({ ...state, preset: event })); const preset = presets[event]; const surfacePalette = this.surfaces.find((s) => s.name === this.selectedSurfaceColor())?.palette; - if (this.configService.appState().preset === 'Material') { + if (this.layoutService.layoutConfig().preset === 'Material') { document.body.classList.add('material'); this.config.ripple.set(true); } else { diff --git a/src/layout/applayout.ts b/src/layout/applayout.ts new file mode 100644 index 0000000..88560e6 --- /dev/null +++ b/src/layout/applayout.ts @@ -0,0 +1,132 @@ +import { Component, Renderer2, ViewChild } from '@angular/core'; +import { ToastModule } from 'primeng/toast'; +import { CommonModule } from '@angular/common'; +import { AppTopBar } from '@/src/layout/apptopbar'; +import { AppSidebar } from '@/src/layout/appsidebar'; +import { NavigationEnd, Router, RouterModule } from '@angular/router'; +import { AppConfigurator } from '@/src/layout/appconfigurator'; +import { AppFooter } from '@/src/layout/appfooter'; +import { filter, Subscription } from 'rxjs'; +import { LayoutService } from '@/src/service/applayoutservice'; + +@Component({ + selector: 'app-layout', + standalone:true, + imports: [ + CommonModule, + ToastModule, + AppTopBar, + AppSidebar, + RouterModule, + AppFooter, + AppConfigurator + ], + template: `
+ +
+ +
+
+
+ +
+ +
+ +
+
+ `, +}) +export class AppLayout { + overlayMenuOpenSubscription: Subscription; + + menuOutsideClickListener: any; + + profileMenuOutsideClickListener: any; + + @ViewChild(AppSidebar) appSidebar!: AppSidebar; + + @ViewChild(AppTopBar) appTopBar!: AppTopBar; + + constructor(public layoutService: LayoutService, public renderer: Renderer2, public router: Router) { + this.overlayMenuOpenSubscription = this.layoutService.overlayOpen$.subscribe(() => { + if (!this.menuOutsideClickListener) { + this.menuOutsideClickListener = this.renderer.listen('document', 'click', event => { + const isOutsideClicked = !(this.appSidebar.el.nativeElement.isSameNode(event.target) || this.appSidebar.el.nativeElement.contains(event.target) + || this.appTopBar.menuButton.nativeElement.isSameNode(event.target) || this.appTopBar.menuButton.nativeElement.contains(event.target)); + + if (isOutsideClicked) { + this.hideMenu(); + } + }); + } + + if (!this.profileMenuOutsideClickListener) { + this.profileMenuOutsideClickListener = this.renderer.listen('document', 'click', event => { + const isOutsideClicked = !(this.appTopBar.menu.nativeElement.isSameNode(event.target) || this.appTopBar.menu.nativeElement.contains(event.target) + || this.appTopBar.topbarMenuButton.nativeElement.isSameNode(event.target) || this.appTopBar.topbarMenuButton.nativeElement.contains(event.target)); + + if (isOutsideClicked) { + } + }); + } + + if (this.layoutService.layoutState().staticMenuMobileActive) { + this.blockBodyScroll(); + } + }); + + this.router.events.pipe(filter(event => event instanceof NavigationEnd)) + .subscribe(() => { + this.hideMenu(); + }); + } + + hideMenu() { + this.layoutService.layoutState.update((prev) => ({...prev, overlayMenuActive:false, staticMenuMobileActive: false, menuHoverActive: false})) + if (this.menuOutsideClickListener) { + this.menuOutsideClickListener(); + this.menuOutsideClickListener = null; + } + this.unblockBodyScroll(); + } + + blockBodyScroll(): void { + if (document.body.classList) { + document.body.classList.add('blocked-scroll'); + } + else { + document.body.className += ' blocked-scroll'; + } + } + + unblockBodyScroll(): void { + if (document.body.classList) { + document.body.classList.remove('blocked-scroll'); + } + else { + document.body.className = document.body.className.replace(new RegExp('(^|\\b)' + + 'blocked-scroll'.split(' ').join('|') + '(\\b|$)', 'gi'), ' '); + } + } + + get containerClass() { + return { + 'layout-overlay': this.layoutService.layoutConfig().menuMode === 'overlay', + 'layout-static': this.layoutService.layoutConfig().menuMode === 'static', + 'layout-static-inactive': this.layoutService.layoutState().staticMenuDesktopInactive && this.layoutService.layoutConfig().menuMode === 'static', + 'layout-overlay-active': this.layoutService.layoutState().overlayMenuActive, + 'layout-mobile-active': this.layoutService.layoutState().staticMenuMobileActive + } + } + + ngOnDestroy() { + if (this.overlayMenuOpenSubscription) { + this.overlayMenuOpenSubscription.unsubscribe(); + } + + if (this.menuOutsideClickListener) { + this.menuOutsideClickListener(); + } + } +} diff --git a/src/layout/appmenu.ts b/src/layout/appmenu.ts index 660b04b..2362729 100644 --- a/src/layout/appmenu.ts +++ b/src/layout/appmenu.ts @@ -1,9 +1,9 @@ import { Component, inject } from '@angular/core'; -import { AppConfigService } from '@/src/service/appconfigservice'; import { CommonModule } from '@angular/common'; import { AppMenuItem } from '@/src/layout/appmenuitem'; @Component({ + selector: 'app-menu', standalone:true, imports: [ CommonModule, @@ -21,8 +21,6 @@ export class AppMenu { model: any[] = []; - configService = inject(AppConfigService); - ngOnInit() { this.model = [ { diff --git a/src/layout/appmenuitem.ts b/src/layout/appmenuitem.ts index f5f3fda..2b83feb 100644 --- a/src/layout/appmenuitem.ts +++ b/src/layout/appmenuitem.ts @@ -1,9 +1,8 @@ -import { ChangeDetectorRef, Component, Host, HostBinding, Input } from '@angular/core'; +import { Component, HostBinding, Input } from '@angular/core'; import { NavigationEnd, Router, RouterModule } from '@angular/router'; import { animate, state, style, transition, trigger } from '@angular/animations'; import { Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; -import { AppConfigService } from '@/src/service/appconfigservice'; import { MenuService } from '@/src/app/layout/app.menu.service'; import { CommonModule } from '@angular/common'; import { RippleModule } from 'primeng/ripple'; @@ -53,7 +52,7 @@ import { MenuItem} from 'primeng/api'; }) export class AppMenuItem { - @Input() item: MenuItem; + @Input() item!: MenuItem; @Input() index!: number; @@ -69,7 +68,7 @@ export class AppMenuItem { key: string = ""; - constructor(public configService: AppConfigService , private cd: ChangeDetectorRef, public router: Router, private menuService: MenuService) { + constructor(public router: Router, private menuService: MenuService) { this.menuSourceSubscription = this.menuService.menuSource$.subscribe(value => { Promise.resolve(null).then(() => { if (value.routeEvent) { diff --git a/src/layout/appsidebar.ts b/src/layout/appsidebar.ts new file mode 100644 index 0000000..aae5357 --- /dev/null +++ b/src/layout/appsidebar.ts @@ -0,0 +1,17 @@ +import { Component, ElementRef } from '@angular/core'; +import { AppMenu } from '@/src/layout/appmenu'; + +@Component({ + selector: 'app-sidebar', + standalone:true, + imports: [ + AppMenu + ], + template: ` +
+ +
`, +}) +export class AppSidebar { + constructor(public el: ElementRef) {} +} diff --git a/src/layout/apptopbar.ts b/src/layout/apptopbar.ts new file mode 100644 index 0000000..eebf44f --- /dev/null +++ b/src/layout/apptopbar.ts @@ -0,0 +1,118 @@ +import { Component, ElementRef, ViewChild } from '@angular/core'; +import { MenuItem } from 'primeng/api'; +import { RouterModule } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { StyleClassModule } from 'primeng/styleclass'; +import { AppConfigurator } from '@/src/layout/appconfigurator'; +import { LayoutService } from '@/src/service/applayoutservice'; + +@Component({ + selector: 'app-topbar', + standalone:true, + imports: [ + RouterModule, + CommonModule, + StyleClassModule, + AppConfigurator + ], + template: ` +
+
+ +
+ + +
+
+ +
+ + +
+
+ + + + + +
+ +
`, +}) +export class AppTopBar { + + items!: MenuItem[]; + + @ViewChild('menubutton') menuButton!: ElementRef; + + @ViewChild('topbarmenubutton') topbarMenuButton!: ElementRef; + + @ViewChild('topbarmenu') menu!: ElementRef; + + constructor(public layoutService: LayoutService) { } + + toggleDarkMode(){ + this.layoutService.toggleDarkMode() + } + +} diff --git a/src/service/appconfigservice.ts b/src/service/appconfigservice.ts deleted file mode 100644 index d299a69..0000000 --- a/src/service/appconfigservice.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { DOCUMENT, isPlatformBrowser } from '@angular/common'; -import { computed, effect, inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; - -interface AppState { - preset?: string; - primary?: string; - surface?: string; - darkTheme?: boolean; - menuActive?: boolean; - designerKey?: string; - RTL?: boolean; - overlayMenuActive?: boolean; - menuMode?: string; - staticMenuDesktopInactive?: boolean; - staticMenuMobileActive?: boolean; - profileSidebarVisible?: boolean; - configSidebarVisible?: boolean; - menuHoverActive?: boolean; - activeMenuItem?: boolean; -} - - -@Injectable({ - providedIn: 'root' -}) -export class AppConfigService { - private readonly STORAGE_KEY = 'appConfigState'; - - appState = signal(null); - - document = inject(DOCUMENT); - - platformId = inject(PLATFORM_ID); - - theme = computed(() => (this.appState()?.darkTheme ? 'dark' : 'light')); - - transitionComplete = signal(false); - - private initialized = false; - - constructor() { - effect(() => { - this.appState.set({ ...this.loadAppState() }); - const state = this.appState(); - - if (!this.initialized || !state) { - this.initialized = true; - return; - } - - this.saveAppState(state); - this.handleDarkModeTransition(state); - }); - } - - private handleDarkModeTransition(state: AppState): void { - if (isPlatformBrowser(this.platformId)) { - if ((document as any).startViewTransition) { - this.startViewTransition(state); - } else { - this.toggleDarkMode(state); - this.onTransitionEnd(); - } - } - } - - private startViewTransition(state: AppState): void { - const transition = (document as any).startViewTransition(() => { - this.toggleDarkMode(state); - }); - - transition.ready.then(() => this.onTransitionEnd()); - } - - private toggleDarkMode(state: AppState): void { - if (state.darkTheme) { - this.document.documentElement.classList.add('app-dark'); - } else { - this.document.documentElement.classList.remove('app-dark'); - } - } - - private toggleMenu () { - const {menuMode, overlayMenuActive, staticMenuDesktopInactive, staticMenuMobileActive} = this.appState(); - - if (menuMode === 'overlay') { - this.appState.update((prev) => ({...prev, overlayMenuActive: !overlayMenuActive})); - } - - if (window.innerWidth > 991) { - this.appState.update((prev) => ({...prev, staticMenuDesktopInactive: !staticMenuDesktopInactive})); - } else { - this.appState.update((prev) => ({...prev, staticMenuMobileActive: !staticMenuMobileActive})); - } - }; - - isSidebarActive = computed(() => this.appState().overlayMenuActive || this.appState().staticMenuMobileActive); - - isDarkTheme = computed(() => this.appState().darkTheme); - - getPrimary = computed(() => this.appState().primary); - - getSurface = computed(() => this.appState().surface); - - private onTransitionEnd() { - this.transitionComplete.set(true); - setTimeout(() => { - this.transitionComplete.set(false); - }); - } - - private loadAppState(): any { - if (isPlatformBrowser(this.platformId)) { - const storedState = localStorage.getItem(this.STORAGE_KEY); - if (storedState) { - return JSON.parse(storedState); - } - } - return { - preset: 'Aura', - primary: 'noir', - surface: null, - darkTheme: false, - menuActive: false, - designerKey: 'primeng-designer-theme', - RTL: false - }; - } - - private saveAppState(state: any): void { - if (isPlatformBrowser(this.platformId)) { - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state)); - } - } -} diff --git a/src/service/applayoutservice.ts b/src/service/applayoutservice.ts new file mode 100644 index 0000000..64238fc --- /dev/null +++ b/src/service/applayoutservice.ts @@ -0,0 +1,154 @@ +import { Injectable, effect, signal, computed } from '@angular/core'; +import { Subject } from 'rxjs'; + +export interface layoutConfig { + preset?: string, + primary?: string; + surface?: string; + darkTheme?: boolean; + menuMode?: string; +} + +interface LayoutState { + staticMenuDesktopInactive?: boolean; + overlayMenuActive?: boolean; + configSidebarVisible?: boolean; + staticMenuMobileActive?: boolean; + menuHoverActive?: boolean; +} + +@Injectable({ + providedIn: 'root', +}) +export class LayoutService { + _config: layoutConfig = { + preset: 'Aura', + primary: 'emerald', + surface: null, + darkTheme: false, + menuMode: 'static' + }; + + _state: LayoutState = { + staticMenuDesktopInactive: false, + overlayMenuActive: false, + configSidebarVisible: false, + staticMenuMobileActive: false, + menuHoverActive: false, + }; + + layoutConfig = signal(this._config); + + layoutState = signal(this._state) + + private configUpdate = new Subject(); + + private overlayOpen = new Subject(); + + configUpdate$ = this.configUpdate.asObservable(); + + overlayOpen$ = this.overlayOpen.asObservable(); + + theme = computed(() => this.layoutConfig()?.darkTheme ? 'light' : 'dark'); + + isSidebarActive = computed(() => this.layoutState().overlayMenuActive || this.layoutState().staticMenuMobileActive); + + isDarkTheme = computed(() => this.layoutConfig().darkTheme); + + getPrimary = computed(() => this.layoutConfig().primary); + + getSurface = computed(() => this.layoutConfig().surface); + + isOverlay = computed(() => this.layoutConfig().menuMode === 'overlay') + + transitionComplete = signal(false); + + private initialized = false; + + constructor() { + effect(() => { + const config = this.layoutConfig(); + if(config) { + this.onConfigUpdate(); + } + }); + + effect(() => { + const config = this.layoutConfig(); + + if (!this.initialized || !config) { + this.initialized = true; + return; + } + + this.handleDarkModeTransition(config); + }) + } + + private handleDarkModeTransition(config: layoutConfig): void { + if ((document as any).startViewTransition) { + this.startViewTransition(config); + } else { + this.toggleDarkMode(config); + this.onTransitionEnd(); + } + } + + private startViewTransition(config: layoutConfig): void { + const transition = (document as any).startViewTransition(() => { + this.toggleDarkMode(config); + }); + + transition.ready.then(() => this.onTransitionEnd()); + } + + toggleDarkMode(config?: layoutConfig): void { + const _config = config || this.layoutConfig() + if (_config.darkTheme) { + document.documentElement.classList.add('app-dark'); + } else { + document.documentElement.classList.remove('app-dark'); + } + } + + private onTransitionEnd() { + this.transitionComplete.set(true); + setTimeout(() => { + this.transitionComplete.set(false); + }); + } + + + onMenuToggle() { + if (this.isOverlay()) { + this.layoutState.update((prev) => ({...prev, overlayMenuActive: !this.layoutState().overlayMenuActive})); + + if (this.layoutState().overlayMenuActive) { + this.overlayOpen.next(null); + } + } + + if (this.isDesktop()) { + this.layoutState.update((prev) => ({...prev, staticMenuDesktopInactive: !this.layoutState().staticMenuDesktopInactive})); + } else { + this.layoutState.update((prev) => ({...prev, staticMenuMobileActive: !this.layoutState().staticMenuMobileActive})); + + if (this.layoutState().staticMenuMobileActive) { + this.overlayOpen.next(null); + } + } + } + + isDesktop() { + return window.innerWidth > 991; + } + + isMobile() { + return !this.isDesktop(); + } + + onConfigUpdate() { + this._config = { ...this.layoutConfig() }; + this.configUpdate.next(this.layoutConfig()); + } +}