diff --git a/index.html b/index.html index d049580..06cff1c 100644 --- a/index.html +++ b/index.html @@ -983,12 +983,60 @@ Sovereignty and service always.`; // ===== OVERLAY ===== + + // Focus trap: cycle through focusable elements within the crisis overlay + function getOverlayFocusableElements() { + return crisisOverlay.querySelectorAll( + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + } + + function trapFocusInOverlay(e) { + if (!crisisOverlay.classList.contains('active')) return; + if (e.key !== 'Tab') return; + + var focusable = getOverlayFocusableElements(); + if (focusable.length === 0) return; + + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + + if (e.shiftKey) { + // Shift+Tab: if on first, wrap to last + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + // Tab: if on last, wrap to first + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + + // Store the element that had focus before the overlay opened + var _preOverlayFocusElement = null; + function showOverlay() { + // Save current focus for restoration on dismiss + _preOverlayFocusElement = document.activeElement; + crisisOverlay.classList.add('active'); overlayDismissBtn.disabled = true; var countdown = 10; overlayDismissBtn.textContent = 'Continue to chat (' + countdown + 's)'; + // Disable background interaction via inert attribute + var mainApp = document.querySelector('.app'); + if (mainApp) mainApp.setAttribute('inert', ''); + // Also hide from assistive tech + var chatSection = document.getElementById('chat'); + if (chatSection) chatSection.setAttribute('aria-hidden', 'true'); + var footerEl = document.querySelector('footer'); + if (footerEl) footerEl.setAttribute('aria-hidden', 'true'); + if (overlayTimer) clearInterval(overlayTimer); overlayTimer = setInterval(function() { countdown--; @@ -1005,6 +1053,9 @@ Sovereignty and service always.`; overlayDismissBtn.focus(); } + // Register focus trap on document (always listening, gated by class check) + document.addEventListener('keydown', trapFocusInOverlay); + overlayDismissBtn.addEventListener('click', function() { if (!overlayDismissBtn.disabled) { crisisOverlay.classList.remove('active'); @@ -1012,7 +1063,22 @@ Sovereignty and service always.`; clearInterval(overlayTimer); overlayTimer = null; } - msgInput.focus(); + + // Re-enable background interaction + var mainApp = document.querySelector('.app'); + if (mainApp) mainApp.removeAttribute('inert'); + var chatSection = document.getElementById('chat'); + if (chatSection) chatSection.removeAttribute('aria-hidden'); + var footerEl = document.querySelector('footer'); + if (footerEl) footerEl.removeAttribute('aria-hidden'); + + // Restore focus to the element that had it before the overlay opened + if (_preOverlayFocusElement && typeof _preOverlayFocusElement.focus === 'function') { + _preOverlayFocusElement.focus(); + } else { + msgInput.focus(); + } + _preOverlayFocusElement = null; } }); diff --git a/tests/test_crisis_overlay_focus_trap.py b/tests/test_crisis_overlay_focus_trap.py new file mode 100644 index 0000000..f657afc --- /dev/null +++ b/tests/test_crisis_overlay_focus_trap.py @@ -0,0 +1,57 @@ +import pathlib +import re +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +INDEX_HTML = ROOT / 'index.html' + + +class TestCrisisOverlayFocusTrap(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.html = INDEX_HTML.read_text() + + def test_overlay_registers_tab_key_focus_trap(self): + self.assertRegex( + self.html, + r"function\s+trapFocusInOverlay\s*\(e\)", + 'Expected crisis overlay focus trap handler to exist.', + ) + self.assertRegex( + self.html, + r"if\s*\(e\.key\s*!==\s*'Tab'\)\s*return;", + 'Expected focus trap handler to guard on Tab key events.', + ) + self.assertRegex( + self.html, + r"document\.addEventListener\('keydown',\s*trapFocusInOverlay\)", + 'Expected overlay focus trap to register on document keydown.', + ) + + def test_overlay_disables_background_interaction(self): + self.assertRegex( + self.html, + r"mainApp\.setAttribute\('inert',\s*''\)", + 'Expected overlay to set inert on the main app while active.', + ) + self.assertRegex( + self.html, + r"mainApp\.removeAttribute\('inert'\)", + 'Expected overlay dismissal to remove inert from the main app.', + ) + + def test_overlay_restores_focus_after_dismiss(self): + self.assertRegex( + self.html, + r"_preOverlayFocusElement\s*=\s*document\.activeElement", + 'Expected overlay to remember the pre-overlay focus target.', + ) + self.assertRegex( + self.html, + r"_preOverlayFocusElement\.focus\(\)", + 'Expected overlay dismissal to restore focus to the prior target.', + ) + + +if __name__ == '__main__': + unittest.main()