diff --git a/src/components/menu/_menu.js b/src/components/menu/_menu.js new file mode 100644 index 00000000000..be2685724c9 --- /dev/null +++ b/src/components/menu/_menu.js @@ -0,0 +1,232 @@ +/** + * @ngdoc module + * @name material.components.menu + */ + +angular.module('material.components.menu', [ + 'material.core', + 'material.components.backdrop' +]) +.directive('mdMenu', MenuDirective); + +/** + * @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 origin element. + * The origin 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 origin 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 origin and the menu content. + * + * To specify the alignment element in the `origin` 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 allign 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: function() { }, // empty function to be built by link + scope: true, + compile: compile + }; + + function compile(tEl) { + tEl.addClass('md-menu'); + tEl.children().eq(0).attr('aria-haspopup', 'true'); + return link; + } + + function link(scope, el, attrs, mdMenuCtrl) { + // Se up mdMenuCtrl to keep our code squeaky clean + buildCtrl(); + + // Expose a open function to the child scope for their html to use + scope.$mdOpenMenu = function() { + mdMenuCtrl.open(); + }; + + if (el.children().length != 2) { + throw new Error('Invalid HTML for md-menu. Expected two children elements.'); + } + + // Move everything into a md-menu-container + var menuContainer = angular.element('
'); + var menuContents = el.children()[1]; + menuContainer.append(menuContents); + + var enabled; + mdMenuCtrl.enable(); + + function buildCtrl() { + mdMenuCtrl.enable = function enableMenu() { + if (!enabled) { + //el.on('keydown', handleKeypress); + enabled = true; + } + }; + + mdMenuCtrl.disable = function disableMenu() { + if (enabled) { + //el.off('keydown', handleKeypress); + enabled = false; + } + }; + + mdMenuCtrl.open = function openMenu() { + el.attr('aria-expanded', 'true'); + $mdMenu.show({ + mdMenuCtrl: mdMenuCtrl, + element: menuContainer, + target: el[0] + }); + }; + + mdMenuCtrl.close = function closeMenu(skipFocus) { + el.attr('aria-expanded', 'false'); + $mdMenu.hide(); + if (!skipFocus) el.children()[0].focus(); + }; + + mdMenuCtrl.positionMode = function() { + var attachment = (attrs.mdPositionMode || 'target').split(' '); + + if (attachment.length == 1) { attachment.push(attachment[0]); } + + return { + left: attachment[0], + top: attachment[1] + }; + + }; + + mdMenuCtrl.offsets = function() { + var offsets = (attrs.mdOffset || '0 0').split(' ').map(function(x) { return parseFloat(x, 10); }); + 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 new Error('Invalid offsets specified. Please follow format or '); + } + }; + } + } +} diff --git a/src/components/menu/demoBasicUsage/index.html b/src/components/menu/demoBasicUsage/index.html new file mode 100644 index 00000000000..559d7556c7b --- /dev/null +++ b/src/components/menu/demoBasicUsage/index.html @@ -0,0 +1,33 @@ +
+ + +
diff --git a/src/components/menu/demoBasicUsage/script.js b/src/components/menu/demoBasicUsage/script.js new file mode 100644 index 00000000000..f4bdaaefa0c --- /dev/null +++ b/src/components/menu/demoBasicUsage/script.js @@ -0,0 +1,28 @@ +angular.module('menuDemoBasic', ['ngMaterial']) +.config(function($mdIconProvider) { + $mdIconProvider + .iconSet("call", '/img/icons/sets/communication-icons.svg', 24) + .iconSet("social", '/img/icons/sets/social-icons.svg', 24); +}) +.controller('BasicDemoCtrl', DemoCtrl); + +function DemoCtrl($mdDialog) { + var vm = this; + vm.notificationsEnabled = true; + vm.toggleNotifications = function() { + vm.notificationsEnabled = !vm.notificationsEnabled; + }; + + vm.redial = function(e) { + $mdDialog.show( + $mdDialog.alert() + .title('Suddenly, a redial') + .content('You just called someone back. They told you the most amazing story that has ever been told. Have a cookie.') + .ok('That was easy') + ); + }; + + vm.checkVoicemail = function() { + // This never happens. + }; +} diff --git a/src/components/menu/demoBasicUsage/style.css b/src/components/menu/demoBasicUsage/style.css new file mode 100644 index 00000000000..0a6743ea4c2 --- /dev/null +++ b/src/components/menu/demoBasicUsage/style.css @@ -0,0 +1,7 @@ +.md-menu-demo { + padding: 24px; +} + +.menu-demo-container { + min-height: 200px; +} diff --git a/src/components/menu/demoMenuPositionModes/index.html b/src/components/menu/demoMenuPositionModes/index.html new file mode 100644 index 00000000000..17b938b9678 --- /dev/null +++ b/src/components/menu/demoMenuPositionModes/index.html @@ -0,0 +1,56 @@ +
+ +
+ + diff --git a/src/components/menu/demoMenuPositionModes/script.js b/src/components/menu/demoMenuPositionModes/script.js new file mode 100644 index 00000000000..14603197bad --- /dev/null +++ b/src/components/menu/demoMenuPositionModes/script.js @@ -0,0 +1,22 @@ +angular.module('menuDemoPosition', ['ngMaterial']) +.config(function($mdIconProvider) { + $mdIconProvider + .iconSet("call", '/img/icons/sets/communication-icons.svg', 24) + .iconSet("social", '/img/icons/sets/social-icons.svg', 24); +}) +.controller('PositionDemoCtrl', DemoCtrl); + +function DemoCtrl($mdDialog) { + var vm = this; + + this.announceClick = function(index) { + $mdDialog.show( + $mdDialog.alert() + .title('You clicked!') + .content('You clicked the menu item at index ' + index) + .ok('Nice') + ); + }; +} + + diff --git a/src/components/menu/demoMenuPositionModes/style.css b/src/components/menu/demoMenuPositionModes/style.css new file mode 100644 index 00000000000..0a6743ea4c2 --- /dev/null +++ b/src/components/menu/demoMenuPositionModes/style.css @@ -0,0 +1,7 @@ +.md-menu-demo { + padding: 24px; +} + +.menu-demo-container { + min-height: 200px; +} diff --git a/src/components/menu/demoMenuWidth/index.html b/src/components/menu/demoMenuWidth/index.html new file mode 100644 index 00000000000..56f1db8cba9 --- /dev/null +++ b/src/components/menu/demoMenuWidth/index.html @@ -0,0 +1,48 @@ +
+ +
+ diff --git a/src/components/menu/demoMenuWidth/script.js b/src/components/menu/demoMenuWidth/script.js new file mode 100644 index 00000000000..2cbfd156a75 --- /dev/null +++ b/src/components/menu/demoMenuWidth/script.js @@ -0,0 +1,21 @@ +angular.module('menuDemoWidth', ['ngMaterial']) +.config(function($mdIconProvider) { + $mdIconProvider + .iconSet("call", '/img/icons/sets/communication-icons.svg', 24) + .iconSet("social", '/img/icons/sets/social-icons.svg', 24); +}) +.controller('WidthDemoCtrl', DemoCtrl); + +function DemoCtrl($mdDialog) { + var vm = this; + + this.announceClick = function(index) { + $mdDialog.show( + $mdDialog.alert() + .title('You clicked!') + .content('You clicked the menu item at index ' + index) + .ok('Nice') + ); + }; +} + diff --git a/src/components/menu/demoMenuWidth/style.css b/src/components/menu/demoMenuWidth/style.css new file mode 100644 index 00000000000..2cba71b21c0 --- /dev/null +++ b/src/components/menu/demoMenuWidth/style.css @@ -0,0 +1,8 @@ +.md-menu-demo { + padding: 24px; +} + +.menu-demo-container { + min-height: 200px; +} + diff --git a/src/components/menu/menu-interim-element.js b/src/components/menu/menu-interim-element.js new file mode 100644 index 00000000000..7889d68ccae --- /dev/null +++ b/src/components/menu/menu-interim-element.js @@ -0,0 +1,307 @@ +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($$rAF, $window, $mdUtil, $mdTheming, $timeout, $mdConstant, $document) { + return { + parent: 'body', + onShow: onShow, + onRemove: onRemove, + hasBackdrop: true, + disableParentScroll: true, + skipCompile: true, + themable: true + }; + + // Interim element onShow fn, handles inserting it into the DOM, wiring up + // listeners and calling the positioning fn + function onShow(scope, element, opts) { + if (!opts.target) { + throw new 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('') + }); + + $mdTheming.inherit(opts.menuContentEl, opts.target); + + opts.resizeFn = function() { + positionMenu(scope, element, opts); + }; + angular.element($window).on('resize', opts.resizeFn); + angular.element($window).on('orientationchange', opts.resizeFn); + + if (opts.disableParentScroll) { + opts.restoreScroll = $mdUtil.disableScrollAround(opts.target); + } + + // 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); + } + opts.parent.append(element); + + element.removeClass('md-leave'); + $$rAF(function() { + $$rAF(function() { + positionMenu(scope, element, opts); + $$rAF(function() { + element.addClass('md-active'); + opts.alreadyOpen = true; + element[0].style[$mdConstant.CSS.TRANSFORM] = ''; + }); + }); + }); + + return $mdUtil.transitionEndPromise(element, {timeout: 350}); + + + // Activate interaction on the menu popup, allowing it to be closed by + // clicking on the backdrop, with escape, clicking options, etc. + function activateInteraction() { + element.addClass('md-clickable'); + opts.backdrop && opts.backdrop.on('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + opts.mdMenuCtrl.close(true); + }); + + 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: focusPrevMenuItem(ev, opts.menuContentEl, opts); break; + case $mdConstant.KEY_CODE.DOWN_ARROW: focusNextMenuItem(ev, opts.menuContentEl, opts); break; + } + }); + }); + + opts.menuContentEl.on('click', function(e) { + var target = e.target; + 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(); + }); + } + }); + + var focusTarget = opts.menuContentEl[0].querySelector('[md-menu-focus-target]'); + if (!focusTarget) focusTarget = opts.menuContentEl[0].firstElementChild.firstElementChild; + focusTarget.focus(); + } + } + + function focusPrevMenuItem(e, menuEl, opts) { + var currentItem = $mdUtil.getClosest(e.target, 'MD-MENU-ITEM'); + + var items = nodesToArray(menuEl[0].children); + var currentIndex = items.indexOf(currentItem); + + for (var i = currentIndex - 1; i >= 0; --i) { + var focusTarget = items[i].firstElementChild || items[i]; + var didFocus = attemptFocus(focusTarget); + if (didFocus) { + break; + } + } + } + + function focusNextMenuItem(e, menuEl, opts) { + var currentItem = $mdUtil.getClosest(e.target, 'MD-MENU-ITEM'); + + var items = nodesToArray(menuEl[0].children); + + var currentIndex = items.indexOf(currentItem); + + for (var i = currentIndex + 1; i < items.length; ++i) { + var focusTarget = items[i].firstElementChild || items[i]; + var didFocus = attemptFocus(focusTarget); + if (didFocus) { + break; + } + } + } + + function attemptFocus(el) { + if (el && el.getAttribute('tabindex') != -1) { + el.focus(); + if ($document[0].activeElement == el) { + return true; + } else { + return false; + } + } + } + + // Interim element onRemove fn, handles removing the element from the DOM + function onRemove(scope, element, opts) { + opts.isRemoved = true; + element.addClass('md-leave') + .removeClass('md-clickable'); + angular.element($window).off('resize', opts.resizeFn); + angular.element($window).off('orientationchange', opts.resizeFn); + opts.resizeFn = undefined; + + 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(); + }); + } + + // Handles computing the pop-ups position relative to the target (origin md-menu) + function positionMenu(scope, 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(); + + var containerNodeStyle = $window.getComputedStyle(containerNode); + existingOffsets = { + top: parseFloat(containerNodeStyle.top, 10), + left: parseFloat(containerNodeStyle.left, 10) + }; + } + + 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) + + ')'; + } + + 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); + } + } + } +} + +// Annoying method to copy nodes to an array, thanks to IE +function nodesToArray(nodes) { + var results = []; + for (var i = 0; i < nodes.length; ++i) { + results.push(nodes.item(i)); + } + return results; +} diff --git a/src/components/menu/menu-theme.scss b/src/components/menu/menu-theme.scss new file mode 100644 index 00000000000..4a3134ddd4c --- /dev/null +++ b/src/components/menu/menu-theme.scss @@ -0,0 +1,7 @@ +md-menu-content.md-THEME_NAME-theme { + background-color: '{{background-color}}'; + + md-menu-divider { + background-color: '{{foreground-4}}'; + } +} diff --git a/src/components/menu/menu.scss b/src/components/menu/menu.scss new file mode 100644 index 00000000000..f0172499dd0 --- /dev/null +++ b/src/components/menu/menu.scss @@ -0,0 +1,120 @@ +.md-open-menu-container { + position: fixed; + left: 0; + top: 0; + z-index: 99; + opacity: 0; + + md-menu-divider { + margin-top: $baseline-grid / 2; + margin-bottom: $baseline-grid / 2; + height: 1px; + width: 100%; + } + + + md-menu-content > * { + opacity: 0; + } + + // Don't let the user click something until it's animated + &:not(.md-clickable) { + pointer-events: none; + } + + // enter: menu scales in, then list fade in. + &.md-active { + opacity: 1; + transition: $swift-ease-out; + transition-duration: 200ms; + > md-menu-content > * { + opacity: 1; + transition: $swift-ease-in; + transition-duration: 200ms; + transition-delay: 100ms; + } + } + // leave: the container fades out + &.md-leave { + opacity: 0; + transition: $swift-ease-in; + transition-duration: 250ms; + } +} + +md-menu-content { + display: flex; + flex-direction: column; + padding: $baseline-grid 0; + + > md-menu-item { + display: flex; + flex-direction: row; + height: 6 * $baseline-grid; + align-content: center; + justify-content: flex-start; + + > * { + width: 100%; + margin: auto 0; + padding-left: 2*$baseline-grid; + padding-right: 2*$baseline-grid; + } + + > .md-button { + border-radius: 0; + margin: auto 0; + font-size: (2*$baseline-grid) - 1; + text-transform: none; + font-weight: 400; + text-align: start; + height: 100%; + padding-left: 2*$baseline-grid; + padding-right: 2*$baseline-grid; + display: flex; + md-icon { + margin: auto 2*$baseline-grid auto 0; + } + p { + margin: auto; + flex: 1; + } + } + } + + &.md-dense > md-menu-item { + height: 4 * $baseline-grid; + } +} + +.md-menu { + padding: $baseline-grid 0; +} + +md-toolbar { + .md-menu { + height: auto; + margin: auto; + } +} + +@media (max-width: $layout-breakpoint-sm - 1) { + md-menu-content { + min-width: 112px; + } + @for $i from 3 through 7 { + md-menu-content[width="#{$i}"] { + min-width: $i * 56px; + } + } +} +@media (min-width: $layout-breakpoint-sm) { + md-menu-content { + min-width: 96px; + } + @for $i from 3 through 7 { + md-menu-content[width="#{$i}"] { + min-width: $i * 64px; + } + } +} diff --git a/src/components/menu/menu.spec.js b/src/components/menu/menu.spec.js new file mode 100644 index 00000000000..718e207bb8d --- /dev/null +++ b/src/components/menu/menu.spec.js @@ -0,0 +1,142 @@ +describe('md-menu directive', function() { + var $mdMenu; + beforeEach(module('material.components.menu', 'ngAnimateMock')); + beforeEach(inject(function($mdUtil, $$q, $document, _$mdMenu_) { + $mdMenu = _$mdMenu_; + $mdUtil.transitionEndPromise = function() { + return $$q.when(true); + }; + var abandonedMenus = $document[0].querySelectorAll('.md-menu-container'); + angular.element(abandonedMenus).remove(); + })); + + function setup() { + var menu; + inject(function($compile, $rootScope) { + menu = $compile([ + '', + '', + '', + '
  • ', + '
    ' + ].join(''))($rootScope); + }); + return menu; + } + + it('errors on invalid markup', inject(function($compile, $rootScope) { + function buildBadMenu() { + $compile('')($rootScope); + } + expect(buildBadMenu).toThrow(); + })); + + it('removes everything but the first element', function() { + var menu = setup()[0]; + expect(menu.children.length).toBe(1); + expect(menu.firstElementChild.nodeName).toBe('BUTTON'); + }); + + it('opens on click', function() { + var menu = setup(); + openMenu(menu); + var menuContainer = getOpenMenuContainer(); + expect(menuContainer.length).toBe(1); + closeMenu(menu); + menuContainer = getOpenMenuContainer(); + expect(menuContainer.length).toBe(0); + }); + + it('closes on backdrop click', inject(function($document) { + var menu = setup(); + openMenu(menu); + var menuContainer = getOpenMenuContainer(); + expect(menuContainer.length).toBe(1); + $document.find('md-backdrop')[0].click(); + waitForMenuClose(); + menuContainer = getOpenMenuContainer(); + expect(menuContainer.length).toBe(0); + })); + + it('closes on escape', inject(function($document, $mdConstant) { + var menu = setup(); + openMenu(menu); + var menuContainer = getOpenMenuContainer(); + expect(menuContainer.length).toBe(1); + + var openMenuEl = $document[0].querySelector('md-menu-content'); + pressKey(openMenuEl, $mdConstant.KEY_CODE.ESCAPE); + waitForMenuClose(); + menuContainer = getOpenMenuContainer(); + expect(menuContainer.length).toBe(0); + })); + + it('closes on option click', inject(function($document) { + var menu = setup(); + openMenu(menu); + + var option = $document.find('md-button'); + option[0].click(); + waitForMenuClose(); + var menuContainer = getOpenMenuContainer(); + expect(menuContainer.length).toBe(0); + })); + + function flushTimeout() { + try { + inject(function($timeout) { + $timeout.flush(); + }); + } catch(e) { + if (e.message != 'No deferred tasks to be flushed') { + throw e; + } + } + } + + function getOpenMenuContainer() { + var res; + inject(function($document) { + res = angular.element($document[0].querySelector('.md-open-menu-container')); + }); + return res; + } + + function openMenu(el) { + el.children().eq(0).triggerHandler('click'); + waitForMenuOpen(); + flushTimeout(); + } + + function closeMenu() { + inject(function($document) { + $document.find('md-backdrop')[0].click(); + waitForMenuClose(); + }); + } + + function waitForMenuOpen() { + inject(function($rootScope, $animate) { + $rootScope.$digest(); + $animate.triggerCallbacks(); + }); + } + + function waitForMenuClose() { + inject(function($rootScope, $animate) { + $rootScope.$digest(); + $animate.triggerCallbacks(); + flushTimeout(); + }); + } + + function pressKey(el, code) { + if (!(el instanceof angular.element)) { + el = angular.element(el); + } + el.triggerHandler({ + type: 'keydown', + keyCode: code + }); + } +}); diff --git a/src/components/select/select.js b/src/components/select/select.js index 25ed7d46590..836b6a10c95 100755 --- a/src/components/select/select.js +++ b/src/components/select/select.js @@ -240,6 +240,7 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $interpolate, element.on('keydown', handleKeypress); } }); + if (!attr.disabled && !attr.ngDisabled) { element.attr({'tabindex': attr.tabindex, 'aria-disabled': 'false'}); element.on('click', openSelect); diff --git a/src/components/select/select.scss b/src/components/select/select.scss index 3dda2507b2a..51b3f018568 100755 --- a/src/components/select/select.scss +++ b/src/components/select/select.scss @@ -27,7 +27,7 @@ $select-max-visible-options: 5; &.md-active { opacity: 1; md-select-menu { - transition: transform $swift-ease-out; + transition: $swift-ease-out; transition-duration: 200ms; > * { opacity: 1; diff --git a/src/components/select/select.spec.js b/src/components/select/select.spec.js index 00b88b073f2..e396c55e51b 100755 --- a/src/components/select/select.spec.js +++ b/src/components/select/select.spec.js @@ -1,4 +1,4 @@ -describe('', function() { +describe('', function() { beforeEach(module('material.components.select', 'ngAnimateMock')); diff --git a/src/core/util/util.js b/src/core/util/util.js index c780fe3045b..d2152dae121 100644 --- a/src/core/util/util.js +++ b/src/core/util/util.js @@ -44,6 +44,11 @@ angular.module('material.core') // and uses CSS/JS to prevent it from scrolling disableScrollAround: function(element) { element = element instanceof angular.element ? element[0] : element; + + if (this.getClosest(element, 'MD-DIALOG')) { + return angular.noop; + } + var parentEl = element; var disableTarget;