Repository: wagtail/wagtail

Issue:
Implement focus management in main menu
### Issue Summary

Tabbing through Wagtail’s main menu, it should be possible to move between all visible menu items one by one, opening submenus as required. Instead, users have to tab through submenus that are closed:

![menu-focus-management](https://user-images.githubusercontent.com/877585/58544795-eaf0cc00-81f9-11e9-9817-0407124e04df.gif)

The correct behavior would be:

- When submenus aren't open, users shouldn't have to tab through their menu items.
- Submenus can be opened by clicking, pressing Enter while focused; and closed with the Esc key or clicking outside
- Opening a submenu, the focus now moves to the submenu and is trapped there until closed
- Submenu triggers should use `button` tags instead of `a`, so screen readers announce the correct behavior (button instead of link), and browsers do not display a link target of "#" (see #5349).

### Steps to Reproduce

1. Go to the Wagtail admin
2. Navigate with a keyboard through the menu items

Related: #5336 


Discussion:
Added a note that we should also change submenu triggers to be `button` elements instead of `a`, both so they announce correctly to screen reader users (these are not links), and so browsers do not show the link targets for no reason (#5349).
Note: would be better to get #5254 in before starting work on this.
Additional task from #5274:

> Clarify that the user is focused on the side navigation by improving the voice-over command to say ‘Navigation’, for example: ‘link, Settings list navigation, 9 items

As part of this issue, I think it makes sense to implement correct labels for each of the submenus, as well as ARIA markup so they are announced as menus (`aria-haspopup="dialog"`, etc).
I'm working on this during Wagtail Sprint US 2019.
@thibaudcolas I'm going to take the route of using `aria-expanded="false"` or `aria-expanded="true"` to show the submenus, since the standard ARIA menu markup is meant for application menus (and not so much for navigation lists on web pages).
@marteki is there an open PR for this issue ? 
I wanted to work on it, following your approach, but if part of the work has already been done here it could be helpful 😉 

Resolving patch:
diff --git a/wagtail/admin/templatetags/wagtailadmin_tags.py b/wagtail/admin/templatetags/wagtailadmin_tags.py
--- a/wagtail/admin/templatetags/wagtailadmin_tags.py
+++ b/wagtail/admin/templatetags/wagtailadmin_tags.py
@@ -8,7 +8,6 @@
 from django.contrib.admin.utils import quote
 from django.contrib.humanize.templatetags.humanize import intcomma
 from django.contrib.messages.constants import DEFAULT_TAGS as MESSAGE_TAGS
-from django.core.serializers.json import DjangoJSONEncoder
 from django.db.models import Min, QuerySet
 from django.forms import Media
 from django.shortcuts import resolve_url as resolve_url_func
@@ -19,7 +18,7 @@
 from django.urls.exceptions import NoReverseMatch
 from django.utils import timezone
 from django.utils.encoding import force_str
-from django.utils.html import avoid_wrapping, format_html, format_html_join
+from django.utils.html import avoid_wrapping, format_html, format_html_join, json_script
 from django.utils.http import urlencode
 from django.utils.safestring import mark_safe
 from django.utils.timesince import timesince
@@ -769,7 +768,7 @@ def locales():
 
 @register.simple_tag()
 def slim_sidebar_enabled():
-    return 'slim-sidebar' in getattr(settings, 'WAGTAIL_EXPERIMENTAL_FEATURES', [])
+    return getattr(settings, 'WAGTAIL_SLIM_SIDEBAR', True)
 
 
 @register.simple_tag(takes_context=True)
@@ -782,7 +781,7 @@ def sidebar_collapsed(context):
 
 
 @register.simple_tag(takes_context=True)
-def menu_props(context):
+def sidebar_props(context):
     request = context['request']
     search_areas = admin_search_areas.search_items_for_request(request)
     if search_areas:
@@ -802,9 +801,9 @@ def menu_props(context):
     ]
     modules = [module for module in modules if module is not None]
 
-    return json.dumps({
+    return json_script({
         'modules': JSContext().pack(modules),
-    }, cls=DjangoJSONEncoder)
+    }, element_id="wagtail-sidebar-props")
 
 
 @register.simple_tag


Test patch:
diff --git a/wagtail/admin/tests/test_admin_search.py b/wagtail/admin/tests/test_admin_search.py
--- a/wagtail/admin/tests/test_admin_search.py
+++ b/wagtail/admin/tests/test_admin_search.py
@@ -92,7 +92,7 @@ def test_dashboard(self):
         response = self.client.get('/admin/')
         # The menu search bar should go to /customsearch/, not /admin/pages/search/
         self.assertNotContains(response, reverse('wagtailadmin_pages:search'))
-        self.assertContains(response, 'action="/customsearch/"')
+        self.assertContains(response, '{"_type": "wagtail.sidebar.SearchModule", "_args": ["/customsearch/"]}')
 
     def test_menu_search(self):
         """
diff --git a/wagtail/admin/tests/test_menu.py b/wagtail/admin/tests/test_menu.py
--- a/wagtail/admin/tests/test_menu.py
+++ b/wagtail/admin/tests/test_menu.py
@@ -20,7 +20,6 @@ def setUp(self):
         self.request.user = self.create_superuser(username='admin')
         self.user = self.login()
 
-    @override_settings(WAGTAIL_EXPERIMENTAL_FEATURES={"slim-sidebar"})
     def test_remember_collapsed(self):
         '''Sidebar should render with collapsed class applied.'''
         # Sidebar should not be collapsed
@@ -33,9 +32,9 @@ def test_remember_collapsed(self):
         response = self.client.get(reverse('wagtailadmin_home'))
         self.assertContains(response, 'sidebar-collapsed')
 
-    @override_settings(WAGTAIL_EXPERIMENTAL_FEATURES={})
-    def test_collapsed_only_with_feature_flag(self):
-        '''Sidebar should only remember its collapsed state with the right feature flag set.'''
+    @override_settings(WAGTAIL_SLIM_SIDEBAR=False)
+    def test_not_collapsed_with_legacy(self):
+        '''Sidebar should only remember its collapsed state with the slim implementation.'''
         # Sidebar should not be collapsed because the feature flag is not enabled
         self.client.cookies['wagtail_sidebar_collapsed'] = '1'
         response = self.client.get(reverse('wagtailadmin_home'))
diff --git a/wagtail/admin/tests/test_workflows.py b/wagtail/admin/tests/test_workflows.py
--- a/wagtail/admin/tests/test_workflows.py
+++ b/wagtail/admin/tests/test_workflows.py
@@ -41,26 +41,26 @@ def setUp(self):
 
     def test_workflow_settings_and_reports_menus_are_shown_to_admin(self):
         response = self.client.get('/admin/')
-        self.assertContains(response, 'href="/admin/workflows/list/"')
-        self.assertContains(response, 'href="/admin/workflows/tasks/index/"')
-        self.assertContains(response, 'href="/admin/reports/workflow/"')
-        self.assertContains(response, 'href="/admin/reports/workflow_tasks/"')
+        self.assertContains(response, '"url": "/admin/workflows/list/"')
+        self.assertContains(response, '"url": "/admin/workflows/tasks/index/"')
+        self.assertContains(response, '"url": "/admin/reports/workflow/"')
+        self.assertContains(response, '"url": "/admin/reports/workflow_tasks/"')
 
     def test_workflow_settings_menus_are_not_shown_to_editor(self):
         self.login(user=self.editor)
         response = self.client.get('/admin/')
-        self.assertNotContains(response, 'href="/admin/workflows/list/"')
-        self.assertNotContains(response, 'href="/admin/workflows/tasks/index/"')
-        self.assertContains(response, 'href="/admin/reports/workflow/"')
-        self.assertContains(response, 'href="/admin/reports/workflow_tasks/"')
+        self.assertNotContains(response, '"url": "/admin/workflows/list/"')
+        self.assertNotContains(response, '"url": "/admin/workflows/tasks/index/"')
+        self.assertContains(response, '"url": "/admin/reports/workflow/"')
+        self.assertContains(response, '"url": "/admin/reports/workflow_tasks/"')
 
     @override_settings(WAGTAIL_WORKFLOW_ENABLED=False)
     def test_workflow_menus_are_hidden_when_workflows_are_disabled(self):
         response = self.client.get('/admin/')
-        self.assertNotContains(response, 'href="/admin/workflows/list/"')
-        self.assertNotContains(response, 'href="/admin/workflows/tasks/index/"')
-        self.assertNotContains(response, 'href="/admin/reports/workflow/"')
-        self.assertNotContains(response, 'href="/admin/reports/workflow_tasks/"')
+        self.assertNotContains(response, '"url": "/admin/workflows/list/"')
+        self.assertNotContains(response, '"url": "/admin/workflows/tasks/index/"')
+        self.assertNotContains(response, '"url": "/admin/reports/workflow/"')
+        self.assertNotContains(response, '"url": "/admin/reports/workflow_tasks/"')
 
 
 class TestWorkflowsIndexView(TestCase, WagtailTestUtils):
diff --git a/wagtail/admin/tests/tests.py b/wagtail/admin/tests/tests.py
--- a/wagtail/admin/tests/tests.py
+++ b/wagtail/admin/tests/tests.py
@@ -33,31 +33,23 @@ def test_simple(self):
     def test_admin_menu(self):
         response = self.client.get(reverse('wagtailadmin_home'))
         self.assertEqual(response.status_code, 200)
-        # check that media attached to menu items is correctly pulled in
+        # check that custom menu items (including classname / icon_name) are pulled in
         self.assertContains(
             response,
-            '<script src="/static/testapp/js/kittens.js"></script>',
-            html=True
-        )
-
-        # check that custom menu items (including classname / attrs parameters) are pulled in
-        self.assertContains(
-            response,
-            '<a href="http://www.tomroyal.com/teaandkittens/" class="icon icon-kitten" data-fluffy="yes">Kittens!</a>',
-            html=True
+            '{"name": "kittens", "label": "Kittens!", "icon_name": "kitten", "classnames": "kitten--test", "url": "http://www.tomroyal.com/teaandkittens/"}',
         )
 
         # Check that the explorer menu item is here, with the right start page.
         self.assertContains(
             response,
-            'data-explorer-start-page="1"'
+            '{"name": "explorer", "label": "Pages", "icon_name": "folder-open-inverse", "classnames": "", "url": "/admin/pages/"}, 1]'
         )
 
         # check that is_shown is respected on menu items
         response = self.client.get(reverse('wagtailadmin_home') + '?hide-kittens=true')
         self.assertNotContains(
             response,
-            '<a href="http://www.tomroyal.com/teaandkittens/" class="icon icon-kitten" data-fluffy="yes">Kittens!</a>'
+            '{"name": "kittens", "label": "Kittens!", "icon_name": "kitten", "classnames": "kitten--test", "url": "http://www.tomroyal.com/teaandkittens/"}'
         )
 
     def test_dashboard_panels(self):
diff --git a/wagtail/contrib/settings/tests/test_admin.py b/wagtail/contrib/settings/tests/test_admin.py
--- a/wagtail/contrib/settings/tests/test_admin.py
+++ b/wagtail/contrib/settings/tests/test_admin.py
@@ -71,8 +71,6 @@ def setUp(self):
     def test_get_edit(self):
         response = self.get()
         self.assertEqual(response.status_code, 200)
-        # there should be a menu item highlighted as active
-        self.assertContains(response, "menu-active")
 
     def test_edit_invalid(self):
         response = self.post(post_data={'foo': 'bar'})
@@ -116,8 +114,6 @@ def setUp(self):
     def test_get_edit(self):
         response = self.get()
         self.assertEqual(response.status_code, 200)
-        # there should be a menu item highlighted as active
-        self.assertContains(response, "menu-active")
 
     def test_non_existant_model(self):
         response = self.client.get(reverse('wagtailsettings:edit', args=['test', 'foo', 1]))
diff --git a/wagtail/contrib/settings/tests/test_register.py b/wagtail/contrib/settings/tests/test_register.py
--- a/wagtail/contrib/settings/tests/test_register.py
+++ b/wagtail/contrib/settings/tests/test_register.py
@@ -19,4 +19,4 @@ def test_register(self):
 
     def test_icon(self):
         admin = self.client.get(reverse('wagtailadmin_home'))
-        self.assertContains(admin, 'icon icon-tag')
+        self.assertContains(admin, 'icon-setting-tag')
diff --git a/wagtail/tests/testapp/models.py b/wagtail/tests/testapp/models.py
--- a/wagtail/tests/testapp/models.py
+++ b/wagtail/tests/testapp/models.py
@@ -1124,7 +1124,7 @@ class ImportantPages(BaseSetting):
         'wagtailcore.Page', related_name="+", null=True, on_delete=models.SET_NULL)
 
 
-@register_setting(icon="tag")
+@register_setting(icon="icon-setting-tag")
 class IconSetting(BaseSetting):
     pass
 
diff --git a/wagtail/tests/testapp/wagtail_hooks.py b/wagtail/tests/testapp/wagtail_hooks.py
--- a/wagtail/tests/testapp/wagtail_hooks.py
+++ b/wagtail/tests/testapp/wagtail_hooks.py
@@ -1,6 +1,4 @@
-from django import forms
 from django.http import HttpResponse
-from django.templatetags.static import static
 from django.utils.safestring import mark_safe
 
 import wagtail.admin.rich_text.editors.draftail.features as draftail_features
@@ -42,10 +40,6 @@ def block_googlebot(page, request, serve_args, serve_kwargs):
 
 
 class KittensMenuItem(MenuItem):
-    @property
-    def media(self):
-        return forms.Media(js=[static('testapp/js/kittens.js')])
-
     def is_shown(self, request):
         return not request.GET.get('hide-kittens', False)
 
@@ -55,8 +49,8 @@ def register_kittens_menu_item():
     return KittensMenuItem(
         'Kittens!',
         'http://www.tomroyal.com/teaandkittens/',
-        classnames='icon icon-kitten',
-        attrs={'data-fluffy': 'yes'},
+        classnames='kitten--test',
+        icon_name='kitten',
         order=10000
     )