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}
|
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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user