feat(theme): add system preference (#20)
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user