← 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, '&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;

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