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
+247 -51

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
animate.enter="enter-animation"
mat-fab
color="primary"
(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>
}
<button
animate.enter="enter-animation"
mat-fab
[color]="effectiveTheme() === 'light' ? 'primary' : 'accent'"
(click)="toggleTheme()"
[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);
}
}