Barrierefreiheit (BFSG/WCAG 2.1 AA): Skip-Link, aria-live, Labels, Fokus-Sichtbarkeit, Überschriften-Hierarchie
Technisches Accessibility-Audit ergab sechs behebbare Punkte, alle jetzt gefixt: - Skip-to-Content-Link ganz oben auf jeder Seite (WCAG 2.4.1) - Fehler-/Statusmeldungen (Checkout, Login, Rezension, Gate-/Verwaltungs-Code) jetzt mit role="alert"/aria-live, damit Screenreader sie automatisch ansagen (WCAG 4.1.3) - Warenkorb-Icon hatte fälschlich das "Konto"-Label geerbt, sagt jetzt korrekt "Warenkorb" (WCAG 4.1.2/2.4.4), dafür t.nav.cart in allen 4 Sprachen neu ergänzt - Sichtbarer Fokus-Ring beim Mengenfeld auf der Produktseite wiederhergestellt (WCAG 2.4.7) - Placeholder-only Formularfelder (Gate-Zugangscode, Verwaltungs-Suche/Filter/Tracking) haben jetzt echte Labels (sichtbar per sr-only-Klasse oder aria-label) statt nur Platzhaltertext (WCAG 1.3.1/3.3.2) - Übersprungene Überschriften-Ebenen (h1→h3 ohne h2) in Checkout, Warenkorb, Kontakt, Konto korrigiert (WCAG 1.3.1) Reine Accessibility-Nachrüstung, keine funktionale Änderung. Kleinstunternehmer-Ausnahme (§3 Abs.3 BFSG) greift hier vermutlich, aber vorsorglich sauber umgesetzt. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b70bad3e41
commit
b944bcf724
+2
-1
@@ -7,7 +7,7 @@ import type { Locale } from "./config";
|
||||
|
||||
export const ui = {
|
||||
de: {
|
||||
nav: { shop: "Shop", sale: "Sale", creative: "Über die Kreative", faq: "FAQ", contact: "Kontakt", account: "Mein Konto", login: "Anmelden" },
|
||||
nav: { shop: "Shop", sale: "Sale", creative: "Über die Kreative", faq: "FAQ", contact: "Kontakt", account: "Mein Konto", login: "Anmelden", cart: "Warenkorb" },
|
||||
accountMenu: { myAccount: "Mein Konto", logout: "Abmelden" },
|
||||
topbar: "Versandkostenfrei ab 75 € (DE) / 85 € (AT, LU) / 100 € (CH)",
|
||||
footer: {
|
||||
@@ -30,6 +30,7 @@ export const ui = {
|
||||
quantity: "Anzahl", photoSoon: "Produktfoto folgt", portraitSoon: "Porträtfoto von VanVan folgt",
|
||||
prevPhoto: "Vorheriges Foto", nextPhoto: "Nächstes Foto",
|
||||
handmadeBadge: "🩵 Handgemacht",
|
||||
skipToContent: "Zum Inhalt springen",
|
||||
},
|
||||
product: {
|
||||
articleNo: "Artikelnummer", material: "Material", size: "Größe", colors: "Farben",
|
||||
|
||||
@@ -66,6 +66,13 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
|
||||
<ClientRouter fallback="swap" />
|
||||
</head>
|
||||
<body>
|
||||
{/* Skip-Link (WCAG 2.1 SC 2.4.1 "Bypass Blocks"): für Maus-/Touch-Nutzer:innen unsichtbar,
|
||||
erscheint aber als allererstes fokussierbares Element sichtbar oben links, sobald man mit
|
||||
Tab durch die Seite geht — erspart Tastatur-/Screenreader-Nutzer:innen das Durchtabben durch
|
||||
Topbar, komplette Kopfnavigation, Sprachumschalter, Konto-Menü und zweite Navigationsleiste,
|
||||
bevor der eigentliche Seiteninhalt (<main id="main-content">) erreichbar ist. */}
|
||||
<a href="#main-content" class="skip-link">{t.common.skipToContent}</a>
|
||||
|
||||
<div class="topbar">🩵 {t.topbar}</div>
|
||||
|
||||
<header class="site-header">
|
||||
@@ -115,7 +122,7 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href={p("/warenkorb/")} class="icon-link cart-link" aria-label={t.nav.account}>
|
||||
<a href={p("/warenkorb/")} class="icon-link cart-link" aria-label={t.nav.cart}>
|
||||
🧺 <span class="cart-count" id="cart-count">0</span>
|
||||
</a>
|
||||
<button class="hamburger" id="hamburger" aria-label="Menü" aria-expanded="false">☰</button>
|
||||
@@ -141,7 +148,7 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<main id="main-content" tabindex="-1">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
@@ -595,6 +602,27 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
|
||||
body { font-family: var(--font-body); }
|
||||
h1, h2, h3, h4 { font-family: var(--font-head-override, var(--font-head)); }
|
||||
|
||||
/* Skip-Link: liegt normalerweise außerhalb des sichtbaren Bereichs (kein Layout-Sprung für
|
||||
Maus-Nutzer:innen), springt aber beim Fokussieren (Tab-Taste) sofort gut sichtbar oben links
|
||||
ins Bild — Standardmuster für WCAG 2.4.1. */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -3rem;
|
||||
left: 0.6rem;
|
||||
z-index: 100;
|
||||
background: var(--c-accent);
|
||||
color: #08181c;
|
||||
font-weight: 700;
|
||||
padding: 0.7rem 1.1rem;
|
||||
border-radius: 0 0 10px 10px;
|
||||
transition: top 0.15s var(--ease);
|
||||
}
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
outline: 2px solid var(--c-text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: linear-gradient(90deg, var(--c-purple), var(--c-purple-deep));
|
||||
color: var(--c-text);
|
||||
|
||||
@@ -19,7 +19,7 @@ const t = useTranslations(lang);
|
||||
<form class="frm card" id="checkout-form">
|
||||
<div class="checkout-split">
|
||||
<div class="checkout-split-col">
|
||||
<h3>{t.checkout.step2}</h3>
|
||||
<h2>{t.checkout.step2}</h2>
|
||||
{/* Nur noch zwei Zahlungsarten (auf ausdrücklichen Wunsch, siehe Recherche zu Rechts-/
|
||||
Kostenlage): Klarna raus (würde eine eigene Händlerprüfung + Bonitätsprüfungs-
|
||||
Pflichten voraussetzen, siehe EU-Verbraucherkreditrichtlinie) und keine eigene
|
||||
@@ -58,7 +58,7 @@ const t = useTranslations(lang);
|
||||
<div class="pay-panel-inline">
|
||||
<p>{t.checkout.payPalHint}</p>
|
||||
<div id="paypal-button-container"></div>
|
||||
<p class="pay-connect-status" id="paypal-error" style="display:none; color: var(--c-sale);"></p>
|
||||
<p class="pay-connect-status" id="paypal-error" role="alert" aria-live="assertive" style="display:none; color: var(--c-sale);"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pay-card" style="--brand-color:#7fa8c9; --brand-bg:#3b5a76;">
|
||||
@@ -79,7 +79,7 @@ const t = useTranslations(lang);
|
||||
</div>
|
||||
<div class="checkout-split-divider" aria-hidden="true"><span>🧵</span></div>
|
||||
<div class="checkout-split-col">
|
||||
<h3>{t.checkout.step1}</h3>
|
||||
<h2>{t.checkout.step1}</h2>
|
||||
<div><label for="email">{t.checkout.email}</label><input id="email" type="email" required /></div>
|
||||
<div class="grid grid-2">
|
||||
<div><label for="vorname">{t.checkout.firstName}</label><input id="vorname" type="text" required /></div>
|
||||
@@ -101,7 +101,7 @@ const t = useTranslations(lang);
|
||||
</div>
|
||||
|
||||
<hr class="divider" />
|
||||
<h3>{t.checkout.step3}</h3>
|
||||
<h2>{t.checkout.step3}</h2>
|
||||
<div id="checkout-summary" class="checkout-summary"></div>
|
||||
<p class="small">{t.common.vatNote}</p>
|
||||
|
||||
@@ -131,7 +131,7 @@ const t = useTranslations(lang);
|
||||
<input type="text" id="coupon-input" placeholder={t.cart.couponPlaceholder} />
|
||||
<button type="button" id="coupon-apply" class="btn btn-outline btn-sm">{t.cart.couponApply}</button>
|
||||
</div>
|
||||
<p class="small" id="coupon-status"></p>
|
||||
<p class="small" id="coupon-status" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<hr class="divider stitched" />
|
||||
<div class="coupon-row">
|
||||
@@ -140,7 +140,7 @@ const t = useTranslations(lang);
|
||||
<input type="text" id="partner-code-input" placeholder={t.cart.partnerCodePlaceholder} />
|
||||
<button type="button" id="partner-code-apply" class="btn btn-outline btn-sm">{t.cart.partnerCodeApply}</button>
|
||||
</div>
|
||||
<p class="small" id="partner-code-status"></p>
|
||||
<p class="small" id="partner-code-status" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<hr class="divider stitched" />
|
||||
<h3>🚚 {t.checkout.shippingHintTitle}</h3>
|
||||
@@ -403,6 +403,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
const submitBtn = document.getElementById("order-submit-btn");
|
||||
const orderErrorEl = document.createElement("p");
|
||||
orderErrorEl.className = "small";
|
||||
orderErrorEl.setAttribute("role", "alert");
|
||||
orderErrorEl.setAttribute("aria-live", "assertive");
|
||||
orderErrorEl.style.color = "var(--c-sale)";
|
||||
orderErrorEl.style.display = "none";
|
||||
submitBtn.insertAdjacentElement("beforebegin", orderErrorEl);
|
||||
|
||||
@@ -44,10 +44,10 @@ const t = useTranslations(lang);
|
||||
</form>
|
||||
|
||||
<div class="card contact-highlight">
|
||||
<h3><span aria-hidden="true">💌</span> {t.contact.directTitle}</h3>
|
||||
<h2><span aria-hidden="true">💌</span> {t.contact.directTitle}</h2>
|
||||
<p class="small">E-Mail: <a href="mailto:[email protected]">[email protected]</a> <span class="small">{t.contact.emailPlaceholderNote}</span></p>
|
||||
<p class="small">{t.contact.orderNote}</p>
|
||||
<h3><span aria-hidden="true">⏱️</span> {t.contact.responseTitle}</h3>
|
||||
<h2><span aria-hidden="true">⏱️</span> {t.contact.responseTitle}</h2>
|
||||
<p class="small">{t.contact.responseNote}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ const t = useTranslations(lang);
|
||||
<section class="section-tight">
|
||||
<div class="container grid grid-2 account-grid">
|
||||
<form class="frm card" id="login-form">
|
||||
<h3>{t.account.loginTitle}</h3>
|
||||
<h2>{t.account.loginTitle}</h2>
|
||||
|
||||
{/* Echtes Google-/PayPal-Login (OAuth 2.0 + PKCE, server-seitig geprüft — siehe
|
||||
functions/api/auth/ + functions/_shared/oauth.js). Ganz normale Links, kein JS
|
||||
@@ -36,7 +36,7 @@ const t = useTranslations(lang);
|
||||
</span>
|
||||
{t.account.socialPaypal}
|
||||
</a>
|
||||
<p class="small social-note" id="social-note" style="display:none;"></p>
|
||||
<p class="small social-note" id="social-note" role="alert" aria-live="assertive" style="display:none;"></p>
|
||||
</div>
|
||||
|
||||
<div class="social-divider">{t.account.orDivider}</div>
|
||||
@@ -66,14 +66,14 @@ const t = useTranslations(lang);
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-block" type="submit">{t.account.loginButton}</button>
|
||||
<p class="small" id="login-error" style="display:none; color: var(--c-sale);">{t.account.loginError}</p>
|
||||
<p class="small" id="login-error" role="alert" aria-live="assertive" style="display:none; color: var(--c-sale);">{t.account.loginError}</p>
|
||||
<a class="small" href="#">{t.account.forgotPassword}</a>
|
||||
|
||||
<p class="small login-consent">{t.account.consentPrefix} <a href="/datenschutz/">{t.account.consentLink}</a>{t.account.consentSuffix}</p>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<h3>{t.account.newTitle}</h3>
|
||||
<h2>{t.account.newTitle}</h2>
|
||||
<p class="small">{t.account.newText}</p>
|
||||
<a class="btn btn-outline btn-block" href="/shop/">{t.account.guestButton}</a>
|
||||
</div>
|
||||
|
||||
@@ -177,7 +177,7 @@ const felder: [string, string | undefined][] = [
|
||||
<textarea id="review-text" rows="3"></textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">{t.product.submitReview}</button>
|
||||
<p class="small" id="review-error" style="display:none; color: var(--c-sale);">{t.product.reviewNeedsRating}</p>
|
||||
<p class="small" id="review-error" role="alert" aria-live="assertive" style="display:none; color: var(--c-sale);">{t.product.reviewNeedsRating}</p>
|
||||
<p class="small" id="review-confirm" role="status" style="display:none; color: var(--c-accent);">{t.product.reviewSaved}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -248,7 +248,9 @@ const lang: Locale = "de";
|
||||
</div>
|
||||
|
||||
<div class="verwaltung-toolbar">
|
||||
<label class="sr-only" for="verwaltung-search">Suche nach Bestellnummer, Name oder E-Mail</label>
|
||||
<input type="search" id="verwaltung-search" class="verwaltung-input" placeholder="🔍 Suche nach Bestellnummer, Name oder E-Mail …" />
|
||||
<label class="sr-only" for="verwaltung-zeitraum">Zeitraum filtern</label>
|
||||
<select id="verwaltung-zeitraum" class="verwaltung-input verwaltung-select">
|
||||
<option value="alle">Alle Zeiträume</option>
|
||||
<option value="heute">Heute</option>
|
||||
@@ -256,6 +258,7 @@ const lang: Locale = "de";
|
||||
<option value="monat">Dieser Monat</option>
|
||||
<option value="30tage">Letzte 30 Tage</option>
|
||||
</select>
|
||||
<label class="sr-only" for="verwaltung-sort">Sortierung</label>
|
||||
<select id="verwaltung-sort" class="verwaltung-input verwaltung-select">
|
||||
<option value="neu">Neueste zuerst</option>
|
||||
<option value="alt">Älteste zuerst</option>
|
||||
@@ -531,7 +534,7 @@ const lang: Locale = "de";
|
||||
<div class="order-card-foot verwaltung-order-foot">
|
||||
<span class="order-total">Summe: <strong>${formatPrice(Number(b.summe), lang)}</strong></span>
|
||||
<div class="verwaltung-tracking-edit">
|
||||
<input type="text" class="verwaltung-input verwaltung-tracking-input" data-order-id="${b.id}" placeholder="Tracking-Nummer" value="${escapeHtml(b.tracking || "")}" />
|
||||
<input type="text" class="verwaltung-input verwaltung-tracking-input" data-order-id="${b.id}" placeholder="Tracking-Nummer" aria-label="Tracking-Nummer für Bestellung ${escapeHtml(b.bestellnummer || `#${b.id}`)}" value="${escapeHtml(b.tracking || "")}" />
|
||||
<button type="button" class="btn btn-outline btn-xs verwaltung-tracking-save" data-order-id="${b.id}">💾</button>
|
||||
</div>
|
||||
<div class="verwaltung-status-edit">
|
||||
|
||||
@@ -34,7 +34,7 @@ const t = useTranslations(lang);
|
||||
<div id="cart-content" class="cart-layout" style="display:none;">
|
||||
<div class="card cart-items" id="cart-items"></div>
|
||||
<aside class="card cart-summary">
|
||||
<h3>🧾 {t.cart.subtotal}</h3>
|
||||
<h2>🧾 {t.cart.subtotal}</h2>
|
||||
<div id="summary-items" class="checkout-summary"></div>
|
||||
<div class="summary-row"><span>{t.cart.subtotal}</span><span id="sum-subtotal">0,00 €</span></div>
|
||||
<div class="summary-row discount-row" id="discount-row" style="display:none;"><span>{t.cart.discount}</span><span id="sum-discount">-0,00 €</span></div>
|
||||
|
||||
+21
-1
@@ -42,6 +42,20 @@
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
/* Für Screenreader vorhanden, visuell aber unsichtbar — für Formularfelder, die aus Platz-/
|
||||
Layoutgründen keinen sichtbaren <label>-Text bekommen (z.B. Icon-/Placeholder-only Suchfelder
|
||||
in der Verwaltung), damit sie trotzdem einen echten zugänglichen Namen haben (WCAG 1.3.1/3.3.2)
|
||||
statt sich allein auf den (beim Tippen verschwindenden) Placeholder zu verlassen. */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--c-black);
|
||||
@@ -2043,7 +2057,13 @@ a:focus-visible, button:focus-visible {
|
||||
}
|
||||
.qty-stepper input::-webkit-outer-spin-button,
|
||||
.qty-stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.qty-stepper input:focus { outline: none; }
|
||||
/* Kein echtes outline (würde durch das runde .qty-stepper-Gehäuse eckig herausragen), aber
|
||||
deswegen NICHT ersatzlos entfernt (WCAG 2.4.7 "Focus Visible") — stattdessen ein innen
|
||||
liegender Ring per box-shadow, der sich sauber an die abgerundete Form anschmiegt. */
|
||||
.qty-stepper input:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--c-accent);
|
||||
}
|
||||
.specs { margin-top: 1.5rem; }
|
||||
.specs th { width: 40%; color: var(--c-text-muted); font-weight: 500; font-family: var(--font-body); }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user