fix(a11y): Datei-Feld nativ „required“, ungültige Felder verweisen auf die Fehlerzeile

- logo-dialog.ts: das Datei-Feld trägt `required` statt `aria-required` (für ein Datei-Feld gilt aria-required nicht; das Formular hat novalidate und
  prüft selbst, es ändert sich nichts am Ablauf).
- logo-dialog.ts und publish-dialog.ts: die Fehlerzeile hat eine ID (logo-upload-error, publish-error); solange ein Feld ungültig ist, steht sie in
  seinem aria-describedby, hinter dem Hinweis des Feldes (Datei, Anzeigename, Tags). Ein Screenreader liest den Fehler so mit dem Feld vor.
  Beim nächsten Senden fällt der Verweis mit aria-invalid weg, der Hinweis bleibt. Im Dialog „Logo hochladen“ löscht auch eine neue Dateiwahl die
  Markierung aller Felder (sie löscht ja den Fehlertext, auf den sie verwiesen).
- editor/dom.ts: setFieldInvalid(control, errorId, invalid) für beide Dialoge; eigene Tests (dom.test.ts).
- Tests: required statt aria-required; ID einmalig; je Feld Verweis samt Hinweis in Reihenfolge, Rücknahme, neue Dateiwahl; der vorhandene Hilfsfall
  `invalid` beider Dialoge prüft den Verweis für jedes Feld mit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 05:49:04 +00:00
1 parent 29c9b2efaf
commit 8167a18857
6 files changed
+216 -10

No files matched your search

+73
View File
@@ -70,6 +70,9 @@ const form = () => dialogEl()!.querySelector('form')!;
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
const errorLine = () => dialogEl()!.querySelector<HTMLElement>('[role="alert"]')!;
/** Die IDs in `aria-describedby` eines Feldes (leer, wenn es das Attribut nicht hat). */
const describedBy = (name: string) => (input(name).getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
const previews = () => [...dialogEl()!.querySelectorAll<HTMLElement>('.logo-preview')];
const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
@@ -533,6 +536,7 @@ describe('Logo-Dialog: Prüfung vor dem Senden', () => {
expect(upload).not.toHaveBeenCalled();
expect(dialogEl()?.open).toBe(true);
expect(input(field).getAttribute('aria-invalid')).toBe('true');
expect(describedBy(field)).toContain(errorLine().id); // das ungültige Feld verweist auf die Fehlerzeile
expect(document.activeElement).toBe(input(field));
};
@@ -597,6 +601,75 @@ describe('Logo-Dialog: Prüfung vor dem Senden', () => {
});
});
describe('Logo-Dialog: Barrierefreiheit', () => {
it('das Datei-Feld ist nativ „required“: aria-required gilt für ein Datei-Feld nicht', () => {
void open();
expect(input('file').required).toBe(true);
expect(input('file').hasAttribute('aria-required')).toBe(false);
});
it('die Fehlerzeile hat eine ID, die im Dialog einmalig ist', () => {
void open();
expect(errorLine().id).toBe('logo-upload-error');
expect(dialogEl()!.querySelectorAll(`#${errorLine().id}`)).toHaveLength(1);
});
it('ohne Fehler verweist kein Feld auf die Fehlerzeile; die Hinweise bleiben', () => {
void open();
expect(describedBy('file')).toEqual(['logo-upload-file-hint']);
expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint']);
expect(describedBy('name')).toEqual([]);
expect(describedBy('consent')).toEqual([]);
});
it('ein ungültiges Feld verweist mit aria-describedby auf die Fehlerzeile, hinter seinem Hinweis, wenn es einen hat', async () => {
void open();
fill();
send(); // keine Datei
expect(describedBy('file')).toEqual(['logo-upload-file-hint', 'logo-upload-error']);
expect(describedBy('name')).toEqual([]); // nur das Feld mit dem Problem
await setup();
fill({ name: '' });
send();
expect(describedBy('name')).toEqual(['logo-upload-error']);
fill({ displayName: 'x'.repeat(31) });
send();
expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint', 'logo-upload-error']);
expect(describedBy('name')).toEqual([]);
fill({ consent: false });
send();
expect(describedBy('consent')).toEqual(['logo-upload-error']);
expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint']);
});
it('ist das Feld behoben, fällt die Verknüpfung beim nächsten Senden weg und der Hinweis bleibt', async () => {
upload.mockResolvedValue(logo());
await setup();
fill({ displayName: 'x'.repeat(31) });
send();
expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint', 'logo-upload-error']);
fill({ displayName: 'Kenso' });
send();
expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint']);
expect(input('displayName').hasAttribute('aria-invalid')).toBe(false);
});
it('eine neue Dateiwahl löscht den Fehlertext und damit auch die Markierung der Felder, die darauf verwiesen', async () => {
await setup();
fill({ name: '' });
send();
expect(describedBy('name')).toEqual(['logo-upload-error']);
choose(imageFile('Zweite.png'));
await settle();
expect(errorText()).toBe('');
expect(input('name').hasAttribute('aria-invalid')).toBe(false);
expect(describedBy('name')).toEqual([]);
});
});
describe('Logo-Dialog: Senden', () => {
it('sendet getrimmte Eingaben genau einmal, merkt den Anzeigenamen und löst mit dem Logo auf', async () => {
const p = prepared();