lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests ui zugaenglicherName.ts

8,3 KB Rohdatei
tests/ui/zugaenglicherName.ts — 195 Zeilen
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 }