UNPKG

@atlassian/aui

Version:

Atlassian User Interface library

288 lines (245 loc) 10.3 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') || $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;