PageSourceSearch

https://www.schlier.de/_assets/094881798ba18447100691146b2e53f1/Ja…/Parts/hw13_content_table.js?1787130223

js schlier.de collected 2026-10-02 18:00:27 UTC 9,069 bytes, 293 lines download raw bytes

1/**
2 * HW Content — Table
3 *
4 * Macht scrollbare Tabellenbereiche bedienbar und erkennbar.
5 * Styling: Resources/Public/Scss/Content/hw13_content_table.scss
6 * Geladen:  Components/Content/Element/Element.html
7 *
8 * WARUM JS UND NICHT NUR CSS
9 * Vier Dinge lassen sich in CSS nicht ausdruecken:
10 *
11 *   1. Ob eine Tabelle ueberhaupt ueberlaeuft. Daran haengt, ob der Container
12 *      einen Tabstop bekommen darf. Ein Tabstop auf einem Bereich, der nichts
13 *      zu scrollen hat, waere eine leere Station fuer Tastaturnutzer.
14 *   2. Wo innerhalb des Scrollbereichs man sich befindet (Anfang / Mitte /
15 *      Ende). Darueber steuert das Stylesheet den Verlauf an der rechten Kante
16 *      und den Schatten der haftenden Spalte.
17 *   3. Der zugaengliche Name des Bereichs, der aus dem <caption> oder der
18 *      Ueberschrift des Inhaltselements kommt.
19 *   4. Welche Zelle wirklich in der ersten Rasterspalte beginnt. Ein
20 *      CSS-Selektor kennt nur die Geschwisterposition; bei rowspan stimmen die
21 *      beiden nicht ueberein (siehe markFirstColumnCells).
22 *
23 * WCAG-BEZUG
24 *   2.1.1 Tastatur: Ein Bereich mit `overflow-x: auto` ist ohne `tabindex="0"`
25 *         per Tastatur nicht scrollbar; der abgeschnittene Inhalt waere ohne
26 *         Maus unerreichbar.
27 *   1.3.1 / 4.1.2: `role="region"` mit Namen macht den Bereich ankuendbar und
28 *         in der Regionen-Uebersicht auffindbar.
29 *   2.4.7: Der Fokus ist sichtbar (:focus-visible im Stylesheet).
30 *
31 * BEWUSST OHNE KARTEN-UMBAU
32 * Der verbreitete Trick, Zeilen auf schmalen Schirmen per `display: block` in
33 * Karten zu verwandeln, ist nicht umgesetzt: er zerlegt die CSS-Tabellen-Box
34 * (in WebKit/Gecko ein dokumentiertes Risiko fuer die Rollen) und braucht je
35 * Zelle die Spaltenbeschriftung im Markup. Bei redaktionell gepflegten
36 * Tabellen mit colspan/rowspan ist die nicht zuverlaessig herleitbar.
37 */
38
39// figure.table erzeugt CKEditor um jede RTE-Tabelle, .hw13-table-scroll kommt
40// aus Typo3_core/templates/Table.html.
41const SELECTOR = 'figure.table, .hw13-table-scroll';
42const HINT_CLASS = 'hw13-table-hint';
43const HINT_TEXT = 'Die Tabelle lässt sich seitlich scrollen.';
44
45let idCounter = 0;
46
47const nextId = (prefix) => {
48	idCounter += 1;
49
50	return `${prefix}-${idCounter}`;
51};
52
53/**
54 * Markiert genau die Zellen, die wirklich in der ersten Rasterspalte beginnen.
55 *
56 * Nur sie duerfen haften. `tr > :first-child` in CSS trifft auch Zellen, vor
57 * denen keine Geschwisterzelle steht, deren Spalte 1 aber von einer
58 * rowspan-Zelle einer hoeheren Zeile belegt ist. Beispiel: eine Kopfzeile mit
59 * `<th rowspan="2">` macht in der zweiten Kopfzeile die naechste Spalte zum
60 * ersten Kind — die klebte dann mitten in der Tabelle fest.
61 *
62 * Verfahren ist das uebliche Tabellenmodell: je Spalte mitzaehlen, wie viele
63 * weitere Zeilen sie noch von einer rowspan-Zelle belegt ist. Die erste Spalte
64 * ohne solche Belegung ist die Startspalte der Zeile.
65 *
66 * Abschnittsweise, weil ein rowspan laut Spezifikation nicht ueber
67 * thead/tbody/tfoot hinausreicht.
68 *
69 * rowSpan="0" bedeutet laut HTML "bis zum Ende des Abschnitts", ist in
70 * redaktionellen Tabellen praktisch nicht anzutreffen und wird hier wie 1
71 * behandelt — im schlechtesten Fall haftet eine Zelle nicht, die haften
72 * duerfte; die Tabelle bleibt in jedem Fall bedienbar.
73 */
74const markFirstColumnCells = (table) => {
75	const sections = [table.tHead, ...table.tBodies, table.tFoot].filter(Boolean);
76
77	sections.forEach((section) => {
78		// belegt[spalte] = Anzahl weiterer Zeilen, die diese Spalte blockiert
79		const belegt = [];
80
81		[...section.rows].forEach((row) => {
82			let startSpalte = 0;
83
84			while ((belegt[startSpalte] || 0) > 0) {
85				startSpalte += 1;
86			}
87
88			const erste = row.cells[0];
89
90			if (erste) {
91				if (startSpalte === 0) {
92					erste.setAttribute('data-hw13-table-rowstart', '');
93				} else {
94					erste.removeAttribute('data-hw13-table-rowstart');
95				}
96			}
97
98			// Belegung dieser Zeile ermitteln …
99			const neu = [];
100			let spalte = 0;
101
102			[...row.cells].forEach((cell) => {
103				while ((belegt[spalte] || 0) > 0) {
104					spalte += 1;
105				}
106
107				const zeilen = Math.max(1, cell.rowSpan || 1);
108				const spalten = Math.max(1, cell.colSpan || 1);
109
110				for (let i = 0; i < spalten; i += 1) {
111					neu[spalte + i] = zeilen;
112				}
113
114				spalte += spalten;
115			});
116
117			// … und auf die naechste Zeile fortschreiben.
118			const breite = Math.max(belegt.length, neu.length);
119
120			for (let i = 0; i < breite; i += 1) {
121				belegt[i] = Math.max(
122					Math.max(0, (belegt[i] || 0) - 1),
123					Math.max(0, (neu[i] || 0) - 1),
124				);
125			}
126		});
127	});
128};
129
130/**
131 * Liefert das Element, das den Bereich benennen soll — bevorzugt das <caption>
132 * der Tabelle, sonst die Ueberschrift des Inhaltselements.
133 */
134const resolveLabel = (scroller, table) => {
135	const caption = table.querySelector(':scope > caption');
136
137	if (caption && caption.textContent.trim() !== '') {
138		if (!caption.id) {
139			caption.id = nextId('hw13-table-caption');
140		}
141
142		return { by: caption.id };
143	}
144
145	// Kein caption: die Ueberschrift des umgebenden Inhaltselements ist die
146	// naechstbeste Beschreibung. Genau dieser Fall liegt bei Bestandstabellen
147	// vor, die ohne Titel eingepflegt wurden.
148	const element = scroller.closest('hw13-content-element');
149	const heading = element && element.querySelector('h1, h2, h3, h4, h5, h6');
150
151	if (heading && heading.textContent.trim() !== '') {
152		if (!heading.id) {
153			heading.id = nextId('hw13-table-heading');
154		}
155
156		return { by: heading.id };
157	}
158
159	return { text: 'Tabelle' };
160};
161
162/** Anfang / Mitte / Ende des Scrollbereichs. */
163const updatePosition = (scroller) => {
164	// Math.abs: in Rechts-nach-links-Kontexten zaehlt scrollLeft negativ.
165	const left = Math.abs(scroller.scrollLeft);
166	const max = scroller.scrollWidth - scroller.clientWidth;
167
168	let pos = 'middle';
169
170	if (left <= 1) {
171		pos = 'start';
172	} else if (left >= max - 1) {
173		pos = 'end';
174	}
175
176	scroller.dataset.hw13TablePos = pos;
177};
178
179const addHint = (scroller) => {
180	const next = scroller.nextElementSibling;
181
182	if (next && next.classList.contains(HINT_CLASS)) {
183		return;
184	}
185
186	const hint = document.createElement('p');
187	hint.className = HINT_CLASS;
188	// aria-hidden: der Bereich kuendigt sich bereits selbst als benannte Region
189	// an, und Screenreader erschliessen Tabellen ueber eigene Kommandos. Der
190	// Hinweis richtet sich an sehende Nutzer, die den Ueberlauf sonst
191	// uebersehen.
192	hint.setAttribute('aria-hidden', 'true');
193	hint.textContent = HINT_TEXT;
194	scroller.after(hint);
195};
196
197const removeHint = (scroller) => {
198	const next = scroller.nextElementSibling;
199
200	if (next && next.classList.contains(HINT_CLASS)) {
201		next.remove();
202	}
203};
204
205/**
206 * Prueft einen Container und setzt bzw. entfernt alles, was vom tatsaechlichen
207 * Ueberlauf abhaengt. Wird bei jeder Groessenaenderung erneut aufgerufen — was
208 * auf dem Handy ueberlaeuft, passt auf dem Desktop, und dann muss der Tabstop
209 * wieder verschwinden.
210 */
211const sync = (scroller) => {
212	const table = scroller.querySelector('table');
213
214	if (!table) {
215		return;
216	}
217
218	// Haengt nur an der Tabellenstruktur, nicht an der Breite — schadet also
219	// nicht, wenn gar nicht gescrollt wird, und ist nach einer
220	// Inhaltsaenderung sofort wieder aktuell.
221	markFirstColumnCells(table);
222
223	const scrollable = scroller.scrollWidth - scroller.clientWidth > 1;
224
225	if (!scrollable) {
226		delete scroller.dataset.hw13TableScroll;
227		delete scroller.dataset.hw13TablePos;
228		scroller.removeAttribute('tabindex');
229		scroller.removeAttribute('role');
230		scroller.removeAttribute('aria-label');
231		scroller.removeAttribute('aria-labelledby');
232		removeHint(scroller);
233
234		return;
235	}
236
237	scroller.dataset.hw13TableScroll = 'true';
238	scroller.tabIndex = 0;
239	scroller.setAttribute('role', 'region');
240
241	const label = resolveLabel(scroller, table);
242
243	if (label.by) {
244		scroller.setAttribute('aria-labelledby', label.by);
245		scroller.removeAttribute('aria-label');
246	} else {
247		scroller.setAttribute('aria-label', label.text);
248		scroller.removeAttribute('aria-labelledby');
249	}
250
251	addHint(scroller);
252	updatePosition(scroller);
253};
254
255const init = () => {
256	const scrollers = document.querySelectorAll(SELECTOR);
257
258	if (scrollers.length === 0) {
259		return;
260	}
261
262	// Ein Observer fuer alle: feuert bei Viewport-Aenderungen, beim Aufklappen
263	// eines Slideblocks und wenn Webfonts die Spaltenbreiten nachtraeglich
264	// verschieben.
265	const observer = new ResizeObserver((entries) => {
266		entries.forEach((entry) => sync(entry.target));
267	});
268
269	scrollers.forEach((scroller) => {
270		sync(scroller);
271
272		// `passive: true`: der Listener ruft nie preventDefault.
273		scroller.addEventListener('scroll', () => updatePosition(scroller), {
274			passive: true,
275		});
276
277		observer.observe(scroller);
278
279		// Zusaetzlich die Tabelle selbst beobachten: der Container behaelt seine
280		// Breite (100% der Spalte), waehrend die Tabelle darin wachsen kann.
281		const table = scroller.querySelector('table');
282
283		if (table) {
284			observer.observe(table);
285		}
286	});
287};
288
289if (document.readyState === 'loading') {
290	document.addEventListener('DOMContentLoaded', init);
291} else {
292	init();
293}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.