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:
1 parent
29c9b2efaf
commit
8167a18857
6 files changed
+216
-10
No files matched your search
@@ -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();
|
||||
|
||||
@@ -10,7 +10,7 @@ import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
|
||||
import { TONE_COLOR } from '../card/config';
|
||||
import { ApiError, apiErrorText } from '../community/api';
|
||||
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 type { CommunityLogo } from './logos';
|
||||
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. */
|
||||
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. */
|
||||
const PREPARE_MESSAGES = [
|
||||
@@ -177,7 +179,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
||||
name: 'file',
|
||||
id: 'logo-upload-file',
|
||||
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',
|
||||
});
|
||||
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))),
|
||||
);
|
||||
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 cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
|
||||
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();
|
||||
}
|
||||
|
||||
/** Kein Feld gilt mehr als ungültig (der Fehlertext, auf den sie verwiesen, ist weg oder wird neu gesetzt). */
|
||||
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. */
|
||||
@@ -320,7 +323,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
||||
preparing = false;
|
||||
setPrepared(null);
|
||||
errorEl.textContent = '';
|
||||
fileInput.removeAttribute('aria-invalid');
|
||||
clearInvalid();
|
||||
if (!file) return;
|
||||
|
||||
const check = validateLogoFile(file);
|
||||
@@ -359,7 +362,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
||||
});
|
||||
if (problem) {
|
||||
errorEl.textContent = problem.message;
|
||||
inputs[problem.field].setAttribute('aria-invalid', 'true');
|
||||
setFieldInvalid(inputs[problem.field], ERROR_ID, true);
|
||||
inputs[problem.field].focus();
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user