Enhance navigation after save by improving URL handling and fallback mechanisms for question transitions

This commit is contained in:
Ross
2026-01-05 14:04:30 +00:00
parent 3928df6d75
commit 20a06323d5
2 changed files with 82 additions and 75 deletions
+70 -70
View File
@@ -85,20 +85,20 @@
.exam-take-container { max-width: 980px; margin: 0 auto; padding: 1rem; } .exam-take-container { max-width: 980px; margin: 0 auto; padding: 1rem; }
.no-select { user-select: none; } .no-select { user-select: none; }
.flex-container { display: flex; align-items: center; gap: 0.75rem; } .flex-container { display: flex; align-items: center; gap: 0.75rem; }
/* fieldWrapper is used around each label + input; ensure it stretches the full width */ /* fieldWrapper is used around each label + input; ensure it stretches the full width */
.fieldWrapper { width: 100%; display: flex; align-items: center; gap: 0.75rem; } .fieldWrapper { width: 100%; display: flex; align-items: center; gap: 0.75rem; }
/* We keep two main columns inside each fieldWrapper: /* We keep two main columns inside each fieldWrapper:
- the label (question-text) which should take the bulk of space and sit to the left on wide screens - the label (question-text) which should take the bulk of space and sit to the left on wide screens
- the input container (.flex-1) which holds the input and a post-input label; the post-input should float right - the input container (.flex-1) which holds the input and a post-input label; the post-input should float right
*/ */
.flex-8 { flex: 8; } .flex-8 { flex: 8; }
.flex-1 { flex: 1; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; } .flex-1 { flex: 1; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
/* Left-align label text on wide screens so the question text appears on the left, input between, and the /* Left-align label text on wide screens so the question text appears on the left, input between, and the
post-input label floats to the right. Padding keeps visual separation. */ post-input label floats to the right. Padding keeps visual separation. */
.question-text { margin: 0; text-align: left; padding-right: 0.5rem; } .question-text { margin: 0; text-align: left; padding-right: 0.5rem; }
/* Answer items styled as cards */ /* Answer items styled as cards */
.physics-answer-list { list-style: none; padding: 0; margin: 0; } .physics-answer-list { list-style: none; padding: 0; margin: 0; }
@@ -118,8 +118,8 @@
.question-text { margin: 0; } .question-text { margin: 0; }
/* Post-input badge (shows True/False text next to inputs) */ /* Post-input badge (shows True/False text next to inputs) */
.postinput { display: inline-block; min-width: 3rem; text-align: right; color: var(--muted); } .postinput { display: inline-block; min-width: 3rem; text-align: right; color: var(--muted); }
.physics-answer-list input { margin: 0 0.25rem 0 0; } .physics-answer-list input { margin: 0 0.25rem 0 0; }
/* show a compact post-label on the right of the input container */ /* show a compact post-label on the right of the input container */
.physics-answer-list input:checked + .postinput::after { .physics-answer-list input:checked + .postinput::after {
content: 'True'; color: var(--success); font-weight: 600; margin-left: 0.4rem; content: 'True'; color: var(--success); font-weight: 600; margin-left: 0.4rem;
@@ -177,15 +177,15 @@
.mt-2 .save.btn.btn-default { background: var(--feedback-bg); border: 1px solid var(--card-border); color: var(--text); } .mt-2 .save.btn.btn-default { background: var(--feedback-bg); border: 1px solid var(--card-border); color: var(--text); }
/* Overview button - make it stand out using the primary color */ /* Overview button - make it stand out using the primary color */
#overview-button { background: var(--primary); border-color: var(--primary); color: #fff; } #overview-button { background: var(--primary); border-color: var(--primary); color: #fff; }
#overview-button:hover { filter: brightness(0.95); box-shadow: 0 3px 8px rgba(13,110,253,0.18); } #overview-button:hover { filter: brightness(0.95); box-shadow: 0 3px 8px rgba(13,110,253,0.18); }
/* Previous / Next button colors */ /* Previous / Next button colors */
.mt-2 button[name="previous"] { background: var(--info); border-color: var(--info); color: #fff; } .mt-2 button[name="previous"] { background: var(--info); border-color: var(--info); color: #fff; }
.mt-2 button[name="previous"]:hover { filter: brightness(0.95); box-shadow: 0 2px 6px rgba(0,0,0,0.08); } .mt-2 button[name="previous"]:hover { filter: brightness(0.95); box-shadow: 0 2px 6px rgba(0,0,0,0.08); }
.mt-2 button[name="next"] { background: var(--info); border-color: var(--info); color: #fff; } .mt-2 button[name="next"] { background: var(--info); border-color: var(--info); color: #fff; }
.mt-2 button[name="next"]:hover { filter: brightness(0.95); box-shadow: 0 3px 8px rgba(13,110,253,0.18); } .mt-2 button[name="next"]:hover { filter: brightness(0.95); box-shadow: 0 3px 8px rgba(13,110,253,0.18); }
/* Small helper: make labels wrap nicely on small screens */ /* Small helper: make labels wrap nicely on small screens */
@media (max-width: 576px) { @media (max-width: 576px) {
@@ -201,77 +201,77 @@
{% endblock %} {% endblock %}
{% block js %} {% block js %}
<script> <script>
/* HTMX swap helpers: preserve scroll and container height to avoid page jumping /* HTMX swap helpers: preserve scroll and container height to avoid page jumping
Listens for htmx:beforeSwap and htmx:afterSwap on the question fragment container. Listens for htmx:beforeSwap and htmx:afterSwap on the question fragment container.
*/ */
(function(){ (function(){
function getTarget(evt){ function getTarget(evt){
return (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target; return (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
} }
document.addEventListener('htmx:beforeSwap', function(evt){ document.addEventListener('htmx:beforeSwap', function(evt){
try{ try{
var target = getTarget(evt); var target = getTarget(evt);
if(!target || target.id !== 'question-fragment') return; if(!target || target.id !== 'question-fragment') return;
// save scroll position // save scroll position
target.__savedScrollY = window.scrollY || window.pageYOffset || 0; target.__savedScrollY = window.scrollY || window.pageYOffset || 0;
// preserve current height to avoid layout shift while fragment is swapping // preserve current height to avoid layout shift while fragment is swapping
target.style.minHeight = target.offsetHeight + 'px'; target.style.minHeight = target.offsetHeight + 'px';
}catch(e){ console && console.error && console.error(e); } }catch(e){ console && console.error && console.error(e); }
}); });
document.addEventListener('htmx:afterSwap', function(evt){ document.addEventListener('htmx:afterSwap', function(evt){
try{ try{
var target = getTarget(evt); var target = getTarget(evt);
if(!target || target.id !== 'question-fragment') return; if(!target || target.id !== 'question-fragment') return;
var y = target.__savedScrollY; var y = target.__savedScrollY;
if(typeof y === 'number') window.scrollTo(0, y); if(typeof y === 'number') window.scrollTo(0, y);
// remove the minHeight after a short delay to allow content to layout // remove the minHeight after a short delay to allow content to layout
setTimeout(function(){ target.style.minHeight = ''; }, 60); setTimeout(function(){ target.style.minHeight = ''; }, 60);
// notify fragment-loaded so fragment init code can listen if required // notify fragment-loaded so fragment init code can listen if required
target.dispatchEvent(new CustomEvent('fragment:loaded', { bubbles: true })); target.dispatchEvent(new CustomEvent('fragment:loaded', { bubbles: true }));
}catch(e){ console && console.error && console.error(e); } }catch(e){ console && console.error && console.error(e); }
}); });
})(); })();
</script> </script>
<script> <script>
// Global listener for server-triggered save confirmations. // Global listener for server-triggered save confirmations.
// The view sets an `HX-Trigger` header named `saved` when an HTMX save occurs. // The view sets an `HX-Trigger` header named `saved` when an HTMX save occurs.
document.addEventListener('saved', function(evt){ document.addEventListener('saved', function(evt){
try { try {
// Prefer toastr if available // Prefer toastr if available
if (typeof toastr !== 'undefined' && toastr && typeof toastr.success === 'function') { if (typeof toastr !== 'undefined' && toastr && typeof toastr.success === 'function') {
toastr.success('Saved'); toastr.success('Saved');
return; return;
} }
// Lightweight fallback toast // Lightweight fallback toast
const id = 'htmx-save-toast'; const id = 'htmx-save-toast';
let el = document.getElementById(id); let el = document.getElementById(id);
if (!el) { if (!el) {
el = document.createElement('div'); el = document.createElement('div');
el.id = id; el.id = id;
el.style.position = 'fixed'; el.style.position = 'fixed';
el.style.top = '1rem'; el.style.top = '1rem';
el.style.right = '1rem'; el.style.right = '1rem';
el.style.zIndex = 2000; el.style.zIndex = 2000;
el.style.padding = '0.6rem 0.9rem'; el.style.padding = '0.6rem 0.9rem';
el.style.background = 'rgba(40,167,69,0.95)'; el.style.background = 'rgba(40,167,69,0.95)';
el.style.color = '#fff'; el.style.color = '#fff';
el.style.borderRadius = '0.4rem'; el.style.borderRadius = '0.4rem';
el.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)'; el.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
el.style.fontWeight = '600'; el.style.fontWeight = '600';
el.style.opacity = '0'; el.style.opacity = '0';
el.style.transition = 'opacity 220ms ease-in-out'; el.style.transition = 'opacity 220ms ease-in-out';
document.body.appendChild(el); document.body.appendChild(el);
} }
el.textContent = 'Saved'; el.textContent = 'Saved';
// show // show
requestAnimationFrame(function(){ el.style.opacity = '1'; }); requestAnimationFrame(function(){ el.style.opacity = '1'; });
// hide after 1.6s // hide after 1.6s
setTimeout(function(){ el.style.opacity = '0'; }, 1600); setTimeout(function(){ el.style.opacity = '0'; }, 1600);
} catch (e) { console && console.error && console.error(e); } } catch (e) { console && console.error && console.error(e); }
}); });
</script> </script>
{% endblock %} {% endblock %}
@@ -170,8 +170,15 @@
if(hxTrigger && hxTrigger.indexOf('saved') !== -1){ if(hxTrigger && hxTrigger.indexOf('saved') !== -1){
handled = true; handled = true;
if(window.htmx && typeof htmx.off === 'function') htmx.off('htmx:afterRequest', htmxHandler); if(window.htmx && typeof htmx.off === 'function') htmx.off('htmx:afterRequest', htmxHandler);
document.getElementById('goto-button').value = destIndex; try{
$('#goto-button').click(); const pageUrl = `{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=0 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=0 %}{% endif %}`.replace('/0/', '/' + destIndex + '/');
window.location = pageUrl;
}catch(e){
if(document.getElementById('goto-button')){
document.getElementById('goto-button').value = destIndex;
$('#goto-button').click();
}
}
} }
}catch(e){/* ignore */} }catch(e){/* ignore */}
} }
@@ -181,13 +188,13 @@
} }
// As a fallback listen for the custom 'saved' event // As a fallback listen for the custom 'saved' event
const onSavedFallback = function(){ if(handled) return; handled = true; document.removeEventListener('saved', onSavedFallback); document.getElementById('goto-button').value = destIndex; $('#goto-button').click(); }; const onSavedFallback = function(){ if(handled) return; handled = true; document.removeEventListener('saved', onSavedFallback); try{ const pageUrl = `{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=0 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=0 %}{% endif %}`.replace('/0/', '/' + destIndex + '/'); window.location = pageUrl; }catch(e){ if(document.getElementById('goto-button')){ document.getElementById('goto-button').value = destIndex; $('#goto-button').click(); } } };
document.addEventListener('saved', onSavedFallback); document.addEventListener('saved', onSavedFallback);
tmp.click(); tmp.click();
// final timeout fallback // final timeout fallback
setTimeout(function(){ if(!handled){ try{ document.removeEventListener('saved', onSavedFallback); if(window.htmx && typeof htmx.off === 'function') htmx.off('htmx:afterRequest', htmxHandler); document.getElementById('goto-button').value = destIndex; $('#goto-button').click(); }catch(e){} } }, 1500); setTimeout(function(){ if(!handled){ try{ document.removeEventListener('saved', onSavedFallback); if(window.htmx && typeof htmx.off === 'function') htmx.off('htmx:afterRequest', htmxHandler); const pageUrl = `{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=0 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=0 %}{% endif %}`.replace('/0/', '/' + destIndex + '/'); window.location = pageUrl; }catch(e){ try{ if(document.getElementById('goto-button')){ document.getElementById('goto-button').value = destIndex; $('#goto-button').click(); } }catch(e){} } } }, 1500);
return; return;
} }
} }