summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGregor Jerše <gregor.jerse@genialis.com>2023-06-01 16:44:57 +0200
committerMariusz Felisiak <felisiak.mariusz@gmail.com>2023-07-06 08:03:19 +0200
commit966ecdd482167f3f6b08b00f484936c837751cb9 (patch)
tree7876e7b05088ed60822604d8bd32d33e3d1f9d9d
parent649262a406168709686f97694493aa1f717c6c96 (diff)
Fixed #32819 -- Established relationship between form fields and their help text.
Thanks Nimra for the initial patch. Thanks Natalia Bidart, Thibaud Colas, David Smith, and Mariusz Felisiak for reviews.
-rw-r--r--django/forms/boundfield.py7
-rw-r--r--django/forms/jinja2/django/forms/field.html2
-rw-r--r--django/forms/jinja2/django/forms/p.html2
-rw-r--r--django/forms/jinja2/django/forms/table.html2
-rw-r--r--django/forms/jinja2/django/forms/ul.html2
-rw-r--r--django/forms/templates/django/forms/field.html2
-rw-r--r--django/forms/templates/django/forms/p.html2
-rw-r--r--django/forms/templates/django/forms/table.html2
-rw-r--r--django/forms/templates/django/forms/ul.html2
-rw-r--r--docs/ref/forms/fields.txt42
-rw-r--r--docs/releases/5.0.txt16
-rw-r--r--docs/topics/forms/index.txt4
-rw-r--r--tests/admin_inlines/tests.py2
-rw-r--r--tests/forms_tests/tests/test_forms.py70
-rw-r--r--tests/model_forms/tests.py3
15 files changed, 144 insertions, 16 deletions
diff --git a/django/forms/boundfield.py b/django/forms/boundfield.py
index 39b0aaf97b..deba739329 100644
--- a/django/forms/boundfield.py
+++ b/django/forms/boundfield.py
@@ -287,6 +287,13 @@ class BoundField(RenderableFieldMixin):
attrs["required"] = True
if self.field.disabled:
attrs["disabled"] = True
+ # If a custom aria-describedby attribute is given and help_text is
+ # used, the custom aria-described by is preserved so user can set the
+ # desired order.
+ if custom_aria_described_by_id := widget.attrs.get("aria-describedby"):
+ attrs["aria-describedby"] = custom_aria_described_by_id
+ elif self.field.help_text and self.id_for_label:
+ attrs["aria-describedby"] = f"{self.id_for_label}_helptext"
return attrs
@property
diff --git a/django/forms/jinja2/django/forms/field.html b/django/forms/jinja2/django/forms/field.html
index 56ffa1ad83..c8943d9481 100644
--- a/django/forms/jinja2/django/forms/field.html
+++ b/django/forms/jinja2/django/forms/field.html
@@ -4,7 +4,7 @@
{% else %}
{% if field.label %}{{ field.label_tag() }}{% endif %}
{% endif %}
-{% if field.help_text %}<div class="helptext">{{ field.help_text|safe }}</div>{% endif %}
+{% if field.help_text %}<div class="helptext"{% if field.id_for_label %} id="{{ field.id_for_label}}_helptext"{% endif %}>{{ field.help_text|safe }}</div>{% endif %}
{{ field.errors }}
{{ field }}
{% if field.use_fieldset %}</fieldset>{% endif %}
diff --git a/django/forms/jinja2/django/forms/p.html b/django/forms/jinja2/django/forms/p.html
index a2872d993e..4db46058b5 100644
--- a/django/forms/jinja2/django/forms/p.html
+++ b/django/forms/jinja2/django/forms/p.html
@@ -8,7 +8,7 @@
{% if field.label %}{{ field.label_tag() }}{% endif %}
{{ field }}
{% if field.help_text %}
- <span class="helptext">{{ field.help_text|safe }}</span>
+ <span class="helptext"{% if field.id_for_label %} id="{{ field.id_for_label}}_helptext"{% endif %}>{{ field.help_text|safe }}</span>
{% endif %}
{% if loop.last %}
{% for field in hidden_fields %}{{ field }}{% endfor %}
diff --git a/django/forms/jinja2/django/forms/table.html b/django/forms/jinja2/django/forms/table.html
index d1d51f2b12..18fa63f8a7 100644
--- a/django/forms/jinja2/django/forms/table.html
+++ b/django/forms/jinja2/django/forms/table.html
@@ -16,7 +16,7 @@
{{ field }}
{% if field.help_text %}
<br>
- <span class="helptext">{{ field.help_text|safe }}</span>
+ <span class="helptext"{% if field.id_for_label %} id="{{ field.id_for_label}}_helptext"{% endif %}>{{ field.help_text|safe }}</span>
{% endif %}
{% if loop.last %}
{% for field in hidden_fields %}{{ field }}{% endfor %}
diff --git a/django/forms/jinja2/django/forms/ul.html b/django/forms/jinja2/django/forms/ul.html
index cc4d893c0e..42c46847fd 100644
--- a/django/forms/jinja2/django/forms/ul.html
+++ b/django/forms/jinja2/django/forms/ul.html
@@ -12,7 +12,7 @@
{% if field.label %}{{ field.label_tag() }}{% endif %}
{{ field }}
{% if field.help_text %}
- <span class="helptext">{{ field.help_text|safe }}</span>
+ <span class="helptext"{% if field.id_for_label %} id="{{ field.id_for_label}}_helptext"{% endif %}>{{ field.help_text|safe }}</span>
{% endif %}
{% if loop.last %}
{% for field in hidden_fields %}{{ field }}{% endfor %}
diff --git a/django/forms/templates/django/forms/field.html b/django/forms/templates/django/forms/field.html
index 8f26213782..72fb357b71 100644
--- a/django/forms/templates/django/forms/field.html
+++ b/django/forms/templates/django/forms/field.html
@@ -4,7 +4,7 @@
{% else %}
{% if field.label %}{{ field.label_tag }}{% endif %}
{% endif %}
-{% if field.help_text %}<div class="helptext">{{ field.help_text|safe }}</div>{% endif %}
+{% if field.help_text %}<div class="helptext"{% if field.id_for_label %} id="{{ field.id_for_label}}_helptext"{% endif %}>{{ field.help_text|safe }}</div>{% endif %}
{{ field.errors }}
{{ field }}
{% if field.use_fieldset %}</fieldset>{% endif %}
diff --git a/django/forms/templates/django/forms/p.html b/django/forms/templates/django/forms/p.html
index 1346937a34..829c42eca6 100644
--- a/django/forms/templates/django/forms/p.html
+++ b/django/forms/templates/django/forms/p.html
@@ -8,7 +8,7 @@
{% if field.label %}{{ field.label_tag }}{% endif %}
{{ field }}
{% if field.help_text %}
- <span class="helptext">{{ field.help_text|safe }}</span>
+ <span class="helptext"{% if field.id_for_label %} id="{{ field.id_for_label}}_helptext"{% endif %}>{{ field.help_text|safe }}</span>
{% endif %}
{% if forloop.last %}
{% for field in hidden_fields %}{{ field }}{% endfor %}
diff --git a/django/forms/templates/django/forms/table.html b/django/forms/templates/django/forms/table.html
index d4aaafcf53..5d41bc4402 100644
--- a/django/forms/templates/django/forms/table.html
+++ b/django/forms/templates/django/forms/table.html
@@ -16,7 +16,7 @@
{{ field }}
{% if field.help_text %}
<br>
- <span class="helptext">{{ field.help_text|safe }}</span>
+ <span class="helptext"{% if field.id_for_label %} id="{{ field.id_for_label}}_helptext"{% endif %}>{{ field.help_text|safe }}</span>
{% endif %}
{% if forloop.last %}
{% for field in hidden_fields %}{{ field }}{% endfor %}
diff --git a/django/forms/templates/django/forms/ul.html b/django/forms/templates/django/forms/ul.html
index ae38af6527..7383b96235 100644
--- a/django/forms/templates/django/forms/ul.html
+++ b/django/forms/templates/django/forms/ul.html
@@ -12,7 +12,7 @@
{% if field.label %}{{ field.label_tag }}{% endif %}
{{ field }}
{% if field.help_text %}
- <span class="helptext">{{ field.help_text|safe }}</span>
+ <span class="helptext"{% if field.id_for_label %} id="{{ field.id_for_label}}_helptext"{% endif %}>{{ field.help_text|safe }}</span>
{% endif %}
{% if forloop.last %}
{% for field in hidden_fields %}{{ field }}{% endfor %}
diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt
index e443e9eabf..77ccc9e862 100644
--- a/docs/ref/forms/fields.txt
+++ b/docs/ref/forms/fields.txt
@@ -275,6 +275,48 @@ fields. We've specified ``auto_id=False`` to simplify the output:
<div>Sender:<div class="helptext">A valid email address, please.</div><input type="email" name="sender" required></div>
<div>Cc myself:<input type="checkbox" name="cc_myself"></div>
+When a field has help text and :attr:`~django.forms.BoundField.id_for_label`
+returns a value, we associate ``help_text`` with the input using the
+``aria-describedby`` HTML attribute:
+
+.. code-block:: pycon
+
+ >>> from django import forms
+ >>> class UserForm(forms.Form):
+ ... username = forms.CharField(max_length=255, help_text="e.g., user@example.com")
+ ...
+ >>> f = UserForm()
+ >>> print(f)
+ <div>
+ <label for="id_username">Username:</label>
+ <div class="helptext" id="id_username_helptext">e.g., user@example.com</div>
+ <input type="text" name="username" maxlength="255" required aria-describedby="id_username_helptext" id="id_username">
+ </div>
+
+When adding a custom ``aria-describedby`` attribute, make sure to also include
+the ``id`` of the ``help_text`` element (if used) in the desired order. For
+screen reader users, descriptions will be read in their order of appearance
+inside ``aria-describedby``:
+
+.. code-block:: pycon
+
+ >>> class UserForm(forms.Form):
+ ... username = forms.CharField(
+ ... max_length=255,
+ ... help_text="e.g., user@example.com",
+ ... widget=forms.TextInput(
+ ... attrs={"aria-describedby": "custom-description id_username_helptext"},
+ ... ),
+ ... )
+ ...
+ >>> f = UserForm()
+ >>> print(f["username"])
+ <input type="text" name="username" aria-describedby="custom-description id_username_helptext" maxlength="255" id="id_username" required>
+
+.. versionchanged:: 5.0
+
+ ``aria-describedby`` was added to associate ``help_text`` with its input.
+
``error_messages``
------------------
diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt
index a2a04c6060..f309721d44 100644
--- a/docs/releases/5.0.txt
+++ b/docs/releases/5.0.txt
@@ -61,7 +61,9 @@ For example, the template below:
<div>
{{ form.name.label_tag }}
{% if form.name.help_text %}
- <div class="helptext">{{ form.name.help_text|safe }}</div>
+ <div class="helptext" id="{{ form.name.id_for_label }}_helptext">
+ {{ form.name.help_text|safe }}
+ </div>
{% endif %}
{{ form.name.errors }}
{{ form.name }}
@@ -69,7 +71,9 @@ For example, the template below:
<div class="col">
{{ form.email.label_tag }}
{% if form.email.help_text %}
- <div class="helptext">{{ form.email.help_text|safe }}</div>
+ <div class="helptext" id="{{ form.email.id_for_label }}_helptext">
+ {{ form.email.help_text|safe }}
+ </div>
{% endif %}
{{ form.email.errors }}
{{ form.email }}
@@ -77,7 +81,9 @@ For example, the template below:
<div class="col">
{{ form.password.label_tag }}
{% if form.password.help_text %}
- <div class="helptext">{{ form.password.help_text|safe }}</div>
+ <div class="helptext" id="{{ form.password.id_for_label }}_helptext">
+ {{ form.password.help_text|safe }}
+ </div>
{% endif %}
{{ form.password.errors }}
{{ form.password }}
@@ -294,6 +300,10 @@ Forms
* The new ``assume_scheme`` argument for :class:`~django.forms.URLField` allows
specifying a default URL scheme.
+* In order to improve accessibility and enable screen readers to associate form
+ fields with their help text, the form field now includes the
+ ``aria-describedby`` HTML attribute.
+
Generic Views
~~~~~~~~~~~~~
diff --git a/docs/topics/forms/index.txt b/docs/topics/forms/index.txt
index 27ea496ca6..673111b025 100644
--- a/docs/topics/forms/index.txt
+++ b/docs/topics/forms/index.txt
@@ -723,7 +723,9 @@ loop:
{{ field.errors }}
{{ field.label_tag }} {{ field }}
{% if field.help_text %}
- <p class="help">{{ field.help_text|safe }}</p>
+ <p class="help" id="{{ field.id_for_label }}_helptext">
+ {{ field.help_text|safe }}
+ </p>
{% endif %}
</div>
{% endfor %}
diff --git a/tests/admin_inlines/tests.py b/tests/admin_inlines/tests.py
index 0be8d665d1..9533cc9af3 100644
--- a/tests/admin_inlines/tests.py
+++ b/tests/admin_inlines/tests.py
@@ -452,7 +452,7 @@ class TestInline(TestDataMixin, TestCase):
self.assertContains(
response,
'<input id="id_-1-0-name" type="text" class="vTextField" name="-1-0-name" '
- 'maxlength="100">',
+ 'maxlength="100" aria-describedby="id_-1-0-name_helptext">',
html=True,
)
self.assertContains(
diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py
index c283ab20ef..99a38eedcb 100644
--- a/tests/forms_tests/tests/test_forms.py
+++ b/tests/forms_tests/tests/test_forms.py
@@ -3016,6 +3016,72 @@ Options: <select multiple name="options" required>
"</td></tr>",
)
+ def test_widget_attrs_custom_aria_describedby(self):
+ # aria-describedby provided to the widget overrides the default.
+
+ class UserRegistration(Form):
+ username = CharField(
+ max_length=255,
+ help_text="e.g., user@example.com",
+ widget=TextInput(attrs={"aria-describedby": "custom-description"}),
+ )
+ password = CharField(
+ widget=PasswordInput, help_text="Wählen Sie mit Bedacht."
+ )
+
+ p = UserRegistration()
+ self.assertHTMLEqual(
+ p.as_div(),
+ '<div><label for="id_username">Username:</label>'
+ '<div class="helptext" id="id_username_helptext">e.g., user@example.com'
+ '</div><input type="text" name="username" maxlength="255" required '
+ 'aria-describedby="custom-description" id="id_username">'
+ "</div><div>"
+ '<label for="id_password">Password:</label>'
+ '<div class="helptext" id="id_password_helptext">Wählen Sie mit Bedacht.'
+ '</div><input type="password" name="password" required '
+ 'aria-describedby="id_password_helptext" id="id_password"></div>',
+ )
+ self.assertHTMLEqual(
+ p.as_ul(),
+ '<li><label for="id_username">Username:</label><input type="text" '
+ 'name="username" maxlength="255" required '
+ 'aria-describedby="custom-description" id="id_username">'
+ '<span class="helptext" id="id_username_helptext">e.g., user@example.com'
+ "</span></li><li>"
+ '<label for="id_password">Password:</label>'
+ '<input type="password" name="password" required '
+ 'aria-describedby="id_password_helptext" id="id_password">'
+ '<span class="helptext" id="id_password_helptext">Wählen Sie mit Bedacht.'
+ "</span></li>",
+ )
+ self.assertHTMLEqual(
+ p.as_p(),
+ '<p><label for="id_username">Username:</label><input type="text" '
+ 'name="username" maxlength="255" required '
+ 'aria-describedby="custom-description" id="id_username">'
+ '<span class="helptext" id="id_username_helptext">e.g., user@example.com'
+ "</span></p><p>"
+ '<label for="id_password">Password:</label>'
+ '<input type="password" name="password" required '
+ 'aria-describedby="id_password_helptext" id="id_password">'
+ '<span class="helptext" id="id_password_helptext">Wählen Sie mit Bedacht.'
+ "</span></p>",
+ )
+ self.assertHTMLEqual(
+ p.as_table(),
+ '<tr><th><label for="id_username">Username:</label></th><td>'
+ '<input type="text" name="username" maxlength="255" required '
+ 'aria-describedby="custom-description" id="id_username"><br>'
+ '<span class="helptext" id="id_username_helptext">e.g., user@example.com'
+ "</span></td></tr><tr><th>"
+ '<label for="id_password">Password:</label></th><td>'
+ '<input type="password" name="password" required '
+ 'aria-describedby="id_password_helptext" id="id_password"><br>'
+ '<span class="helptext" id="id_password_helptext">Wählen Sie mit Bedacht.'
+ "</span></td></tr>",
+ )
+
def test_subclassing_forms(self):
# You can subclass a Form to add fields. The resulting form subclass will have
# all of the fields of the parent Form, plus whichever fields you define in the
@@ -4796,7 +4862,7 @@ class TemplateTests(SimpleTestCase):
"<form>"
'<p><label for="id_username">Username:</label>'
'<input id="id_username" type="text" name="username" maxlength="10" '
- "required></p>"
+ 'aria-describedby="id_username_helptext" required></p>'
'<p><label for="id_password1">Password1:</label>'
'<input type="password" name="password1" id="id_password1" required></p>'
'<p><label for="id_password2">Password2:</label>'
@@ -4833,7 +4899,7 @@ class TemplateTests(SimpleTestCase):
"<form>"
'<p><legend for="id_username">Username:</legend>'
'<input id="id_username" type="text" name="username" maxlength="10" '
- "required></p>"
+ 'aria-describedby="id_username_helptext" required></p>'
'<p><legend for="id_password1">Password1:</legend>'
'<input type="password" name="password1" id="id_password1" required></p>'
'<p><legend for="id_password2">Password2:</legend>'
diff --git a/tests/model_forms/tests.py b/tests/model_forms/tests.py
index b807e90ef9..d1716ce201 100644
--- a/tests/model_forms/tests.py
+++ b/tests/model_forms/tests.py
@@ -958,7 +958,8 @@ class TestFieldOverridesByFormMeta(SimpleTestCase):
)
self.assertHTMLEqual(
str(form["slug"]),
- '<input id="id_slug" type="text" name="slug" maxlength="20" required>',
+ '<input id="id_slug" type="text" name="slug" maxlength="20" '
+ 'aria-describedby="id_slug_helptext" required>',
)
def test_label_overrides(self):