[object Object]

← back to La Socrata Ingester

Viewer: per-column filter inputs in the List header

48e81f352ceec741e0c31c54230fff17e3efdd1b · 2026-08-12 11:51:21 -0700 · Steve Abrams

- A sticky filter row under the column headers: each filterable column gets an
  input wired to the matching server filter param (address->q contains, zip/cd/
  work_type/building_type/status/era->exact with facet-fed <datalist> autocomplete,
  issued->since date, project_value->min_value). Reuses state.filters + qs(), so it
  composes with facets, deep-links, and saved views for free.
- Focus + caret preserved across the reload-triggered re-render; address input
  stays in sync with the top search box; inputs only appear for visible columns.

Author: Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 48e81f352ceec741e0c31c54230fff17e3efdd1b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 12 11:51:21 2026 -0700

    Viewer: per-column filter inputs in the List header
    
    - A sticky filter row under the column headers: each filterable column gets an
      input wired to the matching server filter param (address->q contains, zip/cd/
      work_type/building_type/status/era->exact with facet-fed <datalist> autocomplete,
      issued->since date, project_value->min_value). Reuses state.filters + qs(), so it
      composes with facets, deep-links, and saved views for free.
    - Focus + caret preserved across the reload-triggered re-render; address input
      stays in sync with the top search box; inputs only appear for visible columns.
    
    Author: Steve Abrams
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 viewer/public/index.html | 52 +++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 51 insertions(+), 1 deletion(-)

diff --git a/viewer/public/index.html b/viewer/public/index.html
index f34b1c2..936b821 100644
--- a/viewer/public/index.html
+++ b/viewer/public/index.html
@@ -58,6 +58,13 @@
   thead th.drag{opacity:.4}
   thead th .arrow{color:var(--teal);margin-left:3px}
   thead th .grip{color:#b7bfc7;margin-right:5px;cursor:grab}
+  .filterrow th{position:sticky;top:28px;z-index:1;background:#f6f7f9;border-bottom:1px solid var(--line);
+    padding:3px 6px;cursor:default}
+  .filterrow th:hover{background:#f6f7f9}
+  .filterrow input{width:100%;min-width:52px;padding:3px 6px;font-size:11px;border:1px solid var(--line);
+    border-radius:5px;background:#fff;color:var(--ink)}
+  .filterrow input:focus{outline:none;border-color:var(--teal)}
+  .filterrow th.nofilter{background:#f6f7f9}
   tbody td{padding:var(--pad);border-bottom:1px solid var(--line);white-space:nowrap;height:var(--rowh);
     max-width:340px;overflow:hidden;text-overflow:ellipsis}
   tbody tr:hover{background:#f0f9f8;cursor:pointer}
@@ -162,7 +169,8 @@
   <main>
     <div id="overlay" class="overlay hidden"></div>
     <div id="listview">
-      <table id="tbl"><thead><tr id="head"></tr></thead><tbody id="rows"></tbody></table>
+      <table id="tbl"><thead><tr id="head"></tr><tr id="filterrow" class="filterrow"></tr></thead><tbody id="rows"></tbody></table>
+      <div id="dls" hidden></div>
       <button class="loadmore" id="more" style="display:none">Load more</button>
     </div>
     <div id="gridview" class="hidden">
@@ -270,6 +278,7 @@ function qs(extra={}){
 
 async function loadFacets(){
   const d = await (await fetch(api('/api/facets?'+qs()))).json();
+  facetData = d; renderDatalists(); // feed the per-column filter datalists
   const rail = $('#rail'); const collapsed = LS.get('collapsed',{});
   rail.innerHTML = FACETS.map(([key,label])=>{
     const items=(d[key]||[]).map(o=>{
@@ -324,6 +333,47 @@ function renderHead(){
     th.ondragover=e=>e.preventDefault();
     th.ondrop=e=>{e.preventDefault();dropCol(th.dataset.k);};
   });
+  renderFilterRow();
+}
+// per-column filters map each column to a server filter param (reuses state.filters + qs())
+const COLFILTER = {
+  issued:        {param:'since',        type:'date',   ph:'on/after'},
+  address:       {param:'q',            type:'search', ph:'contains…'},
+  zip:           {param:'zip',          type:'text',   ph:'ZIP',  list:'zip'},
+  cd:            {param:'cd',            type:'text',   ph:'CD',   list:'cd'},
+  work_type:     {param:'work_type',    type:'text',   ph:'any',  list:'work_type'},
+  building_type: {param:'building_type',type:'text',   ph:'any',  list:'building_type'},
+  status:        {param:'status',       type:'text',   ph:'any',  list:'status'},
+  era:           {param:'era',          type:'text',   ph:'any',  list:'era'},
+  project_value: {param:'min_value',    type:'number', ph:'≥ $'},
+};
+let facetData = {};
+function renderFilterRow(){
+  const fr=$('#filterrow'); if(!fr) return;
+  const act=document.activeElement, actP=(act&&act.dataset&&act.dataset.p)?act.dataset.p:null;
+  const selPos = actP ? act.selectionStart : null;
+  fr.innerHTML = cols().map(c=>{
+    const f=COLFILTER[c.key];
+    if(!f) return `<th class="nofilter"></th>`;
+    const v = state.filters[f.param]!=null ? escapeAttr(state.filters[f.param]) : '';
+    return `<th><input data-p="${f.param}" type="${f.type}" placeholder="${f.ph}" value="${v}"${f.list?` list="dl_${f.param}"`:''}></th>`;
+  }).join('');
+  fr.querySelectorAll('input').forEach(inp=>{
+    const debounce = (inp.type==='date') ? 0 : 300;
+    inp.oninput=()=>{ clearTimeout(inp._t); inp._t=setTimeout(()=>{
+      const p=inp.dataset.p, v=inp.value.trim();
+      if(v) state.filters[p]=v; else delete state.filters[p];
+      if(p==='q') $('#q').value=v; // keep top search box in sync
+      reload();
+    }, debounce); };
+  });
+  if(actP){ const el=fr.querySelector(`input[data-p="${actP}"]`); if(el){ el.focus(); try{ el.setSelectionRange(selPos,selPos); }catch(e){} } }
+  renderDatalists();
+}
+function renderDatalists(){
+  const h=$('#dls'); if(!h) return;
+  const mk=(param,key)=>`<datalist id="dl_${param}">`+((facetData[key]||[]).map(o=>`<option value="${escapeAttr(o.v)}">`).join(''))+`</datalist>`;
+  h.innerHTML = mk('zip','zip')+mk('cd','cd')+mk('work_type','work_type')+mk('building_type','building_type')+mk('status','status')+mk('era','era');
 }
 let drag=null;
 function dropCol(target){ if(!drag||drag===target)return; const a=colOrder.indexOf(drag),b=colOrder.indexOf(target);

← bcf4edb Viewer: saved views — named snapshots of the full view state  ·  back to La Socrata Ingester  ·  Viewer: CD/ZIP quick-jump control on the Map view e6f0356 →