@atlassian/aui
Version:
Atlassian User Interface library
288 lines (245 loc) • 10.3 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') || $el.is('[aui-focus-trap="true"]');
}
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.splice(this._focusTrapStack.indexOf($el), 1);
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 findIndexOfNextTabbableElement(originElement, startingIndex, offset, $tabbableElements) {
function advance(index) {
return (index + offset) % $tabbableElements.length;
}
let nextIndex = advance(startingIndex);
if (originElement.type === 'radio' && originElement.name) {
// Advance to the element that's not another radio button of the same group.
while (
$tabbableElements.eq(nextIndex).attr('name') === originElement.name &&
// Stop at the start if we've wrapped around the whole list and everything is the same radio
// group.
nextIndex !== startingIndex
) {
nextIndex = advance(nextIndex);
}
}
// Emulate the browser's behavior: jump to the selected radio of a group, or if none is selected, the
// first or last depending on the direction. Note: we assume the markup is sensible and doesn't
// interleave groups of radio buttons. Supporting that would complicate the code for a non-recommended
// use case.
if (nextIndex !== startingIndex && $tabbableElements.eq(nextIndex).attr('type') === 'radio') {
const name = $tabbableElements.eq(nextIndex).attr('name');
const $radiosOfGroup = $tabbableElements.filter(function () {
return this.type === 'radio' && this.name === name;
});
const $checkedElement = $radiosOfGroup.filter(':checked');
let $elementToFocus;
if ($checkedElement.length > 0) {
$elementToFocus = $checkedElement.first();
} else {
if (offset > 0) {
$elementToFocus = $radiosOfGroup.first();
} else {
$elementToFocus = $radiosOfGroup.last();
}
}
nextIndex = $tabbableElements.index($elementToFocus);
}
return nextIndex;
}
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');
// Try to focus the container if:
// - it is with aui-focus-trap=true attribute
// - there isn't any focusable element inside.
if (!$tabbableElements.length && elementTrapsFocus($focusTrapElement)) {
$focusTrapElement.trigger('focus');
event.preventDefault();
}
// it's not possible to trap focus inside something with no focusable 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 = findIndexOfNextTabbableElement(
focusOrigin,
newFocusIdx,
offset,
$tabbableElements
);
} 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;