1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

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.
This commit is contained in:
catborise 2026-10-19 21:45:07 +03:00
parent d781bbfcbb
commit ca3b5c8d1c
2 changed files with 62 additions and 1 deletions

View file

@ -53,7 +53,7 @@
</h3>
</div>
</div>
{% bootstrap_messages %}
<div id="messages">{% bootstrap_messages %}</div>
{% block content %}{% endblock %}
</div>
@ -66,6 +66,18 @@
<script src="{% static 'js/bootstrap.bundle.min.js' %}"></script>
<!-- JavaScript Cookie -->
<script src="{% static 'js/js.cookie.js' %}"></script>
<script>
// after a refused submission, start at the error: the first visible
// invalid field, else the error message
(function () {
const field = [...document.querySelectorAll(".is-invalid")].find(e => e.offsetParent !== null);
const target = field || document.querySelector("#messages .alert-danger");
if (target) {
if (!field) target.tabIndex = -1;
target.focus();
}
})();
</script>
{% block script %}{% endblock %}
</body>
</html>

View file

@ -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"<script>\s*(// after a refused submission.*?)</script>",
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)