[object Object]

← back to Nationalrealestate

nav-agent: add universal grid-controls drop-in (field-toggle panel + sort/group/density + movable/removable/expandable panels) to all internal grid pages

846689fd3ad060f9778dc2e5b2d100a84ee807f8 · 2026-08-13 08:01:41 -0700 · Steve Abrams

Files touched

Diff

commit 846689fd3ad060f9778dc2e5b2d100a84ee807f8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 13 08:01:41 2026 -0700

    nav-agent: add universal grid-controls drop-in (field-toggle panel + sort/group/density + movable/removable/expandable panels) to all internal grid pages
---
 public/contractors.html       | 67 ++++++++++++++++++++++++++++++++-----------
 public/nav-agent/nav-agent.js | 44 +++++++++++++++++++++-------
 2 files changed, 84 insertions(+), 27 deletions(-)

diff --git a/public/contractors.html b/public/contractors.html
index 276bbe3..af8c2d8 100644
--- a/public/contractors.html
+++ b/public/contractors.html
@@ -60,6 +60,29 @@
   #status button{background:var(--bg);border:1px solid var(--line);color:var(--muted);border-radius:6px;padding:3px 10px;cursor:pointer;font:11px inherit;}
   #status button:hover:not(:disabled){color:var(--fg);border-color:var(--muted);} #status button:disabled{opacity:.4;cursor:default;}
   .empty{color:var(--muted);font-size:13px;padding:50px;text-align:center;grid-column:1/-1;}
+  /* view segment toggle */
+  .cg-seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;}
+  .cg-seg button{background:var(--bg);color:var(--muted);border:0;border-right:1px solid var(--line);padding:6px 9px;font:12px inherit;cursor:pointer;}
+  .cg-seg button:last-child{border-right:0;}
+  .cg-seg button.active{background:var(--gold);color:var(--bg);font-weight:700;}
+  /* list view: one compact row per record */
+  #grid.list{display:block;}
+  #grid.list .cd{display:flex;align-items:baseline;gap:14px;border-radius:0;border:0;border-bottom:1px solid var(--line);padding:8px 10px;background:none;}
+  #grid.list .cd:hover{background:#141a24;}
+  #grid.list .cd .nm{margin:0;min-width:220px;flex:none;}
+  #grid.list .cd .lic{margin:0;}
+  #grid.list .cd .row{margin:0;} #grid.list .cd .row .k{display:none;}
+  #grid.list .cd .cls{margin:0;} #grid.list .cd .when{border:0;padding:0;margin:0;}
+  /* table view: dense all-columns spreadsheet with click-to-sort headers */
+  #grid.table{display:block;}
+  #grid.table + .ctrtbl{display:table;}
+  table.ctrtbl{border-collapse:collapse;width:100%;font-size:var(--fs);font-variant-numeric:tabular-nums;}
+  table.ctrtbl thead th{position:sticky;top:0;z-index:2;background:#0d1119;border-bottom:1px solid var(--line);color:var(--muted);
+    text-align:left;padding:7px 10px;font-weight:600;white-space:nowrap;cursor:pointer;}
+  table.ctrtbl thead th:hover{color:var(--gold);} table.ctrtbl thead th.sorted{color:var(--gold);}
+  table.ctrtbl tbody td{border-bottom:1px solid var(--line);padding:5px 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:280px;}
+  table.ctrtbl tbody tr:hover{background:#141a24;}
+  table.ctrtbl a{color:var(--blue);}
 </style>
   <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
@@ -81,12 +104,28 @@
       <option value="Suspended">Suspended</option>
     </select>
     <select id="sort" title="Sort">
-      <option value="name">Name A→Z</option>
-      <option value="newest">Newest added</option>
-      <option value="city">City A→Z</option>
-      <option value="county">County A→Z</option>
-      <option value="class">Primary class</option>
+      <option value="business_name:asc">Name A→Z</option>
+      <option value="business_name:desc">Name Z→A</option>
+      <option value="license_no:asc">License # ↑</option>
+      <option value="license_no:desc">License # ↓</option>
+      <option value="city:asc">City A→Z</option>
+      <option value="city:desc">City Z→A</option>
+      <option value="county:asc">County A→Z</option>
+      <option value="county:desc">County Z→A</option>
+      <option value="zip:asc">ZIP ↑</option>
+      <option value="zip:desc">ZIP ↓</option>
+      <option value="license_status:asc">Status A→Z</option>
+      <option value="issue_date:desc">Issued newest</option>
+      <option value="issue_date:asc">Issued oldest</option>
+      <option value="expire_date:desc">Expires latest</option>
+      <option value="expire_date:asc">Expires soonest</option>
+      <option value="primary_class:asc">Primary class A→Z</option>
     </select>
+    <span class="cg-seg" id="viewseg">
+      <button data-view="grid" class="active" title="Grid">▦ Grid</button>
+      <button data-view="list" title="List">☰ List</button>
+      <button data-view="table" title="Table">▤ Table</button>
+    </span>
     <span class="lbl">density</span><input id="density" type="range" min="180" max="360" value="236">
   </div>
 </div>
@@ -128,8 +167,12 @@ function readState(){
   return s;
 }
 let state = readState();
-let SORT = localStorage.getItem('ctr:sort') || 'name';
-let PAGE = [];  // last-loaded results (client-side sort operates on this page)
+// SORT is a server-side "col:dir" pair (col from the /api/contractors whitelist). Persisted.
+let SORT = localStorage.getItem('ctr:sort') || 'business_name:asc';
+if(!/:/.test(SORT)) SORT = 'business_name:asc';  // migrate old bare-key values
+let VIEW = localStorage.getItem('ctr:view') || 'grid';  // grid | list | table
+let PAGE = [];  // last-loaded results (this page)
+const sortParts = () => { const [k,d]=SORT.split(':'); return {sort:k, order:(d==='desc'?'desc':'asc')}; };
 
 // per-field visibility toggles (localStorage-persisted)
 const FIELDS = [
@@ -154,6 +197,7 @@ function go(over,replace){
 function syncControls(){
   $('#q').value=state.q||''; $('#class').value=state.class||'';
   $('#status').value=state.status||'Active'; $('#sort').value=SORT;
+  $('#viewseg').querySelectorAll('button').forEach(b=>b.classList.toggle('active',b.dataset.view===VIEW));
 }
 
 // ── field toggles ──
@@ -215,15 +259,6 @@ function aggClasses(rows){
   return [...m.entries()].sort((a,b)=>b[1]-a[1]).slice(0,30).map(([k,n])=>({key:k,label:k,n}));
 }
 
-// ── sort (client-side over the loaded page) ──
-const SORTERS = {
-  name:   (a,b)=>(a.business_name||'').localeCompare(b.business_name||''),
-  newest: (a,b)=>(b.issue_date||'').localeCompare(a.issue_date||''),  // proxy: no created_at in shape → issue_date newest-first
-  city:   (a,b)=>(a.city||'~').localeCompare(b.city||'~')||(a.business_name||'').localeCompare(b.business_name||''),
-  county: (a,b)=>(a.county||'~').localeCompare(b.county||'~')||(a.business_name||'').localeCompare(b.business_name||''),
-  class:  (a,b)=>(a.primary_class||'~').localeCompare(b.primary_class||'~')||(a.business_name||'').localeCompare(b.business_name||''),
-};
-
 // ── card render ──
 function card(r){
   const active = (r.license_status||'').toLowerCase()==='active';
diff --git a/public/nav-agent/nav-agent.js b/public/nav-agent/nav-agent.js
index 8b8faeb..89119e2 100644
--- a/public/nav-agent/nav-agent.js
+++ b/public/nav-agent/nav-agent.js
@@ -172,15 +172,20 @@
 
     function applyAll() { fieldStyleTag(); applySort(); applySearch(); }
 
-    /* re-apply after host re-renders (debounced, ignores our own mutations) */
+    /* re-apply after host re-renders (debounced, ignores our own mutations).
+       If the FIELD SET changed (async data arrived, filter repopulated), tell
+       mount() to rebuild the panel UI. */
+    function fieldSig(m) { return m.kind + ':' + m.fields.map(function (f) { return f.key; }).join(','); }
+    var sig = fieldSig(M);
     var t;
     var mo = new MutationObserver(function () {
       if (applyingSort) return;
       clearTimeout(t); t = setTimeout(function () {
-        M = model(target); // structure may have changed
-        // keep only still-valid selections
+        M = model(target); self.model = M;
+        var ns = fieldSig(M);
+        if (ns !== sig) { sig = ns; if (self.onFields) try { self.onFields(); } catch (e) {} }
         applyAll();
-      }, 120);
+      }, 140);
     });
     try { mo.observe(M.kind === 'table' ? (target.querySelector('tbody') || target) : target, { childList: true }); } catch (e) {}
 
@@ -373,17 +378,26 @@
         else target.setAttribute('data-na-sel', target.id ? '#' + target.id : '.' + (target.className.split(/\s+/)[0] || 'grid'));
         var agent = Agent(target, opt);
         target.__naAgent = agent;
-        var panel = buildPanel(agent, opt);
+        // (re)build the panel — called again by the observer when fields change
+        function makePanel() {
+          var wasOpen = agent._panel && agent._panel.classList.contains('open');
+          if (agent._panel) agent._panel.remove();
+          agent._panel = buildPanel(agent, opt);
+          if (wasOpen) agent._panel.classList.add('open');
+        }
+        agent.onFields = makePanel;
+        makePanel();
         chrome(agent, opt);
         agent.applyAll();
 
-        // launcher
+        // single launcher toggles every panel on the page
         if (!document.querySelector('.na-launch')) {
           var b = el('button', 'na-launch', '⚙'); b.title = 'View controls';
-          b.onclick = function () { panel.classList.toggle('open'); };
+          b.onclick = function () {
+            var anyOpen = document.querySelector('.na-panel.open');
+            [].slice.call(document.querySelectorAll('.na-panel')).forEach(function (p) { p.classList.toggle('open', !anyOpen); });
+          };
           document.body.appendChild(b);
-        } else {
-          document.querySelector('.na-launch').addEventListener('click', function () { panel.classList.toggle('open'); });
         }
         API.instances.push(agent);
         return agent;
@@ -400,8 +414,16 @@
       var tries = 0;
       (function attempt() {
         var t = findTarget();
-        var populated = t && (t.tagName === 'TABLE' ? t.querySelector('tbody tr') : t.children.length);
-        if (populated || tries++ > 20) { API.mount(); return; }
+        var populated = false;
+        if (t) {
+          if (t.tagName === 'TABLE') populated = !!t.querySelector('tbody tr');
+          else {
+            var kids = [].slice.call(t.children).filter(function (c) { return c.nodeType === 1; });
+            // ignore an empty-state placeholder (a lone div, often "No … match")
+            populated = !!t.querySelector('.card') || kids.length >= 2;
+          }
+        }
+        if (populated || tries++ > 32) { API.mount(); return; } // mount anyway after ~8s; observer fills fields when data arrives
         setTimeout(attempt, 250);
       })();
     } catch (e) {}

← 16ce66f usre: add ?sort=&order= column whitelist to /api/deals, /api  ·  back to Nationalrealestate  ·  usre: contractors.html gets grid/list/table views + all-colu cf1e3b2 →