feat(a11y): use native dialog for language selection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9ede5c02c1
commit
20aaf4faa7
3 files changed
+46
-42
No files matched your search
+11
-11
@@ -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>
|
||||
</div>
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@if (isLanguageSelectorOpen()) {
|
||||
<div
|
||||
class="language-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="language-title"
|
||||
(click)="closeLanguageSelectorOnBackdrop($event)"
|
||||
>
|
||||
<dialog
|
||||
#languageDialog
|
||||
class="language-overlay"
|
||||
aria-labelledby="language-title"
|
||||
(close)="closeLanguageSelector()"
|
||||
(click)="closeLanguageSelectorOnBackdrop($event)"
|
||||
>
|
||||
@if (isLanguageSelectorOpen()) {
|
||||
<section class="language-card panel">
|
||||
<div class="language-card-header">
|
||||
<div class="language-copy">
|
||||
@@ -57,5 +57,5 @@
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</dialog>
|
||||
@@ -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<HTMLDialogElement>('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);
|
||||
});
|
||||
});
|
||||
+20
-26
@@ -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<ElementRef<HTMLDialogElement>>('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<LanguageCode>('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 = '';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user