pinafore/src/routes/_thirdparty/a11y-dialog/a11y-dialog.js
James Teh 8792d912bc
fix: focus the textarea in the Compose dialog (#2227)
ComposeBox already specified autoFocus for the ComposeInput.
However, this didn't work because the dialog was disabled when the ComposeInput code tried to focus the textarea.
To fix this, tweak A11yDialog to look for the autofocus attribute when determining what to focus.
This is consistent with the behavior for native HTML dialogs.
Then, have ComposeInput set this attribute if it's in a dialog.
Fixes #1839.
2022-11-23 08:21:42 -08:00

450 lines
14 KiB
JavaScript

// Forked from a11y-dialog 4.0.1, adding a small change to element.focus() to work
// around a Chrome bug with sticky positioning (https://github.com/nolanlawson/pinafore/issues/671)
// Now it also works around shadow DOM and video/audio with controls.
// (video/audio with controls is not 100% fixable because we can't focus the elements inside, but
// you can at least tab to the video/audio and use other controls, like space bar and left/right)
// Original: https://unpkg.com/a11y-dialog@4.0.1/a11y-dialog.js
export const FOCUSABLE_ELEMENTS_QUERY = 'a[href], area[href], input, select, textarea, ' +
'button, iframe, object, embed, [contenteditable], [tabindex], ' +
'video[controls], audio[controls], summary'
const TAB_KEY = 9
const ESCAPE_KEY = 27
const shadowRoots = []
let focusedBeforeDialog
/**
* Define the constructor to instantiate a dialog
*
* @constructor
* @param {Element} node
* @param {(NodeList | Element | string)} targets
*/
function A11yDialog (node, targets) {
// Prebind the functions that will be bound in addEventListener and
// removeEventListener to avoid losing references
this._show = this.show.bind(this)
this._hide = this.hide.bind(this)
this._maintainFocus = this._maintainFocus.bind(this)
this._bindKeypress = this._bindKeypress.bind(this)
// Keep a reference of the node on the instance
this.node = node
// Keep an object of listener types mapped to callback functions
this._listeners = {}
// Initialise everything needed for the dialog to work properly
this.create(targets)
}
/**
* Set up everything necessary for the dialog to be functioning
*
* @param {(NodeList | Element | string)} targets
* @return {this}
*/
A11yDialog.prototype.create = function (targets) {
// Keep a collection of nodes to disable/enable when toggling the dialog
this._targets = this._targets || collect(targets) || getSiblings(this.node)
// Make sure the dialog element is disabled on load, and that the `shown`
// property is synced with its value
this.node.setAttribute('aria-hidden', true)
this.shown = false
// Keep a collection of dialog openers, each of which will be bound a click
// event listener to open the dialog
this._openers = $$('[data-a11y-dialog-show="' + this.node.id + '"]')
this._openers.forEach(function (opener) {
opener.addEventListener('click', this._show)
}.bind(this))
// Keep a collection of dialog closers, each of which will be bound a click
// event listener to close the dialog
this._closers = $$('[data-a11y-dialog-hide]', this.node)
.concat($$('[data-a11y-dialog-hide="' + this.node.id + '"]'))
this._closers.forEach(function (closer) {
closer.addEventListener('click', this._hide)
}.bind(this))
// Execute all callbacks registered for the `create` event
this._fire('create')
return this
}
/**
* Show the dialog element, disable all the targets (siblings), trap the
* current focus within it, listen for some specific key presses and fire all
* registered callbacks for `show` event
*
* @param {Event} event
* @return {this}
*/
A11yDialog.prototype.show = function (event) {
// If the dialog is already open, abort
if (this.shown) {
return this
}
this.shown = true
this.node.removeAttribute('aria-hidden')
// Iterate over the targets to disable them by setting their `aria-hidden`
// attribute to `true`; in case they already have this attribute, keep a
// reference of their original value to be able to restore it later
this._targets.forEach(function (target) {
const original = target.getAttribute('aria-hidden')
if (original) {
target.setAttribute('data-a11y-dialog-original', original)
}
target.setAttribute('aria-hidden', 'true')
})
// Keep a reference to the currently focused element to be able to restore
// it later, then set the focus to the first focusable child of the dialog
// element
focusedBeforeDialog = document.activeElement
setInitialFocus(this.node)
// Bind a focus event listener to the body element to make sure the focus
// stays trapped inside the dialog while open, and start listening for some
// specific key presses (TAB and ESC)
document.body.addEventListener('focus', this._maintainFocus, true)
document.addEventListener('keydown', this._bindKeypress)
// Execute all callbacks registered for the `show` event
this._fire('show', event)
return this
}
/**
* Hide the dialog element, enable all the targets (siblings), restore the
* focus to the previously active element, stop listening for some specific
* key presses and fire all registered callbacks for `hide` event
*
* @param {Event} event
* @return {this}
*/
A11yDialog.prototype.hide = function (event) {
// If the dialog is already closed, abort
if (!this.shown) {
return this
}
this.shown = false
this.node.setAttribute('aria-hidden', 'true')
// Iterate over the targets to enable them by remove their `aria-hidden`
// attribute or resetting them to their initial value
this._targets.forEach(function (target) {
const original = target.getAttribute('data-a11y-dialog-original')
if (original) {
target.setAttribute('aria-hidden', original)
target.removeAttribute('data-a11y-dialog-original')
} else {
target.removeAttribute('aria-hidden')
}
})
// If their was a focused element before the dialog was opened, restore the
// focus back to it
if (focusedBeforeDialog) {
// This double rAF is to work around a bug in Chrome when focusing sticky-positioned
// elements. See https://github.com/nolanlawson/pinafore/issues/671
requestAnimationFrame(() => requestAnimationFrame(() => focusedBeforeDialog.focus()))
}
// Remove the focus event listener to the body element and stop listening
// for specific key presses
document.body.removeEventListener('focus', this._maintainFocus, true)
document.removeEventListener('keydown', this._bindKeypress)
// Execute all callbacks registered for the `hide` event
this._fire('hide', event)
return this
}
/**
* Destroy the current instance (after making sure the dialog has been hidden)
* and remove all associated listeners from dialog openers and closers
*
* @return {this}
*/
A11yDialog.prototype.destroy = function () {
// Hide the dialog to avoid destroying an open instance
this.hide()
// Remove the click event listener from all dialog openers
this._openers.forEach(function (opener) {
opener.removeEventListener('click', this._show)
}.bind(this))
// Remove the click event listener from all dialog closers
this._closers.forEach(function (closer) {
closer.removeEventListener('click', this._hide)
}.bind(this))
// Execute all callbacks registered for the `destroy` event
this._fire('destroy')
// Keep an object of listener types mapped to callback functions
this._listeners = {}
return this
}
/**
* Register a new callback for the given event type
*
* @param {string} type
* @param {Function} handler
*/
A11yDialog.prototype.on = function (type, handler) {
if (typeof this._listeners[type] === 'undefined') {
this._listeners[type] = []
}
this._listeners[type].push(handler)
return this
}
/**
* Unregister an existing callback for the given event type
*
* @param {string} type
* @param {Function} handler
*/
A11yDialog.prototype.off = function (type, handler) {
const index = this._listeners[type].indexOf(handler)
if (index > -1) {
this._listeners[type].splice(index, 1)
}
return this
}
/**
* Iterate over all registered handlers for given type and call them all with
* the dialog element as first argument, event as second argument (if any).
*
* @access private
* @param {string} type
* @param {Event} event
*/
A11yDialog.prototype._fire = function (type, event) {
const listeners = this._listeners[type] || []
listeners.forEach(function (listener) {
listener(this.node, event)
}.bind(this))
}
/**
* Private event handler used when listening to some specific key presses
* (namely ESCAPE and TAB)
*
* @access private
* @param {Event} event
*/
A11yDialog.prototype._bindKeypress = function (event) {
// If the dialog is shown and the ESCAPE key is being pressed, prevent any
// further effects from the ESCAPE key and hide the dialog
if (this.shown && event.which === ESCAPE_KEY) {
event.preventDefault()
this.hide()
}
// If the dialog is shown and the TAB key is being pressed, make sure the
// focus stays trapped within the dialog element
if (this.shown && event.which === TAB_KEY) {
trapTabKey(this.node, event)
}
}
/**
* Private event handler used when making sure the focus stays within the
* currently open dialog
*
* @access private
* @param {Event} event
*/
A11yDialog.prototype._maintainFocus = function (event) {
// If the dialog is shown and the focus is not within the dialog element,
// move it back to its first focusable child
if (this.shown && !this.node.contains(event.target)) {
setInitialFocus(this.node)
}
}
/**
* Convert a NodeList into an array
*
* @param {NodeList} collection
* @return {Array<Element>}
*/
function toArray (collection) {
return Array.prototype.slice.call(collection)
}
/**
* Query the DOM for nodes matching the given selector, scoped to context (or
* the whole document)
*
* @param {String} selector
* @param {Element} [context = document]
* @return {Array<Element>}
*/
function $$ (selector, context) {
return toArray((context || document).querySelectorAll(selector))
}
/**
* Return an array of Element based on given argument (NodeList, Element or
* string representing a selector)
*
* @param {(NodeList | Element | string)} target
* @return {Array<Element>}
*/
function collect (target) {
if (NodeList.prototype.isPrototypeOf(target)) { // eslint-disable-line no-prototype-builtins
return toArray(target)
}
if (Element.prototype.isPrototypeOf(target)) { // eslint-disable-line no-prototype-builtins
return [target]
}
if (typeof target === 'string') {
return $$(target)
}
}
/**
* Set the focus to the first focusable child of the given element
*
* @param {Element} node
*/
function setInitialFocus (node) {
const focusableChildren = getFocusableChildren(node)
// If there's an element with an autofocus attribute, focus that.
for (const child of focusableChildren) {
if (child.getAttribute('autofocus')) {
child.focus()
return
}
}
// Otherwise, focus the first focusable element.
if (focusableChildren.length) {
focusableChildren[0].focus()
}
}
function isAncestor (node, ancestor) {
let parent = node
while (parent) {
parent = parent.parentElement
if (parent === ancestor) {
return true
}
}
return false
}
/**
* Get the focusable children of the given element
*
* @param {Element} node
* @return {Array<Element>}
*/
function getFocusableChildren (node) {
const candidateFocusableChildren = $$(FOCUSABLE_ELEMENTS_QUERY, node)
for (const shadowRoot of shadowRoots) {
if (isAncestor(shadowRoot.getRootNode().host, node)) {
// TODO: technically we should figure out the host's position in the DOM
// and insert the children there, but this works for the emoji picker dialog well
// enough, and that's our only shadow root, so it's fine for now.
candidateFocusableChildren.push(...shadowRoot.querySelectorAll(FOCUSABLE_ELEMENTS_QUERY))
}
}
return candidateFocusableChildren.filter(child => {
return !child.disabled &&
!/^-/.test(child.getAttribute('tabindex') || '') &&
!child.hasAttribute('inert') && // see https://github.com/GoogleChrome/inert-polyfill
(child.offsetWidth || child.offsetHeight || child.getClientRects().length)
})
}
/**
* Trap the focus inside the given element
*
* @param {Element} node
* @param {Event} event
*/
function trapTabKey (node, event) {
const focusableChildren = getFocusableChildren(node)
let activeElement = document.activeElement
for (const shadowRoot of shadowRoots) {
if (shadowRoot.getRootNode().host === activeElement) {
activeElement = shadowRoot.activeElement
break
}
}
const focusedItemIndex = focusableChildren.indexOf(activeElement)
// If the SHIFT key is being pressed while tabbing (moving backwards) and
// the currently focused item is the first one, move the focus to the last
// focusable item from the dialog element
if (event.shiftKey && focusedItemIndex === 0) {
focusableChildren[focusableChildren.length - 1].focus()
event.preventDefault()
// If the SHIFT key is not being pressed (moving forwards) and the currently
// focused item is the last one, move the focus to the first focusable item
// from the dialog element
} else if (!event.shiftKey && focusedItemIndex === focusableChildren.length - 1) {
focusableChildren[0].focus()
event.preventDefault()
}
}
/**
* Retrieve siblings from given element
*
* @param {Element} node
* @return {Array<Element>}
*/
function getSiblings (node) {
const nodes = toArray(node.parentNode.childNodes)
const siblings = nodes.filter(function (node) {
return node.nodeType === 1
})
siblings.splice(siblings.indexOf(node), 1)
return siblings
}
function registerShadowRoot (shadowRoot) {
if (!shadowRoots.includes(shadowRoot)) {
shadowRoots.push(shadowRoot)
}
}
function unregisterShadowRoot (shadowRoot) {
const index = shadowRoots.indexOf(shadowRoot)
if (index !== -1) {
shadowRoots.splice(index, 1)
}
}
export { A11yDialog, registerShadowRoot, unregisterShadowRoot }