Building accessible web forms in Django
A step by step reference to building accessible web forms in Django.
The complete source code can be found on GitHub.
Introduction
Accessibility in Django is taken very seriously. However, as with almost everything, there is no size that fits all, and there is only so much Django can offer by default without resulting too opinionated.
Recent version of Django are already improving the accessibility compartment.
Starting from Django 5.0 for example, form fields receive the aria-describedby
attribute pointing to their help text if the latter is present. Also, invalid fields expose aria-invalid="true"
.
Starting from Django 5.1, fieldsets receive aria-describedby
as well.
When dealing with web forms, we need to apply techniques to augment accessibility if needed, and be also very careful not to break existing capabilities.
This guide offers a collection of techniques for building accessible web forms in Django, by improving the already good defaults.
Disclaimer
Keep in mind that this reference is non-exhaustive and that the changes proposed in this post might not suit your project! There is no "one size fits all" in web accessibility: every project has its requirements and constraints.
Progressive enhancement
In this post we work our way by progressively enhancing the form by first using only standard HTML features. This is know as progressive enhancement.
This tecnique suggests that you should make your page work with HTML first, and only later add JavaScript as needed.
Sources:
Setting the ground: customizing form field group template project-wide
Then main requirement for this reference, in order to apply the techniques presented, is to customize the form renderer of your Django project so that you can provide your own form field group template project-wide.
Please follow Rendering form fields as group in Django before moving forward.
Provide descriptive field errors with aria-describedby
Technique: associate form fields and their respective errors with aria-describedby
.
At the time of writing using aria-describedby
to associate invalid fields with their error message is the most robust way for announcing errors to screen readers (see sources below).
To apply this technique in Django, we augment the default Django field group template by taking control of error rendering:
{% if field.use_fieldset %}
<fieldset{% if field.help_text and field.auto_id and "aria-describedby" not in field.field.widget.attrs %} aria-describedby="{{ field.auto_id }}_helptext"{% endif %}>
{% if field.label %}{{ field.legend_tag }}{% endif %}
{% else %}
{% if field.label %}{{ field.label_tag }}{% endif %}
{% endif %}
{% if field.help_text %}<div class="helptext"{% if field.auto_id %} id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</div>{% endif %}
{{ field }}
- {{ field.errors }}
+ <ul id="{% if field.auto_id %}{{ field.auto_id }}_{% endif %}errorlist" class="errorlist">
+ {% for error in field.errors %}
+ <li class="error">{{ error }}</li>
+ {% endfor %}
+ </ul>
{% if field.use_fieldset %}</fieldset>{% endif %}
Here we wrap field errors in a list, the same as Django does by default, but setting in addition the id
to the error list so that the error box will have id_fieldname_errorlist
.
With the field associated to the relevant error list, we set aria-describedby
in the Django form:
# books/forms.py
from django import forms
from books.models import Book
class BookForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name, field in self.fields.items():
auto_id = self.auto_id % field_name
field.widget.attrs.update(
{
"aria-describedby": f"{auto_id}_errorlist {auto_id}_helptext",
}
)
class Meta:
model = Book
fields = ["title", "publication_date", "author"]
help_texts = {
"title": "Enter the title of the book.",
"publication_date": "Enter the publication date.",
"author": "Select the author of the book.",
}
If a field becomes invalid after being validated on submit, Django will render the following markup :
<label for="id_title">Title:</label>
<div class="helptext" id="id_title_helptext">Enter the title of the book.</div>
<input type="text" name="title" maxlength="200" aria-describedby="id_title_errorlist id_title_helptext" required="" aria-invalid="true" id="id_title">
<ul class="id_title_errorlist">
<li>This field is required.</li>
</ul>
Here aria-describedby="id_title_errorlist id_title_helptext"
points both to the help text and to the error box. This ensures that screen reader will properly announce the field errors to the user.
Testing field errors and aria-describedby
Testing field errors described with aria-describedby
can be exercised with unbrowsed as follows (the example uses pytest):
import pytest
from unbrowsed import parse_html, get_by_role
@pytest.mark.django_db
def test_book_create(client):
"""Test post request to the book create view."""
response = client.post(
"/books/create/",
{
"title": "",
},
)
dom = parse_html(response.content)
get_by_role(dom, "textbox", description="This field is required. Enter the title of the book.")
Sources:
- Describing aria-describedby
- Support for aria-errormessage is getting better, but still not there yet
- Exposing field errors
- Implementing aria-describedby for Web Accessibility
Disabling the default browser validation
Disabling the default browser validation might seem incomprehensible to most developers, but as experience teaches, and confirmed also by multiple sources, the default browser validation makes more harm than good. Therefore it is reasonable to disable it altogether.
What to do instead?
- rely on server-side validation on submit as the default
- progressively enhance with custom JavaScript validation if needed (shown later in the guide)
To disable the default browser validation, just put the novalidate
attribute on your form element:
{% extends "common/base.html" %}
{% block content %}
<form method="post" novalidate>
{% csrf_token %}
<div>
{{ form.title.as_field_group}}
</div>
<div>
{{ form.publication_date.as_field_group}}
</div>
<div>
{{ form.description.as_field_group}}
</div>
<div>
{{ form.author.as_field_group}}
</div>
<button type="submit">Submit</button>
</form>
{% endblock %}
Testing the novalidate attribute
Testing form attributes can be exercised with unbrowsed as follows (the example uses pytest):
import pytest
from unbrowsed import parse_html, get_by_role
@pytest.mark.django_db
def test_book_create(client):
"""Test get request to the book create view."""
response = client.get("/books/create/")
dom = parse_html(response.content)
assert get_by_role(dom, "form").to_have_attribute("novalidate")
Sources:
- Avoid default field validation
- Browser built-in validation doesn’t do everything
- Required attribute requirements
Late validation on focusout / blur
Coming soon.