feat: add unified Settings dialog (#18)

This commit is contained in:
nessar committed 2026-10-04 22:01:00 +02:00
1 parent bdb1e3e230
commit 5a34160ab2
29 files changed
+420 -453

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { HarnessLoader, TestKey } from '@angular/cdk/testing';
import { MatButtonHarness } from '@angular/material/button/testing';
import { MatOptionHarness } from '@angular/material/core/testing';
import { MatDialogHarness } from '@angular/material/dialog/testing';
import { MatSelectHarness } from '@angular/material/select/testing';
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
import { vi } from 'vitest';
import { App } from './app';
import { UserInputService } from './services/user-input-service';
describe('Application Settings', () => {
let fixture: ComponentFixture<App>;
let loader: HarnessLoader;
let opener: HTMLButtonElement;
let mediaQuery: EventTarget & { matches: boolean; media: string };
beforeEach(async () => {
localStorage.clear();
localStorage.setItem('app.language', JSON.stringify('en'));
mediaQuery = Object.assign(new EventTarget(), {
matches: true,
media: '(prefers-color-scheme: light)',
});
const otherMedia = window.matchMedia('(max-width: 0px)');
vi.spyOn(window, 'matchMedia').mockImplementation((query) =>
query === mediaQuery.media
? (mediaQuery as unknown as MediaQueryList)
: { ...otherMedia, media: query },
);
await createApp();
});
async function createApp(): Promise<void> {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
TestBed.inject(UserInputService).vacationNumberSignal.set([]);
fixture = TestBed.createComponent(App);
fixture.detectChanges();
await fixture.whenStable();
loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
}
afterEach(() => {
fixture?.destroy();
vi.restoreAllMocks();
localStorage.clear();
document.body.classList.remove('light-mode', 'dark-mode');
});
async function openSettings(): Promise<MatDialogHarness> {
const root = fixture.nativeElement as HTMLElement;
opener = root.querySelector<HTMLButtonElement>(
'header.app-header button[aria-haspopup="dialog"]',
)!;
expect(opener).toBeDefined();
opener.focus();
opener.click();
return loader.getHarness(MatDialogHarness);
}
async function selectOption(select: MatSelectHarness, text: string): Promise<void> {
await select.open();
for (const option of await loader.getAllHarnesses(MatOptionHarness)) {
if ((await option.getText()) === text) {
await option.click();
await expect.poll(() => document.querySelector('[role="listbox"]')).toBeNull();
return;
}
}
throw new Error(`No option labelled ${text}`);
}
function changeSystemAppearance(isLight: boolean): void {
mediaQuery.matches = isLight;
mediaQuery.dispatchEvent(Object.assign(new Event('change'), { matches: isLight }));
fixture.detectChanges();
}
it('opens one labelled Settings dialog from the header and keeps planner inputs in place', async () => {
const dialog = await openSettings();
expect(await loader.getAllHarnesses(MatDialogHarness)).toHaveLength(1);
expect(await dialog.getTitleText()).toBe('Settings');
expect(await dialog.getRole()).toBe('dialog');
const titleId = await dialog.getAriaLabelledby();
expect(document.getElementById(titleId!)?.textContent?.trim()).toBe('Settings');
const selects = await dialog.getAllHarnesses(MatSelectHarness);
expect(selects).toHaveLength(2);
expect(await selects[0].getLabel()).toBe('Theme');
expect(await selects[1].getLabel()).toBe('Language');
expect(await selects[0].getValueText()).toBe('System');
expect(await selects[1].getValueText()).toBe('English');
expect(await dialog.getHarness(MatSlideToggleHarness)).toBeDefined();
expect(
fixture.nativeElement.querySelector('app-calendar-year app-calendar-settings'),
).toBeNull();
expect(
fixture.nativeElement.querySelector('app-user-input app-rules-panel'),
).not.toBeNull();
expect(await dialog.getAllHarnesses(MatButtonHarness)).toHaveLength(1);
});
it('supports keyboard theme selection and moves focus into the dialog', async () => {
const dialog = await openSettings();
const [theme] = await dialog.getAllHarnesses(MatSelectHarness);
await expect
.poll(() => document.querySelector('[role="dialog"]')?.contains(document.activeElement))
.toBe(true);
await theme.focus();
expect(await theme.isFocused()).toBe(true);
expect(document.querySelector('[role="dialog"]')?.contains(document.activeElement)).toBe(
true,
);
const control = await theme.host();
await control.sendKeys(TestKey.ENTER);
expect(await theme.isOpen()).toBe(true);
await control.sendKeys(TestKey.END, TestKey.ENTER);
expect(await theme.getValueText()).toBe('Dark');
expect(document.body.classList.contains('dark-mode')).toBe(true);
expect(localStorage.getItem('theme.preference')).toBe('"dark"');
});
it('follows live System appearance and keeps explicit theme selections fixed', async () => {
expect(document.body.classList.contains('light-mode')).toBe(true);
const dialog = await openSettings();
const [theme] = await dialog.getAllHarnesses(MatSelectHarness);
expect(await dialog.getContentText()).toMatch(/Current system appearance:\s+Light/);
expect(localStorage.getItem('theme.preference')).toBeNull();
changeSystemAppearance(false);
expect(await theme.getValueText()).toBe('System');
expect(await dialog.getContentText()).toMatch(/Current system appearance:\s+Dark/);
expect(document.body.classList.contains('dark-mode')).toBe(true);
expect(document.body.classList.contains('light-mode')).toBe(false);
expect(localStorage.getItem('theme.preference')).toBeNull();
await selectOption(theme, 'Light');
changeSystemAppearance(true);
changeSystemAppearance(false);
expect(await theme.getValueText()).toBe('Light');
expect(document.body.classList.contains('light-mode')).toBe(true);
expect(document.body.classList.contains('dark-mode')).toBe(false);
await selectOption(theme, 'Dark');
changeSystemAppearance(true);
expect(await theme.getValueText()).toBe('Dark');
expect(document.body.classList.contains('dark-mode')).toBe(true);
expect(document.body.classList.contains('light-mode')).toBe(false);
await selectOption(theme, 'System');
expect(document.body.classList.contains('light-mode')).toBe(true);
changeSystemAppearance(false);
expect(document.body.classList.contains('dark-mode')).toBe(true);
expect(localStorage.getItem('theme.preference')).toBe('"system"');
});
it.each(['Close', 'Escape'])(
'applies and saves selections immediately, retaining them after %s',
async (closing) => {
const dialog = await openSettings();
const [theme, language] = await dialog.getAllHarnesses(MatSelectHarness);
await selectOption(theme, 'Dark');
expect(document.body.classList.contains('dark-mode')).toBe(true);
expect(localStorage.getItem('theme.preference')).toBe('"dark"');
await selectOption(language, 'Français');
expect(localStorage.getItem('app.language')).toBe('"fr"');
expect(await dialog.getTitleText()).toBe('Paramètres');
expect(await theme.getValueText()).toBe('Sombre');
expect(fixture.nativeElement.querySelector('header.app-header p').textContent).toBe(
'Faites plus de place aux vacances.',
);
expect(fixture.nativeElement.querySelector('#leave-settings-title').textContent).toBe(
'Configurez vos congés',
);
expect(fixture.nativeElement.querySelector('.week-number')).toBeNull();
await (await dialog.getHarness(MatSlideToggleHarness)).check();
expect(localStorage.getItem('calendarSettings.showWeekNumbers')).toBe('true');
expect(fixture.nativeElement.querySelectorAll('.week-number').length).toBeGreaterThan(
0,
);
if (closing === 'Close') {
const closeButton = await dialog.getHarness(MatButtonHarness);
expect(await closeButton.getText()).toBe('Fermer');
await closeButton.click();
} else {
await dialog.close();
}
await expect
.poll(async () => (await loader.getAllHarnesses(MatDialogHarness)).length)
.toBe(0);
expect(document.activeElement).toBe(opener);
expect(fixture.nativeElement.querySelector('.week-number')).not.toBeNull();
const reopened = await openSettings();
const [restoredTheme, restoredLanguage] =
await reopened.getAllHarnesses(MatSelectHarness);
expect(await restoredTheme.getValueText()).toBe('Sombre');
expect(await restoredLanguage.getValueText()).toBe('Français');
expect(await (await reopened.getHarness(MatSlideToggleHarness)).isChecked()).toBe(true);
fixture.destroy();
TestBed.resetTestingModule();
await createApp();
expect(document.body.classList.contains('dark-mode')).toBe(true);
expect(
fixture.nativeElement.querySelector('header.app-header button').textContent,
).toContain('Paramètres');
expect(fixture.nativeElement.querySelector('.week-number')).not.toBeNull();
const freshDialog = await openSettings();
const [savedTheme, savedLanguage] = await freshDialog.getAllHarnesses(MatSelectHarness);
expect(await savedTheme.getValueText()).toBe('Sombre');
expect(await savedLanguage.getValueText()).toBe('Français');
expect(await (await freshDialog.getHarness(MatSlideToggleHarness)).isChecked()).toBe(
true,
);
},
);
});
+10 -1
View File
@@ -9,7 +9,16 @@
<p>{{ tr().app.description }}</p>
</div>
</div>
<app-dark-light-mode-button></app-dark-light-mode-button>
<button
mat-stroked-button
type="button"
class="settings-button"
aria-haspopup="dialog"
(click)="openSettings()"
>
<mat-icon aria-hidden="true">settings</mat-icon>
{{ tr().settings.title }}
</button>
</header>
<main class="planner-layout">
+10
View File
@@ -22,10 +22,20 @@
border-block-end: 1px solid var(--ui-border);
}
.settings-button {
flex-shrink: 0;
min-height: 2.75rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.875rem;
min-width: 0;
> div {
min-width: 0;
}
> mat-icon {
display: grid;
+14 -9
View File
@@ -3,10 +3,13 @@ import { MatDatepickerModule } from '@angular/material/datepicker';
import { provideNativeDateAdapter } from '@angular/material/core';
import { UserInput } from './components/user-input/user-input';
import { CalendarYear } from './components/calendar-year/calendar-year';
import { DarkLightModeButton } from './components/dark-light-mode-button/dark-light-mode-button';
import { UserSettings } from './components/user-settings/user-settings';
import { MatButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { TranslationService } from './services/translation-service';
import { ThemeService } from './services/theme-service';
function isBenchmarkUrl(url: string): boolean {
return url.split(/[?#]/, 1)[0].replace(/\/+$/, '') === '/benchmark';
@@ -14,14 +17,7 @@ function isBenchmarkUrl(url: string): boolean {
@Component({
selector: 'app-root',
imports: [
MatDatepickerModule,
UserInput,
CalendarYear,
DarkLightModeButton,
MatIcon,
RouterOutlet,
],
imports: [MatDatepickerModule, UserInput, CalendarYear, MatButton, MatIcon, RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss',
providers: [provideNativeDateAdapter()],
@@ -29,6 +25,7 @@ function isBenchmarkUrl(url: string): boolean {
})
export class App {
private readonly router = inject(Router);
private readonly dialog = inject(MatDialog);
protected readonly tr = inject(TranslationService).tr;
protected readonly title = signal('vac-optimizer');
protected readonly showPlanner = signal(
@@ -36,9 +33,17 @@ export class App {
);
constructor() {
inject(ThemeService);
this.router.events.subscribe((event) => {
if (event instanceof NavigationEnd)
this.showPlanner.set(!isBenchmarkUrl(event.urlAfterRedirects));
});
}
protected openSettings(): void {
this.dialog.open(UserSettings, {
width: '28rem',
maxWidth: 'calc(100vw - 2rem)',
});
}
}
@@ -1,4 +0,0 @@
<button mat-stroked-button type="button" (click)="openDialog()">
<mat-icon aria-hidden="true">settings</mat-icon>
{{ tr().settings.title }}
</button>
@@ -1,7 +0,0 @@
:host {
display: inline-flex;
}
button {
min-height: 2.75rem;
}
@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { CalendarSettings } from './calendar-settings';
describe('CalendarSettings', () => {
let component: CalendarSettings;
let fixture: ComponentFixture<CalendarSettings>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CalendarSettings],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
fixture = TestBed.createComponent(CalendarSettings);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -1,27 +0,0 @@
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { CalendarSettingsDialog } from '../dialog/calendar-settings-dialog/calendar-settings-dialog';
import { MatDividerModule } from '@angular/material/divider';
import { TranslationService } from '../../services/translation-service';
@Component({
selector: 'app-calendar-settings',
imports: [MatButtonModule, MatDividerModule, MatIconModule],
templateUrl: './calendar-settings.html',
styleUrl: './calendar-settings.scss',
})
export class CalendarSettings {
private readonly translationService = inject(TranslationService);
get tr() {
return this.translationService.tr;
}
constructor(private dialog: MatDialog) {}
openDialog() {
this.dialog.open(CalendarSettingsDialog);
}
}
@@ -51,7 +51,6 @@
</p>
}
</div>
<app-calendar-settings></app-calendar-settings>
</header>
@if (planningOutcome(); as outcome) {
@@ -21,7 +21,6 @@ import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-month';
import { CalendarSettings } from '../calendar-settings/calendar-settings';
import {
CalendarService,
DayType,
@@ -69,7 +68,6 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra
MatCardContent,
MatProgressSpinner,
MatIcon,
CalendarSettings,
],
templateUrl: './calendar-year.html',
styleUrl: './calendar-year.scss',
@@ -1,9 +0,0 @@
<button
mat-icon-button
type="button"
(click)="toggleTheme()"
[attr.aria-label]="description()"
[attr.title]="description()"
>
<mat-icon aria-hidden="true" [fontIcon]="icon()"></mat-icon>
</button>
@@ -1,13 +0,0 @@
button {
width: 2.75rem;
height: 2.75rem;
padding: 0.625rem;
border: 1px solid var(--ui-border);
border-radius: 50%;
color: var(--mat-sys-primary);
background: var(--ui-panel-bg);
transition: background-color 150ms ease;
&:hover {
background: var(--mat-sys-primary-container);
}
}
@@ -1,158 +0,0 @@
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)';
private listener?: (event: MediaQueryListEvent) => void;
constructor(matches: boolean) {
this.matches = matches;
}
addEventListener(_type: string, listener: (event: MediaQueryListEvent) => void): void {
this.listener = listener;
}
change(matches: boolean): void {
this.matches = matches;
this.listener?.({ matches, media: this.media } as MediaQueryListEvent);
}
}
function expectTheme(
button: HTMLButtonElement,
icon: string,
description: string,
isLight: boolean,
): void {
expect(button.querySelector('mat-icon')?.getAttribute('data-mat-icon-name')).toBe(icon);
expect(button.getAttribute('aria-label')).toBe(description);
expect(button.getAttribute('title')).toBe(description);
expect(document.body.classList.contains('light-mode')).toBe(isLight);
expect(document.body.classList.contains('dark-mode')).toBe(!isLight);
}
describe('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()],
}).compileComponents();
fixture = TestBed.createComponent(DarkLightModeButton);
fixture.detectChanges();
});
afterEach(() => {
vi.restoreAllMocks();
document.body.classList.remove('light-mode', 'dark-mode');
});
it('starts in System and resolves its initial appearance from the browser preference', () => {
const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
expectTheme(
button,
'brightness_auto',
'Theme: System (currently Light). Switch to Light theme',
true,
);
});
it('cycles System to Light to Dark and wraps back to System', () => {
const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
button.click();
fixture.detectChanges();
expectTheme(
button,
'light_mode',
'Theme: Light (currently Light). Switch to Dark theme',
true,
);
button.click();
fixture.detectChanges();
expectTheme(
button,
'dark_mode',
'Theme: Dark (currently Dark). Switch to System theme',
false,
);
button.click();
fixture.detectChanges();
expectTheme(
button,
'brightness_auto',
'Theme: System (currently Light). Switch to Light theme',
true,
);
});
it('follows live browser changes while System is selected', () => {
const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
mediaQuery.change(false);
fixture.detectChanges();
expectTheme(
button,
'brightness_auto',
'Theme: System (currently Dark). Switch to Light theme',
false,
);
mediaQuery.change(true);
fixture.detectChanges();
expectTheme(
button,
'brightness_auto',
'Theme: System (currently Light). Switch to Light theme',
true,
);
});
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();
expectTheme(
button,
'light_mode',
'Theme: Light (currently Light). Switch to Dark theme',
true,
);
button.click();
fixture.detectChanges();
mediaQuery.change(true);
fixture.detectChanges();
expectTheme(
button,
'dark_mode',
'Theme: Dark (currently Dark). Switch to System theme',
false,
);
mediaQuery.change(false);
button.click();
fixture.detectChanges();
expectTheme(
button,
'brightness_auto',
'Theme: System (currently Dark). Switch to Light theme',
false,
);
});
});
@@ -1,35 +0,0 @@
import { Component, computed, inject } from '@angular/core';
import { EffectiveTheme, ThemePreference, ThemeService } from '../../services/theme-service';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
@Component({
selector: 'app-dark-light-mode-button',
imports: [MatIconButton, MatIcon],
templateUrl: './dark-light-mode-button.html',
styleUrl: './dark-light-mode-button.scss',
})
export class DarkLightModeButton {
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,37 +0,0 @@
<div class="container" style="min-width: 280px; padding: 8px">
<div style="margin-bottom: 1.5rem; text-align: center">
<h1 style="margin: 0 0 0.5rem 0; font-size: 1.5rem">{{ tr().settings.title }}</h1>
<p style="margin: 0; opacity: 0.7; font-size: 0.9rem">{{ tr().settings.description }}</p>
</div>
<div style="display: flex; flex-direction: column; gap: 1rem; width: 100%">
@for (setting of calendarSettingsService.settingsArray; track setting.id) {
<div style="align-self: flex-start; margin-bottom: 0.5rem">
<mat-slide-toggle
[id]="setting.id"
[name]="setting.id"
[checked]="setting.signal()"
(change)="setting.signal.set($event.checked)"
>
{{ getSettingDescription(setting.id) }}
</mat-slide-toggle>
</div>
}
<mat-form-field appearance="outline" style="width: 100%; margin-top: 0.5rem">
<mat-label>{{ tr().settings.selectLanguage }}</mat-label>
<mat-select
[value]="translationService.language()"
(valueChange)="translationService.setLanguage($event)"
>
@for (lang of translationService.availableLanguages; track lang) {
<mat-option [value]="lang">{{
translationService.getLanguageName(lang)
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<button mat-button (click)="closeDialog()" style="margin-top: 1.5rem; width: 100%">
{{ tr().settings.close }}
</button>
</div>
@@ -1,9 +0,0 @@
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
flex-direction: column;
overflow: hidden;
margin: 16px;
}
@@ -1,42 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { MatDialogRef } from '@angular/material/dialog';
import { vi } from 'vitest';
import { CalendarSettingsDialog } from './calendar-settings-dialog';
describe('CalendarSettingsDialog', () => {
let component: CalendarSettingsDialog;
let fixture: ComponentFixture<CalendarSettingsDialog>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CalendarSettingsDialog],
providers: [
provideZonelessChangeDetection(),
{
provide: MatDialogRef,
useValue: {
close: vi.fn(),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(CalendarSettingsDialog);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have calendar settings service', () => {
expect(component['calendarSettingsService']).toBeDefined();
});
it('should close dialog when closeDialog is called', () => {
component.closeDialog();
expect(component.dialogRef.close).toHaveBeenCalled();
});
});
@@ -1,36 +0,0 @@
import { Component, inject } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatDialogRef } from '@angular/material/dialog';
import { CalendarSettingsService } from '../../../services/calendar-settings-service';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { TranslationService } from '../../../services/translation-service';
@Component({
selector: 'app-calendar-settings-dialog',
imports: [MatButton, MatSlideToggle, MatFormFieldModule, MatSelectModule],
templateUrl: './calendar-settings-dialog.html',
styleUrl: './calendar-settings-dialog.scss',
})
export class CalendarSettingsDialog {
public readonly dialogRef = inject<MatDialogRef<CalendarSettingsDialog>>(MatDialogRef);
protected readonly calendarSettingsService = inject(CalendarSettingsService);
protected readonly translationService = inject(TranslationService);
get tr() {
return this.translationService.tr;
}
getSettingDescription(id: string): string {
const tr = this.tr();
if (id === 'showWeekNumbers') {
return tr.settings.showWeekNumbers;
}
return id;
}
closeDialog() {
this.dialogRef.close();
}
}
@@ -1 +1,47 @@
<p>user-settings works!</p>
<h2 mat-dialog-title>{{ tr().settings.title }}</h2>
<mat-dialog-content>
<p>{{ tr().settings.description }}</p>
<div class="settings-controls">
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.selectTheme }}</mat-label>
<mat-select
[value]="themeService.themePreference()"
(valueChange)="themeService.setThemePreference($event)"
>
@for (preference of themePreferences; track preference) {
<mat-option [value]="preference">{{
tr().settings.themeOptions[preference]
}}</mat-option>
}
</mat-select>
@if (themeService.themePreference() === 'system') {
<mat-hint aria-live="polite">
{{ tr().settings.systemAppearance }}:
{{ tr().settings.themeOptions[themeService.effectiveTheme()] }}
</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.selectLanguage }}</mat-label>
<mat-select
[value]="translationService.language()"
(valueChange)="translationService.setLanguage($event)"
>
@for (lang of translationService.availableLanguages; track lang) {
<mat-option [value]="lang">{{
translationService.getLanguageName(lang)
}}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-slide-toggle
[checked]="showWeekNumbers()"
(change)="showWeekNumbers.set($event.checked)"
>
{{ tr().settings.showWeekNumbers }}
</mat-slide-toggle>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" mat-dialog-close>{{ tr().settings.close }}</button>
</mat-dialog-actions>
@@ -0,0 +1,18 @@
:host {
display: block;
}
mat-dialog-content > p {
margin: 0 0 1.5rem;
color: var(--ui-text-secondary);
}
.settings-controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
mat-form-field {
width: 100%;
}
@@ -1,9 +1,38 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButton } from '@angular/material/button';
import {
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { ThemeService } from '../../services/theme-service';
import { TranslationService } from '../../services/translation-service';
@Component({
selector: 'app-user-settings',
imports: [],
imports: [
MatButton,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle,
MatFormFieldModule,
MatSelectModule,
MatSlideToggle,
],
templateUrl: './user-settings.html',
styleUrl: './user-settings.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserSettings {}
export class UserSettings {
protected readonly themeService = inject(ThemeService);
protected readonly translationService = inject(TranslationService);
protected readonly showWeekNumbers = inject(CalendarSettingsService).get('showWeekNumbers');
protected readonly tr = this.translationService.tr;
protected readonly themePreferences = ['system', 'light', 'dark'] as const;
}
@@ -33,7 +33,7 @@ export class CalendarSettingsService {
constructor() {
for (const [id, def] of this.definitions) {
const storageKey = `calendarSettings.${id}`;
this.signals.set(id, storedSignal(storageKey, def.defaultValue, this.injector));
this.signals.set(id, storedSignal(storageKey, def.defaultValue, this.injector, 0));
}
this.settingsArray = Array.from(this.definitions.entries()).map(([id, def]) => ({
+13 -12
View File
@@ -1,4 +1,5 @@
import { computed, Injectable, signal } from '@angular/core';
import { computed, inject, Injectable, Injector, signal } from '@angular/core';
import { storedSignal } from '../shared/utils/stored-signal';
export type ThemePreference = 'system' | 'light' | 'dark';
export type EffectiveTheme = 'light' | 'dark';
@@ -7,15 +8,23 @@ export type EffectiveTheme = 'light' | 'dark';
export class ThemeService {
private readonly lightModeMedia = window.matchMedia('(prefers-color-scheme: light)');
private readonly systemIsLight = signal(this.lightModeMedia.matches);
private readonly selectedPreference = signal<ThemePreference>('system');
private readonly selectedPreference = storedSignal<ThemePreference>(
'theme.preference',
'system',
inject(Injector),
0,
);
readonly themePreference = this.selectedPreference.asReadonly();
readonly effectiveTheme = computed<EffectiveTheme>(() => {
const preference = this.selectedPreference();
return preference === 'system' ? (this.systemIsLight() ? 'light' : 'dark') : preference;
});
readonly nextThemePreference = computed(() => this.nextPreference(this.selectedPreference()));
constructor() {
const preference = this.selectedPreference();
if (preference !== 'system' && preference !== 'light' && preference !== 'dark') {
this.selectedPreference.set('system');
}
this.applyEffectiveTheme();
this.lightModeMedia.addEventListener('change', (e) => {
this.systemIsLight.set(e.matches);
@@ -25,19 +34,11 @@ export class ThemeService {
});
}
toggleTheme(): void {
this.setThemePreference(this.nextThemePreference());
}
private setThemePreference(preference: ThemePreference): void {
setThemePreference(preference: ThemePreference): void {
this.selectedPreference.set(preference);
this.applyEffectiveTheme();
}
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);
+8
View File
@@ -50,6 +50,13 @@ export interface AppTranslations {
showWeekNumbers: string;
close: string;
selectLanguage: string;
selectTheme: string;
themeOptions: {
system: string;
light: string;
dark: string;
};
systemAppearance: string;
};
rules: {
title: string;
@@ -307,6 +314,7 @@ export class TranslationService {
'app.language',
this.getBrowserLanguage(),
this.injector,
0,
);
public readonly tr = computed<AppTranslations>(() => {
+23 -12
View File
@@ -18,14 +18,15 @@ function isLocalStorageAvailable(): boolean {
* The signal will:
* - Initialize with the stored value from localStorage if available
* - Fall back to the provided initial value if no stored value exists or parsing fails
* - Automatically sync changes to localStorage with debouncing (300ms delay)
* - Automatically sync changes to localStorage with debouncing (300ms delay), or
* synchronously on selection when debounceMs is 0
* - Work safely in SSR and environments where localStorage is unavailable
*
* @template T - The type of value stored in the signal (must be JSON-serializable)
* @param key - The localStorage key used to store the value
* @param initialValue - The default value used when no stored value exists
* @param injector - The Angular injector (use inject(Injector) to get current context)
* @param debounceMs - Optional debounce delay in milliseconds (default: 300ms)
* @param debounceMs - Debounce delay in milliseconds; 0 saves synchronously (default: 300ms)
* @returns A writable signal that persists to localStorage
*
* @example
@@ -71,6 +72,25 @@ export function storedSignal<T>(
}
const s = signal(parsedValue);
const saveValue = (value: T): void => {
if (isLocalStorageAvailable()) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.warn(`Failed to save value for key "${key}" to localStorage.`, error);
}
}
};
if (debounceMs === 0) {
const set = s.set;
s.set = (value) => {
set(value);
saveValue(value);
};
s.update = (updater) => s.set(updater(s()));
return s;
}
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let isFirstRun = true;
@@ -92,16 +112,7 @@ export function storedSignal<T>(
// Debounce the localStorage write
timeoutId = setTimeout(() => {
if (isLocalStorageAvailable()) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.warn(
`Failed to save value for key "${key}" to localStorage.`,
error,
);
}
}
saveValue(value);
timeoutId = null;
}, debounceMs);
});
+5 -2
View File
@@ -42,10 +42,13 @@
},
"settings": {
"title": "Einstellungen",
"description": "Einstellungen anpassen",
"description": "Änderungen werden automatisch gespeichert.",
"showWeekNumbers": "Wochennummern anzeigen",
"close": "Schließen",
"selectLanguage": "Sprache auswählen"
"selectLanguage": "Sprache auswählen",
"selectTheme": "Design",
"themeOptions": { "system": "System", "light": "Hell", "dark": "Dunkel" },
"systemAppearance": "Aktuelles Systemdesign"
},
"rules": {
"title": "Planungsregeln",
+6 -3
View File
@@ -41,11 +41,14 @@
"any": "Any"
},
"settings": {
"title": "Calendar Settings",
"description": "Configure your calendar settings here.",
"title": "Settings",
"description": "Changes are saved automatically.",
"showWeekNumbers": "Show week numbers",
"close": "Close",
"selectLanguage": "Language"
"selectLanguage": "Language",
"selectTheme": "Theme",
"themeOptions": { "system": "System", "light": "Light", "dark": "Dark" },
"systemAppearance": "Current system appearance"
},
"rules": {
"title": "Planning rules",
+6 -3
View File
@@ -41,11 +41,14 @@
"any": "Cualquier"
},
"settings": {
"title": "Ajustes del calendario",
"description": "Configure sus ajustes del calendario aquí.",
"title": "Ajustes",
"description": "Los cambios se guardan automáticamente.",
"showWeekNumbers": "Mostrar números de semana",
"close": "Cerrar",
"selectLanguage": "Idioma"
"selectLanguage": "Idioma",
"selectTheme": "Tema",
"themeOptions": { "system": "Sistema", "light": "Claro", "dark": "Oscuro" },
"systemAppearance": "Apariencia actual del sistema"
},
"rules": {
"title": "Reglas de planificación",
+6 -3
View File
@@ -41,11 +41,14 @@
"any": "N'importe"
},
"settings": {
"title": "Paramètres du calendrier",
"description": "Configurez vos paramètres de calendrier ici.",
"title": "Paramètres",
"description": "Les modifications sont enregistrées automatiquement.",
"showWeekNumbers": "Afficher les numéros de semaine",
"close": "Fermer",
"selectLanguage": "Langue"
"selectLanguage": "Langue",
"selectTheme": "Thème",
"themeOptions": { "system": "Système", "light": "Clair", "dark": "Sombre" },
"systemAppearance": "Apparence actuelle du système"
},
"rules": {
"title": "Règles de planification",