@atlassian/aui
Version:
Atlassian User Interface library
229 lines (192 loc) • 8 kB
JavaScript
import $ from './jquery';
import globalize from './internal/globalize';
import keyCodes from './key-code';
import {getTrigger} from './trigger';
(function initSelectors () {
/*
:tabbable and :focusable functions from jQuery UI v 1.10.4
renamed to :aui-tabbable and :aui-focusable to not clash with jquery-ui if it's included.
*/
function visible (element) {
return ($.css(element, 'visibility') === 'visible') && $(element).is(':visible');
}
function focusable (element, isTabIndexNotNaN) {
var nodeName = element.nodeName.toLowerCase();
if (nodeName === 'aui-select') {
return true;
}
if (nodeName === 'area') {
var map = element.parentNode;
var mapName = map.name;
var imageMap = $('img[usemap=#' + mapName + ']').get();
if (!element.href || !mapName || map.nodeName.toLowerCase() !== 'map') {
return false;
}
return imageMap && visible(imageMap);
}
var isFormElement = /input|select|textarea|button|object|iframe/.test(nodeName);
var isAnchor = nodeName === 'a';
var isAnchorTabbable = (element.href || isTabIndexNotNaN);
return (
isFormElement ? !element.disabled :
(isAnchor ? isAnchorTabbable : isTabIndexNotNaN)
) && visible(element);
}
function tabbable (element) {
var tabIndex = $.attr(element, 'tabindex');
var isTabIndexNaN = isNaN(tabIndex);
var hasTabIndex = (isTabIndexNaN || tabIndex >= 0);
return hasTabIndex && focusable(element, !isTabIndexNaN);
}
$.extend($.expr.pseudos, {
'aui-focusable': element => focusable(element, !isNaN($.attr(element, 'tabindex'))),
'aui-tabbable': tabbable
});
}());
var RESTORE_FOCUS_DATA_KEY = '_aui-focus-restore';
/**
* Stores information about last focused element in el.data
*
* @param {HTMLElement} $el - element to store the data about focus
* @param {Element} [lastFocussedEl=document.activeElement] - last focused element
*/
function setLastFocus ($el, lastFocussedEl = document.activeElement) {
$el.data(RESTORE_FOCUS_DATA_KEY, lastFocussedEl);
}
function getLastFocus ($el) {
return $($el.data(RESTORE_FOCUS_DATA_KEY));
}
function elementTrapsFocus ($el) {
return $el.is('.aui-dialog2');
}
function FocusManager() {
this._focusTrapStack = [];
this._handler;
}
FocusManager.defaultFocusSelector = ':aui-tabbable';
/**
*
* @param {HTMLElement} $el - element to move the focus onto
* @param {Element} [$lastFocused] - last focused element
*/
FocusManager.prototype.enter = function ($el, $lastFocused) {
setLastFocus($el, $lastFocused);
// focus on new selector
if ($el.attr('data-aui-focus') !== 'false') {
var focusSelector = $el.attr('data-aui-focus-selector') || FocusManager.defaultFocusSelector;
var $focusEl = $el.is(focusSelector) ? $el : $el.find(focusSelector);
$focusEl.first().trigger('focus');
}
if (elementTrapsFocus($el)) {
this._focusTrapStack.push($el);
if (!this._handler) {
this._handler = focusTrapHandler.bind(undefined, this._focusTrapStack);
$(document).on('keydown.aui-focus-manager', this._handler);
}
}
};
FocusManager.prototype.exit = function ($el) {
if (elementTrapsFocus($el)) {
this._focusTrapStack.pop();
if (!this._focusTrapStack.length) {
$(document).off('.aui-focus-manager', this._handler);
delete this._handler;
}
}
// AUI-1059: remove focus from the active element when dialog is hidden
// AUI-5014 - if focus is already outside focus trap there is no need to restore it
var activeElement = document.activeElement;
if ($el[0] === activeElement || $el.has(activeElement).length) {
$(activeElement).trigger('blur');
var $restoreFocus = getLastFocus($el);
if ($restoreFocus.length) {
$el.removeData(RESTORE_FOCUS_DATA_KEY);
$restoreFocus.trigger('focus');
}
}
};
function focusTrapHandler(focusTrapStack, event) {
if (focusTrapStack.length === 0) {
return;
}
if (event.keyCode !== keyCodes.TAB) {
return;
}
const backwards = event.shiftKey;
const offset = backwards ? -1 : 1;
/**
* always the element where focus is about to be blurred
* @type {HTMLElement}
*/
const focusOrigin = event.target;
const $focusTrapElement = focusTrapStack[focusTrapStack.length - 1];
const $tabbableElements = $focusTrapElement.find(':aui-tabbable');
// it's not possible to trap focus inside something with no focussable elements!
if (!$tabbableElements.length) {
return;
}
const originIdx = $tabbableElements.index(focusOrigin);
let newFocusIdx = -1;
if (originIdx > -1) {
// the currently focussed element is inside our trap container.
// excellent! we'll work with this.
newFocusIdx = originIdx;
} else {
// the currently focussed element was outside our trap container.
// it might be okay to leave it there, though, since AUI has a few layer elements
// and the focussed element might be in one of those.
// let's see if the focus was in an element that AUI roughly "controls".
let $controlledElementWithFocus;
// look for a layer in the "cheapest" way possible first -- check if a parent is a layer.
$controlledElementWithFocus = $(focusOrigin).closest('.aui-layer');
if (!$controlledElementWithFocus.length) {
// look up the controlled layers in a different way -- by finding all controllers first,
// then their layers.
const $controllingElements = $focusTrapElement.find('[aria-controls]');
const $controlledElements = $controllingElements.map(function () {
return document.getElementById(this.getAttribute('aria-controls'));
});
// Find out whether the new focus target is inside a controlled element or not.
$controlledElementWithFocus = $controlledElements.has(focusOrigin);
}
if ($controlledElementWithFocus.length) {
// Find out whether we need to jump the focus out of the controlled element.
const $subTabbable = $controlledElementWithFocus.find(':aui-tabbable');
const subOriginIdx = $subTabbable.index(focusOrigin);
const subMove = subOriginIdx + offset;
if (subMove < 0 || subMove >= $subTabbable.length) {
// This element was on the edge of the list, so we'll pop focus out.
// We'll assume we can pop the focus to a controlled element.
const triggerEl = getTrigger($controlledElementWithFocus.get(0));
newFocusIdx = $tabbableElements.index(triggerEl);
} else {
// Focus will happen normally. Let it happen.
return;
}
}
}
if (newFocusIdx > -1) {
// wrap around the focus trap.
newFocusIdx = (newFocusIdx + offset) % $tabbableElements.length;
} else {
// we will focus the first element in the trap.
newFocusIdx = 0;
}
if ($tabbableElements.get(newFocusIdx).nodeName !== 'IFRAME') {
$tabbableElements.eq(newFocusIdx).trigger('focus');
event.preventDefault();
}
}
// AUI-4403 - Previous maintainers pretended multiple FocusManager instances made sense.
// However, the class is implemented in a way that would never play well with others,
// so here I'm locking it down as a singleton.
let instance;
function getFocusManager() {
if (!instance) {
instance = new FocusManager();
}
return instance;
}
getFocusManager.global = getFocusManager();
globalize('FocusManager', getFocusManager);
export default getFocusManager;