diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 23e5acbc72..ababb541a1 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -12,6 +12,7 @@ Changelog
* Added `WAGTAIL_WORKFLOW_ENABLED` setting for enabling / disabling moderation workflows globally (Matt Westcott)
* Allow specifying `max_width` and `max_height` on EmbedBlock (Petr Dlouhý)
* Add warning when StreamField is used without a StreamFieldPanel (Naomi Morduch Toubman)
+ * Added keyboard and screen reader support to Wagtail user bar (LB Johnston, Storm Heg)
* Fix: Invalid filter values for foreign key fields in the API now give an error instead of crashing (Tidjani Dia)
* Fix: Ordering specified in `construct_explorer_page_queryset` hook is now taken into account again by the page explorer API (Andre Fonseca)
* Fix: Deleting a page from its listing view no longer results in a 404 error (Tidjani Dia)
diff --git a/client/src/entrypoints/admin/userbar.js b/client/src/entrypoints/admin/userbar.js
index 6f60c134fa..24245c8242 100644
--- a/client/src/entrypoints/admin/userbar.js
+++ b/client/src/entrypoints/admin/userbar.js
@@ -2,57 +2,206 @@
// Please stick to old JS APIs and avoid importing anything that might require a vendored module
// More background can be found in webpack.config.js
-document.addEventListener('DOMContentLoaded', (e) => {
+document.addEventListener('DOMContentLoaded', () => {
const userbar = document.querySelector('[data-wagtail-userbar]');
const trigger = userbar.querySelector('[data-wagtail-userbar-trigger]');
- const list = userbar.querySelector('.wagtail-userbar-items');
- const className = 'is-active';
- const hasTouch = 'ontouchstart' in window;
- const clickEvent = 'click';
+ const list = userbar.querySelector('[role=menu]');
+ const listItems = list.querySelectorAll('li');
+ const isActiveClass = 'is-active';
- if (hasTouch) {
- userbar.classList.add('touch');
-
- // Bind to touchend event, preventDefault to prevent DELAY and CLICK
- // in accordance with: https://hacks.mozilla.org/2013/04/detecting-touch-its-the-why-not-the-how/
- trigger.addEventListener('touchend', (e2) => {
- e.preventDefault();
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
- toggleUserbar(e2);
- });
- } else {
- userbar.classList.add('no-touch');
- }
+ // querySelector for all items that can be focused.
+ // source: https://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus
+ const focusableItemSelector = `a[href]:not([tabindex='-1']),
+ button:not([disabled]):not([tabindex='-1']),
+ input:not([disabled]):not([tabindex='-1']),
+ [tabindex]:not([tabindex='-1'])`;
// eslint-disable-next-line @typescript-eslint/no-use-before-define
- trigger.addEventListener(clickEvent, toggleUserbar, false);
+ trigger.addEventListener('click', toggleUserbar, false);
// make sure userbar is hidden when navigating back
// eslint-disable-next-line @typescript-eslint/no-use-before-define
window.addEventListener('pageshow', hideUserbar, false);
- function showUserbar() {
- userbar.classList.add(className);
+ // Handle keyboard events on the trigger
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ userbar.addEventListener('keydown', handleTriggerKeyDown);
+
+
+ function showUserbar(shouldFocus) {
+ userbar.classList.add(isActiveClass);
+ trigger.setAttribute('aria-expanded', 'true');
// eslint-disable-next-line @typescript-eslint/no-use-before-define
- list.addEventListener(clickEvent, sandboxClick, false);
+ list.addEventListener('click', sandboxClick, false);
// eslint-disable-next-line @typescript-eslint/no-use-before-define
- window.addEventListener(clickEvent, clickOutside, false);
+ window.addEventListener('click', clickOutside, false);
+
+ // Start handling keyboard input now that the userbar is open.
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ userbar.addEventListener('keydown', handleUserbarItemsKeyDown, false);
+
+ // The userbar has role=menu which means that the first link should be focused on popup
+ // For weird reasons shifting focus only works after some amount of delay
+ // Which is why we are forced to use setTimeout
+ if (shouldFocus) {
+ // Find the first focusable element (if any) and focus it
+ if (list.querySelector(focusableItemSelector)) {
+ setTimeout(() => {
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ setFocusToFirstItem();
+ }, 300); // Less than 300ms doesn't seem to work
+ }
+ }
}
function hideUserbar() {
- userbar.classList.remove(className);
+ userbar.classList.remove(isActiveClass);
+ trigger.setAttribute('aria-expanded', 'false');
// eslint-disable-next-line @typescript-eslint/no-use-before-define
- list.addEventListener(clickEvent, sandboxClick, false);
+ list.addEventListener('click', sandboxClick, false);
// eslint-disable-next-line @typescript-eslint/no-use-before-define
- window.removeEventListener(clickEvent, clickOutside, false);
+ window.removeEventListener('click', clickOutside, false);
+
+ // Cease handling keyboard input now that the userbar is closed.
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ userbar.removeEventListener('keydown', handleUserbarItemsKeyDown, false);
}
function toggleUserbar(e2) {
e2.stopPropagation();
- if (userbar.classList.contains(className)) {
+ if (userbar.classList.contains(isActiveClass)) {
hideUserbar();
} else {
- showUserbar();
+ showUserbar(true);
+ }
+ }
+
+ function setFocusToTrigger() {
+ setTimeout(() => trigger.focus(), 300);
+ }
+
+ function isFocusOnItems() {
+ return document.activeElement && !!document.activeElement.closest('.wagtail-userbar-items');
+ }
+
+ function setFocusToFirstItem() {
+ if (listItems.length > 0) {
+ setTimeout(() => {
+ listItems[0].firstElementChild.focus();
+ }, 100); // Workaround for focus bug
+ }
+ }
+
+ function setFocusToLastItem() {
+ if (listItems.length > 0) {
+ setTimeout(() => {
+ listItems[listItems.length - 1].firstElementChild.focus();
+ }, 100); // Workaround for focus bug
+ }
+ }
+
+ function setFocusToNextItem() {
+ listItems.forEach((element, idx) => {
+ // Check which item is currently focused
+ if (element.firstElementChild === document.activeElement) {
+ setTimeout(() => {
+ if (idx + 1 < listItems.length) {
+ // Focus the next item
+ listItems[idx + 1].firstElementChild.focus();
+ } else {
+ setFocusToFirstItem();
+ }
+ }, 100); // Workaround for focus bug
+ }
+ });
+ }
+
+ function setFocusToPreviousItem() {
+ // Check which item is currently focused
+ listItems.forEach((element, idx) => {
+ if (element.firstElementChild === document.activeElement) {
+ setTimeout(() => {
+ if (idx > 0) {
+ // Focus the previous item
+ listItems[idx - 1].firstElementChild.focus();
+ } else {
+ setFocusToLastItem();
+ }
+ }, 100); // Workaround for focus bug
+ }
+ });
+ }
+
+ /**
+ This handler is responsible for keyboard input when items inside the userbar are focused.
+ It should only listen when the userbar is open.
+
+ It is responsible for:
+ - Shifting focus using the arrow / home / end keys.
+ - Closing the menu when 'Escape' is pressed.
+ */
+ function handleUserbarItemsKeyDown(event) {
+ // Only handle keyboard input if the userbar is open
+ if (trigger.getAttribute('aria-expanded') === 'true') {
+ if (event.key === 'Escape') {
+ hideUserbar();
+ setFocusToTrigger();
+ return;
+ }
+
+ if (isFocusOnItems()) {
+ switch (event.key) {
+ case 'ArrowDown':
+ event.preventDefault();
+ setFocusToNextItem();
+ break;
+ case 'ArrowUp':
+ event.preventDefault();
+ setFocusToPreviousItem();
+ break;
+ case 'Home':
+ event.preventDefault();
+ setFocusToFirstItem();
+ break;
+ case 'End':
+ event.preventDefault();
+ setFocusToLastItem();
+ break;
+ default:
+ break;
+ }
+ }
+ return;
+ }
+ }
+
+ /**
+ This handler is responsible for opening the userbar with the arrow keys
+ if it's focused and not open yet. It should always be listening.
+ */
+ function handleTriggerKeyDown(event) {
+ // Check if the userbar is focused (but not open yet) and should be opened by keyboard input
+ if (trigger === document.activeElement && trigger.getAttribute('aria-expanded') === 'false') {
+ switch (event.key) {
+ case 'ArrowUp':
+ event.preventDefault();
+ showUserbar(false);
+
+ // Workaround for focus bug
+ // Needs extra delay to account for the userbar open animation. Otherwise won't focus properly.
+ setTimeout(() => setFocusToLastItem(), 300);
+ break;
+ case 'ArrowDown':
+ event.preventDefault();
+ showUserbar(false);
+
+ // Workaround for focus bug
+ // Needs extra delay to account for the userbar open animation. Otherwise won't focus properly.
+ setTimeout(() => setFocusToFirstItem(), 300);
+ break;
+ default:
+ break;
+ }
}
}
diff --git a/docs/reference/hooks.rst b/docs/reference/hooks.rst
index c9e3b52062..f1ace51913 100644
--- a/docs/reference/hooks.rst
+++ b/docs/reference/hooks.rst
@@ -817,7 +817,7 @@ Hooks for customising the way users are directed through the process of creating
class UserbarPuppyLinkItem:
def render(self, request):
return '
'
+ + 'target="_parent" role="menuitem" class="action icon icon-wagtail">Puppies!'
@hooks.register('construct_wagtail_userbar')
def add_puppy_link_item(request, items):
diff --git a/docs/releases/2.14.rst b/docs/releases/2.14.rst
index f6101d0634..dd297a108f 100644
--- a/docs/releases/2.14.rst
+++ b/docs/releases/2.14.rst
@@ -20,6 +20,7 @@ Other features
* Added ``WAGTAIL_WORKFLOW_ENABLED`` setting for enabling / disabling moderation workflows globally (Matt Westcott)
* Allow specifying ``max_width`` and ``max_height`` on EmbedBlock (Petr Dlouhý)
* Add warning when StreamField is used without a StreamFieldPanel (Naomi Morduch Toubman)
+ * Added keyboard and screen reader support to Wagtail user bar (LB Johnston, Storm Heg)
Bug fixes
~~~~~~~~~
@@ -44,3 +45,10 @@ Removed support for Django 2.2
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Django 2.2 is no longer supported as of this release; please upgrade to Django 3.0 or above before upgrading Wagtail.
+
+User bar with keyboard and screen reader support
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The Wagtail user bar (“edit bird”) widget now supports keyboard and screen reader navigation. To make the most of this, we now recommend placing the widget near the top of the page ````, so users can reach it without having to go through the whole page. See :ref:`wagtailuserbar_tag` for more information.
+
+For implementers of custom user bar menu items, we also now require the addition of ``role="menuitem"`` on the ``a`` element to provide the correct semantics. See :ref:`construct_wagtail_userbar` for more information.
diff --git a/docs/topics/writing_templates.rst b/docs/topics/writing_templates.rst
index 489649fdd5..ea0d59e756 100644
--- a/docs/topics/writing_templates.rst
+++ b/docs/topics/writing_templates.rst
@@ -246,11 +246,22 @@ This tag provides a contextual flyout menu for logged-in users. The menu gives e
This tag may be used on regular Django views, without page object. The user bar will contain one item pointing to the admin.
+We recommend putting the tag near the top of the ```` element so keyboard users can reach it. You should consider putting the tag after any `skip links `_ but before the navigation and main content of your page.
+
.. code-block:: html+django
{% load wagtailuserbar %}
...
- {% wagtailuserbar %}
+
+ Skip to content
+ {% wagtailuserbar %} {# This is a good place for the userbar #}
+
+
+ ...
+
+
By default the User Bar appears in the bottom right of the browser window, inset from the edge. If this conflicts with your design it can be moved by passing a parameter to the template tag. These examples show you how to position the userbar in each corner of the screen:
diff --git a/wagtail/admin/static_src/wagtailadmin/scss/userbar.scss b/wagtail/admin/static_src/wagtailadmin/scss/userbar.scss
index 94f58e1655..9fe10f2330 100644
--- a/wagtail/admin/static_src/wagtailadmin/scss/userbar.scss
+++ b/wagtail/admin/static_src/wagtailadmin/scss/userbar.scss
@@ -116,6 +116,7 @@ $positions: (
// stylelint-disable declaration-no-important
.#{$namespace}-userbar-trigger {
+ all: initial;
display: flex;
align-items: center;
justify-content: center;
@@ -124,6 +125,7 @@ $positions: (
margin: 0 !important;
overflow: hidden;
background-color: $color-white;
+ border: 2px solid transparent;
border-radius: 50%;
color: $color-black;
padding: 0 !important;
@@ -134,15 +136,15 @@ $positions: (
text-decoration: none !important;
position: relative;
- .#{$namespace}-userbar.touch.is-active &,
- .#{$namespace}-userbar.no-touch &:hover {
- box-shadow: $box-shadow-props, 0 3px 15px 0 rgba(107, 214, 230, 0.95);
- }
-
.#{$namespace}-userbar-help-text {
+ // Visually hide the help text
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ height: 1px;
+ overflow: hidden;
position: absolute;
- top: 100%;
- left: 0;
+ white-space: nowrap;
+ width: 1px;
}
.#{$namespace}-icon:before {
@@ -151,9 +153,14 @@ $positions: (
width: auto;
margin: 0;
}
+
+ &:focus {
+ outline: $color-focus-outline solid 3px;
+ }
}
.#{$namespace}-userbar-items {
+ all: revert;
display: block;
list-style: none;
position: absolute;
@@ -182,6 +189,11 @@ $positions: (
transition-duration: 0.15s;
transition-timing-function: cubic-bezier(0.55, 0, 0.1, 1);
+ // stylelint-disable-next-line scss/media-feature-value-dollar-variable
+ @media (prefers-reduced-motion: reduce) {
+ transition: none !important;
+ }
+
.#{$namespace}-userbar.is-active & {
opacity: 1;
transform: translateY(0);
@@ -205,6 +217,7 @@ $positions: (
.#{$namespace}-userbar__item {
+ all: revert;
margin: 0;
background-color: $color-grey-1;
opacity: 0;
@@ -215,6 +228,14 @@ $positions: (
font-size: 16px !important;
text-decoration: none !important;
+ // stylelint-disable-next-line scss/media-feature-value-dollar-variable
+ @media (prefers-reduced-motion: reduce) {
+ transition: none !important;
+
+ // Force disable transitions for all items
+ transition-delay: 0s !important;
+ }
+
&:first-child {
border-top-left-radius: $userbar-radius;
border-top-right-radius: $userbar-radius;
@@ -242,11 +263,14 @@ $positions: (
&:hover,
&:focus {
- outline: none;
color: $color-white;
background-color: rgba(100, 100, 100, 0.15);
}
+ &:focus {
+ outline: $color-focus-outline solid 3px;
+ }
+
&-icon {
@include svg-icon(1.1em, middle);
margin-right: 0.5em;
diff --git a/wagtail/admin/templates/wagtailadmin/userbar/base.html b/wagtail/admin/templates/wagtailadmin/userbar/base.html
index 38d57b4017..49cd34359c 100644
--- a/wagtail/admin/templates/wagtailadmin/userbar/base.html
+++ b/wagtail/admin/templates/wagtailadmin/userbar/base.html
@@ -4,7 +4,7 @@