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} onErneutVersuchen={fehlerErneutVersuchen}
/> />
{nachfuellDialog?.materialId === zustand.position.material_id && ( {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 <NachfuellDialog
materialName={zustand.material?.name ?? "Material"} materialName={zustand.material?.name ?? "Material"}
vorschlagMenge={String( vorschlagMenge={String(
@@ -122,6 +126,7 @@ export function KontrollPage() {
} }
onSchliessen={nachfuellDialogSchliessen} onSchliessen={nachfuellDialogSchliessen}
/> />
</>
)} )}
</li> </li>
))} ))}
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useAuth } from "../../auth/AuthContext"; import { useAuth } from "../../auth/AuthContext";
@@ -24,14 +24,38 @@ export function NachfuellDialog({
const [menge, setMenge] = useState(vorschlagMenge); const [menge, setMenge] = useState(vorschlagMenge);
const [mindermengeOffen, setMindermengeOffen] = useState(false); const [mindermengeOffen, setMindermengeOffen] = useState(false);
const [begruendung, setBegruendung] = useState(""); 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 ( return (
<div className="card" style={{ borderColor: "var(--color-warning)", marginTop: "0.6rem" }}> <div
<div className="card-subtitle"> 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? Fehlmenge bei {materialName} jetzt aus Lager nachfüllen?
</div> </div>
<div className="row" style={{ marginTop: "0.5rem" }}> <div className="row" style={{ marginTop: "0.5rem" }}>
<label htmlFor="nachfuell-menge" className="sr-only">
Nachfüllmenge für {materialName}
</label>
<input <input
ref={mengeRef}
id="nachfuell-menge"
type="number" type="number"
className="input" className="input"
value={menge} value={menge}
@@ -52,12 +76,18 @@ export function NachfuellDialog({
</div> </div>
{mindermengeOffen && ( {mindermengeOffen && (
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}> <div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
<label htmlFor="nachfuell-begruendung" className="sr-only">
Begründung für Mindermenge (Pflicht)
</label>
<input <input
id="nachfuell-begruendung"
className="input" className="input"
style={{ flex: 1 }} style={{ flex: 1 }}
placeholder="Begründung (Pflicht)" placeholder="Begründung (Pflicht)"
value={begruendung} value={begruendung}
onChange={(e) => setBegruendung(e.target.value)} onChange={(e) => setBegruendung(e.target.value)}
required
aria-required="true"
/> />
<button <button
className="btn btn-primary" className="btn btn-primary"
+33 -2
View File
@@ -38,22 +38,41 @@ function rahmenFarbe(status: PositionZustand["status"], passt: boolean): string
return "transparent"; 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) { export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVersuchen }: Props) {
const materialtyp = zustand.material?.materialtyp; const materialtyp = zustand.material?.materialtyp;
const passt = zustand.eingabe === zustand.position.sollmenge_effektiv; const passt = zustand.eingabe === zustand.position.sollmenge_effektiv;
const [nurMonat, setNurMonat] = useState(false); const [nurMonat, setNurMonat] = useState(false);
const materialName = zustand.material?.name ?? `Material ${zustand.position.material_id}`;
const feldId = `position-${zustand.position.material_id}`;
return ( return (
<div className="card" style={{ borderLeft: `4px solid ${rahmenFarbe(zustand.status, passt)}` }}> <div className="card" style={{ borderLeft: `4px solid ${rahmenFarbe(zustand.status, passt)}` }}>
<div className="card-title-row"> <div className="card-title-row">
<strong>{zustand.material?.name ?? `Material ${zustand.position.material_id}`}</strong> <strong>{materialName}</strong>
<StatusBadge status={zustand.status} /> <StatusBadge status={zustand.status} />
</div> </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"> <div className="card-subtitle">
Soll: {zustand.position.sollmenge_effektiv} {zustand.material?.einheit ?? ""} Soll: {zustand.position.sollmenge_effektiv} {zustand.material?.einheit ?? ""}
</div> </div>
<div className="row" style={{ marginTop: "0.6rem" }}> <div className="row" style={{ marginTop: "0.6rem" }}>
<label htmlFor={`${feldId}-menge`} className="sr-only">
Menge für {materialName}
</label>
<input <input
id={`${feldId}-menge`}
type="number" type="number"
className="input" className="input"
value={zustand.eingabe} value={zustand.eingabe}
@@ -71,7 +90,11 @@ export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVers
{materialtyp === "ablauf_charge" && ( {materialtyp === "ablauf_charge" && (
<div style={{ marginTop: "0.5rem" }}> <div style={{ marginTop: "0.5rem" }}>
<div className="row"> <div className="row">
<label htmlFor={`${feldId}-ablauf`} className="sr-only">
Ablaufdatum für {materialName}
</label>
<input <input
id={`${feldId}-ablauf`}
type={nurMonat ? "month" : "date"} type={nurMonat ? "month" : "date"}
className="input" className="input"
value={nurMonat ? zustand.ablaufdatum.slice(0, 7) : zustand.ablaufdatum} 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) }} style={{ borderColor: ablaufFarbe(zustand.ablaufdatum), color: ablaufFarbe(zustand.ablaufdatum) }}
/> />
<label htmlFor={`${feldId}-charge`} className="sr-only">
Chargennummer für {materialName}
</label>
<input <input
id={`${feldId}-charge`}
className="input" className="input"
placeholder="Chargennummer" placeholder="Chargennummer"
value={zustand.chargennummer} value={zustand.chargennummer}
@@ -99,7 +126,11 @@ export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVers
)} )}
{materialtyp === "geraet_sn" && ( {materialtyp === "geraet_sn" && (
<div className="row" style={{ marginTop: "0.5rem" }}> <div className="row" style={{ marginTop: "0.5rem" }}>
<label htmlFor={`${feldId}-sn`} className="sr-only">
Seriennummer für {materialName}
</label>
<input <input
id={`${feldId}-sn`}
className="input" className="input"
placeholder="Seriennummer" placeholder="Seriennummer"
value={zustand.seriennummer} value={zustand.seriennummer}
@@ -108,7 +139,7 @@ export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVers
</div> </div>
)} )}
{zustand.status === "fehler" && ( {zustand.status === "fehler" && (
<div style={{ marginTop: "0.6rem" }}> <div style={{ marginTop: "0.6rem" }} role="alert">
<p className="text-muted" style={{ color: "var(--color-danger)" }}> <p className="text-muted" style={{ color: "var(--color-danger)" }}>
{zustand.fehlerText} {zustand.fehlerText}
</p> </p>
+18 -1
View File
@@ -359,12 +359,13 @@ a {
font-weight: 600; font-weight: 600;
} }
.input, .input:not([type="checkbox"]):not([type="radio"]),
select.input, select.input,
textarea.input { textarea.input {
width: 100%; width: 100%;
font-size: 0.98rem; font-size: 0.98rem;
padding: 0.6rem 0.75rem; padding: 0.6rem 0.75rem;
min-height: 44px;
border: 1px solid var(--color-border-strong); border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--color-surface); background: var(--color-surface);
@@ -395,6 +396,9 @@ select.input:focus {
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
padding: 0.6rem 1.05rem; 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-radius: var(--radius-sm);
border: 1px solid transparent; border: 1px solid transparent;
cursor: pointer; cursor: pointer;
@@ -593,6 +597,19 @@ select.input:focus {
color: var(--color-text-muted); 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 { .text-success {
color: var(--color-success); color: var(--color-success);
} }