diff --git a/src/app/app-settings.spec.ts b/src/app/app-settings.spec.ts new file mode 100644 index 0000000..820e8ef --- /dev/null +++ b/src/app/app-settings.spec.ts @@ -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; + 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 { + 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 { + const root = fixture.nativeElement as HTMLElement; + opener = root.querySelector( + '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 { + 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, + ); + }, + ); +}); diff --git a/src/app/app.html b/src/app/app.html index dce6931..cfc03ed 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -9,7 +9,16 @@

{{ tr().app.description }}

- +
diff --git a/src/app/app.scss b/src/app/app.scss index e265f9c..ed9d694 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -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; diff --git a/src/app/app.ts b/src/app/app.ts index e7eab4a..ed74d4b 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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)', + }); + } } diff --git a/src/app/components/calendar-settings/calendar-settings.html b/src/app/components/calendar-settings/calendar-settings.html deleted file mode 100644 index 6712189..0000000 --- a/src/app/components/calendar-settings/calendar-settings.html +++ /dev/null @@ -1,4 +0,0 @@ - diff --git a/src/app/components/calendar-settings/calendar-settings.scss b/src/app/components/calendar-settings/calendar-settings.scss deleted file mode 100644 index 1079938..0000000 --- a/src/app/components/calendar-settings/calendar-settings.scss +++ /dev/null @@ -1,7 +0,0 @@ -:host { - display: inline-flex; -} - -button { - min-height: 2.75rem; -} diff --git a/src/app/components/calendar-settings/calendar-settings.spec.ts b/src/app/components/calendar-settings/calendar-settings.spec.ts deleted file mode 100644 index 35b5cce..0000000 --- a/src/app/components/calendar-settings/calendar-settings.spec.ts +++ /dev/null @@ -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; - - 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(); - }); -}); diff --git a/src/app/components/calendar-settings/calendar-settings.ts b/src/app/components/calendar-settings/calendar-settings.ts deleted file mode 100644 index 089b5d4..0000000 --- a/src/app/components/calendar-settings/calendar-settings.ts +++ /dev/null @@ -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); - } -} diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html index 08cfa34..59ebd65 100644 --- a/src/app/components/calendar-year/calendar-year.html +++ b/src/app/components/calendar-year/calendar-year.html @@ -51,7 +51,6 @@

} - @if (planningOutcome(); as outcome) { diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index d019f6d..4758c5a 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -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', diff --git a/src/app/components/dark-light-mode-button/dark-light-mode-button.html b/src/app/components/dark-light-mode-button/dark-light-mode-button.html deleted file mode 100644 index 919c687..0000000 --- a/src/app/components/dark-light-mode-button/dark-light-mode-button.html +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/src/app/components/dark-light-mode-button/dark-light-mode-button.scss b/src/app/components/dark-light-mode-button/dark-light-mode-button.scss deleted file mode 100644 index 3ae6399..0000000 --- a/src/app/components/dark-light-mode-button/dark-light-mode-button.scss +++ /dev/null @@ -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); - } -} diff --git a/src/app/components/dark-light-mode-button/dark-light-mode-button.spec.ts b/src/app/components/dark-light-mode-button/dark-light-mode-button.spec.ts deleted file mode 100644 index 9bd228c..0000000 --- a/src/app/components/dark-light-mode-button/dark-light-mode-button.spec.ts +++ /dev/null @@ -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; - 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, - ); - }); -}); diff --git a/src/app/components/dark-light-mode-button/dark-light-mode-button.ts b/src/app/components/dark-light-mode-button/dark-light-mode-button.ts deleted file mode 100644 index 0821cfc..0000000 --- a/src/app/components/dark-light-mode-button/dark-light-mode-button.ts +++ /dev/null @@ -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); - } -} diff --git a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.html b/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.html deleted file mode 100644 index f1a208f..0000000 --- a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.html +++ /dev/null @@ -1,37 +0,0 @@ -
-
-

{{ tr().settings.title }}

-

{{ tr().settings.description }}

-
-
- @for (setting of calendarSettingsService.settingsArray; track setting.id) { -
- - {{ getSettingDescription(setting.id) }} - -
- } - - - {{ tr().settings.selectLanguage }} - - @for (lang of translationService.availableLanguages; track lang) { - {{ - translationService.getLanguageName(lang) - }} - } - - -
- -
diff --git a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.scss b/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.scss deleted file mode 100644 index de9fcc0..0000000 --- a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.scss +++ /dev/null @@ -1,9 +0,0 @@ -.container { - display: flex; - justify-content: center; - align-items: center; - height: 100%; - flex-direction: column; - overflow: hidden; - margin: 16px; -} diff --git a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.spec.ts b/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.spec.ts deleted file mode 100644 index a04a71a..0000000 --- a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.spec.ts +++ /dev/null @@ -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; - - 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(); - }); -}); diff --git a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.ts b/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.ts deleted file mode 100644 index 81fcd91..0000000 --- a/src/app/components/dialog/calendar-settings-dialog/calendar-settings-dialog.ts +++ /dev/null @@ -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); - 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(); - } -} diff --git a/src/app/components/user-settings/user-settings.html b/src/app/components/user-settings/user-settings.html index 6191a86..f976c58 100644 --- a/src/app/components/user-settings/user-settings.html +++ b/src/app/components/user-settings/user-settings.html @@ -1 +1,47 @@ -

user-settings works!

+

{{ tr().settings.title }}

+ +

{{ tr().settings.description }}

+
+ + {{ tr().settings.selectTheme }} + + @for (preference of themePreferences; track preference) { + {{ + tr().settings.themeOptions[preference] + }} + } + + @if (themeService.themePreference() === 'system') { + + {{ tr().settings.systemAppearance }}: + {{ tr().settings.themeOptions[themeService.effectiveTheme()] }} + + } + + + {{ tr().settings.selectLanguage }} + + @for (lang of translationService.availableLanguages; track lang) { + {{ + translationService.getLanguageName(lang) + }} + } + + + + {{ tr().settings.showWeekNumbers }} + +
+
+ + + diff --git a/src/app/components/user-settings/user-settings.scss b/src/app/components/user-settings/user-settings.scss index e69de29..16e79eb 100644 --- a/src/app/components/user-settings/user-settings.scss +++ b/src/app/components/user-settings/user-settings.scss @@ -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%; +} diff --git a/src/app/components/user-settings/user-settings.ts b/src/app/components/user-settings/user-settings.ts index c310b86..b985dba 100644 --- a/src/app/components/user-settings/user-settings.ts +++ b/src/app/components/user-settings/user-settings.ts @@ -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; +} diff --git a/src/app/services/calendar-settings-service.ts b/src/app/services/calendar-settings-service.ts index 7db0fe9..e4f1a5e 100644 --- a/src/app/services/calendar-settings-service.ts +++ b/src/app/services/calendar-settings-service.ts @@ -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]) => ({ diff --git a/src/app/services/theme-service.ts b/src/app/services/theme-service.ts index 65291c6..cf7e334 100644 --- a/src/app/services/theme-service.ts +++ b/src/app/services/theme-service.ts @@ -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('system'); + private readonly selectedPreference = storedSignal( + 'theme.preference', + 'system', + inject(Injector), + 0, + ); readonly themePreference = this.selectedPreference.asReadonly(); readonly effectiveTheme = computed(() => { 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); diff --git a/src/app/services/translation-service.ts b/src/app/services/translation-service.ts index b1f6c73..bf18f27 100644 --- a/src/app/services/translation-service.ts +++ b/src/app/services/translation-service.ts @@ -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(() => { diff --git a/src/app/shared/utils/stored-signal.ts b/src/app/shared/utils/stored-signal.ts index 99ba0ee..17e25ac 100644 --- a/src/app/shared/utils/stored-signal.ts +++ b/src/app/shared/utils/stored-signal.ts @@ -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( } 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 | null = null; let isFirstRun = true; @@ -92,16 +112,7 @@ export function storedSignal( // 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); }); diff --git a/src/i18n/de.json b/src/i18n/de.json index f1bd760..2d1772d 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -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", diff --git a/src/i18n/en.json b/src/i18n/en.json index 3611a93..327d5a4 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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", diff --git a/src/i18n/es.json b/src/i18n/es.json index 10a7dc4..7586f12 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -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", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 25c20a8..617a6b4 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -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",