lib/amd/src/popover_region_controller.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/>.
  15. /**
  16. * Controls the popover region element.
  17. *
  18. * See template: core/popover_region
  19. *
  20. * @module core/popover_region_controller
  21. * @copyright 2015 Ryan Wyllie <ryan@moodle.com>
  22. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23. * @since 3.2
  24. */
  25. define(['jquery', 'core/str', 'core/custom_interaction_events'],
  26. function($, str, customEvents) {
  27. var SELECTORS = {
  28. CONTENT: '.popover-region-content',
  29. CONTENT_CONTAINER: '.popover-region-content-container',
  30. MENU_CONTAINER: '.popover-region-container',
  31. MENU_TOGGLE: '.popover-region-toggle',
  32. CAN_RECEIVE_FOCUS: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
  33. };
  34. /**
  35. * Constructor for the PopoverRegionController.
  36. *
  37. * @param {jQuery} element object root element of the popover
  38. */
  39. var PopoverRegionController = function(element) {
  40. this.root = $(element);
  41. this.content = this.root.find(SELECTORS.CONTENT);
  42. this.contentContainer = this.root.find(SELECTORS.CONTENT_CONTAINER);
  43. this.menuContainer = this.root.find(SELECTORS.MENU_CONTAINER);
  44. this.menuToggle = this.root.find(SELECTORS.MENU_TOGGLE);
  45. this.isLoading = false;
  46. this.promises = {
  47. closeHandlers: $.Deferred(),
  48. navigationHandlers: $.Deferred(),
  49. };
  50. // Core event listeners to open and close.
  51. this.registerBaseEventListeners();
  52. };
  53. /**
  54. * The collection of events triggered by this controller.
  55. *
  56. * @returns {object}
  57. */
  58. PopoverRegionController.prototype.events = function() {
  59. return {
  60. menuOpened: 'popoverregion:menuopened',
  61. menuClosed: 'popoverregion:menuclosed',
  62. startLoading: 'popoverregion:startLoading',
  63. stopLoading: 'popoverregion:stopLoading',
  64. };
  65. };
  66. /**
  67. * Return the container element for the content element.
  68. *
  69. * @method getContentContainer
  70. * @return {jQuery} object
  71. */
  72. PopoverRegionController.prototype.getContentContainer = function() {
  73. return this.contentContainer;
  74. };
  75. /**
  76. * Return the content element.
  77. *
  78. * @method getContent
  79. * @return {jQuery} object
  80. */
  81. PopoverRegionController.prototype.getContent = function() {
  82. return this.content;
  83. };
  84. /**
  85. * Checks if the popover is displayed.
  86. *
  87. * @method isMenuOpen
  88. * @return {bool}
  89. */
  90. PopoverRegionController.prototype.isMenuOpen = function() {
  91. return !this.root.hasClass('collapsed');
  92. };
  93. /**
  94. * Toggle the visibility of the popover.
  95. *
  96. * @method toggleMenu
  97. */
  98. PopoverRegionController.prototype.toggleMenu = function() {
  99. if (this.isMenuOpen()) {
  100. this.closeMenu();
  101. } else {
  102. this.openMenu();
  103. }
  104. };
  105. /**
  106. * Hide the popover.
  107. *
  108. * Note: This triggers the menuClosed event.
  109. *
  110. * @method closeMenu
  111. */
  112. PopoverRegionController.prototype.closeMenu = function() {
  113. // We're already closed.
  114. if (!this.isMenuOpen()) {
  115. return;
  116. }
  117. this.root.addClass('collapsed');
  118. this.menuToggle.attr('aria-expanded', 'false');
  119. this.menuContainer.attr('aria-hidden', 'true');
  120. this.updateButtonAriaLabel();
  121. this.updateFocusItemTabIndex();
  122. this.root.trigger(this.events().menuClosed);
  123. };
  124. /**
  125. * Show the popover.
  126. *
  127. * Note: This triggers the menuOpened event.
  128. *
  129. * @method openMenu
  130. */
  131. PopoverRegionController.prototype.openMenu = function() {
  132. // We're already open.
  133. if (this.isMenuOpen()) {
  134. return;
  135. }
  136. this.root.removeClass('collapsed');
  137. this.menuToggle.attr('aria-expanded', 'true');
  138. this.menuContainer.attr('aria-hidden', 'false');
  139. this.updateButtonAriaLabel();
  140. this.updateFocusItemTabIndex();
  141. // Resolve the promises to allow the handlers to be added
  142. // to the DOM, if they have been requested.
  143. this.promises.closeHandlers.resolve();
  144. this.promises.navigationHandlers.resolve();
  145. this.root.trigger(this.events().menuOpened);
  146. };
  147. /**
  148. * Set the appropriate aria label on the popover toggle.
  149. *
  150. * @method updateButtonAriaLabel
  151. */
  152. PopoverRegionController.prototype.updateButtonAriaLabel = function() {
  153. if (this.isMenuOpen()) {
  154. str.get_string('hidepopoverwindow').done(function(string) {
  155. this.menuToggle.attr('aria-label', string);
  156. }.bind(this));
  157. } else {
  158. str.get_string('showpopoverwindow').done(function(string) {
  159. this.menuToggle.attr('aria-label', string);
  160. }.bind(this));
  161. }
  162. };
  163. /**
  164. * Set the loading state on this popover.
  165. *
  166. * Note: This triggers the startLoading event.
  167. *
  168. * @method startLoading
  169. */
  170. PopoverRegionController.prototype.startLoading = function() {
  171. this.isLoading = true;
  172. this.getContentContainer().addClass('loading');
  173. this.getContentContainer().attr('aria-busy', 'true');
  174. this.root.trigger(this.events().startLoading);
  175. };
  176. /**
  177. * Undo the loading state on this popover.
  178. *
  179. * Note: This triggers the stopLoading event.
  180. *
  181. * @method stopLoading
  182. */
  183. PopoverRegionController.prototype.stopLoading = function() {
  184. this.isLoading = false;
  185. this.getContentContainer().removeClass('loading');
  186. this.getContentContainer().attr('aria-busy', 'false');
  187. this.root.trigger(this.events().stopLoading);
  188. };
  189. /**
  190. * Sets the focus on the menu toggle.
  191. *
  192. * @method focusMenuToggle
  193. */
  194. PopoverRegionController.prototype.focusMenuToggle = function() {
  195. this.menuToggle.focus();
  196. };
  197. /**
  198. * Check if a content item has focus.
  199. *
  200. * @method contentItemHasFocus
  201. * @return {bool}
  202. */
  203. PopoverRegionController.prototype.contentItemHasFocus = function() {
  204. return this.getContentItemWithFocus().length > 0;
  205. };
  206. /**
  207. * Return the currently focused content item.
  208. *
  209. * @method getContentItemWithFocus
  210. * @return {jQuery} object
  211. */
  212. PopoverRegionController.prototype.getContentItemWithFocus = function() {
  213. var currentFocus = $(document.activeElement);
  214. var items = this.getContent().children();
  215. var currentItem = items.filter(currentFocus);
  216. if (!currentItem.length) {
  217. currentItem = items.has(currentFocus);
  218. }
  219. return currentItem;
  220. };
  221. /**
  222. * Focus the given content item or the first focusable element within
  223. * the content item.
  224. *
  225. * @method focusContentItem
  226. * @param {object} item The content item jQuery element
  227. */
  228. PopoverRegionController.prototype.focusContentItem = function(item) {
  229. if (item.is(SELECTORS.CAN_RECEIVE_FOCUS)) {
  230. item.focus();
  231. } else {
  232. item.find(SELECTORS.CAN_RECEIVE_FOCUS).first().focus();
  233. }
  234. };
  235. /**
  236. * Set focus on the first content item in the list.
  237. *
  238. * @method focusFirstContentItem
  239. */
  240. PopoverRegionController.prototype.focusFirstContentItem = function() {
  241. this.focusContentItem(this.getContent().children().first());
  242. };
  243. /**
  244. * Set focus on the last content item in the list.
  245. *
  246. * @method focusLastContentItem
  247. */
  248. PopoverRegionController.prototype.focusLastContentItem = function() {
  249. this.focusContentItem(this.getContent().children().last());
  250. };
  251. /**
  252. * Set focus on the content item after the item that currently has focus
  253. * in the list.
  254. *
  255. * @method focusNextContentItem
  256. */
  257. PopoverRegionController.prototype.focusNextContentItem = function() {
  258. var currentItem = this.getContentItemWithFocus();
  259. if (currentItem.length && currentItem.next()) {
  260. this.focusContentItem(currentItem.next());
  261. }
  262. };
  263. /**
  264. * Set focus on the content item preceding the item that currently has focus
  265. * in the list.
  266. *
  267. * @method focusPreviousContentItem
  268. */
  269. PopoverRegionController.prototype.focusPreviousContentItem = function() {
  270. var currentItem = this.getContentItemWithFocus();
  271. if (currentItem.length && currentItem.prev()) {
  272. this.focusContentItem(currentItem.prev());
  273. }
  274. };
  275. /**
  276. * Register the minimal amount of listeners for the popover to function.
  277. *
  278. * @method registerBaseEventListeners
  279. */
  280. PopoverRegionController.prototype.registerBaseEventListeners = function() {
  281. customEvents.define(this.root, [
  282. customEvents.events.activate,
  283. customEvents.events.escape,
  284. ]);
  285. // Toggle the popover visibility on activation (click/enter/space) of the toggle button.
  286. this.root.on(customEvents.events.activate, SELECTORS.MENU_TOGGLE, function() {
  287. this.toggleMenu();
  288. }.bind(this));
  289. // Delay the binding of these handlers until the region has been opened.
  290. this.promises.closeHandlers.done(function() {
  291. // Close the popover if escape is pressed.
  292. this.root.on(customEvents.events.escape, function() {
  293. this.closeMenu();
  294. this.focusMenuToggle();
  295. }.bind(this));
  296. // Close the popover if any other part of the page is clicked.
  297. document.addEventListener('click', (e) => {
  298. const target = e.target;
  299. // Check if the click is outside the root element.
  300. if (!this.root.is(target) && !this.root.has(target).length) {
  301. this.closeMenu();
  302. }
  303. }, true); // `true` makes it a capture phase event listener.
  304. customEvents.define(this.getContentContainer(), [
  305. customEvents.events.scrollBottom
  306. ]);
  307. }.bind(this));
  308. };
  309. /**
  310. * Set up the event listeners for keyboard navigating a list of content items.
  311. *
  312. * @method registerListNavigationEventListeners
  313. */
  314. PopoverRegionController.prototype.registerListNavigationEventListeners = function() {
  315. customEvents.define(this.root, [
  316. customEvents.events.down
  317. ]);
  318. // If the down arrow is pressed then open the menu and focus the first content
  319. // item or focus the next content item if the menu is open.
  320. this.root.on(customEvents.events.down, function(e, data) {
  321. if (!this.isMenuOpen()) {
  322. this.openMenu();
  323. this.focusFirstContentItem();
  324. } else {
  325. if (this.contentItemHasFocus()) {
  326. this.focusNextContentItem();
  327. } else {
  328. this.focusFirstContentItem();
  329. }
  330. }
  331. data.originalEvent.preventDefault();
  332. }.bind(this));
  333. // Delay the binding of these handlers until the region has been opened.
  334. this.promises.navigationHandlers.done(function() {
  335. customEvents.define(this.root, [
  336. customEvents.events.up,
  337. customEvents.events.home,
  338. customEvents.events.end,
  339. ]);
  340. // Shift focus to the previous content item if the up key is pressed.
  341. this.root.on(customEvents.events.up, function(e, data) {
  342. this.focusPreviousContentItem();
  343. data.originalEvent.preventDefault();
  344. }.bind(this));
  345. // Jump focus to the first content item if the home key is pressed.
  346. this.root.on(customEvents.events.home, function(e, data) {
  347. this.focusFirstContentItem();
  348. data.originalEvent.preventDefault();
  349. }.bind(this));
  350. // Jump focus to the last content item if the end key is pressed.
  351. this.root.on(customEvents.events.end, function(e, data) {
  352. this.focusLastContentItem();
  353. data.originalEvent.preventDefault();
  354. }.bind(this));
  355. }.bind(this));
  356. };
  357. /**
  358. * Set the appropriate tabindex attribute on the popover toggle.
  359. *
  360. * @method updateFocusItemTabIndex
  361. */
  362. PopoverRegionController.prototype.updateFocusItemTabIndex = function() {
  363. if (this.isMenuOpen()) {
  364. this.menuContainer.find(SELECTORS.CAN_RECEIVE_FOCUS).removeAttr('tabindex');
  365. } else {
  366. this.menuContainer.find(SELECTORS.CAN_RECEIVE_FOCUS).attr('tabindex', '-1');
  367. }
  368. };
  369. return PopoverRegionController;
  370. });