← back to Commercialrealestate
public/col-resize.js
310 lines
/*!
* col-resize.js — spreadsheet-style draggable column resizing for any <table>.
* Zero dependencies. Drop-in: <script src="/col-resize.js" defer></script>
*
* Behaviour:
* • Hover a column's right edge → col-resize cursor → drag to widen/narrow.
* • Only the dragged column changes; the rest hold still (table-layout:fixed).
* • Double-click a handle → auto-fit that column to its content.
* • Widths persist in localStorage, keyed by page + table signature + column,
* so they survive reloads AND JS re-renders of the same table.
* • Auto-enhances tables added later (fetch-rendered grids) via MutationObserver.
*
* Opt out per table with <table data-no-resize>. Re-scan manually: ColResize.refresh().
*/
(function () {
'use strict';
if (window.__colResizeLoaded) return;
window.__colResizeLoaded = true;
var MIN = 40; // px, smallest a column may shrink to
var LS = 'cr:'; // localStorage key prefix
var HANDLE_W = 8; // px hit-area of the drag handle
var lastResizeUp = 0; // timestamp of the last resize drag end (sort ignores that click)
// ---- persistence ----------------------------------------------------------
function tableSig(headers) {
// Stable id from header text + count — survives re-render & column reorder.
var s = headers.map(function (h) { return (h.textContent || '').trim(); }).join('|') + '#' + headers.length;
var hash = 0;
for (var i = 0; i < s.length; i++) { hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0; }
return 't' + (hash >>> 0).toString(36);
}
function keyFor(sig, idx) { return LS + location.pathname + ':' + sig + ':' + idx; }
function save(sig, idx, w) { try { localStorage.setItem(keyFor(sig, idx), String(Math.round(w))); } catch (e) {} }
function load(sig, idx) { try { var v = localStorage.getItem(keyFor(sig, idx)); return v ? parseFloat(v) : null; } catch (e) { return null; } }
function clear(sig, idx) { try { localStorage.removeItem(keyFor(sig, idx)); } catch (e) {} }
// ---- header-cell detection ------------------------------------------------
function headerCells(table) {
var thead = table.tHead;
if (thead && thead.rows.length) {
var row = thead.rows[thead.rows.length - 1];
if (row && row.cells.length) return Array.prototype.slice.call(row.cells);
}
// No <thead> — treat the first row's cells as headers.
var first = table.rows[0];
return first ? Array.prototype.slice.call(first.cells) : [];
}
// ---- colgroup: one <col> per column ---------------------------------------
function ensureColgroup(table, n) {
var cg = table.querySelector('colgroup[data-cr]');
if (cg && cg.children.length === n) return cg;
if (cg) cg.remove();
cg = document.createElement('colgroup');
cg.setAttribute('data-cr', '1');
for (var i = 0; i < n; i++) cg.appendChild(document.createElement('col'));
table.insertBefore(cg, table.firstChild);
return cg;
}
// ---- wrap table in a horizontal-scroll container --------------------------
function ensureScroll(table) {
var p = table.parentElement;
if (p && p.classList.contains('cr-scroll')) return p;
var wrap = document.createElement('div');
wrap.className = 'cr-scroll';
p.insertBefore(wrap, table);
wrap.appendChild(table);
return wrap;
}
// ---- enhance one table ----------------------------------------------------
function enhance(table) {
if (table.dataset.crDone === '1' || table.hasAttribute('data-no-resize')) return;
var headers = headerCells(table);
if (headers.length < 2) return; // nothing meaningful to resize
table.dataset.crDone = '1';
var sig = tableSig(headers);
var cols = ensureColgroup(table, headers.length).children;
ensureScroll(table);
// Snapshot current widths → explicit px, THEN switch to fixed layout so the
// browser stops redistributing. Saved widths override the snapshot.
var measured = headers.map(function (h) { return Math.max(MIN, Math.round(h.getBoundingClientRect().width)); });
table.style.tableLayout = 'fixed';
table.style.width = 'auto';
for (var i = 0; i < headers.length; i++) {
var saved = load(sig, i);
cols[i].style.width = (saved != null ? Math.max(MIN, saved) : measured[i]) + 'px';
}
// Add a drag handle to each header cell.
headers.forEach(function (th, idx) {
var cs = getComputedStyle(th);
if (cs.position === 'static') th.style.position = 'relative';
var handle = document.createElement('span');
handle.className = 'cr-handle';
handle.setAttribute('role', 'separator');
handle.setAttribute('aria-orientation', 'vertical');
handle.title = 'Drag to resize • double-click to auto-fit';
th.appendChild(handle);
wireHandle(handle, table, cols, headers, sig, idx);
});
addSorting(table, headers);
}
// ---- click-a-header-to-sort (asc → desc → original) -----------------------
function addSorting(table, headers) {
if (table.hasAttribute('data-no-sort')) return;
var headerRow = headers[0].parentNode;
headers.forEach(function (th, idx) {
if (th.querySelector('.cr-sort')) return; // already wired
th.classList.add('cr-sortable');
var ind = document.createElement('span');
ind.className = 'cr-sort';
th.appendChild(ind);
th.addEventListener('click', function (e) {
if (e.target.closest && e.target.closest('.cr-handle')) return; // it was a resize grab
if (Date.now() - lastResizeUp < 300) return; // a drag just ended
var dir = th.dataset.crSort === 'asc' ? 'desc' : th.dataset.crSort === 'desc' ? 'none' : 'asc';
sortBy(table, headers, headerRow, idx, dir);
headers.forEach(function (o) { o.dataset.crSort = ''; o.querySelector('.cr-sort').textContent = ''; });
th.dataset.crSort = dir;
th.querySelector('.cr-sort').textContent = dir === 'asc' ? ' ▲' : dir === 'desc' ? ' ▼' : '';
});
});
}
function bodyRows(table, headerRow) {
var rows = [];
for (var b = 0; b < table.tBodies.length; b++) {
var tb = table.tBodies[b];
for (var i = 0; i < tb.rows.length; i++) if (tb.rows[i] !== headerRow) rows.push(tb.rows[i]);
}
return rows;
}
function cellText(row, idx) {
var c = row.cells[idx];
return c ? (c.textContent || '').trim() : '';
}
// Parse "$1,234.56", "45%", "-12" → number; non-numeric → NaN.
function num(s) {
if (!s) return NaN;
var n = parseFloat(s.replace(/[^0-9.\-]/g, ''));
return isNaN(n) ? NaN : n;
}
function isNumericColumn(rows, idx) {
var seen = 0;
for (var i = 0; i < rows.length && seen < 30; i++) {
var t = cellText(rows[i], idx);
if (!t) continue;
seen++;
if (isNaN(num(t))) return false;
}
return seen > 0;
}
function sortBy(table, headers, headerRow, idx, dir) {
var rows = bodyRows(table, headerRow);
if (!rows.length) return;
if (!table.__crOrig) table.__crOrig = rows.slice(); // remember natural order once
if (dir === 'none') {
var order = table.__crOrig.filter(function (r) { return r.parentNode; });
var dest0 = rows[0].parentNode;
order.forEach(function (r) { dest0.appendChild(r); });
return;
}
var numeric = isNumericColumn(rows, idx);
var mul = dir === 'asc' ? 1 : -1;
// Decorate → sort → undecorate keeps blanks last and the sort stable.
rows.map(function (r, i) { return { r: r, i: i, t: cellText(r, idx) }; })
.sort(function (a, b) {
var ae = a.t === '', be = b.t === '';
if (ae && be) return a.i - b.i;
if (ae) return 1; // blanks always sink
if (be) return -1;
var cmp;
if (numeric) cmp = num(a.t) - num(b.t);
else cmp = a.t.localeCompare(b.t, undefined, { numeric: true, sensitivity: 'base' });
return cmp !== 0 ? cmp * mul : a.i - b.i; // stable tiebreak
})
.forEach(function (o) { o.r.parentNode.appendChild(o.r); });
}
function wireHandle(handle, table, cols, headers, sig, idx) {
var startX = 0, startW = 0, dragging = false;
function down(e) {
dragging = true;
startX = (e.touches ? e.touches[0].clientX : e.clientX);
startW = cols[idx].getBoundingClientRect().width;
document.body.classList.add('cr-dragging');
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
window.addEventListener('touchmove', move, { passive: false });
window.addEventListener('touchend', up);
e.preventDefault();
e.stopPropagation();
}
function move(e) {
if (!dragging) return;
var x = (e.touches ? e.touches[0].clientX : e.clientX);
var w = Math.max(MIN, startW + (x - startX));
cols[idx].style.width = w + 'px';
if (e.cancelable) e.preventDefault();
}
function up() {
if (!dragging) return;
dragging = false;
document.body.classList.remove('cr-dragging');
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
window.removeEventListener('touchmove', move);
window.removeEventListener('touchend', up);
lastResizeUp = Date.now();
save(sig, idx, cols[idx].getBoundingClientRect().width);
}
// Double-click → auto-fit this column to its content.
function autofit(e) {
e.preventDefault(); e.stopPropagation();
clear(sig, idx);
// Remember every column's current width, let this one go auto, measure, restore.
var widths = [];
for (var i = 0; i < cols.length; i++) widths[i] = cols[i].style.width;
table.style.tableLayout = 'auto';
cols[idx].style.width = '';
var natural = Math.max(MIN, Math.round(headers[idx].getBoundingClientRect().width));
for (var j = 0; j < cols.length; j++) cols[j].style.width = widths[j];
cols[idx].style.width = natural + 'px';
table.style.tableLayout = 'fixed';
save(sig, idx, natural);
}
handle.addEventListener('pointerdown', down);
handle.addEventListener('dblclick', autofit);
// Fallback for browsers without Pointer Events.
if (!window.PointerEvent) handle.addEventListener('touchstart', down, { passive: false });
}
// ---- inject the small stylesheet once -------------------------------------
function injectCSS() {
if (document.getElementById('cr-style')) return;
var css = ''
+ '.cr-scroll{overflow-x:auto;max-width:100%;}'
+ '.cr-handle{position:absolute;top:0;right:-' + (HANDLE_W / 2) + 'px;width:' + HANDLE_W + 'px;height:100%;'
+ 'cursor:col-resize;user-select:none;touch-action:none;z-index:5;}'
+ '.cr-handle:hover::after,.cr-dragging .cr-handle::after{content:"";position:absolute;top:0;left:50%;'
+ 'width:2px;height:100%;transform:translateX(-50%);background:currentColor;opacity:.55;}'
+ 'body.cr-dragging{cursor:col-resize!important;user-select:none!important;}'
+ 'body.cr-dragging *{cursor:col-resize!important;}'
+ 'th.cr-sortable{cursor:pointer;}'
+ 'th.cr-sortable:hover{background:rgba(127,127,127,.12);}'
+ '.cr-sort{font-size:.85em;opacity:.8;white-space:nowrap;}';
var s = document.createElement('style');
s.id = 'cr-style';
s.textContent = css;
document.head.appendChild(s);
}
// ---- scan + observe -------------------------------------------------------
function scan(root) {
var tables = (root || document).querySelectorAll('table');
for (var i = 0; i < tables.length; i++) {
try { enhance(tables[i]); } catch (e) { /* never let one bad table break the page */ }
}
}
var pending = false;
function scheduleScan() {
if (pending) return;
pending = true;
(window.requestAnimationFrame || setTimeout)(function () { pending = false; scan(document); });
}
function start() {
injectCSS();
scan(document);
if (window.MutationObserver) {
new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
if (muts[i].addedNodes && muts[i].addedNodes.length) { scheduleScan(); break; }
}
}).observe(document.body, { childList: true, subtree: true });
}
}
window.ColResize = {
refresh: function () { scan(document); },
reset: function () { // wipe all saved widths for this page
try {
Object.keys(localStorage).forEach(function (k) {
if (k.indexOf(LS + location.pathname + ':') === 0) localStorage.removeItem(k);
});
} catch (e) {}
location.reload();
}
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
})();