'use strict';
/**
* Multiple action button component.
* @namespace multiactionButtons
* @memberof UCASDesignFramework
* @param {object} global - UCASDesignFramework object.
* @param {object} _u - UCASUtilities object.
*/
(function (global, _u) {
var multiactionButtonContainer
var keys = {
tab: 9,
enter: 13,
esc: 27,
space: 32,
left: 37,
up: 38,
right: 39,
down: 40
}
/**
* Initialise plugin.
* @function init
* @memberof! UCASDesignFramework.multiactionButtons
* @public
* @param {Node} [context=document] - DOM element we're limiting selection to
* @param {string} [selector='[data-multiaction-button]'] - CSS selector
*/
function init (context, selector) {
context = context || document
selector = selector || '[data-multiaction-button]'
multiactionButtonContainer = document.getElementById('multiactionButtonContainer')
// Set an attribute on .layout-scope for IE.
if (document.documentMode) {
var scopes = context.querySelectorAll('.layout-scope')
scopes.forEach(function (scope) {
scope.setAttribute('data-layout-scope-legacy', '')
})
}
// First, clean up as this may be being called in a SPA.
cleanUp()
// Add event listeners.
prepareMultiactionButtons(context, selector)
}
/**
* Destroy plugin.
* @function destroy
* @memberof! UCASDesignFramework.multiactionButtons
* @public
*/
function destroy () {
cleanUp()
document.removeEventListener('click', hideAll, false)
}
/**
* Clean up any nodes which have been moved or made stray.
* @function cleanUp
* @memberof! UCASDesignFramework.multiactionButtons
* @public
*/
function cleanUp () {
var items = document.querySelectorAll('#multiactionButtonContainer [data-multiaction-button]')
items.forEach(function (item) {
var trigger = document.querySelector('button[aria-controls="' + item.id + '"]')
if (trigger) {
// Move back to original place.
trigger.parentNode.insertBefore(item, trigger.nextSibling)
} else {
// Delete as the trigger has disappeared.
item.parentNode.removeChild(item)
}
})
}
/**
* Prepare multiaction buttons.
* @param {object} context - DOM element we're limiting selection to
* @param {string} selector - CSS selector
*/
function prepareMultiactionButtons (context, selector) {
var items = context.querySelectorAll(selector)
_u.forEach(items, function (i, el) {
var id = el.getAttribute('id')
if (id) {
// Set initial state.
el.setAttribute('aria-expanded', 'false')
el.setAttribute('role', 'menu')
// Add a trigger button if we don't have one already.
if (el.parentNode.querySelector('[aria-controls="' + id + '"]') === null) {
var button = document.createElement('button')
var attributes = _u.json.getJson(el.getAttribute('data-multiaction-button'))
_u.log.log('data-multiaction-button', attributes)
var classes = 'button button--multiaction'
classes += attributes.small ? ' button--small' : ''
classes += attributes.hideLabel ? ' button--multiaction-hide-label' : ''
classes += attributes.orientation ? ' button--multiaction-' + attributes.orientation : ''
classes += attributes.type ? ' button--' + attributes.type : ' button--secondary'
button.setAttribute('class', classes)
button.setAttribute('aria-controls', id)
button.appendChild(document.createTextNode(el.getAttribute('data-multiaction-button-label') || 'More'))
button.addEventListener('click', multiactionButtonHandler, false)
button.addEventListener('keydown', multiactionButtonHandler, false)
button.addEventListener('keyup', multiactionButtonHandler, false)
// Move the multiactions element into a wrapper to control layout.
var wrapper = document.createElement('div')
var wrapperClasses = 'button--multiaction-wrapper'
wrapperClasses += attributes.position === 'left' ? ' button--multiaction-wrapper-left' : ''
wrapperClasses += attributes.position === 'right' ? ' button--multiaction-wrapper-right' : ''
wrapperClasses += attributes.compact === true ? ' button--multiaction-wrapper-compact' : ''
wrapper.setAttribute('class', wrapperClasses)
// Update the attributes for the individual action buttons.
el.querySelectorAll('button').forEach(function (button) {
button.setAttribute('tabindex', '-1')
button.setAttribute('role', 'menuitem')
})
// Handle other keyboard interaction.
el.addEventListener('keydown', multiactionButtonsKeydownHandler, false)
el.addEventListener('keyup', multiactionButtonsKeydownHandler, false)
el.parentNode.insertBefore(wrapper, el)
wrapper.appendChild(button)
wrapper.appendChild(el)
// Check for disabled state.
if (el.getAttribute('data-multiaction-button-disabled')) {
disable(el)
}
}
}
})
}
/**
* Event handler.
* @param {Event} event - event object
*/
function multiactionButtonHandler (event) {
// https://www.w3.org/WAI/tutorials/menus/application-menus/#top-level-menu-items
// The following are required:
// return/enter ↵ Open the submenu, select first submenu item.
// space
// down ↓
// up ↑ Open the submenu, select last submenu item.
// NOTE There is a bug in Firefox that stops space behaving properly: https://bugzilla.mozilla.org/show_bug.cgi?id=1487102
// Handling use of space on keyup and all other keys on keydown
// as I had problems with space on keydown.
if (event.type === 'click' ||
(event.type === 'keyup' && event.keyCode === keys.space) ||
(event.type === 'keydown' && [keys.enter, keys.down, keys.up].indexOf(event.keyCode) !== -1)) {
event.stopPropagation()
event.preventDefault()
var id = event.target.getAttribute('aria-controls')
toggle(id, event.keyCode)
}
}
/**
* Event handler.
* @param {Event} event - event object
*/
function multiactionButtonsKeydownHandler (event) {
// https://www.w3.org/WAI/tutorials/menus/application-menus/#top-level-menu-items
// tab ⇥ Close the submenu, select the next focusable element outside of the menu.
// shift ⇧ + tab ⇥ Close the submenu, select the previous focusable element outside of the menu.
// return/enter ↵ Carry out function of this item. (In this example: bring up a dialog box with the text of the chosen menu item.)
// space
// down ↓ Select next submenu item.
// up ↑ Select previous submenu item.
// esc Close the submenu, select the current top-level menu item.
var triggerButton = document.querySelector('[aria-controls="' + this.id + '"]')
var buttons = Array.prototype.slice.call(this.querySelectorAll(':not([disabled])'))
var index = buttons.indexOf(event.target)
var expectedKey = false
var newIndex
// Handling use of space on keyup and all other keys on keydown
// as I had problems with space on keydown.
if ((event.type === 'keyup' && event.keyCode === keys.space) ||
(event.type === 'keydown' && [keys.enter, keys.down, keys.up, keys.tab, keys.esc].indexOf(event.keyCode) !== -1)) {
switch (event.keyCode) {
case keys.tab:
hide(this)
if (event.shiftKey) {
_u.focus.prev(triggerButton)
} else {
_u.focus.next(triggerButton)
}
expectedKey = true
break
case keys.down:
newIndex = index !== buttons.length - 1 ? index + 1 : 0
buttons[newIndex].focus()
expectedKey = true
break
case keys.up:
newIndex = index > 0 ? index - 1 : buttons.length - 1
buttons[newIndex].focus()
expectedKey = true
break
case keys.enter:
case keys.space:
// This means one of the buttons has been activated.
// Anything could be happening in the DOM at this moment, with elements being removed or added.
// We need to make sure that focus is returned to somewhere sensible.
// But if the content, including the triggering button, has been updated, where can we go?
// Maybe we can't know, so we should emit an event and the application can handle it.
// Save the id.
var originalId = this.id
// Use a timeout so this is executed after the keydown handler has finished work.
window.setTimeout(function () {
// Re-querying the DOM for the trigger button.
var originalTriggerButton = document.querySelector('[aria-controls="' + originalId + '"]')
if (originalTriggerButton) {
originalTriggerButton.focus()
} else {
_u.focus.lost(originalId)
}
}, 0)
break
case keys.esc:
hide(this)
triggerButton.focus()
expectedKey = true
}
}
if (expectedKey) {
event.stopPropagation()
event.preventDefault()
}
}
/**
* Preparation for toggling multiactions.
* @param {string} id - the unique ID of the multiaction button
* @return {Node} - the multiactions DOM node
*/
function prepareToToggle (id) {
var multiactions = document.getElementById(id)
// Clear the timer used for the hide animation.
if (multiactions.timer) {
clearTimeout(multiactions.timer)
multiactions.removeAttribute('data-hiding')
multiactions.timer = 0
}
return multiactions
}
/**
* Toggles multiactions.
* @param {string} id - the unique ID of the multiaction button
* @param {string} keyCode - an optional event keyCode
*/
function toggle (id, keyCode) {
var multiactions = prepareToToggle(id)
if (multiactions === null) {
return
}
keyCode = keyCode || null
// Toggle.
if (multiactions.getAttribute('aria-expanded') === 'false') {
show(multiactions, keyCode)
} else {
hide(multiactions)
}
}
/**
* Displays multiactions.
* @function show
* @memberof! UCASDesignFramework.multiactionButtons
* @public
* @param {(Node|string)} multiactions - the multiactions DOM node or its id
* @param {string} keyCode - an optional event keyCode
*/
function show (multiactions, keyCode) {
// Allow to be used with id as a parameter.
if (!multiactions.nodeType) {
multiactions = prepareToToggle(multiactions)
if (multiactions === null) {
return
}
}
var trigger = document.querySelector('button[aria-controls="' + multiactions.id + '"]')
// Hide any already open.
hideAll()
// Add the container if it doesn't exist.
if (!multiactionButtonContainer) {
multiactionButtonContainer = document.createElement('div')
multiactionButtonContainer.id = 'multiactionButtonContainer'
document.body.appendChild(multiactionButtonContainer)
multiactionButtonContainer.addEventListener('click', function (e) {
// Capture any clicks and hide all multiaction buttons and this container,
// otherwise it may prevent interaction with other elements.
hideAll()
}, true)
}
// Set an attribute on the button (which may or may not have been used to trigger).
trigger.setAttribute('data-multiaction-button-expanded', '')
// Move to container.
multiactionButtonContainer.appendChild(multiactions)
// Display.
multiactionButtonContainer.hidden = false
multiactions.removeAttribute('data-hiding')
multiactions.setAttribute('aria-expanded', 'true')
// Position accordingly.
setMultiactionButtonPosition(multiactions, trigger)
// Focus when using the keyboard.
if (keyCode) {
var activeButtons = multiactions.querySelectorAll(':not([disabled])')
if (activeButtons) {
if (keyCode === keys.up) {
activeButtons[activeButtons.length - 1].focus()
} else {
activeButtons[0].focus()
}
}
}
// Handle clicking elsewhere.
document.addEventListener('click', hideAll, false)
// Handle resize.
window.addEventListener('dfResize', hideAll, false)
}
/**
* Positions the multiactions relative to the trigger.
* @function setMultiactionButtonPosition
* @memberof! UCASDesignFramework.multiactionButtons
* @public
* @param {(Node)} multiactions - the multiactions DOM node
* @param {(Node)} trigger - the trigger DOM node
*/
function setMultiactionButtonPosition (multiactions, trigger) {
_u.position.clone(multiactions, trigger, 0, trigger.clientHeight, trigger.clientWidth, 0)
}
/**
* Hides all open multiactions.
* @function hideAll
* @memberof! UCASDesignFramework.multiactionButtons
* @public
*/
function hideAll () {
var items = document.querySelectorAll('[data-multiaction-button-expanded]')
_u.forEach(items, function (i, el) {
var id = el.getAttribute('aria-controls')
hide(id)
})
}
/**
* Hides multiactions.
* @function hide
* @memberof! UCASDesignFramework.multiactionButtons
* @public
* @param {(Node|string)} multiactions - the multiactions DOM node or its id
*/
function hide (multiactions) {
// Allow to be used with id as a parameter.
if (!multiactions.nodeType) {
multiactions = prepareToToggle(multiactions)
if (multiactions === null) {
return
}
}
// Remove listener for clicks elsewhere.
document.removeEventListener('click', hideAll, false)
// Remove listener for resize.
window.removeEventListener('dfResize', hideAll, false)
var trigger = document.querySelector('button[aria-controls="' + multiactions.id + '"]')
// Handle a situation where the button has been removed from the DOM by something else.
if (!trigger) {
_u.log.log('multiactionButtons: missing trigger', multiactions.id)
cleanUp()
return
}
// Hide with an animation.
trigger.removeAttribute('data-multiaction-button-expanded')
multiactions.setAttribute('data-hiding', '')
multiactions.setAttribute('aria-expanded', 'false')
multiactions.timer = window.setTimeout(function () {
multiactions.removeAttribute('data-hiding')
}, 800)
if (multiactionButtonContainer) {
multiactionButtonContainer.hidden = true
}
// Move back to original place.
trigger.parentNode.insertBefore(multiactions, trigger.nextSibling)
}
/**
* Disables multiactions.
* @function disable
* @memberof! UCASDesignFramework.multiactionButtons
* @public
* @param {(Node|string)} multiactions - the multiactions DOM node or its id
*/
function disable (multiactions) {
// Allow to be used with id as a parameter.
if (!multiactions.nodeType) {
multiactions = prepareToToggle(multiactions)
if (multiactions === null) {
return
}
}
// Hide if already open.
hide(multiactions)
// Set disabled attribute on the button.
multiactions.parentNode.querySelector('[aria-controls]').setAttribute('disabled', '')
}
/**
* Enables multiactions.
* @function enable
* @memberof! UCASDesignFramework.multiactionButtons
* @public
* @param {(Node|string)} multiactions - the multiactions DOM node or its id
*/
function enable (multiactions) {
// Allow to be used with id as a parameter.
if (!multiactions.nodeType) {
multiactions = prepareToToggle(multiactions)
if (multiactions === null) {
return
}
}
// Remove disabled attribute from the button.
multiactions.parentNode.querySelector('[aria-controls]').removeAttribute('disabled')
}
// Expose public methods.
global.multiactionButtons = {
addSubscriptions: true,
destroy: destroy,
init: init,
show: show,
hide: hide,
hideAll: hideAll,
disable: disable,
enable: enable
}
})(UCASDesignFramework, UCASUtilities)