aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--angularFiles.js1
-rw-r--r--docs/src/templates/docs.js1
-rw-r--r--src/AngularPublic.js1
-rw-r--r--src/service/autoScroll.js67
-rw-r--r--src/service/location.js3
-rw-r--r--src/widgets.js8
-rw-r--r--test/service/autoScrollSpec.js175
7 files changed, 251 insertions, 5 deletions
diff --git a/angularFiles.js b/angularFiles.js
index 2a102a18..6bbaa448 100644
--- a/angularFiles.js
+++ b/angularFiles.js
@@ -8,6 +8,7 @@ angularFiles = {
'src/sanitizer.js',
'src/jqLite.js',
'src/apis.js',
+ 'src/service/autoScroll.js',
'src/service/browser.js',
'src/service/compiler.js',
'src/service/cookieStore.js',
diff --git a/docs/src/templates/docs.js b/docs/src/templates/docs.js
index 2a9f62a8..995d8bf9 100644
--- a/docs/src/templates/docs.js
+++ b/docs/src/templates/docs.js
@@ -75,7 +75,6 @@ function DocsController($location, $window, $cookies, $filter) {
scope.loading--;
scope.partialTitle = scope.futurePartialTitle;
SyntaxHighlighter.highlight();
- $window.scrollTo(0,0);
$window._gaq.push(['_trackPageview', currentPageId]);
loadDisqus(currentPageId);
};
diff --git a/src/AngularPublic.js b/src/AngularPublic.js
index 66301104..6352df33 100644
--- a/src/AngularPublic.js
+++ b/src/AngularPublic.js
@@ -66,6 +66,7 @@ function ngModule($provide, $injector) {
$provide.service('$locale', $LocaleProvider);
});
+ $provide.service('$autoScroll', $AutoScrollProvider);
$provide.service('$browser', $BrowserProvider);
$provide.service('$compile', $CompileProvider);
$provide.service('$cookies', $CookiesProvider);
diff --git a/src/service/autoScroll.js b/src/service/autoScroll.js
new file mode 100644
index 00000000..7b5b28e4
--- /dev/null
+++ b/src/service/autoScroll.js
@@ -0,0 +1,67 @@
+/**
+ * @ngdoc function
+ * @name angular.module.ng.$autoScroll
+ * @requires $window
+ * @requires $location
+ * @requires $rootScope
+ *
+ * @description
+ * When called, it checks current value of `$location.hash()` and scroll to related element,
+ * according to rules specified in
+ * {@link http://dev.w3.org/html5/spec/Overview.html#the-indicated-part-of-the-document Html5 spec}.
+ *
+ * If `$location` uses `hashbang` url (running in `hashbang` mode or `html5` mode on browser without
+ * history API support), `$autoScroll` watches the `$location.hash()` and scroll whenever it
+ * changes.
+ *
+ * You can disable `$autoScroll` service by calling `disable()` on `$autoScrollProvider`.
+ * Note: disabling is only possible before the service is instantiated !
+ */
+function $AutoScrollProvider() {
+
+ this.disable = function() {
+ this.$get = function() {return noop;};
+ };
+
+ this.$get = ['$window', '$location', '$rootScope', function($window, $location, $rootScope) {
+ var document = $window.document;
+
+ // helper function to get first anchor from a NodeList
+ // can't use filter.filter, as it accepts only instances of Array
+ // and IE can't convert NodeList to an array using [].slice
+ // TODO(vojta): use filter if we change it to accept lists as well
+ function getFirstAnchor(list) {
+ var result = null;
+ forEach(list, function(element) {
+ if (!result && lowercase(element.nodeName) === 'a') result = element;
+ });
+ return result;
+ }
+
+ function scroll() {
+ var hash = $location.hash(), elm;
+
+ // empty hash, scroll to the top of the page
+ if (!hash) $window.scrollTo(0, 0);
+
+ // element with given id
+ else if ((elm = document.getElementById(hash))) elm.scrollIntoView();
+
+ // first anchor with given name :-D
+ else if ((elm = getFirstAnchor(document.getElementsByName(hash)))) elm.scrollIntoView();
+
+ // no element and hash == 'top', scroll to the top of the page
+ else if (hash === 'top') $window.scrollTo(0, 0);
+ }
+
+ // scroll whenever hash changes (with hashbang url, regular urls are handled by browser)
+ if ($location instanceof LocationHashbangUrl) {
+ $rootScope.$watch(function() {return $location.hash();}, function() {
+ $rootScope.$evalAsync(scroll);
+ });
+ }
+
+ return scroll;
+ }];
+}
+
diff --git a/src/service/location.js b/src/service/location.js
index a29a1a15..f5928eb2 100644
--- a/src/service/location.js
+++ b/src/service/location.js
@@ -195,7 +195,7 @@ function LocationHashbangUrl(url, hashPrefix) {
}
-LocationUrl.prototype = LocationHashbangUrl.prototype = {
+LocationUrl.prototype = {
/**
* Has any change been replacing ?
@@ -374,6 +374,7 @@ LocationUrl.prototype = LocationHashbangUrl.prototype = {
}
};
+LocationHashbangUrl.prototype = inherit(LocationUrl.prototype);
function locationGetter(property) {
return function() {
diff --git a/src/widgets.js b/src/widgets.js
index c4494b29..f6cdb977 100644
--- a/src/widgets.js
+++ b/src/widgets.js
@@ -90,7 +90,7 @@ angularWidget('ng:include', function(element){
this.directives(true);
} else {
element[0]['ng:compiled'] = true;
- return ['$xhr.cache', '$element', function(xhr, element){
+ return ['$xhr.cache', '$autoScroll', '$element', function($xhr, $autoScroll, element) {
var scope = this,
changeCounter = 0,
releaseScopes = [],
@@ -114,7 +114,7 @@ angularWidget('ng:include', function(element){
releaseScopes.pop().$destroy();
}
if (src) {
- xhr('GET', src, null, function(code, response){
+ $xhr('GET', src, null, function(code, response) {
element.html(response);
if (useScope) {
childScope = useScope;
@@ -122,6 +122,7 @@ angularWidget('ng:include', function(element){
releaseScopes.push(childScope = scope.$new());
}
compiler.compile(element)(childScope);
+ $autoScroll();
scope.$eval(onloadExp);
}, false, true);
} else {
@@ -555,7 +556,7 @@ angularWidget('ng:view', function(element) {
if (!element[0]['ng:compiled']) {
element[0]['ng:compiled'] = true;
- return ['$xhr.cache', '$route', '$element', function($xhr, $route, element){
+ return ['$xhr.cache', '$route', '$autoScroll', '$element', function($xhr, $route, $autoScroll, element) {
var template;
var changeCounter = 0;
@@ -572,6 +573,7 @@ angularWidget('ng:view', function(element) {
if (newChangeCounter == changeCounter) {
element.html(response);
compiler.compile(element)($route.current.scope);
+ $autoScroll();
}
});
} else {
diff --git a/test/service/autoScrollSpec.js b/test/service/autoScrollSpec.js
new file mode 100644
index 00000000..8d04268b
--- /dev/null
+++ b/test/service/autoScrollSpec.js
@@ -0,0 +1,175 @@
+describe('$autoScroll', function() {
+
+ var elmSpy;
+
+ function addElements() {
+ var elements = sliceArgs(arguments);
+
+ return function() {
+ forEach(elements, function(identifier) {
+ var match = identifier.match(/(\w* )?(\w*)=(\w*)/),
+ jqElm = jqLite('<' + (match[1] || 'a ') + match[2] + '="' + match[3] + '"/>'),
+ elm = jqElm[0];
+
+ elmSpy[identifier] = spyOn(elm, 'scrollIntoView');
+ jqLite(document.body).append(jqElm);
+ });
+ };
+ }
+
+ function changeHashAndScroll(hash) {
+ return function($location, $autoScroll) {
+ $location.hash(hash);
+ $autoScroll();
+ };
+ }
+
+ function expectScrollingToTop($window) {
+ forEach(elmSpy, function(spy, id) {
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ expect($window.scrollTo).toHaveBeenCalledWith(0, 0);
+ }
+
+ function expectScrollingTo(identifier) {
+ return function($window) {
+ forEach(elmSpy, function(spy, id) {
+ if (identifier === id) expect(spy).toHaveBeenCalledOnce();
+ else expect(spy).not.toHaveBeenCalled();
+ });
+ expect($window.scrollTo).not.toHaveBeenCalled();
+ };
+ }
+
+ function expectNoScrolling() {
+ return expectScrollingTo(NaN);
+ }
+
+ function disableScroller() {
+ return function($autoScrollProvider) {
+ $autoScrollProvider.disable();
+ };
+ }
+
+
+ beforeEach(inject(function($provide) {
+ elmSpy = {};
+ $provide.value('$window', {
+ scrollTo: jasmine.createSpy('$window.scrollTo'),
+ document: document
+ });
+ }));
+
+
+ it('should scroll to top of the window if empty hash', inject(
+ changeHashAndScroll(''),
+ expectScrollingToTop));
+
+
+ it('should not scroll if hash does not match any element', inject(
+ addElements('id=one', 'id=two'),
+ changeHashAndScroll('non-existing'),
+ expectNoScrolling()));
+
+
+ it('should scroll to anchor element with name', inject(
+ addElements('a name=abc'),
+ changeHashAndScroll('abc'),
+ expectScrollingTo('a name=abc')));
+
+
+ it('should not scroll to other than anchor element with name', inject(
+ addElements('input name=xxl', 'select name=xxl', 'form name=xxl'),
+ changeHashAndScroll('xxl'),
+ expectNoScrolling()));
+
+
+ it('should scroll to anchor even if other element with given name exist', inject(
+ addElements('input name=some', 'a name=some'),
+ changeHashAndScroll('some'),
+ expectScrollingTo('a name=some')));
+
+
+ it('should scroll to element with id with precedence over name', inject(
+ addElements('name=abc', 'id=abc'),
+ changeHashAndScroll('abc'),
+ expectScrollingTo('id=abc')));
+
+
+ it('should scroll to top if hash == "top" and no matching element', inject(
+ changeHashAndScroll('top'),
+ expectScrollingToTop));
+
+
+ it('should scroll to element with id "top" if present', inject(
+ addElements('id=top'),
+ changeHashAndScroll('top'),
+ expectScrollingTo('id=top')));
+
+
+ it('should not scroll when disabled', inject(
+ addElements('id=fake', 'a name=fake', 'input name=fake'),
+ disableScroller(),
+ changeHashAndScroll('fake'),
+ expectNoScrolling()));
+
+
+ describe('watcher', function() {
+
+ function initLocation(config) {
+ return function($provide, $locationProvider) {
+ $provide.value('$sniffer', {history: config.historyApi});
+ $locationProvider.html5Mode(config.html5Mode);
+ };
+ }
+
+ function changeHashAndDigest(hash) {
+ return function ($location, $rootScope, $autoScroll) {
+ $location.hash(hash);
+ $rootScope.$digest();
+ };
+ }
+
+ afterEach(inject(function($document) {
+ dealoc($document);
+ }));
+
+
+ it('should scroll to element when hash change in hashbang mode', inject(
+ initLocation({html5Mode: false, historyApi: true}),
+ addElements('id=some'),
+ changeHashAndDigest('some'),
+ expectScrollingTo('id=some')));
+
+
+ it('should scroll to element when hash change in html5 mode with no history api', inject(
+ initLocation({html5Mode: true, historyApi: false}),
+ addElements('id=some'),
+ changeHashAndDigest('some'),
+ expectScrollingTo('id=some')));
+
+
+ it('should not scroll when element does not exist', inject(
+ initLocation({html5Mode: false, historyApi: false}),
+ addElements('id=some'),
+ changeHashAndDigest('other'),
+ expectNoScrolling()));
+
+
+ it('should not scroll when html5 mode with history api', inject(
+ initLocation({html5Mode: true, historyApi: true}),
+ addElements('id=some'),
+ changeHashAndDigest('some'),
+ expectNoScrolling()));
+
+
+ it('should not scroll when disabled', inject(
+ disableScroller(),
+ initLocation({html5Mode: false, historyApi: false}),
+ addElements('id=fake'),
+ changeHashAndDigest('fake'),
+ expectNoScrolling()));
+ });
+});
+