Browse Source

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.
Gregor Jerše 1 year ago
parent
commit
966ecdd482

+ 7 - 0
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

+ 1 - 1
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 %}

+ 1 - 1
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 %}

+ 1 - 1
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 %}

+ 1 - 1
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 %}

+ 1 - 1
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 %}

+ 1 - 1
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 %}

+ 1 - 1
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 %}

+ 1 - 1
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 %}

+ 42 - 0
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``
 ------------------
 

+ 13 - 3
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
 ~~~~~~~~~~~~~
 

+ 3 - 1
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 %}

+ 1 - 1
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(

+ 68 - 2
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>'

+ 2 - 1
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):