[object Object]

← back to Commercialrealestate

CRCP filters: responsive hybrid (collapsed rail desktop / ⚙ Filters modal mobile) + grid/list/table + all-sortable on fha-leads.html

f97fd3d48ffb0dbcf1658e669fef7532d5fc82c0 · 2026-08-18 18:25:12 -0700 · Steve Abrams

Files touched

Diff

commit f97fd3d48ffb0dbcf1658e669fef7532d5fc82c0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 18:25:12 2026 -0700

    CRCP filters: responsive hybrid (collapsed rail desktop / ⚙ Filters modal mobile) + grid/list/table + all-sortable on fha-leads.html
---
 public/filter-modal.js | 137 ++++++++++++++++++++++++++-----------------------
 1 file changed, 72 insertions(+), 65 deletions(-)

diff --git a/public/filter-modal.js b/public/filter-modal.js
index 39cdea2..b4c581a 100644
--- a/public/filter-modal.js
+++ b/public/filter-modal.js
@@ -1,41 +1,45 @@
-/* filter-modal.js — CRCP standard (2026-08-18): collapse the always-open left filter
- * rail (aside.rail full of .rsec sections) into ONE "⚙ Filters" modal made of
- * collapsible panels. Single-line drop-in — <script src="/filter-modal.js" defer></script>.
+/* filter-modal.js — CRCP responsive filter standard (2026-08-18).
  *
- * What it does, with ZERO changes to the host page's own filter JS:
- *   1. Finds `aside.rail` (or `.rail`) and the page toolbar (`.toolbar` or `.topbar`).
- *   2. Injects a "⚙ Filters (N)" trigger button into the toolbar; N = live count of
- *      active filters (active chips + non-empty number/text inputs inside the rail).
- *   3. Moves every `.rsec` filter section into an overlay modal — element IDs and their
- *      already-bound event listeners travel with the nodes, so #fState/#fCity/etc. and
- *      all the page's filtering keep working exactly as before.
- *   4. Turns each `.rsec > h4` into an accordion header (collapsed by default; per-page,
- *      per-section state persisted in localStorage).
- *   5. "Clear all" un-sets every active filter by driving the page's OWN controls
- *      (click active chips off, blank inputs + dispatch input) so the page re-renders.
+ * 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:
  *
- * Fully reversible: it only relocates DOM + injects a modal shell; remove the <script>
- * and the rail renders inline again (nothing about the page's data/logic is touched).
- * Idempotent: guarded so double-inclusion is a no-op.
+ *   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;                                   // page has no filter rail — nothing to do
+    if (!rail) return;
     var secs = Array.prototype.slice.call(rail.querySelectorAll('.rsec'));
-    if (!secs.length) return;
-    var toolbar = document.querySelector('.toolbar') || document.querySelector('.topbar') ||
-                  document.querySelector('main .toolbar, main .topbar') || document.querySelector('.main');
+    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;                // {sectionIndex: open?} persisted collapse state
+    var LSK = 'crcpFilterPanels:' + PAGE;
 
     injectStyle();
 
-    // ---- build the modal shell ----
+    // 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';
@@ -44,15 +48,13 @@
     modal.appendChild(head); modal.appendChild(bodyWrap); ov.appendChild(modal);
     document.body.appendChild(ov);
 
-    // ---- move each .rsec into the modal as a collapsible panel ----
+    // ---- 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');
-      var startOpen = openState[i] === true;             // collapsed by default
-      if (startOpen) sec.classList.add('fm-open');
-      // rebuild the h4 as an accordion toggle (keep its text; add a chevron + per-panel count)
+      if (openState[i] === true) sec.classList.add('fm-open');   // collapsed unless remembered 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>';
@@ -60,16 +62,11 @@
         h.addEventListener('click', function () { togglePanel(sec, i); });
         h.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(sec, i); } });
       }
-      bodyWrap.appendChild(sec);
     });
 
-    // the rail is now empty — hide it so the main content goes full-width
-    rail.classList.add('fm-railhidden');
-
-    // ---- trigger button in the toolbar ----
+    // ---- "⚙ 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>';
-    // put it first so it reads left-to-right before search on most toolbars
     if (toolbar.firstChild) toolbar.insertBefore(trig, toolbar.firstChild); else toolbar.appendChild(trig);
 
     function togglePanel(sec, i) {
@@ -86,17 +83,17 @@
     ov.addEventListener('click', function (e) { if (e.target === ov) closeModal(); });
     head.querySelector('.fm-clear').addEventListener('click', clearAll);
 
-    // recount active filters whenever the user interacts inside the modal
-    bodyWrap.addEventListener('click', function () { setTimeout(recount, 0); });
-    bodyWrap.addEventListener('input', function () { setTimeout(recount, 0); });
-    bodyWrap.addEventListener('change', function () { setTimeout(recount, 0); });
+    // 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;                        // density etc. isn't a "filter"
+        if (t === 'range') return;
         if (t === 'checkbox' || t === 'radio') { if (inp.checked) n++; return; }
         if ((inp.value || '').trim() !== '') n++;
       });
@@ -106,39 +103,48 @@
       return n;
     }
     function recount() {
-      var total = countActive(bodyWrap);
+      var total = countActive(rail);
       var badge = trig.querySelector('.fm-badge');
       badge.textContent = total; badge.hidden = total === 0;
       trig.classList.toggle('fm-has', total > 0);
-      // per-panel counts on the accordion headers
       secs.forEach(function (sec) {
         var pc = sec.querySelector('.fm-pcount'); if (!pc) return;
         var c = countActive(sec); pc.textContent = c ? c : ''; pc.classList.toggle('on', c > 0);
       });
     }
     function clearAll() {
-      // click every active chip off (drives the page's own toggle handlers)
-      Array.prototype.forEach.call(bodyWrap.querySelectorAll('.chip.active'), function (ch) { ch.click(); });
-      // blank inputs + notify the page
-      Array.prototype.forEach.call(bodyWrap.querySelectorAll('input'), function (inp) {
+      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(bodyWrap.querySelectorAll('select'), function (s) {
+      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);
     }
 
-    // keep the badge live even when filters change from outside the modal (e.g. chip
-    // datum-drills elsewhere on the page): observe the modal subtree for class flips.
+    // ---- 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(bodyWrap, { subtree: true, attributes: true, attributeFilter: ['class'], childList: true });
+      mo.observe(rail, { subtree: true, attributes: true, attributeFilter: ['class'], childList: true });
     } catch (e) {}
-
     recount();
   }
 
@@ -147,15 +153,24 @@
   function injectStyle() {
     if (document.getElementById('fm-style')) return;
     var css =
-    '.fm-railhidden{display:none!important}' +
-    '.fm-trigger{display:inline-flex;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}' +
+    /* 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}' +
-    '.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:5vh 16px 16px}' +
+    /* 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:min(560px,96vw);max-height:88vh;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-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}' +
@@ -163,18 +178,10 @@
     '.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 6px 12px}' +
-    '.fm-panel.rsec{border-bottom:1px solid var(--line,#2a313c);padding:0 12px}' +
-    '.fm-panel.rsec:last-child{border-bottom:0}' +
-    '.fm-ph{display:flex;align-items:center;gap:8px;margin:0!important;padding:13px 2px;cursor:pointer;user-select:none;color:var(--mut,#8b949e)!important;font-size:11px!important;text-transform:uppercase;letter-spacing:.5px}' +
-    '.fm-ph .fm-ptitle{flex:1}' +
-    '.fm-ph .fm-chev{transition:transform .16s ease;font-size:11px;color:var(--mut,#8b949e)}' +
-    '.fm-panel.fm-open .fm-ph .fm-chev{transform:rotate(90deg)}' +
-    '.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-ph .fm-pcount.on{display:inline-block}' +
-    '.fm-panel:not(.fm-open) > *:not(.fm-ph){display:none!important}' +   /* collapsed: hide everything but the header; open state falls through to the page\'s own CSS so chip/flex layouts are preserved */
-    '.fm-panel .chips{padding-bottom:12px}' +
-    '@media(max-width:640px){.fm-modal{width:100%;max-height:92vh}.fm-ov{padding:2vh 8px 8px}}';
+    '.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);
   }
 

← 90f3954 residential-brokers: always show broker phone+email (TK-1070  ·  back to Commercialrealestate  ·  licensed: always show professional phone+email (TK-10703) 853e086 →