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 {