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

+53
View File
@@ -31,6 +31,9 @@ const form = () => dialogEl()!.querySelector('form')!;
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent; const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.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 settle = async () => { const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve(); for (let i = 0; i < 30; i++) await Promise.resolve();
}; };
@@ -123,6 +126,7 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
expect(submit).not.toHaveBeenCalled(); expect(submit).not.toHaveBeenCalled();
expect(dialogEl()?.open).toBe(true); expect(dialogEl()?.open).toBe(true);
expect(input(field).getAttribute('aria-invalid')).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)); expect(document.activeElement).toBe(input(field));
}; };
@@ -215,6 +219,55 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
expect(errorText()).toBe(''); expect(errorText()).toBe('');
}); });
describe('Fehlerzeile und Felder (Barrierefreiheit)', () => {
it('die Fehlerzeile hat eine ID, die im Dialog einmalig ist', () => {
void open();
expect(errorLine().id).toBe('publish-error');
expect(dialogEl()!.querySelectorAll(`#${errorLine().id}`)).toHaveLength(1);
});
it('ohne Fehler verweist kein Feld auf die Fehlerzeile; die Hinweise bleiben', () => {
void open();
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
expect(describedBy('displayName')).toEqual(['publish-displayName-hint']);
expect(describedBy('title')).toEqual([]);
expect(describedBy('consent')).toEqual([]);
});
it('ein ungültiges Feld verweist mit aria-describedby auf die Fehlerzeile, hinter seinem Hinweis, wenn es einen hat', () => {
void open();
fill({ title: '' });
send();
expect(describedBy('title')).toEqual(['publish-error']);
fill({ tags: 'a,b,c,d,e,f' });
send();
expect(describedBy('tags')).toEqual(['publish-tags-hint', 'publish-error']);
expect(describedBy('title')).toEqual([]); // nur das Feld mit dem Problem
fill({ displayName: 'x'.repeat(31) });
send();
expect(describedBy('displayName')).toEqual(['publish-displayName-hint', 'publish-error']);
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
fill({ consent: false });
send();
expect(describedBy('consent')).toEqual(['publish-error']);
});
it('ist das Feld behoben, fällt die Verknüpfung beim nächsten Senden weg und der Hinweis bleibt', () => {
submit.mockResolvedValue({ id: 'g1' });
void open();
fill({ tags: 'a,b,c,d,e,f' });
send();
expect(describedBy('tags')).toEqual(['publish-tags-hint', 'publish-error']);
fill({ tags: 'a,b' });
send();
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
expect(input('tags').hasAttribute('aria-invalid')).toBe(false);
});
});
it('validatePublishForm meldet das erste Problem in Formularreihenfolge, sonst null', () => { it('validatePublishForm meldet das erste Problem in Formularreihenfolge, sonst null', () => {
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true }; const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true };
expect(validatePublishForm(ok)).toBeNull(); expect(validatePublishForm(ok)).toBeNull();
+6 -4
View File
@@ -5,7 +5,7 @@ import './community.css';
import { rememberDisplayName, rememberedDisplayName } from '../account/display-name'; import { rememberDisplayName, rememberedDisplayName } from '../account/display-name';
import { errorMessage } from '../account/errors'; import { errorMessage } from '../account/errors';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login'; import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { el } from '../editor/dom'; import { el, setFieldInvalid } from '../editor/dom';
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload'; import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
import { msg, t } from '../i18n/i18n'; import { msg, t } from '../i18n/i18n';
import { ApiError, apiErrorText, isAlreadyPublished } from './api'; import { ApiError, apiErrorText, isAlreadyPublished } from './api';
@@ -24,6 +24,8 @@ const ISSUER_MAX = 40;
export const DISPLAY_NAME_MAX = 30; export const DISPLAY_NAME_MAX = 30;
const TAGS_MAX = 5; const TAGS_MAX = 5;
const TAG_MAX = 20; const TAG_MAX = 20;
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
const ERROR_ID = 'publish-error';
/** /**
* Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF): * Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
* unsichtbar oder umordnend. Dasselbe Muster prüft der Server (deploy/api/app.js, CONTROL_CHARACTERS). * unsichtbar oder umordnend. Dasselbe Muster prüft der Server (deploy/api/app.js, CONTROL_CHARACTERS).
@@ -160,7 +162,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
field(t('Anzeigename (optional)'), nameInput, t('Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.')), field(t('Anzeigename (optional)'), nameInput, t('Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.')),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(CONSENT_TEXT))), el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(CONSENT_TEXT))),
); );
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' }); const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
const statusEl = el('p', { class: 'auth-msg', role: 'status' }); const statusEl = el('p', { class: 'auth-msg', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen')); const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Veröffentlichen')); const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Veröffentlichen'));
@@ -205,7 +207,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
} }
function clearInvalid(): void { function clearInvalid(): void {
for (const control of Object.values(inputs)) control.removeAttribute('aria-invalid'); for (const control of Object.values(inputs)) setFieldInvalid(control, ERROR_ID, false);
} }
function showAlready(): void { function showAlready(): void {
@@ -233,7 +235,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
const problem = validatePublishForm(values); const problem = validatePublishForm(values);
if (problem) { if (problem) {
errorEl.textContent = problem.message; errorEl.textContent = problem.message;
inputs[problem.field].setAttribute('aria-invalid', 'true'); setFieldInvalid(inputs[problem.field], ERROR_ID, true);
inputs[problem.field].focus(); inputs[problem.field].focus();
return; return;
} }
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { el, setFieldInvalid } from './dom';
describe('setFieldInvalid', () => {
const field = (describedBy?: string) => el('input', { 'aria-describedby': describedBy });
it('markiert das Feld und verweist auf die Fehlerzeile', () => {
const input = field();
setFieldInvalid(input, 'fehler', true);
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(input.getAttribute('aria-describedby')).toBe('fehler');
});
it('der Hinweis des Feldes bleibt davor stehen', () => {
const input = field('hinweis');
setFieldInvalid(input, 'fehler', true);
expect(input.getAttribute('aria-describedby')).toBe('hinweis fehler');
});
it('mehrfach markiert verweist es nur einmal auf die Fehlerzeile', () => {
const input = field('a b');
setFieldInvalid(input, 'fehler', true);
setFieldInvalid(input, 'fehler', true);
expect(input.getAttribute('aria-describedby')).toBe('a b fehler');
});
it('zurücknehmen entfernt Markierung und Verweis, die Hinweise bleiben', () => {
const input = field('hinweis');
setFieldInvalid(input, 'fehler', true);
setFieldInvalid(input, 'fehler', false);
expect(input.hasAttribute('aria-invalid')).toBe(false);
expect(input.getAttribute('aria-describedby')).toBe('hinweis');
});
it('ohne Hinweis verschwindet aria-describedby ganz', () => {
const input = field();
setFieldInvalid(input, 'fehler', true);
setFieldInvalid(input, 'fehler', false);
expect(input.hasAttribute('aria-describedby')).toBe(false);
expect(input.hasAttribute('aria-invalid')).toBe(false);
});
it('ein Feld, das nie markiert war, bleibt beim Zurücknehmen unberührt', () => {
const plain = field();
setFieldInvalid(plain, 'fehler', false);
expect(plain.getAttributeNames()).toEqual([]);
const withHint = field('hinweis');
setFieldInvalid(withHint, 'fehler', false);
expect(withHint.getAttribute('aria-describedby')).toBe('hinweis');
});
it('eine andere Fehlerzeile bleibt, wo sie ist (nur die genannte ID wird verwaltet)', () => {
const input = field('hinweis anderer');
setFieldInvalid(input, 'fehler', true);
setFieldInvalid(input, 'fehler', false);
expect(input.getAttribute('aria-describedby')).toBe('hinweis anderer');
});
});
+17
View File
@@ -17,6 +17,23 @@ export function el<K extends keyof HTMLElementTagNameMap>(
return node; return node;
} }
/**
* Markiert ein Formularfeld als ungültig (`aria-invalid`) und verknüpft es mit der Fehlerzeile `errorId` (`aria-describedby`, hinter einem
* Hinweis, den das Feld schon hat): ein Screenreader liest den Fehler dann mit dem Feld vor. Mit `invalid = false` fallen beide wieder
* weg, der Hinweis bleibt.
*/
export function setFieldInvalid(control: HTMLElement, errorId: string, invalid: boolean): void {
const described = (control.getAttribute('aria-describedby') ?? '').split(/\s+/).filter((id) => id !== '' && id !== errorId);
if (invalid) {
control.setAttribute('aria-invalid', 'true');
described.push(errorId);
} else {
control.removeAttribute('aria-invalid');
}
if (described.length > 0) control.setAttribute('aria-describedby', described.join(' '));
else control.removeAttribute('aria-describedby');
}
// Feste Symbole (Strichstärke aus dem Stylesheet); die Zeichenketten sind Konstanten, keine Nutzereingaben. // Feste Symbole (Strichstärke aus dem Stylesheet); die Zeichenketten sind Konstanten, keine Nutzereingaben.
const ICONS = { const ICONS = {
imagePlus: imagePlus:
+73
View File
@@ -70,6 +70,9 @@ const form = () => dialogEl()!.querySelector('form')!;
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent; const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.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 previews = () => [...dialogEl()!.querySelectorAll<HTMLElement>('.logo-preview')];
const settle = async () => { const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve(); 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(upload).not.toHaveBeenCalled();
expect(dialogEl()?.open).toBe(true); expect(dialogEl()?.open).toBe(true);
expect(input(field).getAttribute('aria-invalid')).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)); 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', () => { describe('Logo-Dialog: Senden', () => {
it('sendet getrimmte Eingaben genau einmal, merkt den Anzeigenamen und löst mit dem Logo auf', async () => { it('sendet getrimmte Eingaben genau einmal, merkt den Anzeigenamen und löst mit dem Logo auf', async () => {
const p = prepared(); const p = prepared();
+9 -6
View File
@@ -10,7 +10,7 @@ import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
import { TONE_COLOR } from '../card/config'; import { TONE_COLOR } from '../card/config';
import { ApiError, apiErrorText } from '../community/api'; import { ApiError, apiErrorText } from '../community/api';
import { CONTROL_CHARACTERS, DISPLAY_NAME_MAX } from '../community/publish-dialog'; import { CONTROL_CHARACTERS, DISPLAY_NAME_MAX } from '../community/publish-dialog';
import { el } from '../editor/dom'; import { el, setFieldInvalid } from '../editor/dom';
import { msg, t } from '../i18n/i18n'; import { msg, t } from '../i18n/i18n';
import type { CommunityLogo } from './logos'; import type { CommunityLogo } from './logos';
import { import {
@@ -34,6 +34,8 @@ export const LOGO_CONSENT_TEXT = msg(
/** Grenze wie der Dienst (deploy/api/logos.js): Länge in UTF-16-Einheiten, nach dem Trimmen. Der Anzeigename: wie beim Veröffentlichen. */ /** Grenze wie der Dienst (deploy/api/logos.js): Länge in UTF-16-Einheiten, nach dem Trimmen. Der Anzeigename: wie beim Veröffentlichen. */
const NAME_MAX = 40; const NAME_MAX = 40;
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
const ERROR_ID = 'logo-upload-error';
/** Die Meldungen von `prepareLogo` und `validateLogoFile`: Sie kommen mit fertig übersetztem Text im Fehler an. */ /** Die Meldungen von `prepareLogo` und `validateLogoFile`: Sie kommen mit fertig übersetztem Text im Fehler an. */
const PREPARE_MESSAGES = [ const PREPARE_MESSAGES = [
@@ -177,7 +179,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
name: 'file', name: 'file',
id: 'logo-upload-file', id: 'logo-upload-file',
accept: LOGO_ACCEPT, accept: LOGO_ACCEPT,
'aria-required': 'true', required: true, // nativ: aria-required gilt für ein Datei-Feld nicht (das Formular prüft selbst, siehe novalidate)
'aria-describedby': 'logo-upload-file-hint', 'aria-describedby': 'logo-upload-file-hint',
}); });
const monoInput = el('input', { const monoInput = el('input', {
@@ -227,7 +229,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
), ),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(LOGO_CONSENT_TEXT))), el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(LOGO_CONSENT_TEXT))),
); );
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' }); const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
const statusEl = el('p', { class: 'auth-msg', role: 'status' }); const statusEl = el('p', { class: 'auth-msg', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen')); const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Hochladen')); const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Hochladen'));
@@ -303,8 +305,9 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
refresh(); refresh();
} }
/** Kein Feld gilt mehr als ungültig (der Fehlertext, auf den sie verwiesen, ist weg oder wird neu gesetzt). */
function clearInvalid(): void { function clearInvalid(): void {
for (const control of Object.values(inputs)) control.removeAttribute('aria-invalid'); for (const control of Object.values(inputs)) setFieldInvalid(control, ERROR_ID, false);
} }
/** Den Namen aus dem Dateinamen vorbelegen, solange der Nutzer keinen eigenen eingetippt hat. */ /** Den Namen aus dem Dateinamen vorbelegen, solange der Nutzer keinen eigenen eingetippt hat. */
@@ -320,7 +323,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
preparing = false; preparing = false;
setPrepared(null); setPrepared(null);
errorEl.textContent = ''; errorEl.textContent = '';
fileInput.removeAttribute('aria-invalid'); clearInvalid();
if (!file) return; if (!file) return;
const check = validateLogoFile(file); const check = validateLogoFile(file);
@@ -359,7 +362,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
}); });
if (problem) { if (problem) {
errorEl.textContent = problem.message; errorEl.textContent = problem.message;
inputs[problem.field].setAttribute('aria-invalid', 'true'); setFieldInvalid(inputs[problem.field], ERROR_ID, true);
inputs[problem.field].focus(); inputs[problem.field].focus();
return; return;
} }