Skip to content

Commit 8c57bfa

Browse files
committed
feat: add layout service
1 parent 4a6907b commit 8c57bfa

8 files changed

Lines changed: 288 additions & 19 deletions

File tree

angular.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
],
3434
"styles": [
3535
"src/styles.scss",
36-
"node_modules/primeng/resources/themes/lara-light-blue/theme.css",
36+
"node_modules/primeng/resources/themes/aura-light-blue/theme.css",
3737
"node_modules/primeng/resources/primeng.min.css",
3838
"node_modules/primeflex/primeflex.css"
3939
],

src/app/app.component.ts

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { Component, ViewEncapsulation } from '@angular/core';
2-
import { RouterOutlet } from '@angular/router';
3-
import { MenuItem, PrimeNGConfig } from 'primeng/api';
1+
import { Component, Renderer2, ViewChild, ViewEncapsulation } from '@angular/core';
2+
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
43
import { SharedModule } from './shared/shared.module';
54
import { AppTopbarComponent } from "./core/layout/component/app-topbar/app-topbar.component";
65
import { AppFooterComponent } from "./core/layout/component/app-footer/app-footer.component";
76
import { AppSidebarComponent } from './core/layout/component/app-sidebar/app-sidebar.component';
7+
import { Subscription, filter } from 'rxjs';
8+
import { LayoutService } from './core/layout/services/layout.service';
89

910
@Component({
1011
selector: 'app-root',
@@ -15,24 +16,92 @@ import { AppSidebarComponent } from './core/layout/component/app-sidebar/app-sid
1516
AppTopbarComponent,
1617
AppFooterComponent,
1718
AppSidebarComponent
18-
],
19+
],
1920
templateUrl: './app.component.html',
2021
styleUrl: './app.component.scss',
2122
encapsulation: ViewEncapsulation.None
2223
})
2324
export class AppComponent {
24-
title = 'TaskManagementClient';
25-
constructor(private primengConfig: PrimeNGConfig) {
26-
this.primengConfig.ripple = true;
25+
overlayMenuOpenSubscription: Subscription;
26+
27+
menuOutsideClickListener: any;
28+
29+
@ViewChild(AppSidebarComponent) appSidebar!: AppSidebarComponent;
30+
31+
@ViewChild(AppTopbarComponent) appTopBar!: AppTopbarComponent;
32+
33+
constructor(public layoutService: LayoutService,
34+
public renderer: Renderer2,
35+
public router: Router) {
36+
this.overlayMenuOpenSubscription = this.layoutService.overlayOpen$.subscribe(() => {
37+
if (!this.menuOutsideClickListener) {
38+
this.menuOutsideClickListener = this.renderer.listen('document', 'click', (event) => {
39+
if (this.isOutsideClicked(event)) {
40+
this.hideMenu();
41+
}
42+
});
43+
}
44+
45+
if (this.layoutService.layoutState().staticMenuMobileActive) {
46+
this.blockBodyScroll();
47+
}
48+
});
49+
50+
this.router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe(() => {
51+
this.hideMenu();
52+
});
53+
}
54+
55+
isOutsideClicked(event: MouseEvent) {
56+
const sidebarEl = document.querySelector('.layout-sidebar');
57+
const topbarEl = document.querySelector('.layout-menu-button');
58+
const eventTarget = event.target as Node;
59+
60+
return !(sidebarEl?.isSameNode(eventTarget) || sidebarEl?.contains(eventTarget) || topbarEl?.isSameNode(eventTarget) || topbarEl?.contains(eventTarget));
61+
}
62+
63+
hideMenu() {
64+
this.layoutService.layoutState.update((prev) => ({ ...prev, overlayMenuActive: false, staticMenuMobileActive: false, menuHoverActive: false }));
65+
if (this.menuOutsideClickListener) {
66+
this.menuOutsideClickListener();
67+
this.menuOutsideClickListener = null;
68+
}
69+
this.unblockBodyScroll();
70+
}
71+
72+
blockBodyScroll(): void {
73+
if (document.body.classList) {
74+
document.body.classList.add('blocked-scroll');
75+
} else {
76+
document.body.className += ' blocked-scroll';
77+
}
78+
}
79+
80+
unblockBodyScroll(): void {
81+
if (document.body.classList) {
82+
document.body.classList.remove('blocked-scroll');
83+
} else {
84+
document.body.className = document.body.className.replace(new RegExp('(^|\\b)' + 'blocked-scroll'.split(' ').join('|') + '(\\b|$)', 'gi'), ' ');
85+
}
2786
}
2887

2988
get containerClass() {
3089
return {
31-
'layout-overlay': false,
32-
'layout-static': true,
33-
'layout-static-inactive': false,
34-
'layout-overlay-active': false,
35-
'layout-mobile-active': false
90+
'layout-overlay': this.layoutService.layoutConfig().menuMode === 'overlay',
91+
'layout-static': this.layoutService.layoutConfig().menuMode === 'static',
92+
'layout-static-inactive': this.layoutService.layoutState().staticMenuDesktopInactive && this.layoutService.layoutConfig().menuMode === 'static',
93+
'layout-overlay-active': this.layoutService.layoutState().overlayMenuActive,
94+
'layout-mobile-active': this.layoutService.layoutState().staticMenuMobileActive
3695
};
37-
}
96+
}
97+
98+
ngOnDestroy() {
99+
if (this.overlayMenuOpenSubscription) {
100+
this.overlayMenuOpenSubscription.unsubscribe();
101+
}
102+
103+
if (this.menuOutsideClickListener) {
104+
this.menuOutsideClickListener();
105+
}
106+
}
38107
}

src/app/core/layout/component/app-topbar/app-topbar.component.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<div class="layout-topbar">
22
<div class="layout-topbar-logo-container">
3-
<button class="layout-menu-button layout-topbar-action">
3+
<button class="layout-menu-button layout-topbar-action" (click)="layoutService.onMenuToggle()">
44
<i class="pi pi-bars"></i>
55
</button>
66
<a class="layout-topbar-logo" routerLink="/">
@@ -27,8 +27,8 @@
2727

2828
<div class="layout-topbar-actions">
2929
<div class="layout-config-menu">
30-
<button type="button" class="layout-topbar-action">
31-
<i [ngClass]="{ 'pi ': true, 'pi-moon': false, 'pi-sun': true }"></i>
30+
<button type="button" class="layout-topbar-action" (click)="toggleDarkMode()">
31+
<i [ngClass]="{ 'pi ': true, 'pi-moon': layoutService.isDarkTheme(), 'pi-sun': !layoutService.isDarkTheme() }"></i>
3232
</button>
3333
</div>
3434

src/app/core/layout/component/app-topbar/app-topbar.component.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CommonModule } from '@angular/common';
22
import { Component } from '@angular/core';
33
import { StyleClassModule } from 'primeng/styleclass';
4+
import { LayoutService } from '../../services/layout.service';
45

56
@Component({
67
selector: 'app-topbar',
@@ -10,5 +11,9 @@ import { StyleClassModule } from 'primeng/styleclass';
1011
styleUrl: './app-topbar.component.scss'
1112
})
1213
export class AppTopbarComponent {
14+
constructor(public layoutService: LayoutService) { }
1315

16+
toggleDarkMode() {
17+
this.layoutService.layoutConfig.update((state) => ({ ...state, darkTheme: !state.darkTheme }));
18+
}
1419
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { TestBed } from '@angular/core/testing';
2+
3+
import { LayoutService } from './layout.service';
4+
5+
describe('LayoutService', () => {
6+
let service: LayoutService;
7+
8+
beforeEach(() => {
9+
TestBed.configureTestingModule({});
10+
service = TestBed.inject(LayoutService);
11+
});
12+
13+
it('should be created', () => {
14+
expect(service).toBeTruthy();
15+
});
16+
});
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { Injectable, effect, signal, computed } from '@angular/core';
2+
import { Subject } from 'rxjs';
3+
4+
export interface layoutConfig {
5+
preset?: string;
6+
primary?: string;
7+
surface?: string | undefined | null;
8+
darkTheme?: boolean;
9+
menuMode?: string;
10+
}
11+
12+
interface LayoutState {
13+
staticMenuDesktopInactive?: boolean;
14+
overlayMenuActive?: boolean;
15+
configSidebarVisible?: boolean;
16+
staticMenuMobileActive?: boolean;
17+
menuHoverActive?: boolean;
18+
}
19+
20+
interface MenuChangeEvent {
21+
key: string;
22+
routeEvent?: boolean;
23+
}
24+
25+
@Injectable({
26+
providedIn: 'root'
27+
})
28+
export class LayoutService {
29+
_config: layoutConfig = {
30+
preset: 'Aura',
31+
primary: 'light-blue',
32+
surface: null,
33+
darkTheme: false,
34+
menuMode: 'static'
35+
};
36+
37+
_state: LayoutState = {
38+
staticMenuDesktopInactive: false,
39+
overlayMenuActive: false,
40+
configSidebarVisible: false,
41+
staticMenuMobileActive: false,
42+
menuHoverActive: false
43+
};
44+
45+
layoutConfig = signal<layoutConfig>(this._config);
46+
47+
layoutState = signal<LayoutState>(this._state);
48+
49+
private configUpdate = new Subject<layoutConfig>();
50+
51+
private overlayOpen = new Subject<any>();
52+
53+
private menuSource = new Subject<MenuChangeEvent>();
54+
55+
private resetSource = new Subject();
56+
57+
menuSource$ = this.menuSource.asObservable();
58+
59+
resetSource$ = this.resetSource.asObservable();
60+
61+
configUpdate$ = this.configUpdate.asObservable();
62+
63+
overlayOpen$ = this.overlayOpen.asObservable();
64+
65+
theme = computed(() => (this.layoutConfig()?.darkTheme ? 'light' : 'dark'));
66+
67+
isSidebarActive = computed(() => this.layoutState().overlayMenuActive || this.layoutState().staticMenuMobileActive);
68+
69+
isDarkTheme = computed(() => this.layoutConfig().darkTheme);
70+
71+
getPrimary = computed(() => this.layoutConfig().primary);
72+
73+
getSurface = computed(() => this.layoutConfig().surface);
74+
75+
isOverlay = computed(() => this.layoutConfig().menuMode === 'overlay');
76+
77+
transitionComplete = signal<boolean>(false);
78+
79+
private initialized = false;
80+
81+
constructor() {
82+
effect(() => {
83+
const config = this.layoutConfig();
84+
if (config) {
85+
this.onConfigUpdate();
86+
}
87+
});
88+
89+
effect(() => {
90+
const config = this.layoutConfig();
91+
92+
if (!this.initialized || !config) {
93+
this.initialized = true;
94+
return;
95+
}
96+
97+
this.handleDarkModeTransition(config);
98+
});
99+
}
100+
101+
private handleDarkModeTransition(config: layoutConfig): void {
102+
if ((document as any).startViewTransition) {
103+
this.startViewTransition(config);
104+
} else {
105+
this.toggleDarkMode(config);
106+
this.onTransitionEnd();
107+
}
108+
}
109+
110+
private startViewTransition(config: layoutConfig): void {
111+
const transition = (document as any).startViewTransition(() => {
112+
this.toggleDarkMode(config);
113+
});
114+
115+
transition.ready
116+
.then(() => {
117+
this.onTransitionEnd();
118+
})
119+
.catch(() => { });
120+
}
121+
122+
toggleDarkMode(config?: layoutConfig): void {
123+
const _config = config || this.layoutConfig();
124+
if (_config.darkTheme) {
125+
document.documentElement.classList.add('app-dark');
126+
} else {
127+
document.documentElement.classList.remove('app-dark');
128+
}
129+
}
130+
131+
private onTransitionEnd() {
132+
this.transitionComplete.set(true);
133+
setTimeout(() => {
134+
this.transitionComplete.set(false);
135+
});
136+
}
137+
138+
onMenuToggle() {
139+
if (this.isOverlay()) {
140+
this.layoutState.update((prev) => ({ ...prev, overlayMenuActive: !this.layoutState().overlayMenuActive }));
141+
142+
if (this.layoutState().overlayMenuActive) {
143+
this.overlayOpen.next(null);
144+
}
145+
}
146+
147+
if (this.isDesktop()) {
148+
this.layoutState.update((prev) => ({ ...prev, staticMenuDesktopInactive: !this.layoutState().staticMenuDesktopInactive }));
149+
} else {
150+
this.layoutState.update((prev) => ({ ...prev, staticMenuMobileActive: !this.layoutState().staticMenuMobileActive }));
151+
152+
if (this.layoutState().staticMenuMobileActive) {
153+
this.overlayOpen.next(null);
154+
}
155+
}
156+
}
157+
158+
isDesktop() {
159+
return window.innerWidth > 991;
160+
}
161+
162+
isMobile() {
163+
return !this.isDesktop();
164+
}
165+
166+
onConfigUpdate() {
167+
this._config = { ...this.layoutConfig() };
168+
this.configUpdate.next(this.layoutConfig());
169+
}
170+
171+
onMenuStateChange(event: MenuChangeEvent) {
172+
this.menuSource.next(event);
173+
}
174+
175+
reset() {
176+
this.resetSource.next(true);
177+
}
178+
}

src/app/features/dashboard/components/dashboard/dashboard.component.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@
4747
</ul>
4848
</p-card>
4949

50-
<p-card header="Task Status Overview" class="h-full flex flex-column align-items-center">
50+
<p-card header="Task Status Overview" class="h-full w-full flex flex-column align-items-center">
5151
<ng-template pTemplate="content">
52-
<p-chart type="doughnut" [data]="taskStatusChartData" [options]="taskStatusChartOptions" height="200px" width="700px"></p-chart>
52+
<p-chart type="doughnut" [data]="taskStatusChartData" [options]="taskStatusChartOptions" height="200px" class="flex justify-content-center"></p-chart>
5353
</ng-template>
5454
</p-card>
5555
</div>

src/app/features/dashboard/components/dashboard/dashboard.component.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ $grid-gap: 2rem;
4545
}
4646

4747
::ng-deep .p-card {
48+
width: 100%;
4849
.p-card-body {
4950
display: flex;
5051
flex-direction: column;

0 commit comments

Comments
 (0)