← 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
A public/column-manager.js
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, '&').replace(/</g, '<').replace(/"/g, '"');
+ };
+
+ 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 →