From 564437f767eaa54bb6af86d2ebd2363e49a50421 Mon Sep 17 00:00:00 2001 From: Marcelo Galigniana Date: Sun, 10 Jul 2022 23:10:21 -0300 Subject: [PATCH] Fixed #33726 -- Added skip-link to admin for keyboard navigation. --- .../contrib/admin/static/admin/css/base.css | 14 ++ .../contrib/admin/templates/admin/base.html | 4 +- .../admin_views/test_skip_link_to_content.py | 145 ++++++++++++++++++ 3 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 tests/admin_views/test_skip_link_to_content.py diff --git a/django/contrib/admin/static/admin/css/base.css b/django/contrib/admin/static/admin/css/base.css index 581355d3f7e..8cff31d891b 100644 --- a/django/contrib/admin/static/admin/css/base.css +++ b/django/contrib/admin/static/admin/css/base.css @@ -814,6 +814,20 @@ a.deletelink:focus, a.deletelink:hover { max-width: 100%; } +.skip-to-content-link { + position: absolute; + top: -999px; + margin: 5px; + padding: 5px; + background: var(--body-bg); + z-index: 1; +} + +.skip-to-content-link:focus { + left: 0px; + top: 0px; +} + #content { padding: 20px 40px; } diff --git a/django/contrib/admin/templates/admin/base.html b/django/contrib/admin/templates/admin/base.html index b4bf2420eab..cd4b7592569 100644 --- a/django/contrib/admin/templates/admin/base.html +++ b/django/contrib/admin/templates/admin/base.html @@ -24,7 +24,7 @@ - +{% translate 'Skip to main content' %}
@@ -81,7 +81,7 @@ {% include "admin/nav_sidebar.html" %} {% endblock %} {% endif %} -
+
{% block messages %} {% if messages %}
    {% for message in messages %} diff --git a/tests/admin_views/test_skip_link_to_content.py b/tests/admin_views/test_skip_link_to_content.py new file mode 100644 index 00000000000..9f3287147e3 --- /dev/null +++ b/tests/admin_views/test_skip_link_to_content.py @@ -0,0 +1,145 @@ +from django.contrib.admin.tests import AdminSeleniumTestCase +from django.contrib.auth.models import User +from django.test import override_settings +from django.urls import reverse + + +@override_settings(ROOT_URLCONF="admin_views.urls") +class SeleniumTests(AdminSeleniumTestCase): + available_apps = ["admin_views"] + AdminSeleniumTestCase.available_apps + + def setUp(self): + self.superuser = User.objects.create_superuser( + username="super", + password="secret", + email="super@example.com", + ) + + def test_use_skip_link_to_content(self): + from selenium.webdriver.common.action_chains import ActionChains + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + + self.admin_login( + username="super", + password="secret", + login_url=reverse("admin:index"), + ) + + # `Skip link` is not present. + skip_link = self.selenium.find_element(By.CLASS_NAME, "skip-to-content-link") + self.assertFalse(skip_link.is_displayed()) + + # 1st TAB is pressed, `skip link` is shown. + body = self.selenium.find_element(By.TAG_NAME, "body") + body.send_keys(Keys.TAB) + self.assertTrue(skip_link.is_displayed()) + + # Press RETURN to skip the navbar links (view site / documentation / + # change password / log out) and focus first model in the admin_views list. + skip_link.send_keys(Keys.RETURN) + self.assertFalse(skip_link.is_displayed()) # `skip link` disappear. + keys = [Keys.TAB, Keys.TAB] # The 1st TAB is the section title. + if self.browser == "firefox": + # For some reason Firefox doesn't focus the section title ('ADMIN_VIEWS'). + keys.remove(Keys.TAB) + body.send_keys(keys) + actors_a_tag = self.selenium.find_element(By.LINK_TEXT, "Actors") + self.assertEqual(self.selenium.switch_to.active_element, actors_a_tag) + + # Go to Actors changelist, skip sidebar and focus "Add actor +". + with self.wait_page_loaded(): + actors_a_tag.send_keys(Keys.RETURN) + body = self.selenium.find_element(By.TAG_NAME, "body") + body.send_keys(Keys.TAB) + skip_link = self.selenium.find_element(By.CLASS_NAME, "skip-to-content-link") + self.assertTrue(skip_link.is_displayed()) + ActionChains(self.selenium).send_keys(Keys.RETURN, Keys.TAB).perform() + actors_add_url = reverse("admin:admin_views_actor_add") + actors_a_tag = self.selenium.find_element( + By.CSS_SELECTOR, f"#content [href='{actors_add_url}']" + ) + self.assertEqual(self.selenium.switch_to.active_element, actors_a_tag) + + # Go to the Actor form and the first input will be focused automatically. + with self.wait_page_loaded(): + actors_a_tag.send_keys(Keys.RETURN) + first_input = self.selenium.find_element(By.ID, "id_name") + self.assertEqual(self.selenium.switch_to.active_element, first_input) + + def test_dont_use_skip_link_to_content(self): + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + + self.admin_login( + username="super", + password="secret", + login_url=reverse("admin:index"), + ) + + # `Skip link` is not present. + skip_link = self.selenium.find_element(By.CLASS_NAME, "skip-to-content-link") + self.assertFalse(skip_link.is_displayed()) + + # 1st TAB is pressed, `skip link` is shown. + body = self.selenium.find_element(By.TAG_NAME, "body") + body.send_keys(Keys.TAB) + self.assertTrue(skip_link.is_displayed()) + + # The 2nd TAB will focus the page title. + body.send_keys(Keys.TAB) + django_administration_title = self.selenium.find_element( + By.LINK_TEXT, "Django administration" + ) + self.assertFalse(skip_link.is_displayed()) # `skip link` disappear. + self.assertEqual( + self.selenium.switch_to.active_element, django_administration_title + ) + + def test_skip_link_is_skipped_when_there_is_searchbar(self): + from selenium.webdriver.common.by import By + + self.admin_login( + username="super", + password="secret", + login_url=reverse("admin:index"), + ) + + group_a_tag = self.selenium.find_element(By.LINK_TEXT, "Groups") + with self.wait_page_loaded(): + group_a_tag.click() + + # `Skip link` is not present. + skip_link = self.selenium.find_element(By.CLASS_NAME, "skip-to-content-link") + self.assertFalse(skip_link.is_displayed()) + + # `Searchbar` has autofocus. + searchbar = self.selenium.find_element(By.ID, "searchbar") + self.assertEqual(self.selenium.switch_to.active_element, searchbar) + + def test_skip_link_with_RTL_language_doesnt_create_horizontal_scrolling(self): + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + + with override_settings(LANGUAGE_CODE="ar"): + self.admin_login( + username="super", + password="secret", + login_url=reverse("admin:index"), + ) + + skip_link = self.selenium.find_element( + By.CLASS_NAME, "skip-to-content-link" + ) + body = self.selenium.find_element(By.TAG_NAME, "body") + body.send_keys(Keys.TAB) + self.assertTrue(skip_link.is_displayed()) + + is_vertical_scrolleable = self.selenium.execute_script( + "return arguments[0].scrollHeight > arguments[0].offsetHeight;", body + ) + is_horizontal_scrolleable = self.selenium.execute_script( + "return arguments[0].scrollWeight > arguments[0].offsetWeight;", body + ) + self.assertTrue(is_vertical_scrolleable) + self.assertFalse(is_horizontal_scrolleable)