← back to Commercialrealestate

public/three-view.js

236 lines

/* three-view.js — additive 3-view + all-column-sort helper for the CRCP bespoke
 * browse pages (condos, broker-grid, mls, fha-loans). TK-10526, DTD verdict B
 * (additive enhancement — DON'T rip-and-replace the bespoke pages' notes / modal /
 * drill / find-email / outreach features, just ADD the missing view modes + a
 * guaranteed all-field sort menu).
 *
 * A bespoke page already renders cards ("grid") and/or a dense spreadsheet ("table").
 * What it usually lacks is (1) a distinct COMPACT LIST view — one scannable row per
 * record, every visible field as inline key/value pairs — and (2) a single topbar
 * Sort <select> covering EVERY field. This helper owns those two, plus the
 * Grid / List / Table segmented toggle, and delegates grid/table rendering back to
 * the page. Zero dependencies. Theme-agnostic (reads the page's own CSS with
 * .tv-* fallbacks in three-view.css).
 *
 * ThreeView.mount({
 *   seg:      '#viewseg',            // container the segmented [Grid|List|Table] buttons render into
 *   sortSel:  '#tvSort',             // OPTIONAL <select> el/selector to populate with an all-field sort menu
 *   listMount:'#tvList',            // container the compact-list DOM renders into (helper owns it)
 *   gridMount:'#gridView',          // page's existing cards container (helper shows/hides)
 *   tableMount:'#tableView',        // page's existing table container (helper shows/hides)
 *   fields:   [{k,l,calc,money,num,pct,date,badge,html}],  // field metadata; html:true -> value is raw HTML
 *   getRows:  () => rows,           // CURRENT filtered rows (in the order the page wants for grid/table)
 *   title:    row => ({name,sub,href}),  // compact-list row title + subline
 *   badgeHtml:row => '<span>…'|'',   // optional status badge for the list row
 *   fvis:     k => bool,            // is field k currently visible (mirrors the page's column toggles)
 *   renderGrid: rows => {},          // page renders its cards into gridMount
 *   renderTable:rows => {},          // page renders its table into tableMount
 *   onView:   view => {},           // OPTIONAL notified after a view switch
 *   onSort:   (k,dir) => {},        // called when the all-field Sort select changes (dir 1|-1, k='' = default)
 *   rowClick: (row,el) => {},        // OPTIONAL compact-list row click handler
 *   storageKey:'condo',            // localStorage namespace (View + Sort persisted)
 *   defaultView:'grid',
 *   views:    ['grid','list','table'],  // which segments to show (default all three)
 * })
 * -> returns { render(), setView(v), getView() }.
 */
(function (global) {
  const $ = (s, r) => (r || document).querySelector(s);
  const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');

  function fmt(v, f) {
    if (f.html) return v == null ? '—' : String(v);            // page-formatted HTML passes through
    if (v == null || v === '') return '—';
    if (f.money) return '$' + (+v).toLocaleString() + (f.suf || '');
    if (f.pct)   return (+v).toFixed(2) + '%';
    if (f.num)   return (+v).toLocaleString() + (f.suf || '');
    if (f.date)  { try { return new Date(v).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } catch (e) { return '—'; } }
    return esc(v) + (f.suf || '');
  }

  function ThreeView(O) {
    const KEY = O.storageKey || 'tv';
    const VIEWS = O.views || ['grid', 'list', 'table'];
    const LABELS = { grid: '▦ Grid', list: '☰ List', table: '▤ Table' };
    const fval = (r, f) => f.calc ? f.calc(r) : r[f.k];
    const fvis = O.fvis || (() => true);

    let VIEW = (function () {
      let s = null; try { s = localStorage.getItem(KEY + 'View'); } catch (e) {}
      if (VIEWS.includes(s)) return s;
      return VIEWS.includes(O.defaultView) ? O.defaultView : VIEWS[0];
    })();
    let SORT = null; // {k, dir}
    try { const s = JSON.parse(localStorage.getItem(KEY + 'Sort') || 'null'); if (s && typeof s.k === 'string') SORT = s; } catch (e) {}

    // ---- infinite scroll (windowed reveal) ----------------------------------
    // Instead of dumping the whole filtered set (which froze heavy pages), reveal
    // BATCH rows at a time and load more as a sentinel scrolls into view. All three
    // views (grid/list/table) share one `shown` window; it resets to BATCH whenever
    // the page re-filters (public render()), re-sorts, or switches view. Degrades to
    // "show everything" where IntersectionObserver is unavailable.
    const BATCH = O.batch || 120;
    const IO_OK = typeof IntersectionObserver !== 'undefined';
    let shown = BATCH;      // how many rows are currently revealed
    let LAST_ROWS = [];     // the full sorted set from the last paint (for grow())
    let sentinel = null, io = null, growing = false;

    function ensureSentinel() {
      if (!IO_OK) return null;
      if (!sentinel) {
        sentinel = document.createElement('div');
        sentinel.className = 'tv-sentinel';
        sentinel.setAttribute('aria-hidden', 'true');
        sentinel.style.cssText = 'height:1px;width:100%;';
      }
      return sentinel;
    }
    // The element that actually scrolls (the mount or an ancestor with real overflow), or null
    // when the WINDOW scrolls. Inner-scroll panes (e.g. residential-brokers' #tableView with
    // max-height+overflow:auto) must root the observer to the pane and hold the sentinel INSIDE
    // it, or window-scroll would never reach a sentinel parked after the pane.
    function findScroller(el) {
      let n = el;
      while (n && n !== document.body && n !== document.documentElement) {
        const cs = getComputedStyle(n);
        if (/(auto|scroll)/.test(cs.overflowY) && n.scrollHeight > n.clientHeight + 4) return n;
        n = n.parentElement;
      }
      return null;
    }
    let curRoot = undefined;
    // Park the sentinel at the bottom of the revealed content so the observer fires as it nears view.
    function positionSentinel(hasMore) {
      const s = ensureSentinel(); if (!s) return;
      if (!hasMore) { if (s.parentNode) s.parentNode.removeChild(s); if (io) io.unobserve(s); return; }
      const activeMount = $(VIEW === 'grid' ? O.gridMount : VIEW === 'table' ? O.tableMount : O.listMount);
      if (!activeMount || !activeMount.parentNode) return;
      const scroller = findScroller(activeMount);   // null → window scroll
      if (scroller !== curRoot) {
        if (io) io.disconnect();
        io = new IntersectionObserver(entries => { if (entries.some(e => e.isIntersecting)) grow(); }, { root: scroller || null, rootMargin: '600px 0px' });
        curRoot = scroller;
      }
      if (scroller) {
        if (s.parentNode !== scroller || scroller.lastElementChild !== s) scroller.appendChild(s);
      } else if (s.nextSibling !== activeMount.nextSibling || s.previousSibling !== activeMount) {
        activeMount.parentNode.insertBefore(s, activeMount.nextSibling);
      }
      io.observe(s);
    }
    function grow() {
      if (growing || shown >= LAST_ROWS.length) return;
      growing = true;
      shown = Math.min(shown + BATCH, LAST_ROWS.length);
      paint();               // re-render the larger window (append-at-bottom keeps scroll pos)
      growing = false;
    }

    // ---- segmented view toggle ----
    function buildSeg() {
      const el = $(O.seg); if (!el) return;
      el.innerHTML = VIEWS.map(v => `<button type="button" data-tvview="${v}" class="${VIEW === v ? 'active' : ''}">${LABELS[v] || v}</button>`).join('');
      el.addEventListener('click', e => {
        const b = e.target.closest('button[data-tvview]'); if (!b) return;
        setView(b.dataset.tvview);
      });
    }

    // ---- all-field sort <select> ----
    function buildSort() {
      const sel = $(O.sortSel); if (!sel) return;
      const sortable = O.fields.filter(f => !f.badge && f.k !== 'title');
      sel.innerHTML = `<option value="">Sort…</option>` + sortable.map(f =>
        `<option value="${esc(f.k)}:desc">${esc(f.l)} ↓</option><option value="${esc(f.k)}:asc">${esc(f.l)} ↑</option>`).join('');
      sel.value = SORT && SORT.k ? SORT.k + ':' + (SORT.dir < 0 ? 'desc' : 'asc') : '';
      sel.addEventListener('change', e => {
        const v = e.target.value;
        if (!v) SORT = null; else { const [k, d] = v.split(':'); SORT = { k, dir: d === 'desc' ? -1 : 1 }; }
        try { localStorage.setItem(KEY + 'Sort', JSON.stringify(SORT)); } catch (e2) {}
        if (O.onSort) O.onSort(SORT ? SORT.k : '', SORT ? SORT.dir : 1);
        render();
      });
    }

    // sort applied by the helper so the all-field Sort select works in EVERY view,
    // including the helper-owned compact list. The page can still apply its own
    // secondary sort (e.g. header-click in table view) on top via getRows().
    function applySort(rows) {
      if (!SORT || !SORT.k) return rows;
      const f = O.fields.find(x => x.k === SORT.k); if (!f) return rows;
      const dir = SORT.dir || 1;
      return rows.slice().sort((a, b) => {
        let x = fval(a, f), y = fval(b, f);
        const na = x == null || x === '', nb = y == null || y === '';
        if (na && nb) return 0; if (na) return 1; if (nb) return -1;   // nulls last
        const xn = !isNaN(+x) && x !== true, yn = !isNaN(+y) && y !== true;
        if ((typeof x === 'number' || typeof y === 'number' || f.money || f.num || f.pct) || (xn && yn))
          return ((+x) - (+y)) * dir;
        return String(x).localeCompare(String(y)) * dir;
      });
    }

    // ---- compact-list renderer (the missing 3rd mode) ----
    function listCompact(rows) {
      const mount = $(O.listMount); if (!mount) return;
      const vis = O.fields.filter(f => fvis(f.k) && !f.badge && f.k !== 'title');
      if (!rows.length) { mount.innerHTML = '<div class="tv-empty">No matches for these filters.</div>'; return; }
      mount.innerHTML = `<div class="tv-rows">` + rows.map((r, i) => {
        const t = O.title ? O.title(r) : { name: r.name || '', sub: '' };
        const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer">${esc(t.name)}</a>` : esc(t.name);
        return `<div class="tv-lrow" data-tvri="${i}">` +
          `<div class="tv-lmain"><span class="tv-la">${nm}</span>${t.sub ? `<span class="tv-lsub">${esc(t.sub)}</span>` : ''}${O.badgeHtml ? (O.badgeHtml(r) || '') : ''}</div>` +
          `<div class="tv-lmeta">` +
          vis.map(f => `<span class="tv-lm"><span class="tv-lmk">${esc(f.l)}</span>${fmt(fval(r, f), f)}</span>`).join('') +
          `</div></div>`;
      }).join('') + `</div>`;
      if (O.rowClick) {
        mount.querySelectorAll('.tv-lrow').forEach(el => el.addEventListener('click', e => {
          if (e.target.closest('a')) return;
          O.rowClick(rows[+el.dataset.tvri], el);
        }));
      }
    }

    function showHide() {
      const set = (sel, on) => { const el = sel && $(sel); if (el) el.style.display = on ? '' : 'none'; };
      set(O.gridMount, VIEW === 'grid');
      set(O.listMount, VIEW === 'list');
      set(O.tableMount, VIEW === 'table');
    }

    // paint the CURRENT window (rows 0..shown). Does NOT reset `shown` — grow() and
    // the internal re-renders call this so scrolling accumulates instead of snapping back.
    function paint() {
      const raw = O.getRows ? (O.getRows() || []) : [];
      LAST_ROWS = applySort(raw);
      if (shown > LAST_ROWS.length) shown = Math.max(BATCH, Math.min(shown, LAST_ROWS.length));
      const win = IO_OK ? LAST_ROWS.slice(0, shown) : LAST_ROWS;   // no IO → show all
      showHide();
      if (VIEW === 'grid' && O.renderGrid) O.renderGrid(win);
      else if (VIEW === 'table' && O.renderTable) O.renderTable(win);
      else if (VIEW === 'list') listCompact(win);
      // keep the seg buttons + sort select reflecting current state
      const seg = $(O.seg); if (seg) seg.querySelectorAll('button[data-tvview]').forEach(b => b.classList.toggle('active', b.dataset.tvview === VIEW));
      const sel = $(O.sortSel); if (sel) sel.value = SORT && SORT.k ? SORT.k + ':' + (SORT.dir < 0 ? 'desc' : 'asc') : '';
      positionSentinel(IO_OK && shown < LAST_ROWS.length);
    }

    // public render() — called by the page after a filter change, and internally on
    // sort/view change. Resets the reveal window to the first batch (back to the top).
    function render() { shown = BATCH; paint(); }

    function setView(v) {
      if (!VIEWS.includes(v)) return;
      VIEW = v; try { localStorage.setItem(KEY + 'View', v); } catch (e) {}
      if (O.onView) O.onView(v);
      render();
    }

    buildSeg(); buildSort();
    return { render, setView, getView: () => VIEW, getSort: () => SORT, applySort };
  }

  global.ThreeView = { mount: O => new ThreeView(O) };
})(window);