From 20aaf4faa7bee82f21173cde0368830dfabc7aac Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Thu, 15 Oct 2026 16:23:41 +0200 Subject: [PATCH] feat(a11y): use native dialog for language selection Co-Authored-By: Claude Opus 5.5 --- src/app/app.component.html | 22 ++++++++--------- src/app/app.component.spec.ts | 20 +++++++++++---- src/app/app.component.ts | 46 +++++++++++++++-------------------- 3 files changed, 46 insertions(+), 42 deletions(-) diff --git a/src/app/app.component.html b/src/app/app.component.html index 0aad882..9b9f787 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,4 +1,4 @@ -
+
@@ -16,14 +16,14 @@ -@if (isLanguageSelectorOpen()) { - -} + } + diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 5ea2d0a..b283189 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -27,7 +27,7 @@ describe('AppComponent', (): void => { it('should keep the language selector closed on first visit', (): void => { const el = fixture.nativeElement as HTMLElement; - expect(el.querySelector('.language-overlay')).toBeNull(); + expect(el.querySelector('dialog.language-overlay')?.open).toBe(false); }); it('should preselect English by default', (): void => { @@ -51,7 +51,6 @@ describe('AppComponent', (): void => { const comp = component as unknown as { chooseLanguage(language: 'en' | 'de'): void; currentLanguageCode(): string; - isLanguageConfirmed(): boolean; isLanguageSelectorOpen(): boolean; }; @@ -59,7 +58,6 @@ describe('AppComponent', (): void => { fixture.detectChanges(); expect(comp.currentLanguageCode()).toBe('de'); - expect(comp.isLanguageConfirmed()).toBe(true); expect(comp.isLanguageSelectorOpen()).toBe(false); expect(localStorage.getItem('portfolio-language')).toBe('de'); }); @@ -71,12 +69,10 @@ describe('AppComponent', (): void => { restoredFixture.detectChanges(); const restoredComponent = restoredFixture.componentInstance as unknown as { currentLanguageCode(): string; - isLanguageConfirmed(): boolean; isLanguageSelectorOpen(): boolean; }; expect(restoredComponent.currentLanguageCode()).toBe('de'); - expect(restoredComponent.isLanguageConfirmed()).toBe(true); expect(restoredComponent.isLanguageSelectorOpen()).toBe(false); }); @@ -93,4 +89,18 @@ describe('AppComponent', (): void => { expect(comp.isLanguageSelectorOpen()).toBe(true); }); + + it('should sync the selector state when the dialog closes natively', (): void => { + const comp = component as unknown as { + reopenLanguageSelector(): void; + isLanguageSelectorOpen(): boolean; + }; + comp.reopenLanguageSelector(); + fixture.detectChanges(); + + const dialog = (fixture.nativeElement as HTMLElement).querySelector('dialog'); + dialog?.dispatchEvent(new Event('close')); + + expect(comp.isLanguageSelectorOpen()).toBe(false); + }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 07ee453..85a3f57 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,17 +1,18 @@ -import { DOCUMENT } from '@angular/common'; import { + afterRenderEffect, ChangeDetectionStrategy, Component, computed, - effect, inject, signal, + viewChild, } from '@angular/core'; -import type { OnDestroy, OnInit } from '@angular/core'; +import type { ElementRef, OnInit } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import type { LanguageCode } from '../languages/language.types'; import type { LanguageOption } from './app.types'; +import { LanguagePreferenceService } from './services/language-preference.service'; import { ShellLanguageService } from './services/shell-language.service'; @Component({ @@ -21,13 +22,14 @@ import { ShellLanguageService } from './services/shell-language.service'; templateUrl: './app.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AppComponent implements OnInit, OnDestroy { - private readonly document = inject(DOCUMENT); +export class AppComponent implements OnInit { + private readonly languagePreferenceService = inject(LanguagePreferenceService); private readonly shellLanguageService = inject(ShellLanguageService); + private readonly languageDialog = + viewChild.required>('languageDialog'); protected readonly content = this.shellLanguageService.content; - protected readonly currentLanguageCode = this.shellLanguageService.languageCode; - protected readonly isLanguageConfirmed = this.shellLanguageService.isLanguageConfirmed; + protected readonly currentLanguageCode = this.languagePreferenceService.languageCode; protected readonly isLanguageSelectorOpen = signal(false); protected readonly selectedLanguage = signal('en'); protected readonly languageOptions: readonly LanguageOption[] = [ @@ -41,26 +43,22 @@ export class AppComponent implements OnInit, OnDestroy { this.getLanguageLabel(this.currentLanguageCode(), this.content()), ); - constructor() { - effect((): void => { - const overflowValue = this.isLanguageSelectorOpen() ? 'hidden' : ''; - this.document.body.style.overflow = overflowValue; - this.document.documentElement.style.overflow = overflowValue; - }); - } + private readonly syncDialog = afterRenderEffect((): void => { + const dialog = this.languageDialog().nativeElement; + if (this.isLanguageSelectorOpen() && !dialog.open) { + dialog.showModal(); + } else if (!this.isLanguageSelectorOpen() && dialog.open) { + dialog.close(); + } + }); ngOnInit(): void { - const initialLanguage = this.shellLanguageService.initializeFromStorage(); - this.selectedLanguage.set(initialLanguage); + this.selectedLanguage.set(this.languagePreferenceService.initialize()); } protected chooseLanguage(language: LanguageCode): void { this.selectedLanguage.set(language); - this.confirmLanguage(); - } - - protected confirmLanguage(): void { - this.shellLanguageService.confirmLanguage(this.selectedLanguage()); + this.languagePreferenceService.confirmLanguage(language); this.isLanguageSelectorOpen.set(false); } @@ -69,6 +67,7 @@ export class AppComponent implements OnInit, OnDestroy { this.isLanguageSelectorOpen.set(true); } + /** Also runs when the dialog closes natively, e.g. via Escape. */ protected closeLanguageSelector(): void { this.selectedLanguage.set(this.currentLanguageCode()); this.isLanguageSelectorOpen.set(false); @@ -83,9 +82,4 @@ export class AppComponent implements OnInit, OnDestroy { protected getLanguageLabel(language: LanguageCode, content = this.dialogContent()): string { return language === 'de' ? content.app.languageGerman : content.app.languageEnglish; } - - ngOnDestroy(): void { - this.document.body.style.overflow = ''; - this.document.documentElement.style.overflow = ''; - } }