Compare commits

...

3 Commits

3 changed files with 251 additions and 0 deletions

View File

@@ -993,6 +993,16 @@ Sovereignty and service always.`;
function trapFocusInOverlay(e) {
if (!crisisOverlay.classList.contains('active')) return;
// Escape: close overlay and restore focus
if (e.key === 'Escape') {
e.preventDefault();
if (!overlayDismissBtn.disabled) {
overlayDismissBtn.click();
}
return;
}
if (e.key !== 'Tab') return;
var focusable = getOverlayFocusableElements();
@@ -1001,6 +1011,20 @@ Sovereignty and service always.`;
var first = focusable[0];
var last = focusable[focusable.length - 1];
// If focus is outside the overlay entirely, bring it back
var focusInOverlay = false;
for (var i = 0; i < focusable.length; i++) {
if (focusable[i] === document.activeElement) {
focusInOverlay = true;
break;
}
}
if (!focusInOverlay) {
e.preventDefault();
first.focus();
return;
}
if (e.shiftKey) {
// Shift+Tab: if on first, wrap to last
if (document.activeElement === first) {

View File

@@ -52,6 +52,50 @@ class TestCrisisOverlayFocusTrap(unittest.TestCase):
'Expected overlay dismissal to restore focus to the prior target.',
)
def test_overlay_escape_key_closes(self):
"""Crisis overlay must close on Escape key press (issue #95)."""
self.assertIn(
"Escape: close overlay",
self.html,
'Expected trapFocusInOverlay to handle Escape key.',
)
self.assertRegex(
self.html,
r"e\.key\s*===\s*'Escape'",
'Expected Escape key check in focus trap handler.',
)
self.assertRegex(
self.html,
r"overlayDismissBtn\.click\(\)",
'Expected Escape to trigger dismiss button click.',
)
def test_overlay_focus_recovery(self):
"""If focus escapes the overlay, Tab must bring it back (issue #95)."""
self.assertIn(
"focusInOverlay",
self.html,
'Expected focus trap to check if focus is inside overlay.',
)
self.assertIn(
"If focus is outside the overlay entirely, bring it back",
self.html,
'Expected comment documenting focus recovery behavior.',
)
def test_overlay_aria_modal(self):
"""Overlay must have role=dialog and aria-modal for screen readers."""
self.assertRegex(
self.html,
r'id="crisis-overlay"[^>]*role="dialog"',
'Expected crisis overlay to have role="dialog".',
)
self.assertRegex(
self.html,
r'id="crisis-overlay"[^>]*aria-modal="true"',
'Expected crisis overlay to have aria-modal="true".',
)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,183 @@
"""
Playwright E2E test: Crisis overlay keyboard navigation (issue #95).
Acceptance criteria tested:
- Tab cycles through all focusable elements in overlay
- Shift+Tab reverses direction
- Escape closes overlay and returns focus to chat input
- Focus cannot escape the overlay while it is active
Prerequisites:
pip install playwright
playwright install chromium
Run:
pytest tests/test_crisis_overlay_keyboard.py -v
"""
import pytest
try:
from playwright.sync_api import sync_playwright, expect
except ImportError:
pytest.skip("playwright not installed", allow_module_level=True)
import pathlib
import subprocess
import time
import socket
INDEX = pathlib.Path(__file__).resolve().parents[1] / "index.html"
PORT = 18923
def _port_open(port):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
return s.connect_ex(("127.0.0.1", port)) == 0
@pytest.fixture(scope="module")
def server():
"""Serve index.html on localhost for Playwright."""
proc = subprocess.Popen(
["python3", "-m", "http.server", str(PORT)],
cwd=str(INDEX.parent),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
for _ in range(30):
if _port_open(PORT):
break
time.sleep(0.2)
yield f"http://127.0.0.1:{PORT}"
proc.kill()
proc.wait()
@pytest.fixture(scope="module")
def browser_ctx(server):
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
context = browser.new_context()
page = context.new_page()
page.goto(server, wait_until="domcontentloaded")
yield page
context.close()
browser.close()
def _trigger_crisis_overlay(page):
"""Programmatically show the crisis overlay (bypasses 10s countdown)."""
page.evaluate("""() => {
var overlay = document.getElementById('crisis-overlay');
overlay.classList.add('active');
var btn = document.getElementById('overlay-dismiss-btn');
btn.disabled = false;
btn.textContent = 'Continue to chat';
btn.focus();
}""")
def _dismiss_overlay(page):
"""Close overlay if open."""
page.evaluate("""() => {
var overlay = document.getElementById('crisis-overlay');
if (overlay.classList.contains('active')) {
overlay.classList.remove('active');
var btn = document.getElementById('overlay-dismiss-btn');
btn.disabled = false;
}
}""")
class TestCrisisOverlayKeyboard:
def test_tab_cycles_through_focusable_elements(self, browser_ctx):
"""Tab should cycle through focusable elements within the overlay."""
page = browser_ctx
_trigger_crisis_overlay(page)
focusable = page.evaluate("""() => {
var overlay = document.getElementById('crisis-overlay');
return overlay.querySelectorAll(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
).length;
}""")
assert focusable >= 1, "Overlay must have at least one focusable element"
# Get the dismiss button
dismiss = page.locator("#overlay-dismiss-btn")
expect(dismiss).to_be_focused()
# Tab to the call link
page.keyboard.press("Tab")
call_link = page.locator(".overlay-call")
expect(call_link).to_be_focused()
# Tab again should wrap back to dismiss button
page.keyboard.press("Tab")
expect(dismiss).to_be_focused()
_dismiss_overlay(page)
def test_shift_tab_reverses_direction(self, browser_ctx):
"""Shift+Tab should reverse the tab order and wrap."""
page = browser_ctx
_trigger_crisis_overlay(page)
dismiss = page.locator("#overlay-dismiss-btn")
expect(dismiss).to_be_focused()
# Shift+Tab from dismiss should wrap to last element (call link)
page.keyboard.press("Shift+Tab")
call_link = page.locator(".overlay-call")
expect(call_link).to_be_focused()
# Shift+Tab from call link should wrap back to dismiss
page.keyboard.press("Shift+Tab")
expect(dismiss).to_be_focused()
_dismiss_overlay(page)
def test_escape_closes_overlay(self, browser_ctx):
"""Escape key should close the crisis overlay."""
page = browser_ctx
_trigger_crisis_overlay(page)
overlay = page.locator("#crisis-overlay")
expect(overlay).to_have_class(/active/)
page.keyboard.press("Escape")
expect(overlay).not_to_have_class(/active/)
def test_escape_returns_focus_to_chat_input(self, browser_ctx):
"""After Escape, focus should return to the chat input."""
page = browser_ctx
# Focus the message input first
msg_input = page.locator("#msg-input")
msg_input.focus()
expect(msg_input).to_be_focused()
# Show overlay
_trigger_crisis_overlay(page)
# Dismiss with Escape
page.keyboard.press("Escape")
# Focus should return to chat input
expect(msg_input).to_be_focused()
def test_focus_does_not_escape_overlay(self, browser_ctx):
"""Focus must not reach background elements while overlay is active."""
page = browser_ctx
_trigger_crisis_overlay(page)
# Tab multiple times — focus should stay within overlay
for _ in range(10):
page.keyboard.press("Tab")
focused_tag = page.evaluate("() => document.activeElement.tagName.toLowerCase()")
focused_id = page.evaluate("() => document.activeElement.id || ''")
# Focus should be on overlay-dismiss-btn or overlay-call link
assert focused_id in ("overlay-dismiss-btn", "") or page.evaluate("() => document.activeElement.classList.contains('overlay-call')"), f"Focus escaped overlay to: {focused_tag}#{focused_id}"
_dismiss_overlay(page)