feat(a11y): use native dialog for language selection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fraujulianandClaude Opus 5.5 committed 2026-10-15 16:23:41 +02:00
1 parent 9ede5c02c1
commit 20aaf4faa7
3 files changed
+40 -36

No files matched your search

+6 -6
View File
@@ -1,4 +1,4 @@
<div class="app-content" [attr.aria-hidden]="isLanguageSelectorOpen() ? 'true' : null" [attr.inert]="isLanguageSelectorOpen() ? '' : null"> <div class="app-content">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</div> </div>
@@ -16,14 +16,14 @@
</span> </span>
</button> </button>
@if (isLanguageSelectorOpen()) { <dialog
<div #languageDialog
class="language-overlay" class="language-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="language-title" aria-labelledby="language-title"
(close)="closeLanguageSelector()"
(click)="closeLanguageSelectorOnBackdrop($event)" (click)="closeLanguageSelectorOnBackdrop($event)"
> >
@if (isLanguageSelectorOpen()) {
<section class="language-card panel"> <section class="language-card panel">
<div class="language-card-header"> <div class="language-card-header">
<div class="language-copy"> <div class="language-copy">
@@ -57,5 +57,5 @@
} }
</div> </div>
</section> </section>
</div>
} }
</dialog>
+15 -5
View File
@@ -27,7 +27,7 @@ describe('AppComponent', (): void => {
it('should keep the language selector closed on first visit', (): void => { it('should keep the language selector closed on first visit', (): void => {
const el = fixture.nativeElement as HTMLElement; const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('.language-overlay')).toBeNull(); expect(el.querySelector<HTMLDialogElement>('dialog.language-overlay')?.open).toBe(false);
}); });
it('should preselect English by default', (): void => { it('should preselect English by default', (): void => {
@@ -51,7 +51,6 @@ describe('AppComponent', (): void => {
const comp = component as unknown as { const comp = component as unknown as {
chooseLanguage(language: 'en' | 'de'): void; chooseLanguage(language: 'en' | 'de'): void;
currentLanguageCode(): string; currentLanguageCode(): string;
isLanguageConfirmed(): boolean;
isLanguageSelectorOpen(): boolean; isLanguageSelectorOpen(): boolean;
}; };
@@ -59,7 +58,6 @@ describe('AppComponent', (): void => {
fixture.detectChanges(); fixture.detectChanges();
expect(comp.currentLanguageCode()).toBe('de'); expect(comp.currentLanguageCode()).toBe('de');
expect(comp.isLanguageConfirmed()).toBe(true);
expect(comp.isLanguageSelectorOpen()).toBe(false); expect(comp.isLanguageSelectorOpen()).toBe(false);
expect(localStorage.getItem('portfolio-language')).toBe('de'); expect(localStorage.getItem('portfolio-language')).toBe('de');
}); });
@@ -71,12 +69,10 @@ describe('AppComponent', (): void => {
restoredFixture.detectChanges(); restoredFixture.detectChanges();
const restoredComponent = restoredFixture.componentInstance as unknown as { const restoredComponent = restoredFixture.componentInstance as unknown as {
currentLanguageCode(): string; currentLanguageCode(): string;
isLanguageConfirmed(): boolean;
isLanguageSelectorOpen(): boolean; isLanguageSelectorOpen(): boolean;
}; };
expect(restoredComponent.currentLanguageCode()).toBe('de'); expect(restoredComponent.currentLanguageCode()).toBe('de');
expect(restoredComponent.isLanguageConfirmed()).toBe(true);
expect(restoredComponent.isLanguageSelectorOpen()).toBe(false); expect(restoredComponent.isLanguageSelectorOpen()).toBe(false);
}); });
@@ -93,4 +89,18 @@ describe('AppComponent', (): void => {
expect(comp.isLanguageSelectorOpen()).toBe(true); 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);
});
}); });
+19 -25
View File
@@ -1,17 +1,18 @@
import { DOCUMENT } from '@angular/common';
import { import {
afterRenderEffect,
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed, computed,
effect,
inject, inject,
signal, signal,
viewChild,
} from '@angular/core'; } from '@angular/core';
import type { OnDestroy, OnInit } from '@angular/core'; import type { ElementRef, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import type { LanguageCode } from '../languages/language.types'; import type { LanguageCode } from '../languages/language.types';
import type { LanguageOption } from './app.types'; import type { LanguageOption } from './app.types';
import { LanguagePreferenceService } from './services/language-preference.service';
import { ShellLanguageService } from './services/shell-language.service'; import { ShellLanguageService } from './services/shell-language.service';
@Component({ @Component({
@@ -21,13 +22,14 @@ import { ShellLanguageService } from './services/shell-language.service';
templateUrl: './app.component.html', templateUrl: './app.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AppComponent implements OnInit, OnDestroy { export class AppComponent implements OnInit {
private readonly document = inject(DOCUMENT); private readonly languagePreferenceService = inject(LanguagePreferenceService);
private readonly shellLanguageService = inject(ShellLanguageService); private readonly shellLanguageService = inject(ShellLanguageService);
private readonly languageDialog =
viewChild.required<ElementRef<HTMLDialogElement>>('languageDialog');
protected readonly content = this.shellLanguageService.content; protected readonly content = this.shellLanguageService.content;
protected readonly currentLanguageCode = this.shellLanguageService.languageCode; protected readonly currentLanguageCode = this.languagePreferenceService.languageCode;
protected readonly isLanguageConfirmed = this.shellLanguageService.isLanguageConfirmed;
protected readonly isLanguageSelectorOpen = signal(false); protected readonly isLanguageSelectorOpen = signal(false);
protected readonly selectedLanguage = signal<LanguageCode>('en'); protected readonly selectedLanguage = signal<LanguageCode>('en');
protected readonly languageOptions: readonly LanguageOption[] = [ protected readonly languageOptions: readonly LanguageOption[] = [
@@ -41,26 +43,22 @@ export class AppComponent implements OnInit, OnDestroy {
this.getLanguageLabel(this.currentLanguageCode(), this.content()), this.getLanguageLabel(this.currentLanguageCode(), this.content()),
); );
constructor() { private readonly syncDialog = afterRenderEffect((): void => {
effect((): void => { const dialog = this.languageDialog().nativeElement;
const overflowValue = this.isLanguageSelectorOpen() ? 'hidden' : ''; if (this.isLanguageSelectorOpen() && !dialog.open) {
this.document.body.style.overflow = overflowValue; dialog.showModal();
this.document.documentElement.style.overflow = overflowValue; } else if (!this.isLanguageSelectorOpen() && dialog.open) {
}); dialog.close();
} }
});
ngOnInit(): void { ngOnInit(): void {
const initialLanguage = this.shellLanguageService.initializeFromStorage(); this.selectedLanguage.set(this.languagePreferenceService.initialize());
this.selectedLanguage.set(initialLanguage);
} }
protected chooseLanguage(language: LanguageCode): void { protected chooseLanguage(language: LanguageCode): void {
this.selectedLanguage.set(language); this.selectedLanguage.set(language);
this.confirmLanguage(); this.languagePreferenceService.confirmLanguage(language);
}
protected confirmLanguage(): void {
this.shellLanguageService.confirmLanguage(this.selectedLanguage());
this.isLanguageSelectorOpen.set(false); this.isLanguageSelectorOpen.set(false);
} }
@@ -69,6 +67,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.isLanguageSelectorOpen.set(true); this.isLanguageSelectorOpen.set(true);
} }
/** Also runs when the dialog closes natively, e.g. via Escape. */
protected closeLanguageSelector(): void { protected closeLanguageSelector(): void {
this.selectedLanguage.set(this.currentLanguageCode()); this.selectedLanguage.set(this.currentLanguageCode());
this.isLanguageSelectorOpen.set(false); this.isLanguageSelectorOpen.set(false);
@@ -83,9 +82,4 @@ export class AppComponent implements OnInit, OnDestroy {
protected getLanguageLabel(language: LanguageCode, content = this.dialogContent()): string { protected getLanguageLabel(language: LanguageCode, content = this.dialogContent()): string {
return language === 'de' ? content.app.languageGerman : content.app.languageEnglish; return language === 'de' ? content.app.languageGerman : content.app.languageEnglish;
} }
ngOnDestroy(): void {
this.document.body.style.overflow = '';
this.document.documentElement.style.overflow = '';
}
} }