/*! * Angular Material Design * https://github.com/angular/material * @license MIT * v0.10.0 */ goog.provide('ng.material.components.menu'); goog.require('ng.material.components.backdrop'); goog.require('ng.material.core'); /** * @ngdoc module * @name material.components.menu */ angular.module('material.components.menu', [ 'material.core', 'material.components.backdrop' ]) .directive('mdMenu', MenuDirective) .controller('mdMenuCtrl', MenuController); /** * @ngdoc directive * @name mdMenu * @module material.components.menu * @restrict E * @description * * Menus are elements that open when clicked. They are useful for displaying * additional options within the context of an action. * * Every `md-menu` must specify exactly two child elements. The first element is what is * left in the DOM and is used to open the menu. This element is called the trigger element. * The trigger element's scope has access to `$mdOpenMenu()` * which it may call to open the menu. * * The second element is the `md-menu-content` element which represents the * contents of the menu when it is open. Typically this will contain `md-menu-item`s, * but you can do custom content as well. * * * * * * * * * Do Something * * * * ## Sizing Menus * * The width of the menu when it is open may be specified by specifying a `width` * attribute on the `md-menu-content` element. * See the [Material Design Spec](http://www.google.com/design/spec/components/menus.html#menus-specs) * for more information. * * * ## Aligning Menus * * When a menu opens, it is important that the content aligns with the trigger element. * Failure to align menus can result in jarring experiences for users as content * suddenly shifts. To help with this, `md-menu` provides serveral APIs to help * with alignment. * * ### Target Mode * * By default, `md-menu` will attempt to align the `md-menu-content` by aligning * designated child elements in both the trigger and the menu content. * * To specify the alignment element in the `trigger` you can use the `md-menu-origin` * attribute on a child element. If no `md-menu-origin` is specified, the `md-menu` * will be used as the origin element. * * Similarly, the `md-menu-content` may specify a `md-menu-align-target` for a * `md-menu-item` to specify the node that it should try and align with. * * In this example code, we specify an icon to be our origin element, and an * icon in our menu content to be our alignment target. This ensures that both * icons are aligned when the menu opens. * * * * * * * * * * * Do Something * * * * * * * Sometimes we want to specify alignment on the right side of an element, for example * if we have a menu on the right side a toolbar, we want to right align our menu content. * * We can specify the origin by using the `md-position-mode` attribute on both * the `x` and `y` axis. Right now only the `x-axis` has more than one option. * You may specify the default mode of `target target` or * `target-right target` to specify a right-oriented alignment target. See the * position section of the demos for more examples. * * ### Menu Offsets * * It is sometimes unavoidable to need to have a deeper level of control for * the positioning of a menu to ensure perfect alignment. `md-menu` provides * the `md-offset` attribute to allow pixel level specificty of adjusting the * exact positioning. * * This offset is provided in the format of `x y` or `n` where `n` will be used * in both the `x` and `y` axis. * * For example, to move a menu by `2px` from the top, we can use: * * * * * * * @usage * * * * * * * Do Something * * * * * @param {string} md-position-mode The position mode in the form of `x`, `y`. Default value is `target`,`target`. Right now the `x` axis also suppports `target-right`. * @param {string} md-offset An offset to apply to the dropdown after positioning `x`, `y`. Default value is `0`,`0`. * */ function MenuDirective($mdMenu) { return { restrict: 'E', require: 'mdMenu', controller: 'mdMenuCtrl', // empty function to be built by link scope: true, compile: compile }; function compile(templateElement) { templateElement.addClass('md-menu'); var triggerElement = templateElement.children()[0]; if (!triggerElement.hasAttribute('ng-click')) { triggerElement = triggerElement.querySelector('[ng-click]'); } triggerElement && triggerElement.setAttribute('aria-haspopup', 'true'); if (templateElement.children().length != 2) { throw Error('Invalid HTML for md-menu. Expected two children elements.'); } return link; } function link(scope, element, attrs, mdMenuCtrl) { // Move everything into a md-menu-container and pass it to the controller var menuContainer = angular.element( '
' ); var menuContents = element.children()[1]; menuContainer.append(menuContents); mdMenuCtrl.init(menuContainer); scope.$on('$destroy', function() { if (mdMenuCtrl.isOpen) { menuContainer.remove(); mdMenuCtrl.close(); } }); } } MenuDirective.$inject = ["$mdMenu"]; function MenuController($mdMenu, $attrs, $element, $scope) { var menuContainer; var ctrl = this; var triggerElement; // Called by our linking fn to provide access to the menu-content // element removed during link this.init = function(setMenuContainer) { menuContainer = setMenuContainer; triggerElement = $element[0].querySelector('[ng-click]'); }; // Uses the $mdMenu interim element service to open the menu contents this.open = function openMenu() { ctrl.isOpen = true; triggerElement.setAttribute('aria-expanded', 'true'); $mdMenu.show({ mdMenuCtrl: ctrl, element: menuContainer, target: $element[0] }); }; // Expose a open function to the child scope for html to use $scope.$mdOpenMenu = this.open; // Use the $mdMenu interim element service to close the menu contents this.close = function closeMenu(skipFocus) { ctrl.isOpen = false; triggerElement.setAttribute('aria-expanded', 'false'); $mdMenu.hide(); if (!skipFocus) { $element.children()[0].focus(); } }; // Build a nice object out of our string attribute which specifies the // target mode for left and top positioning this.positionMode = function() { var attachment = ($attrs.mdPositionMode || 'target').split(' '); // If attachment is a single item, duplicate it for our second value. // ie. 'target' -> 'target target' if (attachment.length == 1) { attachment.push(attachment[0]); } return { left: attachment[0], top: attachment[1] }; }; // Build a nice object out of our string attribute which specifies // the offset of top and left in pixels. this.offsets = function() { var offsets = ($attrs.mdOffset || '0 0').split(' ').map(parseFloat); if (offsets.length == 2) { return { left: offsets[0], top: offsets[1] }; } else if (offsets.length == 1) { return { top: offsets[0], left: offsets[0] }; } else { throw Error('Invalid offsets specified. Please follow format or '); } }; } MenuController.$inject = ["$mdMenu", "$attrs", "$element", "$scope"]; angular.module('material.components.menu') .provider('$mdMenu', MenuProvider); /* * Interim element provider for the menu. * Handles behavior for a menu while it is open, including: * - handling animating the menu opening/closing * - handling key/mouse events on the menu element * - handling enabling/disabling scroll while the menu is open * - handling redrawing during resizes and orientation changes * */ function MenuProvider($$interimElementProvider) { var MENU_EDGE_MARGIN = 8; menuDefaultOptions.$inject = ["$$rAF", "$window", "$mdUtil", "$mdTheming", "$timeout", "$mdConstant", "$document"]; return $$interimElementProvider('$mdMenu') .setDefaults({ methods: ['target'], options: menuDefaultOptions }); /* ngInject */ function menuDefaultOptions($$rAF, $window, $mdUtil, $mdTheming, $timeout, $mdConstant, $document) { return { parent: 'body', onShow: onShow, onRemove: onRemove, hasBackdrop: true, disableParentScroll: true, skipCompile: true, themable: true }; /** * Boilerplate interimElement onShow function * Handles inserting the menu into the DOM, positioning it, and wiring up * various interaction events */ function onShow(scope, element, opts) { // Sanitize and set defaults on opts buildOpts(opts); // Wire up theming on our menu element $mdTheming.inherit(opts.menuContentEl, opts.target); // Register various listeners to move menu on resize/orientation change handleResizing(); // Disable scrolling if (opts.disableParentScroll) { opts.restoreScroll = $mdUtil.disableScrollAround(opts.element); } // Only activate click listeners after a short time to stop accidental double taps/clicks // from clicking the wrong item $timeout(activateInteraction, 75, false); if (opts.backdrop) { $mdTheming.inherit(opts.backdrop, opts.parent); opts.parent.append(opts.backdrop); } showMenu(); // Return the promise for when our menu is done animating in return $mdUtil.transitionEndPromise(element, {timeout: 350}); /** Check for valid opts and set some sane defaults */ function buildOpts() { if (!opts.target) { throw Error( '$mdMenu.show() expected a target to animate from in options.target' ); } angular.extend(opts, { alreadyOpen: false, isRemoved: false, target: angular.element(opts.target), //make sure it's not a naked dom node parent: angular.element(opts.parent), menuContentEl: angular.element(element[0].querySelector('md-menu-content')), backdrop: opts.hasBackdrop && angular.element('') }); } /** Wireup various resize listeners for screen changes */ function handleResizing() { opts.resizeFn = function() { positionMenu(element, opts); }; angular.element($window).on('resize', opts.resizeFn); angular.element($window).on('orientationchange', opts.resizeFn); } /** * Place the menu into the DOM and call positioning related functions */ function showMenu() { opts.parent.append(element); element.removeClass('md-leave'); // Kick off our animation/positioning but first, wait a few frames // so all of our computed positions/sizes are accurate $$rAF(function() { $$rAF(function() { positionMenu(element, opts); // Wait a frame before fading in menu (md-active) so that we don't trigger // transitions on the menu position changing $$rAF(function() { element.addClass('md-active'); opts.alreadyOpen = true; element[0].style[$mdConstant.CSS.TRANSFORM] = ''; }); }); }); } /** * Activate interaction on the menu. Wire up keyboard listerns for * clicks, keypresses, backdrop closing, etc. */ function activateInteraction() { element.addClass('md-clickable'); // close on backdrop click opts.backdrop && opts.backdrop.on('click', function(e) { e.preventDefault(); e.stopPropagation(); opts.mdMenuCtrl.close(true); }); // Wire up keyboard listeners. // Close on escape, focus next item on down arrow, focus prev item on up opts.menuContentEl.on('keydown', function(ev) { scope.$apply(function() { switch (ev.keyCode) { case $mdConstant.KEY_CODE.ESCAPE: opts.mdMenuCtrl.close(); break; case $mdConstant.KEY_CODE.UP_ARROW: focusMenuItem(ev, opts.menuContentEl, opts, -1); break; case $mdConstant.KEY_CODE.DOWN_ARROW: focusMenuItem(ev, opts.menuContentEl, opts, 1); break; } }); }); // Close menu on menu item click, if said menu-item is not disabled opts.menuContentEl.on('click', function(e) { var target = e.target; // Traverse up the event until we get to the menuContentEl to see if // there is an ng-click and that the ng-click is not disabled do { if (target && target.hasAttribute('ng-click')) { if (!target.hasAttribute('disabled')) { close(); } break; } } while ((target = target.parentNode) && target != opts.menuContentEl) function close() { scope.$apply(function() { opts.mdMenuCtrl.close(); }); } }); // kick off initial focus in the menu on the first element var focusTarget = opts.menuContentEl[0].querySelector('[md-menu-focus-target]'); if (!focusTarget) focusTarget = opts.menuContentEl[0].firstElementChild.firstElementChild; focusTarget.focus(); } } /** * Takes a keypress event and focuses the next/previous menu * item from the emitting element * @param {event} e - The origin keypress event * @param {angular.element} menuEl - The menu element * @param {object} opts - The interim element options for the mdMenu * @param {number} direction - The direction to move in (+1 = next, -1 = prev) */ function focusMenuItem(e, menuEl, opts, direction) { var currentItem = $mdUtil.getClosest(e.target, 'MD-MENU-ITEM'); var items = $mdUtil.nodesToArray(menuEl[0].children); var currentIndex = items.indexOf(currentItem); // Traverse through our elements in the specified direction (+/-1) and try to // focus them until we find one that accepts focus for (var i = currentIndex + direction; i >= 0 && i < items.length; i = i + direction) { var focusTarget = items[i].firstElementChild || items[i]; var didFocus = attemptFocus(focusTarget); if (didFocus) { break; } } } /** * Attempts to focus an element. Checks whether that element is the currently * focused element after attempting. * @param {HTMLElement} el - the element to attempt focus on * @returns {bool} - whether the element was successfully focused */ function attemptFocus(el) { if (el && el.getAttribute('tabindex') != -1) { el.focus(); if ($document[0].activeElement == el) { return true; } else { return false; } } } /** * Boilerplate interimElement onRemove function * Handles removing the menu from the DOM, cleaning up the element * and removing various listeners */ function onRemove(scope, element, opts) { opts.isRemoved = true; element.addClass('md-leave') .removeClass('md-clickable'); // Disable resizing handlers angular.element($window).off('resize', opts.resizeFn); angular.element($window).off('orientationchange', opts.resizeFn); opts.resizeFn = undefined; // Wait for animate out, then remove from the DOM return $mdUtil.transitionEndPromise(element, { timeout: 350 }).then(function() { element.removeClass('md-active'); opts.backdrop && opts.backdrop.remove(); if (element[0].parentNode === opts.parent[0]) { opts.parent[0].removeChild(element[0]); } opts.restoreScroll && opts.restoreScroll(); }); } /** * Computes menu position and sets the style on the menu container * @param {HTMLElement} el - the menu container element * @param {object} opts - the interim element options object */ function positionMenu(el, opts) { if (opts.isRemoved) return; var containerNode = el[0], openMenuNode = el[0].firstElementChild, openMenuNodeRect = openMenuNode.getBoundingClientRect(), boundryNode = opts.parent[0], boundryNodeRect = boundryNode.getBoundingClientRect(); var originNode = opts.target[0].querySelector('[md-menu-origin]') || opts.target[0], originNodeRect = originNode.getBoundingClientRect(); var bounds = { left: boundryNodeRect.left + MENU_EDGE_MARGIN, top: boundryNodeRect.top + MENU_EDGE_MARGIN, bottom: boundryNodeRect.bottom - MENU_EDGE_MARGIN, right: boundryNodeRect.right - MENU_EDGE_MARGIN }; var alignTarget, alignTargetRect, existingOffsets; var positionMode = opts.mdMenuCtrl.positionMode(); if (positionMode.top == 'target' || positionMode.left == 'target' || positionMode.left == 'target-right') { // TODO: Allow centering on an arbitrary node, for now center on first menu-item's child alignTarget = openMenuNode.firstElementChild.firstElementChild || openMenuNode.firstElementChild; alignTarget = alignTarget.querySelector('[md-menu-align-target]') || alignTarget; alignTargetRect = alignTarget.getBoundingClientRect(); existingOffsets = { top: parseFloat(containerNode.style.top || 0), left: parseFloat(containerNode.style.left || 0) }; } var position = { }; var transformOrigin = 'top '; switch (positionMode.top) { case 'target': position.top = existingOffsets.top + originNodeRect.top - alignTargetRect.top; break; // Future support for mdMenuBar // case 'top': // position.top = originNodeRect.top; // break; // case 'bottom': // position.top = originNodeRect.top + originNodeRect.height; // break; default: throw new Error('Invalid target mode "' + positionMode.top + '" specified for md-menu on Y axis.'); } switch (positionMode.left) { case 'target': position.left = existingOffsets.left + originNodeRect.left - alignTargetRect.left; transformOrigin += 'left'; break; case 'target-right': position.left = originNodeRect.right - openMenuNodeRect.width + (openMenuNodeRect.right - alignTargetRect.right); transformOrigin += 'right'; break; // Future support for mdMenuBar // case 'left': // position.left = originNodeRect.left; // transformOrigin += 'left'; // break; // case 'right': // position.left = originNodeRect.right - containerNode.offsetWidth; // transformOrigin += 'right'; // break; default: throw new Error('Invalid target mode "' + positionMode.left + '" specified for md-menu on X axis.'); } var offsets = opts.mdMenuCtrl.offsets(); position.top += offsets.top; position.left += offsets.left; clamp(position); el.css({ top: position.top + 'px', left: position.left + 'px' }); containerNode.style[$mdConstant.CSS.TRANSFORM_ORIGIN] = transformOrigin; // Animate a scale out if we aren't just repositioning if (!opts.alreadyOpen) { containerNode.style[$mdConstant.CSS.TRANSFORM] = 'scale(' + Math.min(originNodeRect.width / containerNode.offsetWidth, 1.0) + ',' + Math.min(originNodeRect.height / containerNode.offsetHeight, 1.0) + ')'; } /** * Clamps the repositioning of the menu within the confines of * bounding element (often the screen/body) */ function clamp(pos) { pos.top = Math.max(Math.min(pos.top, bounds.bottom - containerNode.offsetHeight), bounds.top); pos.left = Math.max(Math.min(pos.left, bounds.right - containerNode.offsetWidth), bounds.left); } } } } MenuProvider.$inject = ["$$interimElementProvider"]; ng.material.components.menu = angular.module("material.components.menu");