diff --git a/web/src/community/publish-dialog.test.ts b/web/src/community/publish-dialog.test.ts index 0b1e46b..5594e89 100644 --- a/web/src/community/publish-dialog.test.ts +++ b/web/src/community/publish-dialog.test.ts @@ -31,6 +31,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('[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 () => { 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(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)); }; @@ -215,6 +219,55 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => { 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', () => { const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true }; expect(validatePublishForm(ok)).toBeNull(); diff --git a/web/src/community/publish-dialog.ts b/web/src/community/publish-dialog.ts index fcf958b..10a2324 100644 --- a/web/src/community/publish-dialog.ts +++ b/web/src/community/publish-dialog.ts @@ -5,7 +5,7 @@ import './community.css'; import { rememberDisplayName, rememberedDisplayName } from '../account/display-name'; import { errorMessage } from '../account/errors'; 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 { msg, t } from '../i18n/i18n'; import { ApiError, apiErrorText, isAlreadyPublished } from './api'; @@ -24,6 +24,8 @@ const ISSUER_MAX = 40; export const DISPLAY_NAME_MAX = 30; const TAGS_MAX = 5; 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): * 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 { + 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'); + }); +}); diff --git a/web/src/editor/dom.ts b/web/src/editor/dom.ts index d7a774f..d32c162 100644 --- a/web/src/editor/dom.ts +++ b/web/src/editor/dom.ts @@ -17,6 +17,23 @@ export function el( 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. const ICONS = { imagePlus: diff --git a/web/src/logos/logo-dialog.test.ts b/web/src/logos/logo-dialog.test.ts index ac704c6..f5fa718 100644 --- a/web/src/logos/logo-dialog.test.ts +++ b/web/src/logos/logo-dialog.test.ts @@ -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('[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('.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(); diff --git a/web/src/logos/logo-dialog.ts b/web/src/logos/logo-dialog.ts index da69cfc..d96855e 100644 --- a/web/src/logos/logo-dialog.ts +++ b/web/src/logos/logo-dialog.ts @@ -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