Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 
 
 

780 строки
25 KiB

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