kopia lustrzana https://github.com/wagtail/wagtail
Convert userbar implementation to TypeScript
rodzic
71cbf9087a
commit
4bea98d981
|
@ -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
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import { Userbar } from '../../includes/userbar';
|
||||
|
||||
customElements.define('wagtail-userbar', Userbar);
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue