The screen had no mobile state at all: the clock scrolled away mid-paper, the only navigation was save-and-next so a candidate could not go back, the palette sat below the question where it pushed submit off-screen, and the nav row lost three of its four links under 700px. Sticky header carries the clock and a progress bar; previous/next are real buttons; the palette is a drawer below 1024px opened by a floating button that sits above the tab bar. Primary destinations move to a bottom tab bar on phones, data tables scroll inside their own card, and every control meets the 44px touch minimum. Verified with a headless browser across five viewports rather than by reading the stylesheet: scripts/audit_mobile.py fails on horizontal overflow, tap targets under 44px, and content hidden behind a fixed bar; scripts/shots.py captures each page at both widths. Both caught bugs I would otherwise have shipped, including a palette toggle that lived inside the drawer it opened and so could never be pressed on a phone. fmt_dur drops a leading zero hour, so a 24 minute paper reads 24m 00s.
212 lines
No EOL
8.1 KiB
JavaScript
212 lines
No EOL
8.1 KiB
JavaScript
// Exam screen: pane navigation, palette, per-question timing and autosave.
|
|
(() => {
|
|
const form = document.getElementById('exam-form');
|
|
if (!form) return;
|
|
|
|
const panes = [...document.querySelectorAll('.qpane')];
|
|
const palBtns = [...document.querySelectorAll('#pal .pal')];
|
|
const timerEl = document.getElementById('timer');
|
|
const progressEl = document.getElementById('exam-progress');
|
|
const fillEl = document.getElementById('exam-fill');
|
|
const countEl = document.getElementById('cbt-count');
|
|
let cur = 0;
|
|
|
|
const entered = new Map();
|
|
for (const p of panes) {
|
|
const box = p.querySelector('input');
|
|
if (box) entered.set(p.dataset.i, Number(box.dataset.t || 0));
|
|
}
|
|
|
|
function questionId(pane) {
|
|
const radios = [...pane.querySelectorAll('input[type=radio]')];
|
|
if (radios.length) return Number(radios[0].name.replace('q-', ''));
|
|
const num = pane.querySelector('input[type=text]');
|
|
return num ? Number(num.name.replace('num-', '')) : 0;
|
|
}
|
|
|
|
function collect(pane) {
|
|
const radios = [...pane.querySelectorAll('input[type=radio]')];
|
|
const picked = radios.find(r => r.checked);
|
|
const num = pane.querySelector('input[type=text]');
|
|
return {
|
|
question_id: questionId(pane),
|
|
selected: picked ? picked.value : (num ? num.value.trim() : ''),
|
|
marked_review: pane.dataset.review === '1',
|
|
time_secs: Number(entered.get(pane.dataset.i) || 0),
|
|
};
|
|
}
|
|
|
|
function show(i) {
|
|
cur = Math.max(0, Math.min(panes.length - 1, i));
|
|
panes.forEach((p, k) => { p.hidden = k !== cur; });
|
|
palBtns.forEach((b, k) => {
|
|
b.classList.toggle('cur', k === cur);
|
|
b.setAttribute('aria-current', k === cur ? 'true' : 'false');
|
|
});
|
|
const prev = panes[cur].querySelector('[data-act=prev]');
|
|
if (prev) prev.disabled = cur === 0;
|
|
|
|
// Only focus on a pointer device: stealing focus on a phone pops the keyboard over the options.
|
|
if (matchMedia('(pointer: fine)').matches) {
|
|
const f = panes[cur].querySelector('input:not([type=hidden])');
|
|
if (f) f.focus({ preventScroll: true });
|
|
}
|
|
// A pane change on a phone must start the question at the top, not halfway down.
|
|
panes[cur].scrollIntoView({ block: 'nearest' });
|
|
history.replaceState(null, '', '#q' + (cur + 1));
|
|
paintProgress();
|
|
}
|
|
|
|
function syncPalette(pane) {
|
|
const btn = palBtns[Number(pane.dataset.i)];
|
|
if (!btn) return;
|
|
const answered = collect(pane).selected !== '';
|
|
const review = pane.dataset.review === '1';
|
|
btn.classList.toggle('ans', answered && !review);
|
|
btn.classList.toggle('rev', answered && review);
|
|
btn.classList.toggle('skip', !answered);
|
|
btn.classList.remove('cur');
|
|
const mark = answered ? (review ? 'R' : '✓') : '·';
|
|
btn.setAttribute('aria-label', `question ${Number(pane.dataset.i) + 1}, ${answered ? 'answered' : 'blank'}${review ? ', marked for review' : ''}`);
|
|
btn.dataset.mark = mark;
|
|
paintProgress();
|
|
}
|
|
|
|
function counts() {
|
|
let answered = 0, review = 0;
|
|
for (const p of panes) {
|
|
const c = collect(p);
|
|
if (c.selected !== '') answered++;
|
|
if (c.marked_review) review++;
|
|
}
|
|
return { answered, review, left: panes.length - answered };
|
|
}
|
|
|
|
function paintProgress() {
|
|
const { answered, review, left } = counts();
|
|
const done = answered + review;
|
|
if (progressEl) {
|
|
progressEl.textContent = `${done} of ${panes.length} marked · question ${cur + 1}`;
|
|
}
|
|
if (fillEl) fillEl.style.width = `${panes.length ? (done / panes.length) * 100 : 0}%`;
|
|
if (countEl) {
|
|
countEl.textContent = `${answered} answered · ${left} blank${review ? ` · ${review} review` : ''}`;
|
|
}
|
|
const fabCount = document.getElementById('pal-fab-count');
|
|
if (fabCount) fabCount.textContent = left ? `· ${left} left` : '· all answered';
|
|
const bar = document.querySelector('.exam-bar');
|
|
if (bar) bar.classList.toggle('complete', left === 0);
|
|
}
|
|
|
|
let saving = false;
|
|
let pending = false;
|
|
async function save(all) {
|
|
// A second change while a save is in flight is coalesced into one follow-up write.
|
|
if (saving) { pending = all || pending; return; }
|
|
saving = true;
|
|
const panesToSave = all ? panes : [panes[cur]];
|
|
try {
|
|
await fetch(`${form.dataset.base}/attempt/${form.dataset.attempt}/answer`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ answers: panesToSave.map(collect) }),
|
|
});
|
|
} catch (_) {
|
|
// Server unreachable: the selection stays on screen and travels with the form submit.
|
|
}
|
|
saving = false;
|
|
if (pending) { const again = pending; pending = false; save(again); }
|
|
}
|
|
|
|
for (const pane of panes) {
|
|
pane.addEventListener('change', () => {
|
|
pane.querySelectorAll('.opt').forEach(o => {
|
|
const input = o.querySelector('input');
|
|
o.classList.toggle('sel', !!input && input.checked);
|
|
});
|
|
syncPalette(pane);
|
|
save(false);
|
|
});
|
|
pane.addEventListener('click', e => {
|
|
const btn = e.target.closest('button[data-act]');
|
|
if (!btn) return;
|
|
const act = btn.dataset.act;
|
|
if (act === 'prev') { show(cur - 1); }
|
|
if (act === 'save') { save(false).then(() => { if (cur < panes.length - 1) show(cur + 1); }); }
|
|
if (act === 'review') {
|
|
pane.dataset.review = pane.dataset.review === '1' ? '0' : '1';
|
|
btn.classList.toggle('danger', pane.dataset.review === '1');
|
|
syncPalette(pane);
|
|
save(false);
|
|
}
|
|
if (act === 'clear') {
|
|
pane.querySelectorAll('input[type=radio]').forEach(r => { r.checked = false; });
|
|
const num = pane.querySelector('input[type=text]');
|
|
if (num) num.value = '';
|
|
pane.querySelectorAll('.opt').forEach(o => o.classList.remove('sel'));
|
|
syncPalette(pane);
|
|
save(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
palBtns.forEach((b, i) => b.addEventListener('click', () => show(i)));
|
|
|
|
// On a phone the palette is a drawer, so a tap there should also close it.
|
|
const toggle = document.getElementById('pal-toggle');
|
|
const side = document.querySelector('.cbt-side');
|
|
if (toggle && side) {
|
|
const setOpen = open => {
|
|
side.classList.toggle('open', open);
|
|
toggle.setAttribute('aria-expanded', String(open));
|
|
// Trap nothing, but return focus to the opener when the drawer closes.
|
|
if (!open) toggle.focus({ preventScroll: true });
|
|
};
|
|
toggle.addEventListener('click', () => setOpen(!side.classList.contains('open')));
|
|
side.addEventListener('click', e => {
|
|
if (e.target.closest('[data-close-palette]')) { setOpen(false); return; }
|
|
if (e.target.closest('#pal .pal') && matchMedia('(max-width: 1024px)').matches) setOpen(false);
|
|
});
|
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') setOpen(false); });
|
|
}
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
|
|
if (e.key === 'ArrowRight') show(cur + 1);
|
|
if (e.key === 'ArrowLeft') show(cur - 1);
|
|
});
|
|
|
|
let left = Number(timerEl.dataset.secs || 0);
|
|
if (left > 0) {
|
|
const tick = setInterval(() => {
|
|
left -= 1;
|
|
if (left <= 0) {
|
|
clearInterval(tick);
|
|
form.requestSubmit();
|
|
return;
|
|
}
|
|
const h = Math.floor(left / 3600);
|
|
const m = Math.floor(left / 60) % 60;
|
|
const s = left % 60;
|
|
const pad = n => String(n).padStart(2, '0');
|
|
timerEl.textContent = h > 0 ? `${h}:${pad(m)}:${pad(s)}` : m > 0 ? `${m}:${pad(s)}` : `${s}s`;
|
|
// Warn in the last ten minutes, and again in the last minute.
|
|
timerEl.classList.toggle('urgent', left <= 600 && left > 60);
|
|
timerEl.classList.toggle('critical', left <= 60);
|
|
}, 1000);
|
|
}
|
|
|
|
form.addEventListener('submit', e => {
|
|
const { left: blank, review } = counts();
|
|
const msg = blank
|
|
? `${blank} question${blank > 1 ? 's are' : ' is'} still blank. Submit anyway?`
|
|
: review
|
|
? `${review} question${review > 1 ? 's' : ''} marked for review. Submit anyway?`
|
|
: 'Submit this paper? You cannot change answers afterwards.';
|
|
if (!confirm(msg)) e.preventDefault();
|
|
});
|
|
|
|
const hash = Number((location.hash.match(/q(\d+)/) || [])[1]);
|
|
panes.forEach(syncPalette);
|
|
show(hash ? hash - 1 : 0);
|
|
})(); |