Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

Commit 3548fe3

Browse files
committed
feat(service.$autoScroll): scroll to hash fragment
- whenever hash part of the url changes - after ng:view / ng:include load
1 parent 29f9e26 commit 3548fe3

File tree

7 files changed

+251
-5
lines changed

7 files changed

+251
-5
lines changed

angularFiles.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ angularFiles = {
88
'src/sanitizer.js',
99
'src/jqLite.js',
1010
'src/apis.js',
11+
'src/service/autoScroll.js',
1112
'src/service/browser.js',
1213
'src/service/compiler.js',
1314
'src/service/cookieStore.js',

docs/src/templates/docs.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ function DocsController($location, $window, $cookies, $filter) {
7575
scope.loading--;
7676
scope.partialTitle = scope.futurePartialTitle;
7777
SyntaxHighlighter.highlight();
78-
$window.scrollTo(0,0);
7978
$window._gaq.push(['_trackPageview', currentPageId]);
8079
loadDisqus(currentPageId);
8180
};

src/AngularPublic.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ function ngModule($provide, $injector) {
6666
$provide.service('$locale', $LocaleProvider);
6767
});
6868

69+
$provide.service('$autoScroll', $AutoScrollProvider);
6970
$provide.service('$browser', $BrowserProvider);
7071
$provide.service('$compile', $CompileProvider);
7172
$provide.service('$cookies', $CookiesProvider);

src/service/autoScroll.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @ngdoc function
3+
* @name angular.module.ng.$autoScroll
4+
* @requires $window
5+
* @requires $location
6+
* @requires $rootScope
7+
*
8+
* @description
9+
* When called, it checks current value of `$location.hash()` and scroll to related element,
10+
* according to rules specified in
11+
* {@link http://dev.w3.org/html5/spec/Overview.html#the-indicated-part-of-the-document Html5 spec}.
12+
*
13+
* If `$location` uses `hashbang` url (running in `hashbang` mode or `html5` mode on browser without
14+
* history API support), `$autoScroll` watches the `$location.hash()` and scroll whenever it
15+
* changes.
16+
*
17+
* You can disable `$autoScroll` service by calling `disable()` on `$autoScrollProvider`.
18+
* Note: disabling is only possible before the service is instantiated !
19+
*/
20+
function $AutoScrollProvider() {
21+
22+
this.disable = function() {
23+
this.$get = function() {return noop;};
24+
};
25+
26+
this.$get = ['$window', '$location', '$rootScope', function($window, $location, $rootScope) {
27+
var document = $window.document;
28+
29+
// helper function to get first anchor from a NodeList
30+
// can't use filter.filter, as it accepts only instances of Array
31+
// and IE can't convert NodeList to an array using [].slice
32+
// TODO(vojta): use filter if we change it to accept lists as well
33+
function getFirstAnchor(list) {
34+
var result = null;
35+
forEach(list, function(element) {
36+
if (!result && lowercase(element.nodeName) === 'a') result = element;
37+
});
38+
return result;
39+
}
40+
41+
function scroll() {
42+
var hash = $location.hash(), elm;
43+
44+
// empty hash, scroll to the top of the page
45+
if (!hash) $window.scrollTo(0, 0);
46+
47+
// element with given id
48+
else if ((elm = document.getElementById(hash))) elm.scrollIntoView();
49+
50+
// first anchor with given name :-D
51+
else if ((elm = getFirstAnchor(document.getElementsByName(hash)))) elm.scrollIntoView();
52+
53+
// no element and hash == 'top', scroll to the top of the page
54+
else if (hash === 'top') $window.scrollTo(0, 0);
55+
}
56+
57+
// scroll whenever hash changes (with hashbang url, regular urls are handled by browser)
58+
if ($location instanceof LocationHashbangUrl) {
59+
$rootScope.$watch(function() {return $location.hash();}, function() {
60+
$rootScope.$evalAsync(scroll);
61+
});
62+
}
63+
64+
return scroll;
65+
}];
66+
}
67+

src/service/location.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ function LocationHashbangUrl(url, hashPrefix) {
195195
}
196196

197197

198-
LocationUrl.prototype = LocationHashbangUrl.prototype = {
198+
LocationUrl.prototype = {
199199

200200
/**
201201
* Has any change been replacing ?
@@ -374,6 +374,7 @@ LocationUrl.prototype = LocationHashbangUrl.prototype = {
374374
}
375375
};
376376

377+
LocationHashbangUrl.prototype = inherit(LocationUrl.prototype);
377378

378379
function locationGetter(property) {
379380
return function() {

src/widgets.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ angularWidget('ng:include', function(element){
9090
this.directives(true);
9191
} else {
9292
element[0]['ng:compiled'] = true;
93-
return ['$xhr.cache', '$element', function(xhr, element){
93+
return ['$xhr.cache', '$autoScroll', '$element', function($xhr, $autoScroll, element) {
9494
var scope = this,
9595
changeCounter = 0,
9696
releaseScopes = [],
@@ -114,14 +114,15 @@ angularWidget('ng:include', function(element){
114114
releaseScopes.pop().$destroy();
115115
}
116116
if (src) {
117-
xhr('GET', src, null, function(code, response){
117+
$xhr('GET', src, null, function(code, response) {
118118
element.html(response);
119119
if (useScope) {
120120
childScope = useScope;
121121
} else {
122122
releaseScopes.push(childScope = scope.$new());
123123
}
124124
compiler.compile(element)(childScope);
125+
$autoScroll();
125126
scope.$eval(onloadExp);
126127
}, false, true);
127128
} else {
@@ -555,7 +556,7 @@ angularWidget('ng:view', function(element) {
555556

556557
if (!element[0]['ng:compiled']) {
557558
element[0]['ng:compiled'] = true;
558-
return ['$xhr.cache', '$route', '$element', function($xhr, $route, element){
559+
return ['$xhr.cache', '$route', '$autoScroll', '$element', function($xhr, $route, $autoScroll, element) {
559560
var template;
560561
var changeCounter = 0;
561562

@@ -572,6 +573,7 @@ angularWidget('ng:view', function(element) {
572573
if (newChangeCounter == changeCounter) {
573574
element.html(response);
574575
compiler.compile(element)($route.current.scope);
576+
$autoScroll();
575577
}
576578
});
577579
} else {

test/service/autoScrollSpec.js

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
describe('$autoScroll', function() {
2+
3+
var elmSpy;
4+
5+
function addElements() {
6+
var elements = sliceArgs(arguments);
7+
8+
return function() {
9+
forEach(elements, function(identifier) {
10+
var match = identifier.match(/(\w* )?(\w*)=(\w*)/),
11+
jqElm = jqLite('<' + (match[1] || 'a ') + match[2] + '="' + match[3] + '"/>'),
12+
elm = jqElm[0];
13+
14+
elmSpy[identifier] = spyOn(elm, 'scrollIntoView');
15+
jqLite(document.body).append(jqElm);
16+
});
17+
};
18+
}
19+
20+
function changeHashAndScroll(hash) {
21+
return function($location, $autoScroll) {
22+
$location.hash(hash);
23+
$autoScroll();
24+
};
25+
}
26+
27+
function expectScrollingToTop($window) {
28+
forEach(elmSpy, function(spy, id) {
29+
expect(spy).not.toHaveBeenCalled();
30+
});
31+
32+
expect($window.scrollTo).toHaveBeenCalledWith(0, 0);
33+
}
34+
35+
function expectScrollingTo(identifier) {
36+
return function($window) {
37+
forEach(elmSpy, function(spy, id) {
38+
if (identifier === id) expect(spy).toHaveBeenCalledOnce();
39+
else expect(spy).not.toHaveBeenCalled();
40+
});
41+
expect($window.scrollTo).not.toHaveBeenCalled();
42+
};
43+
}
44+
45+
function expectNoScrolling() {
46+
return expectScrollingTo(NaN);
47+
}
48+
49+
function disableScroller() {
50+
return function($autoScrollProvider) {
51+
$autoScrollProvider.disable();
52+
};
53+
}
54+
55+
56+
beforeEach(inject(function($provide) {
57+
elmSpy = {};
58+
$provide.value('$window', {
59+
scrollTo: jasmine.createSpy('$window.scrollTo'),
60+
document: document
61+
});
62+
}));
63+
64+
65+
it('should scroll to top of the window if empty hash', inject(
66+
changeHashAndScroll(''),
67+
expectScrollingToTop));
68+
69+
70+
it('should not scroll if hash does not match any element', inject(
71+
addElements('id=one', 'id=two'),
72+
changeHashAndScroll('non-existing'),
73+
expectNoScrolling()));
74+
75+
76+
it('should scroll to anchor element with name', inject(
77+
addElements('a name=abc'),
78+
changeHashAndScroll('abc'),
79+
expectScrollingTo('a name=abc')));
80+
81+
82+
it('should not scroll to other than anchor element with name', inject(
83+
addElements('input name=xxl', 'select name=xxl', 'form name=xxl'),
84+
changeHashAndScroll('xxl'),
85+
expectNoScrolling()));
86+
87+
88+
it('should scroll to anchor even if other element with given name exist', inject(
89+
addElements('input name=some', 'a name=some'),
90+
changeHashAndScroll('some'),
91+
expectScrollingTo('a name=some')));
92+
93+
94+
it('should scroll to element with id with precedence over name', inject(
95+
addElements('name=abc', 'id=abc'),
96+
changeHashAndScroll('abc'),
97+
expectScrollingTo('id=abc')));
98+
99+
100+
it('should scroll to top if hash == "top" and no matching element', inject(
101+
changeHashAndScroll('top'),
102+
expectScrollingToTop));
103+
104+
105+
it('should scroll to element with id "top" if present', inject(
106+
addElements('id=top'),
107+
changeHashAndScroll('top'),
108+
expectScrollingTo('id=top')));
109+
110+
111+
it('should not scroll when disabled', inject(
112+
addElements('id=fake', 'a name=fake', 'input name=fake'),
113+
disableScroller(),
114+
changeHashAndScroll('fake'),
115+
expectNoScrolling()));
116+
117+
118+
describe('watcher', function() {
119+
120+
function initLocation(config) {
121+
return function($provide, $locationProvider) {
122+
$provide.value('$sniffer', {history: config.historyApi});
123+
$locationProvider.html5Mode(config.html5Mode);
124+
};
125+
}
126+
127+
function changeHashAndDigest(hash) {
128+
return function ($location, $rootScope, $autoScroll) {
129+
$location.hash(hash);
130+
$rootScope.$digest();
131+
};
132+
}
133+
134+
afterEach(inject(function($document) {
135+
dealoc($document);
136+
}));
137+
138+
139+
it('should scroll to element when hash change in hashbang mode', inject(
140+
initLocation({html5Mode: false, historyApi: true}),
141+
addElements('id=some'),
142+
changeHashAndDigest('some'),
143+
expectScrollingTo('id=some')));
144+
145+
146+
it('should scroll to element when hash change in html5 mode with no history api', inject(
147+
initLocation({html5Mode: true, historyApi: false}),
148+
addElements('id=some'),
149+
changeHashAndDigest('some'),
150+
expectScrollingTo('id=some')));
151+
152+
153+
it('should not scroll when element does not exist', inject(
154+
initLocation({html5Mode: false, historyApi: false}),
155+
addElements('id=some'),
156+
changeHashAndDigest('other'),
157+
expectNoScrolling()));
158+
159+
160+
it('should not scroll when html5 mode with history api', inject(
161+
initLocation({html5Mode: true, historyApi: true}),
162+
addElements('id=some'),
163+
changeHashAndDigest('some'),
164+
expectNoScrolling()));
165+
166+
167+
it('should not scroll when disabled', inject(
168+
disableScroller(),
169+
initLocation({html5Mode: false, historyApi: false}),
170+
addElements('id=fake'),
171+
changeHashAndDigest('fake'),
172+
expectNoScrolling()));
173+
});
174+
});
175+

0 commit comments

Comments
 (0)