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.
This commit is contained in:
Gregor Jerše 2023-06-01 16:44:57 +02:00 committed by Mariusz Felisiak
parent 649262a406
commit 966ecdd482
15 changed files with 144 additions and 16 deletions

View File

@ -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

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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``
------------------

View File

@ -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
~~~~~~~~~~~~~

View File

@ -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 %}

View File

@ -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(

View File

@ -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>'

View File

@ -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):