UNPKG

angular-material-npfixed

Version:

The Angular Material project is an implementation of Material Design in Angular.js. This project provides a set of reusable, well-tested, and accessible Material Design UI components. Angular Material is supported internally at Google by the Angular.js, M

586 lines (504 loc) 20.6 kB
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; return $$interimElementProvider('$mdMenu') .setDefaults({ methods: ['target'], options: menuDefaultOptions }); /* @ngInject */ function menuDefaultOptions($mdUtil, $mdTheming, $mdConstant, $document, $window, $q, $$rAF, $animateCss, $animate, $log) { var prefixer = $mdUtil.prefixer(); var animator = $mdUtil.dom.animator; return { parent: 'body', onShow: onShow, onRemove: onRemove, hasBackdrop: true, disableParentScroll: true, skipCompile: true, preserveScope: true, multiple: true, themable: true }; /** * Show modal backdrop element... * @returns {function(): void} A function that removes this backdrop */ function showBackdrop(scope, element, options) { if (options.nestLevel) return angular.noop; // If we are not within a dialog... if (options.disableParentScroll && !$mdUtil.getClosest(options.target, 'MD-DIALOG')) { // !! DO this before creating the backdrop; since disableScrollAround() // configures the scroll offset; which is used by mdBackDrop postLink() options.restoreScroll = $mdUtil.disableScrollAround(options.element, options.parent); } else { options.disableParentScroll = false; } if (options.hasBackdrop) { options.backdrop = $mdUtil.createBackdrop(scope, "md-menu-backdrop md-click-catcher"); $animate.enter(options.backdrop, $document[0].body); } /** * Hide and destroys the backdrop created by showBackdrop() */ return function hideBackdrop() { if (options.backdrop) options.backdrop.remove(); if (options.disableParentScroll) options.restoreScroll(); }; } /** * Removing the menu element from the DOM and remove all associated event listeners * and backdrop */ function onRemove(scope, element, opts) { opts.cleanupInteraction(); opts.cleanupBackdrop(); opts.cleanupResizing(); opts.hideBackdrop(); // Before the menu is closing remove the clickable class. element.removeClass('md-clickable'); // For navigation $destroy events, do a quick, non-animated removal, // but for normal closes (from clicks, etc) animate the removal return (opts.$destroy === true) ? detachAndClean() : animateRemoval().then( detachAndClean ); /** * For normal closes, animate the removal. * For forced closes (like $destroy events), skip the animations */ function animateRemoval() { return $animateCss(element, {addClass: 'md-leave'}).start(); } /** * Detach the element */ function detachAndClean() { element.removeClass('md-active'); detachElement(element, opts); opts.alreadyOpen = false; } } /** * Inserts and configures the staged Menu element into the DOM, positioning it, * and wiring up various interaction events */ function onShow(scope, element, opts) { sanitizeAndConfigure(opts); if (opts.menuContentEl[0]) { // Inherit the theme from the target element. $mdTheming.inherit(opts.menuContentEl, opts.target); } else { $log.warn( '$mdMenu: Menu elements should always contain a `md-menu-content` element,' + 'otherwise interactivity features will not work properly.', element ); } // Register various listeners to move menu on resize/orientation change opts.cleanupResizing = startRepositioningOnResize(); opts.hideBackdrop = showBackdrop(scope, element, opts); // Return the promise for when our menu is done animating in return showMenu() .then(function(response) { opts.alreadyOpen = true; opts.cleanupInteraction = activateInteraction(); opts.cleanupBackdrop = setupBackdrop(); // Since the menu finished its animation, mark the menu as clickable. element.addClass('md-clickable'); return response; }); /** * Place the menu into the DOM and call positioning related functions */ function showMenu() { opts.parent.append(element); element[0].style.display = ''; return $q(function(resolve) { var position = calculateMenuPosition(element, opts); element.removeClass('md-leave'); // Animate the menu scaling, and opacity [from its position origin (default == top-left)] // to normal scale. $animateCss(element, { addClass: 'md-active', from: animator.toCss(position), to: animator.toCss({transform: ''}) }) .start() .then(resolve); }); } /** * Check for valid opts and set some sane defaults */ function sanitizeAndConfigure() { 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')) }); } /** * Configure various resize listeners for screen changes */ function startRepositioningOnResize() { var repositionMenu = (function(target, options) { return $$rAF.throttle(function() { if (opts.isRemoved) return; var position = calculateMenuPosition(target, options); target.css(animator.toCss(position)); }); })(element, opts); $window.addEventListener('resize', repositionMenu); $window.addEventListener('orientationchange', repositionMenu); return function stopRepositioningOnResize() { // Disable resizing handlers $window.removeEventListener('resize', repositionMenu); $window.removeEventListener('orientationchange', repositionMenu); }; } /** * Sets up the backdrop and listens for click elements. * Once the backdrop will be clicked, the menu will automatically close. * @returns {!Function} Function to remove the backdrop. */ function setupBackdrop() { if (!opts.backdrop) return angular.noop; opts.backdrop.on('click', onBackdropClick); return function() { opts.backdrop.off('click', onBackdropClick); } } /** * Function to be called whenever the backdrop is clicked. * @param {!MouseEvent} event */ function onBackdropClick(event) { event.preventDefault(); event.stopPropagation(); scope.$apply(function() { opts.mdMenuCtrl.close(true, { closeAll: true }); }); } /** * Activate interaction on the menu. Resolves the focus target and closes the menu on * escape or option click. * @returns {!Function} Function to deactivate the interaction listeners. */ function activateInteraction() { console.log(opts); if (!opts.menuContentEl[0]) return angular.noop; // Wire up keyboard listeners. // - Close on escape, // - focus next item on down arrow, // - focus prev item on up opts.menuContentEl.on('keydown', onMenuKeyDown); opts.menuContentEl[0].addEventListener('click', captureClickListener, true); // kick off initial focus in the menu on the first enabled element var focusTarget = opts.menuContentEl[0] .querySelector(prefixer.buildSelector(['md-menu-focus-target', 'md-autofocus'])); if ( !focusTarget ) { var childrenLen = opts.menuContentEl[0].children.length; for(var childIndex = 0; childIndex < childrenLen; childIndex++) { var child = opts.menuContentEl[0].children[childIndex]; focusTarget = child.querySelector('.md-button:not([disabled])'); if (focusTarget) { break; } if (child.firstElementChild && !child.firstElementChild.disabled) { focusTarget = child.firstElementChild; break; } } } focusTarget && focusTarget.focus(); return function cleanupInteraction() { opts.menuContentEl.off('keydown', onMenuKeyDown); opts.menuContentEl[0].removeEventListener('click', captureClickListener, true); }; // ************************************ // internal functions // ************************************ function onMenuKeyDown(ev) { console.log(ev); var handled; switch (ev.keyCode) { case $mdConstant.KEY_CODE.ESCAPE: opts.mdMenuCtrl.close(false, { closeAll: true }); handled = true; break; case $mdConstant.KEY_CODE.UP_ARROW: if (!focusMenuItem(ev, opts.menuContentEl, opts, -1) && !opts.nestLevel) { opts.mdMenuCtrl.triggerContainerProxy(ev); } handled = true; break; case $mdConstant.KEY_CODE.DOWN_ARROW: if (!focusMenuItem(ev, opts.menuContentEl, opts, 1) && !opts.nestLevel) { opts.mdMenuCtrl.triggerContainerProxy(ev); } handled = true; break; case $mdConstant.KEY_CODE.LEFT_ARROW: if (opts.nestLevel) { opts.mdMenuCtrl.close(); } else { opts.mdMenuCtrl.triggerContainerProxy(ev); } handled = true; break; case $mdConstant.KEY_CODE.RIGHT_ARROW: var parentMenu = $mdUtil.getClosest(ev.target, 'MD-MENU'); if (parentMenu && parentMenu != opts.parent[0]) { ev.target.click(); } else { opts.mdMenuCtrl.triggerContainerProxy(ev); } handled = true; break; } if (handled) { ev.preventDefault(); ev.stopImmediatePropagation(); } } function onBackdropClick(e) { e.preventDefault(); e.stopPropagation(); scope.$apply(function() { opts.mdMenuCtrl.close(true, { closeAll: true }); }); } // Close menu on menu item click, if said menu-item is not disabled function captureClickListener(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 == opts.menuContentEl[0]) return; if ((hasAnyAttribute(target, ['ng-click', 'ng-href', 'ui-sref']) || target.nodeName == 'BUTTON' || target.nodeName == 'MD-BUTTON') && !hasAnyAttribute(target, ['md-prevent-menu-close'])) { var closestMenu = $mdUtil.getClosest(target, 'MD-MENU'); if (!target.hasAttribute('disabled') && (!closestMenu || closestMenu == opts.parent[0])) { close(); } break; } } while (target = target.parentNode); function close() { scope.$apply(function() { opts.mdMenuCtrl.close(true, { closeAll: true }); }); } function hasAnyAttribute(target, attrs) { if (!target) return false; for (var i = 0, attr; attr = attrs[i]; ++i) { if (prefixer.hasAttribute(target, attr)) { return true; } } return false; } } } } /** * 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); if(items && items[0] && items[0].tagName.toLowerCase() === 'div') { items = $mdUtil.nodesToArray(items[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 var didFocus; for (var i = currentIndex + direction; i >= 0 && i < items.length; i = i + direction) { var focusTarget = items[i].querySelector('.md-button'); didFocus = attemptFocus(focusTarget); if (didFocus) { break; } } return didFocus; } /** * 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(); return ($document[0].activeElement == el); } } /** * Use browser to remove this element without triggering a $destroy event */ function detachElement(element, opts) { if (!opts.preserveElement) { if (toNode(element).parentNode === toNode(opts.parent)) { toNode(opts.parent).removeChild(toNode(element)); } } else { toNode(element).style.display = 'none'; } } /** * 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 calculateMenuPosition(el, opts) { var containerNode = el[0], openMenuNode = el[0].firstElementChild, openMenuNodeRect = openMenuNode.getBoundingClientRect(), boundryNode = $document[0].body, boundryNodeRect = boundryNode.getBoundingClientRect(); var menuStyle = $window.getComputedStyle(openMenuNode); var originNode = opts.target[0].querySelector(prefixer.buildSelector('md-menu-origin')) || opts.target[0], originNodeRect = originNode.getBoundingClientRect(); var bounds = { left: boundryNodeRect.left + MENU_EDGE_MARGIN, top: Math.max(boundryNodeRect.top, 0) + MENU_EDGE_MARGIN, bottom: Math.max(boundryNodeRect.bottom, Math.max(boundryNodeRect.top, 0) + boundryNodeRect.height) - MENU_EDGE_MARGIN, right: boundryNodeRect.right - MENU_EDGE_MARGIN }; var alignTarget, alignTargetRect = { top:0, left : 0, right:0, bottom:0 }, existingOffsets = { top:0, left : 0, right:0, bottom:0 }; var positionMode = opts.mdMenuCtrl.positionMode(); if (positionMode.top == 'target' || positionMode.left == 'target' || positionMode.left == 'target-right') { alignTarget = firstVisibleChild(); if ( alignTarget ) { // TODO: Allow centering on an arbitrary node, for now center on first menu-item's child alignTarget = alignTarget.firstElementChild || alignTarget; alignTarget = alignTarget.querySelector(prefixer.buildSelector('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; case 'cascade': position.top = originNodeRect.top - parseFloat(menuStyle.paddingTop) - originNode.style.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.'); } var rtl = ($mdUtil.bidi() == 'rtl'); switch (positionMode.left) { case 'target': position.left = existingOffsets.left + originNodeRect.left - alignTargetRect.left; transformOrigin += rtl ? 'right' : 'left'; break; case 'target-left': position.left = originNodeRect.left; transformOrigin += 'left'; break; case 'target-right': position.left = originNodeRect.right - openMenuNodeRect.width + (openMenuNodeRect.right - alignTargetRect.right); transformOrigin += 'right'; break; case 'cascade': var willFitRight = rtl ? (originNodeRect.left - openMenuNodeRect.width) < bounds.left : (originNodeRect.right + openMenuNodeRect.width) < bounds.right; position.left = willFitRight ? originNodeRect.right - originNode.style.left : originNodeRect.left - originNode.style.left - openMenuNodeRect.width; transformOrigin += willFitRight ? 'left' : 'right'; break; case 'right': if (rtl) { position.left = originNodeRect.right - originNodeRect.width; transformOrigin += 'left'; } else { position.left = originNodeRect.right - openMenuNodeRect.width; transformOrigin += 'right'; } break; case 'left': if (rtl) { position.left = originNodeRect.right - openMenuNodeRect.width; transformOrigin += 'right'; } else { position.left = originNodeRect.left; transformOrigin += 'left'; } 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); var scaleX = Math.round(100 * Math.min(originNodeRect.width / containerNode.offsetWidth, 1.0)) / 100; var scaleY = Math.round(100 * Math.min(originNodeRect.height / containerNode.offsetHeight, 1.0)) / 100; return { top: Math.round(position.top), left: Math.round(position.left), // Animate a scale out if we aren't just repositioning transform: !opts.alreadyOpen ? $mdUtil.supplant('scale({0},{1})', [scaleX, scaleY]) : undefined, transformOrigin: transformOrigin }; /** * 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); } /** * Gets the first visible child in the openMenuNode * Necessary incase menu nodes are being dynamically hidden */ function firstVisibleChild() { for (var i = 0; i < openMenuNode.children.length; ++i) { if ($window.getComputedStyle(openMenuNode.children[i]).display != 'none') { return openMenuNode.children[i]; } } } } } function toNode(el) { if (el instanceof angular.element) { el = el[0]; } return el; } }