foundation.dropdownMenu.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. 'use strict';
  2. import $ from 'jquery';
  3. import { Plugin } from './foundation.core.plugin';
  4. import { rtl as Rtl, ignoreMousedisappear } from './foundation.core.utils';
  5. import { Keyboard } from './foundation.util.keyboard';
  6. import { Nest } from './foundation.util.nest';
  7. import { Box } from './foundation.util.box';
  8. /**
  9. * DropdownMenu module.
  10. * @module foundation.dropdown-menu
  11. * @requires foundation.util.keyboard
  12. * @requires foundation.util.box
  13. * @requires foundation.util.nest
  14. */
  15. class DropdownMenu extends Plugin {
  16. /**
  17. * Creates a new instance of DropdownMenu.
  18. * @class
  19. * @name DropdownMenu
  20. * @fires DropdownMenu#init
  21. * @param {jQuery} element - jQuery object to make into a dropdown menu.
  22. * @param {Object} options - Overrides to the default plugin settings.
  23. */
  24. _setup(element, options) {
  25. this.$element = element;
  26. this.options = $.extend({}, DropdownMenu.defaults, this.$element.data(), options);
  27. this.className = 'DropdownMenu'; // ie9 back compat
  28. this._init();
  29. Keyboard.register('DropdownMenu', {
  30. 'ENTER': 'open',
  31. 'SPACE': 'open',
  32. 'ARROW_RIGHT': 'next',
  33. 'ARROW_UP': 'up',
  34. 'ARROW_DOWN': 'down',
  35. 'ARROW_LEFT': 'previous',
  36. 'ESCAPE': 'close'
  37. });
  38. }
  39. /**
  40. * Initializes the plugin, and calls _prepareMenu
  41. * @private
  42. * @function
  43. */
  44. _init() {
  45. Nest.Feather(this.$element, 'dropdown');
  46. var subs = this.$element.find('li.is-dropdown-submenu-parent');
  47. this.$element.children('.is-dropdown-submenu-parent').children('.is-dropdown-submenu').addClass('first-sub');
  48. this.$menuItems = this.$element.find('[role="menuitem"]');
  49. this.$tabs = this.$element.children('[role="menuitem"]');
  50. this.$tabs.find('ul.is-dropdown-submenu').addClass(this.options.verticalClass);
  51. if (this.options.alignment === 'auto') {
  52. if (this.$element.hasClass(this.options.rightClass) || Rtl() || this.$element.parents('.top-bar-right').is('*')) {
  53. this.options.alignment = 'right';
  54. subs.addClass('opens-left');
  55. } else {
  56. this.options.alignment = 'left';
  57. subs.addClass('opens-right');
  58. }
  59. } else {
  60. if (this.options.alignment === 'right') {
  61. subs.addClass('opens-left');
  62. } else {
  63. subs.addClass('opens-right');
  64. }
  65. }
  66. this.changed = false;
  67. this._events();
  68. };
  69. _isVertical() {
  70. return this.$tabs.css('display') === 'block' || this.$element.css('flex-direction') === 'column';
  71. }
  72. _isRtl() {
  73. return this.$element.hasClass('align-right') || (Rtl() && !this.$element.hasClass('align-left'));
  74. }
  75. /**
  76. * Adds event listeners to elements within the menu
  77. * @private
  78. * @function
  79. */
  80. _events() {
  81. var _this = this,
  82. hasTouch = 'ontouchstart' in window || (typeof window.ontouchstart !== 'undefined'),
  83. parClass = 'is-dropdown-submenu-parent';
  84. // used for onClick and in the keyboard handlers
  85. var handleClickFn = function(e) {
  86. var $elem = $(e.target).parentsUntil('ul', `.${parClass}`),
  87. hasSub = $elem.hasClass(parClass),
  88. hasClicked = $elem.attr('data-is-click') === 'true',
  89. $sub = $elem.children('.is-dropdown-submenu');
  90. if (hasSub) {
  91. if (hasClicked) {
  92. if (!_this.options.closeOnClick || (!_this.options.clickOpen && !hasTouch) || (_this.options.forceFollow && hasTouch)) { return; }
  93. else {
  94. e.stopImmediatePropagation();
  95. e.preventDefault();
  96. _this._hide($elem);
  97. }
  98. } else {
  99. e.preventDefault();
  100. e.stopImmediatePropagation();
  101. _this._show($sub);
  102. $elem.add($elem.parentsUntil(_this.$element, `.${parClass}`)).attr('data-is-click', true);
  103. }
  104. }
  105. };
  106. if (this.options.clickOpen || hasTouch) {
  107. this.$menuItems.on('click.zf.dropdownmenu touchstart.zf.dropdownmenu', handleClickFn);
  108. }
  109. // Handle Leaf element Clicks
  110. if(_this.options.closeOnClickInside){
  111. this.$menuItems.on('click.zf.dropdownmenu', function(e) {
  112. var $elem = $(this),
  113. hasSub = $elem.hasClass(parClass);
  114. if(!hasSub){
  115. _this._hide();
  116. }
  117. });
  118. }
  119. if (!this.options.disableHover) {
  120. this.$menuItems.on('mouseenter.zf.dropdownmenu', function (e) {
  121. var $elem = $(this),
  122. hasSub = $elem.hasClass(parClass);
  123. if (hasSub) {
  124. clearTimeout($elem.data('_delay'));
  125. $elem.data('_delay', setTimeout(function () {
  126. _this._show($elem.children('.is-dropdown-submenu'));
  127. }, _this.options.hoverDelay));
  128. }
  129. }).on('mouseleave.zf.dropdownMenu', ignoreMousedisappear(function (e) {
  130. var $elem = $(this),
  131. hasSub = $elem.hasClass(parClass);
  132. if (hasSub && _this.options.autoclose) {
  133. if ($elem.attr('data-is-click') === 'true' && _this.options.clickOpen) { return false; }
  134. clearTimeout($elem.data('_delay'));
  135. $elem.data('_delay', setTimeout(function () {
  136. _this._hide($elem);
  137. }, _this.options.closingTime));
  138. }
  139. }));
  140. }
  141. this.$menuItems.on('keydown.zf.dropdownmenu', function(e) {
  142. var $element = $(e.target).parentsUntil('ul', '[role="menuitem"]'),
  143. isTab = _this.$tabs.index($element) > -1,
  144. $elements = isTab ? _this.$tabs : $element.siblings('li').add($element),
  145. $prevElement,
  146. $nextElement;
  147. $elements.each(function(i) {
  148. if ($(this).is($element)) {
  149. $prevElement = $elements.eq(i-1);
  150. $nextElement = $elements.eq(i+1);
  151. return;
  152. }
  153. });
  154. var nextSibling = function() {
  155. $nextElement.children('a:first').focus();
  156. e.preventDefault();
  157. }, prevSibling = function() {
  158. $prevElement.children('a:first').focus();
  159. e.preventDefault();
  160. }, openSub = function() {
  161. var $sub = $element.children('ul.is-dropdown-submenu');
  162. if ($sub.length) {
  163. _this._show($sub);
  164. $element.find('li > a:first').focus();
  165. e.preventDefault();
  166. } else { return; }
  167. }, closeSub = function() {
  168. //if ($element.is(':first-child')) {
  169. var close = $element.parent('ul').parent('li');
  170. close.children('a:first').focus();
  171. _this._hide(close);
  172. e.preventDefault();
  173. //}
  174. };
  175. var functions = {
  176. open: openSub,
  177. close: function() {
  178. _this._hide(_this.$element);
  179. _this.$menuItems.eq(0).children('a').focus(); // focus to first element
  180. e.preventDefault();
  181. },
  182. handled: function() {
  183. e.stopImmediatePropagation();
  184. }
  185. };
  186. if (isTab) {
  187. if (_this._isVertical()) { // vertical menu
  188. if (_this._isRtl()) { // right aligned
  189. $.extend(functions, {
  190. down: nextSibling,
  191. up: prevSibling,
  192. next: closeSub,
  193. previous: openSub
  194. });
  195. } else { // left aligned
  196. $.extend(functions, {
  197. down: nextSibling,
  198. up: prevSibling,
  199. next: openSub,
  200. previous: closeSub
  201. });
  202. }
  203. } else { // horizontal menu
  204. if (_this._isRtl()) { // right aligned
  205. $.extend(functions, {
  206. next: prevSibling,
  207. previous: nextSibling,
  208. down: openSub,
  209. up: closeSub
  210. });
  211. } else { // left aligned
  212. $.extend(functions, {
  213. next: nextSibling,
  214. previous: prevSibling,
  215. down: openSub,
  216. up: closeSub
  217. });
  218. }
  219. }
  220. } else { // not tabs -> one sub
  221. if (_this._isRtl()) { // right aligned
  222. $.extend(functions, {
  223. next: closeSub,
  224. previous: openSub,
  225. down: nextSibling,
  226. up: prevSibling
  227. });
  228. } else { // left aligned
  229. $.extend(functions, {
  230. next: openSub,
  231. previous: closeSub,
  232. down: nextSibling,
  233. up: prevSibling
  234. });
  235. }
  236. }
  237. Keyboard.handleKey(e, 'DropdownMenu', functions);
  238. });
  239. }
  240. /**
  241. * Adds an event handler to the body to close any dropdowns on a click.
  242. * @function
  243. * @private
  244. */
  245. _addBodyHandler() {
  246. var $body = $(document.body),
  247. _this = this;
  248. $body.off('mouseup.zf.dropdownmenu touchend.zf.dropdownmenu')
  249. .on('mouseup.zf.dropdownmenu touchend.zf.dropdownmenu', function(e) {
  250. var $link = _this.$element.find(e.target);
  251. if ($link.length) { return; }
  252. _this._hide();
  253. $body.off('mouseup.zf.dropdownmenu touchend.zf.dropdownmenu');
  254. });
  255. }
  256. /**
  257. * Opens a dropdown pane, and checks for collisions first.
  258. * @param {jQuery} $sub - ul element that is a submenu to show
  259. * @function
  260. * @private
  261. * @fires Dropdownmenu#show
  262. */
  263. _show($sub) {
  264. var idx = this.$tabs.index(this.$tabs.filter(function(i, el) {
  265. return $(el).find($sub).length > 0;
  266. }));
  267. var $sibs = $sub.parent('li.is-dropdown-submenu-parent').siblings('li.is-dropdown-submenu-parent');
  268. this._hide($sibs, idx);
  269. $sub.css('visibility', 'hidden').addClass('js-dropdown-active')
  270. .parent('li.is-dropdown-submenu-parent').addClass('is-active');
  271. var clear = Box.ImNotTouchingYou($sub, null, true);
  272. if (!clear) {
  273. var oldClass = this.options.alignment === 'left' ? '-right' : '-left',
  274. $parentLi = $sub.parent('.is-dropdown-submenu-parent');
  275. $parentLi.removeClass(`opens${oldClass}`).addClass(`opens-${this.options.alignment}`);
  276. clear = Box.ImNotTouchingYou($sub, null, true);
  277. if (!clear) {
  278. $parentLi.removeClass(`opens-${this.options.alignment}`).addClass('opens-inner');
  279. }
  280. this.changed = true;
  281. }
  282. $sub.css('visibility', '');
  283. if (this.options.closeOnClick) { this._addBodyHandler(); }
  284. /**
  285. * Fires when the new dropdown pane is visible.
  286. * @event Dropdownmenu#show
  287. */
  288. this.$element.trigger('show.zf.dropdownmenu', [$sub]);
  289. }
  290. /**
  291. * Hides a single, currently open dropdown pane, if passed a parameter, otherwise, hides everything.
  292. * @function
  293. * @param {jQuery} $elem - element with a submenu to hide
  294. * @param {Number} idx - index of the $tabs collection to hide
  295. * @private
  296. */
  297. _hide($elem, idx) {
  298. var $toClose;
  299. if ($elem && $elem.length) {
  300. $toClose = $elem;
  301. } else if (typeof idx !== 'undefined') {
  302. $toClose = this.$tabs.not(function(i, el) {
  303. return i === idx;
  304. });
  305. }
  306. else {
  307. $toClose = this.$element;
  308. }
  309. var somethingToClose = $toClose.hasClass('is-active') || $toClose.find('.is-active').length > 0;
  310. if (somethingToClose) {
  311. $toClose.find('li.is-active').add($toClose).attr({
  312. 'data-is-click': false
  313. }).removeClass('is-active');
  314. $toClose.find('ul.js-dropdown-active').removeClass('js-dropdown-active');
  315. if (this.changed || $toClose.find('opens-inner').length) {
  316. var oldClass = this.options.alignment === 'left' ? 'right' : 'left';
  317. $toClose.find('li.is-dropdown-submenu-parent').add($toClose)
  318. .removeClass(`opens-inner opens-${this.options.alignment}`)
  319. .addClass(`opens-${oldClass}`);
  320. this.changed = false;
  321. }
  322. /**
  323. * Fires when the open menus are closed.
  324. * @event Dropdownmenu#hide
  325. */
  326. this.$element.trigger('hide.zf.dropdownmenu', [$toClose]);
  327. }
  328. }
  329. /**
  330. * Destroys the plugin.
  331. * @function
  332. */
  333. _destroy() {
  334. this.$menuItems.off('.zf.dropdownmenu').removeAttr('data-is-click')
  335. .removeClass('is-right-arrow is-left-arrow is-down-arrow opens-right opens-left opens-inner');
  336. $(document.body).off('.zf.dropdownmenu');
  337. Nest.Burn(this.$element, 'dropdown');
  338. }
  339. }
  340. /**
  341. * Default settings for plugin
  342. */
  343. DropdownMenu.defaults = {
  344. /**
  345. * Disallows hover events from opening submenus
  346. * @option
  347. * @type {boolean}
  348. * @default false
  349. */
  350. disableHover: false,
  351. /**
  352. * Allow a submenu to automatically close on a mouseleave event, if not clicked open.
  353. * @option
  354. * @type {boolean}
  355. * @default true
  356. */
  357. autoclose: true,
  358. /**
  359. * Amount of time to delay opening a submenu on hover event.
  360. * @option
  361. * @type {number}
  362. * @default 50
  363. */
  364. hoverDelay: 50,
  365. /**
  366. * Allow a submenu to open/remain open on parent click event. Allows cursor to move away from menu.
  367. * @option
  368. * @type {boolean}
  369. * @default false
  370. */
  371. clickOpen: false,
  372. /**
  373. * Amount of time to delay closing a submenu on a mouseleave event.
  374. * @option
  375. * @type {number}
  376. * @default 500
  377. */
  378. closingTime: 500,
  379. /**
  380. * Position of the menu relative to what direction the submenus should open. Handled by JS. Can be `'auto'`, `'left'` or `'right'`.
  381. * @option
  382. * @type {string}
  383. * @default 'auto'
  384. */
  385. alignment: 'auto',
  386. /**
  387. * Allow clicks on the body to close any open submenus.
  388. * @option
  389. * @type {boolean}
  390. * @default true
  391. */
  392. closeOnClick: true,
  393. /**
  394. * Allow clicks on leaf anchor links to close any open submenus.
  395. * @option
  396. * @type {boolean}
  397. * @default true
  398. */
  399. closeOnClickInside: true,
  400. /**
  401. * Class applied to vertical oriented menus, Foundation default is `vertical`. Update this if using your own class.
  402. * @option
  403. * @type {string}
  404. * @default 'vertical'
  405. */
  406. verticalClass: 'vertical',
  407. /**
  408. * Class applied to right-side oriented menus, Foundation default is `align-right`. Update this if using your own class.
  409. * @option
  410. * @type {string}
  411. * @default 'align-right'
  412. */
  413. rightClass: 'align-right',
  414. /**
  415. * Boolean to force overide the clicking of links to perform default action, on second touch event for mobile.
  416. * @option
  417. * @type {boolean}
  418. * @default true
  419. */
  420. forceFollow: true
  421. };
  422. export {DropdownMenu};