fix(a11y): Kontroll-UI - Touch-Targets, Labels, Live-Regions, Dialog-Semantik
CI / backend-tests (push) Failing after 2m12s
CI / frontend-build (push) Successful in 22s

44px-Mindestgröße für Buttons/Inputs (Feldeinsatz mit Handschuhen), echte
Label-Verknüpfung statt reinem placeholder auf allen Kontroll-Eingabefeldern,
aria-live-Statusansagen beim Speichern/Fehler einer Position und beim Öffnen
des Nachfüll-Dialogs, NachfuellDialog jetzt mit role="dialog"/aria-modal,
Fokus beim Öffnen und Esc zum Schließen, Pflichtfeld Begründung mit
aria-required.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-07 23:30:01 +02:00
co-authored by Claude Sonnet 5
parent 7750d49d40
commit c608f06806
4 changed files with 100 additions and 17 deletions
+5
View File
@@ -111,6 +111,10 @@ export function KontrollPage() {
onErneutVersuchen={fehlerErneutVersuchen}
/>
{nachfuellDialog?.materialId === zustand.position.material_id && (
<>
<span role="status" aria-live="polite" className="sr-only">
Fehlmenge bei {zustand.material?.name ?? "Material"} Dialog geöffnet.
</span>
<NachfuellDialog
materialName={zustand.material?.name ?? "Material"}
vorschlagMenge={String(
@@ -122,6 +126,7 @@ export function KontrollPage() {
}
onSchliessen={nachfuellDialogSchliessen}
/>
</>
)}
</li>
))}
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useAuth } from "../../auth/AuthContext";
@@ -24,14 +24,38 @@ export function NachfuellDialog({
const [menge, setMenge] = useState(vorschlagMenge);
const [mindermengeOffen, setMindermengeOffen] = useState(false);
const [begruendung, setBegruendung] = useState("");
const mengeRef = useRef<HTMLInputElement>(null);
const titelId = "nachfuell-dialog-titel";
// Fokus beim Öffnen in den Dialog, Esc schließt ihn - ohne das erwartet ein
// Tastatur-/Screenreader-Nutzer keinen neuen interaktiven Bereich.
useEffect(() => {
mengeRef.current?.focus();
function taste(e: KeyboardEvent) {
if (e.key === "Escape") onSchliessen();
}
document.addEventListener("keydown", taste);
return () => document.removeEventListener("keydown", taste);
}, [onSchliessen]);
return (
<div className="card" style={{ borderColor: "var(--color-warning)", marginTop: "0.6rem" }}>
<div className="card-subtitle">
<div
className="card"
style={{ borderColor: "var(--color-warning)", marginTop: "0.6rem" }}
role="dialog"
aria-modal="true"
aria-labelledby={titelId}
>
<div className="card-subtitle" id={titelId}>
Fehlmenge bei {materialName} jetzt aus Lager nachfüllen?
</div>
<div className="row" style={{ marginTop: "0.5rem" }}>
<label htmlFor="nachfuell-menge" className="sr-only">
Nachfüllmenge für {materialName}
</label>
<input
ref={mengeRef}
id="nachfuell-menge"
type="number"
className="input"
value={menge}
@@ -52,12 +76,18 @@ export function NachfuellDialog({
</div>
{mindermengeOffen && (
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
<label htmlFor="nachfuell-begruendung" className="sr-only">
Begründung für Mindermenge (Pflicht)
</label>
<input
id="nachfuell-begruendung"
className="input"
style={{ flex: 1 }}
placeholder="Begründung (Pflicht)"
value={begruendung}
onChange={(e) => setBegruendung(e.target.value)}
required
aria-required="true"
/>
<button
className="btn btn-primary"
+33 -2
View File
@@ -38,22 +38,41 @@ function rahmenFarbe(status: PositionZustand["status"], passt: boolean): string
return "transparent";
}
const STATUS_ANSAGE: Record<PositionZustand["status"], string> = {
unbestaetigt: "",
wartet: "",
wird_uebertragen: "",
gespeichert: "gespeichert",
fehler: "Fehler beim Speichern",
};
export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVersuchen }: Props) {
const materialtyp = zustand.material?.materialtyp;
const passt = zustand.eingabe === zustand.position.sollmenge_effektiv;
const [nurMonat, setNurMonat] = useState(false);
const materialName = zustand.material?.name ?? `Material ${zustand.position.material_id}`;
const feldId = `position-${zustand.position.material_id}`;
return (
<div className="card" style={{ borderLeft: `4px solid ${rahmenFarbe(zustand.status, passt)}` }}>
<div className="card-title-row">
<strong>{zustand.material?.name ?? `Material ${zustand.position.material_id}`}</strong>
<strong>{materialName}</strong>
<StatusBadge status={zustand.status} />
</div>
{/* Ansage der Statusänderung für Screenreader - Rahmenfarbe/Badge allein
wird nicht bemerkt, wenn die Karte nicht fokussiert ist. */}
<span role="status" aria-live="polite" className="sr-only">
{STATUS_ANSAGE[zustand.status] && `${materialName}: ${STATUS_ANSAGE[zustand.status]}`}
</span>
<div className="card-subtitle">
Soll: {zustand.position.sollmenge_effektiv} {zustand.material?.einheit ?? ""}
</div>
<div className="row" style={{ marginTop: "0.6rem" }}>
<label htmlFor={`${feldId}-menge`} className="sr-only">
Menge für {materialName}
</label>
<input
id={`${feldId}-menge`}
type="number"
className="input"
value={zustand.eingabe}
@@ -71,7 +90,11 @@ export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVers
{materialtyp === "ablauf_charge" && (
<div style={{ marginTop: "0.5rem" }}>
<div className="row">
<label htmlFor={`${feldId}-ablauf`} className="sr-only">
Ablaufdatum für {materialName}
</label>
<input
id={`${feldId}-ablauf`}
type={nurMonat ? "month" : "date"}
className="input"
value={nurMonat ? zustand.ablaufdatum.slice(0, 7) : zustand.ablaufdatum}
@@ -84,7 +107,11 @@ export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVers
}
style={{ borderColor: ablaufFarbe(zustand.ablaufdatum), color: ablaufFarbe(zustand.ablaufdatum) }}
/>
<label htmlFor={`${feldId}-charge`} className="sr-only">
Chargennummer für {materialName}
</label>
<input
id={`${feldId}-charge`}
className="input"
placeholder="Chargennummer"
value={zustand.chargennummer}
@@ -99,7 +126,11 @@ export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVers
)}
{materialtyp === "geraet_sn" && (
<div className="row" style={{ marginTop: "0.5rem" }}>
<label htmlFor={`${feldId}-sn`} className="sr-only">
Seriennummer für {materialName}
</label>
<input
id={`${feldId}-sn`}
className="input"
placeholder="Seriennummer"
value={zustand.seriennummer}
@@ -108,7 +139,7 @@ export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVers
</div>
)}
{zustand.status === "fehler" && (
<div style={{ marginTop: "0.6rem" }}>
<div style={{ marginTop: "0.6rem" }} role="alert">
<p className="text-muted" style={{ color: "var(--color-danger)" }}>
{zustand.fehlerText}
</p>
+18 -1
View File
@@ -359,12 +359,13 @@ a {
font-weight: 600;
}
.input,
.input:not([type="checkbox"]):not([type="radio"]),
select.input,
textarea.input {
width: 100%;
font-size: 0.98rem;
padding: 0.6rem 0.75rem;
min-height: 44px;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
@@ -395,6 +396,9 @@ select.input:focus {
font-size: 0.95rem;
font-weight: 600;
padding: 0.6rem 1.05rem;
/* Mindestens 44x44px Touch-Ziel (WCAG 2.5.5) - im Feld/Lager oft mit
Handschuhen bedient. */
min-height: 44px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
cursor: pointer;
@@ -593,6 +597,19 @@ select.input:focus {
color: var(--color-text-muted);
}
/* Nur für Screenreader sichtbar - Label-Text ohne visuelle Verschiebung. */
.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;
}
.text-success {
color: var(--color-success);
}