Phase 9h: A11y-Pass
Globaler :focus-visible-Outline (var(--color-primary), 2px) — Tailwind 4 strippt die Browser-Defaults, ohne Fokus-Ring sind Tastatur-Nutzer blind. .sr-only-Utility (Standard-Rezept) und .skip-link in app.css. prefers- reduced-motion: schaltet alle Transitions/Animationen auf 0.01ms. Layout: Skip-Link "Zum Inhalt springen" → #main, main bekommt tabindex="-1" und id, html-lang wird via $effect reaktiv mit i18n.current synchronisiert (Initial-SSR rendert "de", Browser zieht nach). Header: nav-aria-label aus i18n (common.main_nav), Locale-Switcher-Label aus common.language_switcher. ToastStack: role=region + aria-live=polite, einzelne Toasts role=alert (error) bzw. status (success/warning), Schließen- Button-Label i18n-konform. Hover-only Delete-Buttons (Decks-Liste, Deck-Detail-Karten) bekommen focus-visible:opacity-100 — bisher waren sie für Tastatur-Nutzer unsichtbar. opacity-0 statt hidden, damit Tab-Order intakt bleibt. svelte-check 379 files 0 errors, prod-Build sauber. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c25c1d0dc4
commit
fd86d968a4
8 changed files with 87 additions and 7 deletions
|
|
@ -26,6 +26,58 @@
|
|||
body {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Sichtbarer Fokus-Ring für Tastatur-Nutzer. Tailwind 4 strippt
|
||||
die Browser-Defaults; wir setzen einen expliziten Outline.
|
||||
Nur :focus-visible, damit Maus-Klicks visuell sauber bleiben. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Screen-Reader-only Utility. Wird im Study-View genutzt, um die
|
||||
Prompt-/Answer-Regionen unsichtbar zu betiteln. */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* Skip-Link: versteckt bis Fokus, dann sprung zur Main-Region. */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-fg);
|
||||
z-index: 50;
|
||||
transform: translateY(-200%);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduce-Motion-Respekt: Animationen + Transitions ausschalten,
|
||||
wenn der User das im OS so eingestellt hat. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<span>{t('app.name')}</span>
|
||||
</a>
|
||||
|
||||
<nav class="flex items-center gap-6 text-sm" aria-label={t('app.name')}>
|
||||
<nav class="flex items-center gap-6 text-sm" aria-label={t('common.main_nav')}>
|
||||
<a
|
||||
href="/decks"
|
||||
class="hover:text-[var(--color-primary)]"
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
<div
|
||||
class="flex overflow-hidden rounded border border-[var(--color-border)] text-xs"
|
||||
role="group"
|
||||
aria-label="Sprache / Language"
|
||||
aria-label={t('common.language_switcher')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,21 +1,30 @@
|
|||
<script lang="ts">
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
const closeLabel = $derived(i18n.current === 'en' ? 'Close' : 'Schließen');
|
||||
</script>
|
||||
|
||||
{#if toasts.items.length > 0}
|
||||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
<div
|
||||
class="fixed bottom-4 right-4 z-50 flex flex-col gap-2"
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
aria-label={t('common.notifications')}
|
||||
>
|
||||
{#each toasts.items as t (t.id)}
|
||||
<div
|
||||
class="flex items-start gap-3 rounded-lg px-4 py-3 shadow-lg max-w-sm border bg-[var(--color-card)] border-[var(--color-border)]"
|
||||
class:text-[var(--color-success)]={t.kind === 'success'}
|
||||
class:text-[var(--color-warning)]={t.kind === 'warning'}
|
||||
class:text-[var(--color-danger)]={t.kind === 'error'}
|
||||
role={t.kind === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
<span class="flex-1 text-sm">{t.message}</span>
|
||||
<button
|
||||
class="text-[var(--color-muted)] hover:text-[var(--color-fg)] text-lg leading-none"
|
||||
onclick={() => toasts.dismiss(t.id)}
|
||||
aria-label="Schließen"
|
||||
aria-label={closeLabel}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -212,5 +212,9 @@ export const de: TranslationNode = {
|
|||
},
|
||||
common: {
|
||||
empty: '(leer)',
|
||||
skip_to_content: 'Zum Inhalt springen',
|
||||
main_nav: 'Hauptnavigation',
|
||||
notifications: 'Benachrichtigungen',
|
||||
language_switcher: 'Sprache wechseln',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -209,5 +209,9 @@ export const en: TranslationNode = {
|
|||
},
|
||||
common: {
|
||||
empty: '(empty)',
|
||||
skip_to_content: 'Skip to content',
|
||||
main_nav: 'Main navigation',
|
||||
notifications: 'Notifications',
|
||||
language_switcher: 'Switch language',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,13 +2,24 @@
|
|||
import '../app.css';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import ToastStack from '$lib/components/ToastStack.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Hält das `lang`-Attribut am <html> aktuell. Initial-SSR rendert
|
||||
// "de"; sobald i18n-Detection im Browser läuft, ziehen wir nach.
|
||||
$effect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('lang', i18n.current);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<a href="#main" class="skip-link">{t('common.skip_to_content')}</a>
|
||||
|
||||
<Header />
|
||||
|
||||
<main class="mx-auto max-w-6xl px-4 py-8">
|
||||
<main id="main" class="mx-auto max-w-6xl px-4 py-8" tabindex="-1">
|
||||
{@render children?.()}
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@
|
|||
</div>
|
||||
</a>
|
||||
<button
|
||||
class="absolute right-2 top-2 hidden rounded p-1 text-[var(--color-muted)] hover:bg-[var(--color-border)] hover:text-[var(--color-danger)] group-hover:block"
|
||||
class="absolute right-2 top-2 rounded p-1 text-[var(--color-muted)] opacity-0 hover:bg-[var(--color-border)] hover:text-[var(--color-danger)] focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onclick={() => onDelete(deck.id, deck.name)}
|
||||
aria-label={t('decks.delete_confirm', { name: deck.name })}
|
||||
title={t('decks.delete_confirm', { name: deck.name })}
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@
|
|||
</p>
|
||||
</a>
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 text-sm text-[var(--color-muted)] hover:text-[var(--color-danger)]"
|
||||
class="text-sm text-[var(--color-muted)] opacity-0 hover:text-[var(--color-danger)] focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onclick={() => onDeleteCard(card.id)}
|
||||
aria-label={t('deck_detail.card_delete_aria')}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue