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