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.

1024 lines
35 KiB

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