UNPKG

@atlassian/aui

Version:

Atlassian User Interface library

229 lines (192 loc) 8 kB
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;