← 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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →