diff --git a/README.md b/README.md index f1da8f5798b..6bf352f3539 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,8 @@ when using online tools such as [CodePen](http://codepen.io/), [Plunkr](http://p ```html - - + + @@ -129,22 +129,22 @@ when using online tools such as [CodePen](http://codepen.io/), [Plunkr](http://p - - + + ``` -> Note that the above sample references the 0.8.3 CDN release. Your version will change based on the latest stable release version. +> Note that the above sample references the 0.10.0 CDN release. Your version will change based on the latest stable release version. -Developers seeking the latest, most-current build versions can use [RawGit.com](//rawgit.com) to +Developers seeking the latest, most-current build versions can use [GitCDN.xyz](//gitcdn.xyz) to pull directly from the distribution GitHub [Bower-Material](https://github.com/angular/bower-material) repository: ```html - - + + @@ -154,11 +154,9 @@ pull directly from the distribution GitHub - - + + ``` -> Please note that the above RawGit access is intended **ONLY** for development purposes or sharing - low-traffic, temporary examples or demos with small numbers of people. diff --git a/config/build.config.js b/config/build.config.js index abf1ebf0592..d47d02d349d 100644 --- a/config/build.config.js +++ b/config/build.config.js @@ -3,7 +3,7 @@ var fs = require('fs'); var versionFile = __dirname + '/../dist/commit'; module.exports = { - ngVersion: '1.3.15', + ngVersion: '1.4.1', version: pkg.version, repository: pkg.repository.url .replace(/^git/,'https') diff --git a/config/karma.conf.js b/config/karma.conf.js index 1ed7851b7f5..76b5a8f019b 100644 --- a/config/karma.conf.js +++ b/config/karma.conf.js @@ -14,15 +14,17 @@ module.exports = function(config) { // demos in the tests, and Karma doesn't support advanced // globbing. + 'dist/angular-material.css', + 'src/core/**/*.js', 'src/components/*/*.js', 'src/components/*/js/*.js', 'src/**/*.spec.js' - ]; var COMPILED_SRC = [ + 'dist/angular-material.min.css', 'dist/angular-material.min.js', // Minified source 'src/**/*.spec.js' ]; diff --git a/package.json b/package.json index 93607c47c1a..9fc5ed2934b 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,12 @@ "url": "git://github.com/angular/material.git" }, "devDependencies": { - "angular": "^1.3.15 || >1.4.0-beta.0", - "angular-animate": "^1.3.15 || >1.4.0-beta.0", - "angular-aria": "^1.3.15 || >1.4.0-beta.0", - "angular-messages": "^1.3.0 || >1.4.0-beta.0", - "angular-mocks": "^1.3.0 || >1.4.0-beta.0", - "angular-route": "^1.3.0 || >1.4.0-beta.0", + "angular": "^1.4.0", + "angular-animate": "^1.4.0", + "angular-aria": "^1.4.0", + "angular-messages": "^1.4.0", + "angular-mocks": "^1.4.0", + "angular-route": "^1.4.0", "angularytics": "^0.3.0", "canonical-path": "0.0.2", "cli-color": "^1.0.0", @@ -58,4 +58,4 @@ "stream-series": "^0.1.1", "through2": "^0.6.3" } -} \ No newline at end of file +} diff --git a/src/components/button/button.scss b/src/components/button/button.scss index 9dea22a0a13..06fea834dbf 100644 --- a/src/components/button/button.scss +++ b/src/components/button/button.scss @@ -151,10 +151,6 @@ $icon-button-margin: rem(0.600) !default; -webkit-mask-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAA5JREFUeNpiYGBgAAgwAAAEAAGbA+oJAAAAAElFTkSuQmCC'); } - md-icon { - margin-top: 0; - } - &.md-mini { line-height: $button-fab-mini-line-height; width: $button-fab-mini-width; diff --git a/src/components/icon/demoFontIconsWithLigatures/index.html b/src/components/icon/demoFontIconsWithLigatures/index.html index 6034dc9b739..cd4e82882fc 100644 --- a/src/components/icon/demoFontIconsWithLigatures/index.html +++ b/src/components/icon/demoFontIconsWithLigatures/index.html @@ -11,7 +11,7 @@
{{ font.name }} diff --git a/src/components/icon/iconDirective.js b/src/components/icon/iconDirective.js index d8b077310b4..e32be613672 100644 --- a/src/components/icon/iconDirective.js +++ b/src/components/icon/iconDirective.js @@ -117,11 +117,12 @@ angular.module('material.components.icon', [ * When using Material Font Icons with ligatures: * * - * + * * face - * face * face * #xE87C; + * + * face * * * When using other Font-Icon libraries: @@ -209,12 +210,23 @@ function mdIconDirective($mdIcon, $mdTheming, $mdAria, $interpolate ) { function prepareForFontIcon () { if (!scope.svgIcon && !scope.svgSrc) { + if (scope.fontIcon) { element.addClass('md-font'); element.addClass(scope.fontIcon); - } else { + } + + if (scope.fontSet) { element.addClass($mdIcon.fontSet(scope.fontSet)); } + + // For Material Design font icons, the class '.material-icons' + // is auto-added IF a style has not been specified + + if (!scope.fontIcon && !scope.fontSet && !angular.isDefined(attr.class)) { + + element.addClass('material-icons'); + } } } diff --git a/src/components/icon/iconDirective.spec.js b/src/components/icon/iconDirective.spec.js index aeea0bab783..06edff8be69 100644 --- a/src/components/icon/iconDirective.spec.js +++ b/src/components/icon/iconDirective.spec.js @@ -11,6 +11,7 @@ describe('mdIcon directive', function() { })); afterEach( function() { $mdIconProvider.defaultFontSet('material-icons'); + $mdIconProvider.fontSet('fa', 'fa'); }); @@ -21,6 +22,7 @@ describe('mdIcon directive', function() { $compile = _$compile_; })); + describe('using font-icons with deprecated md-font-icon=""', function() { it('should render correct HTML with md-font-icon value as class', function() { @@ -64,7 +66,7 @@ describe('mdIcon directive', function() { expect(el.attr('md-font-icon')).toBe($scope.font.name); expect(el.hasClass('step')).toBe(true); - expect(el.hasClass('material-icons')).toBe(true); + expect(el.hasClass('material-icons')).toBe(false); expect(el.attr('aria-label')).toBe($scope.font.name + $scope.font.size); expect(el.attr('role')).toBe('img'); }) @@ -77,7 +79,7 @@ describe('mdIcon directive', function() { el = make( 'face'); expect(el.text()).toEqual('face'); - expect(el.hasClass('material-icons')).toBeTruthy(); + expect(el.hasClass('material-icons')).toBeFalsy(); expect(el.hasClass('md-48')).toBeTruthy(); }); @@ -90,6 +92,15 @@ describe('mdIcon directive', function() { expect( clean(el.attr('class')) ).toEqual("fontawesome"); }); + + it('should render correctly using a md-font-set alias', function() { + el = make( ''); + + expect( clean(el.attr('class')) ).toEqual("md-font fa-info fa"); + }); + + + it('should render correctly using md-font-set value as class', function() { el = make( 'email'); @@ -101,11 +112,19 @@ describe('mdIcon directive', function() { describe('using font-icons with classnames', function() { + it('should auto-add the material-icons style', function() { + el = make( 'apple'); + + expect(el.text()).toEqual('apple'); + expect(el.hasClass('material-icons')).toBeTruthy(); + }); + + it('should render with icon classname', function() { el = make( ''); expect(el.text()).toEqual(''); - expect(el.hasClass('material-icons')).toBeTruthy(); + expect(el.hasClass('material-icons')).toBeFalsy(); expect(el.hasClass('custom-cake')).toBeTruthy(); }); diff --git a/src/components/list/list.scss b/src/components/list/list.scss index 4c5c560a003..955df78d32e 100644 --- a/src/components/list/list.scss +++ b/src/components/list/list.scss @@ -52,6 +52,8 @@ md-list-item { text-transform: none; width: 100%; white-space: normal; + flex-direction: inherit; + align-items: inherit; } &:focus { outline: none diff --git a/src/components/menu/_menu.js b/src/components/menu/_menu.js index fdc3e15f035..1cbc4245b90 100644 --- a/src/components/menu/_menu.js +++ b/src/components/menu/_menu.js @@ -22,8 +22,9 @@ angular.module('material.components.menu', [ * * 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 trigger element's scope has access to `$mdOpenMenu($event)` + * which it may call to open the menu. By passing $event as argument, the + * corresponding event is stopped from propagating up the DOM-tree. * * 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, @@ -32,7 +33,7 @@ angular.module('material.components.menu', [ * * * - * + * * * * @@ -74,7 +75,7 @@ angular.module('material.components.menu', [ * * * - * + * * * * @@ -117,7 +118,7 @@ angular.module('material.components.menu', [ * @usage * * - * + * * * * @@ -190,7 +191,9 @@ function MenuController($mdMenu, $attrs, $element, $scope) { }; // Uses the $mdMenu interim element service to open the menu contents - this.open = function openMenu() { + this.open = function openMenu(ev) { + ev && ev.stopPropagation(); + ctrl.isOpen = true; triggerElement.setAttribute('aria-expanded', 'true'); $mdMenu.show({ diff --git a/src/components/menu/demoBasicUsage/index.html b/src/components/menu/demoBasicUsage/index.html index 559d7556c7b..46eae11dda9 100644 --- a/src/components/menu/demoBasicUsage/index.html +++ b/src/components/menu/demoBasicUsage/index.html @@ -4,7 +4,7 @@

Simple dropdown menu

Note that applying the md-menu-origin and md-menu-align-target attributes ensure that the menu elements align

- + diff --git a/src/components/menu/demoMenuPositionModes/index.html b/src/components/menu/demoMenuPositionModes/index.html index 17b938b9678..9078de377ca 100644 --- a/src/components/menu/demoMenuPositionModes/index.html +++ b/src/components/menu/demoMenuPositionModes/index.html @@ -7,7 +7,7 @@

Target-Based Position Modes

Target Mode Positioning (default)

- + @@ -23,7 +23,7 @@

Target-Based Position Modes

Target mode with md-offset

- + @@ -36,7 +36,7 @@

Target-Based Position Modes

md-position-mode="target-right target"

- + diff --git a/src/components/menu/demoMenuWidth/index.html b/src/components/menu/demoMenuWidth/index.html index 56f1db8cba9..6cb13c07e45 100644 --- a/src/components/menu/demoMenuWidth/index.html +++ b/src/components/menu/demoMenuWidth/index.html @@ -6,7 +6,7 @@

Different Widths

Wide menu (width=6)

- + @@ -19,7 +19,7 @@

Different Widths

Medium menu (width=4)

- + @@ -32,7 +32,7 @@

Different Widths

Small menu (width=2)

- + diff --git a/src/components/menu/menu.spec.js b/src/components/menu/menu.spec.js index 718e207bb8d..d90d7ee045f 100644 --- a/src/components/menu/menu.spec.js +++ b/src/components/menu/menu.spec.js @@ -1,8 +1,10 @@ describe('md-menu directive', function() { - var $mdMenu; + var $mdMenu,$timeout; + beforeEach(module('material.components.menu', 'ngAnimateMock')); - beforeEach(inject(function($mdUtil, $$q, $document, _$mdMenu_) { + beforeEach(inject(function($mdUtil, $$q, $document, _$mdMenu_, _$timeout_) { $mdMenu = _$mdMenu_; + $timeout = _$timeout_; $mdUtil.transitionEndPromise = function() { return $$q.when(true); }; @@ -11,15 +13,17 @@ describe('md-menu directive', function() { })); function setup() { - var menu; + var menu, + template = ''+ + ''+ + '' + + '' + + '
  • '+ + '
    ' + + '
    '; + inject(function($compile, $rootScope) { - menu = $compile([ - '', - '', - '', - '
  • ', - '
    ' - ].join(''))($rootScope); + menu = $compile(template)($rootScope); }); return menu; } @@ -40,13 +44,21 @@ describe('md-menu directive', function() { it('opens on click', function() { var menu = setup(); openMenu(menu); - var menuContainer = getOpenMenuContainer(); - expect(menuContainer.length).toBe(1); + expect(getOpenMenuContainer().length).toBe(1); closeMenu(menu); - menuContainer = getOpenMenuContainer(); - expect(menuContainer.length).toBe(0); + expect(getOpenMenuContainer().length).toBe(0); }); + it('should not propagate the click event', function() { + var clickDetected = false, menu = setup(); + menu.on('click',function() { clickDetected = true; }); + + openMenu(menu); + expect( clickDetected ).toBe(false); + closeMenu(menu); + expect( clickDetected ).toBe(false); + }); + it('closes on backdrop click', inject(function($document) { var menu = setup(); openMenu(menu); @@ -82,18 +94,6 @@ describe('md-menu directive', function() { 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) { @@ -105,7 +105,7 @@ describe('md-menu directive', function() { function openMenu(el) { el.children().eq(0).triggerHandler('click'); waitForMenuOpen(); - flushTimeout(); + $timeout.flush(); } function closeMenu() { @@ -126,7 +126,7 @@ describe('md-menu directive', function() { inject(function($rootScope, $animate) { $rootScope.$digest(); $animate.triggerCallbacks(); - flushTimeout(); + $timeout.flush(); }); } diff --git a/src/components/tabs/js/tabsController.js b/src/components/tabs/js/tabsController.js index 0840e81bd5d..73623d4a593 100644 --- a/src/components/tabs/js/tabsController.js +++ b/src/components/tabs/js/tabsController.js @@ -478,6 +478,7 @@ function MdTabsController ($scope, $element, $window, $timeout, $mdConstant, $md * Forces the pagination to move the focused tab into view. */ function adjustOffset (index) { + if (!elements.tabs[index]) return; if (ctrl.shouldCenterTabs) return; if (index == null) index = ctrl.focusIndex; var tab = elements.tabs[index], diff --git a/src/components/toast/toast.js b/src/components/toast/toast.js index 82b720ae10e..ef1abad16b8 100644 --- a/src/components/toast/toast.js +++ b/src/components/toast/toast.js @@ -77,7 +77,7 @@ function MdToastDirective() { * - $mdToastPreset#action(string) - adds an action button, which resolves the promise returned from `show()` if clicked. * - $mdToastPreset#highlightAction(boolean) - sets action button to be highlighted * - $mdToastPreset#capsule(boolean) - adds 'md-capsule' class to the toast (curved corners) - * - $mdToastPreset#theme(boolean) - sets the theme on the toast to theme (default is `$mdThemingProvider`'s default theme) + * - $mdToastPreset#theme(string) - sets the theme on the toast to theme (default is `$mdThemingProvider`'s default theme) */ /** diff --git a/src/components/virtualRepeater/demoVertical/index.html b/src/components/virtualRepeater/demoVertical/index.html new file mode 100644 index 00000000000..89dcb29f0dd --- /dev/null +++ b/src/components/virtualRepeater/demoVertical/index.html @@ -0,0 +1,11 @@ +
    + + +
    + {{item}} +
    +
    +
    +
    diff --git a/src/components/virtualRepeater/demoVertical/script.js b/src/components/virtualRepeater/demoVertical/script.js new file mode 100644 index 00000000000..667442368c3 --- /dev/null +++ b/src/components/virtualRepeater/demoVertical/script.js @@ -0,0 +1,9 @@ + + +angular.module('dataTableDemo', ['ngMaterial']) +.controller('AppCtrl', function($scope) { + this.items = [] + for (var i = 0; i < 1000; i++) { + this.items.push(i); + } +}); diff --git a/src/components/virtualRepeater/demoVertical/style.css b/src/components/virtualRepeater/demoVertical/style.css new file mode 100644 index 00000000000..02ed8a9085d --- /dev/null +++ b/src/components/virtualRepeater/demoVertical/style.css @@ -0,0 +1,11 @@ + +#vertical-container { + height: 300px; + width: 400px; +} + +.repeated-item { + border-bottom: 1px solid #ddd; + box-sizing: border-box; + height: 40px; +} \ No newline at end of file diff --git a/src/components/virtualRepeater/virtualRepeater.js b/src/components/virtualRepeater/virtualRepeater.js new file mode 100644 index 00000000000..5b0cc2f5a46 --- /dev/null +++ b/src/components/virtualRepeater/virtualRepeater.js @@ -0,0 +1,530 @@ +/** + * @ngdoc module + * @name material.components.virtualRepeater + */ +angular.module('material.components.virtualRepeater', [ + 'material.core', + 'material.core.gestures' +]) +.directive('mdVirtualRepeatContainer', VirtualRepeatContainerDirective) +.directive('mdVirtualRepeat', VirtualRepeatDirective); + + +/** + * @ngdoc directive + * @name mdVirtualRepeatContainer + * @module material.components.virtualRepeat + * @restrict E + * @description + * `md-virtual-repeat-container` provides the scroll container for md-virtual-repeat. + * + * Virtual repeat is a limited substitute for ng-repeat that renders only + * enough dom nodes to fill the container and recycling them as the user scrolls. + * + * @usage + * + * + *
    Hello {{i}}!
    + *
    + * + * + *
    Hello {{i}}!
    + *
    + *
    + * + * @param {boolean=} md-horizontal Whether the container should scroll horizontally + * (defaults to scrolling vertically). + */ +function VirtualRepeatContainerDirective() { + return { + controller: VirtualRepeatContainerController, + restrict: 'E', + template: virtualRepeatContainerTemplate, + compile: function virtualRepeatContainerCompile($element, $attrs) { + $element + .addClass('md-virtual-repeat-container') + .addClass($attrs.mdHorizontal ? 'md-horizontal' : 'md-vertical'); + } + }; +} + + +function virtualRepeatContainerTemplate($element, $attrs) { + var innerHtml = $element[0].innerHTML; + $element[0].innerHTML = ''; + + return '
    ' + + '
    ' + + '
    ' + + innerHtml + + '
    '; +} + + +/** @ngInject */ +function VirtualRepeatContainerController($$rAF, $scope, $element, $attrs) { + this.$scope = $scope; + this.$element = $element; + this.$attrs = $attrs; + + /** @type {number} The width or height of the container */ + this.size = 0; + /** @type {number} The scroll width or height of the scroller */ + this.scrollSize = 0; + /** @type {number} The scrollLeft or scrollTop of the scroller */ + this.scrollOffset = 0; + /** @type {!VirtualRepeatController} The repeater inside of this container */ + this.repeater = null; + + this.scroller = $element[0].getElementsByClassName('md-virtual-repeat-scroller')[0]; + this.sizer = this.scroller.getElementsByClassName('md-virtual-repeat-sizer')[0]; + this.offsetter = this.scroller.getElementsByClassName('md-virtual-repeat-offsetter')[0]; + + $$rAF(angular.bind(this, this.updateSize)); + + // TODO: Come up with a more robust (But hopefully also quick!) way of + // detecting that we're not visible. + if ($attrs.ngHide) { + $scope.$watch($attrs.ngHide, angular.bind(this, function(hidden) { + if (!hidden) { + $$rAF(angular.bind(this, this.updateSize)); + } + })); + } +} + + +/** Called by the md-virtual-repeat inside of the container at startup. */ +VirtualRepeatContainerController.prototype.register = function(repeaterCtrl) { + this.repeater = repeaterCtrl; + + angular.element(this.scroller) + .on('scroll wheel touchmove touchend', angular.bind(this, this.handleScroll_)); +}; + + +/** @return {boolean} Whether the container is configured for horizontal scrolling. */ +VirtualRepeatContainerController.prototype.isHorizontal = function() { + return this.$attrs.hasOwnProperty('mdHorizontal'); +}; + + +/** @return {number} The size (width or height) of the container. */ +VirtualRepeatContainerController.prototype.getSize = function() { + return this.size; +}; + + +/** Instructs the container to re-measure its size. */ +VirtualRepeatContainerController.prototype.updateSize = function() { + this.size = this.isHorizontal() + ? this.$element[0].clientWidth + : this.$element[0].clientHeight; + this.repeater && this.repeater.containerUpdated(); +}; + + +/** @return {number} The container's scrollHeight or scrollWidth. */ +VirtualRepeatContainerController.prototype.getScrollSize = function() { + return this.scrollSize; +}; + + +/** + * Sets the scrollHeight or scrollWidth. Called by the repeater based on + * its item count and item size. + * @param {number} The new size. + */ +VirtualRepeatContainerController.prototype.setScrollSize = function(size) { + if (this.scrollSize !== size) { + this.sizer.style[this.isHorizontal() ? 'width' : 'height'] = size + 'px'; + } + + this.scrollSize = size; +}; + + +/** @return {number} The container's current scroll offset. */ +VirtualRepeatContainerController.prototype.getScrollOffset = function() { + return this.scrollOffset; +}; + + +VirtualRepeatContainerController.prototype.resetScroll = function() { + this.scroller[this.isHorizontal() ? 'scrollLeft' : 'scrollTop'] = 0; + this.handleScroll_(); +}; + + +VirtualRepeatContainerController.prototype.handleScroll_ = function() { + var transform, offset; + if (this.isHorizontal()) { + offset = this.scroller.scrollLeft; + transform = 'translateX('; + } else { + offset = this.scroller.scrollTop; + transform = 'translateY('; + } + + if (offset === this.scrollOffset) return; + this.scrollOffset = offset; + + var itemSize = this.repeater.getItemSize(); + transform += Math.max(0, Math.floor( + this.scrollOffset / itemSize) - VirtualRepeatController.NUM_EXTRA) * itemSize + 'px)'; + + this.offsetter.style.webkitTransform = transform; + this.offsetter.style.transform = transform; + + this.repeater.containerUpdated(); +}; + + +/** + * @ngdoc directive + * @name mdVirtualRepeat + * @module material.components.virtualRepeat + * @restrict A + * @priority 1000 + * @description + * `md-virtual-repeat` specifies an element to repeat using virtual scrolling. + * + * Virtual repeat is a limited substitute for ng-repeat that renders only + * enough dom nodes to fill the container and recycling them as the user scrolls. + * Arrays, but not objects are supported for iteration. + * Track by and (key, value) syntax are not supported. + * + * @usage + * + * + *
    Hello {{i}}!
    + *
    + * + * + *
    Hello {{i}}!
    + *
    + *
    + * + * @param {number} md-size The height or width of the repeated elements (which + * must be identical for each element). Required. + * @param {string=} md-extra-name Evaluates to an additional name to which + * the current iterated item can be assigned on the repeated scope. (Needed + * for use in md-autocomplete). + */ +function VirtualRepeatDirective($parse) { + return { + controller: VirtualRepeatController, + priority: 1000, + require: ['mdVirtualRepeat', '^^mdVirtualRepeatContainer'], + restrict: 'A', + terminal: true, + transclude: 'element', + compile: function VirtualRepeatCompile($element, $attrs) { + var expression = $attrs.mdVirtualRepeat; + var match = expression.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)\s*$/); + var repeatName = match[1]; + var repeatListExpression = $parse(match[2]); + var extraName = $attrs.mdExtraName && $parse($attrs.mdExtraName); + + return function VirtualRepeatLink($scope, $element, $attrs, ctrl, $transclude) { + ctrl[0].link_(ctrl[1], $transclude, repeatName, repeatListExpression, extraName); + }; + } + }; +} + + +/** @ngInject */ +function VirtualRepeatController($scope, $element, $attrs, $browser, $document) { + this.$scope = $scope; + this.$element = $element; + this.$attrs = $attrs; + this.$browser = $browser; + this.$document = $document; + + /** @type {!Function} Backup reference to $browser.$$checkUrlChange */ + this.browserCheckUrlChange = $browser.$$checkUrlChange; + /** @type {number} Most recent starting repeat index (based on scroll offset) */ + this.newStartIndex = 0; + /** @type {number} Most recent ending repeat index (based on scroll offset) */ + this.newEndIndex = 0; + /** @type {number} Previous starting repeat index (based on scroll offset) */ + this.startIndex = 0; + /** @type {number} Previous ending repeat index (based on scroll offset) */ + this.endIndex = 0; + // TODO: measure width/height of first element from dom if not provided. + // getComputedStyle? + /** @type {number} Height/width of repeated elements. */ + this.itemSize = $scope.$eval($attrs.mdSize); + /** + * Presently rendered blocks by repeat index. + * @type {Object} A pool of presently unused blocks. */ + this.pooledBlocks = []; +} + + +/** + * An object representing a repeated item. + * @typedef {{element: !jqLite, new: boolean, scope: !angular.Scope}} + */ +VirtualRepeatController.Block; + + +/** + * Number of additional elements to render above and below the visible area inside + * of the virtual repeat container. A higher number results in less flicker when scrolling + * very quickly in Safari, but comes with a higher rendering and dirty-checking cost. + * @const {number} + */ +VirtualRepeatController.NUM_EXTRA = 3; + + +/** + * Called at startup by the md-virtual-repeat postLink function. + * @param {!VirtualRepeatContainerController} container The container's controller. + * @param {!Function} transclude The repeated element's bound transclude function. + * @param {string} repeatName The left hand side of the repeat expression, indicating + * the name for each item in the array. + * @param {!Function} repeatListExpression A compiled expression based on the right hand side + * of the repeat expression. Points to the array to repeat over. + * @param {string|undefined} extraName The optional extra repeatName. + */ +VirtualRepeatController.prototype.link_ = + function(container, transclude, repeatName, repeatListExpression, extraName) { + this.container = container; + this.transclude = transclude; + this.repeatName = repeatName; + this.repeatListExpression = repeatListExpression; + this.extraName = extraName; + this.sized = false; + + this.container.register(this); +}; + + +/** + * Called by the container. Informs us that the containers scroll or size has + * changed. + */ +VirtualRepeatController.prototype.containerUpdated = function() { + if (!this.sized) { + this.sized = true; + this.$scope.$watchCollection(this.repeatListExpression, + angular.bind(this, this.virtualRepeatUpdate_)); + this.items = this.repeatListExpression(this.$scope); + } + + this.updateIndexes_(); + + if (this.newStartIndex !== this.startIndex || + this.newEndIndex !== this.endIndex || + this.container.getScrollOffset() > this.container.getScrollSize()) { + this.virtualRepeatUpdate_(this.items, this.items); + } +}; + + +/** + * Called by the container. Returns the size of a single repeated item. + * @return {number} Size of a repeated item. + */ +VirtualRepeatController.prototype.getItemSize = function() { + return this.itemSize; +}; + + +/** + * Updates the order and visible offset of repeated blocks in response to scrolling + * or items updates. + * @private + */ +VirtualRepeatController.prototype.virtualRepeatUpdate_ = function(items, oldItems) { + var itemsLength = items ? items.length : 0; + + // If the number of items shrank, scroll up to the top. + if (this.items && itemsLength < this.items.length && this.container.getScrollOffset() !== 0) { + this.items = items; + this.container.resetScroll(); + return; + } + + this.items = items; + if (items !== oldItems) { + this.updateIndexes_(); + } + + this.parentNode = this.$element[0].parentNode; + this.container.setScrollSize(itemsLength * this.itemSize); + + // Detach and pool any blocks that are no longer in the viewport. + Object.keys(this.blocks).forEach(function(blockIndex) { + var index = parseInt(blockIndex, 10); + if (index < this.newStartIndex || index >= this.newEndIndex) { + this.poolBlock_(index); + } + }, this); + + // Add needed blocks. + // For performance reasons, temporarily block browser url checks as we digest + // the restored block scopes ($$checkUrlChange reads window.location to + // check for changes and trigger route change, etc, which we don't need when + // trying to scroll at 60fps). + this.$browser.$$checkUrlChange = angular.noop; + + var i, block, + newStartBlocks = [], + newEndBlocks = []; + + // Collect blocks at the top. + for (i = this.newStartIndex; i < this.newEndIndex && this.blocks[i] == null; i++) { + block = this.getBlock_(i); + this.updateBlock_(block, i); + newStartBlocks.push(block); + } + + // Update blocks that are already rendered. + for (; this.blocks[i] != null; i++) { + this.updateBlock_(this.blocks[i], i); + } + var maxIndex = i - 1; + + // Collect blocks at the end. + for (; i < this.newEndIndex; i++) { + block = this.getBlock_(i); + this.updateBlock_(block, i); + newEndBlocks.push(block); + } + + // Attach collected blocks to the document. + if (newStartBlocks.length) { + this.parentNode.insertBefore( + this.domFragmentFromBlocks_(newStartBlocks), + this.$element[0].nextSibling); + } + if (newEndBlocks.length) { + this.parentNode.insertBefore( + this.domFragmentFromBlocks_(newEndBlocks), + this.blocks[maxIndex] && this.blocks[maxIndex].element[0].nextSibling); + } + + // Restore $$checkUrlChange. + this.$browser.$$checkUrlChange = this.browserCheckUrlChange; + + this.startIndex = this.newStartIndex; + this.endIndex = this.newEndIndex; +}; + + +/** + * @param {number} index Where the block is to be in the repeated list. + * @return {!VirtualRepeatController.Block} A new or pooled block to place at the specified index. + * @private + */ +VirtualRepeatController.prototype.getBlock_ = function(index) { + if (this.pooledBlocks.length) { + return this.pooledBlocks.pop(); + } + + var block; + this.transclude(angular.bind(this, function(clone, scope) { + block = { + element: clone, + new: true, + scope: scope + }; + + this.updateScope_(scope, index); + this.parentNode.appendChild(clone[0]); + })); + + return block; +}; + + +/** + * Updates and if not in a digest cycle, digests the specified block's scope to the data + * at the specified index. + * @param {!VirtualRepeatController.Block} block The block whose scope should be updated. + * @param {number} index The index to set. + * @private + */ +VirtualRepeatController.prototype.updateBlock_ = function(block, index) { + this.blocks[index] = block; + + if (!block.new && + (block.scope.$index === index && block.scope[this.repeatName] === this.items[index])) { + return; + } + block.new = false; + + // Update and digest the block's scope. + this.updateScope_(block.scope, index); + + // Perform digest before reattaching the block. + // Any resulting synchronous dom mutations should be much faster as a result. + // This might break some directives, but I'm going to try it for now. + if (!this.$scope.$root.$$phase) { + block.scope.$digest(); + } +}; + + +/** + * Updates scope to the data at the specified index. + * @param {!angular.Scope} scope The scope which should be updated. + * @param {number} index The index to set. + * @private + */ +VirtualRepeatController.prototype.updateScope_ = function(scope, index) { + scope.$index = index; + scope[this.repeatName] = this.items[index]; + if (this.extraName) scope[this.extraName(this.$scope)] = this.items[index]; +}; + + +/** + * Pools the block at the specified index (Pulls its element out of the dom and stores it). + * @param {number} index The index at which the block to pool is stored. + * @private + */ +VirtualRepeatController.prototype.poolBlock_ = function(index) { + this.pooledBlocks.push(this.blocks[index]); + this.parentNode.removeChild(this.blocks[index].element[0]); + delete this.blocks[index]; +}; + + +/** + * Produces a dom fragment containing the elements from the list of blocks. + * @param {!Array} blocks The blocks whose elements + * should be added to the document fragment. + * @return {DocumentFragment} + * @private + */ +VirtualRepeatController.prototype.domFragmentFromBlocks_ = function(blocks) { + var fragment = this.$document[0].createDocumentFragment(); + blocks.forEach(function(block) { + fragment.appendChild(block.element[0]); + }); + return fragment; +}; + + +/** + * Updates start and end indexes based on length of repeated items and container size. + * @private + */ +VirtualRepeatController.prototype.updateIndexes_ = function() { + var itemsLength = this.items ? this.items.length : 0; + var containerLength = Math.ceil(this.container.getSize() / this.itemSize); + + this.newStartIndex = Math.max(0, Math.min( + itemsLength - containerLength, + Math.floor(this.container.getScrollOffset() / this.itemSize))); + this.newEndIndex = Math.min(itemsLength, this.newStartIndex + containerLength + + VirtualRepeatController.NUM_EXTRA); + this.newStartIndex = Math.max(0, this.newStartIndex - VirtualRepeatController.NUM_EXTRA); +}; diff --git a/src/components/virtualRepeater/virtualRepeater.scss b/src/components/virtualRepeater/virtualRepeater.scss new file mode 100644 index 00000000000..8ae769fa3c3 --- /dev/null +++ b/src/components/virtualRepeater/virtualRepeater.scss @@ -0,0 +1,58 @@ +$virtual-repeat-scrollbar-width: 16px; + +.md-virtual-repeat-container { + box-sizing: border-box; + display: block; + margin: 0; + overflow: hidden; + padding: 0; + position: relative; + + .md-virtual-repeat-scroller { + bottom: 0; + box-sizing: border-box; + left: 0; + margin: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 0; + position: absolute; + right: 0; + top: 0; + } + + .md-virtual-repeat-sizer { + box-sizing: border-box; + height: 1px; + display: inline-block; + margin: 0; + padding: 0; + width: 1px; + } + + .md-virtual-repeat-offsetter { + box-sizing: border-box; + left: 0; + margin: 0; + padding: 0; + position: absolute; + // Leave room for the scorll bar. + // TODO: Will probably need to performa measurements at runtime. + right: $virtual-repeat-scrollbar-width; + top: 0; + } +} + +.md-virtual-repeat-container.md-horizontal { + .md-virtual-repeat-scroller { + overflow-x: auto; + overflow-y: hidden; + } + + .md-virtual-repeat-offsetter { + // Leave room for the scorll bar. + // TODO: Will probably need to performa measurements at runtime. + bottom: $virtual-repeat-scrollbar-width; + right: auto; + } +} diff --git a/src/components/virtualRepeater/virtualRepeater.spec.js b/src/components/virtualRepeater/virtualRepeater.spec.js new file mode 100644 index 00000000000..1db40c619ab --- /dev/null +++ b/src/components/virtualRepeater/virtualRepeater.spec.js @@ -0,0 +1,221 @@ +describe('', function() { + + beforeEach(module('ngMaterial-mock', 'material.components.virtualRepeater')); + + var CONTAINER_HTML = '' + + ''; + var REPEATER_HTML = '
    ' + + '{{i}} {{$index}}
    '; + var container, repeater, component, $$rAF, $compile, $document, scope, + frameFunctions, scroller, sizer, offsetter; + + var NUM_ITEMS = 110, + VERTICAL_PX = 100, + HORIZONTAL_PX = 150, + ITEM_SIZE = 10; + + beforeEach(inject(function(_$$rAF_, _$compile_, _$document_, $rootScope) { + repeater = angular.element(REPEATER_HTML); + container = angular.element(CONTAINER_HTML).append(repeater); + component = null; + $$rAF = _$$rAF_; + $compile = _$compile_; + $document = _$document_; + scope = $rootScope.$new(); + frameFunctions = []; + scroller = null; + sizer = null; + offsetter = null; + })); + + afterEach(function() { + container.remove(); + component && component.remove(); + scope.$destroy(); + }); + + function createRepeater() { + angular.element($document[0].body).append(container); + component = $compile(container)(scope); + + scroller = angular.element(component[0].querySelector('.md-virtual-repeat-scroller')); + sizer = angular.element(component[0].querySelector('.md-virtual-repeat-sizer')); + offsetter = angular.element(component[0].querySelector('.md-virtual-repeat-offsetter')); + + return component; + } + + function createItems(num) { + var items = []; + + for (var i = 0; i < num; i++) { + items.push('s' + (i * 2) + 's'); + } + + return items; + } + + function getRepeated() { + return component[0].querySelectorAll('[md-virtual-repeat]'); + } + + it('should render only enough items to fill the viewport + 3 (vertical)', function() { + createRepeater(); + scope.items = createItems(NUM_ITEMS); + scope.$apply(); + $$rAF.flush(); + + expect(getRepeated().length) + .toBe(VERTICAL_PX / ITEM_SIZE + VirtualRepeatController.NUM_EXTRA); + expect(sizer[0].offsetHeight).toBe(NUM_ITEMS * ITEM_SIZE); + }); + + it('should render only enough items to fill the viewport + 3 (horizontal)', function() { + container.attr('md-horizontal', 'true'); + createRepeater(); + scope.items = createItems(NUM_ITEMS); + scope.$apply(); + $$rAF.flush(); + + expect(getRepeated().length) + .toBe(HORIZONTAL_PX / ITEM_SIZE + VirtualRepeatController.NUM_EXTRA); + expect(sizer[0].offsetWidth).toBe(NUM_ITEMS * ITEM_SIZE); + }); + + it('should reposition and swap items on scroll (vertical)', function() { + createRepeater(); + scope.items = createItems(NUM_ITEMS); + scope.$apply(); + $$rAF.flush(); + + var repeated; + + // Don't quite scroll past the first item. + scroller[0].scrollTop = ITEM_SIZE - 1; + scroller.triggerHandler('scroll'); + expect(offsetter.css('transform')).toBe('translateY(0px)'); + repeated = getRepeated(); + expect(repeated.length).toBe(VERTICAL_PX / ITEM_SIZE + VirtualRepeatController.NUM_EXTRA); + expect(repeated[0].textContent).toBe('s0s 0'); + + // Scroll past the first item. + // Expect that one new item is created. + scroller[0].scrollTop = ITEM_SIZE; + scroller.triggerHandler('scroll'); + expect(offsetter.css('transform')).toBe('translateY(0px)'); + repeated = getRepeated(); + expect(repeated.length).toBe(VERTICAL_PX / ITEM_SIZE + VirtualRepeatController.NUM_EXTRA + 1); + expect(repeated[0].textContent).toBe('s0s 0'); + + // Scroll past the fourth item. + // Expect that we now have the full set of extra items above and below. + scroller[0].scrollTop = ITEM_SIZE * (VirtualRepeatController.NUM_EXTRA + 1); + scroller.triggerHandler('scroll'); + expect(offsetter.css('transform')).toBe('translateY(10px)'); + repeated = getRepeated(); + expect(repeated.length).toBe(VERTICAL_PX / ITEM_SIZE + VirtualRepeatController.NUM_EXTRA * 2); + expect(repeated[0].textContent).toBe('s2s 1'); + + // Scroll to the end. + // Expect the bottom extra items to be removed (pooled). + scroller[0].scrollTop = 1000; + scroller.triggerHandler('scroll'); + expect(offsetter.css('transform')).toBe('translateY(970px)'); + repeated = getRepeated(); + expect(repeated.length).toBe(VERTICAL_PX / ITEM_SIZE + VirtualRepeatController.NUM_EXTRA); + expect(repeated[0].textContent).toBe('s194s 97'); + }); + + it('should reposition and swap items on scroll (horizontal)', function() { + container.attr('md-horizontal', 'true'); + createRepeater(); + scope.items = createItems(NUM_ITEMS); + scope.$apply(); + $$rAF.flush(); + + var repeated; + + // Don't quite scroll past the first item. + scroller[0].scrollLeft = ITEM_SIZE - 1; + scroller.triggerHandler('scroll'); + expect(offsetter.css('transform')).toBe('translateX(0px)'); + repeated = getRepeated(); + expect(repeated.length).toBe(HORIZONTAL_PX / ITEM_SIZE + VirtualRepeatController.NUM_EXTRA); + expect(repeated[0].textContent).toBe('s0s 0'); + + // Scroll past the first item. + // Expect that we now have the full set of extra items above and below. + scroller[0].scrollLeft = ITEM_SIZE; + scroller.triggerHandler('scroll'); + expect(offsetter.css('transform')).toBe('translateX(0px)'); + repeated = getRepeated(); + expect(repeated.length) + .toBe(HORIZONTAL_PX / ITEM_SIZE + VirtualRepeatController.NUM_EXTRA + 1); + expect(repeated[0].textContent).toBe('s0s 0'); + + // Scroll past the fourth item. + // Expect that one new item is created. + scroller[0].scrollLeft = ITEM_SIZE * (VirtualRepeatController.NUM_EXTRA + 1);; + scroller.triggerHandler('scroll'); + expect(offsetter.css('transform')).toBe('translateX(10px)'); + repeated = getRepeated(); + expect(repeated.length) + .toBe(HORIZONTAL_PX / ITEM_SIZE + VirtualRepeatController.NUM_EXTRA * 2); + expect(repeated[0].textContent).toBe('s2s 1'); + + // Scroll to the end. + // Expect the bottom extra items to be removed (pooled). + scroller[0].scrollLeft = 950; + scroller.triggerHandler('scroll'); + expect(offsetter.css('transform')).toBe('translateX(920px)'); + repeated = getRepeated(); + expect(repeated.length).toBe(HORIZONTAL_PX / ITEM_SIZE + VirtualRepeatController.NUM_EXTRA); + expect(repeated[0].textContent).toBe('s184s 92'); + }); + + it('should dirty-check only the swapped scope on scroll', function() { + createRepeater(); + scope.items = createItems(NUM_ITEMS); + scope.$apply(); + $$rAF.flush(); + scroller[0].scrollTop = 100; + scroller.triggerHandler('scroll'); + + var scopes = Array.prototype.map.call(getRepeated(), function(elem) { + var s = angular.element(elem).scope(); + spyOn(s, '$digest').and.callThrough(); + return s; + }); + + // Scroll up by one. + // Expect only the last (index 15) scope to have $digested. + scroller[0].scrollTop = 90; + scroller.triggerHandler('scroll'); + expect(scopes[15].$digest).toHaveBeenCalled(); + expect(scopes[14].$digest).not.toHaveBeenCalled(); + + // Scroll down by two. + // Expect only the first scope to have $digested. + scroller[0].scrollTop = 110; + scroller.triggerHandler('scroll'); + expect(scopes[0].$digest).toHaveBeenCalled(); + expect(scopes[1].$digest).not.toHaveBeenCalled(); + }); + + it('should update when the watched array changes', function() { + createRepeater(); + scope.items = createItems(NUM_ITEMS); + scope.$apply(); + $$rAF.flush(); + scroller[0].scrollTop = 100; + scroller.triggerHandler('scroll'); + + scope.items = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']; + scope.$apply(); + + expect(scroller[0].scrollTop).toBe(0); + expect(getRepeated()[0].textContent).toBe('a 0'); + }); +}); diff --git a/src/core/util/util.js b/src/core/util/util.js index cc7072abfbe..19764e16897 100644 --- a/src/core/util/util.js +++ b/src/core/util/util.js @@ -7,349 +7,351 @@ var nextUniqueId = 0; angular.module('material.core') -.factory('$mdUtil', function($cacheFactory, $document, $timeout, $q, $window, $mdConstant) { - var Util; - - function getNode(el) { - return el[0] || el; - } - - return Util = { - now: window.performance ? - angular.bind(window.performance, window.performance.now) : - Date.now, - - clientRect: function(element, offsetParent, isOffsetRect) { - var node = getNode(element); - offsetParent = getNode(offsetParent || node.offsetParent || document.body); - var nodeRect = node.getBoundingClientRect(); - - // The user can ask for an offsetRect: a rect relative to the offsetParent, - // or a clientRect: a rect relative to the page - var offsetRect = isOffsetRect ? - offsetParent.getBoundingClientRect() : - { left: 0, top: 0, width: 0, height: 0 }; - return { - left: nodeRect.left - offsetRect.left, - top: nodeRect.top - offsetRect.top, - width: nodeRect.width, - height: nodeRect.height - }; - }, - offsetRect: function(element, offsetParent) { - return Util.clientRect(element, offsetParent, true); - }, - - // Annoying method to copy nodes to an array, thanks to IE - nodesToArray: function (nodes) { - var results = []; - for (var i = 0; i < nodes.length; ++i) { - results.push(nodes.item(i)); - } - return results; - }, - - // Disables scroll around the passed element. - disableScrollAround: function(element) { - if (Util.disableScrollAround._enableScrolling) return Util.disableScrollAround._enableScrolling; - element = angular.element(element); - var body = $document[0].body, + .factory('$mdUtil', function ($cacheFactory, $document, $timeout, $q, $window, $mdConstant) { + var Util; + + function getNode(el) { + return el[0] || el; + } + + return Util = { + now: window.performance ? + angular.bind(window.performance, window.performance.now) : + Date.now, + + clientRect: function (element, offsetParent, isOffsetRect) { + var node = getNode(element); + offsetParent = getNode(offsetParent || node.offsetParent || document.body); + var nodeRect = node.getBoundingClientRect(); + + // The user can ask for an offsetRect: a rect relative to the offsetParent, + // or a clientRect: a rect relative to the page + var offsetRect = isOffsetRect ? + offsetParent.getBoundingClientRect() : + {left: 0, top: 0, width: 0, height: 0}; + return { + left: nodeRect.left - offsetRect.left, + top: nodeRect.top - offsetRect.top, + width: nodeRect.width, + height: nodeRect.height + }; + }, + offsetRect: function (element, offsetParent) { + return Util.clientRect(element, offsetParent, true); + }, + + // Annoying method to copy nodes to an array, thanks to IE + nodesToArray: function (nodes) { + var results = []; + for (var i = 0; i < nodes.length; ++i) { + results.push(nodes.item(i)); + } + return results; + }, + + // Disables scroll around the passed element. + disableScrollAround: function (element) { + if (Util.disableScrollAround._enableScrolling) return Util.disableScrollAround._enableScrolling; + element = angular.element(element); + var body = $document[0].body, restoreBody = disableBodyScroll(), restoreElement = disableElementScroll(); - return Util.disableScrollAround._enableScrolling = function () { - restoreBody(); - restoreElement(); - delete Util.disableScrollAround._enableScrolling; - }; - - // Creates a virtual scrolling mask to absorb touchmove, keyboard, scrollbar clicking, and wheel events - function disableElementScroll() { - var zIndex = $window.getComputedStyle(element[0]).zIndex - 1; - if (isNaN(zIndex)) zIndex = 99; - var scrollMask = angular.element( + return Util.disableScrollAround._enableScrolling = function () { + restoreBody(); + restoreElement(); + delete Util.disableScrollAround._enableScrolling; + }; + + // Creates a virtual scrolling mask to absorb touchmove, keyboard, scrollbar clicking, and wheel events + function disableElementScroll() { + var zIndex = $window.getComputedStyle(element[0]).zIndex - 1; + if (isNaN(zIndex)) zIndex = 99; + var scrollMask = angular.element( '
    ' + '
    ' + '
    '); - body.appendChild(scrollMask[0]); - - scrollMask.on('wheel', preventDefault); - scrollMask.on('touchmove', preventDefault); - $document.on('keydown', disableKeyNav); - - return function restoreScroll () { - scrollMask.off('wheel'); - scrollMask.off('touchmove'); - scrollMask[0].parentNode.removeChild(scrollMask[0]); - $document.off('keydown', disableKeyNav); - delete Util.disableScrollAround._enableScrolling; - }; + body.appendChild(scrollMask[0]); + + scrollMask.on('wheel', preventDefault); + scrollMask.on('touchmove', preventDefault); + $document.on('keydown', disableKeyNav); + + return function restoreScroll() { + scrollMask.off('wheel'); + scrollMask.off('touchmove'); + scrollMask[0].parentNode.removeChild(scrollMask[0]); + $document.off('keydown', disableKeyNav); + delete Util.disableScrollAround._enableScrolling; + }; + + // Prevent keypresses from elements inside the body + // used to stop the keypresses that could cause the page to scroll + // (arrow keys, spacebar, tab, etc). + function disableKeyNav(e) { + //-- temporarily removed this logic, will possibly re-add at a later date + return; + if (!element[0].contains(e.target)) { + e.preventDefault(); + e.stopImmediatePropagation(); + } + } - // Prevent keypresses from elements inside the body - // used to stop the keypresses that could cause the page to scroll - // (arrow keys, spacebar, tab, etc). - function disableKeyNav(e) { - //-- temporarily removed this logic, will possibly re-add at a later date - return; - if (!element[0].contains(e.target)) { + function preventDefault(e) { e.preventDefault(); - e.stopImmediatePropagation(); } } - function preventDefault(e) { - e.preventDefault(); + // Converts the body to a position fixed block and translate it to the proper scroll + // position + function disableBodyScroll() { + var restoreStyle = body.getAttribute('style') || ''; + var scrollOffset = body.scrollTop + body.parentElement.scrollTop; + var clientWidth = body.clientWidth; + + applyStyles(body, { + position: 'fixed', + width: '100%', + overflowY: 'scroll', + top: -scrollOffset + 'px' + }); + + if (body.clientWidth < clientWidth) applyStyles(body, {overflow: 'hidden'}); + + return function restoreScroll() { + body.setAttribute('style', restoreStyle); + body.scrollTop = scrollOffset; + }; } - } - - // Converts the body to a position fixed block and translate it to the proper scroll - // position - function disableBodyScroll() { - var restoreStyle = body.getAttribute('style') || ''; - var scrollOffset = body.scrollTop + body.parentElement.scrollTop; - var clientWidth = body.clientWidth; - - applyStyles(body, { - position: 'fixed', - width: '100%', - overflowY: 'scroll', - top: -scrollOffset + 'px' - }); - - if (body.clientWidth < clientWidth) applyStyles(body, { overflow: 'hidden' }); - - return function restoreScroll() { - body.setAttribute('style', restoreStyle); - body.scrollTop = scrollOffset; - }; - } - function applyStyles (el, styles) { - for (var key in styles) { - el.style[key] = styles[key]; - } - } - }, - enableScrolling: function () { - var method = this.disableScrollAround._enableScrolling; - method && method(); - }, - floatingScrollbars: function() { - if (this.floatingScrollbars.cached === undefined) { - var tempNode = angular.element('
    '); - $document[0].body.appendChild(tempNode[0]); - this.floatingScrollbars.cached = (tempNode[0].offsetWidth == tempNode[0].childNodes[0].offsetWidth); - tempNode.remove(); - } - return this.floatingScrollbars.cached; - }, - - // Mobile safari only allows you to set focus in click event listeners... - forceFocus: function(element) { - var node = element[0] || element; - - document.addEventListener('click', function focusOnClick(ev) { - if (ev.target === node && ev.$focus) { - node.focus(); - ev.stopImmediatePropagation(); - ev.preventDefault(); - node.removeEventListener('click', focusOnClick); + function applyStyles(el, styles) { + for (var key in styles) { + el.style[key] = styles[key]; + } } - }, true); - - var newEvent = document.createEvent('MouseEvents'); - newEvent.initMouseEvent('click', false, true, window, {}, 0, 0, 0, 0, - false, false, false, false, 0, null); - newEvent.$material = true; - newEvent.$focus = true; - node.dispatchEvent(newEvent); - }, - - transitionEndPromise: function(element, opts) { - opts = opts || {}; - var deferred = $q.defer(); - element.on($mdConstant.CSS.TRANSITIONEND, finished); - function finished(ev) { - // Make sure this transitionend didn't bubble up from a child - if (!ev || ev.target === element[0]) { - element.off($mdConstant.CSS.TRANSITIONEND, finished); - deferred.resolve(); + }, + enableScrolling: function () { + var method = this.disableScrollAround._enableScrolling; + method && method(); + }, + floatingScrollbars: function () { + if (this.floatingScrollbars.cached === undefined) { + var tempNode = angular.element('
    '); + $document[0].body.appendChild(tempNode[0]); + this.floatingScrollbars.cached = (tempNode[0].offsetWidth == tempNode[0].childNodes[0].offsetWidth); + tempNode.remove(); } - } - if (opts.timeout) $timeout(finished, opts.timeout); - return deferred.promise; - }, - - fakeNgModel: function() { - return { - $fake: true, - $setTouched: angular.noop, - $setViewValue: function(value) { - this.$viewValue = value; - this.$render(value); - this.$viewChangeListeners.forEach(function(cb) { cb(); }); - }, - $isEmpty: function(value) { - return ('' + value).length === 0; - }, - $parsers: [], - $formatters: [], - $viewChangeListeners: [], - $render: angular.noop - }; - }, - - // Returns a function, that, as long as it continues to be invoked, will not - // be triggered. The function will be called after it stops being called for - // N milliseconds. - // @param wait Integer value of msecs to delay (since last debounce reset); default value 10 msecs - // @param invokeApply should the $timeout trigger $digest() dirty checking - debounce: function (func, wait, scope, invokeApply) { - var timer; - - return function debounced() { - var context = scope, - args = Array.prototype.slice.call(arguments); - - $timeout.cancel(timer); - timer = $timeout(function() { - - timer = undefined; - func.apply(context, args); - - }, wait || 10, invokeApply ); - }; - }, - - // Returns a function that can only be triggered every `delay` milliseconds. - // In other words, the function will not be called unless it has been more - // than `delay` milliseconds since the last call. - throttle: function throttle(func, delay) { - var recent; - return function throttled() { - var context = this; - var args = arguments; - var now = Util.now(); - - if (!recent || (now - recent > delay)) { - func.apply(context, args); - recent = now; + return this.floatingScrollbars.cached; + }, + + // Mobile safari only allows you to set focus in click event listeners... + forceFocus: function (element) { + var node = element[0] || element; + + document.addEventListener('click', function focusOnClick(ev) { + if (ev.target === node && ev.$focus) { + node.focus(); + ev.stopImmediatePropagation(); + ev.preventDefault(); + node.removeEventListener('click', focusOnClick); + } + }, true); + + var newEvent = document.createEvent('MouseEvents'); + newEvent.initMouseEvent('click', false, true, window, {}, 0, 0, 0, 0, + false, false, false, false, 0, null); + newEvent.$material = true; + newEvent.$focus = true; + node.dispatchEvent(newEvent); + }, + + transitionEndPromise: function (element, opts) { + opts = opts || {}; + var deferred = $q.defer(); + element.on($mdConstant.CSS.TRANSITIONEND, finished); + function finished(ev) { + // Make sure this transitionend didn't bubble up from a child + if (!ev || ev.target === element[0]) { + element.off($mdConstant.CSS.TRANSITIONEND, finished); + deferred.resolve(); + } } - }; - }, - - /** - * Measures the number of milliseconds taken to run the provided callback - * function. Uses a high-precision timer if available. - */ - time: function time(cb) { - var start = Util.now(); - cb(); - return Util.now() - start; - }, - - /** - * Get a unique ID. - * - * @returns {string} an unique numeric string - */ - nextUid: function() { - return '' + nextUniqueId++; - }, - - // Stop watchers and events from firing on a scope without destroying it, - // by disconnecting it from its parent and its siblings' linked lists. - disconnectScope: function disconnectScope(scope) { - if (!scope) return; - - // we can't destroy the root scope or a scope that has been already destroyed - if (scope.$root === scope) return; - if (scope.$$destroyed ) return; - - var parent = scope.$parent; - scope.$$disconnected = true; - - // See Scope.$destroy - if (parent.$$childHead === scope) parent.$$childHead = scope.$$nextSibling; - if (parent.$$childTail === scope) parent.$$childTail = scope.$$prevSibling; - if (scope.$$prevSibling) scope.$$prevSibling.$$nextSibling = scope.$$nextSibling; - if (scope.$$nextSibling) scope.$$nextSibling.$$prevSibling = scope.$$prevSibling; - - scope.$$nextSibling = scope.$$prevSibling = null; - - }, - - // Undo the effects of disconnectScope above. - reconnectScope: function reconnectScope(scope) { - if (!scope) return; - - // we can't disconnect the root node or scope already disconnected - if (scope.$root === scope) return; - if (!scope.$$disconnected) return; - - var child = scope; - - var parent = child.$parent; - child.$$disconnected = false; - // See Scope.$new for this logic... - child.$$prevSibling = parent.$$childTail; - if (parent.$$childHead) { - parent.$$childTail.$$nextSibling = child; - parent.$$childTail = child; - } else { - parent.$$childHead = parent.$$childTail = child; - } - }, - - /* - * getClosest replicates jQuery.closest() to walk up the DOM tree until it finds a matching nodeName - * - * @param el Element to start walking the DOM from - * @param tagName Tag name to find closest to el, such as 'form' - */ - getClosest: function getClosest(el, tagName, onlyParent) { - if (el instanceof angular.element) el = el[0]; - tagName = tagName.toUpperCase(); - if (onlyParent) el = el.parentNode; - if (!el) return null; - do { - if (el.nodeName === tagName) { - return el; + + if (opts.timeout) $timeout(finished, opts.timeout); + return deferred.promise; + }, + + fakeNgModel: function () { + return { + $fake: true, + $setTouched: angular.noop, + $setViewValue: function (value) { + this.$viewValue = value; + this.$render(value); + this.$viewChangeListeners.forEach(function (cb) { + cb(); + }); + }, + $isEmpty: function (value) { + return ('' + value).length === 0; + }, + $parsers: [], + $formatters: [], + $viewChangeListeners: [], + $render: angular.noop + }; + }, + + // Returns a function, that, as long as it continues to be invoked, will not + // be triggered. The function will be called after it stops being called for + // N milliseconds. + // @param wait Integer value of msecs to delay (since last debounce reset); default value 10 msecs + // @param invokeApply should the $timeout trigger $digest() dirty checking + debounce: function (func, wait, scope, invokeApply) { + var timer; + + return function debounced() { + var context = scope, + args = Array.prototype.slice.call(arguments); + + $timeout.cancel(timer); + timer = $timeout(function () { + + timer = undefined; + func.apply(context, args); + + }, wait || 10, invokeApply); + }; + }, + + // Returns a function that can only be triggered every `delay` milliseconds. + // In other words, the function will not be called unless it has been more + // than `delay` milliseconds since the last call. + throttle: function throttle(func, delay) { + var recent; + return function throttled() { + var context = this; + var args = arguments; + var now = Util.now(); + + if (!recent || (now - recent > delay)) { + func.apply(context, args); + recent = now; + } + }; + }, + + /** + * Measures the number of milliseconds taken to run the provided callback + * function. Uses a high-precision timer if available. + */ + time: function time(cb) { + var start = Util.now(); + cb(); + return Util.now() - start; + }, + + /** + * Get a unique ID. + * + * @returns {string} an unique numeric string + */ + nextUid: function () { + return '' + nextUniqueId++; + }, + + // Stop watchers and events from firing on a scope without destroying it, + // by disconnecting it from its parent and its siblings' linked lists. + disconnectScope: function disconnectScope(scope) { + if (!scope) return; + + // we can't destroy the root scope or a scope that has been already destroyed + if (scope.$root === scope) return; + if (scope.$$destroyed) return; + + var parent = scope.$parent; + scope.$$disconnected = true; + + // See Scope.$destroy + if (parent.$$childHead === scope) parent.$$childHead = scope.$$nextSibling; + if (parent.$$childTail === scope) parent.$$childTail = scope.$$prevSibling; + if (scope.$$prevSibling) scope.$$prevSibling.$$nextSibling = scope.$$nextSibling; + if (scope.$$nextSibling) scope.$$nextSibling.$$prevSibling = scope.$$prevSibling; + + scope.$$nextSibling = scope.$$prevSibling = null; + + }, + + // Undo the effects of disconnectScope above. + reconnectScope: function reconnectScope(scope) { + if (!scope) return; + + // we can't disconnect the root node or scope already disconnected + if (scope.$root === scope) return; + if (!scope.$$disconnected) return; + + var child = scope; + + var parent = child.$parent; + child.$$disconnected = false; + // See Scope.$new for this logic... + child.$$prevSibling = parent.$$childTail; + if (parent.$$childHead) { + parent.$$childTail.$$nextSibling = child; + parent.$$childTail = child; + } else { + parent.$$childHead = parent.$$childTail = child; } - } while (el = el.parentNode); - return null; - }, - - /** - * Functional equivalent for $element.filter(‘md-bottom-sheet’) - * useful with interimElements where the element and its container are important... - */ - extractElementByName: function (element, nodeName) { - for (var i = 0, len = element.length; i < len; i++) { - if (element[i].nodeName.toLowerCase() === nodeName){ - return angular.element(element[i]); + }, + + /* + * getClosest replicates jQuery.closest() to walk up the DOM tree until it finds a matching nodeName + * + * @param el Element to start walking the DOM from + * @param tagName Tag name to find closest to el, such as 'form' + */ + getClosest: function getClosest(el, tagName, onlyParent) { + if (el instanceof angular.element) el = el[0]; + tagName = tagName.toUpperCase(); + if (onlyParent) el = el.parentNode; + if (!el) return null; + do { + if (el.nodeName === tagName) { + return el; + } + } while (el = el.parentNode); + return null; + }, + + /** + * Functional equivalent for $element.filter(‘md-bottom-sheet’) + * useful with interimElements where the element and its container are important... + */ + extractElementByName: function (element, nodeName) { + for (var i = 0, len = element.length; i < len; i++) { + if (element[i].nodeName.toLowerCase() === nodeName) { + return angular.element(element[i]); + } } + return element; + }, + + /** + * Give optional properties with no value a boolean true if attr provided or false otherwise + */ + initOptionalProperties: function (scope, attr, defaults) { + defaults = defaults || {}; + angular.forEach(scope.$$isolateBindings, function (binding, key) { + if (binding.optional && angular.isUndefined(scope[key])) { + var attrIsDefined = angular.isDefined(attr[binding.attrName]); + scope[key] = angular.isDefined(defaults[key]) ? defaults[key] : attrIsDefined; + } + }); } - return element; - }, - - /** - * Give optional properties with no value a boolean true by default - */ - initOptionalProperties: function (scope, attr, defaults ) { - defaults = defaults || { }; - angular.forEach(scope.$$isolateBindings, function (binding, key) { - if (binding.optional && angular.isUndefined(scope[key])) { - var hasKey = attr.hasOwnProperty(attr.$normalize(binding.attrName)); - - scope[key] = angular.isDefined(defaults[key]) ? defaults[key] : hasKey; - } - }); - } - }; + }; -}); + }); /* * Since removing jQuery from the demos, some code that uses `element.focus()` is broken. @@ -359,15 +361,15 @@ angular.module('material.core') * TODO(ajoslin): This should be added in a better place later. */ -angular.element.prototype.focus = angular.element.prototype.focus || function() { - if (this.length) { - this[0].focus(); - } - return this; -}; -angular.element.prototype.blur = angular.element.prototype.blur || function() { - if (this.length) { - this[0].blur(); - } - return this; -}; +angular.element.prototype.focus = angular.element.prototype.focus || function () { + if (this.length) { + this[0].focus(); + } + return this; + }; +angular.element.prototype.blur = angular.element.prototype.blur || function () { + if (this.length) { + this[0].blur(); + } + return this; + }; diff --git a/test/angular-material-mocks.js b/test/angular-material-mocks.js index 4f025786d39..723a320ada6 100644 --- a/test/angular-material-mocks.js +++ b/test/angular-material-mocks.js @@ -72,6 +72,22 @@ angular.module('ngMaterial-mock', ['ngMock', 'material.core']) return $delegate; }); + /** + * Capture $timeout.flush() errors: "No deferred tasks to be flushed" + * errors + */ + $provide.decorator('$timeout', function throttleInjector($delegate){ + + var ngFlush = $delegate.flush; + $delegate.flush = function() { + var args = Array.prototype.slice.call(arguments); + try { ngFlush.apply($delegate, args); } + catch(e) { ; } + }; + + return $delegate; + }); + }]); })(window, window.angular);