menu.js 32 KB

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