Repository: wagtail/wagtail

Issue:
Form field's `help_text`'s `id` is not rendered with `{% field %}` tag unless explicitly specified
<!--
Found a bug? Please fill out the sections below. 👍
-->

### Issue Summary

If you have a form field with a `help_text`, rendering it using Wagtail's `{% field %}` tag won't render the field's help text ID unless you explicitly specify `help_text_id`.

Meanwhile, in newer Django versions (5.0 and up as of django/django#16920 and django/django#17065), this causes an issue where the widget will have an `aria-describedby` that points to a non-existing element `id`.

This affects the following templates in Wagtail:

https://github.com/wagtail/wagtail/blob/69724e4e3a3d372fa3cd47c66c11df74daa203bb/wagtail/admin/templates/wagtailadmin/login.html#L41-L45

https://github.com/wagtail/wagtail/blob/69724e4e3a3d372fa3cd47c66c11df74daa203bb/wagtail/admin/templates/wagtailadmin/account/password_reset/form.html#L19-L23

https://github.com/wagtail/wagtail/blob/69724e4e3a3d372fa3cd47c66c11df74daa203bb/wagtail/admin/templates/wagtailadmin/shared/filters.html#L15-L18

### Steps to Reproduce

1. Spin up a new instance of Wagtail
2. Create a form class with a field that has a `help_text`.
3. Render the form field with `{% field field=field %}`.
   - For quick testing, you can use the form as the login form or the password reset form, via the `WAGTAILADMIN_USER_LOGIN_FORM` or `WAGTAILADMIN_USER_PASSWORD_RESET_FORM` setting.
     - For even quicker testing, you can skip creating a form class and just set `WAGTAILADMIN_USER_LOGIN_FORM = "wagtail.admin.tests.test_forms.CustomLoginForm"`.
4. See the rendered template, e.g. go to the login or the password reset view.
5. Notice that the help element (the one with `data-field-help`) has no ID.
6. In Django 5.0 and later, field's widget (usually an `<input>` element) is rendered with an `aria-describedby` attribute that points to a nonexisting element.


Discussion:
The changes in https://github.com/django/django/pull/16920/files#diff-9da17cc8ef134b4f62a6cd6df0ff75a0d970737818bf54ab51adddfb1460ceb7R290-R296 seem suspect. I'm wondering why Django doesn't check whether `aria-describedby` is already in `attrs` before assigning it with the value from `widget.attrs`. Filed an upstream ticket: https://code.djangoproject.com/ticket/34705
Upstream patch: https://github.com/django/django/pull/17065

Wagtail's test suite mostly passes with it, but there are still some tests need updating as they assume there is no `aria-describedby` present.

Resolving patch:
diff --git a/wagtail/admin/templatetags/wagtailadmin_tags.py b/wagtail/admin/templatetags/wagtailadmin_tags.py
--- a/wagtail/admin/templatetags/wagtailadmin_tags.py
+++ b/wagtail/admin/templatetags/wagtailadmin_tags.py
@@ -343,7 +343,23 @@ def render_with_errors(bound_field):
             errors=bound_field.errors,
         )
     else:
-        return bound_field.as_widget()
+        attrs = {}
+        # If the widget doesn't have an aria-describedby attribute,
+        # and the field has help text, and the field has an id,
+        # add an aria-describedby attribute pointing to the help text.
+        # In this case, the corresponding help text element's id is set in the
+        # wagtailadmin/shared/field.html template.
+
+        # In Django 5.0 and up, this is done automatically, but we want to keep
+        # this code because we use a different convention for the help text id
+        # (we use -helptext suffix instead of Django's _helptext).
+        if (
+            not bound_field.field.widget.attrs.get("aria-describedby")
+            and bound_field.field.help_text
+            and bound_field.id_for_label
+        ):
+            attrs["aria-describedby"] = f"{bound_field.id_for_label}-helptext"
+        return bound_field.as_widget(attrs=attrs)
 
 
 @register.filter


Test patch:
diff --git a/wagtail/admin/tests/test_views.py b/wagtail/admin/tests/test_views.py
--- a/wagtail/admin/tests/test_views.py
+++ b/wagtail/admin/tests/test_views.py
@@ -86,7 +86,21 @@ def test_bidi_language_changes_dir_attribute(self):
     def test_login_page_renders_extra_fields(self):
         response = self.client.get(reverse("wagtailadmin_login"))
         self.assertContains(
-            response, '<input type="text" name="captcha" required id="id_captcha">'
+            response,
+            """
+            <input type="text" name="captcha" required
+            aria-describedby="id_captcha-helptext" id="id_captcha">
+            """,
+            html=True,
+        )
+        self.assertContains(
+            response,
+            """
+            <div class="w-field__help" id="id_captcha-helptext" data-field-help>
+                <div class="help">should be in extra_fields()</div>
+            </div>
+            """,
+            html=True,
         )
 
     def test_session_expire_on_browser_close(self):
@@ -126,7 +140,21 @@ def test_password_reset_view_uses_correct_form(self):
     def test_password_reset_page_renders_extra_fields(self):
         response = self.client.get(reverse("wagtailadmin_password_reset"))
         self.assertContains(
-            response, '<input type="text" name="captcha" required id="id_captcha">'
+            response,
+            """
+            <input type="text" name="captcha" required
+            aria-describedby="id_captcha-helptext" id="id_captcha">
+            """,
+            html=True,
+        )
+        self.assertContains(
+            response,
+            """
+            <div class="w-field__help" id="id_captcha-helptext" data-field-help>
+                <div class="help">should be in extra_fields()</div>
+            </div>
+            """,
+            html=True,
         )