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

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