60651c90d41f37aca2192a8597fecabc26026858
[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'], function($) {
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                 M.util.pending_js('core/aria:delayed-focus');
69                 var delayedFocus = function() {
70                     $(this).focus();
71                     M.util.complete_js('core/aria:delayed-focus');
72                 }.bind(element);
73                 setTimeout(delayedFocus, 50);
74             };
76             $('.dropdown').on('shown.bs.dropdown', function(e) {
77                 // We need to focus on the first menuitem.
78                 var menu = $(e.target).find('[role="menu"]'),
79                     menuItems = false,
80                     foundMenuItem = false;
82                 if (menu) {
83                     menuItems = $(menu).find('[role="menuitem"]');
84                 }
85                 if (menuItems && menuItems.length > 0) {
86                     if (getFocusEnd()) {
87                         foundMenuItem = menuItems[menuItems.length - 1];
88                     } else {
89                         // The first menu entry, pretty reasonable.
90                         foundMenuItem = menuItems[0];
91                     }
92                 }
93                 if (foundMenuItem) {
94                     shiftFocus(foundMenuItem);
95                 }
96             });
97             // Search for menu items by finding the first item that has
98             // text starting with the typed character (case insensitive).
99             $('.dropdown [role="menu"] [role="menuitem"]').keypress(function(e) {
100                 var trigger = String.fromCharCode(e.which || e.keyCode),
101                     menu = $(e.target).closest('[role="menu"]'),
102                     i = 0,
103                     menuItems = false,
104                     item,
105                     itemText;
107                 if (!menu) {
108                     return;
109                 }
110                 menuItems = $(menu).find('[role="menuitem"]');
111                 if (!menuItems) {
112                     return;
113                 }
115                 trigger = trigger.toLowerCase();
116                 for (i = 0; i < menuItems.length; i++) {
117                     item = $(menuItems[i]);
118                     itemText = item.text().trim().toLowerCase();
119                     if (itemText.indexOf(trigger) == 0) {
120                         shiftFocus(item);
121                         break;
122                     }
123                 }
124             });
126             // Keyboard navigation for arrow keys, home and end keys.
127             $('.dropdown [role="menu"] [role="menuitem"]').keydown(function(e) {
128                 var trigger = e.which || e.keyCode,
129                     next = false,
130                     menu = $(e.target).closest('[role="menu"]'),
131                     i = 0,
132                     menuItems = false;
133                 if (!menu) {
134                     return;
135                 }
136                 menuItems = $(menu).find('[role="menuitem"]');
137                 if (!menuItems) {
138                     return;
139                 }
140                 // Down key.
141                 if (trigger == 40) {
142                     for (i = 0; i < menuItems.length - 1; i++) {
143                         if (menuItems[i] == e.target) {
144                             next = menuItems[i + 1];
145                             break;
146                         }
147                     }
148                     if (!next) {
149                         // Wrap to first item.
150                         next = menuItems[0];
151                     }
153                 } else if (trigger == 38) {
154                     // Up key.
155                     for (i = 1; i < menuItems.length; i++) {
156                         if (menuItems[i] == e.target) {
157                             next = menuItems[i - 1];
158                             break;
159                         }
160                     }
161                     if (!next) {
162                         // Wrap to last item.
163                         next = menuItems[menuItems.length - 1];
164                     }
166                 } else if (trigger == 36) {
167                     // Home key.
168                     next = menuItems[0];
170                 } else if (trigger == 35) {
171                     // End key.
172                     next = menuItems[menuItems.length - 1];
173                 }
174                 // Variable next is set if we do want to act on the keypress.
175                 if (next) {
176                     e.preventDefault();
177                     shiftFocus(next);
178                 }
179                 return;
180             });
181             $('.dropdown').on('hidden.bs.dropdown', function(e) {
182                 // We need to focus on the menu trigger.
183                 var trigger = $(e.target).find('[data-toggle="dropdown"]');
184                 if (trigger) {
185                     shiftFocus(trigger);
186                 }
187             });
189             // After page load, focus on any element with special autofocus attribute.
190             $(function() {
191                 M.util.pending_js('core/aria:delayed-focus');
192                 window.setTimeout(function() {
193                     var alerts = $('[role="alert"][data-aria-autofocus="true"]');
194                     if (alerts.length > 0) {
195                         $(alerts[0]).attr('tabindex', '0');
196                         $(alerts[0]).focus();
197                     }
198                     M.util.complete_js('core/aria:delayed-focus');
199                 }, 300);
200             });
201         }
202     };
203 });