feat: add unified Settings dialog (#18)
This commit is contained in:
1 parent
bdb1e3e230
commit
5a34160ab2
29 files changed
+420
-453
No files matched your search
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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]) => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>(() => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user