UNPKG

8 kBJavaScriptView Raw
1import $ from './jquery';
2import globalize from './internal/globalize';
3import keyCodes from './key-code';
4import {getTrigger} from './trigger';
5
6(function initSelectors () {
7 /*
8 :tabbable and :focusable functions from jQuery UI v 1.10.4
9 renamed to :aui-tabbable and :aui-focusable to not clash with jquery-ui if it's included.
10 */
11
12 function visible (element) {
13 return ($.css(element, 'visibility') === 'visible') && $(element).is(':visible');
14 }
15
16 function focusable (element, isTabIndexNotNaN) {
17 var nodeName = element.nodeName.toLowerCase();
18
19 if (nodeName === 'aui-select') {
20 return true;
21 }
22
23 if (nodeName === 'area') {
24 var map = element.parentNode;
25 var mapName = map.name;
26 var imageMap = $('img[usemap=#' + mapName + ']').get();
27
28 if (!element.href || !mapName || map.nodeName.toLowerCase() !== 'map') {
29 return false;
30 }
31 return imageMap && visible(imageMap);
32 }
33 var isFormElement = /input|select|textarea|button|object|iframe/.test(nodeName);
34 var isAnchor = nodeName === 'a';
35 var isAnchorTabbable = (element.href || isTabIndexNotNaN);
36
37 return (
38 isFormElement ? !element.disabled :
39 (isAnchor ? isAnchorTabbable : isTabIndexNotNaN)
40 ) && visible(element);
41 }
42
43 function tabbable (element) {
44 var tabIndex = $.attr(element, 'tabindex');
45 var isTabIndexNaN = isNaN(tabIndex);
46 var hasTabIndex = (isTabIndexNaN || tabIndex >= 0);
47
48 return hasTabIndex && focusable(element, !isTabIndexNaN);
49 }
50
51 $.extend($.expr.pseudos, {
52 'aui-focusable': element => focusable(element, !isNaN($.attr(element, 'tabindex'))),
53 'aui-tabbable': tabbable
54 });
55}());
56
57var RESTORE_FOCUS_DATA_KEY = '_aui-focus-restore';
58
59/**
60 * Stores information about last focused element in el.data
61 *
62 * @param {HTMLElement} $el - element to store the data about focus
63 * @param {Element} [lastFocussedEl=document.activeElement] - last focused element
64 */
65function setLastFocus ($el, lastFocussedEl = document.activeElement) {
66 $el.data(RESTORE_FOCUS_DATA_KEY, lastFocussedEl);
67}
68
69function getLastFocus ($el) {
70 return $($el.data(RESTORE_FOCUS_DATA_KEY));
71}
72
73function elementTrapsFocus ($el) {
74 return $el.is('.aui-dialog2');
75}
76
77function FocusManager() {
78 this._focusTrapStack = [];
79 this._handler;
80}
81FocusManager.defaultFocusSelector = ':aui-tabbable';
82/**
83 *
84 * @param {HTMLElement} $el - element to move the focus onto
85 * @param {Element} [$lastFocused] - last focused element
86 */
87FocusManager.prototype.enter = function ($el, $lastFocused) {
88 setLastFocus($el, $lastFocused);
89
90 // focus on new selector
91 if ($el.attr('data-aui-focus') !== 'false') {
92 var focusSelector = $el.attr('data-aui-focus-selector') || FocusManager.defaultFocusSelector;
93 var $focusEl = $el.is(focusSelector) ? $el : $el.find(focusSelector);
94 $focusEl.first().trigger('focus');
95 }
96
97 if (elementTrapsFocus($el)) {
98 this._focusTrapStack.push($el);
99 if (!this._handler) {
100 this._handler = focusTrapHandler.bind(undefined, this._focusTrapStack);
101 $(document).on('keydown.aui-focus-manager', this._handler);
102 }
103 }
104};
105
106FocusManager.prototype.exit = function ($el) {
107 if (elementTrapsFocus($el)) {
108 this._focusTrapStack.pop();
109 if (!this._focusTrapStack.length) {
110 $(document).off('.aui-focus-manager', this._handler);
111 delete this._handler;
112 }
113 }
114
115 // AUI-1059: remove focus from the active element when dialog is hidden
116 // AUI-5014 - if focus is already outside focus trap there is no need to restore it
117 var activeElement = document.activeElement;
118 if ($el[0] === activeElement || $el.has(activeElement).length) {
119 $(activeElement).trigger('blur');
120 var $restoreFocus = getLastFocus($el);
121 if ($restoreFocus.length) {
122 $el.removeData(RESTORE_FOCUS_DATA_KEY);
123 $restoreFocus.trigger('focus');
124 }
125 }
126};
127
128function focusTrapHandler(focusTrapStack, event) {
129 if (focusTrapStack.length === 0) {
130 return;
131 }
132
133 if (event.keyCode !== keyCodes.TAB) {
134 return;
135 }
136
137 const backwards = event.shiftKey;
138 const offset = backwards ? -1 : 1;
139
140 /**
141 * always the element where focus is about to be blurred
142 * @type {HTMLElement}
143 */
144 const focusOrigin = event.target;
145
146 const $focusTrapElement = focusTrapStack[focusTrapStack.length - 1];
147 const $tabbableElements = $focusTrapElement.find(':aui-tabbable');
148
149 // it's not possible to trap focus inside something with no focussable elements!
150 if (!$tabbableElements.length) {
151 return;
152 }
153
154 const originIdx = $tabbableElements.index(focusOrigin);
155 let newFocusIdx = -1;
156
157 if (originIdx > -1) {
158 // the currently focussed element is inside our trap container.
159 // excellent! we'll work with this.
160 newFocusIdx = originIdx;
161 } else {
162 // the currently focussed element was outside our trap container.
163 // it might be okay to leave it there, though, since AUI has a few layer elements
164 // and the focussed element might be in one of those.
165 // let's see if the focus was in an element that AUI roughly "controls".
166 let $controlledElementWithFocus;
167
168 // look for a layer in the "cheapest" way possible first -- check if a parent is a layer.
169 $controlledElementWithFocus = $(focusOrigin).closest('.aui-layer');
170
171 if (!$controlledElementWithFocus.length) {
172 // look up the controlled layers in a different way -- by finding all controllers first,
173 // then their layers.
174 const $controllingElements = $focusTrapElement.find('[aria-controls]');
175 const $controlledElements = $controllingElements.map(function () {
176 return document.getElementById(this.getAttribute('aria-controls'));
177 });
178
179 // Find out whether the new focus target is inside a controlled element or not.
180 $controlledElementWithFocus = $controlledElements.has(focusOrigin);
181 }
182
183 if ($controlledElementWithFocus.length) {
184 // Find out whether we need to jump the focus out of the controlled element.
185 const $subTabbable = $controlledElementWithFocus.find(':aui-tabbable');
186 const subOriginIdx = $subTabbable.index(focusOrigin);
187 const subMove = subOriginIdx + offset;
188 if (subMove < 0 || subMove >= $subTabbable.length) {
189 // This element was on the edge of the list, so we'll pop focus out.
190 // We'll assume we can pop the focus to a controlled element.
191 const triggerEl = getTrigger($controlledElementWithFocus.get(0));
192 newFocusIdx = $tabbableElements.index(triggerEl);
193 } else {
194 // Focus will happen normally. Let it happen.
195 return;
196 }
197 }
198 }
199
200 if (newFocusIdx > -1) {
201 // wrap around the focus trap.
202 newFocusIdx = (newFocusIdx + offset) % $tabbableElements.length;
203 } else {
204 // we will focus the first element in the trap.
205 newFocusIdx = 0;
206 }
207
208 if ($tabbableElements.get(newFocusIdx).nodeName !== 'IFRAME') {
209 $tabbableElements.eq(newFocusIdx).trigger('focus');
210 event.preventDefault();
211 }
212}
213
214// AUI-4403 - Previous maintainers pretended multiple FocusManager instances made sense.
215// However, the class is implemented in a way that would never play well with others,
216// so here I'm locking it down as a singleton.
217let instance;
218function getFocusManager() {
219 if (!instance) {
220 instance = new FocusManager();
221 }
222 return instance;
223}
224getFocusManager.global = getFocusManager();
225
226globalize('FocusManager', getFocusManager);
227
228export default getFocusManager;