diff --git a/django/forms/renderers.py b/django/forms/renderers.py
index 0e406c9c7e..43340c6c88 100644
--- a/django/forms/renderers.py
+++ b/django/forms/renderers.py
@@ -15,6 +15,9 @@ def get_default_renderer():
class BaseRenderer:
+ # RemovedInDjango50Warning: When the deprecation ends, replace with
+ # form_template_name = "django/forms/div.html"
+ # formset_template_name = "django/forms/formsets/div.html"
form_template_name = "django/forms/default.html"
formset_template_name = "django/forms/formsets/default.html"
@@ -64,6 +67,31 @@ class Jinja2(EngineMixin, BaseRenderer):
return Jinja2
+class DjangoDivFormRenderer(DjangoTemplates):
+ """
+ Load Django templates from django/forms/templates and from apps'
+ 'templates' directory and use the 'div.html' template to render forms and
+ formsets.
+ """
+
+ # RemovedInDjango50Warning Deprecate this class in 5.0 and remove in 6.0.
+
+ form_template_name = "django/forms/div.html"
+ formset_template_name = "django/forms/formsets/div.html"
+
+
+class Jinja2DivFormRenderer(Jinja2):
+ """
+ Load Jinja2 templates from the built-in widget templates in
+ django/forms/jinja2 and from apps' 'jinja2' directory.
+ """
+
+ # RemovedInDjango50Warning Deprecate this class in 5.0 and remove in 6.0.
+
+ form_template_name = "django/forms/div.html"
+ formset_template_name = "django/forms/formsets/div.html"
+
+
class TemplatesSetting(BaseRenderer):
"""
Load templates using template.loader.get_template() which is configured
diff --git a/django/forms/utils.py b/django/forms/utils.py
index 77678054db..905babce4d 100644
--- a/django/forms/utils.py
+++ b/django/forms/utils.py
@@ -1,13 +1,16 @@
import json
+import warnings
from collections import UserList
from django.conf import settings
from django.core.exceptions import ValidationError
from django.forms.renderers import get_default_renderer
from django.utils import timezone
+from django.utils.deprecation import RemovedInDjango50Warning
from django.utils.html import escape, format_html_join
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
+from django.utils.version import get_docs_version
def pretty_name(name):
@@ -42,6 +45,16 @@ def flatatt(attrs):
)
+DEFAULT_TEMPLATE_DEPRECATION_MSG = (
+ 'The "default.html" templates for forms and formsets will be removed. These were '
+ 'proxies to the equivalent "table.html" templates, but the new "div.html" '
+ "templates will be the default from Django 5.0. Transitional renderers are "
+ "provided to allow you to opt-in to the new output style now. See "
+ "https://docs.djangoproject.com/en/%s/releases/4.1/ for more details"
+ % get_docs_version()
+)
+
+
class RenderableMixin:
def get_context(self):
raise NotImplementedError(
@@ -49,12 +62,17 @@ class RenderableMixin:
)
def render(self, template_name=None, context=None, renderer=None):
- return mark_safe(
- (renderer or self.renderer).render(
- template_name or self.template_name,
- context or self.get_context(),
+ renderer = renderer or self.renderer
+ template = template_name or self.template_name
+ context = context or self.get_context()
+ if (
+ template == "django/forms/default.html"
+ or template == "django/forms/formsets/default.html"
+ ):
+ warnings.warn(
+ DEFAULT_TEMPLATE_DEPRECATION_MSG, RemovedInDjango50Warning, stacklevel=2
)
- )
+ return mark_safe(renderer.render(template, context))
__str__ = render
__html__ = render
diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt
index bb6b889f91..73ed9a3c6b 100644
--- a/docs/internals/deprecation.txt
+++ b/docs/internals/deprecation.txt
@@ -105,6 +105,9 @@ details on these changes.
* The ``django.contrib.auth.hashers.CryptPasswordHasher`` will be removed.
+* The ``"django/forms/default.html"`` and
+ ``"django/forms/formsets/default.html"`` templates will be removed.
+
* The ability to pass ``nulls_first=False`` or ``nulls_last=False`` to
``Expression.asc()`` and ``Expression.desc()`` methods, and the ``OrderBy``
expression will be removed.
diff --git a/docs/ref/forms/renderers.txt b/docs/ref/forms/renderers.txt
index 8c0263e051..f11b23ab12 100644
--- a/docs/ref/forms/renderers.txt
+++ b/docs/ref/forms/renderers.txt
@@ -56,6 +56,12 @@ should return a rendered templates (as a string) or raise
Defaults to ``"django/forms/default.html"``, which is a proxy for
``"django/forms/table.html"``.
+ .. deprecated:: 4.1
+
+ The ``"django/forms/default.html"`` template is deprecated and will be
+ removed in Django 5.0. The default will become
+ ``"django/forms/default.html"`` at that time.
+
.. attribute:: formset_template_name
.. versionadded:: 4.1
@@ -65,6 +71,12 @@ should return a rendered templates (as a string) or raise
Defaults to ``"django/forms/formsets/default.html"``, which is a proxy
for ``"django/forms/formsets/table.html"``.
+ .. deprecated:: 4.1
+
+ The ``"django/forms/formset/default.html"`` template is deprecated and
+ will be removed in Django 5.0. The default will become
+ ``"django/forms/formset/div.html"`` template.
+
.. method:: get_template(template_name)
Subclasses must implement this method with the appropriate template
@@ -97,6 +109,26 @@ If you want to render templates with customizations from your
:setting:`TEMPLATES` setting, such as context processors for example, use the
:class:`TemplatesSetting` renderer.
+.. class:: DjangoDivFormRenderer
+
+.. versionadded:: 4.1
+
+Subclass of :class:`DjangoTemplates` that specifies
+:attr:`~BaseRenderer.form_template_name` and
+:attr:`~BaseRenderer.formset_template_name` as ``"django/forms/div.html"`` and
+``"django/forms/formset/div.html"`` respectively.
+
+This is a transitional renderer for opt-in to the new ``
`` based
+templates, which are the default from Django 5.0.
+
+Apply this via the :setting:`FORM_RENDERER` setting::
+
+ FORM_RENDERER = "django.forms.renderers.DjangoDivFormRenderer"
+
+Once the ``
`` templates are the default, this transitional renderer will
+be deprecated, for removal in Django 6.0. The ``FORM_RENDERER`` declaration can
+be removed at that time.
+
``Jinja2``
----------
@@ -113,6 +145,17 @@ templates for widgets that don't have any, you can't use this renderer. For
example, :mod:`django.contrib.admin` doesn't include Jinja2 templates for its
widgets due to their usage of Django template tags.
+.. class:: Jinja2DivFormRenderer
+
+.. versionadded:: 4.1
+
+A transitional renderer as per :class:`DjangoDivFormRenderer` above, but
+subclassing :class:`Jinja2` for use with the Jinja2 backend.
+
+Apply this via the :setting:`FORM_RENDERER` setting::
+
+ FORM_RENDERER = "django.forms.renderers.Jinja2DivFormRenderer"
+
``TemplatesSetting``
--------------------
diff --git a/docs/releases/4.1.txt b/docs/releases/4.1.txt
index 36a510b460..49f74a93b3 100644
--- a/docs/releases/4.1.txt
+++ b/docs/releases/4.1.txt
@@ -74,6 +74,24 @@ Validation of Constraints
in the :attr:`Meta.constraints ` option
are now checked during :ref:`model validation `.
+Form rendering accessibility
+----------------------------
+
+In order to aid users with screen readers, and other assistive technology, new
+``
`` based form templates are available from this release. These provide
+more accessible navigation than the older templates, and are able to correctly
+group related controls, such as radio-lists, into fieldsets.
+
+The new templates are recommended, and will become the default form rendering
+style when outputting a form, like ``{{ form }}`` in a template, from Django
+5.0.
+
+In order to ease adopting the new output style, the default form and formset
+templates are now configurable at the project level via the
+:setting:`FORM_RENDERER` setting.
+
+See :ref:`the Forms section (below)` for full details.
+
.. _csrf-cookie-masked-usage:
``CSRF_COOKIE_MASKED`` setting
@@ -253,6 +271,8 @@ File Uploads
* ...
+.. _forms-4.1:
+
Forms
~~~~~
@@ -279,6 +299,34 @@ Forms
as the template implements ``
\n",
)
def test_management_form_invalid_data(self):
@@ -1732,18 +1730,18 @@ class TestIsBoundBehavior(SimpleTestCase):
# Can still render the formset.
self.assertHTMLEqual(
str(formset),
- '
'
'
'
"
(Hidden field TOTAL_FORMS) Enter a whole number.
"
"
(Hidden field INITIAL_FORMS) Enter a whole number.
',
)
class PartialArticleFormWithSlug(forms.ModelForm):
@@ -2990,10 +2991,10 @@ class OtherModelFormTests(TestCase):
self.assertHTMLEqual(
str(CategoryForm()),
- """
-
-
-
""",
+ '
'
+ '
',
)
# to_field_name should also work on ModelMultipleChoiceField ##################
@@ -3014,8 +3015,8 @@ class OtherModelFormTests(TestCase):
self.assertEqual(list(CustomFieldForExclusionForm.base_fields), ["name"])
self.assertHTMLEqual(
str(CustomFieldForExclusionForm()),
- """