import { afterEach, describe, expect, it } from 'vitest'; import { button, el } from '@/ui/dom'; import { Panel, confirmDialog } from '@/ui/feedback'; import { hilfeKnopf, schliesseHilfe } from '@/ui/help/hilfe'; /** * Befund 17 zur Fassung 5.9.0 (WCAG 2.4.3 Fokusreihenfolge). * * WAS DER ANWENDER SONST ERLEBT * * Er drueckt in einem Tabellenkopf auf "?", liest die Erklaerung und geht von * dort mit dem Knopf "Glossar" weiter (src/ui/help/hilfe.ts: `panel.close();` * gefolgt von `zeigeGlossar();`). Schliesst er das Glossar, liegt der Fokus * NIRGENDS: Der Erbauer des Glossars hat sich als ausloesendes Element den * Seitenkoerper gemerkt - das alte Overlay samt fokussiertem Knopf war in * diesem Augenblick schon entfernt -, und `gibFokusZurueck` nahm den * Seitenkoerper als gueltiges Rueckgabeziel an. `focus()` auf einem `body` * ohne `tabindex` ist ein Nichttun, das `return` dahinter uebersprang beide * Rueckfallstufen (Fenster darunter, `#inhalt`). Der naechste Tabulatordruck * beginnt danach wieder ganz oben bei der Sprungmarke. * * Zugesagt ist das Gegenteil, zu Kriterium 2.4.3: "sonst in das Fenster * darunter, sonst in den Inhaltsbereich". * * Dieselbe Stelle traegt die Rueckfrage (`confirmDialog`), deshalb der zweite * Fall. */ afterEach(() => { schliesseHilfe(); document.body.replaceChildren(); }); /** Wartet einen Mikroschritt ab - die Fokusrueckgabe laeuft in einem solchen. */ async function mikroschritt(): Promise { await new Promise((fertig) => { queueMicrotask(fertig); }); } /** Der Inhaltsbereich, wie ihn `src/ui/shell.ts` aufbaut: fokussierbar, aber nicht im Tabulatorlauf. */ function inhaltsbereich(): HTMLElement { const inhalt = el('div', { id: 'inhalt' }); inhalt.tabIndex = -1; document.body.append(inhalt); return inhalt; } /** Schaltflaeche in der Fusszeile des obersten Fensters. */ function fussknopf(beschriftung: string): HTMLButtonElement { const knopf = [ ...document.querySelectorAll('.dialog-hintergrund .dialog-aktionen button'), ].find((b) => (b.textContent ?? '').trim() === beschriftung); if (knopf === undefined) throw new Error(`Keine Schaltflaeche "${beschriftung}"`); return knopf; } describe('Fokus nach einem Fenster, das aus einem gerade geschlossenen heraus geoeffnet wurde', () => { it('faellt in den Inhaltsbereich statt auf den Seitenkoerper (Hilfe -> Glossar -> schliessen)', async () => { const inhalt = inhaltsbereich(); const frage = hilfeKnopf('konfliktbeziehung'); document.body.append(frage); frage.focus(); frage.click(); await mikroschritt(); await mikroschritt(); // Die Kette aus hilfe.ts: schliessen und im selben Takt das naechste // Fenster oeffnen. Der Erbauer des Glossars liest hier `document.body`. fussknopf('Glossar').click(); await mikroschritt(); await mikroschritt(); expect(document.activeElement, 'das Glossar hat den Fokus nicht bekommen').not.toBe( document.body, ); fussknopf('Schließen').click(); await mikroschritt(); await mikroschritt(); expect(document.activeElement, 'der Fokus liegt nach dem Glossar nirgends').toBe(inhalt); }); it('gibt den Fokus auch nach einer Rueckfrage aus einem geschlossenen Fenster heraus zurueck', async () => { const inhalt = inhaltsbereich(); const ausloeser = button({ label: 'Zwischenzeit bearbeiten', onClick: () => {} }); document.body.append(ausloeser); ausloeser.focus(); const panel = new Panel({ title: 'Zwischenzeit' }); panel.setActions(button({ label: 'Weiter', onClick: () => {} })); await mikroschritt(); // Erst schliessen, dann - noch im selben Takt - die Rueckfrage stellen. // `previousFocus` der Rueckfrage ist damit der Seitenkoerper. panel.close(); const antwort = confirmDialog({ title: 'Verwerfen?', message: 'Die Angaben gehen verloren.' }); ausloeser.remove(); await mikroschritt(); fussknopf('Abbrechen').click(); expect(await antwort).toBe('abgebrochen'); await mikroschritt(); await mikroschritt(); expect(document.activeElement, 'der Fokus liegt nach der Rueckfrage nirgends').toBe(inhalt); }); });