← back to Commercialrealestate
public/col-resize.js
398 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;
}
// ---- keep the table's own width == sum of column widths -------------------
// CRITICAL for table-layout:fixed: with width:auto the browser shrink-to-fits
// the table to its container and REDISTRIBUTES column widths, silently ignoring
// any <col> width we set (so resizing appears to do nothing). Pinning the table
// to the exact sum of the column widths makes the <col> widths authoritative and
// lets the table grow past its container into the overflow-x:auto scroll wrapper.
function syncTableWidth(table, cols) {
if (table.getBoundingClientRect().width === 0) return; // hidden — never pin garbage widths
var sum = 0;
for (var i = 0; i < cols.length; i++) sum += parseFloat(cols[i].style.width) || 0;
if (sum > 0) table.style.width = Math.round(sum) + 'px';
}
// ---- 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
// Don't enhance a HIDDEN table: its cells measure 0 → clamp to MIN, and
// syncTableWidth would then pin the table to MIN×ncols (e.g. 240px), so it
// renders as a broken sliver when later shown. Leave it untouched (renders
// fine at its natural CSS width); it enhances once a scan sees it visible.
if (table.getBoundingClientRect().width === 0) return;
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';
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';
}
syncTableWidth(table, cols); // definite width so fixed-layout honors the <col> widths
// Add a drag handle to each header cell.
attachHandles(table, headers, cols, sig);
addSorting(table, headers);
}
// ---- (re)attach a drag handle to any header cell missing one ---------------
// Idempotent: an existing .cr-handle short-circuits, so this is safe to call
// repeatedly. Used both by enhance() and by reattach() after a host-app
// <thead> rebuild wipes the handle spans.
function attachHandles(table, headers, cols, sig) {
headers.forEach(function (th, idx) {
if (th.querySelector('.cr-handle')) return;
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);
guardDraggableAncestor(handle, th);
wireHandle(handle, table, cols, headers, sig, idx);
});
}
// ---- coexist with native column-reorder (draggable <th>) ------------------
// If a header cell (or an ancestor) is draggable="true" for HTML5 drag-reorder,
// a real mousedown on the resize handle starts a NATIVE drag, which suppresses
// the pointermove stream the resizer needs — so the edge won't resize. The drag
// decision is made at mousedown, so we must clear draggable BEFORE the press:
// disable it whenever the pointer is over the handle, restore it on leave.
function guardDraggableAncestor(handle, th) {
var anc = (th.closest && th.closest('[draggable="true"]')) || null;
if (!anc) return;
var restore = function () { anc.draggable = true; };
handle.addEventListener('mouseenter', function () { anc.draggable = false; });
handle.addEventListener('mouseleave', restore);
// Belt-and-suspenders for pointer paths that skip mouseenter (pen/touch):
handle.addEventListener('pointerdown', function () { anc.draggable = false; });
window.addEventListener('pointerup', restore);
}
// ---- reattach after the host app rebuilds <thead> innerHTML ----------------
// A single-page app that re-renders its own <thead> (sort / search / filter)
// destroys the handle spans but leaves the <colgroup> — so widths survive but
// resizing dies. This re-wires the handles onto the fresh <th>s WITHOUT
// touching the colgroup, preserving custom widths. Falls back to a full
// enhance() when the table was never enhanced or its column COUNT changed.
function reattach(table) {
if (!table) return;
var cg = table.querySelector('colgroup[data-cr]');
if (!cg) { enhance(table); return; } // never enhanced → full path
var headers = headerCells(table);
if (!headers.length || headers.length !== cg.children.length) {
cg.remove(); // column set changed → rebuild
delete table.dataset.crDone;
table.style.tableLayout = '';
table.style.width = '';
enhance(table);
return;
}
table.dataset.crDone = '1';
attachHandles(table, headers, cg.children, tableSig(headers));
syncTableWidth(table, cg.children); // re-assert width in case a re-render cleared it
}
// ---- 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);
// Read the width we SET, not <col>.getBoundingClientRect() — that rect is
// unreliable for a <col> (Chromium returns stale/tiny values, same quirk as
// TK-10089). A bad startW + a zero-delta move (e.g. the mousedowns inside a
// double-click) would otherwise collapse the column to MIN (40px).
startW = parseFloat(cols[idx].style.width) || 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';
syncTableWidth(table, cols); // grow/shrink the table with the dragged column
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();
// Persist the width move() actually applied. A <col>'s getBoundingClientRect()
// does NOT reliably reflect a just-set style.width (Chromium returns a stale value),
// so saving that reverted resized columns to their old width on reload. Read the
// style we set; fall back to the header cell's rendered width (TK-10089).
var savedW = parseFloat(cols[idx].style.width) || headers[idx].getBoundingClientRect().width;
save(sig, idx, savedW);
}
// 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;
var savedTableW = table.style.width;
table.style.tableLayout = 'auto';
table.style.width = ''; // release the pinned width so the column can size to content
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';
table.style.width = savedTableW;
syncTableWidth(table, cols);
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%;}'
// Handle sits INSIDE the column's right edge (right:0), not straddling it.
// Straddling (right:-N) pushes half the handle over the NEXT sticky <th>,
// which then paints on top (sibling stacking context) and steals the click —
// breaking resize entirely on draggable-column tables.
+ '.cr-handle{position:absolute;top:0;right:0;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); },
reattach: reattach, // re-wire handles after a host-app thead rebuild
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();
})();