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.