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
JavaScript
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;
}
}