← 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 →