← back to Commercialrealestate
public/column-manager.js
231 lines
/*!
* 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;
// EXTERNAL-STATE MODE: pass opts.state to make this panel a pure VIEW over a host
// page that already owns column visibility/order (e.g. mls.html's VISCOL/COLORDER).
// Hooks: visible(k)->bool · setVisible(k,v) · order()->[keys] · setOrder([keys]) · reset().
// Omit opts.state and the panel owns its own localStorage-backed state.
var ext = opts.state || null;
var VIS = ext ? null : ls(NS + ':vis', {}); // internal-mode only
var ORDER = ext ? null : ls(NS + ':order', []);
function colVis(k) { return ext ? !!ext.visible(k) : ((k in VIS) ? !!VIS[k] : DEFAULT_ON.has(k)); }
function setColVis(k, v) { if (ext) { ext.setVisible(k, v); } else { VIS[k] = v; lsSet(NS + ':vis', VIS); } }
function doReset() { if (ext) { ext.reset(); } else { VIS = {}; ORDER = []; lsSet(NS + ':vis', VIS); lsSet(NS + ':order', ORDER); } }
function getOrder() {
if (ext) return ext.order().slice();
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
return ORDER.slice();
}
function setOrder(arr) { if (ext) { ext.setOrder(arr.slice()); } else { ORDER = arr.slice(); lsSet(NS + ':order', ORDER); } }
function orderedCols() { return getOrder().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() {
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>';
if (opts.footer !== false) {
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>';
}
if (opts.hint !== false) 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;
setColVis(cb.dataset.ck, cb.checked); onChange(visKeys(), getOrder());
});
// 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) { setColVis(c.k, true); }); build(); }
else if (act === 'none') { COLS.forEach(function (c) { setColVis(c.k, false); }); build(); }
else if (act === 'reset') { doReset(); build(); }
onChange(visKeys(), getOrder());
});
// 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) {
var ord = getOrder(); var from = ord.indexOf(dragK);
if (from >= 0) {
var r = row.getBoundingClientRect(), after = (e.clientY - r.top) > r.height / 2;
ord.splice(from, 1); var to = ord.indexOf(tgt); if (after) to += 1; ord.splice(to, 0, dragK);
setOrder(ord); build(); onChange(visKeys(), getOrder());
}
}
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 () { return getOrder(); },
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 };
})();