#168: Formulare — Fehler-Verdrahtung und Namenslücken

Der Field-Baustein verdrahtet Hinweis/Fehler jetzt per aria-describedby
und aria-invalid mit dem Eingabefeld (cloneElement auf das einzelne
Kind; Fragmente bleiben unangetastet) — Screenreader nennen den Fehler
damit auch beim Feld-Fokus. Quota-Typ-Select mit Namen; die leeren
Aktions-/Erledigt-Spaltenköpfe in API-Tokens, Feed-Tokens, Sitzungen
und der Aufgabenübersicht (NodeView UND Server-Renderpfad) tragen
visually-hidden-Beschriftungen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
This commit is contained in:
Claude Fable 5 2026-07-21 14:26:54 +02:00
parent 4ba7b50336
commit 8719b0ee1e
11 changed files with 40 additions and 10 deletions

View File

@ -171,7 +171,8 @@ export class TasksService {
} }
return ( return (
`<table class="dt-task-overview-table"><thead><tr>` + `<table class="dt-task-overview-table"><thead><tr>` +
`<th></th><th>${escapeHtml(t('colTask'))}</th><th>${escapeHtml(t('colMentions'))}</th>` + `<th><span class="visually-hidden">${escapeHtml(t('colDone'))}</span></th>` +
`<th>${escapeHtml(t('colTask'))}</th><th>${escapeHtml(t('colMentions'))}</th>` +
`<th>${escapeHtml(t('colStart'))}</th><th>${escapeHtml(t('colDue'))}</th>` + `<th>${escapeHtml(t('colStart'))}</th><th>${escapeHtml(t('colDue'))}</th>` +
`<th>${escapeHtml(t('colPage'))}</th>` + `<th>${escapeHtml(t('colPage'))}</th>` +
`</tr></thead><tbody>${rows.join('')}</tbody></table>` `</tr></thead><tbody>${rows.join('')}</tbody></table>`

View File

@ -182,7 +182,9 @@ function TokenList({ tokens }: { tokens: ApiTokenView[] }): React.JSX.Element {
<th>{t('list.lastUsed')}</th> <th>{t('list.lastUsed')}</th>
<th>{t('list.expires')}</th> <th>{t('list.expires')}</th>
<th>{t('list.status')}</th> <th>{t('list.status')}</th>
<th></th> <th>
<span className="visually-hidden">{t('common:tableActions')}</span>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@ -91,7 +91,9 @@ export function FeedTokensSection(): React.JSX.Element {
<th>{t('fields.name')}</th> <th>{t('fields.name')}</th>
<th>{t('list.created')}</th> <th>{t('list.created')}</th>
<th>{t('list.lastUsed')}</th> <th>{t('list.lastUsed')}</th>
<th></th> <th>
<span className="visually-hidden">{t('common:tableActions')}</span>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@ -1,3 +1,4 @@
import { cloneElement, isValidElement, useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ApiError } from '../lib/api'; import { ApiError } from '../lib/api';
@ -20,13 +21,28 @@ export function Field({
children: React.ReactNode; children: React.ReactNode;
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
const noteId = useId();
// Tie the hint/error text to the control itself (#168, WCAG 3.3.1):
// screen readers then repeat it when the field receives focus. Only a
// single element child can be wired; fragments render unchanged.
const wired =
isValidElement(children) && (error || hint)
? cloneElement(children as React.ReactElement<Record<string, unknown>>, {
'aria-describedby': noteId,
...(error ? { 'aria-invalid': true } : {}),
})
: children;
return ( return (
<label className="field"> <label className="field">
<span className="field__label">{label}</span> <span className="field__label">{label}</span>
{children} {wired}
{hint && !error && <span className="field__hint">{hint}</span>} {hint && !error && (
<span className="field__hint" id={noteId}>
{hint}
</span>
)}
{error && ( {error && (
<span className="field__error" role="alert"> <span className="field__error" role="alert" id={noteId}>
{t(`errors:${error}`, t('errors:bad_request'))} {t(`errors:${error}`, t('errors:bad_request'))}
</span> </span>
)} )}

View File

@ -86,7 +86,9 @@ function TaskOverviewView({ editor }: NodeViewProps): React.JSX.Element {
<table className="dt-task-overview-table"> <table className="dt-task-overview-table">
<thead> <thead>
<tr> <tr>
<th></th> <th>
<span className="visually-hidden">{t('colDone')}</span>
</th>
<th>{t('colTask')}</th> <th>{t('colTask')}</th>
<th>{t('colMentions')}</th> <th>{t('colMentions')}</th>
<th>{t('colStart')}</th> <th>{t('colStart')}</th>

View File

@ -63,6 +63,7 @@ export function QuotaManager(): React.JSX.Element {
<select <select
className="quota-manager__type" className="quota-manager__type"
value={type} value={type}
aria-label={t('overrides.typeLabel')}
onChange={(e) => setType(e.target.value as QuotaSubject)} onChange={(e) => setType(e.target.value as QuotaSubject)}
> >
<option value="user">{t('overrides.user')}</option> <option value="user">{t('overrides.user')}</option>

View File

@ -255,7 +255,9 @@ function SessionsSection(): React.JSX.Element {
<th>{t('settings:sessions.device')}</th> <th>{t('settings:sessions.device')}</th>
<th>{t('settings:sessions.created')}</th> <th>{t('settings:sessions.created')}</th>
<th>{t('settings:sessions.lastSeen')}</th> <th>{t('settings:sessions.lastSeen')}</th>
<th></th> <th>
<span className="visually-hidden">{t('common:tableActions')}</span>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@ -25,7 +25,8 @@
"usage": "Nutzung", "usage": "Nutzung",
"set": "Setzen", "set": "Setzen",
"clear": "Löschen", "clear": "Löschen",
"overQuota": "über Kontingent" "overQuota": "über Kontingent",
"typeLabel": "Geltungsbereich"
}, },
"keys": { "keys": {
"editors_per_pond": "Bearbeiter pro Teich", "editors_per_pond": "Bearbeiter pro Teich",

View File

@ -1,6 +1,7 @@
{ {
"title": "Aufgabenübersicht", "title": "Aufgabenübersicht",
"editorCard": "Aufgabenübersicht — diese Seite und ihre Unterseiten", "editorCard": "Aufgabenübersicht — diese Seite und ihre Unterseiten",
"colDone": "Erledigt",
"colTask": "Aufgabe", "colTask": "Aufgabe",
"colMentions": "Wer", "colMentions": "Wer",
"colStart": "Start", "colStart": "Start",

View File

@ -25,7 +25,8 @@
"usage": "Usage", "usage": "Usage",
"set": "Set", "set": "Set",
"clear": "Clear", "clear": "Clear",
"overQuota": "over quota" "overQuota": "over quota",
"typeLabel": "Applies to"
}, },
"keys": { "keys": {
"editors_per_pond": "Editors per pond", "editors_per_pond": "Editors per pond",

View File

@ -1,6 +1,7 @@
{ {
"title": "Task overview", "title": "Task overview",
"editorCard": "Task overview — this page and its subpages", "editorCard": "Task overview — this page and its subpages",
"colDone": "Done",
"colTask": "Task", "colTask": "Task",
"colMentions": "Who", "colMentions": "Who",
"colStart": "Start", "colStart": "Start",