fix(form): accessibility for qty, status, and descriptions (#81)

* fix(form): add status regions, live total, and a11y CSS

Dual alert/status slots for form and admin, aria-live on the sticky
total, success heading focus target, More/Less affordance styles, and
44px coarse-pointer chip padding.

* fix(form): wire a11y labels, status helper, and desc expand

Meal-scoped qty labels at card create time, aria-pressed filter chips,
dual-node showStatus replacing all alert() calls (confirm retained),
overflow-gated More/Less, and success-heading focus after submit.

* test(form): cover a11y labels, status region, and desc toggle

Structural checks for dual status nodes and no alert(); Playwright for
init-time qty labels, aria-pressed chips, overflow More/Less, and
failed-submit text landing in role=alert.

* fix: address review comments
This commit is contained in:
Adam Moussa 2026-07-31 10:15:43 -04:00 • committed by GitHub
parent 83077f7aa9
commit 602b0c7fd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 364 additions and 34 deletions

View file

@ -71,16 +71,19 @@ function buildEditQtyRow(name, qty) {
minusBtn.dataset.adminAction = 'edit-qty';
minusBtn.dataset.mealName = name;
minusBtn.dataset.delta = '-1';
minusBtn.setAttribute('aria-label', 'Remove one ' + name);
minusBtn.innerHTML = '−';
const span = document.createElement('span');
span.textContent = qty;
span.setAttribute('aria-label', 'Quantity of ' + name);
const plusBtn = document.createElement('button');
plusBtn.type = 'button';
plusBtn.dataset.adminAction = 'edit-qty';
plusBtn.dataset.mealName = name;
plusBtn.dataset.delta = '1';
plusBtn.setAttribute('aria-label', 'Add one ' + name);
plusBtn.textContent = '+';
wrap.appendChild(minusBtn);
@ -311,6 +314,7 @@ function downloadSummaryPdf() {
const week = currentAdminWeek || WEEK;
const btn = document.getElementById('admin-pdf-btn');
if (btn) btn.disabled = true;
window.FormUtils.clearStatus('admin');
adminFetch(ADMIN_PDF_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
.then(r => r.json().then(data => ({ ok: r.ok, status: r.status, data })))
.then(({ ok, status, data }) => {
@ -319,19 +323,34 @@ function downloadSummaryPdf() {
if (typeof data.url === 'string' && data.url.startsWith('https://')) {
window.location.href = data.url;
} else {
alert('Could not fetch the summary PDF: invalid URL');
window.FormUtils.showStatus('Could not fetch the summary PDF: invalid URL', {
tone: 'error',
scope: 'admin',
});
}
} else if (status === 404) {
alert('No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.');
window.FormUtils.showStatus(
'No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.',
{ tone: 'info', scope: 'admin' }
);
} else {
alert('Could not fetch the summary PDF: ' + (data.error || 'unknown error'));
window.FormUtils.showStatus(
'Could not fetch the summary PDF: ' + (data.error || 'unknown error'),
{ tone: 'error', scope: 'admin' }
);
}
})
.catch(() => alert('Could not fetch the summary PDF.'))
.catch(() => {
window.FormUtils.showStatus('Could not fetch the summary PDF.', {
tone: 'error',
scope: 'admin',
});
})
.finally(() => { if (btn) btn.disabled = false; });
}
function adminStartEdit(email) {
window.FormUtils.clearStatus('admin');
const requestedWeek = currentAdminWeek;
setAdminActionDisabled('start-edit', true);
adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(requestedWeek), { headers: adminHeaders() })
@ -349,7 +368,12 @@ function adminStartEdit(email) {
order.items.forEach(i => { editQuantities[i.name] = i.quantity; });
renderAdminTable(data);
})
.catch(() => alert('Failed to load this order for editing.'))
.catch(() => {
window.FormUtils.showStatus('Failed to load this order for editing.', {
tone: 'error',
scope: 'admin',
});
})
.finally(() => setAdminActionDisabled('start-edit', false));
}
@ -376,11 +400,13 @@ function adminSaveEdit(email) {
.filter(([, q]) => q > 0)
.map(([name, quantity]) => ({ name, quantity }));
if (items.length === 0) {
// confirm() retained deliberately for destructive zero-qty → delete guard.
if (confirm('All quantities are zero. Delete this order instead?')) {
adminDeleteOrder(email, '');
}
return;
}
window.FormUtils.clearStatus('admin');
setAdminActionDisabled('save-edit', true);
adminFetch(ADMIN_URL, {
method: 'PUT',
@ -389,13 +415,20 @@ function adminSaveEdit(email) {
})
.then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error); }); return r.json(); })
.then(() => { editingOrder = null; editQuantities = {}; loadWeekOrders(currentAdminWeek); })
.catch(e => alert('Failed to update: ' + e.message))
.catch(e => {
window.FormUtils.showStatus('Failed to update: ' + e.message, {
tone: 'error',
scope: 'admin',
});
})
.finally(() => setAdminActionDisabled('save-edit', false));
}
function adminDeleteOrder(email, name) {
const label = name ? name + ' (' + email + ')' : email;
// confirm() retained deliberately for destructive delete guard.
if (!confirm('Delete order for ' + label + '?')) return;
window.FormUtils.clearStatus('admin');
setAdminActionDisabled('delete', true);
adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek) + '&email=' + encodeURIComponent(email), {
method: 'DELETE',
@ -403,7 +436,12 @@ function adminDeleteOrder(email, name) {
})
.then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error); }); return r.json(); })
.then(() => loadWeekOrders(currentAdminWeek))
.catch(e => alert('Failed to delete: ' + e.message))
.catch(e => {
window.FormUtils.showStatus('Failed to delete: ' + e.message, {
tone: 'error',
scope: 'admin',
});
})
.finally(() => setAdminActionDisabled('delete', false));
}

View file

@ -152,6 +152,28 @@ header p { font-size: 0.85rem; opacity: 0.8; }
overflow: hidden;
}
.meal-desc.is-expanded {
display: block;
-webkit-line-clamp: unset;
overflow: visible;
}
.meal-desc-toggle {
display: inline-block;
margin: 0 0 4px;
padding: 0;
border: none;
background: none;
color: var(--color-brand);
font-size: 0.8rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
text-decoration: underline;
}
.meal-desc-toggle:hover { opacity: 0.85; }
.qty-control {
display: flex;
align-items: center;
@ -367,6 +389,42 @@ header p { font-size: 0.85rem; opacity: 0.8; }
.qty-control .qty { width: 44px; height: 44px; }
.edit-qty button { width: 44px; height: 44px; }
.edit-qty span { width: 44px; height: 44px; line-height: 44px; }
.btn-chip { min-height: 44px; padding: 10px 16px; }
}
.form-status {
max-width: 1400px;
margin: 0 auto 8px;
}
.sticky-footer .form-status {
margin-bottom: 8px;
}
.admin-panel > .form-status {
margin: 0 0 12px;
}
.form-status-msg:empty { display: none; }
.form-status-msg {
padding: 8px 12px;
border-radius: var(--radius-control);
font-size: 0.85rem;
font-weight: 500;
text-align: center;
}
.form-status-error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.form-status-info {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
.discount-banner {

View file

@ -85,7 +85,7 @@
</div>
<div class="qty-control">
<button type="button" data-qty-delta="-1" aria-label="Decrease">&minus;</button>
<input class="qty" type="number" min="0" step="1" value="0">
<input class="qty" type="number" min="0" step="1" value="0" aria-label="Quantity">
<button type="button" data-qty-delta="1" aria-label="Increase">+</button>
</div>
</div>
@ -94,8 +94,12 @@
<div id="meals-list"></div>
<div class="sticky-footer{% if config.authMode == 'google' %} is-hidden{% endif %}" id="sticky-footer">
<div class="form-status" id="form-status">
<div id="form-status-alert" class="form-status-msg form-status-error" role="alert"></div>
<div id="form-status-info" class="form-status-msg form-status-info" role="status"></div>
</div>
<div class="inner">
<div>
<div aria-live="polite" aria-atomic="true">
<span class="total" id="total-display">$0.00</span>
<span class="count-display" id="count-display">0 meals</span>
</div>
@ -114,6 +118,10 @@
<button type="button" class="btn btn-outline back-btn" data-admin-action="hide-admin">Back to Menu</button>
</div>
</div>
<div class="form-status" id="admin-status">
<div id="admin-status-alert" class="form-status-msg form-status-error" role="alert"></div>
<div id="admin-status-info" class="form-status-msg form-status-info" role="status"></div>
</div>
<div id="admin-stats" class="admin-stats"></div>
<div class="admin-table-wrap">
<table class="admin-table">
@ -134,7 +142,7 @@
</div>
<div id="success" class="success-msg is-hidden">
<h2>Order submitted!</h2>
<h2 id="success-heading" tabindex="-1">Order submitted!</h2>
<p id="success-detail"></p>
<button type="button" id="success-back-btn" class="btn btn-outline back-btn">Back to Menu</button>
</div>

View file

@ -40,7 +40,32 @@ function employeePrice(retail) {
return empPrice;
}
window.FormUtils = { employeePrice };
function clearStatus(scope) {
const prefix = scope === 'admin' ? 'admin-status' : 'form-status';
const alertEl = document.getElementById(prefix + '-alert');
const infoEl = document.getElementById(prefix + '-info');
if (alertEl) alertEl.textContent = '';
if (infoEl) infoEl.textContent = '';
}
function showStatus(message, options) {
const opts = options || {};
const tone = opts.tone || 'error';
const scope = opts.scope || 'form';
const prefix = scope === 'admin' ? 'admin-status' : 'form-status';
const alertEl = document.getElementById(prefix + '-alert');
const infoEl = document.getElementById(prefix + '-info');
if (!alertEl || !infoEl) return;
if (tone === 'info') {
alertEl.textContent = '';
infoEl.textContent = message || '';
} else {
infoEl.textContent = '';
alertEl.textContent = message || '';
}
}
window.FormUtils = { employeePrice, showStatus, clearStatus };
function syncGoogleCredential() {
window.googleCredential = googleCredential;
@ -184,6 +209,36 @@ function buildMealTagsEl(meal) {
return tagsEl;
}
function removeDescToggle(card) {
const existing = card.querySelector('.meal-desc-toggle');
if (existing) existing.remove();
}
function attachDescToggleIfOverflow(card) {
const descEl = card.querySelector('.meal-desc');
if (!descEl || descEl.classList.contains('is-hidden')) return;
removeDescToggle(card);
if (descEl.scrollHeight <= descEl.clientHeight + 1) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'meal-desc-toggle';
btn.textContent = 'More';
btn.setAttribute('aria-expanded', 'false');
btn.addEventListener('click', () => {
const expanded = descEl.classList.toggle('is-expanded');
btn.textContent = expanded ? 'Less' : 'More';
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
});
descEl.after(btn);
}
function scheduleDescToggle(card) {
requestAnimationFrame(() => {
attachDescToggleIfOverflow(card);
});
}
function updateMealCard(card, meal, i) {
const qty = quantities[i] || 0;
card.dataset.mealIndex = String(i);
@ -203,9 +258,12 @@ function updateMealCard(card, meal, i) {
card.querySelector('.meal-name').textContent = meal.name;
const descEl = card.querySelector('.meal-desc');
removeDescToggle(card);
descEl.classList.remove('is-expanded');
if (meal.description) {
descEl.textContent = meal.description;
descEl.classList.remove('is-hidden');
scheduleDescToggle(card);
} else {
descEl.textContent = '';
descEl.classList.add('is-hidden');
@ -221,6 +279,12 @@ function updateMealCard(card, meal, i) {
const qtyInput = card.querySelector('.qty');
qtyInput.value = qty;
qtyInput.setAttribute('aria-label', 'Quantity of ' + meal.name);
const minusBtn = card.querySelector('[data-qty-delta="-1"]');
const plusBtn = card.querySelector('[data-qty-delta="1"]');
if (minusBtn) minusBtn.setAttribute('aria-label', 'Remove one ' + meal.name);
if (plusBtn) plusBtn.setAttribute('aria-label', 'Add one ' + meal.name);
const disabled = formClosed && !isAdmin;
card.querySelectorAll('[data-qty-delta]').forEach(btn => { btn.disabled = disabled; });
@ -323,20 +387,38 @@ function updateTotal() {
}
async function submitOrder() {
if (formClosed && !isAdmin) { alert('Orders are closed.'); return; }
clearStatus('form');
if (formClosed && !isAdmin) {
showStatus('Orders are closed.', { tone: 'error', scope: 'form' });
return;
}
let payload;
let successName;
if (CONFIG.authMode === 'google') {
if (!googleCredential || !googleUser) { alert('Please sign in with Google first.'); return; }
if (!googleCredential || !googleUser) {
showStatus('Please sign in with Google first.', { tone: 'error', scope: 'form' });
return;
}
payload = { google_id_token: googleCredential, items: buildItems() };
successName = googleUser.name;
} else {
const name = document.getElementById('emp-name').value.trim();
const email = document.getElementById('emp-email').value.trim();
if (!name) { alert('Please enter your name.'); return; }
if (!email) { alert('Please enter your email.'); return; }
const nameEl = document.getElementById('emp-name');
const emailEl = document.getElementById('emp-email');
const name = nameEl.value.trim();
const email = emailEl.value.trim();
if (!name) {
showStatus('Please enter your name.', { tone: 'error', scope: 'form' });
nameEl.focus();
return;
}
if (!email) {
showStatus('Please enter your email.', { tone: 'error', scope: 'form' });
emailEl.focus();
return;
}
payload = { employee_name: name, employee_email: email, items: buildItems() };
successName = name;
}
@ -364,18 +446,42 @@ async function submitOrder() {
const footer = document.querySelector('.sticky-footer');
if (footer) footer.classList.add('is-hidden');
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {}
const successHeading = document.getElementById('success-heading');
if (successHeading) successHeading.focus();
} else {
alert(data.error || 'Something went wrong.');
showStatus(data.error || 'Something went wrong.', { tone: 'error', scope: 'form' });
btn.disabled = false;
btn.textContent = 'Submit Order';
}
} catch (e) {
alert('Failed to submit. Check your connection and try again.');
showStatus('Failed to submit. Check your connection and try again.', { tone: 'error', scope: 'form' });
btn.disabled = false;
btn.textContent = 'Submit Order';
}
}
function bindEmpNameChange(select) {
if (select.dataset.boundChange === '1') return;
select.dataset.boundChange = '1';
select.addEventListener('change', () => {
const selected = select.options[select.selectedIndex];
document.getElementById('emp-email').value = selected?.dataset?.email || '';
clearStatus('form');
});
}
function populateEmpSelect(select, employees) {
select.innerHTML = '<option value="">Select your name</option>';
employees.forEach(emp => {
const opt = document.createElement('option');
opt.value = emp.name;
opt.dataset.email = emp.email;
opt.textContent = emp.name;
select.appendChild(opt);
});
bindEmpNameChange(select);
}
async function loadRoster() {
let employees = ROSTER;
try {
@ -390,18 +496,7 @@ async function loadRoster() {
} catch (e) {}
const select = document.getElementById('emp-name');
select.innerHTML = '<option value="">Select your name</option>';
employees.forEach(emp => {
const opt = document.createElement('option');
opt.value = emp.name;
opt.dataset.email = emp.email;
opt.textContent = emp.name;
select.appendChild(opt);
});
select.addEventListener('change', () => {
const selected = select.options[select.selectedIndex];
document.getElementById('emp-email').value = selected?.dataset?.email || '';
});
populateEmpSelect(select, employees);
}
function showClosedOverlay() {
@ -588,11 +683,17 @@ function init() {
cats.forEach(cat => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-chip' + (cat === 'All Meals' ? ' active' : '');
const isActive = cat === 'All Meals';
btn.className = 'btn btn-chip' + (isActive ? ' active' : '');
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
btn.textContent = cat;
btn.addEventListener('click', () => {
filtersEl.querySelectorAll('button').forEach(b => b.classList.remove('active'));
filtersEl.querySelectorAll('button').forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-pressed', 'false');
});
btn.classList.add('active');
btn.setAttribute('aria-pressed', 'true');
renderMeals();
});
filtersEl.appendChild(btn);

View file

@ -19,7 +19,7 @@
"price": 14.5,
"calories": "620",
"protein": "35g",
"description": "With onions",
"description": "Stir-fried beef tenderloin with onions, tomatoes, and soy sauce, served over french fries with a side of steamed white rice and aji amarillo. A long fixture blurb used to exercise the three-line clamp and the More expand control in accessibility tests.",
"dietary_tags": [],
"image_url": "",
"is_new": true

View file

@ -235,6 +235,26 @@ class TestGenerateFormStructural:
assert "data-meal-index" in html
assert "data-qty-delta" in html
def test_a11y_status_and_live_total_markup(self):
html = _render()
assert 'id="form-status-alert"' in html
assert 'id="form-status-info"' in html
assert 'id="admin-status-alert"' in html
assert 'id="admin-status-info"' in html
assert 'role="alert"' in html
assert 'role="status"' in html
assert 'aria-live="polite"' in html
assert 'id="success-heading"' in html
assert 'tabindex="-1"' in html
def test_no_alert_dialogs_in_form_scripts(self):
form_js = (SERVER_DIR / "templates" / "form.js").read_text()
admin_js = (SERVER_DIR / "templates" / "admin.js").read_text()
assert "alert(" not in form_js
assert "alert(" not in admin_js
# confirm() retained for destructive admin delete / zero-qty save.
assert "confirm(" in admin_js
def test_design_tokens_only_in_definitions(self):
css = (SERVER_DIR / "templates" / "form.css").read_text()
# Strip :root block, then assert brand/muted/border hexes are gone
@ -430,6 +450,111 @@ class TestGenerateFormPlaywright:
assert page.locator("#count-display").inner_text() == "2 meals"
assert page.locator("#total-display").inner_text() == "$10.80"
def test_qty_labels_present_after_init(self, browser_page):
page = browser_page
card = page.locator('[data-meal-index="0"]')
assert (
card.locator('[data-qty-delta="-1"]').get_attribute("aria-label")
== "Remove one Chicken Bowl"
)
assert (
card.locator('[data-qty-delta="1"]').get_attribute("aria-label")
== "Add one Chicken Bowl"
)
assert (
card.locator(".qty").get_attribute("aria-label")
== "Quantity of Chicken Bowl"
)
def test_filter_chip_aria_pressed(self, browser_page):
page = browser_page
all_btn = page.locator("#filters button", has_text="All Meals")
assert all_btn.get_attribute("aria-pressed") == "true"
other = page.locator("#filters button").nth(1)
other_label = other.inner_text()
other.click()
page.wait_for_function(
"""(label) => {
const active = document.querySelector('#filters button.active');
return active && active.textContent === label
&& active.getAttribute('aria-pressed') === 'true';
}""",
arg=other_label,
)
assert all_btn.get_attribute("aria-pressed") == "false"
assert other.get_attribute("aria-pressed") == "true"
all_btn.click()
page.wait_for_function(
"""() => {
const active = document.querySelector('#filters button.active');
return active && active.textContent === 'All Meals'
&& active.getAttribute('aria-pressed') === 'true';
}"""
)
def test_desc_more_only_when_overflow(self, browser_page):
page = browser_page
# Ensure All Meals filter so both short and long cards are visible.
page.locator("#filters button", has_text="All Meals").click()
page.wait_for_selector('[data-meal-index="0"]')
page.wait_for_selector('[data-meal-index="1"]')
short_card = page.locator('[data-meal-index="0"]')
long_card = page.locator('[data-meal-index="1"]')
page.wait_for_function(
"""() => {
const long = document.querySelector('[data-meal-index="1"] .meal-desc-toggle');
return !!long;
}"""
)
assert short_card.locator(".meal-desc-toggle").count() == 0
toggle = long_card.locator(".meal-desc-toggle")
assert toggle.inner_text() == "More"
assert toggle.get_attribute("aria-expanded") == "false"
toggle.click()
assert toggle.inner_text() == "Less"
assert toggle.get_attribute("aria-expanded") == "true"
assert "is-expanded" in (
long_card.locator(".meal-desc").get_attribute("class") or ""
)
def test_submit_failure_lands_in_alert_region(self, browser_page):
page = browser_page
page.route(
"**/api/submit-order**",
lambda route: route.fulfill(
status=500,
content_type="application/json",
body='{"error":"Simulated submit failure"}',
),
)
page.locator("#emp-name").select_option(label="Ada Lovelace")
page.evaluate(
"""() => {
document.querySelectorAll('[data-meal-index] .qty').forEach((input) => {
input.value = '0';
input.dispatchEvent(new Event('change', { bubbles: true }));
});
}"""
)
page.wait_for_function(
"() => document.getElementById('count-display').textContent === '0 meals'"
)
page.locator('[data-meal-index="0"] [data-qty-delta="1"]').click()
page.wait_for_function(
"() => document.getElementById('count-display').textContent === '1 meal'"
)
page.locator("#submit-btn").click()
page.wait_for_function(
"""() => document.getElementById('form-status-alert').textContent
=== 'Simulated submit failure'"""
)
assert (
page.locator("#form-status-alert").inner_text()
== "Simulated submit failure"
)
assert page.locator("#form-status-info").inner_text() == ""
page.unroute("**/api/submit-order**")
def test_qty_typein_clamps_negative(self, browser_page):
page = browser_page
# Reset first card via type-in