menu.js 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129
  1. /*!
  2. * AngularJS Material Design
  3. * https://github.com/angular/material
  4. * @license MIT
  5. * v1.1.8-master-aba7b2b
  6. */
  7. goog.provide('ngmaterial.components.menu');
  8. goog.require('ngmaterial.components.backdrop');
  9. goog.require('ngmaterial.core');
  10. /**
  11. * @ngdoc module
  12. * @name material.components.menu
  13. */
  14. angular.module('material.components.menu', [
  15. 'material.core',
  16. 'material.components.backdrop'
  17. ]);
  18. MenuController['$inject'] = ["$mdMenu", "$attrs", "$element", "$scope", "$mdUtil", "$timeout", "$rootScope", "$q", "$log"];
  19. angular
  20. .module('material.components.menu')
  21. .controller('mdMenuCtrl', MenuController);
  22. /**
  23. * ngInject
  24. */
  25. function MenuController($mdMenu, $attrs, $element, $scope, $mdUtil, $timeout, $rootScope, $q, $log) {
  26. var prefixer = $mdUtil.prefixer();
  27. var menuContainer;
  28. var self = this;
  29. var triggerElement;
  30. this.nestLevel = parseInt($attrs.mdNestLevel, 10) || 0;
  31. /**
  32. * Called by our linking fn to provide access to the menu-content
  33. * element removed during link
  34. */
  35. this.init = function init(setMenuContainer, opts) {
  36. opts = opts || {};
  37. menuContainer = setMenuContainer;
  38. // Default element for ARIA attributes has the ngClick or ngMouseenter expression
  39. triggerElement = $element[0].querySelector(prefixer.buildSelector(['ng-click', 'ng-mouseenter']));
  40. triggerElement.setAttribute('aria-expanded', 'false');
  41. this.isInMenuBar = opts.isInMenuBar;
  42. this.nestedMenus = $mdUtil.nodesToArray(menuContainer[0].querySelectorAll('.md-nested-menu'));
  43. menuContainer.on('$mdInterimElementRemove', function() {
  44. self.isOpen = false;
  45. $mdUtil.nextTick(function(){ self.onIsOpenChanged(self.isOpen);});
  46. });
  47. $mdUtil.nextTick(function(){ self.onIsOpenChanged(self.isOpen);});
  48. var menuContainerId = 'menu_container_' + $mdUtil.nextUid();
  49. menuContainer.attr('id', menuContainerId);
  50. angular.element(triggerElement).attr({
  51. 'aria-owns': menuContainerId,
  52. 'aria-haspopup': 'true'
  53. });
  54. $scope.$on('$destroy', angular.bind(this, function() {
  55. this.disableHoverListener();
  56. $mdMenu.destroy();
  57. }));
  58. menuContainer.on('$destroy', function() {
  59. $mdMenu.destroy();
  60. });
  61. };
  62. var openMenuTimeout, menuItems, deregisterScopeListeners = [];
  63. this.enableHoverListener = function() {
  64. deregisterScopeListeners.push($rootScope.$on('$mdMenuOpen', function(event, el) {
  65. if (menuContainer[0].contains(el[0])) {
  66. self.currentlyOpenMenu = el.controller('mdMenu');
  67. self.isAlreadyOpening = false;
  68. self.currentlyOpenMenu.registerContainerProxy(self.triggerContainerProxy.bind(self));
  69. }
  70. }));
  71. deregisterScopeListeners.push($rootScope.$on('$mdMenuClose', function(event, el) {
  72. if (menuContainer[0].contains(el[0])) {
  73. self.currentlyOpenMenu = undefined;
  74. }
  75. }));
  76. menuItems = angular.element($mdUtil.nodesToArray(menuContainer[0].children[0].children));
  77. menuItems.on('mouseenter', self.handleMenuItemHover);
  78. menuItems.on('mouseleave', self.handleMenuItemMouseLeave);
  79. };
  80. this.disableHoverListener = function() {
  81. while (deregisterScopeListeners.length) {
  82. deregisterScopeListeners.shift()();
  83. }
  84. menuItems && menuItems.off('mouseenter', self.handleMenuItemHover);
  85. menuItems && menuItems.off('mouseleave', self.handleMenuItemMouseLeave);
  86. };
  87. this.handleMenuItemHover = function(event) {
  88. if (self.isAlreadyOpening) return;
  89. var nestedMenu = (
  90. event.target.querySelector('md-menu')
  91. || $mdUtil.getClosest(event.target, 'MD-MENU')
  92. );
  93. openMenuTimeout = $timeout(function() {
  94. if (nestedMenu) {
  95. nestedMenu = angular.element(nestedMenu).controller('mdMenu');
  96. }
  97. if (self.currentlyOpenMenu && self.currentlyOpenMenu != nestedMenu) {
  98. var closeTo = self.nestLevel + 1;
  99. self.currentlyOpenMenu.close(true, { closeTo: closeTo });
  100. self.isAlreadyOpening = !!nestedMenu;
  101. nestedMenu && nestedMenu.open();
  102. } else if (nestedMenu && !nestedMenu.isOpen && nestedMenu.open) {
  103. self.isAlreadyOpening = !!nestedMenu;
  104. nestedMenu && nestedMenu.open();
  105. }
  106. }, nestedMenu ? 100 : 250);
  107. var focusableTarget = event.currentTarget.querySelector('.md-button:not([disabled])');
  108. focusableTarget && focusableTarget.focus();
  109. };
  110. this.handleMenuItemMouseLeave = function() {
  111. if (openMenuTimeout) {
  112. $timeout.cancel(openMenuTimeout);
  113. openMenuTimeout = undefined;
  114. }
  115. };
  116. /**
  117. * Uses the $mdMenu interim element service to open the menu contents
  118. */
  119. this.open = function openMenu(ev) {
  120. ev && ev.stopPropagation();
  121. ev && ev.preventDefault();
  122. if (self.isOpen) return;
  123. self.enableHoverListener();
  124. self.isOpen = true;
  125. $mdUtil.nextTick(function(){ self.onIsOpenChanged(self.isOpen);});
  126. triggerElement = triggerElement || (ev ? ev.target : $element[0]);
  127. triggerElement.setAttribute('aria-expanded', 'true');
  128. $scope.$emit('$mdMenuOpen', $element);
  129. $mdMenu.show({
  130. scope: $scope,
  131. mdMenuCtrl: self,
  132. nestLevel: self.nestLevel,
  133. element: menuContainer,
  134. target: triggerElement,
  135. preserveElement: true,
  136. parent: 'body'
  137. }).finally(function() {
  138. triggerElement.setAttribute('aria-expanded', 'false');
  139. self.disableHoverListener();
  140. });
  141. };
  142. this.onIsOpenChanged = function(isOpen) {
  143. if (isOpen) {
  144. menuContainer.attr('aria-hidden', 'false');
  145. $element[0].classList.add('md-open');
  146. angular.forEach(self.nestedMenus, function(el) {
  147. el.classList.remove('md-open');
  148. });
  149. } else {
  150. menuContainer.attr('aria-hidden', 'true');
  151. $element[0].classList.remove('md-open');
  152. }
  153. $scope.$mdMenuIsOpen = self.isOpen;
  154. };
  155. this.focusMenuContainer = function focusMenuContainer() {
  156. var focusTarget = menuContainer[0]
  157. .querySelector(prefixer.buildSelector(['md-menu-focus-target', 'md-autofocus']));
  158. if (!focusTarget) focusTarget = menuContainer[0].querySelector('.md-button:not([disabled])');
  159. focusTarget.focus();
  160. };
  161. this.registerContainerProxy = function registerContainerProxy(handler) {
  162. this.containerProxy = handler;
  163. };
  164. this.triggerContainerProxy = function triggerContainerProxy(ev) {
  165. this.containerProxy && this.containerProxy(ev);
  166. };
  167. this.destroy = function() {
  168. return self.isOpen ? $mdMenu.destroy() : $q.when(false);
  169. };
  170. // Use the $mdMenu interim element service to close the menu contents
  171. this.close = function closeMenu(skipFocus, closeOpts) {
  172. if ( !self.isOpen ) return;
  173. self.isOpen = false;
  174. $mdUtil.nextTick(function(){ self.onIsOpenChanged(self.isOpen);});
  175. var eventDetails = angular.extend({}, closeOpts, { skipFocus: skipFocus });
  176. $scope.$emit('$mdMenuClose', $element, eventDetails);
  177. $mdMenu.hide(null, closeOpts);
  178. if (!skipFocus) {
  179. var el = self.restoreFocusTo || $element.find('button')[0];
  180. if (el instanceof angular.element) el = el[0];
  181. if (el) el.focus();
  182. }
  183. };
  184. /**
  185. * Build a nice object out of our string attribute which specifies the
  186. * target mode for left and top positioning
  187. */
  188. this.positionMode = function positionMode() {
  189. var attachment = ($attrs.mdPositionMode || 'target').split(' ');
  190. // If attachment is a single item, duplicate it for our second value.
  191. // ie. 'target' -> 'target target'
  192. if (attachment.length === 1) {
  193. attachment.push(attachment[0]);
  194. }
  195. return {
  196. left: attachment[0],
  197. top: attachment[1]
  198. };
  199. };
  200. /**
  201. * Build a nice object out of our string attribute which specifies
  202. * the offset of top and left in pixels.
  203. */
  204. this.offsets = function offsets() {
  205. var position = ($attrs.mdOffset || '0 0').split(' ').map(parseFloat);
  206. if (position.length === 2) {
  207. return {
  208. left: position[0],
  209. top: position[1]
  210. };
  211. } else if (position.length === 1) {
  212. return {
  213. top: position[0],
  214. left: position[0]
  215. };
  216. } else {
  217. throw Error('Invalid offsets specified. Please follow format <x, y> or <n>');
  218. }
  219. };
  220. // Functionality that is exposed in the view.
  221. $scope.$mdMenu = {
  222. open: this.open,
  223. close: this.close
  224. };
  225. // Deprecated APIs
  226. $scope.$mdOpenMenu = angular.bind(this, function() {
  227. $log.warn('mdMenu: The $mdOpenMenu method is deprecated. Please use `$mdMenu.open`.');
  228. return this.open.apply(this, arguments);
  229. });
  230. }
  231. /**
  232. * @ngdoc directive
  233. * @name mdMenu
  234. * @module material.components.menu
  235. * @restrict E
  236. * @description
  237. *
  238. * Menus are elements that open when clicked. They are useful for displaying
  239. * additional options within the context of an action.
  240. *
  241. * Every `md-menu` must specify exactly two child elements. The first element is what is
  242. * left in the DOM and is used to open the menu. This element is called the trigger element.
  243. * The trigger element's scope has access to `$mdMenu.open($event)`
  244. * which it may call to open the menu. By passing $event as argument, the
  245. * corresponding event is stopped from propagating up the DOM-tree. Similarly, `$mdMenu.close()`
  246. * can be used to close the menu.
  247. *
  248. * The second element is the `md-menu-content` element which represents the
  249. * contents of the menu when it is open. Typically this will contain `md-menu-item`s,
  250. * but you can do custom content as well.
  251. *
  252. * <hljs lang="html">
  253. * <md-menu>
  254. * <!-- Trigger element is a md-button with an icon -->
  255. * <md-button ng-click="$mdMenu.open($event)" class="md-icon-button" aria-label="Open sample menu">
  256. * <md-icon md-svg-icon="call:phone"></md-icon>
  257. * </md-button>
  258. * <md-menu-content>
  259. * <md-menu-item><md-button ng-click="doSomething()">Do Something</md-button></md-menu-item>
  260. * </md-menu-content>
  261. * </md-menu>
  262. * </hljs>
  263. * ## Sizing Menus
  264. *
  265. * The width of the menu when it is open may be specified by specifying a `width`
  266. * attribute on the `md-menu-content` element.
  267. * See the [Material Design Spec](https://material.io/guidelines/components/menus.html#menus-simple-menus)
  268. * for more information.
  269. *
  270. *
  271. * ## Aligning Menus
  272. *
  273. * When a menu opens, it is important that the content aligns with the trigger element.
  274. * Failure to align menus can result in jarring experiences for users as content
  275. * suddenly shifts. To help with this, `md-menu` provides several APIs to help
  276. * with alignment.
  277. *
  278. * ### Target Mode
  279. *
  280. * By default, `md-menu` will attempt to align the `md-menu-content` by aligning
  281. * designated child elements in both the trigger and the menu content.
  282. *
  283. * To specify the alignment element in the `trigger` you can use the `md-menu-origin`
  284. * attribute on a child element. If no `md-menu-origin` is specified, the `md-menu`
  285. * will be used as the origin element.
  286. *
  287. * Similarly, the `md-menu-content` may specify a `md-menu-align-target` for a
  288. * `md-menu-item` to specify the node that it should try and align with.
  289. *
  290. * In this example code, we specify an icon to be our origin element, and an
  291. * icon in our menu content to be our alignment target. This ensures that both
  292. * icons are aligned when the menu opens.
  293. *
  294. * <hljs lang="html">
  295. * <md-menu>
  296. * <md-button ng-click="$mdMenu.open($event)" class="md-icon-button" aria-label="Open some menu">
  297. * <md-icon md-menu-origin md-svg-icon="call:phone"></md-icon>
  298. * </md-button>
  299. * <md-menu-content>
  300. * <md-menu-item>
  301. * <md-button ng-click="doSomething()" aria-label="Do something">
  302. * <md-icon md-menu-align-target md-svg-icon="call:phone"></md-icon>
  303. * Do Something
  304. * </md-button>
  305. * </md-menu-item>
  306. * </md-menu-content>
  307. * </md-menu>
  308. * </hljs>
  309. *
  310. * ### Position Mode
  311. *
  312. * We can specify the origin of the menu by using the `md-position-mode` attribute.
  313. * This attribute allows specifying the positioning by the `x` and `y` axes.
  314. *
  315. * The default mode is `target target`. This mode uses the left and top edges of the origin element
  316. * to position the menu in LTR layouts. The `x` axis modes will adjust when in RTL layouts.
  317. *
  318. * Sometimes you want to specify alignment from the right side of a origin element. For example,
  319. * if we have a menu on the right side a toolbar, we may want to right align our menu content.
  320. * We can use `target-right target` to specify a right-oriented alignment target.
  321. * There is a working example of this in the Menu Position Modes demo.
  322. *
  323. * #### Horizontal Positioning Options
  324. * - `target`
  325. * - `target-left`
  326. * - `target-right`
  327. * - `cascade`
  328. * - `right`
  329. * - `left`
  330. *
  331. * #### Vertical Positioning Options
  332. * - `target`
  333. * - `cascade`
  334. * - `bottom`
  335. *
  336. * ### Menu Offsets
  337. *
  338. * It is sometimes unavoidable to need to have a deeper level of control for
  339. * the positioning of a menu to ensure perfect alignment. `md-menu` provides
  340. * the `md-offset` attribute to allow pixel-level specificity when adjusting
  341. * menu positioning.
  342. *
  343. * This offset is provided in the format of `x y` or `n` where `n` will be used
  344. * in both the `x` and `y` axis.
  345. * For example, to move a menu by `2px` down from the top, we can use:
  346. *
  347. * <hljs lang="html">
  348. * <md-menu md-offset="0 2">
  349. * <!-- menu-content -->
  350. * </md-menu>
  351. * </hljs>
  352. *
  353. * Specifying `md-offset="2 2"` would shift the menu two pixels down and two pixels to the right.
  354. *
  355. * ### Auto Focus
  356. * By default, when a menu opens, `md-menu` focuses the first button in the menu content.
  357. *
  358. * Sometimes you would like to focus another menu item instead of the first.<br/>
  359. * This can be done by applying the `md-autofocus` directive on the given element.
  360. *
  361. * <hljs lang="html">
  362. * <md-menu-item>
  363. * <md-button md-autofocus ng-click="doSomething()">
  364. * Auto Focus
  365. * </md-button>
  366. * </md-menu-item>
  367. * </hljs>
  368. *
  369. *
  370. * ### Preventing close
  371. *
  372. * Sometimes you would like to be able to click on a menu item without having the menu
  373. * close. To do this, AngularJS Material exposes the `md-prevent-menu-close` attribute which
  374. * can be added to a button inside a menu to stop the menu from automatically closing.
  375. * You can then close the menu either by using `$mdMenu.close()` in the template,
  376. * or programmatically by injecting `$mdMenu` and calling `$mdMenu.hide()`.
  377. *
  378. * <hljs lang="html">
  379. * <md-menu-content ng-mouseleave="$mdMenu.close()">
  380. * <md-menu-item>
  381. * <md-button ng-click="doSomething()" aria-label="Do something" md-prevent-menu-close="md-prevent-menu-close">
  382. * <md-icon md-menu-align-target md-svg-icon="call:phone"></md-icon>
  383. * Do Something
  384. * </md-button>
  385. * </md-menu-item>
  386. * </md-menu-content>
  387. * </hljs>
  388. *
  389. * @usage
  390. * <hljs lang="html">
  391. * <md-menu>
  392. * <md-button ng-click="$mdMenu.open($event)" class="md-icon-button">
  393. * <md-icon md-svg-icon="call:phone"></md-icon>
  394. * </md-button>
  395. * <md-menu-content>
  396. * <md-menu-item><md-button ng-click="doSomething()">Do Something</md-button></md-menu-item>
  397. * </md-menu-content>
  398. * </md-menu>
  399. * </hljs>
  400. *
  401. * @param {string=} md-position-mode Specify pre-defined position modes for the `x` and `y` axes.
  402. * The default modes are `target target`. This positions the origin of the menu using the left and top edges
  403. * of the origin element in LTR layouts.<br>
  404. * #### Valid modes for horizontal positioning
  405. * - `target`
  406. * - `target-left`
  407. * - `target-right`
  408. * - `cascade`
  409. * - `right`
  410. * - `left`<br>
  411. * #### Valid modes for vertical positioning
  412. * - `target`
  413. * - `cascade`
  414. * - `bottom`
  415. * @param {string=} md-offset An offset to apply to the dropdown on opening, after positioning.
  416. * Defined as `x` and `y` pixel offset values in the form of `x y`.<br>
  417. * The default value is `0 0`.
  418. */
  419. MenuDirective['$inject'] = ["$mdUtil"];
  420. angular
  421. .module('material.components.menu')
  422. .directive('mdMenu', MenuDirective);
  423. /**
  424. * ngInject
  425. */
  426. function MenuDirective($mdUtil) {
  427. var INVALID_PREFIX = 'Invalid HTML for md-menu: ';
  428. return {
  429. restrict: 'E',
  430. require: ['mdMenu', '?^mdMenuBar'],
  431. controller: 'mdMenuCtrl', // empty function to be built by link
  432. scope: true,
  433. compile: compile
  434. };
  435. function compile(templateElement) {
  436. templateElement.addClass('md-menu');
  437. var triggerEl = templateElement.children()[0];
  438. var prefixer = $mdUtil.prefixer();
  439. if (!prefixer.hasAttribute(triggerEl, 'ng-click')) {
  440. triggerEl = triggerEl
  441. .querySelector(prefixer.buildSelector(['ng-click', 'ng-mouseenter'])) || triggerEl;
  442. }
  443. var isButtonTrigger = triggerEl.nodeName === 'MD-BUTTON' || triggerEl.nodeName === 'BUTTON';
  444. if (triggerEl && isButtonTrigger && !triggerEl.hasAttribute('type')) {
  445. triggerEl.setAttribute('type', 'button');
  446. }
  447. if (!triggerEl) {
  448. throw Error(INVALID_PREFIX + 'Expected the menu to have a trigger element.');
  449. }
  450. if (templateElement.children().length !== 2) {
  451. throw Error(INVALID_PREFIX + 'Expected two children elements. The second element must have a `md-menu-content` element.');
  452. }
  453. // Default element for ARIA attributes has the ngClick or ngMouseenter expression
  454. triggerEl && triggerEl.setAttribute('aria-haspopup', 'true');
  455. var nestedMenus = templateElement[0].querySelectorAll('md-menu');
  456. var nestingDepth = parseInt(templateElement[0].getAttribute('md-nest-level'), 10) || 0;
  457. if (nestedMenus) {
  458. angular.forEach($mdUtil.nodesToArray(nestedMenus), function(menuEl) {
  459. if (!menuEl.hasAttribute('md-position-mode')) {
  460. menuEl.setAttribute('md-position-mode', 'cascade');
  461. }
  462. menuEl.classList.add('_md-nested-menu');
  463. menuEl.setAttribute('md-nest-level', nestingDepth + 1);
  464. });
  465. }
  466. return link;
  467. }
  468. function link(scope, element, attr, ctrls) {
  469. var mdMenuCtrl = ctrls[0];
  470. var isInMenuBar = !!ctrls[1];
  471. // Move everything into a md-menu-container and pass it to the controller
  472. var menuContainer = angular.element( '<div class="_md md-open-menu-container md-whiteframe-z2"></div>');
  473. var menuContents = element.children()[1];
  474. element.addClass('_md'); // private md component indicator for styling
  475. if (!menuContents.hasAttribute('role')) {
  476. menuContents.setAttribute('role', 'menu');
  477. }
  478. menuContainer.append(menuContents);
  479. element.on('$destroy', function() {
  480. menuContainer.remove();
  481. });
  482. element.append(menuContainer);
  483. menuContainer[0].style.display = 'none';
  484. mdMenuCtrl.init(menuContainer, { isInMenuBar: isInMenuBar });
  485. }
  486. }
  487. MenuProvider['$inject'] = ["$$interimElementProvider"];angular
  488. .module('material.components.menu')
  489. .provider('$mdMenu', MenuProvider);
  490. /*
  491. * Interim element provider for the menu.
  492. * Handles behavior for a menu while it is open, including:
  493. * - handling animating the menu opening/closing
  494. * - handling key/mouse events on the menu element
  495. * - handling enabling/disabling scroll while the menu is open
  496. * - handling redrawing during resizes and orientation changes
  497. *
  498. */
  499. function MenuProvider($$interimElementProvider) {
  500. menuDefaultOptions['$inject'] = ["$mdUtil", "$mdTheming", "$mdConstant", "$document", "$window", "$q", "$$rAF", "$animateCss", "$animate", "$log"];
  501. var MENU_EDGE_MARGIN = 8;
  502. return $$interimElementProvider('$mdMenu')
  503. .setDefaults({
  504. methods: ['target'],
  505. options: menuDefaultOptions
  506. });
  507. /* ngInject */
  508. function menuDefaultOptions($mdUtil, $mdTheming, $mdConstant, $document, $window, $q, $$rAF,
  509. $animateCss, $animate, $log) {
  510. var prefixer = $mdUtil.prefixer();
  511. var animator = $mdUtil.dom.animator;
  512. return {
  513. parent: 'body',
  514. onShow: onShow,
  515. onRemove: onRemove,
  516. hasBackdrop: true,
  517. disableParentScroll: true,
  518. skipCompile: true,
  519. preserveScope: true,
  520. multiple: true,
  521. themable: true
  522. };
  523. /**
  524. * Show modal backdrop element...
  525. * @returns {function(): void} A function that removes this backdrop
  526. */
  527. function showBackdrop(scope, element, options) {
  528. if (options.nestLevel) return angular.noop;
  529. // If we are not within a dialog...
  530. if (options.disableParentScroll && !$mdUtil.getClosest(options.target, 'MD-DIALOG')) {
  531. // !! DO this before creating the backdrop; since disableScrollAround()
  532. // configures the scroll offset; which is used by mdBackDrop postLink()
  533. options.restoreScroll = $mdUtil.disableScrollAround(options.element, options.parent);
  534. } else {
  535. options.disableParentScroll = false;
  536. }
  537. if (options.hasBackdrop) {
  538. options.backdrop = $mdUtil.createBackdrop(scope, "md-menu-backdrop md-click-catcher");
  539. $animate.enter(options.backdrop, $document[0].body);
  540. }
  541. /**
  542. * Hide and destroys the backdrop created by showBackdrop()
  543. */
  544. return function hideBackdrop() {
  545. if (options.backdrop) options.backdrop.remove();
  546. if (options.disableParentScroll) options.restoreScroll();
  547. };
  548. }
  549. /**
  550. * Removing the menu element from the DOM and remove all associated event listeners
  551. * and backdrop
  552. */
  553. function onRemove(scope, element, opts) {
  554. opts.cleanupInteraction();
  555. opts.cleanupBackdrop();
  556. opts.cleanupResizing();
  557. opts.hideBackdrop();
  558. // Before the menu is closing remove the clickable class.
  559. element.removeClass('md-clickable');
  560. // For navigation $destroy events, do a quick, non-animated removal,
  561. // but for normal closes (from clicks, etc) animate the removal
  562. return (opts.$destroy === true) ? detachAndClean() : animateRemoval().then( detachAndClean );
  563. /**
  564. * For normal closes, animate the removal.
  565. * For forced closes (like $destroy events), skip the animations
  566. */
  567. function animateRemoval() {
  568. return $animateCss(element, {addClass: 'md-leave'}).start();
  569. }
  570. /**
  571. * Detach the element
  572. */
  573. function detachAndClean() {
  574. element.removeClass('md-active');
  575. detachElement(element, opts);
  576. opts.alreadyOpen = false;
  577. }
  578. }
  579. /**
  580. * Inserts and configures the staged Menu element into the DOM, positioning it,
  581. * and wiring up various interaction events
  582. */
  583. function onShow(scope, element, opts) {
  584. sanitizeAndConfigure(opts);
  585. if (opts.menuContentEl[0]) {
  586. // Inherit the theme from the target element.
  587. $mdTheming.inherit(opts.menuContentEl, opts.target);
  588. } else {
  589. $log.warn(
  590. '$mdMenu: Menu elements should always contain a `md-menu-content` element,' +
  591. 'otherwise interactivity features will not work properly.',
  592. element
  593. );
  594. }
  595. // Register various listeners to move menu on resize/orientation change
  596. opts.cleanupResizing = startRepositioningOnResize();
  597. opts.hideBackdrop = showBackdrop(scope, element, opts);
  598. // Return the promise for when our menu is done animating in
  599. return showMenu()
  600. .then(function(response) {
  601. opts.alreadyOpen = true;
  602. opts.cleanupInteraction = activateInteraction();
  603. opts.cleanupBackdrop = setupBackdrop();
  604. // Since the menu finished its animation, mark the menu as clickable.
  605. element.addClass('md-clickable');
  606. return response;
  607. });
  608. /**
  609. * Place the menu into the DOM and call positioning related functions
  610. */
  611. function showMenu() {
  612. opts.parent.append(element);
  613. element[0].style.display = '';
  614. return $q(function(resolve) {
  615. var position = calculateMenuPosition(element, opts);
  616. element.removeClass('md-leave');
  617. // Animate the menu scaling, and opacity [from its position origin (default == top-left)]
  618. // to normal scale.
  619. $animateCss(element, {
  620. addClass: 'md-active',
  621. from: animator.toCss(position),
  622. to: animator.toCss({transform: ''})
  623. })
  624. .start()
  625. .then(resolve);
  626. });
  627. }
  628. /**
  629. * Check for valid opts and set some sane defaults
  630. */
  631. function sanitizeAndConfigure() {
  632. if (!opts.target) {
  633. throw Error(
  634. '$mdMenu.show() expected a target to animate from in options.target'
  635. );
  636. }
  637. angular.extend(opts, {
  638. alreadyOpen: false,
  639. isRemoved: false,
  640. target: angular.element(opts.target), //make sure it's not a naked dom node
  641. parent: angular.element(opts.parent),
  642. menuContentEl: angular.element(element[0].querySelector('md-menu-content'))
  643. });
  644. }
  645. /**
  646. * Configure various resize listeners for screen changes
  647. */
  648. function startRepositioningOnResize() {
  649. var repositionMenu = (function(target, options) {
  650. return $$rAF.throttle(function() {
  651. if (opts.isRemoved) return;
  652. var position = calculateMenuPosition(target, options);
  653. target.css(animator.toCss(position));
  654. });
  655. })(element, opts);
  656. $window.addEventListener('resize', repositionMenu);
  657. $window.addEventListener('orientationchange', repositionMenu);
  658. return function stopRepositioningOnResize() {
  659. // Disable resizing handlers
  660. $window.removeEventListener('resize', repositionMenu);
  661. $window.removeEventListener('orientationchange', repositionMenu);
  662. };
  663. }
  664. /**
  665. * Sets up the backdrop and listens for click elements.
  666. * Once the backdrop will be clicked, the menu will automatically close.
  667. * @returns {!Function} Function to remove the backdrop.
  668. */
  669. function setupBackdrop() {
  670. if (!opts.backdrop) return angular.noop;
  671. opts.backdrop.on('click', onBackdropClick);
  672. return function() {
  673. opts.backdrop.off('click', onBackdropClick);
  674. };
  675. }
  676. /**
  677. * Function to be called whenever the backdrop is clicked.
  678. * @param {!MouseEvent} event
  679. */
  680. function onBackdropClick(event) {
  681. event.preventDefault();
  682. event.stopPropagation();
  683. scope.$apply(function() {
  684. opts.mdMenuCtrl.close(true, { closeAll: true });
  685. });
  686. }
  687. /**
  688. * Activate interaction on the menu. Resolves the focus target and closes the menu on
  689. * escape or option click.
  690. * @returns {!Function} Function to deactivate the interaction listeners.
  691. */
  692. function activateInteraction() {
  693. if (!opts.menuContentEl[0]) return angular.noop;
  694. // Wire up keyboard listeners.
  695. // - Close on escape,
  696. // - focus next item on down arrow,
  697. // - focus prev item on up
  698. opts.menuContentEl.on('keydown', onMenuKeyDown);
  699. opts.menuContentEl[0].addEventListener('click', captureClickListener, true);
  700. // kick off initial focus in the menu on the first enabled element
  701. var focusTarget = opts.menuContentEl[0]
  702. .querySelector(prefixer.buildSelector(['md-menu-focus-target', 'md-autofocus']));
  703. if ( !focusTarget ) {
  704. var childrenLen = opts.menuContentEl[0].children.length;
  705. for(var childIndex = 0; childIndex < childrenLen; childIndex++) {
  706. var child = opts.menuContentEl[0].children[childIndex];
  707. focusTarget = child.querySelector('.md-button:not([disabled])');
  708. if (focusTarget) {
  709. break;
  710. }
  711. if (child.firstElementChild && !child.firstElementChild.disabled) {
  712. focusTarget = child.firstElementChild;
  713. break;
  714. }
  715. }
  716. }
  717. focusTarget && focusTarget.focus();
  718. return function cleanupInteraction() {
  719. opts.menuContentEl.off('keydown', onMenuKeyDown);
  720. opts.menuContentEl[0].removeEventListener('click', captureClickListener, true);
  721. };
  722. // ************************************
  723. // internal functions
  724. // ************************************
  725. function onMenuKeyDown(ev) {
  726. var handled;
  727. switch (ev.keyCode) {
  728. case $mdConstant.KEY_CODE.ESCAPE:
  729. opts.mdMenuCtrl.close(false, { closeAll: true });
  730. handled = true;
  731. break;
  732. case $mdConstant.KEY_CODE.TAB:
  733. opts.mdMenuCtrl.close(false, { closeAll: true });
  734. // Don't prevent default or stop propagation on this event as we want tab
  735. // to move the focus to the next focusable element on the page.
  736. handled = false;
  737. break;
  738. case $mdConstant.KEY_CODE.UP_ARROW:
  739. if (!focusMenuItem(ev, opts.menuContentEl, opts, -1) && !opts.nestLevel) {
  740. opts.mdMenuCtrl.triggerContainerProxy(ev);
  741. }
  742. handled = true;
  743. break;
  744. case $mdConstant.KEY_CODE.DOWN_ARROW:
  745. if (!focusMenuItem(ev, opts.menuContentEl, opts, 1) && !opts.nestLevel) {
  746. opts.mdMenuCtrl.triggerContainerProxy(ev);
  747. }
  748. handled = true;
  749. break;
  750. case $mdConstant.KEY_CODE.LEFT_ARROW:
  751. if (opts.nestLevel) {
  752. opts.mdMenuCtrl.close();
  753. } else {
  754. opts.mdMenuCtrl.triggerContainerProxy(ev);
  755. }
  756. handled = true;
  757. break;
  758. case $mdConstant.KEY_CODE.RIGHT_ARROW:
  759. var parentMenu = $mdUtil.getClosest(ev.target, 'MD-MENU');
  760. if (parentMenu && parentMenu != opts.parent[0]) {
  761. ev.target.click();
  762. } else {
  763. opts.mdMenuCtrl.triggerContainerProxy(ev);
  764. }
  765. handled = true;
  766. break;
  767. }
  768. if (handled) {
  769. ev.preventDefault();
  770. ev.stopImmediatePropagation();
  771. }
  772. }
  773. function onBackdropClick(e) {
  774. e.preventDefault();
  775. e.stopPropagation();
  776. scope.$apply(function() {
  777. opts.mdMenuCtrl.close(true, { closeAll: true });
  778. });
  779. }
  780. // Close menu on menu item click, if said menu-item is not disabled
  781. function captureClickListener(e) {
  782. var target = e.target;
  783. // Traverse up the event until we get to the menuContentEl to see if
  784. // there is an ng-click and that the ng-click is not disabled
  785. do {
  786. if (target == opts.menuContentEl[0]) return;
  787. if ((hasAnyAttribute(target, ['ng-click', 'ng-href', 'ui-sref']) ||
  788. target.nodeName == 'BUTTON' || target.nodeName == 'MD-BUTTON') && !hasAnyAttribute(target, ['md-prevent-menu-close'])) {
  789. var closestMenu = $mdUtil.getClosest(target, 'MD-MENU');
  790. if (!target.hasAttribute('disabled') && (!closestMenu || closestMenu == opts.parent[0])) {
  791. close();
  792. }
  793. break;
  794. }
  795. } while (target = target.parentNode);
  796. function close() {
  797. scope.$apply(function() {
  798. opts.mdMenuCtrl.close(true, { closeAll: true });
  799. });
  800. }
  801. function hasAnyAttribute(target, attrs) {
  802. if (!target) return false;
  803. for (var i = 0, attr; attr = attrs[i]; ++i) {
  804. if (prefixer.hasAttribute(target, attr)) {
  805. return true;
  806. }
  807. }
  808. return false;
  809. }
  810. }
  811. }
  812. }
  813. /**
  814. * Takes a keypress event and focuses the next/previous menu
  815. * item from the emitting element
  816. * @param {event} e - The origin keypress event
  817. * @param {angular.element} menuEl - The menu element
  818. * @param {object} opts - The interim element options for the mdMenu
  819. * @param {number} direction - The direction to move in (+1 = next, -1 = prev)
  820. */
  821. function focusMenuItem(e, menuEl, opts, direction) {
  822. var currentItem = $mdUtil.getClosest(e.target, 'MD-MENU-ITEM');
  823. var items = $mdUtil.nodesToArray(menuEl[0].children);
  824. var currentIndex = items.indexOf(currentItem);
  825. // Traverse through our elements in the specified direction (+/-1) and try to
  826. // focus them until we find one that accepts focus
  827. var didFocus;
  828. for (var i = currentIndex + direction; i >= 0 && i < items.length; i = i + direction) {
  829. var focusTarget = items[i].querySelector('.md-button');
  830. didFocus = attemptFocus(focusTarget);
  831. if (didFocus) {
  832. break;
  833. }
  834. }
  835. return didFocus;
  836. }
  837. /**
  838. * Attempts to focus an element. Checks whether that element is the currently
  839. * focused element after attempting.
  840. * @param {HTMLElement} el - the element to attempt focus on
  841. * @returns {boolean} - whether the element was successfully focused
  842. */
  843. function attemptFocus(el) {
  844. if (el && el.getAttribute('tabindex') != -1) {
  845. el.focus();
  846. return ($document[0].activeElement == el);
  847. }
  848. }
  849. /**
  850. * Use browser to remove this element without triggering a $destroy event
  851. */
  852. function detachElement(element, opts) {
  853. if (!opts.preserveElement) {
  854. if (toNode(element).parentNode === toNode(opts.parent)) {
  855. toNode(opts.parent).removeChild(toNode(element));
  856. }
  857. } else {
  858. toNode(element).style.display = 'none';
  859. }
  860. }
  861. /**
  862. * Computes menu position and sets the style on the menu container
  863. * @param {HTMLElement} el - the menu container element
  864. * @param {object} opts - the interim element options object
  865. */
  866. function calculateMenuPosition(el, opts) {
  867. var containerNode = el[0],
  868. openMenuNode = el[0].firstElementChild,
  869. openMenuNodeRect = openMenuNode.getBoundingClientRect(),
  870. boundryNode = $document[0].body,
  871. boundryNodeRect = boundryNode.getBoundingClientRect();
  872. var menuStyle = $window.getComputedStyle(openMenuNode);
  873. var originNode = opts.target[0].querySelector(prefixer.buildSelector('md-menu-origin')) || opts.target[0],
  874. originNodeRect = originNode.getBoundingClientRect();
  875. var bounds = {
  876. left: boundryNodeRect.left + MENU_EDGE_MARGIN,
  877. top: Math.max(boundryNodeRect.top, 0) + MENU_EDGE_MARGIN,
  878. bottom: Math.max(boundryNodeRect.bottom, Math.max(boundryNodeRect.top, 0) + boundryNodeRect.height) - MENU_EDGE_MARGIN,
  879. right: boundryNodeRect.right - MENU_EDGE_MARGIN
  880. };
  881. var alignTarget, alignTargetRect = { top:0, left : 0, right:0, bottom:0 }, existingOffsets = { top:0, left : 0, right:0, bottom:0 };
  882. var positionMode = opts.mdMenuCtrl.positionMode();
  883. if (positionMode.top === 'target' || positionMode.left === 'target' || positionMode.left === 'target-right') {
  884. alignTarget = firstVisibleChild();
  885. if ( alignTarget ) {
  886. // TODO: Allow centering on an arbitrary node, for now center on first menu-item's child
  887. alignTarget = alignTarget.firstElementChild || alignTarget;
  888. alignTarget = alignTarget.querySelector(prefixer.buildSelector('md-menu-align-target')) || alignTarget;
  889. alignTargetRect = alignTarget.getBoundingClientRect();
  890. existingOffsets = {
  891. top: parseFloat(containerNode.style.top || 0),
  892. left: parseFloat(containerNode.style.left || 0)
  893. };
  894. }
  895. }
  896. var position = {};
  897. var transformOrigin = 'top ';
  898. switch (positionMode.top) {
  899. case 'target':
  900. position.top = existingOffsets.top + originNodeRect.top - alignTargetRect.top;
  901. break;
  902. case 'cascade':
  903. position.top = originNodeRect.top - parseFloat(menuStyle.paddingTop) - originNode.style.top;
  904. break;
  905. case 'bottom':
  906. position.top = originNodeRect.top + originNodeRect.height;
  907. break;
  908. default:
  909. throw new Error('Invalid target mode "' + positionMode.top + '" specified for md-menu on Y axis.');
  910. }
  911. var rtl = ($mdUtil.bidi() === 'rtl');
  912. switch (positionMode.left) {
  913. case 'target':
  914. position.left = existingOffsets.left + originNodeRect.left - alignTargetRect.left;
  915. transformOrigin += rtl ? 'right' : 'left';
  916. break;
  917. case 'target-left':
  918. position.left = originNodeRect.left;
  919. transformOrigin += 'left';
  920. break;
  921. case 'target-right':
  922. position.left = originNodeRect.right - openMenuNodeRect.width + (openMenuNodeRect.right - alignTargetRect.right);
  923. transformOrigin += 'right';
  924. break;
  925. case 'cascade':
  926. var willFitRight = rtl ? (originNodeRect.left - openMenuNodeRect.width) < bounds.left : (originNodeRect.right + openMenuNodeRect.width) < bounds.right;
  927. position.left = willFitRight ? originNodeRect.right - originNode.style.left : originNodeRect.left - originNode.style.left - openMenuNodeRect.width;
  928. transformOrigin += willFitRight ? 'left' : 'right';
  929. break;
  930. case 'right':
  931. if (rtl) {
  932. position.left = originNodeRect.right - originNodeRect.width;
  933. transformOrigin += 'left';
  934. } else {
  935. position.left = originNodeRect.right - openMenuNodeRect.width;
  936. transformOrigin += 'right';
  937. }
  938. break;
  939. case 'left':
  940. if (rtl) {
  941. position.left = originNodeRect.right - openMenuNodeRect.width;
  942. transformOrigin += 'right';
  943. } else {
  944. position.left = originNodeRect.left;
  945. transformOrigin += 'left';
  946. }
  947. break;
  948. default:
  949. throw new Error('Invalid target mode "' + positionMode.left + '" specified for md-menu on X axis.');
  950. }
  951. var offsets = opts.mdMenuCtrl.offsets();
  952. position.top += offsets.top;
  953. position.left += offsets.left;
  954. clamp(position);
  955. var scaleX = Math.round(100 * Math.min(originNodeRect.width / containerNode.offsetWidth, 1.0)) / 100;
  956. var scaleY = Math.round(100 * Math.min(originNodeRect.height / containerNode.offsetHeight, 1.0)) / 100;
  957. return {
  958. top: Math.round(position.top),
  959. left: Math.round(position.left),
  960. // Animate a scale out if we aren't just repositioning
  961. transform: !opts.alreadyOpen ? $mdUtil.supplant('scale({0},{1})', [scaleX, scaleY]) : undefined,
  962. transformOrigin: transformOrigin
  963. };
  964. /**
  965. * Clamps the repositioning of the menu within the confines of
  966. * bounding element (often the screen/body)
  967. */
  968. function clamp(pos) {
  969. pos.top = Math.max(Math.min(pos.top, bounds.bottom - containerNode.offsetHeight), bounds.top);
  970. pos.left = Math.max(Math.min(pos.left, bounds.right - containerNode.offsetWidth), bounds.left);
  971. }
  972. /**
  973. * Gets the first visible child in the openMenuNode
  974. * Necessary incase menu nodes are being dynamically hidden
  975. */
  976. function firstVisibleChild() {
  977. for (var i = 0; i < openMenuNode.children.length; ++i) {
  978. if ($window.getComputedStyle(openMenuNode.children[i]).display != 'none') {
  979. return openMenuNode.children[i];
  980. }
  981. }
  982. }
  983. }
  984. }
  985. function toNode(el) {
  986. if (el instanceof angular.element) {
  987. el = el[0];
  988. }
  989. return el;
  990. }
  991. }
  992. ngmaterial.components.menu = angular.module("material.components.menu");