touch-keyboard-navigation.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. /**
  2. * Touch & Keyboard navigation.
  3. *
  4. * Contains handlers for touch devices and keyboard navigation.
  5. */
  6. (function() {
  7. /**
  8. * Debounce
  9. *
  10. * @param {Function} func
  11. * @param {number} wait
  12. * @param {boolean} immediate
  13. */
  14. function debounce(func, wait, immediate) {
  15. 'use strict';
  16. var timeout;
  17. wait = (typeof wait !== 'undefined') ? wait : 20;
  18. immediate = (typeof immediate !== 'undefined') ? immediate : true;
  19. return function() {
  20. var context = this, args = arguments;
  21. var later = function() {
  22. timeout = null;
  23. if (!immediate) {
  24. func.apply(context, args);
  25. }
  26. };
  27. var callNow = immediate && !timeout;
  28. clearTimeout(timeout);
  29. timeout = setTimeout(later, wait);
  30. if (callNow) {
  31. func.apply(context, args);
  32. }
  33. };
  34. }
  35. /**
  36. * Add class
  37. *
  38. * @param {Object} el
  39. * @param {string} cls
  40. */
  41. function addClass(el, cls) {
  42. if ( ! el.className.match( '(?:^|\\s)' + cls + '(?!\\S)') ) {
  43. el.className += ' ' + cls;
  44. }
  45. }
  46. /**
  47. * Delete class
  48. *
  49. * @param {Object} el
  50. * @param {string} cls
  51. */
  52. function deleteClass(el, cls) {
  53. el.className = el.className.replace( new RegExp( '(?:^|\\s)' + cls + '(?!\\S)' ),'' );
  54. }
  55. /**
  56. * Has class?
  57. *
  58. * @param {Object} el
  59. * @param {string} cls
  60. *
  61. * @returns {boolean} Has class
  62. */
  63. function hasClass(el, cls) {
  64. if ( el.className.match( '(?:^|\\s)' + cls + '(?!\\S)' ) ) {
  65. return true;
  66. }
  67. }
  68. /**
  69. * Toggle Aria Expanded state for screenreaders
  70. *
  71. * @param {Object} ariaItem
  72. */
  73. function toggleAriaExpandedState( ariaItem ) {
  74. 'use strict';
  75. var ariaState = ariaItem.getAttribute('aria-expanded');
  76. if ( ariaState === 'true' ) {
  77. ariaState = 'false';
  78. } else {
  79. ariaState = 'true';
  80. }
  81. ariaItem.setAttribute('aria-expanded', ariaState);
  82. }
  83. /**
  84. * Open sub-menu
  85. *
  86. * @param {Object} currentSubMenu
  87. */
  88. function openSubMenu( currentSubMenu ) {
  89. 'use strict';
  90. // Update classes
  91. // classList.add is not supported in IE11
  92. currentSubMenu.parentElement.className += ' off-canvas';
  93. currentSubMenu.parentElement.lastElementChild.className += ' expanded-true';
  94. // Update aria-expanded state
  95. toggleAriaExpandedState( currentSubMenu );
  96. }
  97. /**
  98. * Close sub-menu
  99. *
  100. * @param {Object} currentSubMenu
  101. */
  102. function closeSubMenu( currentSubMenu ) {
  103. 'use strict';
  104. var menuItem = getCurrentParent( currentSubMenu, '.menu-item' ); // this.parentNode
  105. var menuItemAria = menuItem.querySelector('a[aria-expanded]');
  106. var subMenu = currentSubMenu.closest('.sub-menu');
  107. // If this is in a sub-sub-menu, go back to parent sub-menu
  108. if ( getCurrentParent( currentSubMenu, 'ul' ).classList.contains( 'sub-menu' ) ) {
  109. // Update classes
  110. // classList.remove is not supported in IE11
  111. menuItem.className = menuItem.className.replace( 'off-canvas', '' );
  112. subMenu.className = subMenu.className.replace( 'expanded-true', '' );
  113. // Update aria-expanded and :focus states
  114. toggleAriaExpandedState( menuItemAria );
  115. // Or else close all sub-menus
  116. } else {
  117. // Update classes
  118. // classList.remove is not supported in IE11
  119. menuItem.className = menuItem.className.replace( 'off-canvas', '' );
  120. menuItem.lastElementChild.className = menuItem.lastElementChild.className.replace( 'expanded-true', '' );
  121. // Update aria-expanded and :focus states
  122. toggleAriaExpandedState( menuItemAria );
  123. }
  124. }
  125. /**
  126. * Find first ancestor of an element by selector
  127. *
  128. * @param {Object} child
  129. * @param {String} selector
  130. * @param {String} stopSelector
  131. */
  132. function getCurrentParent( child, selector, stopSelector ) {
  133. var currentParent = null;
  134. while ( child ) {
  135. if ( child.matches(selector) ) {
  136. currentParent = child;
  137. break;
  138. } else if ( stopSelector && child.matches(stopSelector) ) {
  139. break;
  140. }
  141. child = child.parentElement;
  142. }
  143. return currentParent;
  144. }
  145. /**
  146. * Remove all off-canvas states
  147. */
  148. function removeAllFocusStates() {
  149. 'use strict';
  150. var siteBranding = document.getElementsByClassName( 'site-branding' )[0];
  151. var getFocusedElements = siteBranding.querySelectorAll(':hover, :focus, :focus-within');
  152. var getFocusedClassElements = siteBranding.querySelectorAll('.is-focused');
  153. var i;
  154. var o;
  155. for ( i = 0; i < getFocusedElements.length; i++) {
  156. getFocusedElements[i].blur();
  157. }
  158. for ( o = 0; o < getFocusedClassElements.length; o++) {
  159. deleteClass( getFocusedClassElements[o], 'is-focused' );
  160. }
  161. }
  162. /**
  163. * Matches polyfill for IE11
  164. */
  165. if (!Element.prototype.matches) {
  166. Element.prototype.matches = Element.prototype.msMatchesSelector;
  167. }
  168. /**
  169. * Toggle `focus` class to allow sub-menu access on touch screens.
  170. */
  171. function toggleSubmenuDisplay() {
  172. document.addEventListener('touchstart', function(event) {
  173. if ( event.target.matches('a') ) {
  174. var url = event.target.getAttribute( 'href' ) ? event.target.getAttribute( 'href' ) : '';
  175. // Open submenu if url is #
  176. if ( '#' === url && event.target.nextSibling.matches('.submenu-expand') ) {
  177. openSubMenu( event.target );
  178. }
  179. }
  180. // Check if .submenu-expand is touched
  181. if ( event.target.matches('.submenu-expand') ) {
  182. openSubMenu(event.target);
  183. // Check if child of .submenu-expand is touched
  184. } else if ( null != getCurrentParent( event.target, '.submenu-expand' ) &&
  185. getCurrentParent( event.target, '.submenu-expand' ).matches( '.submenu-expand' ) ) {
  186. openSubMenu( getCurrentParent( event.target, '.submenu-expand' ) );
  187. // Check if .menu-item-link-return is touched
  188. } else if ( event.target.matches('.menu-item-link-return') ) {
  189. closeSubMenu( event.target );
  190. // Check if child of .menu-item-link-return is touched
  191. } else if ( null != getCurrentParent( event.target, '.menu-item-link-return' ) && getCurrentParent( event.target, '.menu-item-link-return' ).matches( '.menu-item-link-return' ) ) {
  192. closeSubMenu( event.target );
  193. }
  194. // Prevent default mouse/focus events
  195. removeAllFocusStates();
  196. }, false);
  197. document.addEventListener('touchend', function(event) {
  198. var mainNav = getCurrentParent( event.target, '.main-navigation' );
  199. if ( null != mainNav && hasClass( mainNav, '.main-navigation' ) ) {
  200. // Prevent default mouse events
  201. event.preventDefault();
  202. } else if (
  203. event.target.matches('.submenu-expand') ||
  204. null != getCurrentParent( event.target, '.submenu-expand' ) &&
  205. getCurrentParent( event.target, '.submenu-expand' ).matches( '.submenu-expand' ) ||
  206. event.target.matches('.menu-item-link-return') ||
  207. null != getCurrentParent( event.target, '.menu-item-link-return' ) &&
  208. getCurrentParent( event.target, '.menu-item-link-return' ).matches( '.menu-item-link-return' ) ) {
  209. // Prevent default mouse events
  210. event.preventDefault();
  211. }
  212. // Prevent default mouse/focus events
  213. removeAllFocusStates();
  214. }, false);
  215. document.addEventListener('focus', function(event) {
  216. if ( event.target.matches('.main-navigation > div > ul > li a') ) {
  217. // Remove Focused elements in sibling div
  218. var currentDiv = getCurrentParent( event.target, 'div', '.main-navigation' );
  219. var currentDivSibling = currentDiv.previousElementSibling === null ? currentDiv.nextElementSibling : currentDiv.previousElementSibling;
  220. var focusedElement = currentDivSibling.querySelector( '.is-focused' );
  221. var focusedClass = 'is-focused';
  222. var prevLi = getCurrentParent( event.target, '.main-navigation > div > ul > li', '.main-navigation' ).previousElementSibling;
  223. var nextLi = getCurrentParent( event.target, '.main-navigation > div > ul > li', '.main-navigation' ).nextElementSibling;
  224. if ( null !== focusedElement && null !== hasClass( focusedElement, focusedClass ) ) {
  225. deleteClass( focusedElement, focusedClass );
  226. }
  227. // Add .is-focused class to top-level li
  228. if ( getCurrentParent( event.target, '.main-navigation > div > ul > li', '.main-navigation' ) ) {
  229. addClass( getCurrentParent( event.target, '.main-navigation > div > ul > li', '.main-navigation' ), focusedClass );
  230. }
  231. // Check for previous li
  232. if ( prevLi && hasClass( prevLi, focusedClass ) ) {
  233. deleteClass( prevLi, focusedClass );
  234. }
  235. // Check for next li
  236. if ( nextLi && hasClass( nextLi, focusedClass ) ) {
  237. deleteClass( nextLi, focusedClass );
  238. }
  239. }
  240. }, true);
  241. document.addEventListener('click', function(event) {
  242. // Remove all focused menu states when clicking outside site branding
  243. if ( event.target !== document.getElementsByClassName( 'site-branding' )[0] ) {
  244. removeAllFocusStates();
  245. } else {
  246. // nothing
  247. }
  248. }, false);
  249. }
  250. /**
  251. * Run our sub-menu function as soon as the document is `ready`
  252. */
  253. document.addEventListener( 'DOMContentLoaded', function() {
  254. toggleSubmenuDisplay();
  255. });
  256. /**
  257. * Run our sub-menu function on selective refresh in the customizer
  258. */
  259. document.addEventListener( 'customize-preview-menu-refreshed', function( e, params ) {
  260. if ( 'menu-1' === params.wpNavMenuArgs.theme_location ) {
  261. toggleSubmenuDisplay();
  262. }
  263. });
  264. /**
  265. * Run our sub-menu function every time the window resizes
  266. */
  267. var isResizing = false;
  268. window.addEventListener( 'resize', function() {
  269. isResizing = true;
  270. debounce( function() {
  271. if ( isResizing ) {
  272. return;
  273. }
  274. toggleSubmenuDisplay();
  275. isResizing = false;
  276. }, 150 );
  277. } );
  278. })();