[object Object]

← back to Commercialrealestate

col-resize: add click-to-sort headers (asc/desc/reset, numeric-aware, blanks last)

d433ef8a235c86309e19fcf1e2acc718199c9741 · 2026-07-12 11:06:56 -0700 · Steve

Files touched

Diff

commit d433ef8a235c86309e19fcf1e2acc718199c9741
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jul 12 11:06:56 2026 -0700

    col-resize: add click-to-sort headers (asc/desc/reset, numeric-aware, blanks last)
---
 public/col-resize.js | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 91 insertions(+), 1 deletion(-)

diff --git a/public/col-resize.js b/public/col-resize.js
index 4cd3e22..66ba0c1 100644
--- a/public/col-resize.js
+++ b/public/col-resize.js
@@ -20,6 +20,7 @@
   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) {
@@ -102,6 +103,91 @@
       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) {
@@ -134,6 +220,7 @@
       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.
@@ -168,7 +255,10 @@
       + '.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;}';
+      + '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;

← dd4abf6 Add spreadsheet-style draggable column resizing (col-resize.  ·  back to Commercialrealestate  ·  CRCP email-alerts: fix George send (/api/send + body field) 035640a →