Add keyboard and screen reader support to Wagtail user bar (#6994). Fix #6108

Co-authored-by: Thibaud Colas <thibaudcolas@gmail.com>
pull/7291/head
Storm Heg 2021-06-26 21:53:44 +02:00 zatwierdzone przez GitHub
rodzic 297fb10597
commit 3ee060ffd7
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
15 zmienionych plików z 266 dodań i 83 usunięć

Wyświetl plik

@ -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)

Wyświetl plik

@ -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;
}
}
}

Wyświetl plik

@ -817,7 +817,7 @@ Hooks for customising the way users are directed through the process of creating
class UserbarPuppyLinkItem:
def render(self, request):
return '<li><a href="http://cuteoverload.com/tag/puppehs/" ' \
+ 'target="_parent" class="action icon icon-wagtail">Puppies!</a></li>'
+ 'target="_parent" role="menuitem" class="action icon icon-wagtail">Puppies!</a></li>'
@hooks.register('construct_wagtail_userbar')
def add_puppy_link_item(request, items):

Wyświetl plik

@ -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 ``<body>``, 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.

Wyświetl plik

@ -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 ``<body>`` element so keyboard users can reach it. You should consider putting the tag after any `skip links <https://webaim.org/techniques/skipnav/>`_ but before the navigation and main content of your page.
.. code-block:: html+django
{% load wagtailuserbar %}
...
{% wagtailuserbar %}
<body>
<a id="#content">Skip to content</a>
{% wagtailuserbar %} {# This is a good place for the userbar #}
<nav>
...
</nav>
<main id="content">
...
</main>
</body>
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:

Wyświetl plik

@ -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;

Wyświetl plik

@ -4,7 +4,7 @@
<div class="wagtail-userbar wagtail-userbar--{{ position|default:'bottom-right' }}" data-wagtail-userbar>
<link rel="stylesheet" href="{% versioned_static 'wagtailadmin/css/userbar.css' %}" type="text/css" />
<div class="wagtail-userbar-nav">
<div class="wagtail-userbar-trigger" data-wagtail-userbar-trigger>
<button aria-controls="wagtail-userbar-items" aria-haspopup="true" class="wagtail-userbar-trigger" id="wagtail-userbar-trigger" data-wagtail-userbar-trigger>
{% block branding_logo %}
<div style="display: none">
<svg>
@ -22,13 +22,13 @@
<use href="#icon-wagtail-icon"></use>
</svg>
{% endblock %}
<span class="wagtail-userbar-help-text">{% trans 'Go to Wagtail admin interface' %}</span>
</div>
<div class='wagtail-userbar-items'>
<span class="wagtail-userbar-help-text">{% trans 'View Wagtail quick actions' %}</span>
</button>
<ul aria-labelledby="wagtail-userbar-trigger" class="wagtail-userbar-items" id="wagtail-userbar-items" role="menu">
{% for item in items %}
{{ item|safe }}
{% endfor %}
</div>
</ul>
</div>
<script src="{% versioned_static 'wagtailadmin/js/userbar.js' %}"></script>
</div>

Wyświetl plik

@ -2,10 +2,8 @@
{% load i18n wagtailadmin_tags %}
{% block item_content %}
<div class="wagtail-action">
<a href="{% url 'wagtailadmin_home' %}" target="_parent">
{% icon name="wagtail-icon" class_name="wagtail-action-icon" %}
{% trans 'Go to Wagtail admin' %}
</a>
</div>
<a href="{% url 'wagtailadmin_home' %}" target="_parent" class="wagtail-userbar-link" role="menuitem">
{% icon name="wagtail-icon" class_name="wagtail-action-icon" %}
{% trans 'Go to Wagtail admin' %}
</a>
{% endblock %}

Wyświetl plik

@ -1 +1,3 @@
<div class="wagtail-userbar__item {% block item_classes %}{% endblock %}">{% block item_content %}{% endblock %}</div>
<li class="wagtail-userbar__item {% block item_classes %}{% endblock %}" role="presentation">
{% block item_content %}{% endblock %}
</li>

Wyświetl plik

@ -2,10 +2,8 @@
{% load i18n wagtailadmin_tags %}
{% block item_content %}
<div class="wagtail-action">
<a href="{% url 'wagtailadmin_pages:add_subpage' self.page.id %}" target="_parent">
{% icon name="plus" class_name="wagtail-action-icon" %}
{% trans 'Add a child page' %}
</a>
</div>
<a href="{% url 'wagtailadmin_pages:add_subpage' self.page.id %}" target="_parent" role="menuitem">
{% icon name="plus" class_name="wagtail-action-icon" %}
{% trans 'Add a child page' %}
</a>
{% endblock %}

Wyświetl plik

@ -4,11 +4,9 @@
{% block item_content %}
<form action="{% url 'wagtailadmin_pages:approve_moderation' self.revision.id %}" target="_parent" method="post">
{% csrf_token %}
<div class="wagtail-action">
<button type="submit" value="{% trans 'Approve' %}" class="button">
{% icon name="tick" class_name="wagtail-action-icon" %}
{% trans 'Approve' %}
</button>
</div>
<button type="submit" value="{% trans 'Approve' %}" class="button" role="menuitem">
{% icon name="tick" class_name="wagtail-action-icon" %}
{% trans 'Approve' %}
</button>
</form>
{% endblock %}

Wyświetl plik

@ -2,10 +2,8 @@
{% load i18n wagtailadmin_tags %}
{% block item_content %}
<div class="wagtail-action">
<a href="{% url 'wagtailadmin_pages:edit' self.page.id %}" target="_parent">
{% icon name="edit" class_name="wagtail-action-icon" %}
{% trans 'Edit this page' %}
</a>
</div>
{% endblock %}
<a href="{% url 'wagtailadmin_pages:edit' self.page.id %}" target="_parent" role="menuitem">
{% icon name="edit" class_name="wagtail-action-icon" %}
{% trans 'Edit this page' %}
</a>
{% endblock %}

Wyświetl plik

@ -2,10 +2,8 @@
{% load i18n wagtailadmin_tags %}
{% block item_content %}
<div class="wagtail-action">
<a href="{% url 'wagtailadmin_explore' self.parent_page.id %}" target="_parent">
{% icon name="folder-open-inverse" class_name="wagtail-action-icon" %}
{% trans 'Show in Explorer' %}
</a>
</div>
{% endblock %}
<a href="{% url 'wagtailadmin_explore' self.parent_page.id %}" target="_parent" role="menuitem">
{% icon name="folder-open-inverse" class_name="wagtail-action-icon" %}
{% trans 'Show in Explorer' %}
</a>
{% endblock %}

Wyświetl plik

@ -4,11 +4,9 @@
{% block item_content %}
<form action="{% url 'wagtailadmin_pages:reject_moderation' self.revision.id %}" target="_parent" method="post">
{% csrf_token %}
<div class="wagtail-action">
<button type="submit" value="{% trans 'Reject' %}" class="button">
{% icon name="cross" class_name="wagtail-action-icon" %}
{% trans 'Reject' %}
</button>
</div>
<button type="submit" value="{% trans 'Reject' %}" class="button" role="menuitem">
{% icon name="cross" class_name="wagtail-action-icon" %}
{% trans 'Reject' %}
</button>
</form>
{% endblock %}

Wyświetl plik

@ -111,7 +111,7 @@ class TestUserbarAddLink(TestCase, WagtailTestUtils):
# page allows subpages, so the 'add page' button should show
expected_url = reverse('wagtailadmin_pages:add_subpage', args=(self.event_index.id, ))
needle = f"""
<a href="{expected_url}" target="_parent">
<a href="{expected_url}" target="_parent" role="menuitem">
<svg class="icon icon-plus wagtail-action-icon" aria-hidden="true" focusable="false">
<use href="#icon-plus"></use>
</svg>