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>
|
<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-labelledby="language-title"
|
||||||
aria-modal="true"
|
(close)="closeLanguageSelector()"
|
||||||
aria-labelledby="language-title"
|
(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>
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+20
-26
@@ -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 = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user