/** * Ermittelt den zugaenglichen Namen eines Bedienelements. * * Das ist die Angabe, die eine Sprachausgabe vorliest, wenn der Fokus auf das * Element springt. Fehlt sie, sagt die Ausgabe nur "Eingabefeld" - der Anwender * erfaehrt nicht, was er dort eintragen soll. * * Umgesetzt ist die uebliche Rangfolge, so weit sie sich in jsdom pruefen * laesst. Absichtlich keine Fremdbibliothek: Die Regel gehoert zu den Zusagen * dieser Anwendung und soll im Quelltext nachlesbar sein. * * 1. aria-labelledby - Text der verwiesenen Elemente * 2. aria-label * 3. verknuepfte Beschriftung: label[for] oder umschliessendes label * 4. bei Schaltflaechen: der eigene Textinhalt * * `title` ZAEHLT NICHT MEHR (Fassung 5.7.0). Es war zuvor Rang 5 dieser Kette, * und genau daran ist der Nachweis gescheitert: Befund M2 - 32 Auswahlfelder * der Signalgruppentabelle, deren "Name" ein Erklaersatz war ("Abbiegende * Kraftfahrzeuge raeumen langsamer und erhalten ...", in jeder der sechzehn * Zeilen derselbe) - lief durch diese Pruefung hindurch, ohne dass ein Fall rot * wurde. Ein Helfer, der `title` gelten laesst, prueft nicht die Zusage der * Anwendung, sondern den Rueckfallpfad des Browsers. * * Die Anwendung selbst hat diese Entscheidung laengst getroffen: `button()` in * `src/ui/dom.ts` setzt `title` seit dem Umbau ausdruecklich NICHT mehr als * Namen, und `benenneBedienelemente` benennt Tabellenfelder aus Spalte und * Zeile. Der Pruefhelfer zog nach - solange er hinterherhinkte, sicherte er * einen schwaecheren Stand ab als den, der ausgeliefert wird. * * Fachlich traegt `title` als Name auch nicht: Er erscheint nur unter dem * ruhenden Mauszeiger, auf Beruehrungsgeraeten gar nicht, laesst sich nicht * vergroessern und nicht mit der Tastatur abrufen. * * Beim Textinhalt wird `aria-hidden="true"` beachtet (siehe `sichtbarerText`). * Das ist keine Feinheit: Die Anwendung setzt an mehreren Stellen ein * Formmerkmal neben die Farbe - das Ausrufezeichen am Fehlerabzeichen, die * "0 !" der Zwischenzeitenmatrix - und nimmt es ausdruecklich aus dem * Vorlesetext heraus. Ein Helfer, der schlicht `textContent` nimmt, misst * einen Namen, den niemand hoert, und haette die Zusage nicht geprueft, * sondern nur behauptet. */ export type Bedienelement = HTMLElement; /** Alle Bedienelemente eines Teilbaums, die einen Namen brauchen. */ export function bedienelemente(wurzel: ParentNode): Bedienelement[] { const treffer = wurzel.querySelectorAll( 'input, select, textarea, button, [role="button"], [role="checkbox"], [role="radio"]', ); return [...treffer].filter((e) => { // Verborgene Elemente muessen keinen Namen tragen. if (e.hasAttribute('hidden')) return false; if (e.getAttribute('aria-hidden') === 'true') return false; // Ein Feld vom Typ hidden ist kein Bedienelement. if (e instanceof HTMLInputElement && e.type === 'hidden') return false; return true; }); } /** * Text eines Teilbaums, wie eine Sprachausgabe ihn liest. * * Zweig fuer Zweig statt ueber `textContent`, weil Teilbaeume mit * `aria-hidden="true"` nicht zum Namen gehoeren - und weil ein umschliessendes * `hidden` ebenso wenig gilt. */ function sichtbarerText(knoten: Node): string { if (knoten.nodeType === Node.TEXT_NODE) return knoten.textContent ?? ''; if (knoten.nodeType !== Node.ELEMENT_NODE) return ''; const element = knoten as Element; if (element.getAttribute('aria-hidden') === 'true') return ''; if (element.hasAttribute('hidden')) return ''; return [...element.childNodes].map(sichtbarerText).join(''); } export function zugaenglicherName(element: HTMLElement): string { const beschriftetVon = element.getAttribute('aria-labelledby'); if (beschriftetVon !== null && beschriftetVon.trim() !== '') { const text = beschriftetVon .split(/\s+/) .map((id) => { const ziel = element.ownerDocument.getElementById(id); // Ein ausdruecklich verwiesenes Element zaehlt auch dann, wenn es // selbst verborgen ist - das ist der uebliche Weg, einen Namen // unsichtbar zu hinterlegen. Verborgene Teile DARIN zaehlen nicht. return ziel === null ? '' : [...ziel.childNodes].map(sichtbarerText).join(''); }) .join(' ') .trim(); if (text !== '') return text; } const label = element.getAttribute('aria-label'); if (label !== null && label.trim() !== '') return label.trim(); const id = element.getAttribute('id'); if (id !== null && id !== '') { // Bewusst ueber htmlFor verglichen statt ueber einen Auswahlausdruck: // CSS.escape gibt es in jsdom nicht, und eine Kennung mit Sonderzeichen // wuerde den Ausdruck sonst zerreissen. const zugehoerig = [...element.ownerDocument.querySelectorAll('label')].find( (l) => l.htmlFor === id, ); const text = zugehoerig?.textContent?.trim() ?? ''; if (text !== '') return text; } const umschliessend = element.closest('label'); if (umschliessend !== null) { const text = umschliessend.textContent?.trim() ?? ''; if (text !== '') return text; } if (element instanceof HTMLButtonElement) { const text = sichtbarerText(element).trim(); if (text !== '') return text; } // Kein Rueckfall auf `title` - die Begruendung steht im Dateikopf. return ''; } /** * Traegt der Name ueberhaupt eine Aussage? * * BEFUND (Fassung 5.7.0): Bei Schaltflaechen wurde der Textinhalt als Name * genommen, und "x", "hoch" und "runter" sind nicht leer. Damit galten die * sechzehn Loeschknoepfe der Signalgruppentabelle als benannt (Befund M3) - * jeder von ihnen entfernt eine Signalgruppe samt allen Konfliktbeziehungen, * und keiner sagte, welche. * * Ein Zeichen ohne Buchstaben und ohne Ziffer ist kein Name: Eine Sprachausgabe * liest es je nach Zeichentabelle als "mal", "Kreuz", "Stern" oder gar nicht * vor; eine Sprachsteuerung hat nichts, was sie ansprechen koennte. WCAG 2.1 * Erfolgskriterium 2.4.6 (AA) verlangt, dass eine Beschriftung Thema oder Zweck * beschreibt. * * Geprueft wird auf das Fehlen JEDES Buchstabens und JEDER Ziffer und nicht nur * auf die Laenge eins: "<<" und "->" sind genauso wenig ein Name wie "x". Ein * Name, der neben dem Zeichen ein Wort traegt, bleibt gueltig - die * Zwischenzeitenmatrix schreibt mit Absicht "0 !", damit sich die Null nicht * allein durch ihre Farbe unterscheidet, und das soll sie duerfen. */ export function nameTraegtAussage(name: string): boolean { return /[\p{L}\p{N}]/u.test(name); } /** * Was dem Namen eines Bedienelements fehlt - oder `null`, wenn er traegt. * * Zwei Maengel mit derselben Wirkung, aber verschiedener Ursache; die * Fehlermeldung soll sie auseinanderhalten, damit ein roter Fall unmittelbar * sagt, ob eine Beschriftung fehlt oder nur nichtssagend ist. */ export function namensmangel(element: HTMLElement): string | null { const name = zugaenglicherName(element); if (name === '') { const titel = element.getAttribute('title'); // Der haeufigste Fall in dieser Anwendung: Es steht etwas da, nur an der // falschen Stelle. Das gehoert in die Meldung, sonst sucht man im // Quelltext nach einem Element ganz ohne Beschriftung. return titel !== null && titel.trim() !== '' ? `ohne zugaenglichen Namen (nur title="${titel.trim()}")` : 'ohne zugaenglichen Namen'; } if (!nameTraegtAussage(name)) { return `Name "${name}" besteht nur aus Sonderzeichen`; } return null; } /** Kurze Beschreibung eines Elements fuer Fehlermeldungen im Test. */ export function beschreibe(element: HTMLElement): string { const art = element instanceof HTMLInputElement ? `input[type=${element.type}]` : element.tagName.toLowerCase(); const spalte = spaltenkopf(element); const umgebung = element.closest('tr') !== null ? ' in einer Tabellenzeile' : ''; return `${art}${spalte === null ? '' : ` unter der Spalte "${spalte}"`}${umgebung}`; } /** Spaltenkopf ueber einer Tabellenzelle - hilft beim Auffinden im Quelltext. */ function spaltenkopf(element: HTMLElement): string | null { const zelle = element.closest('td, th'); const zeile = zelle?.closest('tr'); const tabelle = zeile?.closest('table'); if (!zelle || !zeile || !tabelle) return null; const index = [...zeile.children].indexOf(zelle); const kopfzeile = tabelle.querySelector('thead tr'); const kopf = kopfzeile?.children[index]; return kopf?.textContent?.trim() ?? null; }