Skip to content

Commit

Permalink
Development: Update theme switcher to use Angular 18 practices (#9250)
Browse files Browse the repository at this point in the history
  • Loading branch information
FelixTJDietrich authored Nov 16, 2024
1 parent 77c8ee9 commit e0ce21e
Show file tree
Hide file tree
Showing 30 changed files with 519 additions and 453 deletions.
2 changes: 1 addition & 1 deletion docs/dev/guidelines/client-design.rst
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ Example:

.. code-block:: ts
this.themeService.applyThemeExplicitly(Theme.DARK);
this.themeService.applyThemePreference(Theme.DARK);
4 changes: 2 additions & 2 deletions src/main/webapp/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { OrionOutdatedComponent } from 'app/shared/orion/outdated-plugin-warning
import { LoadingNotificationComponent } from 'app/shared/notification/loading-notification/loading-notification.component';
import { NotificationPopupComponent } from 'app/shared/notification/notification-popup/notification-popup.component';
import { UserSettingsModule } from 'app/shared/user-settings/user-settings.module';
import { ThemeModule } from 'app/core/theme/theme.module';
import { ThemeSwitchComponent } from 'app/core/theme/theme-switch.component';
import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module';
import { FaIconLibrary } from '@fortawesome/angular-fontawesome';
import { artemisIconPack } from 'src/main/webapp/content/icons/icons';
Expand All @@ -42,7 +42,7 @@ import { ScrollingModule } from '@angular/cdk/scrolling';
ArtemisComplaintsModule,
ArtemisHeaderExercisePageWithDetailsModule,
UserSettingsModule,
ThemeModule,
ThemeSwitchComponent,
ArtemisSharedComponentModule,
ScrollingModule,
],
Expand Down
10 changes: 5 additions & 5 deletions src/main/webapp/app/core/theme/theme-switch.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
<div class="popover-content" (mouseenter)="openPopover()" id="theme-switch-popover-content">
<div class="head"><span jhiTranslate="artemisApp.theme.darkMode"></span></div>
<div class="switch-box">
<div><fa-icon [icon]="faSync" /> {{ 'artemisApp.theme.sync' | artemisTranslate }}</div>
<div><fa-icon [icon]="faSync" /> <span jhiTranslate="artemisApp.theme.sync"></span></div>
<div class="form-switch">
<input class="form-check-input" type="checkbox" (click)="toggleSynced()" [checked]="isSynced" />
<input class="form-check-input" type="checkbox" (click)="toggleSynced()" [checked]="isSyncedWithOS()" />
</div>
</div>
</div>
Expand All @@ -17,10 +17,10 @@
[triggers]="''"
#popover="ngbPopover"
[autoClose]="false"
[animation]="animate"
[placement]="popoverPlacement"
[animation]="true"
[placement]="popoverPlacement()"
>
<div class="theme-toggle" [ngClass]="{ dark: isDark }" id="theme-toggle">
<div class="theme-toggle" [class.dark]="isDarkTheme()" id="theme-toggle">
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
<g class="sun-beams" stroke="currentColor">
Expand Down
57 changes: 23 additions & 34 deletions src/main/webapp/app/core/theme/theme-switch.component.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { Component, Input, OnInit, ViewChild } from '@angular/core';
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap';
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, input, viewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ArtemisSharedModule } from 'app/shared/shared.module';
import { TranslateModule } from '@ngx-translate/core';
import { NgbModule, NgbPopover } from '@ng-bootstrap/ng-bootstrap';
import { PlacementArray } from '@ng-bootstrap/ng-bootstrap/util/positioning';
import { Theme, ThemeService } from 'app/core/theme/theme.service';
import { fromEvent } from 'rxjs';
import { faSync } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';

/**
* Displays a sun or a moon in the navbar, depending on the current theme.
Expand All @@ -13,56 +18,41 @@ import { faSync } from '@fortawesome/free-solid-svg-icons';
selector: 'jhi-theme-switch',
templateUrl: './theme-switch.component.html',
styleUrls: ['theme-switch.component.scss'],
imports: [TranslateModule, CommonModule, ArtemisSharedModule, NgbModule, FontAwesomeModule],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
})
export class ThemeSwitchComponent implements OnInit {
@ViewChild('popover') popover: NgbPopover;
protected readonly faSync = faSync;

@Input() popoverPlacement: string;
private readonly themeService = inject(ThemeService);

isDark = false;
isSynced = false;
animate = true;
openPopupAfterNextChange = false;
closeTimeout: any;
popoverPlacement = input.required<PlacementArray>();
popover = viewChild.required<NgbPopover>('popover');

// Icons
faSync = faSync;
isDarkTheme = computed(() => this.themeService.currentTheme() === Theme.DARK);
isSyncedWithOS = computed(() => this.themeService.userPreference() === undefined);

constructor(private themeService: ThemeService) {}
closeTimeout: any;

ngOnInit() {
// Listen to theme changes to change our own state accordingly
this.themeService.getCurrentThemeObservable().subscribe((theme) => {
this.isDark = theme === Theme.DARK;
this.animate = true;
if (this.openPopupAfterNextChange) {
this.openPopupAfterNextChange = false;
setTimeout(() => this.openPopover(), 250);
}
});

// Listen to preference changes
this.themeService.getPreferenceObservable().subscribe((themeOrUndefined) => {
this.isSynced = !themeOrUndefined;
});

// Workaround as we can't dynamically change the "autoClose" property on popovers
fromEvent(window, 'click').subscribe((e) => {
const popoverContentElement = document.getElementById('theme-switch-popover-content');
if (this.popover.isOpen() && !popoverContentElement?.contains(e.target as Node)) {
if (this.popover().isOpen() && !popoverContentElement?.contains(e.target as Node)) {
this.closePopover();
}
});
}

openPopover() {
this.popover?.open();
this.popover().open();
clearTimeout(this.closeTimeout);
}

closePopover() {
clearTimeout(this.closeTimeout);
this.popover?.close();
this.popover().close();
}

mouseLeave() {
Expand All @@ -74,9 +64,8 @@ export class ThemeSwitchComponent implements OnInit {
* Changes the theme to the currently not active theme.
*/
toggleTheme() {
this.animate = false;
this.openPopupAfterNextChange = true;
setTimeout(() => this.themeService.applyThemeExplicitly(this.isDark ? Theme.LIGHT : Theme.DARK));
this.themeService.applyThemePreference(this.isDarkTheme() ? Theme.LIGHT : Theme.DARK);
setTimeout(() => this.openPopover(), 250);
}

/**
Expand All @@ -85,6 +74,6 @@ export class ThemeSwitchComponent implements OnInit {
* - if it's currently not synced, we remove the preference to apply the system theme
*/
toggleSynced() {
this.themeService.applyThemeExplicitly(this.isSynced ? this.themeService.getCurrentTheme() : undefined);
this.themeService.applyThemePreference(this.isSyncedWithOS() ? this.themeService.currentTheme() : undefined);
}
}
12 changes: 0 additions & 12 deletions src/main/webapp/app/core/theme/theme.module.ts

This file was deleted.

142 changes: 51 additions & 91 deletions src/main/webapp/app/core/theme/theme.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Injectable } from '@angular/core';
import { Injectable, computed, effect, inject, signal, untracked } from '@angular/core';
import { IconDefinition, faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
import { LocalStorageService } from 'ngx-webstorage';
import { BehaviorSubject, Observable } from 'rxjs';

export const THEME_LOCAL_STORAGE_KEY = 'artemisapp.theme.preference';
export const THEME_OVERRIDE_ID = 'artemis-theme-override';
Expand Down Expand Up @@ -44,43 +43,37 @@ export class Theme {
})
export class ThemeService {
/**
* The currently applied theme
* The user preference changes as WritableSignal.
* If changed, the theme is applied immediately.
*/
private currentTheme: Theme = Theme.LIGHT;
/**
* A behavior subject that fires for each new applied theme.
*/
private currentThemeSubject: BehaviorSubject<Theme> = new BehaviorSubject<Theme>(Theme.LIGHT);
/**
* A behavior subject that fires if the user preference changes.
* Can be either a theme for an explicit theme or undefined if system settings are preferred
*/
private preferenceSubject: BehaviorSubject<Theme | undefined> = new BehaviorSubject<Theme | undefined>(undefined);

private darkSchemeMediaQuery: MediaQueryList;

constructor(private localStorageService: LocalStorageService) {}
private _userPreference = signal<Theme | undefined>(undefined);

/**
* Returns the currently active theme.
* The user preference changes as ReadonlySignal.
* Can be either a theme for an explicit theme or undefined if system settings are preferred.
*/
public getCurrentTheme(): Theme {
return this.currentTheme;
}
public readonly userPreference = this._userPreference.asReadonly();

/**
* Returns an observable that will be fired immediately for the current theme and for each future theme change until unsubscribed.
* The system preference as WritableSignal.
*/
public getCurrentThemeObservable(): Observable<Theme> {
return this.currentThemeSubject.asObservable();
}
private systemPreference = signal<Theme>(Theme.LIGHT);

/**
* Returns an observable that will be fired immediately for the current user preference and if the user preference changes.
* Can be either a theme for an explicit theme or undefined if system settings are preferred
* The currently applied theme as Signal.
*/
public getPreferenceObservable(): Observable<Theme | undefined> {
return this.preferenceSubject.asObservable();
public currentTheme = computed(() => this.userPreference() ?? this.systemPreference());

private localStorageService = inject(LocalStorageService);

private darkSchemeMediaQuery: MediaQueryList;

constructor() {
effect(() => {
// Apply the theme as soon as the currentTheme changes
const currentTheme = this.currentTheme();
untracked(() => this.applyTheme(currentTheme));
});
}

/**
Expand All @@ -91,52 +84,35 @@ export class ThemeService {
initialize() {
this.darkSchemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
if (this.darkSchemeMediaQuery.media !== 'not all') {
this.darkSchemeMediaQuery.addEventListener('change', () => this.applyPreferredTheme());
this.darkSchemeMediaQuery.addEventListener('change', () => {
if (this.darkSchemeMediaQuery.matches) {
this.systemPreference.set(Theme.DARK);
} else {
this.systemPreference.set(Theme.LIGHT);
}
});
}

addEventListener('storage', (event) => {
if (event.key === 'jhi-' + THEME_LOCAL_STORAGE_KEY) {
this.preferenceSubject.next(this.getStoredTheme());
this.applyPreferredTheme();
this._userPreference.set(this.getStoredThemePreference());
}
});

this.applyPreferredTheme();
this.preferenceSubject.next(this.getStoredTheme());
}

/**
* Applies the preferred theme.
* The preferred theme is either
* - the theme stored in local storage, if present, or else
* - the system preference, if present, or else
* - the default theme
*/
private applyPreferredTheme() {
const storedTheme = this.getStoredTheme();
if (storedTheme) {
this.applyThemeInternal(storedTheme);
return;
}

if (this.darkSchemeMediaQuery.matches) {
this.applyThemeInternal(Theme.DARK);
return;
}

this.applyThemeInternal(Theme.LIGHT);
this.systemPreference.set(this.darkSchemeMediaQuery.matches ? Theme.DARK : Theme.LIGHT);
this._userPreference.set(this.getStoredThemePreference());
}

/**
* Returns the theme preference stored in local storage or undefined if no preference is stored
*/
private getStoredTheme(): Theme | undefined {
private getStoredThemePreference(): Theme | undefined {
const storedIdentifier = this.localStorageService.retrieve(THEME_LOCAL_STORAGE_KEY);
const storedTheme = Theme.all.find((theme) => theme.identifier === storedIdentifier);

// An unknown theme was stored. Let's clear it
if (storedIdentifier && !storedTheme) {
this.storePreference(undefined);
this.localStorageService.clear(THEME_LOCAL_STORAGE_KEY);
}

return storedTheme;
Expand Down Expand Up @@ -170,37 +146,36 @@ export class ThemeService {
}

/**
* Applies the specified theme.
* Applies the specified theme preference.
* Should only be called upon user request.
* Stores the preference in local storage.
*
* @param theme the theme to be applied; pass undefined to use system preference mode
* @param preference the theme to be applied; pass undefined to use system preference mode
*/
public applyThemeExplicitly(theme: Theme | undefined) {
this.storePreference(theme);
this.applyPreferredTheme();
}

private applyThemeInternal(theme: Theme) {
if (!theme) {
return;
}

// Do not inject or remove anything from the DOM if the applied theme is the current theme
if (this.currentTheme === theme) {
return;
public applyThemePreference(preference: Theme | undefined) {
if (preference) {
this.localStorageService.store(THEME_LOCAL_STORAGE_KEY, preference.identifier);
} else {
this.localStorageService.clear(THEME_LOCAL_STORAGE_KEY);
}
this._userPreference.set(preference);
}

/**
* Applies the theme to the application.
*
* Only call if the theme changed.
*
* @param theme the theme to apply
*/
private applyTheme(theme: Theme) {
// Get current <link> theme override
const overrideTag = document.getElementById(THEME_OVERRIDE_ID);

if (theme.isDefault) {
// The default theme is always injected by Angular; therefore, we just need to remove
// our theme override, if present
overrideTag?.remove();

this.currentTheme = theme;
this.currentThemeSubject.next(theme);
} else {
// If the theme is not the default theme, we need to add a theme override stylesheet to the page header

Expand All @@ -215,11 +190,8 @@ export class ThemeService {
newTag.href = theme.fileName! + '?_=' + new Date().setMinutes(0, 0, 0);

// As soon as the new style sheet loaded, remove the old override (if present)
// and fire the subject to inform other services and components
newTag.onload = () => {
overrideTag?.remove();
this.currentTheme = theme;
this.currentThemeSubject.next(theme);
};

// Insert the new stylesheet link tag after the last existing link tag
Expand All @@ -229,18 +201,6 @@ export class ThemeService {
}
}

private storePreference(theme?: Theme) {
if (theme) {
this.localStorageService.store(THEME_LOCAL_STORAGE_KEY, theme.identifier);
} else {
this.localStorageService.clear(THEME_LOCAL_STORAGE_KEY);
}

if (this.preferenceSubject.getValue() !== theme) {
this.preferenceSubject.next(theme);
}
}

/**
* Hides the notification sidebar as there will be an overlay ove the whole page
* that covers details of the exam summary (=> exam summary cannot be read).
Expand Down
Loading

0 comments on commit e0ce21e

Please sign in to comment.