mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
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.
This commit is contained in:
parent
c0f6316f3f
commit
9f34cb093f
3 changed files with 170 additions and 31 deletions
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
2
tests/fixtures/menu.json
vendored
2
tests/fixtures/menu.json
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue