[object Object]

← back to Ventura Claw Leads

/find a11y: combobox pattern + aria-current on pagination + cleaner select labels

94e6ea5d25f729eb073af7a467efc428362284b8 · 2026-05-07 15:49:58 -0700 · Steve Abrams

Search input now implements the WAI-ARIA combobox pattern: role='combobox',
aria-controls='find-suggest', aria-autocomplete='list', aria-expanded
toggling on open/close, and aria-activedescendant tracking the active
suggestion as the user arrows through. Each <li> in the suggest list
now has a stable id + aria-selected. Screen readers will announce
each suggestion as the user moves the focus marker.

Removed emoji-in-option-text from the Category select — '🍽 Food &
drink' reads as 'fork-and-knife Food and drink' on JAWS/NVDA. Plain
labels match what's spoken.

Pagination now has aria-current='page' on the 'Page N of M' indicator
and explicit aria-label='Previous page' / 'Next page' on the arrow
links so the rel=prev/next attribute isn't the only signal.

Sort/Category/City selects now use proper for=/id= label association
instead of just implicit wrapping (Safari + older AT prefers explicit).

Files touched

Diff

commit 94e6ea5d25f729eb073af7a467efc428362284b8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 7 15:49:58 2026 -0700

    /find a11y: combobox pattern + aria-current on pagination + cleaner select labels
    
    Search input now implements the WAI-ARIA combobox pattern: role='combobox',
    aria-controls='find-suggest', aria-autocomplete='list', aria-expanded
    toggling on open/close, and aria-activedescendant tracking the active
    suggestion as the user arrows through. Each <li> in the suggest list
    now has a stable id + aria-selected. Screen readers will announce
    each suggestion as the user moves the focus marker.
    
    Removed emoji-in-option-text from the Category select — '🍽 Food &
    drink' reads as 'fork-and-knife Food and drink' on JAWS/NVDA. Plain
    labels match what's spoken.
    
    Pagination now has aria-current='page' on the 'Page N of M' indicator
    and explicit aria-label='Previous page' / 'Next page' on the arrow
    links so the rel=prev/next attribute isn't the only signal.
    
    Sort/Category/City selects now use proper for=/id= label association
    instead of just implicit wrapping (Safari + older AT prefers explicit).
---
 views/public/find.ejs | 56 ++++++++++++++++++++++++++++++++++++---------------
 1 file changed, 40 insertions(+), 16 deletions(-)

diff --git a/views/public/find.ejs b/views/public/find.ejs
index c5e1a49..418d4c9 100644
--- a/views/public/find.ejs
+++ b/views/public/find.ejs
@@ -33,19 +33,20 @@
 
   <form action="/find" method="get" class="find-filters" role="search" autocomplete="off">
     <div class="filter-row">
-      <label style="position:relative">Search
-        <input type="search" name="q" value="<%= q %>" placeholder="Name, neighborhood, or category" id="find-q" autocomplete="off">
-        <ul id="find-suggest" class="find-suggest" role="listbox" hidden></ul>
+      <label style="position:relative" for="find-q">Search
+        <input type="search" name="q" value="<%= q %>" placeholder="Name, neighborhood, or category" id="find-q" autocomplete="off"
+          role="combobox" aria-autocomplete="list" aria-controls="find-suggest" aria-expanded="false">
+        <ul id="find-suggest" class="find-suggest" role="listbox" aria-label="Business name suggestions" hidden></ul>
       </label>
-      <label>Category
-        <select name="vertical">
+      <label for="find-vertical">Category
+        <select name="vertical" id="find-vertical">
           <option value="">All categories</option>
           <% verticals.forEach(function(vv){ %>
-            <option value="<%= vv.key %>" <%= v === vv.key ? 'selected' : '' %>><%= vv.icon %>  <%= vv.label %></option>
+            <option value="<%= vv.key %>" <%= v === vv.key ? 'selected' : '' %>><%= vv.label %></option>
           <% }); %>
         </select>
       </label>
-      <label>City<input type="text" name="city" value="<%= city %>" placeholder="Sherman Oaks"></label>
+      <label for="find-city">City<input type="text" name="city" value="<%= city %>" placeholder="Sherman Oaks" id="find-city"></label>
       <button type="submit" class="btn btn-primary">Search</button>
     </div>
   </form>
@@ -59,8 +60,8 @@
       <% } %>
     </p>
     <div class="grid-controls-right">
-      <label class="grid-control"><span>Sort</span>
-        <select id="grid-sort">
+      <label class="grid-control" for="grid-sort"><span>Sort</span>
+        <select id="grid-sort" aria-label="Sort businesses by">
           <option value="featured" <%= sort === 'featured' ? 'selected' : '' %>>Featured</option>
           <option value="newest"   <%= sort === 'newest'   ? 'selected' : '' %>>Newest</option>
           <option value="updated"  <%= sort === 'updated'  ? 'selected' : '' %>>Recently updated</option>
@@ -132,11 +133,11 @@
     %>
       <nav class="pagination" role="navigation" aria-label="Pagination" style="display:flex;gap:8px;justify-content:center;align-items:center;margin:32px 0 16px;flex-wrap:wrap">
         <% if (page > 1) { %>
-          <a href="<%= pageUrl(page - 1) %>" class="btn" rel="prev">← Prev</a>
+          <a href="<%= pageUrl(page - 1) %>" class="btn" rel="prev" aria-label="Previous page">← Prev</a>
         <% } %>
-        <span class="muted" style="font-size:13px">Page <%= page %> of <%= totalPages %></span>
+        <span class="muted" style="font-size:13px" aria-current="page">Page <%= page %> of <%= totalPages %></span>
         <% if (page < totalPages) { %>
-          <a href="<%= pageUrl(page + 1) %>" class="btn btn-primary" rel="next">Next →</a>
+          <a href="<%= pageUrl(page + 1) %>" class="btn btn-primary" rel="next" aria-label="Next page">Next →</a>
         <% } %>
       </nav>
     <% } %>
@@ -154,12 +155,30 @@
     var currentQ = '';
     var activeIdx = -1;
     function escHtml(s){ return String(s||'').replace(/[&<>"']/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
-    function close(){ list.hidden = true; activeIdx = -1; list.innerHTML = ''; }
-    function open(){ list.hidden = false; }
+    function close(){
+      list.hidden = true; activeIdx = -1; list.innerHTML = '';
+      input.setAttribute('aria-expanded', 'false');
+      input.removeAttribute('aria-activedescendant');
+    }
+    function open(){
+      list.hidden = false;
+      input.setAttribute('aria-expanded', 'true');
+    }
     function setActive(idx) {
       var items = list.querySelectorAll('li');
-      items.forEach(function(li, i){ li.classList.toggle('is-active', i === idx); });
+      items.forEach(function(li, i){
+        var on = i === idx;
+        li.classList.toggle('is-active', on);
+        li.setAttribute('aria-selected', on ? 'true' : 'false');
+      });
       activeIdx = idx;
+      // Track active option via aria-activedescendant so screen readers
+      // announce the active suggestion as the user arrows through.
+      if (idx >= 0 && items[idx]) {
+        input.setAttribute('aria-activedescendant', items[idx].id);
+      } else {
+        input.removeAttribute('aria-activedescendant');
+      }
     }
     async function fetchSuggest(q){
       try {
@@ -172,7 +191,10 @@
     function render(items){
       if (!items.length) { close(); return; }
       list.innerHTML = items.map(function(b, i){
-        return '<li role="option" data-href="/business/' + escHtml(b.slug) + '"' + (i === 0 ? ' class="is-active"' : '') + '>' +
+        var id = 'find-suggest-opt-' + i;
+        var aSel = i === 0 ? 'true' : 'false';
+        var aCls = i === 0 ? ' class="is-active"' : '';
+        return '<li role="option" id="' + id + '" aria-selected="' + aSel + '" data-href="/business/' + escHtml(b.slug) + '"' + aCls + '>' +
           '<strong>' + escHtml(b.business_name) + '</strong>' +
           '<span class="suggest-vert">' + escHtml((b.vertical||'').replace(/_/g,' ')) + '</span>' +
           '<span class="suggest-loc">' + escHtml([b.neighborhood, b.city].filter(Boolean).join(' · ')) + '</span>' +
@@ -180,6 +202,8 @@
       }).join('');
       activeIdx = 0;
       open();
+      var first = list.querySelector('li');
+      if (first) input.setAttribute('aria-activedescendant', first.id);
     }
     input.addEventListener('input', function(){
       var v = input.value.trim();

← bdc2730 Auto-fire codex-3way on every commit (hook + viewer)  ·  back to Ventura Claw Leads  ·  Right-rail visibility: brass-color + flex-shrink:0 on home r d613a8f →