[object Object]

← back to Commercialrealestate

Add reusable column-manager.js drop-in (DTD verdict A — docked rail, TK-10120)

6710021c4a5fa0a3b07c29297df1c5857cffebfe · 2026-08-01 21:56:21 -0700 · Steve Abrams

Packages the docked-rail Column Manager (show/hide + drag-reorder + density +
category groups + search + All/None/Reset, localStorage-persisted) that
broker-grid pioneered inline, into one zero-dependency drop-in so the other ~19
grid pages get the full manager with a single <script> tag instead of copy-paste.
Pairs with col-resize.js (this owns visibility/order/density; that owns widths).

Vanilla, not React — the grid pages are static HTML/JS with no build, so a
shadcn/React component (the 21st exploration) can't drop in; the docked-rail
LAYOUT the panel picked is what this implements in the stack the pages use.

Verified (Playwright): renders grouped list + density + search + drag handles;
visibility toggle fires onChange; drag-reorder reorders; reset restores default;
0 console errors. Not yet wired into pages — reference/adoption is a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 6710021c4a5fa0a3b07c29297df1c5857cffebfe
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 21:56:21 2026 -0700

    Add reusable column-manager.js drop-in (DTD verdict A — docked rail, TK-10120)
    
    Packages the docked-rail Column Manager (show/hide + drag-reorder + density +
    category groups + search + All/None/Reset, localStorage-persisted) that
    broker-grid pioneered inline, into one zero-dependency drop-in so the other ~19
    grid pages get the full manager with a single <script> tag instead of copy-paste.
    Pairs with col-resize.js (this owns visibility/order/density; that owns widths).
    
    Vanilla, not React — the grid pages are static HTML/JS with no build, so a
    shadcn/React component (the 21st exploration) can't drop in; the docked-rail
    LAYOUT the panel picked is what this implements in the stack the pages use.
    
    Verified (Playwright): renders grouped list + density + search + drag handles;
    visibility toggle fires onChange; drag-reorder reorders; reset restores default;
    0 console errors. Not yet wired into pages — reference/adoption is a follow-up.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/column-manager.js | 220 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 220 insertions(+)

diff --git a/public/column-manager.js b/public/column-manager.js
new file mode 100644
index 0000000..bb7f6e6
--- /dev/null
+++ b/public/column-manager.js
@@ -0,0 +1,220 @@
+/*!
+ * column-manager.js — reusable "Column Manager" rail panel for any data grid.
+ * Zero dependencies. Drop-in: <script src="/column-manager.js" defer></script>
+ *
+ * The docked-rail column control (DTD verdict A, TK-10120): show/hide columns,
+ * drag-to-reorder, density slider, category groups, search, All/None/Reset — the
+ * inline logic broker-grid pioneered, packaged so the other grid pages get the
+ * full manager with one script tag instead of copy-paste.
+ *
+ * Pairs with col-resize.js: this owns column VISIBILITY + ORDER + DENSITY;
+ * col-resize.js owns column WIDTHS. Both persist to localStorage per page.
+ *
+ * Usage:
+ *   const cm = ColumnManager.init({
+ *     mount: '#colman',                 // container element (in your left rail)
+ *     storageKey: 'brokerCols',         // localStorage namespace for this grid
+ *     cols: [ {k:'name', l:'Name', g:'Identity'},
+ *             {k:'firm', l:'Company / Firm', g:'Identity'}, ... ],
+ *     defaultOn: ['name','firm','state'],   // tight default visible set
+ *     density: { min:1, max:10, def:5 },    // optional density slider
+ *     onChange: (visibleKeys, orderedKeys) => renderGrid(),  // re-render your grid
+ *     onDensity: (v) => applyDensity(v),
+ *   });
+ *   cm.visibleCols();  // -> ordered array of visible column objects
+ */
+(function () {
+  'use strict';
+  if (window.ColumnManager) return;
+
+  var esc = function (s) {
+    return (s == null ? '' : String(s))
+      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
+  };
+
+  function ls(key, fallback) {
+    try { var v = JSON.parse(localStorage.getItem(key)); return v == null ? fallback : v; }
+    catch (e) { return fallback; }
+  }
+  function lsSet(key, val) { try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {} }
+
+  injectCSS();
+
+  function init(opts) {
+    var mount = typeof opts.mount === 'string' ? document.querySelector(opts.mount) : opts.mount;
+    if (!mount) { console.warn('[column-manager] mount not found:', opts.mount); return null; }
+
+    var COLS = opts.cols || [];
+    var NS = opts.storageKey || 'colman';
+    var DEFAULT_ON = new Set(opts.defaultOn || COLS.map(function (c) { return c.k; }));
+    var onChange = opts.onChange || function () {};
+    var onDensity = opts.onDensity || null;
+    var dens = opts.density || null;
+
+    var VIS = ls(NS + ':vis', {});                 // {key: bool} overrides
+    var ORDER = ls(NS + ':order', []);             // ordered keys (persisted)
+
+    function colVis(k) { return (k in VIS) ? !!VIS[k] : DEFAULT_ON.has(k); }
+    function saveVis() { lsSet(NS + ':vis', VIS); }
+    function saveOrder() { lsSet(NS + ':order', ORDER); }
+
+    function syncOrder() {
+      var keys = COLS.map(function (c) { return c.k; });
+      ORDER = ORDER.filter(function (k) { return keys.indexOf(k) >= 0; });          // prune removed
+      keys.forEach(function (k) { if (ORDER.indexOf(k) < 0) ORDER.push(k); });       // append new
+    }
+    function orderedCols() { syncOrder(); return ORDER.map(function (k) { return COLS.find(function (c) { return c.k === k; }); }).filter(Boolean); }
+    function visibleCols() { return orderedCols().filter(function (c) { return colVis(c.k); }); }
+    function visKeys() { return visibleCols().map(function (c) { return c.k; }); }
+
+    // ---- density ----
+    var densVal = dens ? clampDensity(ls(NS + ':density', dens.def)) : null;
+    function clampDensity(v) { v = parseInt(v, 10); if (isNaN(v)) v = dens.def; return Math.max(dens.min, Math.min(dens.max, v)); }
+
+    // ---- render the panel ----
+    function build() {
+      syncOrder();
+      var groups = {};
+      orderedCols().forEach(function (c) { (groups[c.g || 'Other'] = groups[c.g || 'Other'] || []).push(c); });
+
+      var html = '<div class="cm">';
+      if (dens) {
+        html += '<div class="cm-dens"><span class="cm-dens-ic">⤢</span>' +
+          '<input type="range" class="cm-dens-range" min="' + dens.min + '" max="' + dens.max + '" step="1" value="' + densVal + '"></div>';
+      }
+      html += '<input type="text" class="cm-search" placeholder="search columns…">';
+      html += '<div class="cm-list">';
+      Object.keys(groups).forEach(function (g) {
+        html += '<div class="cm-grp">' + esc(g) + '</div>';
+        groups[g].forEach(function (c) {
+          html += '<label class="cm-row" draggable="true" data-k="' + esc(c.k) + '">' +
+            '<input type="checkbox" data-ck="' + esc(c.k) + '"' + (colVis(c.k) ? ' checked' : '') + '>' +
+            '<span class="cm-lbl">' + esc(c.l) + '</span>' +
+            '<span class="cm-drag" title="Drag to reorder">⠿</span></label>';
+        });
+      });
+      html += '</div>';
+      html += '<div class="cm-foot">' +
+        '<button class="cm-btn" data-act="all">All</button>' +
+        '<button class="cm-btn" data-act="none">None</button>' +
+        '<button class="cm-btn" data-act="reset" title="Reset columns to the tight default">↺ Reset</button></div>';
+      html += '<div class="cm-hint">Drag a column header to reorder · drag its right edge to resize · click to sort</div>';
+      html += '</div>';
+      mount.innerHTML = html;
+      wire();
+    }
+
+    function wire() {
+      // visibility toggles
+      mount.addEventListener('change', function (e) {
+        var cb = e.target.closest && e.target.closest('input[data-ck]');
+        if (!cb) return;
+        VIS[cb.dataset.ck] = cb.checked; saveVis(); onChange(visKeys(), ORDER.slice());
+      });
+      // footer buttons
+      mount.addEventListener('click', function (e) {
+        var b = e.target.closest && e.target.closest('.cm-btn'); if (!b) return;
+        var act = b.dataset.act;
+        if (act === 'all') { COLS.forEach(function (c) { VIS[c.k] = true; }); saveVis(); build(); }
+        else if (act === 'none') { COLS.forEach(function (c) { VIS[c.k] = false; }); saveVis(); build(); }
+        else if (act === 'reset') { VIS = {}; ORDER = []; saveVis(); saveOrder(); build(); }
+        onChange(visKeys(), ORDER.slice());
+      });
+      // search filter
+      var q = mount.querySelector('.cm-search');
+      if (q) q.addEventListener('input', function () {
+        var t = q.value.trim().toLowerCase();
+        mount.querySelectorAll('.cm-row').forEach(function (row) {
+          var lbl = row.querySelector('.cm-lbl').textContent.toLowerCase();
+          row.style.display = (!t || lbl.indexOf(t) >= 0) ? '' : 'none';
+        });
+        // hide a group header whose rows are all filtered out
+        mount.querySelectorAll('.cm-grp').forEach(function (h) {
+          var vis = false, n = h.nextElementSibling;
+          while (n && n.classList.contains('cm-row')) { if (n.style.display !== 'none') vis = true; n = n.nextElementSibling; }
+          h.style.display = vis ? '' : 'none';
+        });
+      });
+      // density
+      if (dens) {
+        var dz = mount.querySelector('.cm-dens-range');
+        if (dz) dz.addEventListener('input', function () {
+          densVal = clampDensity(dz.value); lsSet(NS + ':density', densVal); if (onDensity) onDensity(densVal);
+        });
+      }
+      wireDrag();
+    }
+
+    // ---- drag-to-reorder (HTML5 DnD, within the list) ----
+    var dragK = null;
+    function wireDrag() {
+      var list = mount.querySelector('.cm-list'); if (!list) return;
+      list.addEventListener('dragstart', function (e) {
+        var row = e.target.closest('.cm-row'); if (!row) return;
+        dragK = row.dataset.k; row.classList.add('cm-dragging');
+        try { e.dataTransfer.setData('text/plain', dragK); } catch (_) {}
+        e.dataTransfer.effectAllowed = 'move';
+      });
+      list.addEventListener('dragover', function (e) {
+        if (!dragK) return; var row = e.target.closest('.cm-row'); if (!row || row.dataset.k === dragK) return;
+        e.preventDefault();
+        var r = row.getBoundingClientRect(), after = (e.clientY - r.top) > r.height / 2;
+        mount.querySelectorAll('.cm-row').forEach(function (x) { x.classList.remove('cm-dropB', 'cm-dropA'); });
+        row.classList.toggle('cm-dropA', after); row.classList.toggle('cm-dropB', !after);
+      });
+      list.addEventListener('drop', function (e) {
+        if (!dragK) return; var row = e.target.closest('.cm-row'); if (!row) return; e.preventDefault();
+        var tgt = row.dataset.k;
+        if (tgt !== dragK) {
+          syncOrder(); var from = ORDER.indexOf(dragK);
+          if (from >= 0) {
+            var r = row.getBoundingClientRect(), after = (e.clientY - r.top) > r.height / 2;
+            ORDER.splice(from, 1); var to = ORDER.indexOf(tgt); if (after) to += 1; ORDER.splice(to, 0, dragK);
+            saveOrder(); build(); onChange(visKeys(), ORDER.slice());
+          }
+        }
+        dragK = null;
+      });
+      list.addEventListener('dragend', function () {
+        mount.querySelectorAll('.cm-dragging,.cm-dropA,.cm-dropB').forEach(function (x) { x.classList.remove('cm-dragging', 'cm-dropA', 'cm-dropB'); });
+        dragK = null;
+      });
+    }
+
+    build();
+    if (dens && onDensity) onDensity(densVal);   // apply persisted density on init
+
+    return {
+      visibleCols: visibleCols,
+      order: function () { syncOrder(); return ORDER.slice(); },
+      density: function () { return densVal; },
+      refresh: build,                    // call after COLS changes (auto-discovered columns)
+      setCols: function (next) { COLS = next || []; build(); }
+    };
+  }
+
+  function injectCSS() {
+    if (document.getElementById('cm-style')) return;
+    var css = ''
+      + '.cm{--cm-gold:#C8A24B;--cm-acc:#70AD47;--cm-ink:#E6E8EC;--cm-mut:#8A93A2;--cm-card:#141925;--cm-line:#232a38;font-size:12.5px;color:var(--cm-ink);}'
+      + '.cm-dens{display:flex;align-items:center;gap:6px;color:var(--cm-mut);margin-bottom:8px;}'
+      + '.cm-dens-ic{font-size:14px;} .cm-dens-range{flex:1;accent-color:var(--cm-acc);}'
+      + '.cm-search{width:100%;box-sizing:border-box;background:var(--cm-card);border:1px solid var(--cm-line);color:var(--cm-ink);border-radius:8px;padding:6px 10px;font-size:12.5px;margin-bottom:8px;}'
+      + '.cm-list{display:flex;flex-direction:column;gap:1px;max-height:340px;overflow-y:auto;}'
+      + '.cm-grp{font-size:9.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--cm-gold);font-weight:700;opacity:.85;margin:9px 0 3px;}'
+      + '.cm-grp:first-child{margin-top:0;}'
+      + '.cm-row{display:flex;align-items:center;gap:8px;padding:3px 4px;border-radius:6px;cursor:default;user-select:none;}'
+      + '.cm-row:hover{background:rgba(200,162,75,.08);}'
+      + '.cm-row input{accent-color:var(--cm-gold);flex:0 0 auto;}'
+      + '.cm-lbl{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}'
+      + '.cm-drag{color:var(--cm-mut);cursor:grab;flex:0 0 auto;font-size:13px;}'
+      + '.cm-row.cm-dragging{opacity:.45;} .cm-row.cm-dropA{box-shadow:inset 0 -2px 0 var(--cm-gold);} .cm-row.cm-dropB{box-shadow:inset 0 2px 0 var(--cm-gold);}'
+      + '.cm-foot{display:flex;gap:5px;margin-top:8px;}'
+      + '.cm-btn{background:var(--cm-card);color:var(--cm-mut);border:1px solid var(--cm-line);border-radius:7px;padding:4px 9px;font-size:11px;cursor:pointer;}'
+      + '.cm-btn:hover{border-color:var(--cm-gold);color:var(--cm-ink);}'
+      + '.cm-hint{color:var(--cm-mut);font-size:10.5px;margin-top:7px;line-height:1.35;}';
+    var s = document.createElement('style'); s.id = 'cm-style'; s.textContent = css; document.head.appendChild(s);
+  }
+
+  window.ColumnManager = { init: init };
+})();

← ab38858 auto-save: 2026-08-01T21:09:29 (1 files) — package-lock.json  ·  back to Commercialrealestate  ·  column-manager: add external-state + footer/hint opts; adopt 3b9663c →