1/* Global mobile "table -> cards" layer. 2 * 3 * Content editors build tables in the WYSIWYG editor with fixed pixel/percent 4 * widths and 4-8 columns. On a phone those tables push the page sideways. This 5 * script turns such a table into one card per row ("label: value" lines), and 6 * where no header row can be identified it falls back to a horizontal scroll 7 * box so the page itself never overflows. 8 * 9 * The layout is CSS-only (css/tableCards.css, @media max-width:767px); this 10 * file only tags the DOM. Desktop rendering is never touched. 11 * 12 * Safety - the script is a FALLBACK and deliberately refuses to act unless it 13 * is sure nobody else owns the table: 14 * - it runs one tick after DOMContentLoaded, so every per-schema and 15 * per-mercaz script has already had its turn; 16 * - it only ever touches a table that is ACTUALLY WIDER than its container 17 * at mobile width - a table that already fits is left completely alone; 18 * - it skips application widgets (carts, modals, tabs, datepickers, 19 * DataTables, sliders), anything holding form controls, nested tables and 20 * tables another stylesheet/script already stacks or labels; 21 * - labels use data-tc-label, not data-label/data-th/data-title, so it can 22 * never collide with the per-schema card CSS that already uses those. 23 * 24 * Manual override on a single table: data-tablecards="off" | "on" 25 * ("on" only bypasses the overflow test - the safety skips still apply). 26 * Re-scan after injecting content with jQuery/ajax: window.asTableCards.scan() 27 */ 28(function () { 29 'use strict'; 30 31 var MQ = '(max-width: 767px)'; 32 var MAX_LABEL_LEN = 45; /* longer first-row cells read as data, not a header */ 33 var NOT_ECHO = -2; /* headerEchoCell(): the row is real data */ 34 var FLAG = 'asTableCardsDone'; 35 var REJECTED = 'asTableCardsSkip'; 36 37 /* Widgets that own their tables' markup, row order or visibility. */ 38 var SKIP_INSIDE = '.modal, .tab-pane, .accordion_table_wrapper, .swiper-container,' + 39 ' .swiper-wrapper, .slick-slider, .owl-carousel, .ui-datepicker, .fc,' + 40 ' .dataTables_wrapper, #datatable, #cartForm, .keytanaDataForMobile'; 41 42 /* Tables that already ship a mobile behaviour of their own. */ 43 var SKIP_SELF = '.table_responsive_new, .viowOrderTable, .personal-cart-table,' + 44 ' .dataTable, .tcard, .no-mobile, .no-desktop, .safari-table, .non-safari-table,' + 45 ' .as-tc, .as-tc-scroll'; 46 47 /* A cell already carrying one of these is labelled by someone else's CSS. */ 48 var SKIP_LABELLED = '[data-label], [data-th], [data-title], .is-table-header-row'; 49 50 function closest(el, sel) { 51 if (el.closest) { 52 return el.closest(sel); 53 } 54 var node = el; 55 while (node && node.nodeType === 1) { 56 if (matches(node, sel)) { 57 return node; 58 } 59 node = node.parentElement; 60 } 61 return null; 62 } 63 64 function matches(el, sel) { 65 var fn = el.matches || el.msMatchesSelector || el.webkitMatchesSelector; 66 return fn ? fn.call(el, sel) : false; 67 } 68 69 function text(el) { 70 return (el.textContent || '').replace(/\s+/g, ' ').trim(); 71 } 72 73 /* The table is already stacked by another stylesheet if its rows/cells no 74 longer compute to table display at this width - leave it to them. */ 75 function alreadyStacked(table) { 76 var row = table.rows[table.rows.length - 1]; 77 if (!row) { 78 return true; 79 } 80 if (window.getComputedStyle(row).display !== 'table-row') { 81 return true; 82 } 83 var cell = row.cells[0]; 84 return !!cell && window.getComputedStyle(cell).display !== 'table-cell'; 85 } 86 87 /* The table itself, or an ancestor, already scrolls horizontally - the 88 overflow is contained and somebody meant it that way. */ 89 function inScrollBox(table) { 90 var node = table; 91 for (var i = 0; node && node.nodeType === 1 && i < 4; i++, node = node.parentElement) { 92 var overflow = window.getComputedStyle(node).overflowX; 93 if (overflow === 'auto' || overflow === 'scroll') { 94 return true; 95 } 96 } 97 return false;
98 } 99 100 /* Only tables that genuinely break the layout are worth changing. */ 101 function overflows(table) { 102 var host = table.parentElement; 103 if (!host) { 104 return false; 105 } 106 var room = host.clientWidth || document.documentElement.clientWidth; 107 if (room < 1) { 108 return false; 109 } 110 var width = Math.max(table.scrollWidth, Math.ceil(table.getBoundingClientRect().width)); 111 return width - room > 4; /* tolerance: sub-pixel rounding is not overflow */ 112 } 113 114 /* Reasons that can never change with the viewport - checked once, then 115 remembered, so the resize handler stays cheap on table-heavy pages. */ 116 function isDisqualified(table) { 117 if (table[REJECTED]) { 118 return true; 119 } 120 var no = table.getAttribute('data-tablecards') === 'off' || 121 matches(table, SKIP_SELF) || 122 !!closest(table, SKIP_INSIDE) || 123 /* nested tables, and tables inside a table, are layout scaffolding */ 124 table.getElementsByTagName('table').length > 0 || 125 (table.parentElement && !!closest(table.parentElement, 'table')) || 126 /* anything interactive belongs to the application, not the editor */ 127 !!table.querySelector('input, select, textarea, button, [contenteditable]') || 128 !!table.querySelector(SKIP_LABELLED) || 129 table.rows.length < 2; 130 131 if (no) { 132 table[REJECTED] = true; 133 } 134 return no; 135 } 136 137 function isCandidate(table) { 138 if (table[FLAG] || isDisqualified(table)) { 139 return false; 140 } 141 /* width-dependent, so re-evaluated on every pass */ 142 if (alreadyStacked(table) || inScrollBox(table)) { 143 return false; 144 } 145 return table.getAttribute('data-tablecards') === 'on' || overflows(table); 146 } 147 148 /* Resolve rowspan/colspan into a row x column grid so every visual column 149 has an entry, and cells spanning down can be found from the rows below. */ 150 function buildGrid(rows) { 151 var grid = []; 152 for (var r = 0; r < rows.length; r++) { 153 grid[r] = grid[r] || []; 154 var c = 0; 155 var cells = rows[r].cells; 156 for (var i = 0; i < cells.length; i++) { 157 var cell = cells[i]; 158 while (grid[r][c]) { 159 c++; 160 } 161 var rowSpan = cell.rowSpan || 1; 162 var colSpan = cell.colSpan || 1; 163 for (var rr = 0; rr < rowSpan; rr++) { 164 for (var cc = 0; cc < colSpan; cc++) { 165 grid[r + rr] = grid[r + rr] || []; 166 grid[r + rr][c + cc] = { cell: cell, ownRow: rr === 0 }; 167 } 168 } 169 c += colSpan; 170 } 171 } 172 return grid; 173 } 174 175 function distinctCells(gridRow) { 176 var out = []; 177 for (var c = 0; c < (gridRow || []).length; c++) { 178 var entry = gridRow[c]; 179 if (entry && out.indexOf(entry.cell) === -1) { 180 out.push(entry.cell); 181 } 182 } 183 return out; 184 } 185 186 /* Only ~a quarter of editor tables use <th>, so fall back to reading the 187 first multi-column row as the header - but only when it looks like one: 188 every column filled, short plain text, no media, and real data below. */ 189 function findHeaderIndex(rows, grid) { 190 var i; 191 192 for (i = 0; i < rows.length; i++) { 193 var cells = distinctCells(grid[i]); 194 if (cells.length < 2) { 195 continue; /* full-width row above the header = title banner */ 196 } 197 198 var allTh = true; 199 var looksLikeLabels = true; 200 for (var j = 0; j < cells.length; j++) { 201 var cell = cells[j]; 202 if (cell.tagName !== 'TH') { 203 allTh = false; 204 } 205 var value = text(cell); 206 if (!value || value.length > MAX_LABEL_LEN || 207 cell.querySelector('img, table, ul, ol, iframe, br')) { 208 looksLikeLabels = false;
209 } 210 } 211 212 if (allTh) { 213 return i; 214 } 215 /* a header made of <td> is only believable with data rows under it */ 216 if (looksLikeLabels && rows.length - i >= 3) { 217 return i; 218 } 219 return -1; /* first real row is data - no header to read */ 220 } 221 return -1; 222 } 223 224 function labelsFrom(gridRow) { 225 var labels = []; 226 for (var c = 0; c < (gridRow || []).length; c++) { 227 labels[c] = gridRow[c] ? text(gridRow[c].cell) : ''; 228 } 229 return labels; 230 } 231 232 /* A cell with no text and no media adds an empty line to the card. */ 233 function isBlank(cell) { 234 return !text(cell) && !cell.querySelector('img, svg, input, a'); 235 } 236 237 function allBlank(gridRow) { 238 var cells = distinctCells(gridRow); 239 for (var i = 0; i < cells.length; i++) { 240 if (!isBlank(cells[i])) { 241 return false; 242 } 243 } 244 return true; 245 } 246 247 /* Editor tables often repeat the header row part-way down to start a new 248 section, putting the section name in the first cell and the same column 249 titles in the rest ("× ×¦××× ×¢×ר××× | ×××× | ×× | ×ת××ת"). As a card that 250 reads "××××: ××××", so such a row becomes a section banner showing only 251 the name - or is dropped entirely when every cell just repeats a label. */ 252 function headerEchoCell(gridRow, labels) { 253 var cols = 0; 254 var matched = 0; 255 var odd = -1; 256 257 for (var c = 0; c < (gridRow || []).length; c++) { 258 var entry = gridRow[c]; 259 if (!entry || !entry.ownRow) { 260 return NOT_ECHO; /* spanned cell - not a plain header repeat */ 261 } 262 cols++; 263 if (labels[c] && text(entry.cell) === labels[c]) { 264 matched++; 265 } else if (odd === -1) { 266 odd = c; 267 } else { 268 return NOT_ECHO; /* two columns differ - it is real data */ 269 } 270 } 271 if (cols < 3 || matched < 2 || matched < cols - 1) { 272 return NOT_ECHO; 273 } 274 return odd; /* -1 = pure repeat, >=0 = the section name */ 275 } 276 277 /* Keep one cell of a section-banner row, drop the repeated column titles. */ 278 function keepOnly(gridRow, col) { 279 var keep = gridRow[col] ? gridRow[col].cell : null; 280 var cells = distinctCells(gridRow); 281 for (var i = 0; i < cells.length; i++) { 282 if (cells[i] !== keep) { 283 cells[i].className += ' as-tc-echo'; 284 } 285 } 286 } 287 288 function toScrollBox(table) { 289 table[FLAG] = true; 290 table.className = table.className ? table.className + ' as-tc-scroll' : 'as-tc-scroll'; 291 } 292 293 function toCards(table, rows, grid, headerIdx) { 294 var labels = labelsFrom(grid[headerIdx]); 295 296 for (var r = 0; r < rows.length; r++) { 297 var row = rows[r]; 298 299 if (r < headerIdx) { 300 /* full-width row above the header: a banner, unless it is the 301 blank spacer row editors leave between sections */ 302 row.className += allBlank(grid[r]) ? ' as-tc-head' : ' as-tc-title'; 303 continue; 304 } 305 if (r === headerIdx) { 306 row.className += ' as-tc-head'; 307 continue; 308 } 309 310 var echo = headerEchoCell(grid[r], labels); 311 if (echo !== NOT_ECHO) { 312 if (echo < 0) { 313 row.className += ' as-tc-head'; /* nothing but repeated labels */ 314 } else { 315 row.className += ' as-tc-title'; 316 keepOnly(grid[r], echo); 317 } 318 continue; 319 } 320 321 row.className += ' as-tc-row'; 322 323 var seen = []; 324 for (var c = 0; c < grid[r].length; c++) { 325 var entry = grid[r][c]; 326 if (!entry || seen.indexOf(entry.cell) !== -1) { 327 continue; 328 } 329 seen.push(entry.cell); 330 331 if (entry.ownRow) { 332 entry.cell.setAttribute('data-tc-label', labels[c] || ''); 333 entry.cell.style.order = c; 334 if (isBlank(entry.cell)) { 335 entry.cell.className += ' as-tc-empty'; 336 } 337 } else { 338 /* the cell spans down from an earlier row: clone it into this 339 card so each card is self-contained. The clone is appended 340 at the end, so `order` restores the original column order - 341 it is inert in the desktop table layout, where the clone is 342 hidden anyway. */ 343 var clone = entry.cell.cloneNode(true); 344 clone.removeAttribute('rowspan'); 345 clone.setAttribute('data-tc-label', labels[c] || ''); 346 clone.className += ' as-tc-clone'; 347 clone.style.order = c; 348 if (isBlank(clone)) { 349 clone.className += ' as-tc-empty'; 350 } 351 row.appendChild(clone); 352 } 353 } 354 } 355 356 table[FLAG] = true; 357 table.className = table.className ? table.className + ' as-tc' : 'as-tc'; 358 } 359 360 function convert(table) { 361 var rows = []; 362 for (var i = 0; i < table.rows.length; i++) { 363 rows.push(table.rows[i]); 364 } 365 366 var grid = buildGrid(rows); 367 var headerIdx = findHeaderIndex(rows, grid); 368 369 if (headerIdx === -1 || headerIdx >= rows.length - 1) { 370 toScrollBox(table); /* no labels to read - keep it scrollable */ 371 return; 372 } 373 toCards(table, rows, grid, headerIdx); 374 } 375 376 function scan() { 377 if (!window.matchMedia || !window.matchMedia(MQ).matches) { 378 return; 379 } 380 var tables = document.getElementsByTagName('table'); 381 /* live collection + we append cells: snapshot first */ 382 var list = []; 383 for (var i = 0; i < tables.length; i++) { 384 list.push(tables[i]); 385 } 386 for (var j = 0; j < list.length; j++) { 387 if (isCandidate(list[j])) { 388 convert(list[j]); 389 } 390 } 391 } 392 393 var pending = null; 394 function scanSoon() { 395 if (pending) { 396 clearTimeout(pending); 397 } 398 pending = setTimeout(function () { 399 pending = null; 400 scan(); 401 }, 150); 402 } 403 404 function start() { 405 /* one tick late, so per-schema / per-mercaz DOMContentLoaded handlers 406 (which may card these tables themselves) run first */ 407 setTimeout(scan, 0); 408 } 409 410 if (document.readyState === 'loading') { 411 document.addEventListener('DOMContentLoaded', start); 412 } else { 413 start(); 414 } 415 /* late webfonts and images change what overflows */ 416 window.addEventListener('load', scanSoon); 417 window.addEventListener('resize', scanSoon); 418 window.addEventListener('orientationchange', scanSoon); 419 420 window.asTableCards = { scan: scan }; 421})();
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.