Files

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;
})();