diff options
| author | Gregor Jerše <gregor.jerse@genialis.com> | 2023-06-01 16:44:57 +0200 |
|---|---|---|
| committer | Mariusz Felisiak <felisiak.mariusz@gmail.com> | 2023-07-06 08:03:19 +0200 |
| commit | 966ecdd482167f3f6b08b00f484936c837751cb9 (patch) | |
| tree | 7876e7b05088ed60822604d8bd32d33e3d1f9d9d | |
| parent | 649262a406168709686f97694493aa1f717c6c96 (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.py | 7 | ||||
| -rw-r--r-- | django/forms/jinja2/django/forms/field.html | 2 | ||||
| -rw-r--r-- | django/forms/jinja2/django/forms/p.html | 2 | ||||
| -rw-r--r-- | django/forms/jinja2/django/forms/table.html | 2 | ||||
| -rw-r--r-- | django/forms/jinja2/django/forms/ul.html | 2 | ||||
| -rw-r--r-- | django/forms/templates/django/forms/field.html | 2 | ||||
| -rw-r--r-- | django/forms/templates/django/forms/p.html | 2 | ||||
| -rw-r--r-- | django/forms/templates/django/forms/table.html | 2 | ||||
| -rw-r--r-- | django/forms/templates/django/forms/ul.html | 2 | ||||
| -rw-r--r-- | docs/ref/forms/fields.txt | 42 | ||||
| -rw-r--r-- | docs/releases/5.0.txt | 16 | ||||
| -rw-r--r-- | docs/topics/forms/index.txt | 4 | ||||
| -rw-r--r-- | tests/admin_inlines/tests.py | 2 | ||||
| -rw-r--r-- | tests/forms_tests/tests/test_forms.py | 70 | ||||
| -rw-r--r-- | tests/model_forms/tests.py | 3 |
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): |
