feat(theme): add system preference (#20)

This commit is contained in:
nessar committed 2026-08-28 14:45:16 +02:00
1 parent bb8d09dda4
commit c19b62ff85
6 files changed
+244 -48

No files matched your search

@@ -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;
}
}
}
@@ -1,21 +1,10 @@
@if (lightMode()) {
<button
<button
animate.enter="enter-animation"
mat-fab
color="primary"
[color]="effectiveTheme() === 'light' ? 'primary' : 'accent'"
(click)="toggleTheme()"
aria-label="Toggle light mode"
>
<mat-icon aria-hidden="false" aria-label="Dark mode icon" fontIcon="dark_mode"></mat-icon>
</button>
} @else {
<button
animate.enter="enter-animation"
mat-fab
color="accent"
(click)="toggleTheme()"
aria-label="Toggle dark mode"
>
<mat-icon aria-hidden="false" aria-label="Light mode icon" fontIcon="light_mode"></mat-icon>
</button>
}
[attr.aria-label]="description()"
[attr.title]="description()"
>
<mat-icon aria-hidden="true" [fontIcon]="icon()"></mat-icon>
</button>
@@ -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<DarkLightModeButton>;
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);
});
});
@@ -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);
}
}
+39 -8
View File
@@ -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<ThemePreference>('system');
readonly themePreference = this.selectedPreference.asReadonly();
readonly effectiveTheme = computed<EffectiveTheme>(() => {
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);
}
+4
View File
@@ -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 {