fix(a11y): Kontroll-UI - Touch-Targets, Labels, Live-Regions, Dialog-Semantik
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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user