// ═══════════════════════════════════════════════════════════════════ // shared/sortable.js | v1.0.0 | 2026-05-10 | task N5 // Auto-attaches click-to-sort to every
| . // // Usage: // Skips: // • tables with [data-sort-skip] attribute // • tables already using the legacy sportHeader() pattern (those have // sort handlers wired in their | already; we detect them and bail) // // Click behavior per | : // 1st click → ASC // 2nd click → DESC // 3rd click → unsorted (original order restored) // // Type detection per column (sampled from first 8 non-empty rows): // numeric → integers / floats / "1.234,56 €" / "12 kn" // date → ISO yyyy-mm-dd or hr-HR dd.mm.yyyy // string → fallback, locale-aware (hr-HR collation) // ═══════════════════════════════════════════════════════════════════ (function(){ 'use strict'; var ARROW_NEUTRAL = ' ↕'; var ARROW_ASC = ' ▲'; var ARROW_DESC = ' ▼'; var COLLATOR = (typeof Intl !== 'undefined' && Intl.Collator) ? new Intl.Collator('hr-HR', { numeric: true, sensitivity: 'base' }) : null; function txt(node){ return (node.textContent || '').trim(); } function detectType(samples){ // Decide column type from up to 8 sample cell values var nNum = 0, nDate = 0, n = 0; for (var i = 0; i < samples.length && n < 8; i++){ var v = samples[i]; if (v === '' || v === '—' || v === '-' || v == null) continue; n++; if (parseHRNumber(v) !== null) nNum++; else if (parseDate(v)) nDate++; } if (n === 0) return 'string'; if (nNum / n >= 0.6) return 'numeric'; if (nDate / n >= 0.6) return 'date'; return 'string'; } function parseHRNumber(s){ // Accept: "1234", "1.234,56", "1,234.56", "1 234", "12 kn", "€ 12,5", "0.45" var raw = String(s).replace(/[^\d,.\- ]/g, '').trim(); if (raw === '' || raw === '-') return null; // Detect decimal separator: if both . and , present, the rightmost is decimal var hasComma = raw.indexOf(',') >= 0, hasDot = raw.indexOf('.') >= 0; var cleaned; if (hasComma && hasDot){ if (raw.lastIndexOf(',') > raw.lastIndexOf('.')){ cleaned = raw.replace(/\./g, '').replace(/ /g, '').replace(',', '.'); } else { cleaned = raw.replace(/,/g, '').replace(/ /g, ''); } } else if (hasComma){ // could be 1234,56 or 1,234 — treat , as decimal if exactly one and 1-2 digits after var parts = raw.split(','); if (parts.length === 2 && parts[1].replace(/ /g,'').length <= 2){ cleaned = parts[0].replace(/ /g,'').replace(/\./g,'') + '.' + parts[1].replace(/ /g,''); } else { cleaned = raw.replace(/,/g, '').replace(/ /g, ''); } } else { cleaned = raw.replace(/ /g, ''); } var n = parseFloat(cleaned); return isFinite(n) ? n : null; } var DATE_ISO = /^\d{4}-\d{2}-\d{2}/; var DATE_HR = /^(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})/; function parseDate(s){ s = String(s).trim(); if (DATE_ISO.test(s)) return Date.parse(s.slice(0, 10)); var m = s.match(DATE_HR); if (m) return Date.parse(m[3] + '-' + ('0'+m[2]).slice(-2) + '-' + ('0'+m[1]).slice(-2)); return null; } function compare(type, a, b){ if (type === 'numeric'){ var na = parseHRNumber(a), nb = parseHRNumber(b); if (na === null && nb === null) return 0; if (na === null) return 1; if (nb === null) return -1; return na - nb; } if (type === 'date'){ var da = parseDate(a), db = parseDate(b); if (da == null && db == null) return 0; if (da == null) return 1; if (db == null) return -1; return da - db; } return COLLATOR ? COLLATOR.compare(a, b) : a.localeCompare(b); } function sampleColumn(rows, colIdx){ var out = []; for (var i = 0; i < rows.length; i++){ var c = rows[i].cells && rows[i].cells[colIdx]; if (c) out.push(txt(c)); } return out; } function attach(table){ if (table.__sortableAttached) return; if (table.hasAttribute('data-sort-skip')) return; // skip tables using legacy sortHeader() — they already have | var firstTh = table.querySelector('thead th, tr th'); if (!firstTh) return; var legacy = !!table.querySelector('thead th[onclick*="setSort"], thead th[onclick*="_sort"]'); if (legacy) return; var thead = table.tHead || (table.querySelector('tr') && table.querySelector('tr').parentNode); if (!thead) return; var headerRow = thead.querySelector('tr') || (table.rows[0] && table.rows[0].parentNode === thead ? table.rows[0] : null); if (!headerRow) return; var ths = headerRow.cells; if (!ths || ths.length === 0) return; // Snapshot original order so 3rd-click can revert var tbody = table.tBodies && table.tBodies[0]; if (!tbody) return; var originalRows = Array.prototype.slice.call(tbody.rows); if (originalRows.length < 2) return; // nothing to sort // State per table var state = { col: -1, dir: 0 }; // 0 = unsorted, 1 = asc, -1 = desc for (var i = 0; i < ths.length; i++){ (function(idx, th){ // Don't make non-data columns sortable (action icons etc.) if (th.hasAttribute('data-sort-skip')) return; var label = txt(th); if (label === '') return; // skip empty headers (icons) th.style.cursor = 'pointer'; th.style.userSelect = 'none'; th.title = 'Klikni za sort · ASC → DESC → reset'; // Append neutral arrow if no existing sort marker if (!/[↕▲▼]/.test(label)){ var span = document.createElement('span'); span.className = '__sort-ind'; span.style.opacity = '0.45'; span.textContent = ARROW_NEUTRAL; th.appendChild(span); } th.addEventListener('click', function(){ // Cycle: idx is current → asc; same idx asc → desc; same idx desc → reset if (state.col !== idx){ state.col = idx; state.dir = 1; } else if (state.dir === 1){ state.dir = -1; } else if (state.dir === -1){ state.col = -1; state.dir = 0; } else { state.dir = 1; } // Update arrow indicators on all ths for (var j = 0; j < ths.length; j++){ var ind = ths[j].querySelector('.__sort-ind'); if (!ind) continue; if (j === state.col){ ind.style.opacity = '1'; ind.textContent = state.dir === 1 ? ARROW_ASC : ARROW_DESC; } else { ind.style.opacity = '0.45'; ind.textContent = ARROW_NEUTRAL; } } if (state.dir === 0){ // Restore original order for (var k = 0; k < originalRows.length; k++) tbody.appendChild(originalRows[k]); return; } var rows = Array.prototype.slice.call(tbody.rows); var samples = sampleColumn(rows, idx); var type = detectType(samples); rows.sort(function(a, b){ var av = a.cells[idx] ? txt(a.cells[idx]) : ''; var bv = b.cells[idx] ? txt(b.cells[idx]) : ''; var c = compare(type, av, bv); return state.dir === 1 ? c : -c; }); for (var r = 0; r < rows.length; r++) tbody.appendChild(rows[r]); }); })(i, ths[i]); } table.__sortableAttached = true; } function attachAll(scope){ var root = scope || document; var tables = root.querySelectorAll ? root.querySelectorAll('table') : []; for (var i = 0; i < tables.length; i++) attach(tables[i]); } // Auto-init on DOMContentLoaded; also re-scan when DOM mutates (SPAs that // re-render tables in-place). function boot(){ attachAll(document); if (typeof MutationObserver !== 'undefined'){ var rescan = function(){ attachAll(document); }; var obs = new MutationObserver(function(muts){ for (var i = 0; i < muts.length; i++){ if (muts[i].addedNodes && muts[i].addedNodes.length){ // throttle — debounce 100ms if (boot._t) clearTimeout(boot._t); boot._t = setTimeout(rescan, 100); break; } } }); obs.observe(document.body, { childList: true, subtree: true }); } } if (document.readyState === 'loading'){ document.addEventListener('DOMContentLoaded', boot); } else { boot(); } // Public API for explicit re-scans from app code window.attachSortable = attachAll; })(); |
|---|