← back to Commercialrealestate
public/filter-modal.js
206 lines
/* filter-modal.js — CRCP responsive filter standard (2026-08-18).
*
* Steve's fleet rule: persistent COLLAPSED RAIL on desktop, "⚙ Filters" MODAL on mobile.
* One drop-in — <script src="/filter-modal.js" defer></script> — gives both:
*
* DESKTOP (>=900px): the page's own left `.rail` stays put as a sidebar. Each `.rsec`
* filter section becomes a collapsible accordion (collapsed by default, per-page
* state persisted). This matches the established CRCP `.panel.collapsed` convention.
*
* MOBILE (<900px): the whole rail is physically moved into a full-screen modal that
* opens from a "⚙ Filters (N)" toolbar button (N = live active-filter count). The
* accordion panels come along; the grid gets the full narrow width.
*
* It relocates ONE rail between a home placeholder and the modal on breakpoint change —
* element IDs + their already-bound listeners travel with the nodes, so the page's own
* filtering (#fState/#fCity/number inputs…) keeps working untouched in both modes.
*
* Scoped to `.rsec` sections only, so it never double-binds accordion handlers on the
* bespoke `.panel` pages. Fully reversible (relocation + a modal shell); idempotent.
*/
(function () {
if (window.__crcpFilterModal) return; window.__crcpFilterModal = true;
var MOBILE_Q = '(max-width: 899px)';
function boot() {
var rail = document.querySelector('aside.rail') || document.querySelector('.rail');
if (!rail) return;
var secs = Array.prototype.slice.call(rail.querySelectorAll('.rsec'));
if (!secs.length) return; // .panel pages are owned elsewhere — no-op
var toolbar = document.querySelector('.toolbar') || document.querySelector('.topbar') || document.querySelector('main .toolbar, main .topbar');
if (!toolbar) return;
var PAGE = (location.pathname.split('/').pop() || 'index').replace(/\.html?$/, '') || 'index';
var LSK = 'crcpFilterPanels:' + PAGE;
injectStyle();
// home placeholder so the rail can return to its exact desktop position
var home = document.createComment('fm-rail-home');
rail.parentNode.insertBefore(home, rail);
// ---- modal shell (used only in mobile mode) ----
var ov = document.createElement('div'); ov.className = 'fm-ov'; ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'Filters');
var modal = document.createElement('div'); modal.className = 'fm-modal';
var head = document.createElement('div'); head.className = 'fm-head';
head.innerHTML = '<h3>Filters</h3><div class="fm-hactions"><button type="button" class="fm-clear">Clear all</button><button type="button" class="fm-x" aria-label="Close">✕</button></div>';
var bodyWrap = document.createElement('div'); bodyWrap.className = 'fm-body';
modal.appendChild(head); modal.appendChild(bodyWrap); ov.appendChild(modal);
document.body.appendChild(ov);
// ---- accordion-ify each .rsec (collapsed by default, state persisted) ----
var openState = {}; try { openState = JSON.parse(localStorage.getItem(LSK) || '{}') || {}; } catch (e) {}
secs.forEach(function (sec, i) {
var h = sec.querySelector('h4');
var label = h ? (h.textContent || '').trim() : ('Filter ' + (i + 1));
sec.classList.add('fm-panel');
// collapsed by default on multi-section pages; sparse pages (<=2 sections) start
// expanded so a single filter isn't hidden behind a click.
var defOpen = secs.length <= 2;
var open = (i in openState) ? openState[i] === true : defOpen;
if (open) sec.classList.add('fm-open');
if (h) {
h.classList.add('fm-ph');
h.innerHTML = '<span class="fm-ptitle">' + escapeHtml(label) + '</span><span class="fm-pcount"></span><span class="fm-chev">▸</span>';
h.setAttribute('role', 'button'); h.setAttribute('tabindex', '0');
h.addEventListener('click', function () { togglePanel(sec, i); });
h.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(sec, i); } });
}
});
// ---- "⚙ Filters" trigger (visible only in mobile mode, via CSS) ----
var trig = document.createElement('button'); trig.type = 'button'; trig.className = 'fm-trigger';
trig.innerHTML = '<span class="fm-tico">⚙</span> Filters<span class="fm-badge" hidden>0</span>';
if (toolbar.firstChild) toolbar.insertBefore(trig, toolbar.firstChild); else toolbar.appendChild(trig);
function togglePanel(sec, i) {
var open = sec.classList.toggle('fm-open');
openState[i] = open;
try { localStorage.setItem(LSK, JSON.stringify(openState)); } catch (e) {}
}
function openModal() { ov.classList.add('on'); recount(); document.addEventListener('keydown', onEsc); }
function closeModal() { ov.classList.remove('on'); document.removeEventListener('keydown', onEsc); }
function onEsc(e) { if (e.key === 'Escape') closeModal(); }
trig.addEventListener('click', openModal);
head.querySelector('.fm-x').addEventListener('click', closeModal);
ov.addEventListener('click', function (e) { if (e.target === ov) closeModal(); });
head.querySelector('.fm-clear').addEventListener('click', clearAll);
// recount whenever filters change (works whether rail is in the sidebar or the modal)
rail.addEventListener('click', function () { setTimeout(recount, 0); });
rail.addEventListener('input', function () { setTimeout(recount, 0); });
rail.addEventListener('change', function () { setTimeout(recount, 0); });
function countActive(scope) {
var n = 0;
n += scope.querySelectorAll('.chip.active').length;
Array.prototype.forEach.call(scope.querySelectorAll('input'), function (inp) {
var t = (inp.type || 'text').toLowerCase();
if (t === 'range') return;
if (t === 'checkbox' || t === 'radio') { if (inp.checked) n++; return; }
if ((inp.value || '').trim() !== '') n++;
});
Array.prototype.forEach.call(scope.querySelectorAll('select'), function (s) {
if (s.value && s.value !== '' && s.selectedIndex > 0) n++;
});
return n;
}
function recount() {
var total = countActive(rail);
var badge = trig.querySelector('.fm-badge');
// idempotent writes only: the MutationObserver below watches the rail's subtree for
// childList/class changes, and .fm-pcount lives INSIDE the rail — so a *redundant*
// textContent write (which still emits a childList mutation record even when the value
// is unchanged) would re-fire the observer → recount() → write → observer → an INFINITE
// loop that OOM-crashes the renderer on any page whose filters start with a value
// (e.g. valley-pools' pre-filled price/rate/down). Guarding every write so recount()
// mutates nothing when the counts are stable makes the observer settle after one pass.
var bt = String(total);
if (badge.textContent !== bt) badge.textContent = bt;
if (badge.hidden !== (total === 0)) badge.hidden = total === 0;
trig.classList.toggle('fm-has', total > 0);
secs.forEach(function (sec) {
var pc = sec.querySelector('.fm-pcount'); if (!pc) return;
var c = countActive(sec), want = c ? String(c) : '';
if (pc.textContent !== want) pc.textContent = want; // write only on real change → no self-trigger
pc.classList.toggle('on', c > 0); // toggle(force) is a no-op when already correct
});
}
function clearAll() {
Array.prototype.forEach.call(rail.querySelectorAll('.chip.active'), function (ch) { ch.click(); });
Array.prototype.forEach.call(rail.querySelectorAll('input'), function (inp) {
var t = (inp.type || 'text').toLowerCase();
if (t === 'range') return;
if (t === 'checkbox' || t === 'radio') { if (inp.checked) { inp.checked = false; inp.dispatchEvent(new Event('change', { bubbles: true })); inp.dispatchEvent(new Event('input', { bubbles: true })); } return; }
if ((inp.value || '').trim() !== '') { inp.value = ''; inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); }
});
Array.prototype.forEach.call(rail.querySelectorAll('select'), function (s) {
if (s.selectedIndex > 0) { s.selectedIndex = 0; s.dispatchEvent(new Event('change', { bubbles: true })); }
});
setTimeout(recount, 0);
}
// ---- responsive relocation: rail<->modal on the breakpoint ----
var mq = window.matchMedia(MOBILE_Q);
function place() {
if (mq.matches) { // mobile: rail lives in the modal
if (rail.parentNode !== bodyWrap) bodyWrap.appendChild(rail);
rail.classList.add('fm-inmodal');
} else { // desktop: rail returns to its sidebar home
if (home.parentNode && rail.parentNode !== home.parentNode) home.parentNode.insertBefore(rail, home);
rail.classList.remove('fm-inmodal');
closeModal();
}
}
if (mq.addEventListener) mq.addEventListener('change', place); else if (mq.addListener) mq.addListener(place);
place();
try {
var mo = new MutationObserver(function () { recount(); });
mo.observe(rail, { subtree: true, attributes: true, attributeFilter: ['class'], childList: true });
} catch (e) {}
recount();
}
function escapeHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
function injectStyle() {
if (document.getElementById('fm-style')) return;
var css =
/* trigger: hidden on desktop, shown on mobile */
'.fm-trigger{display:none;align-items:center;gap:6px;background:var(--card,#161b22);color:var(--ink,#e6edf3);border:1px solid var(--line,#2a313c);border-radius:8px;padding:8px 12px;font-size:12px;font-weight:600;cursor:pointer;line-height:1;white-space:nowrap}' +
'.fm-trigger:hover{border-color:var(--blue,#58a6ff)}' +
'.fm-trigger.fm-has{border-color:var(--blue,#58a6ff);color:var(--blue,#58a6ff)}' +
'.fm-tico{font-size:13px}' +
'.fm-badge{background:var(--blue,#58a6ff);color:#0e1116;border-radius:20px;padding:1px 7px;font-size:10px;font-weight:700;min-width:16px;text-align:center}' +
/* accordion panel headers (apply in both modes) */
'.fm-panel .fm-ph{display:flex!important;align-items:center;gap:8px;cursor:pointer;user-select:none}' +
'.fm-panel .fm-ph .fm-ptitle{flex:1}' +
'.fm-panel .fm-ph .fm-chev{transition:transform .16s ease;font-size:11px;opacity:.7}' +
'.fm-panel.fm-open .fm-ph .fm-chev{transform:rotate(90deg)}' +
'.fm-panel .fm-ph .fm-pcount{background:var(--blue,#58a6ff);color:#0e1116;border-radius:20px;padding:0 6px;font-size:9.5px;font-weight:700;display:none}' +
'.fm-panel .fm-ph .fm-pcount.on{display:inline-block}' +
'.fm-panel:not(.fm-open) > *:not(.fm-ph){display:none!important}' + /* collapsed: header only; open falls through to the page CSS so chip/flex layouts survive */
/* modal shell */
'.fm-ov{position:fixed;inset:0;background:rgba(2,6,12,.62);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);display:none;align-items:flex-start;justify-content:center;z-index:9999;padding:16px}' +
'.fm-ov.on{display:flex}' +
'.fm-modal{width:100%;max-width:560px;max-height:92vh;display:flex;flex-direction:column;background:var(--card,#161b22);border:1px solid var(--line,#2a313c);border-radius:16px;box-shadow:0 24px 70px rgba(0,0,0,.55);overflow:hidden}' +
'.fm-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:15px 18px;border-bottom:1px solid var(--line,#2a313c)}' +
'.fm-head h3{margin:0;font-size:16px;color:var(--ink,#e6edf3)}' +
'.fm-hactions{display:flex;align-items:center;gap:8px}' +
'.fm-clear{background:none;border:1px solid var(--line,#2a313c);color:var(--mut,#8b949e);border-radius:7px;padding:6px 11px;font-size:11.5px;cursor:pointer}' +
'.fm-clear:hover{color:var(--ink,#e6edf3);border-color:var(--blue,#58a6ff)}' +
'.fm-x{background:none;border:0;color:var(--mut,#8b949e);font-size:20px;line-height:1;cursor:pointer;padding:2px 4px}' +
'.fm-x:hover{color:var(--ink,#e6edf3)}' +
'.fm-body{overflow:auto;padding:6px 14px 14px}' +
/* rail restyled to fill the modal when relocated */
'.rail.fm-inmodal{width:auto!important;flex:none!important;border:0!important;height:auto!important;position:static!important;padding:0!important;overflow:visible!important}' +
'@media(max-width:899px){.fm-trigger{display:inline-flex}}';
var st = document.createElement('style'); st.id = 'fm-style'; st.textContent = css; document.head.appendChild(st);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();