8792d912bc
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.
450 lines
14 KiB
JavaScript
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 }
|