diff --git a/django/forms/boundfield.py b/django/forms/boundfield.py
index d0d5c13b3d..4d3cd88660 100644
--- a/django/forms/boundfield.py
+++ b/django/forms/boundfield.py
@@ -289,20 +289,24 @@ class BoundField(RenderableFieldMixin):
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.
- if (
- not attrs.get("aria-describedby")
- and not widget.attrs.get("aria-describedby")
- and self.field.help_text
- and not self.use_fieldset
- and self.auto_id
- and not self.is_hidden
- ):
- attrs["aria-describedby"] = f"{self.auto_id}_helptext"
+ # Preserve aria-describedby provided by the attrs argument so user
+ # can set the desired order.
+ if not attrs.get("aria-describedby") and not self.use_fieldset:
+ if aria_describedby := self.aria_describedby:
+ attrs["aria-describedby"] = aria_describedby
return attrs
+ @property
+ def aria_describedby(self):
+ # Preserve aria-describedby set on the widget.
+ if self.field.widget.attrs.get("aria-describedby"):
+ return None
+ aria_describedby = []
+ if self.auto_id and not self.is_hidden:
+ if self.help_text:
+ aria_describedby.append(f"{self.auto_id}_helptext")
+ return " ".join(aria_describedby)
+
@property
def widget_type(self):
return re.sub(
diff --git a/django/forms/jinja2/django/forms/field.html b/django/forms/jinja2/django/forms/field.html
index 642d45ddc7..faeed0a45b 100644
--- a/django/forms/jinja2/django/forms/field.html
+++ b/django/forms/jinja2/django/forms/field.html
@@ -1,5 +1,5 @@
{% if field.use_fieldset %}
-