You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1095 lines
37 KiB

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