← 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, '&').replace(/</g, '<').replace(/"/g, '"');
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);