232 lines
8.4 KiB
JavaScript
232 lines
8.4 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════════
|
|
// shared/sortable.js | v1.0.0 | 2026-05-10 | task N5
|
|
// Auto-attaches click-to-sort to every <table> with a <thead><tr><th>.
|
|
//
|
|
// Usage: <script src="/static/shared/sortable.js" defer></script>
|
|
// Skips:
|
|
// • tables with [data-sort-skip] attribute
|
|
// • tables already using the legacy sportHeader() pattern (those have
|
|
// sort handlers wired in their <th> already; we detect them and bail)
|
|
//
|
|
// Click behavior per <th>:
|
|
// 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 <th onclick=...>
|
|
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;
|
|
})();
|