From ca3b5c8d1cb39e21b56547ade4c119dab7386ec9 Mon Sep 17 00:00:00 2001 From: catborise Date: Mon, 19 Oct 2026 21:45:07 +0300 Subject: [PATCH] feat(templates): a page that shows an error starts with the focus on it After a refused submission the focus stayed on the page body: a keyboard or screen reader user had to find the error. base.html now focuses the first visible invalid field (the inline errors of the page forms), else the first error message at the top of the page (the modal forms, and pages whose host does not answer). The messages sit in a #messages container so the script finds only them. --- templates/base.html | 14 +++++++- webvirtcloud/tests/test_error_focus.py | 49 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 webvirtcloud/tests/test_error_focus.py diff --git a/templates/base.html b/templates/base.html index a68ec054..d96bf019 100644 --- a/templates/base.html +++ b/templates/base.html @@ -53,7 +53,7 @@ - {% bootstrap_messages %} +
{% bootstrap_messages %}
{% block content %}{% endblock %} @@ -66,6 +66,18 @@ + {% block script %}{% endblock %} diff --git a/webvirtcloud/tests/test_error_focus.py b/webvirtcloud/tests/test_error_focus.py new file mode 100644 index 00000000..f58d8bd6 --- /dev/null +++ b/webvirtcloud/tests/test_error_focus.py @@ -0,0 +1,49 @@ +"""After a refused submission the page starts at the error: base.html's +script focuses the first visible invalid field, else the error message. +The script runs in Node against a stand-in document.""" + +import json +import re +import shutil +import subprocess +import unittest +from pathlib import Path + +from django.conf import settings + +SCRIPT = re.search( + r"", + Path(settings.BASE_DIR, "templates/base.html").read_text(), + re.S, +).group(1) + +HARNESS = """ +const els = %s; +let focused = null; +for (const e of els) e.focus = () => { focused = e; }; +const document = { + querySelectorAll: sel => els.filter(e => e.sel === sel), + querySelector: sel => els.find(e => e.sel === sel) || null, +}; +%s +console.log(JSON.stringify({focused: focused && focused.name, tabIndex: focused && focused.tabIndex !== undefined ? focused.tabIndex : null})); +""" + + +class ErrorFocusTests(unittest.TestCase): + @unittest.skipUnless(shutil.which("node"), "node is not installed") + def test_focus_goes_to_the_error(self): + invalid = {"sel": ".is-invalid", "offsetParent": {}, "name": "field"} + hidden = {"sel": ".is-invalid", "offsetParent": None, "name": "hidden"} + alert = {"sel": "#messages .alert-danger", "offsetParent": {}, "name": "message"} + for els, expected in ( + ([hidden, invalid, alert], {"focused": "field", "tabIndex": None}), + ([hidden, alert], {"focused": "message", "tabIndex": -1}), + ([], {"focused": None, "tabIndex": None}), + ): + with self.subTest(expected=expected["focused"]): + result = subprocess.run( + ["node", "-e", HARNESS % (json.dumps(els), SCRIPT)], capture_output=True, text=True, timeout=30 + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), expected)