From 87b7a621dda520768112e0bfcd0f305da5aebc3d Mon Sep 17 00:00:00 2001 From: Flaminel Date: Mon, 12 Oct 2026 22:15:22 +0300 Subject: [PATCH] Add option to follow system preferences for dark mode (#834) --- code/frontend/src/app/app.icons.ts | 2 + .../core/feature-badges/feature-registry.ts | 1 + .../app/core/services/theme.service.spec.ts | 133 +++++++++++++++-- .../src/app/core/services/theme.service.ts | 66 ++++++--- .../appearance-settings.component.html | 21 ++- .../appearance-settings.component.scss | 27 +++- .../appearance-settings.component.spec.ts | 136 ++++++++++++++++++ .../appearance-settings.component.ts | 9 +- .../notifications.component.spec.ts | 2 +- .../nav-sidebar/nav-sidebar.component.spec.ts | 2 +- .../app/layout/shell/shell.component.spec.ts | 2 +- .../app/layout/toolbar/toolbar.component.html | 16 ++- .../layout/toolbar/toolbar.component.spec.ts | 96 +++++++++++++ .../app/layout/toolbar/toolbar.component.ts | 29 +++- .../animated-counter.component.ts | 3 +- code/frontend/src/index.html | 14 ++ 16 files changed, 511 insertions(+), 48 deletions(-) create mode 100644 code/frontend/src/app/features/settings/appearance/appearance-settings.component.spec.ts create mode 100644 code/frontend/src/app/layout/toolbar/toolbar.component.spec.ts diff --git a/code/frontend/src/app/app.icons.ts b/code/frontend/src/app/app.icons.ts index 0fd0875c..dc2621cf 100644 --- a/code/frontend/src/app/app.icons.ts +++ b/code/frontend/src/app/app.icons.ts @@ -15,6 +15,7 @@ import { tablerLayoutSidebarLeftCollapse, tablerSun, tablerMoon, + tablerDeviceDesktop, tablerCheck, tablerBrandGithub, tablerBrandDiscord, @@ -68,6 +69,7 @@ export const APP_ICONS = { tablerLayoutSidebarLeftCollapse, tablerSun, tablerMoon, + tablerDeviceDesktop, tablerCheck, tablerBrandGithub, tablerBrandDiscord, diff --git a/code/frontend/src/app/core/feature-badges/feature-registry.ts b/code/frontend/src/app/core/feature-badges/feature-registry.ts index e8f69f2e..7e380e8c 100644 --- a/code/frontend/src/app/core/feature-badges/feature-registry.ts +++ b/code/frontend/src/app/core/feature-badges/feature-registry.ts @@ -51,4 +51,5 @@ export const NEW_FEATURES: NewFeature[] = [ { id: 'force-import' }, { id: 'usenet-queue-cleaner' }, { id: 'qbit-api-key' }, + { id: 'theme-follow-system' }, ]; diff --git a/code/frontend/src/app/core/services/theme.service.spec.ts b/code/frontend/src/app/core/services/theme.service.spec.ts index 4abc66f6..86471e7f 100644 --- a/code/frontend/src/app/core/services/theme.service.spec.ts +++ b/code/frontend/src/app/core/services/theme.service.spec.ts @@ -1,18 +1,28 @@ import { TestBed } from '@angular/core/testing'; -import { ThemeService } from './theme.service'; +import { RootDataKey, ThemeService } from './theme.service'; const BRAND_SHADES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const; -const DATA_ATTRIBUTES = ['data-theme', 'data-performance-mode', 'data-full-width', 'data-accent']; describe('ThemeService', () => { const root = document.documentElement; + let darkMatches = false; + let darkListeners: ((event: MediaQueryListEvent) => void)[]; - function setup(): ThemeService { + function setup(initialSystemDark = false): ThemeService { + darkMatches = initialSystemDark; const service = TestBed.inject(ThemeService); TestBed.tick(); return service; } + function fireSystemThemeChange(matches: boolean): void { + darkMatches = matches; + for (const listener of darkListeners) { + listener({ matches } as MediaQueryListEvent); + } + TestBed.tick(); + } + function brandStop(shade: number): string { return root.style.getPropertyValue(`--brand-${shade}`).trim(); } @@ -29,14 +39,29 @@ describe('ThemeService', () => { beforeEach(() => { localStorage.clear(); - vi.stubGlobal('matchMedia', () => ({ matches: false })); + darkMatches = false; + darkListeners = []; + vi.stubGlobal('matchMedia', (query: string) => ({ + get matches(): boolean { + return query === '(prefers-color-scheme: dark)' ? darkMatches : false; + }, + addEventListener: (type: string, listener: (event: MediaQueryListEvent) => void) => { + if (type === 'change' && query === '(prefers-color-scheme: dark)') + { + darkListeners.push(listener); + } + }, + removeEventListener: (type: string, listener: (event: MediaQueryListEvent) => void) => { + darkListeners = darkListeners.filter((registered) => registered !== listener); + }, + })); }); afterEach(() => { localStorage.clear(); vi.unstubAllGlobals(); - for (const attribute of DATA_ATTRIBUTES) { - root.removeAttribute(attribute); + for (const key of Object.values(RootDataKey)) { + delete root.dataset[key]; } for (const shade of BRAND_SHADES) { root.style.removeProperty(`--brand-${shade}`); @@ -44,6 +69,15 @@ describe('ThemeService', () => { root.style.removeProperty('--accent-rgb'); }); + it('removes its system theme listener when the injector is destroyed', () => { + setup(); + expect(darkListeners.length).toBe(1); + + TestBed.resetTestingModule(); + + expect(darkListeners.length).toBe(0); + }); + it('round-trips the picked hex back through the mid brand stop and publishes its rgb triple', () => { const service = setup(); @@ -131,7 +165,7 @@ describe('ThemeService', () => { expect(service.customAccent()).toBe('#8b5cf6'); expect(service.accent()).toBe('default'); - expect(root.getAttribute('data-accent')).toBe('default'); + expect(root.dataset[RootDataKey.Accent]).toBe('default'); expect(brandStop(500)).toBe(''); expect(localStorage.getItem('cleanuparr-custom-accent')).toBeNull(); }); @@ -143,13 +177,13 @@ describe('ThemeService', () => { TestBed.tick(); expect(service.accent()).toBe('custom'); - expect(root.getAttribute('data-accent')).toBe('custom'); + expect(root.dataset[RootDataKey.Accent]).toBe('custom'); expect(brandStop(500)).toBe('#f43f5e'); service.setAccent('teal'); TestBed.tick(); - expect(root.getAttribute('data-accent')).toBe('teal'); + expect(root.dataset[RootDataKey.Accent]).toBe('teal'); expect(brandStop(500)).toBe(''); expect(root.style.getPropertyValue('--accent-rgb')).toBe(''); expect(service.customAccent()).toBe('#f43f5e'); @@ -162,10 +196,89 @@ describe('ThemeService', () => { const service = setup(); expect(service.theme()).toBe('light'); - expect(root.getAttribute('data-theme')).toBe('light'); + expect(root.dataset[RootDataKey.Theme]).toBe('light'); expect(service.accent()).toBe('default'); }); + it('resolves to dark from the system preference when nothing is stored', () => { + const service = setup(true); + + expect(service.preference()).toBe('system'); + expect(service.theme()).toBe('dark'); + expect(root.dataset[RootDataKey.Theme]).toBe('dark'); + }); + + it('resolves to light from the system preference when nothing is stored', () => { + const service = setup(false); + + expect(service.preference()).toBe('system'); + expect(service.theme()).toBe('light'); + expect(root.dataset[RootDataKey.Theme]).toBe('light'); + }); + + it('restores a stored system preference', () => { + localStorage.setItem('cleanuparr-theme', 'system'); + + const service = setup(true); + + expect(service.preference()).toBe('system'); + expect(service.theme()).toBe('dark'); + }); + + it('restores a legacy stored dark or light theme as an explicit preference', () => { + localStorage.setItem('cleanuparr-theme', 'dark'); + + const service = setup(true); + + expect(service.preference()).toBe('dark'); + expect(service.theme()).toBe('dark'); + }); + + it('follows a system theme change while the preference is system', () => { + const service = setup(false); + + expect(service.theme()).toBe('light'); + + fireSystemThemeChange(true); + + expect(service.theme()).toBe('dark'); + expect(root.dataset[RootDataKey.Theme]).toBe('dark'); + }); + + it('ignores a system theme change while the preference is explicit', () => { + const service = setup(false); + + service.setTheme('light'); + TestBed.tick(); + + fireSystemThemeChange(true); + + expect(service.theme()).toBe('light'); + expect(root.dataset[RootDataKey.Theme]).toBe('light'); + }); + + it('cycles dark to light to system to dark, persisting each step', () => { + const service = setup(false); + + service.setTheme('dark'); + TestBed.tick(); + + service.cycleTheme(); + TestBed.tick(); + expect(service.preference()).toBe('light'); + expect(localStorage.getItem('cleanuparr-theme')).toBe('light'); + + service.cycleTheme(); + TestBed.tick(); + expect(service.preference()).toBe('system'); + expect(localStorage.getItem('cleanuparr-theme')).toBe('system'); + + service.cycleTheme(); + TestBed.tick(); + expect(service.preference()).toBe('dark'); + expect(localStorage.getItem('cleanuparr-theme')).toBe('dark'); + }); + it('falls back to the default accent when the stored accent or custom hex is unusable', () => { localStorage.setItem('cleanuparr-accent', 'chartreuse'); localStorage.setItem('cleanuparr-custom-accent', 'not-a-color'); diff --git a/code/frontend/src/app/core/services/theme.service.ts b/code/frontend/src/app/core/services/theme.service.ts index f23c3e07..e5822ca8 100644 --- a/code/frontend/src/app/core/services/theme.service.ts +++ b/code/frontend/src/app/core/services/theme.service.ts @@ -1,6 +1,16 @@ -import { Injectable, signal, effect } from '@angular/core'; +import { DestroyRef, Injectable, inject, signal, computed, effect } from '@angular/core'; export type Theme = 'dark' | 'light'; +export type ThemePreference = Theme | 'system'; + +export enum RootDataKey { + Theme = 'theme', + PerformanceMode = 'performanceMode', + FullWidth = 'fullWidth', + Accent = 'accent', +} + +const THEME_PREFERENCE_ORDER: readonly ThemePreference[] = ['dark', 'light', 'system']; export const ACCENT_PRESETS = [ 'default', @@ -25,6 +35,7 @@ export const ACCENT_PRESET_HEX: Record = { teal: '#14b8a6', }; +// index.html reads this key before Angular boots. const THEME_KEY = 'cleanuparr-theme'; const PERFORMANCE_MODE_KEY = 'cleanuparr-performance-mode'; const FULL_WIDTH_KEY = 'cleanuparr-full-width'; @@ -53,13 +64,23 @@ const LIGHTNESS_STOPS: Record<(typeof BRAND_SHADES)[number], number> = { @Injectable({ providedIn: 'root' }) export class ThemeService { private readonly root = document.documentElement; - private readonly _theme = signal('dark'); + private readonly destroyRef = inject(DestroyRef); + private readonly _preference = signal('system'); + private readonly _systemDark = signal(false); private readonly _performanceMode = signal(false); private readonly _fullWidth = signal(false); private readonly _accent = signal('default'); private readonly _customAccent = signal(DEFAULT_CUSTOM_ACCENT); - readonly theme = this._theme.asReadonly(); + readonly theme = computed(() => { + const preference = this._preference(); + if (preference === 'dark' || preference === 'light') + { + return preference; + } + return this._systemDark() ? 'dark' : 'light'; + }); + readonly preference = this._preference.asReadonly(); readonly performanceMode = this._performanceMode.asReadonly(); readonly fullWidth = this._fullWidth.asReadonly(); readonly accent = this._accent.asReadonly(); @@ -67,19 +88,20 @@ export class ThemeService { constructor() { this.restoreFromStorage(); + this.watchSystemTheme(); this.detectSystemPreferences(); this.bindToDom(); } - toggleTheme(): void { - const next = this._theme() === 'dark' ? 'light' : 'dark'; - this._theme.set(next); - localStorage.setItem(THEME_KEY, next); + cycleTheme(): void { + const index = THEME_PREFERENCE_ORDER.indexOf(this._preference()); + const next = THEME_PREFERENCE_ORDER[(index + 1) % THEME_PREFERENCE_ORDER.length]; + this.setTheme(next); } - setTheme(theme: Theme): void { - this._theme.set(theme); - localStorage.setItem(THEME_KEY, theme); + setTheme(preference: ThemePreference): void { + this._preference.set(preference); + localStorage.setItem(THEME_KEY, preference); } togglePerformanceMode(): void { @@ -105,7 +127,7 @@ export class ThemeService { * `*-dark.svg` (designed for light backgrounds) convention. */ themedIconSrc(src: string): string { - if (this._theme() === 'dark') + if (this.theme() === 'dark') { return src; } @@ -128,8 +150,8 @@ export class ThemeService { private restoreFromStorage(): void { const savedTheme = localStorage.getItem(THEME_KEY); - if (savedTheme === 'light' || savedTheme === 'dark') { - this._theme.set(savedTheme); + if (savedTheme === 'light' || savedTheme === 'dark' || savedTheme === 'system') { + this._preference.set(savedTheme); } const saved = localStorage.getItem(PERFORMANCE_MODE_KEY); @@ -162,22 +184,32 @@ export class ThemeService { } } + private watchSystemTheme(): void { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); + this._systemDark.set(prefersDark.matches); + const onChange = (event: MediaQueryListEvent): void => { + this._systemDark.set(event.matches); + }; + prefersDark.addEventListener('change', onChange); + this.destroyRef.onDestroy(() => prefersDark.removeEventListener('change', onChange)); + } + private bindToDom(): void { effect(() => { - this.root.setAttribute('data-theme', this._theme()); + this.root.dataset[RootDataKey.Theme] = this.theme(); }); effect(() => { - this.root.setAttribute('data-performance-mode', String(this._performanceMode())); + this.root.dataset[RootDataKey.PerformanceMode] = String(this._performanceMode()); }); effect(() => { - this.root.setAttribute('data-full-width', String(this._fullWidth())); + this.root.dataset[RootDataKey.FullWidth] = String(this._fullWidth()); }); effect(() => { const accent = this._accent(); - this.root.setAttribute('data-accent', accent); + this.root.dataset[RootDataKey.Accent] = accent; if (accent === 'custom') { diff --git a/code/frontend/src/app/features/settings/appearance/appearance-settings.component.html b/code/frontend/src/app/features/settings/appearance/appearance-settings.component.html index c74b66a8..1d3589cb 100644 --- a/code/frontend/src/app/features/settings/appearance/appearance-settings.component.html +++ b/code/frontend/src/app/features/settings/appearance/appearance-settings.component.html @@ -5,12 +5,13 @@
-

Choose between dark and light color modes.

+

Choose dark, light, or follow your system setting.

+
diff --git a/code/frontend/src/app/features/settings/appearance/appearance-settings.component.scss b/code/frontend/src/app/features/settings/appearance/appearance-settings.component.scss index 0474ab3d..3b6de54b 100644 --- a/code/frontend/src/app/features/settings/appearance/appearance-settings.component.scss +++ b/code/frontend/src/app/features/settings/appearance/appearance-settings.component.scss @@ -14,7 +14,7 @@ .theme-options { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); margin-top: var(--space-2); @@ -62,9 +62,34 @@ &--light { background: linear-gradient(135deg, #ffffff 0%, var(--brand-100) 100%); } + + &--system { + position: relative; + overflow: hidden; + + &::before, + &::after { + content: ''; + position: absolute; + inset: 0; + } + + &::before { + background: linear-gradient(135deg, var(--brand-950) 0%, #0c0614 100%); + clip-path: polygon(0 0, 100% 0, 0 100%); + } + + &::after { + background: linear-gradient(135deg, #ffffff 0%, var(--brand-100) 100%); + clip-path: polygon(100% 0, 100% 100%, 0 100%); + } + } } &__label { + display: flex; + align-items: center; + gap: var(--space-2); font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); } diff --git a/code/frontend/src/app/features/settings/appearance/appearance-settings.component.spec.ts b/code/frontend/src/app/features/settings/appearance/appearance-settings.component.spec.ts new file mode 100644 index 00000000..e23ae5b5 --- /dev/null +++ b/code/frontend/src/app/features/settings/appearance/appearance-settings.component.spec.ts @@ -0,0 +1,136 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { + ACCENT_PRESETS, + ACCENT_PRESET_HEX, + Accent, + ThemePreference, + ThemeService, +} from '@core/services/theme.service'; +import { FeatureBadgeService } from '@core/feature-badges/feature-badge.service'; +import { AppearanceSettingsComponent } from './appearance-settings.component'; + +function createThemeService() { + return { + preference: signal('system'), + accent: signal('default'), + customAccent: signal('#8b5cf6'), + setTheme: vi.fn(), + setAccent: vi.fn(), + setCustomAccent: vi.fn(), + }; +} + +interface Setup { + fixture: ComponentFixture; + themeService: ReturnType; +} + +function setup(): Setup { + const themeService = createThemeService(); + + TestBed.configureTestingModule({ + providers: [ + { provide: ThemeService, useValue: themeService }, + { provide: FeatureBadgeService, useValue: { isNew: vi.fn(() => false) } }, + ], + }); + + const fixture = TestBed.createComponent(AppearanceSettingsComponent); + fixture.detectChanges(); + + return { fixture, themeService }; +} + +function themeOptions(fixture: ComponentFixture): HTMLButtonElement[] { + return Array.from(fixture.nativeElement.querySelectorAll('.theme-option')); +} + +describe('AppearanceSettingsComponent', () => { + it('renders dark, light and system theme options', () => { + const { fixture } = setup(); + + const labels = themeOptions(fixture).map((button) => button.textContent?.trim()); + + expect(labels.length).toBe(3); + expect(labels[0]).toContain('Dark'); + expect(labels[1]).toContain('Light'); + expect(labels[2]).toContain('System'); + }); + + it('marks the option matching the current preference as active', () => { + const { fixture, themeService } = setup(); + + const [dark, light, system] = themeOptions(fixture); + expect(system.classList).toContain('theme-option--active'); + expect(dark.classList).not.toContain('theme-option--active'); + expect(light.classList).not.toContain('theme-option--active'); + + themeService.preference.set('dark'); + fixture.detectChanges(); + + expect(dark.classList).toContain('theme-option--active'); + expect(system.classList).not.toContain('theme-option--active'); + }); + + it('exposes the current preference through aria-pressed', () => { + const { fixture, themeService } = setup(); + + const [dark, light, system] = themeOptions(fixture); + expect(system.getAttribute('aria-pressed')).toBe('true'); + expect(dark.getAttribute('aria-pressed')).toBe('false'); + expect(light.getAttribute('aria-pressed')).toBe('false'); + + themeService.preference.set('light'); + fixture.detectChanges(); + + expect(light.getAttribute('aria-pressed')).toBe('true'); + expect(system.getAttribute('aria-pressed')).toBe('false'); + }); + + it('calls setTheme with the clicked preference', () => { + const { fixture, themeService } = setup(); + + const [dark, light, system] = themeOptions(fixture); + + system.click(); + fixture.detectChanges(); + expect(themeService.setTheme).toHaveBeenCalledWith('system'); + + dark.click(); + fixture.detectChanges(); + expect(themeService.setTheme).toHaveBeenCalledWith('dark'); + + light.click(); + fixture.detectChanges(); + expect(themeService.setTheme).toHaveBeenCalledWith('light'); + }); + + it('marks the active accent swatch and switches on click', () => { + const { fixture, themeService } = setup(); + + const swatches = Array.from( + fixture.nativeElement.querySelectorAll('.swatch:not(.swatch--custom)'), + ); + expect(swatches.length).toBe(ACCENT_PRESETS.length); + expect(swatches[0].classList).toContain('swatch--active'); + + const greenIndex = ACCENT_PRESETS.indexOf('green'); + swatches[greenIndex].click(); + fixture.detectChanges(); + + expect(themeService.setAccent).toHaveBeenCalledWith('green'); + }); + + it('uses the registered preset hex colors for the swatch dots', () => { + const { fixture } = setup(); + + const dots = Array.from(fixture.nativeElement.querySelectorAll('.swatch__dot')); + const probe = document.createElement('div'); + + ACCENT_PRESETS.forEach((preset, index) => { + probe.style.background = ACCENT_PRESET_HEX[preset]; + expect(dots[index].style.background).toBe(probe.style.background); + }); + }); +}); diff --git a/code/frontend/src/app/features/settings/appearance/appearance-settings.component.ts b/code/frontend/src/app/features/settings/appearance/appearance-settings.component.ts index 4afaedb6..7e52f06b 100644 --- a/code/frontend/src/app/features/settings/appearance/appearance-settings.component.ts +++ b/code/frontend/src/app/features/settings/appearance/appearance-settings.component.ts @@ -1,11 +1,12 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { PageHeaderComponent } from '@layout/page-header/page-header.component'; import { CardComponent } from '@ui'; +import { NewBadgeComponent } from '@ui/new-badge/new-badge.component'; import { ACCENT_PRESETS, ACCENT_PRESET_HEX, Accent, - Theme, + ThemePreference, ThemeService, } from '@core/services/theme.service'; @@ -18,7 +19,7 @@ interface AccentSwatch { @Component({ selector: 'app-appearance-settings', standalone: true, - imports: [PageHeaderComponent, CardComponent], + imports: [PageHeaderComponent, CardComponent, NewBadgeComponent], templateUrl: './appearance-settings.component.html', styleUrl: './appearance-settings.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -26,7 +27,7 @@ interface AccentSwatch { export class AppearanceSettingsComponent { private readonly themeService = inject(ThemeService); - readonly theme = this.themeService.theme; + readonly preference = this.themeService.preference; readonly accent = this.themeService.accent; readonly customAccent = this.themeService.customAccent; @@ -36,7 +37,7 @@ export class AppearanceSettingsComponent { color: ACCENT_PRESET_HEX[value], })); - selectTheme(theme: Theme): void { + selectTheme(theme: ThemePreference): void { this.themeService.setTheme(theme); } diff --git a/code/frontend/src/app/features/settings/notifications/notifications.component.spec.ts b/code/frontend/src/app/features/settings/notifications/notifications.component.spec.ts index 77402896..9604283f 100644 --- a/code/frontend/src/app/features/settings/notifications/notifications.component.spec.ts +++ b/code/frontend/src/app/features/settings/notifications/notifications.component.spec.ts @@ -71,7 +71,7 @@ interface Setup { describe('NotificationsComponent', () => { beforeEach(() => { - vi.stubGlobal('matchMedia', () => ({ matches: false })); + vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: () => undefined, removeEventListener: () => undefined })); }); afterEach(() => { diff --git a/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.spec.ts b/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.spec.ts index 6afa2411..f678cf88 100644 --- a/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.spec.ts +++ b/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.spec.ts @@ -19,7 +19,7 @@ describe('NavSidebarComponent', () => { }); function setup(status: AppStatus | null = null): Harness { - vi.stubGlobal('matchMedia', () => ({ matches: false })); + vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: () => undefined, removeEventListener: () => undefined })); const appStatus = signal(status); const logouts: number[] = []; diff --git a/code/frontend/src/app/layout/shell/shell.component.spec.ts b/code/frontend/src/app/layout/shell/shell.component.spec.ts index f02caf2e..ad4c094a 100644 --- a/code/frontend/src/app/layout/shell/shell.component.spec.ts +++ b/code/frontend/src/app/layout/shell/shell.component.spec.ts @@ -25,7 +25,7 @@ describe('ShellComponent', () => { } function setup(width = DESKTOP_WIDTH): Harness { - vi.stubGlobal('matchMedia', () => ({ matches: false })); + vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: () => undefined, removeEventListener: () => undefined })); setWidth(width); const hubCalls: string[] = []; diff --git a/code/frontend/src/app/layout/toolbar/toolbar.component.html b/code/frontend/src/app/layout/toolbar/toolbar.component.html index 67f673a7..0a9a8b7d 100644 --- a/code/frontend/src/app/layout/toolbar/toolbar.component.html +++ b/code/frontend/src/app/layout/toolbar/toolbar.component.html @@ -6,13 +6,15 @@
- + + + ; + preference: WritableSignal; + theme: WritableSignal; + cycleTheme: () => void; +} + +describe('ToolbarComponent', () => { + function setup(): Harness { + const preference = signal('dark'); + const theme = signal('dark'); + const cycleTheme = vi.fn(); + + TestBed.configureTestingModule({ + providers: [ + { + provide: ThemeService, + useValue: { + preference, + theme, + performanceMode: signal(false), + fullWidth: signal(false), + cycleTheme, + togglePerformanceMode: vi.fn(), + toggleFullWidth: vi.fn(), + }, + }, + ], + }); + + const fixture = TestBed.createComponent(ToolbarComponent); + fixture.detectChanges(); + return { fixture, preference, theme, cycleTheme }; + } + + function themeButton(fixture: ComponentFixture): HTMLButtonElement { + return fixture.nativeElement.querySelector('.toolbar__icon-btn') as HTMLButtonElement; + } + + function themeIconName(fixture: ComponentFixture): unknown { + const icon = fixture.debugElement + .query(By.css('.toolbar__icon-btn')) + .query(By.directive(NgIcon)); + return icon.componentInstance.name(); + } + + it('shows the moon icon and dark tooltip for the dark preference', () => { + const { fixture } = setup(); + + expect(themeIconName(fixture)).toBe('tablerMoon'); + expect(themeButton(fixture).getAttribute('aria-label')).toBe('Theme: Dark. Click for light.'); + }); + + it('shows the sun icon and light tooltip for the light preference', () => { + const { fixture, preference, theme } = setup(); + + preference.set('light'); + theme.set('light'); + fixture.detectChanges(); + + expect(themeIconName(fixture)).toBe('tablerSun'); + expect(themeButton(fixture).getAttribute('aria-label')).toBe('Theme: Light. Click to follow system.'); + }); + + it('shows the desktop icon and resolved theme for the system preference', () => { + const { fixture, preference, theme } = setup(); + + preference.set('system'); + theme.set('dark'); + fixture.detectChanges(); + + expect(themeIconName(fixture)).toBe('tablerDeviceDesktop'); + expect(themeButton(fixture).getAttribute('aria-label')).toBe('Theme: System (dark). Click for dark.'); + + theme.set('light'); + fixture.detectChanges(); + + expect(themeButton(fixture).getAttribute('aria-label')).toBe('Theme: System (light). Click for dark.'); + }); + + it('cycles the theme preference on click', () => { + const { fixture, cycleTheme } = setup(); + + themeButton(fixture).click(); + fixture.detectChanges(); + + expect(cycleTheme).toHaveBeenCalledTimes(1); + }); +}); diff --git a/code/frontend/src/app/layout/toolbar/toolbar.component.ts b/code/frontend/src/app/layout/toolbar/toolbar.component.ts index c3c8cd8c..0b5fe1d4 100644 --- a/code/frontend/src/app/layout/toolbar/toolbar.component.ts +++ b/code/frontend/src/app/layout/toolbar/toolbar.component.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, input, output, inject } from '@angular/core'; +import { Component, ChangeDetectionStrategy, computed, input, output, inject } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { ThemeService } from '@core/services/theme.service'; import { ToggleComponent, TooltipComponent } from '@ui'; @@ -18,11 +18,36 @@ export class ToolbarComponent { private themeService = inject(ThemeService); theme = this.themeService.theme; + preference = this.themeService.preference; performanceMode = this.themeService.performanceMode; fullWidth = this.themeService.fullWidth; + themeIcon = computed(() => { + switch (this.preference()) { + case 'dark': + return 'tablerMoon'; + case 'light': + return 'tablerSun'; + default: + return 'tablerDeviceDesktop'; + } + }); + + themeTooltip = computed(() => { + const preference = this.preference(); + if (preference === 'dark') + { + return 'Theme: Dark. Click for light.'; + } + if (preference === 'light') + { + return 'Theme: Light. Click to follow system.'; + } + return `Theme: System (${this.theme()}). Click for dark.`; + }); + onToggleTheme(): void { - this.themeService.toggleTheme(); + this.themeService.cycleTheme(); } onTogglePerformanceMode(): void { diff --git a/code/frontend/src/app/ui/animated-counter/animated-counter.component.ts b/code/frontend/src/app/ui/animated-counter/animated-counter.component.ts index 29c7c21b..d4420f3d 100644 --- a/code/frontend/src/app/ui/animated-counter/animated-counter.component.ts +++ b/code/frontend/src/app/ui/animated-counter/animated-counter.component.ts @@ -10,6 +10,7 @@ import { inject, } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; +import { RootDataKey } from '../../core/services/theme.service'; @Component({ selector: 'app-animated-counter', @@ -46,7 +47,7 @@ export class AnimatedCounterComponent implements OnDestroy { } // Skip animation in performance mode - if (document.documentElement.dataset['performanceMode'] === 'true') { + if (document.documentElement.dataset[RootDataKey.PerformanceMode] === 'true') { this.displayValue.set(target); return; } diff --git a/code/frontend/src/index.html b/code/frontend/src/index.html index 6ad7afe3..1d25cbb1 100644 --- a/code/frontend/src/index.html +++ b/code/frontend/src/index.html @@ -18,6 +18,20 @@ document.head.appendChild(baseTag); })(); +