summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorantoliny0919 <antoliny0919@gmail.com>2025-05-08 19:24:07 +0900
committerSarah Boyce <42296566+sarahboyce@users.noreply.github.com>2025-06-27 18:03:16 +0200
commit3f59711581bd22ebd0f13fb040b15b69c0eee21f (patch)
tree7b3eea333004013bbfdd1d48b6eb4644cd164c8f
parent29f5e1e97d660855d34c1ee5edbd5cfe094b6ca7 (diff)
Fixed #36366 -- Improved accessibility of pagination in the admin.
-rw-r--r--django/contrib/admin/static/admin/css/base.css27
-rw-r--r--django/contrib/admin/static/admin/css/changelists.css20
-rw-r--r--django/contrib/admin/static/admin/css/rtl.css2
-rw-r--r--django/contrib/admin/templates/admin/change_list.html7
-rw-r--r--django/contrib/admin/templates/admin/object_history.html13
-rw-r--r--django/contrib/admin/templates/admin/pagination.html18
-rw-r--r--django/contrib/admin/templatetags/admin_list.py8
-rw-r--r--tests/admin_changelist/tests.py35
-rw-r--r--tests/admin_views/test_history_view.py21
9 files changed, 118 insertions, 33 deletions
diff --git a/django/contrib/admin/static/admin/css/base.css b/django/contrib/admin/static/admin/css/base.css
index 79ea073bda..c672a3051f 100644
--- a/django/contrib/admin/static/admin/css/base.css
+++ b/django/contrib/admin/static/admin/css/base.css
@@ -1164,7 +1164,19 @@ a.deletelink:focus, a.deletelink:hover {
box-sizing: border-box;
}
+.paginator ul {
+ margin: 0;
+ margin-right: 6px;
+}
+
+.paginator ul li {
+ display: inline-block;
+ line-height: 22px;
+ padding: 0;
+}
+
.paginator a:link, .paginator a:visited {
+ display: inline-block;
padding: 2px 6px;
background: var(--button-bg);
text-decoration: none;
@@ -1182,18 +1194,15 @@ a.deletelink:focus, a.deletelink:hover {
color: var(--link-hover-color);
}
-.paginator .end {
- margin-right: 6px;
-}
-
-.paginator .this-page {
- padding: 2px 6px;
+.paginator a[aria-current="page"] {
+ color: var(--body-quiet-color);
+ background: transparent;
font-weight: bold;
- font-size: 0.8125rem;
- vertical-align: top;
+ cursor: default;
}
-.paginator a:focus, .paginator a:hover {
+.paginator a:not([aria-current="page"]):focus,
+.paginator a:not([aria-current="page"]):hover {
color: white;
background: var(--link-hover-color);
}
diff --git a/django/contrib/admin/static/admin/css/changelists.css b/django/contrib/admin/static/admin/css/changelists.css
index 005b7768c8..1edca65698 100644
--- a/django/contrib/admin/static/admin/css/changelists.css
+++ b/django/contrib/admin/static/admin/css/changelists.css
@@ -43,6 +43,22 @@
border-bottom: 1px solid var(--hairline-color);
}
+#changelist .changelist-footer {
+ display: flex;
+ align-items: center;
+ padding: 10px;
+ border-top: 1px solid var(--hairline-color);
+ border-bottom: 1px solid var(--hairline-color);
+}
+
+#changelist .changelist-footer .paginator {
+ color: var(--body-quiet-color);
+ background: var(--body-bg);
+ border: none;
+ padding: 0;
+ overflow: hidden;
+}
+
#changelist .paginator {
color: var(--body-quiet-color);
border-bottom: 1px solid var(--hairline-color);
@@ -50,6 +66,10 @@
overflow: hidden;
}
+#changelist .paginator ul {
+ padding: 0;
+}
+
/* CHANGELIST TABLES */
#changelist table thead th {
diff --git a/django/contrib/admin/static/admin/css/rtl.css b/django/contrib/admin/static/admin/css/rtl.css
index 0d843726bf..b824d7d0db 100644
--- a/django/contrib/admin/static/admin/css/rtl.css
+++ b/django/contrib/admin/static/admin/css/rtl.css
@@ -107,7 +107,7 @@ thead th.sorted .text {
border-left: none;
}
-.paginator .end {
+.paginator ul {
margin-left: 6px;
margin-right: 0;
}
diff --git a/django/contrib/admin/templates/admin/change_list.html b/django/contrib/admin/templates/admin/change_list.html
index 162560dabc..3b3ea408d3 100644
--- a/django/contrib/admin/templates/admin/change_list.html
+++ b/django/contrib/admin/templates/admin/change_list.html
@@ -69,7 +69,12 @@
{% result_list cl %}
{% if action_form and actions_on_bottom and cl.show_admin_actions %}{% admin_actions %}{% endif %}
{% endblock %}
- {% block pagination %}{% pagination cl %}{% endblock %}
+ {% block pagination %}
+ <div class="changelist-footer">
+ {% pagination cl %}
+ {% if cl.formset and cl.result_count %}<input type="submit" name="_save" class="default" value="{% translate 'Save' %}">{% endif %}
+ {% endblock %}
+ </div>
</form>
</div>
{% block filters %}
diff --git a/django/contrib/admin/templates/admin/object_history.html b/django/contrib/admin/templates/admin/object_history.html
index 2b3b06caa0..40aaecc40a 100644
--- a/django/contrib/admin/templates/admin/object_history.html
+++ b/django/contrib/admin/templates/admin/object_history.html
@@ -34,20 +34,23 @@
{% endfor %}
</tbody>
</table>
- <p class="paginator">
+ <nav class="paginator" aria-labelledby="pagination">
+ <h2 id="pagination" class="visually-hidden">{% blocktranslate with name=opts.verbose_name %}Pagination {{ name }} entries{% endblocktranslate %}</h2>
{% if pagination_required %}
+ <ul>
{% for i in page_range %}
{% if i == action_list.paginator.ELLIPSIS %}
- {{ action_list.paginator.ELLIPSIS }}
+ <li>{{ action_list.paginator.ELLIPSIS }}</li>
{% elif i == action_list.number %}
- <span class="this-page">{{ i }}</span>
+ <li><a role="button" href="" aria-current="page">{{ i }}</a></li>
{% else %}
- <a role="button" href="?{{ page_var }}={{ i }}" {% if i == action_list.paginator.num_pages %} class="end" {% endif %}>{{ i }}</a>
+ <li><a role="button" href="?{{ page_var }}={{ i }}">{{ i }}</a></li>
{% endif %}
{% endfor %}
+ </ul>
{% endif %}
{{ action_list.paginator.count }} {% blocktranslate count counter=action_list.paginator.count %}entry{% plural %}entries{% endblocktranslate %}
- </p>
+ </nav>
{% else %}
<p>{% translate 'This object doesn’t have a change history. It probably wasn’t added via this admin site.' %}</p>
{% endif %}
diff --git a/django/contrib/admin/templates/admin/pagination.html b/django/contrib/admin/templates/admin/pagination.html
index bc3117bfcb..954e710856 100644
--- a/django/contrib/admin/templates/admin/pagination.html
+++ b/django/contrib/admin/templates/admin/pagination.html
@@ -1,12 +1,14 @@
{% load admin_list %}
{% load i18n %}
-<p class="paginator">
-{% if pagination_required %}
-{% for i in page_range %}
- {% paginator_number cl i %}
-{% endfor %}
-{% endif %}
+<nav class="paginator" aria-labelledby="pagination">
+ <h2 id="pagination" class="visually-hidden">{% blocktranslate with name=cl.opts.verbose_name_plural %}Pagination {{ name }}{% endblocktranslate %}</h2>
+ {% if pagination_required %}
+ <ul>
+ {% for i in page_range %}
+ <li>{% paginator_number cl i %}</li>
+ {% endfor %}
+ </ul>
+ {% endif %}
{{ cl.result_count }} {% if cl.result_count == 1 %}{{ cl.opts.verbose_name }}{% else %}{{ cl.opts.verbose_name_plural }}{% endif %}
{% if show_all_url %}<a href="{{ show_all_url }}" class="showall">{% translate 'Show all' %}</a>{% endif %}
-{% if cl.formset and cl.result_count %}<input type="submit" name="_save" class="default" value="{% translate 'Save' %}">{% endif %}
-</p>
+</nav>
diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py
index e7cb244af1..1e6f8bf298 100644
--- a/django/contrib/admin/templatetags/admin_list.py
+++ b/django/contrib/admin/templatetags/admin_list.py
@@ -42,12 +42,14 @@ def paginator_number(cl, i):
if i == cl.paginator.ELLIPSIS:
return format_html("{} ", cl.paginator.ELLIPSIS)
elif i == cl.page_num:
- return format_html('<span class="this-page">{}</span> ', i)
+ return format_html(
+ '<a role="button" href="" aria-current="page">{}</a> ',
+ i,
+ )
else:
return format_html(
- '<a role="button" href="{}"{}>{}</a> ',
+ '<a role="button" href="{}">{}</a> ',
cl.get_query_string({PAGE_VAR: i}),
- mark_safe(' class="end"' if i == cl.paginator.num_pages else ""),
i,
)
diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py
index 7203daa6b0..cd2fe7c645 100644
--- a/tests/admin_changelist/tests.py
+++ b/tests/admin_changelist/tests.py
@@ -946,6 +946,41 @@ class ChangeListTests(TestCase):
self.assertEqual(cl.paginator.count, 30)
self.assertEqual(list(cl.paginator.page_range), [1, 2, 3])
+ def test_pagination_render(self):
+ objs = [Swallow(origin=f"Swallow {i}", load=i, speed=i) for i in range(1, 5)]
+ Swallow.objects.bulk_create(objs)
+
+ request = self.factory.get("/child/")
+ request.user = self.superuser
+
+ admin = SwallowAdmin(Swallow, custom_site)
+ cl = admin.get_changelist_instance(request)
+ template = Template(
+ "{% load admin_list %}{% spaceless %}{% pagination cl %}{% endspaceless %}"
+ )
+ context = Context({"cl": cl, "opts": cl.opts})
+ pagination_output = template.render(context)
+ self.assertTrue(
+ pagination_output.startswith(
+ '<nav class="paginator" aria-labelledby="pagination">'
+ )
+ )
+ self.assertInHTML(
+ '<h2 id="pagination" class="visually-hidden">Pagination swallows</h2>',
+ pagination_output,
+ )
+ self.assertTrue(pagination_output.endswith("</nav>"))
+ self.assertInHTML(
+ '<li><a role="button" href="" aria-current="page">1</a></li>',
+ pagination_output,
+ )
+ self.assertInHTML(
+ '<li><a role="button" href="?p=2">2</a></li>',
+ pagination_output,
+ )
+ self.assertEqual(pagination_output.count('aria-current="page"'), 1)
+ self.assertEqual(pagination_output.count('href=""'), 1)
+
def test_computed_list_display_localization(self):
"""
Regression test for #13196: output of functions should be localized
diff --git a/tests/admin_views/test_history_view.py b/tests/admin_views/test_history_view.py
index 7079c1d0d8..49a60aa306 100644
--- a/tests/admin_views/test_history_view.py
+++ b/tests/admin_views/test_history_view.py
@@ -80,20 +80,29 @@ class SeleniumTests(AdminSeleniumTestCase):
self.selenium.get(self.live_server_url + user_history_url)
paginator = self.selenium.find_element(By.CSS_SELECTOR, ".paginator")
+ self.assertEqual(paginator.tag_name, "nav")
+ labelledby = paginator.get_attribute("aria-labelledby")
+ description = self.selenium.find_element(By.CSS_SELECTOR, "#%s" % labelledby)
+ self.assertHTMLEqual(
+ description.get_attribute("outerHTML"),
+ '<h2 id="pagination" class="visually-hidden">Pagination user entries</h2>',
+ )
self.assertTrue(paginator.is_displayed())
+ aria_current_link = paginator.find_elements(By.CSS_SELECTOR, "[aria-current]")
+ self.assertEqual(len(aria_current_link), 1)
+ # The current page.
+ current_page_link = aria_current_link[0]
+ self.assertEqual(current_page_link.get_attribute("aria-current"), "page")
+ self.assertEqual(current_page_link.get_attribute("href"), "")
self.assertIn("%s entries" % LogEntry.objects.count(), paginator.text)
self.assertIn(str(Paginator.ELLIPSIS), paginator.text)
- # The current page.
- current_page_link = self.selenium.find_element(
- By.CSS_SELECTOR, "span.this-page"
- )
self.assertEqual(current_page_link.text, "1")
# The last page.
- last_page_link = self.selenium.find_element(By.CSS_SELECTOR, ".end")
+ last_page_link = self.selenium.find_element(By.XPATH, "//ul/li[last()]/a")
self.assertTrue(last_page_link.text, "20")
# Select the second page.
pages = paginator.find_elements(By.TAG_NAME, "a")
- second_page_link = pages[0]
+ second_page_link = pages[1]
self.assertEqual(second_page_link.text, "2")
second_page_link.click()
self.assertIn("?p=2", self.selenium.current_url)