mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
Add option to follow system preferences for dark mode (#834)
This commit is contained in:
1 parent
9265ea8e31
commit
87b7a621dd
16 files changed
+511
-48
No files matched your search
@@ -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,
|
||||
|
||||
@@ -51,4 +51,5 @@ export const NEW_FEATURES: NewFeature[] = [
|
||||
{ id: 'force-import' },
|
||||
{ id: 'usenet-queue-cleaner' },
|
||||
{ id: 'qbit-api-key' },
|
||||
{ id: 'theme-follow-system' },
|
||||
];
|
||||
@@ -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');
|
||||
|
||||
@@ -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<AccentPreset, string> = {
|
||||
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<Theme>('dark');
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly _preference = signal<ThemePreference>('system');
|
||||
private readonly _systemDark = signal(false);
|
||||
private readonly _performanceMode = signal(false);
|
||||
private readonly _fullWidth = signal(false);
|
||||
private readonly _accent = signal<Accent>('default');
|
||||
private readonly _customAccent = signal<string>(DEFAULT_CUSTOM_ACCENT);
|
||||
|
||||
readonly theme = this._theme.asReadonly();
|
||||
readonly theme = computed<Theme>(() => {
|
||||
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')
|
||||
{
|
||||
|
||||
+18
-3
@@ -5,12 +5,13 @@
|
||||
|
||||
<div class="settings-form">
|
||||
<app-card header="Theme">
|
||||
<p class="section-hint">Choose between dark and light color modes.</p>
|
||||
<p class="section-hint">Choose dark, light, or follow your system setting.</p>
|
||||
<div class="theme-options">
|
||||
<button
|
||||
type="button"
|
||||
class="theme-option"
|
||||
[class.theme-option--active]="theme() === 'dark'"
|
||||
[class.theme-option--active]="preference() === 'dark'"
|
||||
[attr.aria-pressed]="preference() === 'dark'"
|
||||
(click)="selectTheme('dark')"
|
||||
>
|
||||
<span class="theme-option__preview theme-option__preview--dark"></span>
|
||||
@@ -19,12 +20,26 @@
|
||||
<button
|
||||
type="button"
|
||||
class="theme-option"
|
||||
[class.theme-option--active]="theme() === 'light'"
|
||||
[class.theme-option--active]="preference() === 'light'"
|
||||
[attr.aria-pressed]="preference() === 'light'"
|
||||
(click)="selectTheme('light')"
|
||||
>
|
||||
<span class="theme-option__preview theme-option__preview--light"></span>
|
||||
<span class="theme-option__label">Light</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="theme-option"
|
||||
[class.theme-option--active]="preference() === 'system'"
|
||||
[attr.aria-pressed]="preference() === 'system'"
|
||||
(click)="selectTheme('system')"
|
||||
>
|
||||
<span class="theme-option__preview theme-option__preview--system"></span>
|
||||
<span class="theme-option__label">
|
||||
System
|
||||
<app-new-badge variant="inline" featureId="theme-follow-system" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
|
||||
+26
-1
@@ -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);
|
||||
}
|
||||
|
||||
+136
@@ -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<ThemePreference>('system'),
|
||||
accent: signal<Accent>('default'),
|
||||
customAccent: signal<string>('#8b5cf6'),
|
||||
setTheme: vi.fn(),
|
||||
setAccent: vi.fn(),
|
||||
setCustomAccent: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
interface Setup {
|
||||
fixture: ComponentFixture<AppearanceSettingsComponent>;
|
||||
themeService: ReturnType<typeof createThemeService>;
|
||||
}
|
||||
|
||||
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<AppearanceSettingsComponent>): 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<HTMLButtonElement>(
|
||||
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<HTMLElement>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ interface Setup {
|
||||
|
||||
describe('NotificationsComponent', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('matchMedia', () => ({ matches: false }));
|
||||
vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: () => undefined, removeEventListener: () => undefined }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -6,13 +6,15 @@
|
||||
</div>
|
||||
|
||||
<div class="toolbar__right">
|
||||
<button
|
||||
class="toolbar__icon-btn"
|
||||
(click)="onToggleTheme()"
|
||||
[attr.aria-label]="theme() === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
|
||||
>
|
||||
<ng-icon [name]="theme() === 'dark' ? 'tablerSun' : 'tablerMoon'" />
|
||||
</button>
|
||||
<app-tooltip [text]="themeTooltip()">
|
||||
<button
|
||||
class="toolbar__icon-btn"
|
||||
(click)="onToggleTheme()"
|
||||
[attr.aria-label]="themeTooltip()"
|
||||
>
|
||||
<ng-icon [name]="themeIcon()" />
|
||||
</button>
|
||||
</app-tooltip>
|
||||
|
||||
<app-tooltip text="Expands page content edge-to-edge on wide monitors, removing the default width cap.">
|
||||
<app-toggle
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { WritableSignal, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { ThemeService, ThemePreference, Theme } from '@core/services/theme.service';
|
||||
import { ToolbarComponent } from './toolbar.component';
|
||||
|
||||
interface Harness {
|
||||
fixture: ComponentFixture<ToolbarComponent>;
|
||||
preference: WritableSignal<ThemePreference>;
|
||||
theme: WritableSignal<Theme>;
|
||||
cycleTheme: () => void;
|
||||
}
|
||||
|
||||
describe('ToolbarComponent', () => {
|
||||
function setup(): Harness {
|
||||
const preference = signal<ThemePreference>('dark');
|
||||
const theme = signal<Theme>('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<ToolbarComponent>): HTMLButtonElement {
|
||||
return fixture.nativeElement.querySelector('.toolbar__icon-btn') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function themeIconName(fixture: ComponentFixture<ToolbarComponent>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,20 @@
|
||||
document.head.appendChild(baseTag);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
let theme = null;
|
||||
try {
|
||||
theme = localStorage.getItem('cleanuparr-theme');
|
||||
} catch (e) {}
|
||||
|
||||
if (theme !== 'dark' && theme !== 'light') {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
document.documentElement.dataset.theme = theme;
|
||||
})();
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
|
||||
Reference in new issue
Block a user