From dfe4462e101bc7521583bd1d0cbcf951df01090d Mon Sep 17 00:00:00 2001
From: Michael
Date: Fri, 6 Mar 2026 20:23:27 +0100
Subject: [PATCH] UI/UX, Design und Accessibility Optimierung
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Farbkontrast: text-muted #64748b → #475569 (WCAG 2.2 AA konform)
- Touch Targets: Kalender-Tage 44px → 48px, Checkbox 20px → 24px
- Formulare: py-3 → py-3.5, aria-required, aria-describedby für Fehler
- Fehler: role="alert" + bg-danger/5 Highlight auf allen Fehlermeldungen
- Modal: role="dialog", aria-modal, Focus Trap, Escape-Taste, Safe Area
- ProgressBar: aria-valuenow/min/max, grössere Labels (10px → 12px)
- Kalender: gap-2, bessere ARIA Labels mit Status, grössere Legende
- Skip Navigation: "Zum Hauptinhalt springen" Link in Layout
- prefers-reduced-motion: alle Animationen deaktiviert
- Placeholder-Kontrast verbessert
- Footer: grössere Schrift, Hover-Underline auf Links
- Header: Focus Ring auf Zurück-Button, aria-hidden auf SVGs
- Error-Seiten: Gemeindeamt-Telefonnummer als Kontakt
- Loading States: "Daten werden geladen..." Text
- CAPTCHA: "Sicherheitsüberprüfung" Label
- Wassermenge: "1 m³ = 1.000 Liter" Hilfetext
Co-Authored-By: Claude Opus 4.6
---
src/app/globals.css | 32 ++++++++++++--
src/app/layout.tsx | 6 +++
src/app/page.tsx | 6 +--
src/app/pool/page.tsx | 66 ++++++++++++++++------------
src/app/storno/page.tsx | 16 ++++---
src/app/wasserzaehler/page.tsx | 16 ++++---
src/components/BookingCalendar.tsx | 22 ++++++----
src/components/ConfirmationModal.tsx | 49 ++++++++++++++++++---
src/components/Footer.tsx | 8 ++--
src/components/Header.tsx | 4 +-
src/components/ProgressBar.tsx | 11 ++++-
11 files changed, 168 insertions(+), 68 deletions(-)
diff --git a/src/app/globals.css b/src/app/globals.css
index cd1910b..b9e72b9 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -12,7 +12,7 @@
--color-bg: #f8fafc;
--color-bg-card: #ffffff;
--color-text: #1e293b;
- --color-text-muted: #64748b;
+ --color-text-muted: #475569;
--color-border: #e2e8f0;
}
@@ -75,9 +75,9 @@ body {
/* ── Calendar day styles ── */
.cal-day {
- @apply w-11 h-11 rounded-xl flex items-center justify-center text-sm cursor-pointer transition-all;
- min-width: 44px;
- min-height: 44px;
+ @apply w-12 h-12 rounded-xl flex items-center justify-center text-sm cursor-pointer transition-all;
+ min-width: 48px;
+ min-height: 48px;
}
.cal-day:hover:not(.cal-disabled):not(.cal-full) {
@apply bg-accent/15 text-accent;
@@ -115,3 +115,27 @@ input:focus, select:focus, textarea:focus {
z-index: 10;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
+
+/* ── Placeholder contrast ── */
+input::placeholder,
+textarea::placeholder {
+ color: var(--color-text-muted);
+ opacity: 0.7;
+}
+
+/* ── Reduced motion ── */
+@media (prefers-reduced-motion: reduce) {
+ .animate-checkmark-circle,
+ .animate-checkmark-draw,
+ .animate-slide-up,
+ .animate-fade-in {
+ animation: none !important;
+ opacity: 1;
+ }
+ .skeleton {
+ animation: none !important;
+ }
+ .cal-day:active:not(.cal-disabled):not(.cal-full) {
+ transform: none !important;
+ }
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 12f2fe1..a7822c4 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -14,6 +14,12 @@ export default function RootLayout({
return (
+
+ Zum Hauptinhalt springen
+
{children}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 65df66f..2712b92 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -24,7 +24,7 @@ export default function Home() {
-
+
{/* Seasonal Banner */}
{isSaison && (
@@ -56,7 +56,7 @@ export default function Home() {
Wunschtermin wählen, in 2 Min. erledigt
-
+
@@ -76,7 +76,7 @@ export default function Home() {
QR-Code scannen, Zählerstand eingeben
-
+
diff --git a/src/app/pool/page.tsx b/src/app/pool/page.tsx
index 2edb22b..4030ef9 100644
--- a/src/app/pool/page.tsx
+++ b/src/app/pool/page.tsx
@@ -192,7 +192,7 @@ export default function PoolBuchungPage() {
/>
-
+
{/* Progress Bar */}
@@ -247,8 +247,8 @@ export default function PoolBuchungPage() {
{/* Wassermenge bei Ortswasserleitung */}
{wasserquelle === 'ortswasserleitung' && (
-
- Geschätzte Wassermenge (m³) *
+
+ Geschätzte Wassermenge (m³) *
{ setWassermenge(e.target.value); setError(''); }}
min="5"
step="0.5"
- className="w-full border border-border rounded-xl px-4 py-3 text-base max-w-[200px]"
+ required
+ aria-required="true"
+ aria-describedby="wassermenge_help"
+ className="w-full border border-border rounded-xl px-4 py-3.5 text-base max-w-[200px]"
placeholder="z.B. 25"
/>
-
- Die Kapazität ist pro Tag begrenzt. Anhand Ihrer Menge wird die Verfügbarkeit berechnet.
+
+ 1 m³ = 1.000 Liter. Die Kapazität ist pro Tag begrenzt.
)}
{error && (
-
+
{error}
)}
@@ -329,7 +332,7 @@ export default function PoolBuchungPage() {
)}
{error && (
-
+
{error}
)}
@@ -398,8 +401,8 @@ export default function PoolBuchungPage() {
{/* Name */}
-
- Name *
+
+ Name *
- {fieldErrors.name && {fieldErrors.name}
}
+ {fieldErrors.name && {fieldErrors.name}
}
{/* Adresse */}
-
+
Straße & Hausnummer
{/* Telefon */}
-
- Telefon *
+
+ Telefon *
- {fieldErrors.telefon && {fieldErrors.telefon}
}
+ {fieldErrors.telefon && {fieldErrors.telefon}
}
{/* Email */}
-
- E-Mail *
+
+ E-Mail *
- {fieldErrors.email && {fieldErrors.email}
}
+ {fieldErrors.email && {fieldErrors.email}
}
{/* Erinnerung */}
@@ -481,22 +490,25 @@ export default function PoolBuchungPage() {
type="checkbox"
checked={erinnerung}
onChange={(e) => setErinnerung(e.target.checked)}
- className="mt-0.5 w-5 h-5 rounded border-border text-accent accent-accent flex-shrink-0"
+ className="mt-0.5 w-6 h-6 rounded border-2 border-border text-accent accent-accent flex-shrink-0 focus:ring-2 focus:ring-accent focus:ring-offset-2"
/>
-
+
Ich möchte nächstes Jahr per E-Mail an die Pool-Befüllung erinnert werden.
{/* CAPTCHA */}
-
-
+
+
Sicherheitsüberprüfung
+
{/* Error */}
{error && (
-
+
{error}
)}
diff --git a/src/app/storno/page.tsx b/src/app/storno/page.tsx
index 169efdf..1542dd7 100644
--- a/src/app/storno/page.tsx
+++ b/src/app/storno/page.tsx
@@ -117,9 +117,10 @@ function StornoContent() {
// Loading
if (loading) {
return (
-
+
+ Daten werden geladen...
@@ -139,14 +140,17 @@ function StornoContent() {
Ungültiger Link
-
- Dieser Storno-Link ist ungültig oder abgelaufen. Bitte kontaktieren Sie das Gemeindeamt.
+
+ Dieser Storno-Link ist ungültig oder abgelaufen.
+
+ Gemeindeamt anrufen: +43 7243 50600
+
@@ -190,7 +194,7 @@ function StornoContent() {
return (
-
+
Buchung stornieren
@@ -225,7 +229,7 @@ function StornoContent() {
{error && (
-
+
{error}
)}
diff --git a/src/app/wasserzaehler/page.tsx b/src/app/wasserzaehler/page.tsx
index 18edd82..6449e1e 100644
--- a/src/app/wasserzaehler/page.tsx
+++ b/src/app/wasserzaehler/page.tsx
@@ -111,9 +111,10 @@ function WasserzaehlerContent() {
if (loading) {
return (
-
+
+ Daten werden geladen...
@@ -133,16 +134,19 @@ function WasserzaehlerContent() {
Ungültiger Zugang
-
+
{!token
? 'Bitte nutzen Sie den QR-Code auf Ihrem Ableseblatt um diese Seite aufzurufen.'
- : 'Der verwendete Link ist ungültig oder abgelaufen. Bitte kontaktieren Sie das Gemeindeamt.'}
+ : 'Der verwendete Link ist ungültig oder abgelaufen.'}
+
+ Gemeindeamt anrufen: +43 7243 50600
+
@@ -153,7 +157,7 @@ function WasserzaehlerContent() {
return (
-
+
Wasserzähler-Ablesung
@@ -221,7 +225,7 @@ function WasserzaehlerContent() {
{/* Error */}
{error && (
-
+
{error}
)}
diff --git a/src/components/BookingCalendar.tsx b/src/components/BookingCalendar.tsx
index 7fb30a2..8e4279f 100644
--- a/src/components/BookingCalendar.tsx
+++ b/src/components/BookingCalendar.tsx
@@ -162,9 +162,9 @@ export default function BookingCalendar({ onDateSelect, selectedDate, requestedM
{/* Weekday headers */}
-
+
{WEEKDAYS.map((d) => (
-
+
{d}
))}
@@ -172,15 +172,15 @@ export default function BookingCalendar({ onDateSelect, selectedDate, requestedM
{/* Days grid */}
{loading ? (
-
+
{Array.from({ length: 35 }).map((_, i) => (
-
+
))}
) : (
-
+
{days.map((day, i) => {
- if (!day) return
;
+ if (!day) return
;
const status = getDayStatus(day);
const dateStr = formatDate(day);
@@ -188,9 +188,13 @@ export default function BookingCalendar({ onDateSelect, selectedDate, requestedM
const usedM3 = auslastungM3[dateStr] || 0;
const freeM3 = maxM3PerDay - usedM3;
+ const statusText = status === 'disabled' ? 'nicht verfügbar'
+ : status === 'full' ? 'keine Kapazität'
+ : status === 'partial' ? 'begrenzt verfügbar'
+ : 'verfügbar';
const ariaLabel = isBrunnen
- ? `${day.getDate()}. ${MONTH_NAMES[viewMonth]}, verfügbar`
- : `${day.getDate()}. ${MONTH_NAMES[viewMonth]}, ${status === 'full' ? 'nicht genug Kapazität' : freeM3 + ' m³ frei'}`;
+ ? `${day.getDate()}. ${MONTH_NAMES[viewMonth]} ${viewYear}, ${statusText}`
+ : `${day.getDate()}. ${MONTH_NAMES[viewMonth]} ${viewYear}, ${statusText}${status !== 'disabled' && status !== 'full' ? ', ' + freeM3 + ' m³ frei' : ''}`;
return (
+
Frei
diff --git a/src/components/ConfirmationModal.tsx b/src/components/ConfirmationModal.tsx
index 9345664..a822500 100644
--- a/src/components/ConfirmationModal.tsx
+++ b/src/components/ConfirmationModal.tsx
@@ -1,6 +1,6 @@
'use client';
-import { ReactNode } from 'react';
+import { ReactNode, useRef, useEffect } from 'react';
interface ConfirmationModalProps {
title: string;
@@ -22,9 +22,47 @@ function generateCalendarUrl(title: string, dateStr: string): string {
}
export default function ConfirmationModal({ title, message, onClose, details, calendarEvent, children }: ConfirmationModalProps) {
+ const modalRef = useRef
(null);
+
+ useEffect(() => {
+ const modal = modalRef.current;
+ if (!modal) return;
+
+ const focusable = modal.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ );
+ if (focusable.length > 0) focusable[0].focus();
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') { onClose(); return; }
+ if (e.key === 'Tab' && focusable.length > 0) {
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (e.shiftKey && document.activeElement === first) {
+ last.focus(); e.preventDefault();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ first.focus(); e.preventDefault();
+ }
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
return (
-
-
+
e.target === e.currentTarget && onClose()}
+ >
+
{/* Animated Checkmark */}
@@ -39,7 +77,7 @@ export default function ConfirmationModal({ title, message, onClose, details, ca
-
{title}
+
{title}
{message}
{/* Detail Card */}
@@ -62,7 +100,8 @@ export default function ConfirmationModal({ title, message, onClose, details, ca
href={generateCalendarUrl(calendarEvent.title, calendarEvent.date)}
target="_blank"
rel="noopener noreferrer"
- className="flex items-center justify-center gap-2 w-full py-3 mb-3 border border-border rounded-xl text-sm font-medium text-primary hover:bg-bg transition-colors"
+ aria-label="In Google Kalender speichern (öffnet neues Fenster)"
+ className="flex items-center justify-center gap-2 w-full py-3 mb-3 border border-border rounded-xl text-sm font-medium text-primary hover:bg-bg transition-colors focus:ring-2 focus:ring-accent focus:ring-offset-2"
>
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index d052eb6..4e3c137 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -2,13 +2,13 @@ export default function Footer() {
return (
-
+
Gemeindeamt Weißkirchen an der Traun
-
- +43 7243 50600
+
+ +43 7243 50600
{' | '}
-
+
gemeinde@weisskirchen.ooe.gv.at
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 5b27f81..c3a4351 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -12,10 +12,10 @@ export default function Header({ back }: HeaderProps) {
-
+
{back.label}
diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx
index 6ec9f5b..7b9b473 100644
--- a/src/components/ProgressBar.tsx
+++ b/src/components/ProgressBar.tsx
@@ -12,7 +12,14 @@ interface ProgressBarProps {
export default function ProgressBar({ steps }: ProgressBarProps) {
return (
-
+
s.done).length + (steps.some(s => s.active) ? 1 : 0)}
+ aria-valuemin={0}
+ aria-valuemax={steps.length}
+ >
{steps.map((step, i) => (
{/* Step indicator */}
@@ -34,7 +41,7 @@ export default function ProgressBar({ steps }: ProgressBarProps) {
i + 1
)}
-
{step.label}