lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui zugaenglicherName.ts
| 1 | /** |
| 2 | * Ermittelt den zugaenglichen Namen eines Bedienelements. |
| 3 | * |
| 4 | * Das ist die Angabe, die eine Sprachausgabe vorliest, wenn der Fokus auf das |
| 5 | * Element springt. Fehlt sie, sagt die Ausgabe nur "Eingabefeld" - der Anwender |
| 6 | * erfaehrt nicht, was er dort eintragen soll. |
| 7 | * |
| 8 | * Umgesetzt ist die uebliche Rangfolge, so weit sie sich in jsdom pruefen |
| 9 | * laesst. Absichtlich keine Fremdbibliothek: Die Regel gehoert zu den Zusagen |
| 10 | * dieser Anwendung und soll im Quelltext nachlesbar sein. |
| 11 | * |
| 12 | * 1. aria-labelledby - Text der verwiesenen Elemente |
| 13 | * 2. aria-label |
| 14 | * 3. verknuepfte Beschriftung: label[for] oder umschliessendes label |
| 15 | * 4. bei Schaltflaechen: der eigene Textinhalt |
| 16 | * |
| 17 | * `title` ZAEHLT NICHT MEHR (Fassung 5.7.0). Es war zuvor Rang 5 dieser Kette, |
| 18 | * und genau daran ist der Nachweis gescheitert: Befund M2 - 32 Auswahlfelder |
| 19 | * der Signalgruppentabelle, deren "Name" ein Erklaersatz war ("Abbiegende |
| 20 | * Kraftfahrzeuge raeumen langsamer und erhalten ...", in jeder der sechzehn |
| 21 | * Zeilen derselbe) - lief durch diese Pruefung hindurch, ohne dass ein Fall rot |
| 22 | * wurde. Ein Helfer, der `title` gelten laesst, prueft nicht die Zusage der |
| 23 | * Anwendung, sondern den Rueckfallpfad des Browsers. |
| 24 | * |
| 25 | * Die Anwendung selbst hat diese Entscheidung laengst getroffen: `button()` in |
| 26 | * `src/ui/dom.ts` setzt `title` seit dem Umbau ausdruecklich NICHT mehr als |
| 27 | * Namen, und `benenneBedienelemente` benennt Tabellenfelder aus Spalte und |
| 28 | * Zeile. Der Pruefhelfer zog nach - solange er hinterherhinkte, sicherte er |
| 29 | * einen schwaecheren Stand ab als den, der ausgeliefert wird. |
| 30 | * |
| 31 | * Fachlich traegt `title` als Name auch nicht: Er erscheint nur unter dem |
| 32 | * ruhenden Mauszeiger, auf Beruehrungsgeraeten gar nicht, laesst sich nicht |
| 33 | * vergroessern und nicht mit der Tastatur abrufen. |
| 34 | * |
| 35 | * Beim Textinhalt wird `aria-hidden="true"` beachtet (siehe `sichtbarerText`). |
| 36 | * Das ist keine Feinheit: Die Anwendung setzt an mehreren Stellen ein |
| 37 | * Formmerkmal neben die Farbe - das Ausrufezeichen am Fehlerabzeichen, die |
| 38 | * "0 !" der Zwischenzeitenmatrix - und nimmt es ausdruecklich aus dem |
| 39 | * Vorlesetext heraus. Ein Helfer, der schlicht `textContent` nimmt, misst |
| 40 | * einen Namen, den niemand hoert, und haette die Zusage nicht geprueft, |
| 41 | * sondern nur behauptet. |
| 42 | */ |
| 43 | |
| 44 | export type Bedienelement = HTMLElement; |
| 45 | |
| 46 | /** Alle Bedienelemente eines Teilbaums, die einen Namen brauchen. */ |
| 47 | export function bedienelemente(wurzel: ParentNode): Bedienelement[] { |
| 48 | const treffer = wurzel.querySelectorAll<HTMLElement>( |
| 49 | 'input, select, textarea, button, [role="button"], [role="checkbox"], [role="radio"]', |
| 50 | ); |
| 51 | return [...treffer].filter((e) => { |
| 52 | // Verborgene Elemente muessen keinen Namen tragen. |
| 53 | if (e.hasAttribute('hidden')) return false; |
| 54 | if (e.getAttribute('aria-hidden') === 'true') return false; |
| 55 | // Ein Feld vom Typ hidden ist kein Bedienelement. |
| 56 | if (e instanceof HTMLInputElement && e.type === 'hidden') return false; |
| 57 | return true; |
| 58 | }); |
| 59 | } |
| 60 | |
| 61 | /** |
| 62 | * Text eines Teilbaums, wie eine Sprachausgabe ihn liest. |
| 63 | * |
| 64 | * Zweig fuer Zweig statt ueber `textContent`, weil Teilbaeume mit |
| 65 | * `aria-hidden="true"` nicht zum Namen gehoeren - und weil ein umschliessendes |
| 66 | * `hidden` ebenso wenig gilt. |
| 67 | */ |
| 68 | function sichtbarerText(knoten: Node): string { |
| 69 | if (knoten.nodeType === Node.TEXT_NODE) return knoten.textContent ?? ''; |
| 70 | if (knoten.nodeType !== Node.ELEMENT_NODE) return ''; |
| 71 | const element = knoten as Element; |
| 72 | if (element.getAttribute('aria-hidden') === 'true') return ''; |
| 73 | if (element.hasAttribute('hidden')) return ''; |
| 74 | return [...element.childNodes].map(sichtbarerText).join(''); |
| 75 | } |
| 76 | |
| 77 | export function zugaenglicherName(element: HTMLElement): string { |
| 78 | const beschriftetVon = element.getAttribute('aria-labelledby'); |
| 79 | if (beschriftetVon !== null && beschriftetVon.trim() !== '') { |
| 80 | const text = beschriftetVon |
| 81 | .split(/\s+/) |
| 82 | .map((id) => { |
| 83 | const ziel = element.ownerDocument.getElementById(id); |
| 84 | // Ein ausdruecklich verwiesenes Element zaehlt auch dann, wenn es |
| 85 | // selbst verborgen ist - das ist der uebliche Weg, einen Namen |
| 86 | // unsichtbar zu hinterlegen. Verborgene Teile DARIN zaehlen nicht. |
| 87 | return ziel === null ? '' : [...ziel.childNodes].map(sichtbarerText).join(''); |
| 88 | }) |
| 89 | .join(' ') |
| 90 | .trim(); |
| 91 | if (text !== '') return text; |
| 92 | } |
| 93 | |
| 94 | const label = element.getAttribute('aria-label'); |
| 95 | if (label !== null && label.trim() !== '') return label.trim(); |
| 96 | |
| 97 | const id = element.getAttribute('id'); |
| 98 | if (id !== null && id !== '') { |
| 99 | // Bewusst ueber htmlFor verglichen statt ueber einen Auswahlausdruck: |
| 100 | // CSS.escape gibt es in jsdom nicht, und eine Kennung mit Sonderzeichen |
| 101 | // wuerde den Ausdruck sonst zerreissen. |
| 102 | const zugehoerig = [...element.ownerDocument.querySelectorAll('label')].find( |
| 103 | (l) => l.htmlFor === id, |
| 104 | ); |
| 105 | const text = zugehoerig?.textContent?.trim() ?? ''; |
| 106 | if (text !== '') return text; |
| 107 | } |
| 108 | |
| 109 | const umschliessend = element.closest('label'); |
| 110 | if (umschliessend !== null) { |
| 111 | const text = umschliessend.textContent?.trim() ?? ''; |
| 112 | if (text !== '') return text; |
| 113 | } |
| 114 | |
| 115 | if (element instanceof HTMLButtonElement) { |
| 116 | const text = sichtbarerText(element).trim(); |
| 117 | if (text !== '') return text; |
| 118 | } |
| 119 | |
| 120 | // Kein Rueckfall auf `title` - die Begruendung steht im Dateikopf. |
| 121 | return ''; |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * Traegt der Name ueberhaupt eine Aussage? |
| 126 | * |
| 127 | * BEFUND (Fassung 5.7.0): Bei Schaltflaechen wurde der Textinhalt als Name |
| 128 | * genommen, und "x", "hoch" und "runter" sind nicht leer. Damit galten die |
| 129 | * sechzehn Loeschknoepfe der Signalgruppentabelle als benannt (Befund M3) - |
| 130 | * jeder von ihnen entfernt eine Signalgruppe samt allen Konfliktbeziehungen, |
| 131 | * und keiner sagte, welche. |
| 132 | * |
| 133 | * Ein Zeichen ohne Buchstaben und ohne Ziffer ist kein Name: Eine Sprachausgabe |
| 134 | * liest es je nach Zeichentabelle als "mal", "Kreuz", "Stern" oder gar nicht |
| 135 | * vor; eine Sprachsteuerung hat nichts, was sie ansprechen koennte. WCAG 2.1 |
| 136 | * Erfolgskriterium 2.4.6 (AA) verlangt, dass eine Beschriftung Thema oder Zweck |
| 137 | * beschreibt. |
| 138 | * |
| 139 | * Geprueft wird auf das Fehlen JEDES Buchstabens und JEDER Ziffer und nicht nur |
| 140 | * auf die Laenge eins: "<<" und "->" sind genauso wenig ein Name wie "x". Ein |
| 141 | * Name, der neben dem Zeichen ein Wort traegt, bleibt gueltig - die |
| 142 | * Zwischenzeitenmatrix schreibt mit Absicht "0 !", damit sich die Null nicht |
| 143 | * allein durch ihre Farbe unterscheidet, und das soll sie duerfen. |
| 144 | */ |
| 145 | export function nameTraegtAussage(name: string): boolean { |
| 146 | return /[\p{L}\p{N}]/u.test(name); |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * Was dem Namen eines Bedienelements fehlt - oder `null`, wenn er traegt. |
| 151 | * |
| 152 | * Zwei Maengel mit derselben Wirkung, aber verschiedener Ursache; die |
| 153 | * Fehlermeldung soll sie auseinanderhalten, damit ein roter Fall unmittelbar |
| 154 | * sagt, ob eine Beschriftung fehlt oder nur nichtssagend ist. |
| 155 | */ |
| 156 | export function namensmangel(element: HTMLElement): string | null { |
| 157 | const name = zugaenglicherName(element); |
| 158 | if (name === '') { |
| 159 | const titel = element.getAttribute('title'); |
| 160 | // Der haeufigste Fall in dieser Anwendung: Es steht etwas da, nur an der |
| 161 | // falschen Stelle. Das gehoert in die Meldung, sonst sucht man im |
| 162 | // Quelltext nach einem Element ganz ohne Beschriftung. |
| 163 | return titel !== null && titel.trim() !== '' |
| 164 | ? `ohne zugaenglichen Namen (nur title="${titel.trim()}")` |
| 165 | : 'ohne zugaenglichen Namen'; |
| 166 | } |
| 167 | if (!nameTraegtAussage(name)) { |
| 168 | return `Name "${name}" besteht nur aus Sonderzeichen`; |
| 169 | } |
| 170 | return null; |
| 171 | } |
| 172 | |
| 173 | /** Kurze Beschreibung eines Elements fuer Fehlermeldungen im Test. */ |
| 174 | export function beschreibe(element: HTMLElement): string { |
| 175 | const art = |
| 176 | element instanceof HTMLInputElement |
| 177 | ? `input[type=${element.type}]` |
| 178 | : element.tagName.toLowerCase(); |
| 179 | const spalte = spaltenkopf(element); |
| 180 | const umgebung = element.closest('tr') !== null ? ' in einer Tabellenzeile' : ''; |
| 181 | return `${art}${spalte === null ? '' : ` unter der Spalte "${spalte}"`}${umgebung}`; |
| 182 | } |
| 183 | |
| 184 | /** Spaltenkopf ueber einer Tabellenzelle - hilft beim Auffinden im Quelltext. */ |
| 185 | function spaltenkopf(element: HTMLElement): string | null { |
| 186 | const zelle = element.closest('td, th'); |
| 187 | const zeile = zelle?.closest('tr'); |
| 188 | const tabelle = zeile?.closest('table'); |
| 189 | if (!zelle || !zeile || !tabelle) return null; |
| 190 | |
| 191 | const index = [...zeile.children].indexOf(zelle); |
| 192 | const kopfzeile = tabelle.querySelector('thead tr'); |
| 193 | const kopf = kopfzeile?.children[index]; |
| 194 | return kopf?.textContent?.trim() ?? null; |
| 195 | } |