diff --git a/src/server/templates/admin.js b/src/server/templates/admin.js
index b835ad6..cd3af56 100644
--- a/src/server/templates/admin.js
+++ b/src/server/templates/admin.js
@@ -45,16 +45,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);
@@ -283,6 +286,7 @@ function downloadSummaryPdf() {
const week = currentAdminWeek || WEEK;
const btn = document.getElementById('admin-pdf-btn');
if (btn) btn.disabled = true;
+ window.FormUtils.clearStatus('admin');
fetch(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 }) => {
@@ -291,15 +295,29 @@ 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; });
}
@@ -340,11 +358,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');
fetch(ADMIN_URL, {
method: 'PUT',
headers: { ...adminHeaders(), 'Content-Type': 'application/json' },
@@ -352,19 +372,31 @@ 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',
+ });
+ });
}
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');
fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek) + '&email=' + encodeURIComponent(email), {
method: 'DELETE',
headers: adminHeaders(),
})
.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',
+ });
+ });
}
function handleAdminPanelClick(e) {
diff --git a/src/server/templates/form.js b/src/server/templates/form.js
index d37fa23..7a61bae 100644
--- a/src/server/templates/form.js
+++ b/src/server/templates/form.js
@@ -47,7 +47,32 @@ function employeePrice(retail) {
return empPrice;
}
-window.FormUtils = { escapeHtml, 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 = { escapeHtml, employeePrice, showStatus, clearStatus };
function syncGoogleCredential() {
window.googleCredential = googleCredential;
@@ -183,6 +208,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);
@@ -202,9 +257,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');
@@ -220,6 +278,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; });
@@ -318,20 +382,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;
}
@@ -359,45 +441,50 @@ 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 = '';
+ 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() {
+ const select = document.getElementById('emp-name');
try {
const res = await fetch(ROSTER_URL);
const data = await res.json();
- const select = document.getElementById('emp-name');
- select.innerHTML = '';
- (data.employees || ROSTER).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, data.employees || ROSTER);
} catch (e) {
- const select = document.getElementById('emp-name');
- select.innerHTML = '';
- ROSTER.forEach(emp => {
- const opt = document.createElement('option');
- opt.value = emp.name;
- opt.dataset.email = emp.email;
- opt.textContent = emp.name;
- select.appendChild(opt);
- });
+ populateEmpSelect(select, ROSTER);
}
}
@@ -585,11 +672,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);
diff --git a/tests/fixtures/menu.json b/tests/fixtures/menu.json
index ee0671b..5b52de1 100644
--- a/tests/fixtures/menu.json
+++ b/tests/fixtures/menu.json
@@ -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