Compare commits
3 Commits
fix/97
...
fix/95-cri
| Author | SHA1 | Date | |
|---|---|---|---|
| eb156cf09e | |||
| 6ace6c43ed | |||
| 65ac8b2f19 |
24
index.html
24
index.html
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
183
tests/test_crisis_overlay_keyboard.py
Normal file
183
tests/test_crisis_overlay_keyboard.py
Normal 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)
|
||||
Reference in New Issue
Block a user