From c19b62ff857090a90bb0980daac69878aa664855 Mon Sep 17 00:00:00 2001
From: Nessar
Date: Fri, 28 Aug 2026 14:45:16 +0200
Subject: [PATCH] feat(theme): add system preference (#20)
---
.../calendar-year/calendar-year.scss | 17 +-
.../dark-light-mode-button.html | 31 ++--
.../dark-light-mode-button.spec.ts | 169 ++++++++++++++++++
.../dark-light-mode-button.ts | 30 +++-
src/app/services/theme-service.ts | 47 ++++-
src/styles.scss | 4 +
6 files changed, 247 insertions(+), 51 deletions(-)
diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss
index dd1cdef..baa9817 100644
--- a/src/app/components/calendar-year/calendar-year.scss
+++ b/src/app/components/calendar-year/calendar-year.scss
@@ -22,10 +22,7 @@
align-items: center;
border-radius: 4px;
z-index: 1000;
-
- @media (prefers-color-scheme: dark) {
- background-color: rgba(0, 0, 0, 0.7);
- }
+ background-color: var(--loading-overlay-bg);
}
.loading-spinner {
@@ -37,12 +34,8 @@
p {
font-size: 16px;
font-weight: 500;
- color: #333;
+ color: var(--loading-text-color);
margin: 0;
-
- @media (prefers-color-scheme: dark) {
- color: #ddd;
- }
}
}
@@ -63,12 +56,8 @@
transform: translate(-50%, -50%);
font-size: 20px;
font-weight: bold;
- color: #333;
+ color: var(--loading-text-color);
pointer-events: none;
-
- @media (prefers-color-scheme: dark) {
- color: #ddd;
- }
}
}
diff --git a/src/app/components/dark-light-mode-button/dark-light-mode-button.html b/src/app/components/dark-light-mode-button/dark-light-mode-button.html
index 40ce8ad..e04a99b 100644
--- a/src/app/components/dark-light-mode-button/dark-light-mode-button.html
+++ b/src/app/components/dark-light-mode-button/dark-light-mode-button.html
@@ -1,21 +1,10 @@
-@if (lightMode()) {
-
-} @else {
-
-}
+
diff --git a/src/app/components/dark-light-mode-button/dark-light-mode-button.spec.ts b/src/app/components/dark-light-mode-button/dark-light-mode-button.spec.ts
index 9a5e150..c0b70bf 100644
--- a/src/app/components/dark-light-mode-button/dark-light-mode-button.spec.ts
+++ b/src/app/components/dark-light-mode-button/dark-light-mode-button.spec.ts
@@ -1,12 +1,59 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { DarkLightModeButton } from './dark-light-mode-button';
+import { vi } from 'vitest';
+
+class ControllableMatchMedia {
+ matches: boolean;
+ readonly media = '(prefers-color-scheme: light)';
+ onchange: ((this: MediaQueryList, event: MediaQueryListEvent) => unknown) | null = null;
+ private readonly listeners = new Set<(event: MediaQueryListEvent) => void>();
+
+ constructor(matches: boolean) {
+ this.matches = matches;
+ }
+
+ addEventListener(_type: string, listener: EventListenerOrEventListenerObject | null): void {
+ if (typeof listener === 'function') {
+ this.listeners.add(listener as (event: MediaQueryListEvent) => void);
+ }
+ }
+
+ removeEventListener(_type: string, listener: EventListenerOrEventListenerObject | null): void {
+ if (typeof listener === 'function') {
+ this.listeners.delete(listener as (event: MediaQueryListEvent) => void);
+ }
+ }
+
+ addListener(listener: (this: MediaQueryList, event: MediaQueryListEvent) => unknown): void {
+ this.listeners.add(listener);
+ }
+
+ removeListener(listener: (this: MediaQueryList, event: MediaQueryListEvent) => unknown): void {
+ this.listeners.delete(listener);
+ }
+
+ dispatchEvent(_event: Event): boolean {
+ return true;
+ }
+
+ change(matches: boolean): void {
+ this.matches = matches;
+ const event = { matches, media: this.media } as MediaQueryListEvent;
+ this.listeners.forEach((listener) => listener(event));
+ this.onchange?.call(this as unknown as MediaQueryList, event);
+ }
+}
describe('DarkLightModeButton', () => {
let component: DarkLightModeButton;
let fixture: ComponentFixture;
+ let mediaQuery: ControllableMatchMedia;
beforeEach(async () => {
+ mediaQuery = new ControllableMatchMedia(true);
+ vi.spyOn(window, 'matchMedia').mockReturnValue(mediaQuery as unknown as MediaQueryList);
+
await TestBed.configureTestingModule({
imports: [DarkLightModeButton],
providers: [provideZonelessChangeDetection()],
@@ -17,7 +64,129 @@ describe('DarkLightModeButton', () => {
fixture.detectChanges();
});
+ afterEach(() => {
+ vi.restoreAllMocks();
+ document.body.classList.remove('light-mode', 'dark-mode');
+ });
+
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ it('starts in System and resolves its initial appearance from the browser preference', () => {
+ const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
+ const icon = button.querySelector('mat-icon');
+
+ expect(icon?.getAttribute('data-mat-icon-name')).toBe('brightness_auto');
+ expect(button.getAttribute('aria-label')).toBe(
+ 'Theme: System (currently Light). Switch to Light theme',
+ );
+ expect(button.getAttribute('title')).toBe(
+ 'Theme: System (currently Light). Switch to Light theme',
+ );
+ expect(document.body.classList.contains('light-mode')).toBe(true);
+ expect(document.body.classList.contains('dark-mode')).toBe(false);
+ });
+
+ it('cycles System to Light to Dark and wraps back to System', () => {
+ const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
+
+ button.click();
+ fixture.detectChanges();
+ expect(button.querySelector('mat-icon')?.getAttribute('data-mat-icon-name')).toBe(
+ 'light_mode',
+ );
+ expect(button.getAttribute('aria-label')).toBe(
+ 'Theme: Light (currently Light). Switch to Dark theme',
+ );
+ expect(button.getAttribute('title')).toBe(
+ 'Theme: Light (currently Light). Switch to Dark theme',
+ );
+ expect(document.body.classList.contains('light-mode')).toBe(true);
+ expect(document.body.classList.contains('dark-mode')).toBe(false);
+
+ button.click();
+ fixture.detectChanges();
+ expect(button.querySelector('mat-icon')?.getAttribute('data-mat-icon-name')).toBe(
+ 'dark_mode',
+ );
+ expect(button.getAttribute('aria-label')).toBe(
+ 'Theme: Dark (currently Dark). Switch to System theme',
+ );
+ expect(button.getAttribute('title')).toBe(
+ 'Theme: Dark (currently Dark). Switch to System theme',
+ );
+ expect(document.body.classList.contains('light-mode')).toBe(false);
+ expect(document.body.classList.contains('dark-mode')).toBe(true);
+
+ button.click();
+ fixture.detectChanges();
+ expect(button.querySelector('mat-icon')?.getAttribute('data-mat-icon-name')).toBe(
+ 'brightness_auto',
+ );
+ expect(button.getAttribute('aria-label')).toBe(
+ 'Theme: System (currently Light). Switch to Light theme',
+ );
+ expect(button.getAttribute('title')).toBe(
+ 'Theme: System (currently Light). Switch to Light theme',
+ );
+ expect(document.body.classList.contains('light-mode')).toBe(true);
+ expect(document.body.classList.contains('dark-mode')).toBe(false);
+ });
+
+ it('follows live browser changes while System is selected', () => {
+ const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
+
+ mediaQuery.change(false);
+ fixture.detectChanges();
+ expect(button.getAttribute('aria-label')).toBe(
+ 'Theme: System (currently Dark). Switch to Light theme',
+ );
+ expect(document.body.classList.contains('light-mode')).toBe(false);
+ expect(document.body.classList.contains('dark-mode')).toBe(true);
+ expect(document.body.classList.contains('light-mode')).not.toBe(
+ document.body.classList.contains('dark-mode'),
+ );
+
+ mediaQuery.change(true);
+ fixture.detectChanges();
+ expect(button.getAttribute('aria-label')).toBe(
+ 'Theme: System (currently Light). Switch to Light theme',
+ );
+ expect(document.body.classList.contains('light-mode')).toBe(true);
+ expect(document.body.classList.contains('dark-mode')).toBe(false);
+ });
+
+ it('keeps explicit modes fixed and uses the latest browser value when returning to System', () => {
+ const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
+
+ button.click();
+ fixture.detectChanges();
+ mediaQuery.change(false);
+ fixture.detectChanges();
+ expect(button.getAttribute('aria-label')).toBe(
+ 'Theme: Light (currently Light). Switch to Dark theme',
+ );
+ expect(document.body.classList.contains('light-mode')).toBe(true);
+ expect(document.body.classList.contains('dark-mode')).toBe(false);
+
+ button.click();
+ fixture.detectChanges();
+ mediaQuery.change(true);
+ fixture.detectChanges();
+ expect(button.getAttribute('aria-label')).toBe(
+ 'Theme: Dark (currently Dark). Switch to System theme',
+ );
+ expect(document.body.classList.contains('light-mode')).toBe(false);
+ expect(document.body.classList.contains('dark-mode')).toBe(true);
+
+ mediaQuery.change(false);
+ button.click();
+ fixture.detectChanges();
+ expect(button.getAttribute('aria-label')).toBe(
+ 'Theme: System (currently Dark). Switch to Light theme',
+ );
+ expect(document.body.classList.contains('light-mode')).toBe(false);
+ expect(document.body.classList.contains('dark-mode')).toBe(true);
+ });
});
diff --git a/src/app/components/dark-light-mode-button/dark-light-mode-button.ts b/src/app/components/dark-light-mode-button/dark-light-mode-button.ts
index 8580724..8d51321 100644
--- a/src/app/components/dark-light-mode-button/dark-light-mode-button.ts
+++ b/src/app/components/dark-light-mode-button/dark-light-mode-button.ts
@@ -1,21 +1,35 @@
-import { Component } from '@angular/core';
-import { ThemeService } from '../../services/theme-service';
+import { Component, computed, inject } from '@angular/core';
+import { EffectiveTheme, ThemePreference, ThemeService } from '../../services/theme-service';
import { MatIcon } from '@angular/material/icon';
+import { MatFabButton } from '@angular/material/button';
@Component({
selector: 'app-dark-light-mode-button',
- imports: [MatIcon],
+ imports: [MatFabButton, MatIcon],
templateUrl: './dark-light-mode-button.html',
styleUrl: './dark-light-mode-button.scss',
})
export class DarkLightModeButton {
- constructor(private themeService: ThemeService) {}
-
- lightMode(): boolean {
- return this.themeService.isLightMode();
- }
+ private readonly themeService = inject(ThemeService);
+ readonly themePreference = this.themeService.themePreference;
+ readonly effectiveTheme = this.themeService.effectiveTheme;
+ readonly icon = computed(() => {
+ const preference = this.themePreference();
+ return preference === 'system' ? 'brightness_auto' : `${preference}_mode`;
+ });
+ readonly description = computed(() => {
+ const preference = this.themePreference();
+ const selected = this.preferenceLabel(preference);
+ const effective = this.preferenceLabel(this.effectiveTheme());
+ const next = this.preferenceLabel(this.themeService.nextThemePreference());
+ return `Theme: ${selected} (currently ${effective}). Switch to ${next} theme`;
+ });
toggleTheme(): void {
this.themeService.toggleTheme();
}
+
+ private preferenceLabel(preference: ThemePreference | EffectiveTheme): string {
+ return preference.charAt(0).toUpperCase() + preference.slice(1);
+ }
}
diff --git a/src/app/services/theme-service.ts b/src/app/services/theme-service.ts
index 88ad254..9b5a6e5 100644
--- a/src/app/services/theme-service.ts
+++ b/src/app/services/theme-service.ts
@@ -1,20 +1,51 @@
-import { Injectable, signal } from '@angular/core';
+import { computed, Injectable, signal } from '@angular/core';
+
+export type ThemePreference = 'system' | 'light' | 'dark';
+export type EffectiveTheme = 'light' | 'dark';
@Injectable({ providedIn: 'root' })
export class ThemeService {
- private lightModeMedia = window.matchMedia('(prefers-color-scheme: light)');
- isLightMode = signal(this.lightModeMedia.matches);
+ private readonly lightModeMedia = window.matchMedia('(prefers-color-scheme: light)');
+ private readonly systemIsLight = signal(this.lightModeMedia.matches);
+ private readonly selectedPreference = signal('system');
+ readonly themePreference = this.selectedPreference.asReadonly();
+ readonly effectiveTheme = computed(() => {
+ const preference = this.selectedPreference();
+ return preference === 'system' ? (this.systemIsLight() ? 'light' : 'dark') : preference;
+ });
+ readonly isLightMode = computed(() => this.effectiveTheme() === 'light');
+ readonly nextThemePreference = computed(() => this.nextPreference(this.selectedPreference()));
constructor() {
- this.toggleTheme(this.isLightMode());
+ this.applyEffectiveTheme();
this.lightModeMedia.addEventListener('change', (e) => {
- this.isLightMode.set(e.matches);
+ this.systemIsLight.set(e.matches);
+ if (this.selectedPreference() === 'system') {
+ this.applyEffectiveTheme();
+ }
});
}
- toggleTheme(forceLight?: boolean) {
- const isLight = forceLight ?? !this.isLightMode();
- this.isLightMode.set(isLight);
+ setThemePreference(preference: ThemePreference): void {
+ this.selectedPreference.set(preference);
+ this.applyEffectiveTheme();
+ }
+
+ toggleTheme(forceLight?: boolean): void {
+ if (forceLight !== undefined) {
+ this.setThemePreference(forceLight ? 'light' : 'dark');
+ return;
+ }
+
+ this.setThemePreference(this.nextThemePreference());
+ }
+
+ private nextPreference(preference: ThemePreference): ThemePreference {
+ return preference === 'system' ? 'light' : preference === 'light' ? 'dark' : 'system';
+ }
+
+ private applyEffectiveTheme(): void {
+ const isLight = this.effectiveTheme() === 'light';
document.body.classList.toggle('light-mode', isLight);
document.body.classList.toggle('dark-mode', !isLight);
}
diff --git a/src/styles.scss b/src/styles.scss
index 195be80..137d2b2 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -34,6 +34,8 @@ html {
--rtt-bg: #99ff87;
--cp-bg: #eca0ff;
--other-bg: #ff9b9b;
+ --loading-overlay-bg: rgba(255, 255, 255, 0.9);
+ --loading-text-color: #333;
}
.dark-mode {
@@ -52,6 +54,8 @@ html {
--rtt-bg: #468149;
--cp-bg: #703e79;
--other-bg: #9b3939;
+ --loading-overlay-bg: rgba(0, 0, 0, 0.7);
+ --loading-text-color: #ddd;
}
body {