MDL-63714 javascript: Add new core/pending module
[moodle.git] / theme / boost / amd / src / aria.js
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16 /**
17  * Enhancements to Bootstrap components for accessibility.
18  *
19  * @module     theme_boost/aria
20  * @copyright  2018 Damyon Wiese <damyon@moodle.com>
21  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22  */
23 define(['jquery', 'core/pending'], function($, Pending) {
24     return {
25         init: function() {
26             // Drop downs from bootstrap don't support keyboard accessibility by default.
27             var focusEnd = false,
28                 setFocusEnd = function() {
29                     focusEnd = true;
30                 },
31                 getFocusEnd = function() {
32                     var result = focusEnd;
33                     focusEnd = false;
34                     return result;
35                 };
37             // Special handling for "up" keyboard control.
38             $('[data-toggle="dropdown"]').keydown(function(e) {
39                 var trigger = e.which || e.keyCode,
40                     expanded;
42                 // Up key opens the menu at the end.
43                 if (trigger == 38) {
44                     // Focus the end of the menu, not the beginning.
45                     setFocusEnd();
46                 }
48                 // Escape key only closes the menu, it doesn't open it.
49                 if (trigger == 27) {
50                     expanded = $(e.target).attr('aria-expanded');
51                     e.preventDefault();
52                     if (expanded == "false") {
53                         $(e.target).click();
54                     }
55                 }
57                 // Space key or Enter key opens the menu.
58                 if (trigger == 32 || trigger == 13) {
59                     // Cancel random scroll.
60                     e.preventDefault();
61                     // Open the menu instead.
62                     $(e.target).click();
63                 }
64             });
66             // Special handling for navigation keys when menu is open.
67             var shiftFocus = function(element) {
68                 var delayedFocus = function(pendingPromise) {
69                     $(this).focus();
70                     pendingPromise.resolve();
71                 }.bind(element);
72                 setTimeout(delayedFocus, 50, new Pending('core/aria:delayed-focus'));
73             };
75             $('.dropdown').on('shown.bs.dropdown', function(e) {
76                 // We need to focus on the first menuitem.
77                 var menu = $(e.target).find('[role="menu"]'),
78                     menuItems = false,
79                     foundMenuItem = false;
81                 if (menu) {
82                     menuItems = $(menu).find('[role="menuitem"]');
83                 }
84                 if (menuItems && menuItems.length > 0) {
85                     if (getFocusEnd()) {
86                         foundMenuItem = menuItems[menuItems.length - 1];
87                     } else {
88                         // The first menu entry, pretty reasonable.
89                         foundMenuItem = menuItems[0];
90                     }
91                 }
92                 if (foundMenuItem) {
93                     shiftFocus(foundMenuItem);
94                 }
95             });
96             // Search for menu items by finding the first item that has
97             // text starting with the typed character (case insensitive).
98             $('.dropdown [role="menu"] [role="menuitem"]').keypress(function(e) {
99                 var trigger = String.fromCharCode(e.which || e.keyCode),
100                     menu = $(e.target).closest('[role="menu"]'),
101                     i = 0,
102                     menuItems = false,
103                     item,
104                     itemText;
106                 if (!menu) {
107                     return;
108                 }
109                 menuItems = $(menu).find('[role="menuitem"]');
110                 if (!menuItems) {
111                     return;
112                 }
114                 trigger = trigger.toLowerCase();
115                 for (i = 0; i < menuItems.length; i++) {
116                     item = $(menuItems[i]);
117                     itemText = item.text().trim().toLowerCase();
118                     if (itemText.indexOf(trigger) == 0) {
119                         shiftFocus(item);
120                         break;
121                     }
122                 }
123             });
125             // Keyboard navigation for arrow keys, home and end keys.
126             $('.dropdown [role="menu"] [role="menuitem"]').keydown(function(e) {
127                 var trigger = e.which || e.keyCode,
128                     next = false,
129                     menu = $(e.target).closest('[role="menu"]'),
130                     i = 0,
131                     menuItems = false;
132                 if (!menu) {
133                     return;
134                 }
135                 menuItems = $(menu).find('[role="menuitem"]');
136                 if (!menuItems) {
137                     return;
138                 }
139                 // Down key.
140                 if (trigger == 40) {
141                     for (i = 0; i < menuItems.length - 1; i++) {
142                         if (menuItems[i] == e.target) {
143                             next = menuItems[i + 1];
144                             break;
145                         }
146                     }
147                     if (!next) {
148                         // Wrap to first item.
149                         next = menuItems[0];
150                     }
152                 } else if (trigger == 38) {
153                     // Up key.
154                     for (i = 1; i < menuItems.length; i++) {
155                         if (menuItems[i] == e.target) {
156                             next = menuItems[i - 1];
157                             break;
158                         }
159                     }
160                     if (!next) {
161                         // Wrap to last item.
162                         next = menuItems[menuItems.length - 1];
163                     }
165                 } else if (trigger == 36) {
166                     // Home key.
167                     next = menuItems[0];
169                 } else if (trigger == 35) {
170                     // End key.
171                     next = menuItems[menuItems.length - 1];
172                 }
173                 // Variable next is set if we do want to act on the keypress.
174                 if (next) {
175                     e.preventDefault();
176                     shiftFocus(next);
177                 }
178                 return;
179             });
180             $('.dropdown').on('hidden.bs.dropdown', function(e) {
181                 // We need to focus on the menu trigger.
182                 var trigger = $(e.target).find('[data-toggle="dropdown"]');
183                 if (trigger) {
184                     shiftFocus(trigger);
185                 }
186             });
188             // After page load, focus on any element with special autofocus attribute.
189             $(function() {
190                 window.setTimeout(function(pendingPromise) {
191                     var alerts = $('[role="alert"][data-aria-autofocus="true"]');
192                     if (alerts.length > 0) {
193                         $(alerts[0]).attr('tabindex', '0');
194                         $(alerts[0]).focus();
195                     }
196                     pendingPromise.resolve();
197                 }, 300, new Pending('core/aria:delayed-focus'));
198             });
199         }
200     };
201 });