diff --git a/django/forms/boundfield.py b/django/forms/boundfield.py index 0b4439efde..98ac5b8208 100644 --- a/django/forms/boundfield.py +++ b/django/forms/boundfield.py @@ -287,6 +287,8 @@ class BoundField(RenderableFieldMixin): attrs["required"] = True if self.field.disabled: attrs["disabled"] = True + if not widget.is_hidden and self.errors: + attrs["aria-invalid"] = "true" # If a custom aria-describedby attribute is given (either via the attrs # argument or widget.attrs) and help_text is used, the custom # aria-described by is preserved so user can set the desired order. diff --git a/docs/ref/forms/api.txt b/docs/ref/forms/api.txt index 4d4f73d0b4..c4df99af7f 100644 --- a/docs/ref/forms/api.txt +++ b/docs/ref/forms/api.txt @@ -992,10 +992,20 @@ method you're using: ... } >>> f = ContactForm(data, auto_id=False) >>> print(f) -
Subject:
-
Message:
-
Sender:
-
Cc myself:
+
Subject: + + +
+
Message: + +
+
Sender: + + +
+
Cc myself: + +
.. _ref-forms-error-list-format: @@ -1154,7 +1164,7 @@ Attributes of ``BoundField`` >>> data = {"subject": "hi", "message": "", "sender": "", "cc_myself": ""} >>> f = ContactForm(data, auto_id=False) >>> print(f["message"]) - + >>> f["message"].errors ['This field is required.'] >>> print(f["message"].errors) @@ -1166,6 +1176,13 @@ Attributes of ``BoundField`` >>> str(f["subject"].errors) '' + When rendering a field with errors, ``aria-invalid="true"`` will be set on + the field's widget to indicate there is an error to screen reader users. + + .. versionchanged:: 5.0 + + The ``aria-invalid="true"`` was added when a field has errors. + .. attribute:: BoundField.field The form :class:`~django.forms.Field` instance from the form class that diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt index dad5f1bbb8..307ebb15a2 100644 --- a/docs/ref/forms/fields.txt +++ b/docs/ref/forms/fields.txt @@ -196,9 +196,17 @@ and the HTML output will include any validation errors: >>> default_data = {"name": "Your name", "url": "http://"} >>> f = CommentForm(default_data, auto_id=False) >>> print(f) -
Name:
-
Url:
-
Comment:
+
Name: + +
+
Url: + + +
+
Comment: + + +
This is why ``initial`` values are only displayed for unbound forms. For bound forms, the HTML output will use the bound data. diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index cceba667f9..d2265f9045 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -310,6 +310,9 @@ Forms fields with their help text, the form field now includes the ``aria-describedby`` HTML attribute. +* In order to improve accessibility, the invalid form field now includes the + ``aria-invalid="true"`` HTML attribute. + Generic Views ~~~~~~~~~~~~~ diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py index 0baddad69b..547f58632f 100644 --- a/tests/forms_tests/tests/test_forms.py +++ b/tests/forms_tests/tests/test_forms.py @@ -182,61 +182,70 @@ class FormsTestCase(SimpleTestCase): str(p), '
' '' - '
' + '' '
' '' - '
' + '
' '' '' - '
', + '', ) self.assertHTMLEqual( p.as_table(), """ - - + + - - + + -""", + +""", ) self.assertHTMLEqual( p.as_ul(), """
  • -
  • -
  • + +
  • -
  • -
  • + +
  • -
  • """, + +""", ) self.assertHTMLEqual( p.as_p(), """

    -

    - + +

    -

    - + +

    -

    """, + +

    """, ) self.assertHTMLEqual( p.as_div(), '
    ' '' - '
    ' + '' '
    ' '' - '
    ' + '
    ' '' '' - '
    ', + '', ) def test_empty_querydict_args(self): @@ -582,14 +591,16 @@ class FormsTestCase(SimpleTestCase): {"email": "test@example.com", "get_spam": "False"}, auto_id=False ) self.assertHTMLEqual( - str(f["get_spam"]), '' + str(f["get_spam"]), + '', ) f = SignupForm( {"email": "test@example.com", "get_spam": "false"}, auto_id=False ) self.assertHTMLEqual( - str(f["get_spam"]), '' + str(f["get_spam"]), + '', ) # A value of '0' should be interpreted as a True value (#16820) @@ -1406,13 +1417,13 @@ class FormsTestCase(SimpleTestCase): <em>Special</em> Field: - - + Special Field: + aria-invalid="true" required> """, ) f = EscapingForm( @@ -1429,13 +1440,14 @@ class FormsTestCase(SimpleTestCase): "Something's wrong with 'Should escape < & > and " "<script>alert('xss')</script>'" '' - "" + '> and <script>alert('xss')</script>" ' + 'aria-invalid="true" required>' "Special Field:" '" '', + 'value="<i>Do not escape</i>" aria-invalid="true" required>' + "", ) def test_validating_multiple_fields(self): @@ -1537,11 +1549,12 @@ class FormsTestCase(SimpleTestCase): f.as_table(), """Username: - + + Password1: - + Password2: -""", +""", ) self.assertEqual(f.errors["username"], ["This field is required."]) self.assertEqual(f.errors["password1"], ["This field is required."]) @@ -2412,17 +2425,17 @@ class FormsTestCase(SimpleTestCase): self.assertHTMLEqual( p.as_ul(), """
  • -Username:
  • -
  • -Password:
  • """, +Username:
  • +Password:
  • """, ) p = UserRegistration({"username": ""}, auto_id=False) self.assertHTMLEqual( p.as_ul(), """
  • -Username:
  • -
  • -Password:
  • """, +Username:
  • +Password:
  • """, ) p = UserRegistration({"username": "foo"}, auto_id=False) self.assertHTMLEqual( @@ -2431,7 +2444,8 @@ Password: """,
  • Username:
  • - Password:
  • + Password: """, ) @@ -2478,9 +2492,9 @@ Password: """, self.assertHTMLEqual( p.as_ul(), """
  • -Username:
  • -
  • -Password:
  • """, +Username:
  • +Password:
  • """, ) p = UserRegistration( {"username": ""}, initial={"username": "django"}, auto_id=False @@ -2488,9 +2502,9 @@ Password: """, self.assertHTMLEqual( p.as_ul(), """
  • -Username:
  • -
  • -Password:
  • """, +Username:
  • +Password:
  • """, ) p = UserRegistration( {"username": "foo"}, initial={"username": "django"}, auto_id=False @@ -2501,7 +2515,8 @@ Password: """,
  • Username:
  • - Password:
  • + Password: """, ) @@ -2580,11 +2595,11 @@ Password: """, self.assertHTMLEqual( p.as_ul(), """
  • -Username:
  • -
  • -Password:
  • -
  • -Options:
  • +Password:
  • +Options: self.assertHTMLEqual( p.as_ul(), """
  • - Username:
  • +Username:
  • +Password:
  • -Password:
  • -
  • -Options: @@ -2617,8 +2632,8 @@ Options:
  • - Password:
  • -
  • Options:
  • Options: 'maxlength="10" required>' 'e.g., user@example.com
  • ' '
  • ' - 'Password: ' - 'Wählen Sie mit Bedacht.
  • ', + 'Password: Wählen Sie mit Bedacht.', ) # help_text is not displayed for hidden fields. It can be used for documentation @@ -3405,7 +3420,7 @@ Options: ', + '', ) f = FileForm( @@ -3415,7 +3430,7 @@ Options: ', + '', ) f = FileForm( @@ -3426,7 +3441,7 @@ Options: ', + '', ) f = FileForm( @@ -3591,8 +3606,8 @@ Options: -
  • + +
  • -
  • """, + + """, ) self.assertHTMLEqual( @@ -3613,8 +3629,8 @@ Options:

    -

    + +

    -

    - """, + +

    """, ) self.assertHTMLEqual( @@ -3634,7 +3650,7 @@ Options: + -""", +""", ) self.assertHTMLEqual( p.as_div(), '
    ' - '
    ' + '' '
    '
    ' '
    ' - '
    ', + '', ) def test_label_has_required_css_class(self): @@ -4342,7 +4360,8 @@ Options: ' + '' '', ) @@ -4813,10 +4832,12 @@ class TemplateTests(SimpleTestCase): "

    " '

    ' "

    ' + '' + "

    " '' "

    ' + '' + "

    " '' "", ) @@ -4851,7 +4872,10 @@ class TemplateTests(SimpleTestCase): # Form gives each field an "id" attribute. t = Template( "
    " - "

    {{ form.username.label_tag }} {{ form.username }}

    " + "

    {{ form.username.label_tag }} {{ form.username }}" + '' + "{{ form.username.help_text}}

    " "

    {{ form.password1.label_tag }} {{ form.password1 }}

    " "

    {{ form.password2.label_tag }} {{ form.password2 }}

    " '' @@ -4861,7 +4885,8 @@ class TemplateTests(SimpleTestCase): t.render(Context({"form": f})), "" "

    Username: " - '

    ' + '' + "Good luck picking a username that doesn't already exist.

    " '

    Password1:

    ' '

    Password2:

    ' '' @@ -4873,7 +4898,9 @@ class TemplateTests(SimpleTestCase): "" '

    ' '

    ' + 'aria-describedby="id_username_helptext" required>' + '' + "Good luck picking a username that doesn't already exist.

    " '

    ' '

    ' '

    ' @@ -5066,7 +5093,7 @@ class TemplateTests(SimpleTestCase): "

  • Please make sure your passwords match.
  • " '
    Username:" - '
    ' "
    Password1:" '
    ' @@ -5174,10 +5201,11 @@ class OverrideTests(SimpleTestCase): '
    ' '
    Enter a valid email address.
    ' "

    Email: " - '' - '

    ' + '

    ' '
    This field is required.
    ' - '

    Comment:

    ', + '

    Comment:

    ", ) def test_cyclic_context_boundfield_render(self): diff --git a/tests/forms_tests/tests/test_i18n.py b/tests/forms_tests/tests/test_i18n.py index 2548ceb481..15c456c0bd 100644 --- a/tests/forms_tests/tests/test_i18n.py +++ b/tests/forms_tests/tests/test_i18n.py @@ -103,13 +103,16 @@ class FormsI18nTests(SimpleTestCase): "

    " '

    \n
    \n' + 'name="somechoice" aria-invalid="true" required>' + "En tied\xe4
    \n" '
    \n
    ' + 'name="somechoice" aria-invalid="true" required>' + "Mies
    \n
    " '
    \n

    ', + 'name="somechoice" aria-invalid="true" required>' + "Nainen\n

    ", ) def test_select_translated_text(self): diff --git a/tests/gis_tests/geoadmin/tests.py b/tests/gis_tests/geoadmin/tests.py index 869307328e..e101050464 100644 --- a/tests/gis_tests/geoadmin/tests.py +++ b/tests/gis_tests/geoadmin/tests.py @@ -30,7 +30,7 @@ class GeoAdminTest(SimpleTestCase): ' rows="10" name="point" hidden>', output, ) - self.assertEqual(len(cm.records), 1) + self.assertEqual(len(cm.records), 2) self.assertEqual( cm.records[0].getMessage(), "Error creating geometry from value 'INVALID()' (String input "