From 564437f767eaa54bb6af86d2ebd2363e49a50421 Mon Sep 17 00:00:00 2001
From: Marcelo Galigniana <marcelogaligniana@gmail.com>
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 581355d3f7..8cff31d891 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 b4bf2420ea..cd4b759256 100644
--- a/django/contrib/admin/templates/admin/base.html
+++ b/django/contrib/admin/templates/admin/base.html
@@ -24,7 +24,7 @@
 
 <body class="{% if is_popup %}popup {% endif %}{% block bodyclass %}{% endblock %}"
   data-admin-utc-offset="{% now "Z" %}">
-
+<a href="#content-start" class="skip-to-content-link">{% translate 'Skip to main content' %}</a>
 <!-- Container -->
 <div id="container">
 
@@ -81,7 +81,7 @@
           {% include "admin/nav_sidebar.html" %}
         {% endblock %}
       {% endif %}
-      <div class="content">
+      <div id="content-start" class="content" tabindex="-1">
         {% block messages %}
           {% if messages %}
             <ul class="messagelist">{% 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 0000000000..9f3287147e
--- /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)