Convert userbar implementation to TypeScript

pull/10108/head
Thibaud Colas 2023-02-17 00:27:28 +00:00
rodzic 71cbf9087a
commit 4bea98d981
4 zmienionych plików z 138 dodań i 105 usunięć

Wyświetl plik

@ -38,6 +38,7 @@ Changelog
* Maintenance: Rename the Stimulus `AutoFieldController` to the less confusing `SubmitController` (Loveth Omokaro)
* Maintenance: Replace `script` tags with `template` tag for image/document bulk uploads (Rishabh Kumar Bahukhandi)
* Maintenance: Remove unneeded float styles on 404 page (Fabien Le Frapper)
* Maintenance: Convert userbar implementation to TypeScript (Albina Starykova)
4.2.1 (xx.xx.xxxx) - IN DEVELOPMENT

Wyświetl plik

@ -0,0 +1,3 @@
import { Userbar } from '../../includes/userbar';
customElements.define('wagtail-userbar', Userbar);

Wyświetl plik

@ -1,17 +1,24 @@
import axe from 'axe-core';
import axe, { AxeResults } from 'axe-core';
import { dialog } from './dialog';
// This entrypoint is not bundled with any polyfills to keep it as light as possible
// 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
/*
This entrypoint is not bundled with any polyfills to keep it as light as possible
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
// This component implements a roving tab index for keyboard navigation
// Learn more about roving tabIndex: https://w3c.github.io/aria-practices/#kbd_roving_tabindex
This component implements a roving tab index for keyboard navigation
Learn more about roving tabIndex: https://w3c.github.io/aria-practices/#kbd_roving_tabindex
*/
export class Userbar extends HTMLElement {
declare trigger: HTMLElement;
connectedCallback() {
const template = document.getElementById('wagtail-userbar-template');
const template = document.querySelector<HTMLTemplateElement>(
'#wagtail-userbar-template',
);
if (!template) return;
const shadowRoot = this.attachShadow({
mode: 'open',
});
@ -19,40 +26,36 @@ export class Userbar extends HTMLElement {
// Removes the template from html after it's being used
template.remove();
const userbar = shadowRoot.querySelector('[data-wagtail-userbar]');
const trigger = userbar.querySelector('[data-wagtail-userbar-trigger]');
this.trigger = trigger;
const list = userbar.querySelector('[role=menu]');
const userbar = shadowRoot.querySelector<HTMLDivElement>(
'[data-wagtail-userbar]',
);
const trigger = userbar?.querySelector<HTMLElement>(
'[data-wagtail-userbar-trigger]',
);
const list = userbar?.querySelector('[role=menu]');
if (!userbar || !trigger || !list) {
return;
}
const listItems = list.querySelectorAll('li');
const isActiveClass = 'is-active';
this.trigger = trigger;
// Avoid Web Component FOUC while stylesheets are loading.
userbar.style.display = 'none';
// querySelector for all items that can be focused
// tabIndex has been removed for roving tabindex compatibility
// source: https://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus
/*
querySelector for all items that can be focused
tabIndex has been removed for roving tabindex compatibility
source: https://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus
*/
const focusableItemSelector = `a[href],
button:not([disabled]),
input:not([disabled])`;
// eslint-disable-next-line @typescript-eslint/no-use-before-define
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);
// Handle keyboard events on the trigger
// eslint-disable-next-line @typescript-eslint/no-use-before-define
userbar.addEventListener('keydown', handleTriggerKeyDown);
// eslint-disable-next-line @typescript-eslint/no-use-before-define
list.addEventListener('focusout', handleFocusChange);
// eslint-disable-next-line @typescript-eslint/no-use-before-define
resetItemsTabIndex(); // On initialisation, all menu items should be disabled for roving tab index
function showUserbar(shouldFocus) {
const showUserbar = (shouldFocus) => {
userbar.classList.add(isActiveClass);
trigger.setAttribute('aria-expanded', 'true');
// eslint-disable-next-line @typescript-eslint/no-use-before-define
@ -64,9 +67,11 @@ export class Userbar extends HTMLElement {
// 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
/*
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)) {
@ -76,9 +81,9 @@ export class Userbar extends HTMLElement {
}, 300); // Less than 300ms doesn't seem to work
}
}
}
};
function hideUserbar() {
const hideUserbar = () => {
userbar.classList.remove(isActiveClass);
trigger.setAttribute('aria-expanded', 'false');
// eslint-disable-next-line @typescript-eslint/no-use-before-define
@ -89,60 +94,58 @@ export class Userbar extends HTMLElement {
// 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) {
const toggleUserbar = (e2) => {
e2.stopPropagation();
if (userbar.classList.contains(isActiveClass)) {
hideUserbar();
} else {
showUserbar(true);
}
}
};
function isFocusOnItems() {
return (
shadowRoot.activeElement &&
!!shadowRoot.activeElement.closest('.w-userbar-nav')
);
}
const isFocusOnItems = () =>
shadowRoot.activeElement &&
shadowRoot.activeElement.closest('.w-userbar-nav');
/** Reset all focusable menu items to `tabIndex = -1` */
function resetItemsTabIndex() {
// Reset all focusable menu items to `tabIndex = -1`
const resetItemsTabIndex = () => {
listItems.forEach((listItem) => {
// eslint-disable-next-line no-param-reassign
listItem.firstElementChild.tabIndex = -1;
(listItem.firstElementChild as HTMLElement).tabIndex = -1;
});
}
};
/** Focus element using a roving tab index */
function focusElement(el) {
// Focus element using a roving tab index
const focusElement = (el) => {
resetItemsTabIndex();
// eslint-disable-next-line no-param-reassign
el.tabIndex = 0;
setTimeout(() => {
el.focus();
}, 100); // Workaround, changing focus only works after a timeout
}
};
function setFocusToTrigger() {
const setFocusToTrigger = () => {
if (!trigger) return;
setTimeout(() => trigger.focus(), 300);
resetItemsTabIndex();
}
};
function setFocusToFirstItem() {
const setFocusToFirstItem = () => {
if (listItems.length > 0) {
focusElement(listItems[0].firstElementChild);
}
}
};
function setFocusToLastItem() {
const setFocusToLastItem = () => {
if (listItems.length > 0) {
focusElement(listItems[listItems.length - 1].firstElementChild);
}
}
};
function setFocusToNextItem() {
const setFocusToNextItem = () => {
listItems.forEach((element, idx) => {
// Check which item is currently focused
if (element.firstElementChild === shadowRoot.activeElement) {
@ -154,9 +157,9 @@ export class Userbar extends HTMLElement {
}
}
});
}
};
function setFocusToPreviousItem() {
const setFocusToPreviousItem = () => {
listItems.forEach((element, idx) => {
// Check which item is currently focused
if (element.firstElementChild === shadowRoot.activeElement) {
@ -167,17 +170,17 @@ export class Userbar extends HTMLElement {
}
}
});
}
};
/**
/*
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) {
*/
const handleUserbarItemsKeyDown = (event) => {
// Only handle keyboard input if the userbar is open
if (trigger.getAttribute('aria-expanded') === 'true') {
if (event.key === 'Escape') {
@ -211,9 +214,9 @@ export class Userbar extends HTMLElement {
}
}
return true;
}
};
function handleFocusChange(event) {
const handleFocusChange = (event) => {
// Is the focus is still in the menu? If so, don't to anything
if (
event.relatedTarget == null ||
@ -224,13 +227,13 @@ export class Userbar extends HTMLElement {
// List items not in focus - the menu should close
resetItemsTabIndex();
hideUserbar();
}
};
/**
/*
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) {
*/
const handleTriggerKeyDown = (event) => {
// Check if the userbar is focused (but not open yet) and should be opened by keyboard input
if (
trigger === document.activeElement &&
@ -257,33 +260,52 @@ export class Userbar extends HTMLElement {
break;
}
}
}
};
function sandboxClick(e2) {
const sandboxClick = (e2) => {
e2.stopPropagation();
}
};
function clickOutside() {
const clickOutside = () => {
hideUserbar();
}
};
trigger.addEventListener('click', toggleUserbar, false);
// Make sure userbar is hidden when navigating back
window.addEventListener('pageshow', hideUserbar, false);
// Handle keyboard events on the trigger
userbar.addEventListener('keydown', handleTriggerKeyDown);
list.addEventListener('focusout', handleFocusChange);
// On initialisation, all menu items should be disabled for roving tab index
resetItemsTabIndex();
document.addEventListener('DOMContentLoaded', async () => {
await this.initialiseAxe();
});
}
// Integrating Axe accessibility checker to improve ATAG compliance, adapted for content authors to identify and fix accessibility issues.
// Scans loaded page for errors with 3 initial rules ('empty-heading', 'p-as-heading', 'heading-order') and outputs the results in GUI.
// See documentation: https://github.com/dequelabs/axe-core/tree/develop/doc
/*
Integrating Axe accessibility checker to improve ATAG compliance, adapted for content authors to identify and fix accessibility issues.
Scans loaded page for errors with 3 initial rules ('empty-heading', 'p-as-heading', 'heading-order') and outputs the results in GUI.
See documentation: https://github.com/dequelabs/axe-core/tree/develop/doc
*/
getAxeConfiguration() {
const script = this.shadowRoot.getElementById(
'accessibility-axe-configuration',
const script = this.shadowRoot?.querySelector<HTMLScriptElement>(
'#accessibility-axe-configuration',
);
if (!script || !script.textContent) return null;
try {
return JSON.parse(script?.textContent);
return JSON.parse(script.textContent);
} catch (err) {
/* eslint-disable no-console */
// eslint-disable-next-line no-console
console.error('Error loading Axe config');
// eslint-disable-next-line no-console
console.error(err);
}
@ -293,22 +315,19 @@ export class Userbar extends HTMLElement {
// Initialise axe accessibility checker
async initialiseAxe() {
const accessibilityTrigger = this.shadowRoot.getElementById(
const accessibilityTrigger = this.shadowRoot?.getElementById(
'accessibility-trigger',
);
if (!accessibilityTrigger) {
return;
}
const config = this.getAxeConfiguration();
if (!config) {
return;
}
if (!this.shadowRoot || !accessibilityTrigger || !config) return;
// Initialise Axe based on the configurable context (whole page body by default) and options ('empty-heading', 'p-as-heading' and 'heading-order' rules by default)
const results = await axe.run(config.context, config.options);
const results = (await axe.run(
config.context,
config.options,
)) as unknown as AxeResults;
const a11yErrorsNumber = results.violations.reduce(
(sum, violation) => sum + violation.nodes.length,
@ -317,7 +336,7 @@ export class Userbar extends HTMLElement {
if (results.violations.length) {
const a11yErrorBadge = document.createElement('span');
a11yErrorBadge.textContent = a11yErrorsNumber;
a11yErrorBadge.textContent = String(a11yErrorsNumber);
a11yErrorBadge.classList.add('w-userbar-axe-count');
this.trigger.appendChild(a11yErrorBadge);
}
@ -325,29 +344,38 @@ export class Userbar extends HTMLElement {
const dialogTemplates = this.shadowRoot.querySelectorAll(
'[data-wagtail-dialog]',
);
const dialogs = dialog(dialogTemplates, this.shadowRoot);
const dialogs = dialog(
dialogTemplates,
this.shadowRoot as unknown as HTMLElement,
);
if (!dialogs.length) return;
if (!dialogs.length) {
return;
}
const modal = dialogs[0];
// Disable TS linter check for legacy code in 3rd party `A11yDialog` element
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const modalBody = modal.$el.querySelector('[data-dialog-body]');
const accessibilityResultsBox = this.shadowRoot.querySelector(
'#accessibility-results',
);
const a11yRowTemplate = this.shadowRoot.querySelector(
const a11yRowTemplate = this.shadowRoot.querySelector<HTMLTemplateElement>(
'#w-a11y-result-row-template',
);
const a11ySelectorTemplate = this.shadowRoot.querySelector(
'#w-a11y-result-selector-template',
);
const a11ySelectorTemplate =
this.shadowRoot.querySelector<HTMLTemplateElement>(
'#w-a11y-result-selector-template',
);
if (!accessibilityResultsBox || !a11yRowTemplate || !a11ySelectorTemplate) {
return;
}
const innerErrorBadges = this.shadowRoot.querySelectorAll(
'[data-a11y-result-count]',
);
innerErrorBadges.forEach((badge) => {
// eslint-disable-next-line no-param-reassign
badge.textContent = a11yErrorsNumber || '0';
badge.textContent = String(a11yErrorsNumber) || '0';
if (results.violations.length) {
badge.classList.add('has-errors');
} else {
@ -405,7 +433,9 @@ export class Userbar extends HTMLElement {
'',
);
currentA11ySelector.addEventListener('click', () => {
const inaccessibleElement = document.querySelector(selectorName);
const inaccessibleElement =
document.querySelector<HTMLElement>(selectorName);
if (!inaccessibleElement) return;
inaccessibleElement.style.scrollMargin = '6.25rem';
inaccessibleElement.scrollIntoView({
behavior: 'smooth',
@ -428,5 +458,3 @@ export class Userbar extends HTMLElement {
accessibilityTrigger.addEventListener('click', toggleAxeResults);
}
}
customElements.define('wagtail-userbar', Userbar);

Wyświetl plik

@ -57,6 +57,7 @@ depth: 1
* Rename the Stimulus `AutoFieldController` to the less confusing `SubmitController` (Loveth Omokaro)
* Replace `script` tags with `template` tag for image/document bulk uploads (Rishabh Kumar Bahukhandi)
* Remove unneeded float styles on 404 page (Fabien Le Frapper)
* Convert userbar implementation to TypeScript (Albina Starykova)
## Upgrade considerations