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