menu.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787
  1. /**
  2. * Copyright © Magento, Inc. All rights reserved.
  3. * See COPYING.txt for license details.
  4. */
  5. (function (factory) {
  6. 'use strict';
  7. if (typeof define === 'function' && define.amd) {
  8. define([
  9. 'jquery',
  10. 'jquery/ui'
  11. ], factory);
  12. } else {
  13. factory(jQuery);
  14. }
  15. }(function ($) {
  16. 'use strict';
  17. $.widget('mage.menu', {
  18. widgetEventPrefix: 'menu',
  19. version: '1.10.1',
  20. defaultElement: '<ul>',
  21. delay: 300,
  22. options: {
  23. icons: {
  24. submenu: 'ui-icon-carat-1-e'
  25. },
  26. menus: 'ul',
  27. position: {
  28. my: 'left top',
  29. at: 'right top'
  30. },
  31. role: 'menu',
  32. // callbacks
  33. blur: null,
  34. focus: null,
  35. select: null
  36. },
  37. /**
  38. * @private
  39. */
  40. _create: function () {
  41. this.activeMenu = this.element;
  42. // flag used to prevent firing of the click handler
  43. // as the event bubbles up through nested menus
  44. this.mouseHandled = false;
  45. this.element
  46. .uniqueId()
  47. .addClass('ui-menu ui-widget ui-widget-content ui-corner-all')
  48. .toggleClass('ui-menu-icons', !!this.element.find('.ui-icon').length)
  49. .attr({
  50. role: this.options.role,
  51. tabIndex: 0
  52. })
  53. // need to catch all clicks on disabled menu
  54. // not possible through _on
  55. .bind('click' + this.eventNamespace, $.proxy(function (event) {
  56. if (this.options.disabled) {
  57. event.preventDefault();
  58. }
  59. }, this));
  60. if (this.options.disabled) {
  61. this.element
  62. .addClass('ui-state-disabled')
  63. .attr('aria-disabled', 'true');
  64. }
  65. this._on({
  66. /**
  67. * Prevent focus from sticking to links inside menu after clicking
  68. * them (focus should always stay on UL during navigation).
  69. */
  70. 'mousedown .ui-menu-item > a': function (event) {
  71. event.preventDefault();
  72. },
  73. /**
  74. * Prevent focus from sticking to links inside menu after clicking
  75. * them (focus should always stay on UL during navigation).
  76. */
  77. 'click .ui-state-disabled > a': function (event) {
  78. event.preventDefault();
  79. },
  80. /**
  81. * @param {jQuery.Event} event
  82. */
  83. 'click .ui-menu-item:has(a)': function (event) {
  84. var target = $(event.target).closest('.ui-menu-item');
  85. if (!this.mouseHandled && target.not('.ui-state-disabled').length) {
  86. this.mouseHandled = true;
  87. this.select(event);
  88. // Open submenu on click
  89. if (target.has('.ui-menu').length) {
  90. this.expand(event);
  91. } else if (!this.element.is(':focus')) {
  92. // Redirect focus to the menu
  93. this.element.trigger('focus', [true]);
  94. // If the active item is on the top level, let it stay active.
  95. // Otherwise, blur the active item since it is no longer visible.
  96. if (this.active && this.active.parents('.ui-menu').length === 1) { //eslint-disable-line
  97. clearTimeout(this.timer);
  98. }
  99. }
  100. }
  101. },
  102. /**
  103. * @param {jQuery.Event} event
  104. */
  105. 'mouseenter .ui-menu-item': function (event) {
  106. var target = $(event.currentTarget);
  107. // Remove ui-state-active class from siblings of the newly focused menu item
  108. // to avoid a jump caused by adjacent elements both having a class with a border
  109. target.siblings().children('.ui-state-active').removeClass('ui-state-active');
  110. this.focus(event, target);
  111. },
  112. mouseleave: 'collapseAll',
  113. 'mouseleave .ui-menu': 'collapseAll',
  114. /**
  115. * @param {jQuery.Event} event
  116. * @param {*} keepActiveItem
  117. */
  118. focus: function (event, keepActiveItem) {
  119. // If there's already an active item, keep it active
  120. // If not, activate the first item
  121. var item = this.active || this.element.children('.ui-menu-item').eq(0);
  122. if (!keepActiveItem) {
  123. this.focus(event, item);
  124. }
  125. },
  126. /**
  127. * @param {jQuery.Event} event
  128. */
  129. blur: function (event) {
  130. this._delay(function () {
  131. if (!$.contains(this.element[0], this.document[0].activeElement)) {
  132. this.collapseAll(event);
  133. }
  134. });
  135. },
  136. keydown: '_keydown'
  137. });
  138. this.refresh();
  139. // Clicks outside of a menu collapse any open menus
  140. this._on(this.document, {
  141. /**
  142. * @param {jQuery.Event} event
  143. */
  144. click: function (event) {
  145. if (!$(event.target).closest('.ui-menu').length) {
  146. this.collapseAll(event);
  147. }
  148. // Reset the mouseHandled flag
  149. this.mouseHandled = false;
  150. }
  151. });
  152. },
  153. /**
  154. * @private
  155. */
  156. _destroy: function () {
  157. // Destroy (sub)menus
  158. this.element
  159. .removeAttr('aria-activedescendant')
  160. .find('.ui-menu').addBack()
  161. .removeClass('ui-menu ui-widget ui-widget-content ui-corner-all ui-menu-icons')
  162. .removeAttr('role')
  163. .removeAttr('tabIndex')
  164. .removeAttr('aria-labelledby')
  165. .removeAttr('aria-expanded')
  166. .removeAttr('aria-hidden')
  167. .removeAttr('aria-disabled')
  168. .removeUniqueId()
  169. .show();
  170. // Destroy menu items
  171. this.element.find('.ui-menu-item')
  172. .removeClass('ui-menu-item')
  173. .removeAttr('role')
  174. .removeAttr('aria-disabled')
  175. .children('a')
  176. .removeUniqueId()
  177. .removeClass('ui-corner-all ui-state-hover')
  178. .removeAttr('tabIndex')
  179. .removeAttr('role')
  180. .removeAttr('aria-haspopup')
  181. .children().each(function () {
  182. var elem = $(this);
  183. if (elem.data('ui-menu-submenu-carat')) {
  184. elem.remove();
  185. }
  186. });
  187. // Destroy menu dividers
  188. this.element.find('.ui-menu-divider').removeClass('ui-menu-divider ui-widget-content');
  189. },
  190. /**
  191. * @param {jQuery.Event} event
  192. * @private
  193. */
  194. _keydown: function (event) {
  195. var match, prev, character, skip, regex,
  196. preventDefault = true;
  197. /**
  198. * @param {String} value
  199. */
  200. function escape(value) {
  201. return value.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&');
  202. }
  203. switch (event.keyCode) {
  204. case $.ui.keyCode.PAGE_UP:
  205. this.previousPage(event);
  206. break;
  207. case $.ui.keyCode.PAGE_DOWN:
  208. this.nextPage(event);
  209. break;
  210. case $.ui.keyCode.HOME:
  211. this._move('first', 'first', event);
  212. break;
  213. case $.ui.keyCode.END:
  214. this._move('last', 'last', event);
  215. break;
  216. case $.ui.keyCode.UP:
  217. this.previous(event);
  218. break;
  219. case $.ui.keyCode.DOWN:
  220. this.next(event);
  221. break;
  222. case $.ui.keyCode.LEFT:
  223. this.collapse(event);
  224. break;
  225. case $.ui.keyCode.RIGHT:
  226. if (this.active && !this.active.is('.ui-state-disabled')) {
  227. this.expand(event);
  228. }
  229. break;
  230. case $.ui.keyCode.ENTER:
  231. case $.ui.keyCode.SPACE:
  232. this._activate(event);
  233. break;
  234. case $.ui.keyCode.ESCAPE:
  235. this.collapse(event);
  236. break;
  237. default:
  238. preventDefault = false;
  239. prev = this.previousFilter || '';
  240. character = String.fromCharCode(event.keyCode);
  241. skip = false;
  242. clearTimeout(this.filterTimer);
  243. if (character === prev) {
  244. skip = true;
  245. } else {
  246. character = prev + character;
  247. }
  248. regex = new RegExp('^' + escape(character), 'i');
  249. match = this.activeMenu.children('.ui-menu-item').filter(function () {
  250. return regex.test($(this).children('a').text());
  251. });
  252. match = skip && match.index(this.active.next()) !== -1 ?
  253. this.active.nextAll('.ui-menu-item') :
  254. match;
  255. // If no matches on the current filter, reset to the last character pressed
  256. // to move down the menu to the first item that starts with that character
  257. if (!match.length) {
  258. character = String.fromCharCode(event.keyCode);
  259. regex = new RegExp('^' + escape(character), 'i');
  260. match = this.activeMenu.children('.ui-menu-item').filter(function () {
  261. return regex.test($(this).children('a').text());
  262. });
  263. }
  264. if (match.length) {
  265. this.focus(event, match);
  266. if (match.length > 1) { //eslint-disable-line max-depth
  267. this.previousFilter = character;
  268. this.filterTimer = this._delay(function () {
  269. delete this.previousFilter;
  270. }, 1000);
  271. } else {
  272. delete this.previousFilter;
  273. }
  274. } else {
  275. delete this.previousFilter;
  276. }
  277. }
  278. if (preventDefault) {
  279. event.preventDefault();
  280. }
  281. },
  282. /**
  283. * @param {jQuery.Event} event
  284. * @private
  285. */
  286. _activate: function (event) {
  287. if (!this.active.is('.ui-state-disabled')) {
  288. if (this.active.children('a[aria-haspopup="true"]').length) {
  289. this.expand(event);
  290. } else {
  291. this.select(event);
  292. }
  293. }
  294. },
  295. /**
  296. * Refresh.
  297. */
  298. refresh: function () {
  299. var menus,
  300. icon = this.options.icons.submenu,
  301. submenus = this.element.find(this.options.menus);
  302. // Initialize nested menus
  303. submenus.filter(':not(.ui-menu)')
  304. .addClass('ui-menu ui-widget ui-widget-content ui-corner-all')
  305. .hide()
  306. .attr({
  307. role: this.options.role,
  308. 'aria-hidden': 'true',
  309. 'aria-expanded': 'false'
  310. })
  311. .each(function () {
  312. var menu = $(this),
  313. item = menu.prev('a'),
  314. submenuCarat = $('<span>')
  315. .addClass('ui-menu-icon ui-icon ' + icon)
  316. .data('ui-menu-submenu-carat', true);
  317. item
  318. .attr('aria-haspopup', 'true')
  319. .prepend(submenuCarat);
  320. menu.attr('aria-labelledby', item.attr('id'));
  321. });
  322. menus = submenus.add(this.element);
  323. // Don't refresh list items that are already adapted
  324. menus.children(':not(.ui-menu-item):has(a)')
  325. .addClass('ui-menu-item')
  326. .attr('role', 'presentation')
  327. .children('a')
  328. .uniqueId()
  329. .addClass('ui-corner-all')
  330. .attr({
  331. tabIndex: -1,
  332. role: this._itemRole()
  333. });
  334. // Initialize unlinked menu-items containing spaces and/or dashes only as dividers
  335. menus.children(':not(.ui-menu-item)').each(function () {
  336. var item = $(this);
  337. // hyphen, em dash, en dash
  338. if (!/[^\-\u2014\u2013\s]/.test(item.text())) {
  339. item.addClass('ui-widget-content ui-menu-divider');
  340. }
  341. });
  342. // Add aria-disabled attribute to any disabled menu item
  343. menus.children('.ui-state-disabled').attr('aria-disabled', 'true');
  344. // If the active item has been removed, blur the menu
  345. if (this.active && !$.contains(this.element[0], this.active[0])) {
  346. this.blur();
  347. }
  348. },
  349. /**
  350. * @return {*}
  351. * @private
  352. */
  353. _itemRole: function () {
  354. return {
  355. menu: 'menuitem',
  356. listbox: 'option'
  357. }[this.options.role];
  358. },
  359. /**
  360. * @param {String} key
  361. * @param {*} value
  362. * @private
  363. */
  364. _setOption: function (key, value) {
  365. if (key === 'icons') {
  366. this.element.find('.ui-menu-icon')
  367. .removeClass(this.options.icons.submenu)
  368. .addClass(value.submenu);
  369. }
  370. this._super(key, value);
  371. },
  372. /**
  373. * @param {jQuery.Event} event
  374. * @param {Object} item
  375. */
  376. focus: function (event, item) {
  377. var nested, focused;
  378. this.blur(event, event && event.type === 'focus');
  379. this._scrollIntoView(item);
  380. this.active = item.first();
  381. focused = this.active.children('a').addClass('ui-state-focus');
  382. // Only update aria-activedescendant if there's a role
  383. // otherwise we assume focus is managed elsewhere
  384. if (this.options.role) {
  385. this.element.attr('aria-activedescendant', focused.attr('id'));
  386. }
  387. // Highlight active parent menu item, if any
  388. this.active
  389. .parent()
  390. .closest('.ui-menu-item')
  391. .children('a:first')
  392. .addClass('ui-state-active');
  393. if (event && event.type === 'keydown') {
  394. this._close();
  395. } else {
  396. this.timer = this._delay(function () {
  397. this._close();
  398. }, this.delay);
  399. }
  400. nested = item.children('.ui-menu');
  401. if (nested.length && /^mouse/.test(event.type)) {
  402. this._startOpening(nested);
  403. }
  404. this.activeMenu = item.parent();
  405. this._trigger('focus', event, {
  406. item: item
  407. });
  408. },
  409. /**
  410. * @param {Object} item
  411. * @private
  412. */
  413. _scrollIntoView: function (item) {
  414. var borderTop, paddingTop, offset, scroll, elementHeight, itemHeight;
  415. if (this._hasScroll()) {
  416. borderTop = parseFloat($.css(this.activeMenu[0], 'borderTopWidth')) || 0;
  417. paddingTop = parseFloat($.css(this.activeMenu[0], 'paddingTop')) || 0;
  418. offset = item.offset().top - this.activeMenu.offset().top - borderTop - paddingTop;
  419. scroll = this.activeMenu.scrollTop();
  420. elementHeight = this.activeMenu.height();
  421. itemHeight = item.height();
  422. if (offset < 0) {
  423. this.activeMenu.scrollTop(scroll + offset);
  424. } else if (offset + itemHeight > elementHeight) {
  425. this.activeMenu.scrollTop(scroll + offset - elementHeight + itemHeight);
  426. }
  427. }
  428. },
  429. /**
  430. * @param {jQuery.Event} event
  431. * @param {*} fromFocus
  432. */
  433. blur: function (event, fromFocus) {
  434. if (!fromFocus) {
  435. clearTimeout(this.timer);
  436. }
  437. if (!this.active) {
  438. return;
  439. }
  440. this.active.children('a').removeClass('ui-state-focus');
  441. this.active = null;
  442. this._trigger('blur', event, {
  443. item: this.active
  444. });
  445. },
  446. /**
  447. * @param {*} submenu
  448. * @private
  449. */
  450. _startOpening: function (submenu) {
  451. clearTimeout(this.timer);
  452. // Don't open if already open fixes a Firefox bug that caused a .5 pixel
  453. // shift in the submenu position when mousing over the carat icon
  454. if (submenu.attr('aria-hidden') !== 'true') {
  455. return;
  456. }
  457. this.timer = this._delay(function () {
  458. this._close();
  459. this._open(submenu);
  460. }, this.delay);
  461. },
  462. /**
  463. * @param {*} submenu
  464. * @private
  465. */
  466. _open: function (submenu) {
  467. var position = $.extend({
  468. of: this.active
  469. }, this.options.position);
  470. clearTimeout(this.timer);
  471. this.element.find('.ui-menu').not(submenu.parents('.ui-menu'))
  472. .hide()
  473. .attr('aria-hidden', 'true');
  474. submenu
  475. .show()
  476. .removeAttr('aria-hidden')
  477. .attr('aria-expanded', 'true')
  478. .position(position);
  479. },
  480. /**
  481. * @param {jQuery.Event} event
  482. * @param {*} all
  483. */
  484. collapseAll: function (event, all) {
  485. clearTimeout(this.timer);
  486. this.timer = this._delay(function () {
  487. // If we were passed an event, look for the submenu that contains the event
  488. var currentMenu = all ? this.element :
  489. $(event && event.target).closest(this.element.find('.ui-menu'));
  490. // If we found no valid submenu ancestor, use the main menu to close all sub menus anyway
  491. if (!currentMenu.length) {
  492. currentMenu = this.element;
  493. }
  494. this._close(currentMenu);
  495. this.blur(event);
  496. this.activeMenu = currentMenu;
  497. }, this.delay);
  498. },
  499. // With no arguments, closes the currently active menu - if nothing is active
  500. // it closes all menus. If passed an argument, it will search for menus BELOW
  501. /**
  502. * With no arguments, closes the currently active menu - if nothing is active
  503. * it closes all menus. If passed an argument, it will search for menus BELOW.
  504. *
  505. * @param {*} startMenu
  506. * @private
  507. */
  508. _close: function (startMenu) {
  509. if (!startMenu) {
  510. startMenu = this.active ? this.active.parent() : this.element;
  511. }
  512. startMenu
  513. .find('.ui-menu')
  514. .hide()
  515. .attr('aria-hidden', 'true')
  516. .attr('aria-expanded', 'false')
  517. .end()
  518. .find('a.ui-state-active')
  519. .removeClass('ui-state-active');
  520. },
  521. /**
  522. * @param {jQuery.Event} event
  523. */
  524. collapse: function (event) {
  525. var newItem = this.active &&
  526. this.active.parent().closest('.ui-menu-item', this.element);
  527. if (newItem && newItem.length) {
  528. this._close();
  529. this.focus(event, newItem);
  530. }
  531. },
  532. /**
  533. * @param {jQuery.Event} event
  534. */
  535. expand: function (event) {
  536. var newItem = this.active &&
  537. this.active
  538. .children('.ui-menu ')
  539. .children('.ui-menu-item')
  540. .first();
  541. if (newItem && newItem.length) {
  542. this._open(newItem.parent());
  543. // Delay so Firefox will not hide activedescendant change in expanding submenu from AT
  544. this._delay(function () {
  545. this.focus(event, newItem);
  546. });
  547. }
  548. },
  549. /**
  550. * @param {jQuery.Event} event
  551. */
  552. next: function (event) {
  553. this._move('next', 'first', event);
  554. },
  555. /**
  556. * @param {jQuery.Event} event
  557. */
  558. previous: function (event) {
  559. this._move('prev', 'last', event);
  560. },
  561. /**
  562. * @return {null|Boolean}
  563. */
  564. isFirstItem: function () {
  565. return this.active && !this.active.prevAll('.ui-menu-item').length;
  566. },
  567. /**
  568. * @return {null|Boolean}
  569. */
  570. isLastItem: function () {
  571. return this.active && !this.active.nextAll('.ui-menu-item').length;
  572. },
  573. /**
  574. * @param {*} direction
  575. * @param {*} filter
  576. * @param {jQuery.Event} event
  577. * @private
  578. */
  579. _move: function (direction, filter, event) {
  580. var next;
  581. if (this.active) {
  582. if (direction === 'first' || direction === 'last') {
  583. next = this.active
  584. [direction === 'first' ? 'prevAll' : 'nextAll']('.ui-menu-item')
  585. .eq(-1);
  586. } else {
  587. next = this.active
  588. [direction + 'All']('.ui-menu-item')
  589. .eq(0);
  590. }
  591. }
  592. if (!next || !next.length || !this.active) {
  593. next = this.activeMenu.children('.ui-menu-item')[filter]();
  594. }
  595. this.focus(event, next);
  596. },
  597. /**
  598. * @param {jQuery.Event} event
  599. */
  600. nextPage: function (event) {
  601. var item, base, height;
  602. if (!this.active) {
  603. this.next(event);
  604. return;
  605. }
  606. if (this.isLastItem()) {
  607. return;
  608. }
  609. if (this._hasScroll()) {
  610. base = this.active.offset().top;
  611. height = this.element.height();
  612. this.active.nextAll('.ui-menu-item').each(function () {
  613. item = $(this);
  614. return item.offset().top - base - height < 0;
  615. });
  616. this.focus(event, item);
  617. } else {
  618. this.focus(event, this.activeMenu.children('.ui-menu-item')
  619. [!this.active ? 'first' : 'last']());
  620. }
  621. },
  622. /**
  623. * @param {jQuery.Event} event
  624. */
  625. previousPage: function (event) {
  626. var item, base, height;
  627. if (!this.active) {
  628. this.next(event);
  629. return;
  630. }
  631. if (this.isFirstItem()) {
  632. return;
  633. }
  634. if (this._hasScroll()) {
  635. base = this.active.offset().top;
  636. height = this.element.height();
  637. this.active.prevAll('.ui-menu-item').each(function () {
  638. item = $(this);
  639. return item.offset().top - base + height > 0;
  640. });
  641. this.focus(event, item);
  642. } else {
  643. this.focus(event, this.activeMenu.children('.ui-menu-item').first());
  644. }
  645. },
  646. /**
  647. * @return {Boolean}
  648. * @private
  649. */
  650. _hasScroll: function () {
  651. return this.element.outerHeight() < this.element.prop('scrollHeight');
  652. },
  653. /**
  654. * @param {jQuery.Event} event
  655. */
  656. select: function (event) {
  657. // TODO: It should never be possible to not have an active item at this
  658. // point, but the tests don't trigger mouseenter before click.
  659. var ui;
  660. this.active = this.active || $(event.target).closest('.ui-menu-item');
  661. ui = {
  662. item: this.active
  663. };
  664. if (!this.active.has('.ui-menu').length) {
  665. this.collapseAll(event, true);
  666. }
  667. this._trigger('select', event, ui);
  668. }
  669. });
  670. return $.mage.menu;
  671. }));