diff options
| author | Marijke Luttekes <mail@marijkeluttekes.dev> | 2024-05-20 14:39:09 -0300 |
|---|---|---|
| committer | nessita <124304+nessita@users.noreply.github.com> | 2024-05-22 00:13:55 -0300 |
| commit | e4a693f50a8342ed1a54b74e1a202b44c8e62981 (patch) | |
| tree | fe5924414b2edafaec2ee4c7709cbf86dfc94220 | |
| parent | 01ed59f753139afb514170ee7f7384c155ecbc2d (diff) | |
Fixed #35189 -- Improved admin collapsible fieldsets by using <details> elements.
This work improves the accessibility of the add and change pages in the
admin site by adding <details> and <summary> elements to the collapsible
fieldsets. This has the nice side effect of no longer requiring custom
JavaScript helpers to implement the fieldsets' show/hide capabilities.
Thanks to James Scholes for the accessibility advice, and to Sarah Boyce
and Tom Carrick for reviews.
Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
Co-authored-by: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com>
| -rw-r--r-- | django/contrib/admin/helpers.py | 15 | ||||
| -rw-r--r-- | django/contrib/admin/options.py | 2 | ||||
| -rw-r--r-- | django/contrib/admin/static/admin/css/forms.css | 55 | ||||
| -rw-r--r-- | django/contrib/admin/static/admin/css/responsive.css | 4 | ||||
| -rw-r--r-- | django/contrib/admin/templates/admin/edit_inline/stacked.html | 3 | ||||
| -rw-r--r-- | django/contrib/admin/templates/admin/edit_inline/tabular.html | 3 | ||||
| -rw-r--r-- | django/contrib/admin/templates/admin/includes/fieldset.html | 3 | ||||
| -rw-r--r-- | docs/ref/contrib/admin/index.txt | 21 | ||||
| -rw-r--r-- | docs/releases/5.1.txt | 6 | ||||
| -rw-r--r-- | tests/admin_inlines/tests.py | 86 | ||||
| -rw-r--r-- | tests/admin_views/tests.py | 9 |
11 files changed, 119 insertions, 88 deletions
diff --git a/django/contrib/admin/helpers.py b/django/contrib/admin/helpers.py index c4613fa24e..a4aa8e40e3 100644 --- a/django/contrib/admin/helpers.py +++ b/django/contrib/admin/helpers.py @@ -18,6 +18,7 @@ from django.db.models.fields.related import ( from django.forms.utils import flatatt from django.template.defaultfilters import capfirst, linebreaksbr from django.urls import NoReverseMatch, reverse +from django.utils.functional import cached_property from django.utils.html import conditional_escape, format_html from django.utils.safestring import mark_safe from django.utils.translation import gettext @@ -116,10 +117,14 @@ class Fieldset: @property def media(self): - if "collapse" in self.classes: - return forms.Media(js=["admin/js/collapse.js"]) return forms.Media() + @cached_property + def is_collapsible(self): + if any([field in self.fields for field in self.form.errors]): + return False + return "collapse" in self.classes + def __iter__(self): for field in self.fields: yield Fieldline( @@ -438,6 +443,12 @@ class InlineAdminFormSet: def forms(self): return self.formset.forms + @cached_property + def is_collapsible(self): + if any(self.formset.errors): + return False + return "collapse" in self.classes + def non_form_errors(self): return self.formset.non_form_errors() diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 47b4821fcc..12467de74d 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -2398,8 +2398,6 @@ class InlineModelAdmin(BaseModelAdmin): js = ["vendor/jquery/jquery%s.js" % extra, "jquery.init.js", "inlines.js"] if self.filter_vertical or self.filter_horizontal: js.extend(["SelectBox.js", "SelectFilter2.js"]) - if self.classes and "collapse" in self.classes: - js.append("collapse.js") return forms.Media(js=["admin/js/%s" % url for url in js]) def get_extra(self, request, obj=None, **kwargs): diff --git a/django/contrib/admin/static/admin/css/forms.css b/django/contrib/admin/static/admin/css/forms.css index fe60536d62..539a11ae61 100644 --- a/django/contrib/admin/static/admin/css/forms.css +++ b/django/contrib/admin/static/admin/css/forms.css @@ -76,6 +76,20 @@ form ul.inline li { padding-right: 7px; } +/* FIELDSETS */ + +fieldset .fieldset-heading, +fieldset .inline-heading, +:not(.inline-related) .collapse summary { + border: 1px solid var(--header-bg); + margin: 0; + padding: 8px; + font-weight: 400; + font-size: 0.8125rem; + background: var(--header-bg); + color: var(--header-link-color); +} + /* ALIGNED FIELDSETS */ .aligned label { @@ -207,35 +221,16 @@ form div.help ul { width: 450px; } -/* COLLAPSED FIELDSETS */ +/* COLLAPSIBLE FIELDSETS */ -fieldset.collapsed * { - display: none; -} - -fieldset.collapsed h2, fieldset.collapsed { - display: block; -} - -fieldset.collapsed { - border: 1px solid var(--hairline-color); - border-radius: 4px; - overflow: hidden; -} - -fieldset.collapsed h2 { - background: var(--darkened-bg); - color: var(--body-quiet-color); -} - -fieldset .collapse-toggle { - color: var(--header-link-color); -} - -fieldset.collapsed .collapse-toggle { +.collapse summary .fieldset-heading, +.collapse summary .inline-heading { background: transparent; + border: none; + color: currentColor; display: inline; - color: var(--link-fg); + margin: 0; + padding: 0; } /* MONOSPACE TEXTAREAS */ @@ -387,14 +382,16 @@ body.popup .submit-row { position: relative; } -.inline-related h3 { +.inline-related h4, +.inline-related:not(.tabular) .collapse summary { margin: 0; color: var(--body-quiet-color); padding: 5px; font-size: 0.8125rem; background: var(--darkened-bg); - border-top: 1px solid var(--hairline-color); - border-bottom: 1px solid var(--hairline-color); + border: 1px solid var(--hairline-color); + border-left-color: var(--darkened-bg); + border-right-color: var(--darkened-bg); } .inline-related h3 span.delete { diff --git a/django/contrib/admin/static/admin/css/responsive.css b/django/contrib/admin/static/admin/css/responsive.css index b58cbd964e..932e824c1c 100644 --- a/django/contrib/admin/static/admin/css/responsive.css +++ b/django/contrib/admin/static/admin/css/responsive.css @@ -565,10 +565,6 @@ input[type="submit"], button { padding-top: 15px; } - fieldset.collapsed .form-row { - display: none; - } - .aligned label { width: 100%; min-width: auto; diff --git a/django/contrib/admin/templates/admin/edit_inline/stacked.html b/django/contrib/admin/templates/admin/edit_inline/stacked.html index b0331c65f8..73f459ee47 100644 --- a/django/contrib/admin/templates/admin/edit_inline/stacked.html +++ b/django/contrib/admin/templates/admin/edit_inline/stacked.html @@ -4,6 +4,7 @@ data-inline-type="stacked" data-inline-formset="{{ inline_admin_formset.inline_formset_data }}"> <fieldset class="module {{ inline_admin_formset.classes }}" aria-labelledby="{{ inline_admin_formset.formset.prefix }}-heading"> + {% if inline_admin_formset.is_collapsible %}<details><summary>{% endif %} <h2 id="{{ inline_admin_formset.formset.prefix }}-heading" class="inline-heading"> {% if inline_admin_formset.formset.max_num == 1 %} {{ inline_admin_formset.opts.verbose_name|capfirst }} @@ -11,6 +12,7 @@ {{ inline_admin_formset.opts.verbose_name_plural|capfirst }} {% endif %} </h2> + {% if inline_admin_formset.is_collapsible %}</summary>{% endif %} {{ inline_admin_formset.formset.management_form }} {{ inline_admin_formset.formset.non_form_errors }} @@ -31,5 +33,6 @@ {% if inline_admin_form.needs_explicit_pk_field %}{{ inline_admin_form.pk_field.field }}{% endif %} {% if inline_admin_form.fk_field %}{{ inline_admin_form.fk_field.field }}{% endif %} </div>{% endfor %} + {% if inline_admin_formset.is_collapsible %}</details>{% endif %} </fieldset> </div> diff --git a/django/contrib/admin/templates/admin/edit_inline/tabular.html b/django/contrib/admin/templates/admin/edit_inline/tabular.html index 845d3917a4..7acfda7bd1 100644 --- a/django/contrib/admin/templates/admin/edit_inline/tabular.html +++ b/django/contrib/admin/templates/admin/edit_inline/tabular.html @@ -5,6 +5,7 @@ <div class="tabular inline-related {% if forloop.last %}last-related{% endif %}"> {{ inline_admin_formset.formset.management_form }} <fieldset class="module {{ inline_admin_formset.classes }}" aria-labelledby="{{ inline_admin_formset.formset.prefix }}-heading"> + {% if inline_admin_formset.is_collapsible %}<details><summary>{% endif %} <h2 id="{{ inline_admin_formset.formset.prefix }}-heading" class="inline-heading"> {% if inline_admin_formset.formset.max_num == 1 %} {{ inline_admin_formset.opts.verbose_name|capfirst }} @@ -12,6 +13,7 @@ {{ inline_admin_formset.opts.verbose_name_plural|capfirst }} {% endif %} </h2> + {% if inline_admin_formset.is_collapsible %}</summary>{% endif %} {{ inline_admin_formset.formset.non_form_errors }} <table> <thead><tr> @@ -63,6 +65,7 @@ {% endfor %} </tbody> </table> + {% if inline_admin_formset.is_collapsible %}</details>{% endif %} </fieldset> </div> </div> diff --git a/django/contrib/admin/templates/admin/includes/fieldset.html b/django/contrib/admin/templates/admin/includes/fieldset.html index 66bf714e9a..b4eef47547 100644 --- a/django/contrib/admin/templates/admin/includes/fieldset.html +++ b/django/contrib/admin/templates/admin/includes/fieldset.html @@ -1,7 +1,9 @@ {% with prefix=fieldset.formset.prefix|default:"fieldset" id_prefix=id_prefix|default:"0" id_suffix=id_suffix|default:"0" name=fieldset.name|default:""|slugify %} <fieldset class="module aligned {{ fieldset.classes }}"{% if name %} aria-labelledby="{{ prefix }}-{{ id_prefix}}-{{ name }}-{{ id_suffix }}-heading"{% endif %}> {% if name %} + {% if fieldset.is_collapsible %}<details><summary>{% endif %} <h{{ heading_level|default:2 }} id="{{ prefix }}-{{ id_prefix}}-{{ name }}-{{ id_suffix }}-heading" class="fieldset-heading">{{ fieldset.name }}</h{{ heading_level|default:2 }}> + {% if fieldset.is_collapsible %}</summary>{% endif %} {% endif %} {% if fieldset.description %} <div class="description">{{ fieldset.description|safe }}</div> @@ -34,5 +36,6 @@ {% if not line.fields|length == 1 %}</div>{% endif %} </div> {% endfor %} + {% if name and fieldset.is_collapsible %}</details>{% endif %} </fieldset> {% endwith %} diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index 2a5652c24b..b7e94c7387 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -424,9 +424,16 @@ subclass:: "classes": ["wide", "collapse"], } - Fieldsets with the ``collapse`` style will be initially collapsed in - the admin and replaced with a small "click to expand" link. Fieldsets - with the ``wide`` style will be given extra horizontal space. + Fieldsets with the ``wide`` style will be given extra horizontal + space in the admin interface. + Fieldsets with a name and the ``collapse`` style will be initially + collapsed, using an expandable widget with a toggle for switching + their visibility. + + .. versionchanged:: 5.1 + + ``fieldsets`` using the ``collapse`` class now use ``<details>`` + and ``<summary>`` elements, provided they define a ``name``. * ``description`` A string of optional extra text to be displayed at the top of each @@ -2308,8 +2315,12 @@ The ``InlineModelAdmin`` class adds or customizes: A list or tuple containing extra CSS classes to apply to the fieldset that is rendered for the inlines. Defaults to ``None``. As with classes configured in :attr:`~ModelAdmin.fieldsets`, inlines with a ``collapse`` - class will be initially collapsed and their header will have a small "show" - link. + class will be initially collapsed using an expandable widget. + + .. versionchanged:: 5.1 + + ``fieldsets`` using the ``collapse`` class now use ``<details>`` and + ``<summary>`` elements, provided they define a ``name``. .. attribute:: InlineModelAdmin.extra diff --git a/docs/releases/5.1.txt b/docs/releases/5.1.txt index 68315205df..e4f0b37402 100644 --- a/docs/releases/5.1.txt +++ b/docs/releases/5.1.txt @@ -419,6 +419,12 @@ Miscellaneous a ``<footer>`` tag instead of a ``<div>``, and also moved below the ``<div id="main">`` element. +* In order to improve accessibility, the expandable widget used for + :attr:`ModelAdmin.fieldsets <django.contrib.admin.ModelAdmin.fieldsets>` and + :attr:`InlineModelAdmin.fieldsets <django.contrib.admin.InlineModelAdmin>`, + when the fieldset has a name and use the ``collapse`` class, now includes + ``<details>`` and ``<summary>`` elements. + * :meth:`.SimpleTestCase.assertURLEqual` and :meth:`~django.test.SimpleTestCase.assertInHTML` now add ``": "`` to the ``msg_prefix``. This is consistent with the behavior of other assertions. diff --git a/tests/admin_inlines/tests.py b/tests/admin_inlines/tests.py index 7b38619cc0..dee703825d 100644 --- a/tests/admin_inlines/tests.py +++ b/tests/admin_inlines/tests.py @@ -4,7 +4,9 @@ from django.contrib.admin.tests import AdminSeleniumTestCase from django.contrib.auth.models import Permission, User from django.contrib.contenttypes.models import ContentType from django.test import RequestFactory, TestCase, override_settings +from django.test.selenium import screenshot_cases from django.urls import reverse +from django.utils.translation import gettext from .admin import InnerInline from .admin import site as admin_site @@ -1790,6 +1792,10 @@ class TestInlineWithFieldsets(TestDataMixin, TestCase): if "tabular" in inline_admin_formset.opts.template: continue + if "collapse" in inline_admin_formset.classes: + formset_heading = f"<summary>{formset_heading}</summary>" + self.assertContains(response, formset_heading, html=True, count=1) + # Headings for every formset (the amount depends on `extra`). for y, inline_admin_form in enumerate(inline_admin_formset): y_plus_one = y + 1 @@ -1813,6 +1819,12 @@ class TestInlineWithFieldsets(TestDataMixin, TestCase): f"Details</h4>" ) self.assertContains(response, fieldset_heading) + if "collapse" in fieldset.classes: + self.assertContains( + response, + f"<summary>{fieldset_heading}</summary>", + html=True, + ) self.assertContains(response, f'id="{heading_id}"', count=1) else: @@ -2182,10 +2194,11 @@ class SeleniumTests(AdminSeleniumTestCase): "form#profilecollection_form tr.dynamic-profile_set#profile_set-2", 1 ) + @screenshot_cases(["desktop_size", "mobile_size", "rtl", "dark", "high_contrast"]) def test_collapsed_inlines(self): from selenium.webdriver.common.by import By - # Collapsed inlines have SHOW/HIDE links. + # Collapsed inlines use details and summary elements. self.admin_login(username="super", password="secret") self.selenium.get( self.live_server_url + reverse("admin:admin_inlines_author_add") @@ -2195,19 +2208,21 @@ class SeleniumTests(AdminSeleniumTestCase): "#id_nonautopkbook_set-0-title", "#id_nonautopkbook_set-2-0-title", ] - show_links = self.selenium.find_elements(By.LINK_TEXT, "SHOW") - self.assertEqual(len(show_links), 3) + summaries = self.selenium.find_elements(By.TAG_NAME, "summary") + self.assertEqual(len(summaries), 3) + self.take_screenshot("loaded") for show_index, field_name in enumerate(test_fields, 0): self.wait_until_invisible(field_name) - show_links[show_index].click() + summaries[show_index].click() self.wait_until_visible(field_name) - hide_links = self.selenium.find_elements(By.LINK_TEXT, "HIDE") - self.assertEqual(len(hide_links), 2) + self.take_screenshot("expanded") for hide_index, field_name in enumerate(test_fields, 0): self.wait_until_visible(field_name) - hide_links[hide_index].click() + summaries[hide_index].click() self.wait_until_invisible(field_name) + self.take_screenshot("collapsed") + @screenshot_cases(["desktop_size", "mobile_size", "rtl", "dark", "high_contrast"]) def test_added_stacked_inline_with_collapsed_fields(self): from selenium.webdriver.common.by import By @@ -2215,20 +2230,22 @@ class SeleniumTests(AdminSeleniumTestCase): self.selenium.get( self.live_server_url + reverse("admin:admin_inlines_teacher_add") ) - self.selenium.find_element(By.LINK_TEXT, "Add another Child").click() + add_text = gettext("Add another %(verbose_name)s") % {"verbose_name": "Child"} + self.selenium.find_element(By.LINK_TEXT, add_text).click() test_fields = ["#id_child_set-0-name", "#id_child_set-1-name"] - show_links = self.selenium.find_elements(By.LINK_TEXT, "SHOW") - self.assertEqual(len(show_links), 2) + summaries = self.selenium.find_elements(By.TAG_NAME, "summary") + self.assertEqual(len(summaries), 3) + self.take_screenshot("loaded") for show_index, field_name in enumerate(test_fields, 0): self.wait_until_invisible(field_name) - show_links[show_index].click() + summaries[show_index].click() self.wait_until_visible(field_name) - hide_links = self.selenium.find_elements(By.LINK_TEXT, "HIDE") - self.assertEqual(len(hide_links), 2) + self.take_screenshot("expanded") for hide_index, field_name in enumerate(test_fields, 0): self.wait_until_visible(field_name) - hide_links[hide_index].click() + summaries[hide_index].click() self.wait_until_invisible(field_name) + self.take_screenshot("collapsed") def assertBorder(self, element, border): width, style, color = border.split(" ") @@ -2264,9 +2281,9 @@ class SeleniumTests(AdminSeleniumTestCase): self.wait_until_visible("#id_dummy") self.selenium.find_element(By.ID, "id_dummy").send_keys(1) fields = ["id_inner5stacked_set-0-dummy", "id_inner5tabular_set-0-dummy"] - show_links = self.selenium.find_elements(By.LINK_TEXT, "SHOW") + summaries = self.selenium.find_elements(By.TAG_NAME, "summary") for show_index, field_name in enumerate(fields): - show_links[show_index].click() + summaries[show_index].click() self.wait_until_visible("#" + field_name) self.selenium.find_element(By.ID, field_name).send_keys(1) @@ -2304,49 +2321,40 @@ class SeleniumTests(AdminSeleniumTestCase): self.selenium.get( self.live_server_url + reverse("admin:admin_inlines_holder5_add") ) - stacked_inline_formset_selector = ( - "div#inner5stacked_set-group fieldset.module.collapse" + stacked_inline_details_selector = ( + "div#inner5stacked_set-group fieldset.module.collapse details" ) - tabular_inline_formset_selector = ( - "div#inner5tabular_set-group fieldset.module.collapse" + tabular_inline_details_selector = ( + "div#inner5tabular_set-group fieldset.module.collapse details" ) # Inlines without errors, both inlines collapsed self.selenium.find_element(By.XPATH, '//input[@value="Save"]').click() self.assertCountSeleniumElements( - stacked_inline_formset_selector + ".collapsed", 1 + stacked_inline_details_selector + ":not([open])", 1 ) self.assertCountSeleniumElements( - tabular_inline_formset_selector + ".collapsed", 1 + tabular_inline_details_selector + ":not([open])", 1 ) - show_links = self.selenium.find_elements(By.LINK_TEXT, "SHOW") - self.assertEqual(len(show_links), 2) + summaries = self.selenium.find_elements(By.TAG_NAME, "summary") + self.assertEqual(len(summaries), 2) # Inlines with errors, both inlines expanded test_fields = ["#id_inner5stacked_set-0-dummy", "#id_inner5tabular_set-0-dummy"] for show_index, field_name in enumerate(test_fields): - show_links[show_index].click() + summaries[show_index].click() self.wait_until_visible(field_name) self.selenium.find_element(By.ID, field_name[1:]).send_keys(1) - hide_links = self.selenium.find_elements(By.LINK_TEXT, "HIDE") - self.assertEqual(len(hide_links), 2) for hide_index, field_name in enumerate(test_fields): - hide_link = hide_links[hide_index] + summary = summaries[hide_index] self.selenium.execute_script( - "window.scrollTo(0, %s);" % hide_link.location["y"] + "window.scrollTo(0, %s);" % summary.location["y"] ) - hide_link.click() + summary.click() self.wait_until_invisible(field_name) with self.wait_page_loaded(): self.selenium.find_element(By.XPATH, '//input[@value="Save"]').click() - with self.disable_implicit_wait(): - self.assertCountSeleniumElements( - stacked_inline_formset_selector + ".collapsed", 0 - ) - self.assertCountSeleniumElements( - tabular_inline_formset_selector + ".collapsed", 0 - ) - self.assertCountSeleniumElements(stacked_inline_formset_selector, 1) - self.assertCountSeleniumElements(tabular_inline_formset_selector, 1) + self.assertCountSeleniumElements(stacked_inline_details_selector, 0) + self.assertCountSeleniumElements(tabular_inline_details_selector, 0) def test_inlines_verbose_name(self): """ diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index 5fab253f80..d49e7d028b 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -1939,7 +1939,6 @@ class AdminJavaScriptTest(TestCase): self.assertContains(response, "vendor/jquery/jquery.min.js") self.assertContains(response, "prepopulate.js") self.assertContains(response, "actions.js") - self.assertContains(response, "collapse.js") self.assertContains(response, "inlines.js") with override_settings(DEBUG=True): response = self.client.get(reverse("admin:admin_views_section_add")) @@ -1947,7 +1946,6 @@ class AdminJavaScriptTest(TestCase): self.assertNotContains(response, "vendor/jquery/jquery.min.js") self.assertContains(response, "prepopulate.js") self.assertContains(response, "actions.js") - self.assertContains(response, "collapse.js") self.assertContains(response, "inlines.js") @@ -6086,11 +6084,8 @@ class SeleniumTests(AdminSeleniumTestCase): ) self.assertFalse(self.selenium.find_element(By.ID, "id_title").is_displayed()) self.take_screenshot("collapsed") - self.selenium.find_elements(By.LINK_TEXT, "Show")[0].click() + self.selenium.find_elements(By.TAG_NAME, "summary")[0].click() self.assertTrue(self.selenium.find_element(By.ID, "id_title").is_displayed()) - self.assertEqual( - self.selenium.find_element(By.ID, "fieldsetcollapser0").text, "Hide" - ) self.take_screenshot("expanded") @screenshot_cases(["desktop_size", "mobile_size", "rtl", "dark", "high_contrast"]) @@ -6104,7 +6099,7 @@ class SeleniumTests(AdminSeleniumTestCase): ) url = self.live_server_url + reverse("admin7:admin_views_pizza_add") self.selenium.get(url) - self.selenium.find_elements(By.ID, "fieldsetcollapser0")[0].click() + self.selenium.find_elements(By.TAG_NAME, "summary")[0].click() from_filter_box = self.selenium.find_element(By.ID, "id_toppings_filter") from_box = self.selenium.find_element(By.ID, "id_toppings_from") to_filter_box = self.selenium.find_element(By.ID, "id_toppings_filter_selected") |
