From 5942ab5eb165ee2e759174e297148a40dd855920 Mon Sep 17 00:00:00 2001 From: David Smith Date: Fri, 11 Jun 2021 07:39:12 +0100 Subject: [PATCH] Refs #32338 -- Made RadioSelect/CheckboxSelectMultiple render in
tags. This improves accessibility for screen reader users. --- .../django/forms/widgets/multiple_input.html | 10 +- .../django/forms/widgets/multiple_input.html | 10 +- docs/ref/forms/widgets.txt | 32 ++-- docs/releases/4.0.txt | 7 + tests/forms_tests/tests/test_forms.py | 108 +++++------ tests/forms_tests/tests/test_i18n.py | 22 +-- .../test_checkboxselectmultiple.py | 175 ++++++++---------- .../widget_tests/test_radioselect.py | 132 +++++++------ tests/model_forms/test_modelchoicefield.py | 26 +-- 9 files changed, 258 insertions(+), 264 deletions(-) diff --git a/django/forms/jinja2/django/forms/widgets/multiple_input.html b/django/forms/jinja2/django/forms/widgets/multiple_input.html index 21cd9b665db..aee0bd68525 100644 --- a/django/forms/jinja2/django/forms/widgets/multiple_input.html +++ b/django/forms/jinja2/django/forms/widgets/multiple_input.html @@ -1,5 +1,5 @@ -{% set id = widget.attrs.id %}{% for group, options, index in widget.optgroups %}{% if group %} -
  • {{ group }}{% endif %}{% for widget in options %} -
  • {% include widget.template_name %}
  • {% endfor %}{% if group %} - {% endif %}{% endfor %} - +{% set id = widget.attrs.id %}{% for group, options, index in widget.optgroups %}{% if group %} +
    {% endif %}{% for widget in options %}
    + {% include widget.template_name %}
    {% endfor %}{% if group %} +
    {% endif %}{% endfor %} +
    diff --git a/django/forms/templates/django/forms/widgets/multiple_input.html b/django/forms/templates/django/forms/widgets/multiple_input.html index 0ba99428745..2a0fec6ecc1 100644 --- a/django/forms/templates/django/forms/widgets/multiple_input.html +++ b/django/forms/templates/django/forms/widgets/multiple_input.html @@ -1,5 +1,5 @@ -{% with id=widget.attrs.id %}{% for group, options, index in widget.optgroups %}{% if group %} -
  • {{ group }}{% endif %}{% for option in options %} -
  • {% include option.template_name with widget=option %}
  • {% endfor %}{% if group %} - {% endif %}{% endfor %} -{% endwith %} +{% with id=widget.attrs.id %}{% for group, options, index in widget.optgroups %}{% if group %} +
    {% endif %}{% for option in options %}
    + {% include option.template_name with widget=option %}
    {% endfor %}{% if group %} +
    {% endif %}{% endfor %} +{% endwith %} diff --git a/docs/ref/forms/widgets.txt b/docs/ref/forms/widgets.txt index 48e83a2da97..970e30456c2 100644 --- a/docs/ref/forms/widgets.txt +++ b/docs/ref/forms/widgets.txt @@ -703,14 +703,19 @@ that specifies the template used to render each choice. For example, for the * ``option_template_name``: ``'django/forms/widgets/radio_option.html'`` Similar to :class:`Select`, but rendered as a list of radio buttons within - ``
  • `` tags: + ``
    `` tags: .. code-block:: html -
      -
    • +
      +
      ... -
    +
    + + .. versionchanged:: 4.0 + + So they are announced more concisely by screen readers, radio buttons + were changed to render in ``
    `` tags. For more granular control over the generated markup, you can loop over the radio buttons in the template. Assuming a form ``myform`` with a field @@ -788,10 +793,10 @@ that specifies the template used to render each choice. For example, for the If you decide not to loop over the radio buttons -- e.g., if your template - includes ``{{ myform.beatles }}`` -- they'll be output in a ``
      `` with - ``
    • `` tags, as above. + includes ``{{ myform.beatles }}`` -- they'll be output in a ``
      `` with + ``
      `` tags, as above. - The outer ``
        `` container receives the ``id`` attribute of the widget, + The outer ``
        `` container receives the ``id`` attribute of the widget, if defined, or :attr:`BoundField.auto_id` otherwise. When looping over the radio buttons, the ``label`` and ``input`` tags include @@ -810,14 +815,19 @@ that specifies the template used to render each choice. For example, for the .. code-block:: html -
          -
        • +
          +
          ... -
        +
        - The outer ``
          `` container receives the ``id`` attribute of the widget, + The outer ``
          `` container receives the ``id`` attribute of the widget, if defined, or :attr:`BoundField.auto_id` otherwise. + .. versionchanged:: 4.0 + + So they are announced more concisely by screen readers, checkboxes were + changed to render in ``
          `` tags. + Like :class:`RadioSelect`, you can loop over the individual checkboxes for the widget's choices. Unlike :class:`RadioSelect`, the checkboxes won't include the ``required`` HTML attribute if the field is required because browser validation diff --git a/docs/releases/4.0.txt b/docs/releases/4.0.txt index aa45d1e1e9c..45743c41392 100644 --- a/docs/releases/4.0.txt +++ b/docs/releases/4.0.txt @@ -551,6 +551,13 @@ Miscellaneous ``django.db.migrations.state.ProjectState.__init__()`` method must now be a set if provided. +* :class:`~django.forms.RadioSelect` and + :class:`~django.forms.CheckboxSelectMultiple` widgets are now rendered in + ``
          `` tags so they are announced more concisely by screen readers. If you + need the previous behavior, :ref:`override the widget template + ` with the appropriate template from + Django 3.2. + .. _deprecated-features-4.0: Features deprecated in 4.0 diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py index 212af2ad147..83dc5fec20e 100644 --- a/tests/forms_tests/tests/test_forms.py +++ b/tests/forms_tests/tests/test_forms.py @@ -585,20 +585,20 @@ class FormsTestCase(SimpleTestCase): language = ChoiceField(choices=[('P', 'Python'), ('J', 'Java')], widget=RadioSelect) f = FrameworkForm(auto_id=False) - self.assertHTMLEqual(str(f['language']), """
            -
          • -
          • -
          """) + self.assertHTMLEqual(str(f['language']), """
          +
          +
          +
          """) self.assertHTMLEqual(f.as_table(), """Name: -Language:
            -
          • -
          • -
          """) +Language:
          +
          +
          +
          """) self.assertHTMLEqual(f.as_ul(), """
        • Name:
        • -
        • Language:
            -
          • -
          • -
        • """) +
        • Language:
          +
          +
          +
        • """) # Regarding auto_id and self.assertHTMLEqual( f.as_table(), """ -
            -
          • -
          • -
          """ +
          +
          +
          +
          """ ) self.assertHTMLEqual( f.as_ul(), """
        • -
          • -
          • -
          • -
        • """ +
        • +
          +
          +
        • """ ) self.assertHTMLEqual( f.as_p(), """

          -

            -
          • -
          • -

          """ +

          +
          +
          +

          """ ) # Test iterating on individual radios in a template @@ -870,20 +870,20 @@ Java ) f = SongForm(auto_id=False) - self.assertHTMLEqual(str(f['composers']), """
            -
          • -
          • -
          """) + self.assertHTMLEqual(str(f['composers']), """
          +
          +
          +
          """) f = SongForm({'composers': ['J']}, auto_id=False) - self.assertHTMLEqual(str(f['composers']), """
            -
          • -
          • -
          """) + self.assertHTMLEqual(str(f['composers']), """
          +
          +
          +
          """) f = SongForm({'composers': ['J', 'P']}, auto_id=False) - self.assertHTMLEqual(str(f['composers']), """
            -
          • -
          • -
          """) + self.assertHTMLEqual(str(f['composers']), """
          +
          +
          +
          """) # Test iterating on individual checkboxes in a template t = Template('{% for checkbox in form.composers %}
          {{ checkbox }}
          {% endfor %}') self.assertHTMLEqual(t.render(Context({'form': f})), """
          f = SongForm(auto_id='%s_id') self.assertHTMLEqual( str(f['composers']), - """
            -
          • -
          • -
          """ + """
          +
          +
          +
          """ ) def test_multiple_choice_list_data(self): diff --git a/tests/forms_tests/tests/test_i18n.py b/tests/forms_tests/tests/test_i18n.py index ec1f323f1ad..d941902af26 100644 --- a/tests/forms_tests/tests/test_i18n.py +++ b/tests/forms_tests/tests/test_i18n.py @@ -57,15 +57,15 @@ class FormsI18nTests(SimpleTestCase): self.assertHTMLEqual( f.as_p(), '

          ' - '

            \n' - '
          • \n' - '
          \n' + '
          \n
        • \n
          \n

        ' + 'Nainen
      \n

      ' ) # Translated error messages @@ -77,14 +77,14 @@ class FormsI18nTests(SimpleTestCase): '\u041e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c' '\u043d\u043e\u0435 \u043f\u043e\u043b\u0435.
    \n' '

    ' - '

      \n
    • \n' - '
    \n' + '
  • \n
  • \n
  • \n

    ' + 'Nainen\n

    ' ) def test_select_translated_text(self): diff --git a/tests/forms_tests/widget_tests/test_checkboxselectmultiple.py b/tests/forms_tests/widget_tests/test_checkboxselectmultiple.py index 42555d98a63..05406894dd6 100644 --- a/tests/forms_tests/widget_tests/test_checkboxselectmultiple.py +++ b/tests/forms_tests/widget_tests/test_checkboxselectmultiple.py @@ -11,39 +11,39 @@ class CheckboxSelectMultipleTest(WidgetTest): widget = CheckboxSelectMultiple def test_render_value(self): - self.check_html(self.widget(choices=self.beatles), 'beatles', ['J'], html=( - """
      -
    • -
    • -
    • -
    • -
    """ - )) + self.check_html(self.widget(choices=self.beatles), 'beatles', ['J'], html=""" +
    +
    +
    +
    +
    +
    + """) def test_render_value_multiple(self): - self.check_html(self.widget(choices=self.beatles), 'beatles', ['J', 'P'], html=( - """
      -
    • -
    • -
    • -
    • -
    """ - )) + self.check_html(self.widget(choices=self.beatles), 'beatles', ['J', 'P'], html=""" +
    +
    +
    +
    +
    +
    + """) def test_render_none(self): """ If the value is None, none of the options are selected, even if the choices have an empty option. """ - self.check_html(self.widget(choices=(('', 'Unknown'),) + self.beatles), 'beatles', None, html=( - """
      -
    • -
    • -
    • -
    • -
    • -
    """ - )) + self.check_html(self.widget(choices=(('', 'Unknown'),) + self.beatles), 'beatles', None, html=""" +
    +
    +
    +
    +
    +
    +
    + """) def test_nested_choices(self): nested_choices = ( @@ -52,31 +52,23 @@ class CheckboxSelectMultipleTest(WidgetTest): ('Video', (('vhs', 'VHS'), ('dvd', 'DVD'))), ) html = """ -
      -
    • - -
    • -
    • Audio
        -
      • - -
      • -
      • - -
      • -
    • -
    • Video
        -
      • - -
      • -
      • - -
      • -
    • -
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    """ self.check_html( self.widget(choices=nested_choices), 'nestchoice', ('vinyl', 'dvd'), @@ -90,31 +82,18 @@ class CheckboxSelectMultipleTest(WidgetTest): ('Video', (('vhs', 'VHS'), ('dvd', 'DVD'))), ) html = """ -
      -
    • - -
    • -
    • Audio
        -
      • - -
      • -
      • - -
      • -
    • -
    • Video
        -
      • - -
      • -
      • - -
      • -
    • -
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    """ self.check_html(self.widget(choices=nested_choices), 'nestchoice', ('vinyl', 'dvd'), html=html) @@ -124,15 +103,15 @@ class CheckboxSelectMultipleTest(WidgetTest): """ choices = [('a', 'A'), ('b', 'B'), ('c', 'C')] html = """ -
      -
    • +
      +
      -
    • -
    • -
    • + +
      +
      -
    • -
    + + """ self.check_html(self.widget(choices=choices), 'letters', ['a', 'c'], attrs={'id': 'abc'}, html=html) @@ -142,15 +121,15 @@ class CheckboxSelectMultipleTest(WidgetTest): """ widget = CheckboxSelectMultiple(attrs={'id': 'abc'}, choices=[('a', 'A'), ('b', 'B'), ('c', 'C')]) html = """ -
      -
    • +
      +
      -
    • -
    • -
    • + +
      +
      -
    • -
    + + """ self.check_html(widget, 'letters', ['a', 'c'], html=html) @@ -162,11 +141,11 @@ class CheckboxSelectMultipleTest(WidgetTest): (1000000, 'One million'), ] html = """ -
      -
    • -
    • -
    • -
    +
    +
    +
    +
    +
    """ self.check_html(self.widget(choices=choices), 'numbers', None, html=html) @@ -175,10 +154,10 @@ class CheckboxSelectMultipleTest(WidgetTest): (datetime.time(12, 0), 'noon'), ] html = """ -
      -
    • -
    • -
    +
    +
    +
    +
    """ self.check_html(self.widget(choices=choices), 'times', None, html=html) diff --git a/tests/forms_tests/widget_tests/test_radioselect.py b/tests/forms_tests/widget_tests/test_radioselect.py index 0f85563a58f..41f771e9407 100644 --- a/tests/forms_tests/widget_tests/test_radioselect.py +++ b/tests/forms_tests/widget_tests/test_radioselect.py @@ -11,15 +11,15 @@ class RadioSelectTest(WidgetTest): def test_render(self): choices = (('', '------'),) + self.beatles - self.check_html(self.widget(choices=choices), 'beatle', 'J', html=( - """
      -
    • -
    • -
    • -
    • -
    • -
    """ - )) + self.check_html(self.widget(choices=choices), 'beatle', 'J', html=""" +
    +
    +
    +
    +
    +
    +
    + """) def test_nested_choices(self): nested_choices = ( @@ -28,25 +28,23 @@ class RadioSelectTest(WidgetTest): ('Video', (('vhs', 'VHS'), ('dvd', 'DVD'))), ) html = """ -
      -
    • - -
    • -
    • Audio
        -
      • - -
      • -
      • -
    • -
    • Video
        -
      • -
      • - -
      • -
    • -
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    """ self.check_html( self.widget(choices=nested_choices), 'nestchoice', 'dvd', @@ -60,14 +58,14 @@ class RadioSelectTest(WidgetTest): """ widget = RadioSelect(attrs={'id': 'foo'}, choices=self.beatles) html = """ -
      -
    • +
      +
      -
    • -
    • -
    • -
    • -
    + +
    +
    +
    + """ self.check_html(widget, 'beatle', 'J', html=html) @@ -77,29 +75,29 @@ class RadioSelectTest(WidgetTest): inputs. """ html = """ -
      -
    • +
      +
      -
    • -
    • -
    • -
    • -
    + +
    +
    +
    + """ self.check_html(self.widget(choices=self.beatles), 'beatle', 'J', attrs={'id': 'bar'}, html=html) def test_class_attrs(self): """ - The
      in the multiple_input.html widget template include the class + The
      in the multiple_input.html widget template include the class attribute. """ html = """ -
        -
      • -
      • -
      • -
      • -
      +
      +
      +
      +
      +
      +
      """ self.check_html(self.widget(choices=self.beatles), 'beatle', 'J', attrs={'class': 'bar'}, html=html) @@ -111,11 +109,11 @@ class RadioSelectTest(WidgetTest): (1000000, 'One million'), ] html = """ -
        -
      • -
      • -
      • -
      +
      +
      +
      +
      +
      """ self.check_html(self.widget(choices=choices), 'number', None, html=html) @@ -124,22 +122,22 @@ class RadioSelectTest(WidgetTest): (datetime.time(12, 0), 'noon'), ] html = """ -
        -
      • -
      • -
      +
      +
      +
      +
      """ self.check_html(self.widget(choices=choices), 'time', None, html=html) def test_render_as_subwidget(self): """A RadioSelect as a subwidget of MultiWidget.""" choices = (('', '------'),) + self.beatles - self.check_html(MultiWidget([self.widget(choices=choices)]), 'beatle', ['J'], html=( - """
        -
      • -
      • -
      • -
      • -
      • -
      """ - )) + self.check_html(MultiWidget([self.widget(choices=choices)]), 'beatle', ['J'], html=""" +
      +
      +
      +
      +
      +
      +
      + """) diff --git a/tests/model_forms/test_modelchoicefield.py b/tests/model_forms/test_modelchoicefield.py index 8f41ce9c400..2a0c05d8039 100644 --- a/tests/model_forms/test_modelchoicefield.py +++ b/tests/model_forms/test_modelchoicefield.py @@ -294,14 +294,14 @@ class ModelChoiceFieldTests(TestCase): field = CustomModelMultipleChoiceField(Category.objects.all()) self.assertHTMLEqual( field.widget.render('name', []), ( - '
        ' - '
      • ' - '
      • ' - '
      • ' - '
      ' + '
      ' + '
      ' + '
      ' + '
      ' + '
      ' ) % (self.c1.pk, self.c2.pk, self.c3.pk), ) @@ -334,11 +334,11 @@ class ModelChoiceFieldTests(TestCase): field = CustomModelMultipleChoiceField(Category.objects.all()) self.assertHTMLEqual( field.widget.render('name', []), - '''
        -
      • -
      • -
      • -
      ''' % (self.c1.pk, self.c2.pk, self.c3.pk), + """
      +
      +
      +
      +
      """ % (self.c1.pk, self.c2.pk, self.c3.pk), ) def test_choices_not_fetched_when_not_rendering(self):