Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 0c6cfa1

Browse files
committedOct 10, 2014
feat($anchorScroll): support a configurable vertical scroll offset
Add support for a configurable vertical scroll offset to `$anchorScroll`. The offset can be defined by a specific number of pixels, a callback function that returns the number of pixels on demand or a jqLite/JQuery wrapped DOM element whose height and position are used if it has fixed position. The offset algorithm takes into account items that are near the bottom of the page preventing over-zealous offset correction. Closes angular#9368 Closes angular#2070 Closes angular#9360
1 parent b90f5e5 commit 0c6cfa1

File tree

2 files changed

+678
-115
lines changed

2 files changed

+678
-115
lines changed
 

‎src/ng/anchorScroll.js

+230-53
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,174 @@
11
'use strict';
22

33
/**
4-
* @ngdoc service
5-
* @name $anchorScroll
6-
* @kind function
7-
* @requires $window
8-
* @requires $location
9-
* @requires $rootScope
4+
* @ngdoc provider
5+
* @name $anchorScrollProvider
106
*
117
* @description
12-
* When called, it checks current value of `$location.hash()` and scrolls to the related element,
13-
* according to rules specified in
14-
* [Html5 spec](http://dev.w3.org/html5/spec/Overview.html#the-indicated-part-of-the-document).
15-
*
16-
* It also watches the `$location.hash()` and scrolls whenever it changes to match any anchor.
17-
* This can be disabled by calling `$anchorScrollProvider.disableAutoScrolling()`.
18-
*
19-
* @example
20-
<example module="anchorScrollExample">
21-
<file name="index.html">
22-
<div id="scrollArea" ng-controller="ScrollController">
23-
<a ng-click="gotoBottom()">Go to bottom</a>
24-
<a id="bottom"></a> You're at the bottom!
25-
</div>
26-
</file>
27-
<file name="script.js">
28-
angular.module('anchorScrollExample', [])
29-
.controller('ScrollController', ['$scope', '$location', '$anchorScroll',
30-
function ($scope, $location, $anchorScroll) {
31-
$scope.gotoBottom = function() {
32-
// set the location.hash to the id of
33-
// the element you wish to scroll to.
34-
$location.hash('bottom');
35-
36-
// call $anchorScroll()
37-
$anchorScroll();
38-
};
39-
}]);
40-
</file>
41-
<file name="style.css">
42-
#scrollArea {
43-
height: 350px;
44-
overflow: auto;
45-
}
46-
47-
#bottom {
48-
display: block;
49-
margin-top: 2000px;
50-
}
51-
</file>
52-
</example>
8+
* Use `$anchorScrollProvider` to disable automatic scrolling whenever
9+
* {@link ng.$location#hash $location.hash()} changes.
5310
*/
5411
function $AnchorScrollProvider() {
5512

5613
var autoScrollingEnabled = true;
5714

15+
/**
16+
* @ngdoc method
17+
* @name $anchorScrollProvider#disableAutoScrolling
18+
*
19+
* @description
20+
* By default, {@link ng.$anchorScroll $anchorScroll()} will automatically will detect changes to
21+
* {@link ng.$location#hash $location.hash()} and scroll to the element matching the new hash.<br />
22+
* Use this method to disable automatic scrolling.
23+
*
24+
* If automatic scrolling is disabled, one must explicitly call
25+
* {@link ng.$anchorScroll $anchorScroll()} in order to scroll to the element related to the
26+
* current hash.
27+
*/
5828
this.disableAutoScrolling = function() {
5929
autoScrollingEnabled = false;
6030
};
6131

32+
/**
33+
* @ngdoc service
34+
* @name $anchorScroll
35+
* @kind function
36+
* @requires $window
37+
* @requires $location
38+
* @requires $rootScope
39+
*
40+
* @description
41+
* When called, it checks the current value of {@link ng.$location#hash $location.hash()} and
42+
* scrolls to the related element, according to the rules specified in the
43+
* [Html5 spec](http://dev.w3.org/html5/spec/Overview.html#the-indicated-part-of-the-document).
44+
*
45+
* It also watches the {@link ng.$location#hash $location.hash()} and automatically scrolls to
46+
* match any anchor whenever it changes. This can be disabled by calling
47+
* {@link ng.$anchorScrollProvider#disableAutoScrolling $anchorScrollProvider.disableAutoScrolling()}.
48+
*
49+
* Additionally, you can use its {@link ng.$anchorScroll#yOffset yOffset} property to specify a
50+
* vertical scroll-offset (either fixed or dynamic).
51+
*
52+
* @property {(number|function|jqLite)} yOffset
53+
* If set, specifies a vertical scroll-offset. This is often useful when there are fixed
54+
* positioned elements at the top of the page, such as navbars, headers etc.
55+
*
56+
* `yOffset` can be specified in various ways:
57+
* - **number**: A fixed number of pixels to be used as offset.<br /><br />
58+
* - **function**: A getter function called everytime `$anchorScroll()` is executed. Must return
59+
* a number representing the offset (in pixels).<br /><br />
60+
* - **jqLite**: A jqLite/jQuery element to be used for specifying the offset. The sum of the
61+
* element's height and its distance from the top of the page will be used as offset.<br />
62+
* **Note**: The element will be taken into account only as long as its `position` is set to
63+
* `fixed`. This option is useful, when dealing with responsive navbars/headers that adjust
64+
* their height and/or positioning according to the viewport's size.
65+
*
66+
* <br />
67+
* <div class="alert alert-warning">
68+
* In order for `yOffset` to work properly, scrolling should take place on the document's root and
69+
* not some child element.
70+
* </div>
71+
*
72+
* @example
73+
<example module="anchorScrollExample">
74+
<file name="index.html">
75+
<div id="scrollArea" ng-controller="ScrollController">
76+
<a ng-click="gotoBottom()">Go to bottom</a>
77+
<a id="bottom"></a> You're at the bottom!
78+
</div>
79+
</file>
80+
<file name="script.js">
81+
angular.module('anchorScrollExample', [])
82+
.controller('ScrollController', ['$scope', '$location', '$anchorScroll',
83+
function ($scope, $location, $anchorScroll) {
84+
$scope.gotoBottom = function() {
85+
// set the location.hash to the id of
86+
// the element you wish to scroll to.
87+
$location.hash('bottom');
88+
89+
// call $anchorScroll()
90+
$anchorScroll();
91+
};
92+
}]);
93+
</file>
94+
<file name="style.css">
95+
#scrollArea {
96+
height: 280px;
97+
overflow: auto;
98+
}
99+
100+
#bottom {
101+
display: block;
102+
margin-top: 2000px;
103+
}
104+
</file>
105+
</example>
106+
*
107+
* <hr />
108+
* The example below illustrates the use of a vertical scroll-offset (specified as a fixed value).
109+
* See {@link ng.$anchorScroll#yOffset $anchorScroll.yOffset} for more details.
110+
*
111+
* @example
112+
<example module="anchorScrollOffsetExample">
113+
<file name="index.html">
114+
<div class="fixed-header" ng-controller="headerCtrl">
115+
<a href="" ng-click="gotoAnchor(x)" ng-repeat="x in [1,2,3,4,5]">
116+
Go to anchor {{x}}
117+
</a>
118+
</div>
119+
<div id="anchor{{x}}" class="anchor" ng-repeat="x in [1,2,3,4,5]">
120+
Anchor {{x}} of 5
121+
</div>
122+
</file>
123+
<file name="script.js">
124+
angular.module('anchorScrollOffsetExample', [])
125+
.run(['$anchorScroll', function($anchorScroll) {
126+
$anchorScroll.yOffset = 50; // always scroll by 50 extra pixels
127+
}])
128+
.controller('headerCtrl', ['$anchorScroll', '$location', '$scope',
129+
function ($anchorScroll, $location, $scope) {
130+
$scope.gotoAnchor = function(x) {
131+
var newHash = 'anchor' + x;
132+
if ($location.hash() !== newHash) {
133+
// set the $location.hash to `newHash` and
134+
// $anchorScroll will automatically scroll to it
135+
$location.hash('anchor' + x);
136+
} else {
137+
// call $anchorScroll() explicitly,
138+
// since $location.hash hasn't changed
139+
$anchorScroll();
140+
}
141+
};
142+
}
143+
]);
144+
</file>
145+
<file name="style.css">
146+
body {
147+
padding-top: 50px;
148+
}
149+
150+
.anchor {
151+
border: 2px dashed DarkOrchid;
152+
padding: 10px 10px 200px 10px;
153+
}
154+
155+
.fixed-header {
156+
background-color: rgba(0, 0, 0, 0.2);
157+
height: 50px;
158+
position: fixed;
159+
top: 0; left: 0; right: 0;
160+
}
161+
162+
.fixed-header > a {
163+
display: inline-block;
164+
margin: 5px 15px;
165+
}
166+
</file>
167+
</example>
168+
*/
62169
this.$get = ['$window', '$location', '$rootScope', function($window, $location, $rootScope) {
63170
var document = $window.document;
171+
var scrollScheduled = false;
64172

65173
// Helper function to get first anchor from a NodeList
66174
// (using `Array#some()` instead of `angular#forEach()` since it's more performant
@@ -76,20 +184,90 @@ function $AnchorScrollProvider() {
76184
return result;
77185
}
78186

187+
function getYOffset() {
188+
189+
var offset = scroll.yOffset;
190+
191+
if (isFunction(offset)) {
192+
offset = offset();
193+
} else if (isElement(offset)) {
194+
var elem = offset[0];
195+
var style = $window.getComputedStyle(elem);
196+
if (style.position !== 'fixed') {
197+
offset = 0;
198+
} else {
199+
var rect = elem.getBoundingClientRect();
200+
var top = rect.top;
201+
var height = rect.height;
202+
offset = top + height;
203+
}
204+
} else if (!isNumber(offset)) {
205+
offset = 0;
206+
}
207+
208+
return offset;
209+
}
210+
211+
function scrollTo(elem) {
212+
if (elem) {
213+
elem.scrollIntoView();
214+
215+
var offset = getYOffset();
216+
217+
if (offset) {
218+
// `offset` is the number of pixels we should scroll up in order to align `elem` properly.
219+
// This is true ONLY if the call to `elem.scrollIntoView()` initially aligns `elem` at the
220+
// top of the viewport. IF the number of pixels from the top of `elem` to the end of the
221+
// page's content is less than the height of the viewport, then `elem.scrollIntoView()`
222+
// will NOT align the top of `elem` at the top of the viewport (but further down). This is
223+
// often the case for elements near the bottom of the page.
224+
// In such cases we do not need to scroll the whole `offset` up, just the fraction of the
225+
// offset that is necessary to align the top of `elem` at the desired position.
226+
var elemTop = elem.getBoundingClientRect().top;
227+
var bodyTop = document.body.getBoundingClientRect().top;
228+
var scrollTop = $window.pageYOffset;
229+
var necessaryOffset = offset - (elemTop - (bodyTop + scrollTop));
230+
231+
$window.scrollBy(0, -1 * necessaryOffset);
232+
}
233+
} else {
234+
$window.scrollTo(0, 0);
235+
}
236+
}
237+
238+
function scrollWhenReady() {
239+
if (document.readyState === 'complete') {
240+
$rootScope.$evalAsync(scroll);
241+
} else if (!scrollScheduled) {
242+
scrollScheduled = true;
243+
document.addEventListener('readystatechange', function unbindAndScroll() {
244+
// When navigating to a page with a URL including a hash,
245+
// Firefox overwrites our `yOffset` if `$apply()` is used instead.
246+
$rootScope.$evalAsync(function() {
247+
if (document.readyState === 'complete') {
248+
scrollScheduled = false;
249+
document.removeEventListener('readystatechange', unbindAndScroll);
250+
scroll();
251+
}
252+
});
253+
});
254+
}
255+
}
256+
79257
function scroll() {
80258
var hash = $location.hash(), elm;
81259

82260
// empty hash, scroll to the top of the page
83-
if (!hash) $window.scrollTo(0, 0);
261+
if (!hash) scrollTo(null);
84262

85263
// element with given id
86-
else if ((elm = document.getElementById(hash))) elm.scrollIntoView();
264+
else if ((elm = document.getElementById(hash))) scrollTo(elm);
87265

88266
// first anchor with given name :-D
89-
else if ((elm = getFirstAnchor(document.getElementsByName(hash)))) elm.scrollIntoView();
267+
else if ((elm = getFirstAnchor(document.getElementsByName(hash)))) scrollTo(elm);
90268

91269
// no element and hash == 'top', scroll to the top of the page
92-
else if (hash === 'top') $window.scrollTo(0, 0);
270+
else if (hash === 'top') scrollTo(null);
93271
}
94272

95273
// does not scroll when user clicks on anchor link that is currently on
@@ -100,11 +278,10 @@ function $AnchorScrollProvider() {
100278
// skip the initial scroll if $location.hash is empty
101279
if (newVal === oldVal && newVal === '') return;
102280

103-
$rootScope.$evalAsync(scroll);
281+
scrollWhenReady();
104282
});
105283
}
106284

107285
return scroll;
108286
}];
109287
}
110-

‎test/ng/anchorScrollSpec.js

+448-62
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,139 @@
11
'use strict';
2+
23
describe('$anchorScroll', function() {
34

45
var elmSpy;
6+
var docSpies;
7+
var windowSpies;
58

69
function addElements() {
710
var elements = sliceArgs(arguments);
811

9-
return function() {
12+
return function($window) {
1013
forEach(elements, function(identifier) {
11-
var match = identifier.match(/(\w* )?(\w*)=(\w*)/),
12-
jqElm = jqLite('<' + (match[1] || 'a ') + match[2] + '="' + match[3] + '"/>'),
14+
var match = identifier.match(/(?:(\w*) )?(\w*)=(\w*)/),
15+
nodeName = match[1] || 'a',
16+
tmpl = '<' + nodeName + ' ' + match[2] + '="' + match[3] + '">' +
17+
match[3] + // add some content or else Firefox and IE place the element
18+
// in weird ways that break yOffset-testing.
19+
'</' + nodeName + '>',
20+
jqElm = jqLite(tmpl),
1321
elm = jqElm[0];
22+
// Inline elements cause Firefox to report an unexpected value for
23+
// `getBoundingClientRect().top` on some platforms (depending on the default font and
24+
// line-height). Using inline-block elements prevents this.
25+
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1014738
26+
elm.style.display = 'inline-block';
1427

1528
elmSpy[identifier] = spyOn(elm, 'scrollIntoView');
16-
jqLite(document.body).append(jqElm);
29+
jqLite($window.document.body).append(jqElm);
1730
});
1831
};
1932
}
2033

34+
function callAnchorScroll() {
35+
return function($anchorScroll) {
36+
$anchorScroll();
37+
};
38+
}
39+
2140
function changeHashAndScroll(hash) {
2241
return function($location, $anchorScroll) {
2342
$location.hash(hash);
2443
$anchorScroll();
2544
};
2645
}
2746

47+
function changeHashTo(hash) {
48+
return function($anchorScroll, $location, $rootScope) {
49+
$rootScope.$apply(function() {
50+
$location.hash(hash);
51+
});
52+
};
53+
}
54+
55+
function createMockDocument(initialReadyState) {
56+
var mockedDoc = {};
57+
docSpies = {};
58+
59+
initialReadyState = initialReadyState || 'complete';
60+
var propsToPassThrough = ['body', 'documentElement'];
61+
var methodsToPassThrough = [
62+
'getElementById',
63+
'getElementsByName',
64+
'addEventListener',
65+
'removeEventListener'
66+
];
67+
68+
var document_ = document;
69+
70+
propsToPassThrough.forEach(function(prop) {
71+
mockedDoc[prop] = document_[prop];
72+
});
73+
methodsToPassThrough.forEach(function(method) {
74+
mockedDoc[method] = document_[method].bind(document_);
75+
docSpies[method] = spyOn(mockedDoc, method).andCallThrough();
76+
});
77+
78+
mockedDoc.readyState = initialReadyState || 'complete';
79+
mockedDoc.dispatchFakeReadyStateChangeEvent = function() {
80+
var evt = document_.createEvent('Event');
81+
evt.initEvent('readystatechange', false, false);
82+
document_.dispatchEvent(evt);
83+
};
84+
mockedDoc.updateReadyState = function(newState) {
85+
this.readyState = newState;
86+
this.dispatchFakeReadyStateChangeEvent();
87+
};
88+
89+
return mockedDoc;
90+
}
91+
92+
function createMockWindow(initialReadyState) {
93+
return function() {
94+
module(function($provide) {
95+
elmSpy = {};
96+
windowSpies = {};
97+
98+
$provide.value('$window', {
99+
scrollTo: (windowSpies.scrollTo = jasmine.createSpy('$window.scrollTo')),
100+
scrollBy: (windowSpies.scrollBy = jasmine.createSpy('$window.scrollBy')),
101+
document: createMockDocument(initialReadyState),
102+
navigator: {},
103+
pageYOffset: 0,
104+
getComputedStyle: function(elem) {
105+
return getComputedStyle(elem);
106+
}
107+
});
108+
});
109+
};
110+
}
111+
112+
function expectNoScrolling() {
113+
return expectScrollingTo(NaN);
114+
}
115+
116+
function expectScrollingTo(identifierCountMap) {
117+
var map = {};
118+
if (isString(identifierCountMap)) {
119+
map[identifierCountMap] = 1;
120+
} else if (isArray(identifierCountMap)) {
121+
forEach(identifierCountMap, function(identifier) {
122+
map[identifier] = 1;
123+
});
124+
} else {
125+
map = identifierCountMap;
126+
}
127+
128+
return function($window) {
129+
forEach(elmSpy, function(spy, id) {
130+
var count = map[id] || 0;
131+
expect(spy.callCount).toBe(count);
132+
});
133+
expect($window.scrollTo).not.toHaveBeenCalled();
134+
};
135+
}
136+
28137
function expectScrollingToTop($window) {
29138
forEach(elmSpy, function(spy, id) {
30139
expect(spy).not.toHaveBeenCalled();
@@ -33,75 +142,156 @@ describe('$anchorScroll', function() {
33142
expect($window.scrollTo).toHaveBeenCalledWith(0, 0);
34143
}
35144

36-
function expectScrollingTo(identifier) {
145+
function resetAllSpies() {
146+
function resetSpy(spy) {
147+
spy.reset();
148+
}
149+
37150
return function($window) {
38-
forEach(elmSpy, function(spy, id) {
39-
if (identifier === id) expect(spy).toHaveBeenCalledOnce();
40-
else expect(spy).not.toHaveBeenCalled();
41-
});
42-
expect($window.scrollTo).not.toHaveBeenCalled();
151+
forEach(elmSpy, resetSpy);
152+
forEach(docSpies, resetSpy);
153+
forEach(windowSpies, resetSpy);
43154
};
44155
}
45156

46-
function expectNoScrolling() {
47-
return expectScrollingTo(NaN);
157+
function updateMockReadyState(newState) {
158+
return function($browser, $window) {
159+
// It is possible that this operation adds tasks to the asyncQueue (needs flushing)
160+
$window.document.updateReadyState(newState);
161+
if ($browser.deferredFns.length) {
162+
$browser.defer.flush();
163+
}
164+
};
48165
}
49166

50167

51-
beforeEach(module(function($provide) {
52-
elmSpy = {};
53-
$provide.value('$window', {
54-
scrollTo: jasmine.createSpy('$window.scrollTo'),
55-
document: document,
56-
navigator: {}
57-
});
168+
afterEach(inject(function($browser, $document) {
169+
expect($browser.deferredFns.length).toBe(0);
170+
dealoc($document);
58171
}));
59172

60173

61-
it('should scroll to top of the window if empty hash', inject(
62-
changeHashAndScroll(''),
63-
expectScrollingToTop));
174+
describe('when explicitly called', function() {
175+
176+
beforeEach(createMockWindow());
177+
178+
179+
it('should scroll to top of the window if empty hash', inject(
180+
changeHashAndScroll(''),
181+
expectScrollingToTop));
182+
183+
184+
it('should not scroll if hash does not match any element', inject(
185+
addElements('id=one', 'id=two'),
186+
changeHashAndScroll('non-existing'),
187+
expectNoScrolling()));
188+
64189

190+
it('should scroll to anchor element with name', inject(
191+
addElements('a name=abc'),
192+
changeHashAndScroll('abc'),
193+
expectScrollingTo('a name=abc')));
65194

66-
it('should not scroll if hash does not match any element', inject(
67-
addElements('id=one', 'id=two'),
68-
changeHashAndScroll('non-existing'),
69-
expectNoScrolling()));
70195

196+
it('should not scroll to other than anchor element with name', inject(
197+
addElements('input name=xxl', 'select name=xxl', 'form name=xxl'),
198+
changeHashAndScroll('xxl'),
199+
expectNoScrolling()));
71200

72-
it('should scroll to anchor element with name', inject(
73-
addElements('a name=abc'),
74-
changeHashAndScroll('abc'),
75-
expectScrollingTo('a name=abc')));
76201

202+
it('should scroll to anchor even if other element with given name exist', inject(
203+
addElements('input name=some', 'a name=some'),
204+
changeHashAndScroll('some'),
205+
expectScrollingTo('a name=some')));
77206

78-
it('should not scroll to other than anchor element with name', inject(
79-
addElements('input name=xxl', 'select name=xxl', 'form name=xxl'),
80-
changeHashAndScroll('xxl'),
81-
expectNoScrolling()));
82207

208+
it('should scroll to element with id with precedence over name', inject(
209+
addElements('name=abc', 'id=abc'),
210+
changeHashAndScroll('abc'),
211+
expectScrollingTo('id=abc')));
83212

84-
it('should scroll to anchor even if other element with given name exist', inject(
85-
addElements('input name=some', 'a name=some'),
86-
changeHashAndScroll('some'),
87-
expectScrollingTo('a name=some')));
88213

214+
it('should scroll to top if hash == "top" and no matching element', inject(
215+
changeHashAndScroll('top'),
216+
expectScrollingToTop));
89217

90-
it('should scroll to element with id with precedence over name', inject(
91-
addElements('name=abc', 'id=abc'),
92-
changeHashAndScroll('abc'),
93-
expectScrollingTo('id=abc')));
218+
219+
it('should scroll to element with id "top" if present', inject(
220+
addElements('id=top'),
221+
changeHashAndScroll('top'),
222+
expectScrollingTo('id=top')));
223+
});
224+
225+
226+
describe('in respect to `document.readyState`', function() {
227+
228+
beforeEach(createMockWindow('mocked'));
229+
230+
231+
it('should wait for `document.readyState === "complete"', inject(
232+
addElements('id=some1'),
233+
234+
changeHashTo('some1'),
235+
expectNoScrolling(),
236+
237+
updateMockReadyState('some-arbitrary-state'),
238+
expectNoScrolling(),
239+
240+
updateMockReadyState('complete'),
241+
expectScrollingTo('id=some1')));
94242

95243

96-
it('should scroll to top if hash == "top" and no matching element', inject(
97-
changeHashAndScroll('top'),
98-
expectScrollingToTop));
244+
it('should only register once for execution when `document.readyState === "complete"', inject(
245+
addElements('id=some1', 'id=some2'),
99246

247+
changeHashTo('some1'),
248+
changeHashTo('some2'),
249+
updateMockReadyState('some-other-arbitrary-state'),
250+
changeHashTo('some1'),
251+
changeHashTo('some2'),
252+
expectNoScrolling(),
100253

101-
it('should scroll to element with id "top" if present', inject(
102-
addElements('id=top'),
103-
changeHashAndScroll('top'),
104-
expectScrollingTo('id=top')));
254+
updateMockReadyState('complete'),
255+
expectScrollingTo('id=some2')));
256+
257+
258+
it('should properly register and unregister listeners for `readystatechange` event', inject(
259+
addElements('id=some1', 'id=some2'),
260+
261+
changeHashTo('some1'),
262+
changeHashTo('some2'),
263+
updateMockReadyState('some-other-arbitrary-state'),
264+
changeHashTo('some1'),
265+
changeHashTo('some2'),
266+
updateMockReadyState('complete'),
267+
268+
function() {
269+
expect(docSpies.addEventListener.callCount).toBe(1);
270+
expect(docSpies.addEventListener).
271+
toHaveBeenCalledWith('readystatechange', jasmine.any(Function));
272+
273+
expect(docSpies.removeEventListener.callCount).toBe(1);
274+
expect(docSpies.removeEventListener).
275+
toHaveBeenCalledWith('readystatechange', jasmine.any(Function));
276+
277+
var registeredListener = docSpies.addEventListener.calls[0].args[1];
278+
var unregisteredListener = docSpies.removeEventListener.calls[0].args[1];
279+
expect(unregisteredListener).toBe(registeredListener);
280+
}));
281+
282+
283+
it('should scroll immediately if already `readyState === "complete"`', inject(
284+
addElements('id=some1'),
285+
286+
updateMockReadyState('complete'),
287+
changeHashTo('some1'),
288+
289+
expectScrollingTo('id=some1'),
290+
function() {
291+
expect(docSpies.addEventListener.callCount).toBe(0);
292+
expect(docSpies.removeEventListener.callCount).toBe(0);
293+
}));
294+
});
105295

106296

107297
describe('watcher', function() {
@@ -119,23 +309,13 @@ describe('$anchorScroll', function() {
119309
};
120310
}
121311

122-
function changeHashTo(hash) {
123-
return function ($location, $rootScope, $anchorScroll) {
124-
$rootScope.$apply(function() {
125-
$location.hash(hash);
126-
});
127-
};
128-
}
129-
130312
function disableAutoScrolling() {
131313
return function($anchorScrollProvider) {
132314
$anchorScrollProvider.disableAutoScrolling();
133315
};
134316
}
135317

136-
afterEach(inject(function($document) {
137-
dealoc($document);
138-
}));
318+
beforeEach(createMockWindow());
139319

140320

141321
it('should scroll to element when hash change in hashbang mode', function() {
@@ -189,7 +369,7 @@ describe('$anchorScroll', function() {
189369
});
190370

191371

192-
it('should not scroll when disabled', function() {
372+
it('should not scroll when auto-scrolling is disabled', function() {
193373
module(
194374
disableAutoScrolling(),
195375
initLocation({html5Mode: false, historyApi: false})
@@ -200,6 +380,212 @@ describe('$anchorScroll', function() {
200380
expectNoScrolling()
201381
);
202382
});
383+
384+
385+
it('should scroll when called explicitly (even if auto-scrolling is disabled)', function() {
386+
module(
387+
disableAutoScrolling(),
388+
initLocation({html5Mode: false, historyApi: false})
389+
);
390+
inject(
391+
addElements('id=fake'),
392+
changeHashTo('fake'),
393+
expectNoScrolling(),
394+
callAnchorScroll(),
395+
expectScrollingTo('id=fake')
396+
);
397+
});
203398
});
204-
});
205399

400+
401+
// TODO: Add tests for <body> with:
402+
// 1. border/margin/padding !== 0
403+
// 2. box-sizing === border-box
404+
describe('yOffset', function() {
405+
406+
function expectScrollingWithOffset(identifierCountMap, offsetList) {
407+
var list = isArray(offsetList) ? offsetList : [offsetList];
408+
409+
return function($rootScope, $window) {
410+
inject(expectScrollingTo(identifierCountMap));
411+
expect($window.scrollBy.callCount).toBe(list.length);
412+
forEach(list, function(offset, idx) {
413+
// Due to sub-pixel rendering, there is a +/-1 error margin in the actual offset
414+
var args = $window.scrollBy.calls[idx].args;
415+
expect(args[0]).toBe(0);
416+
expect(Math.abs(offset + args[1])).toBeLessThan(1);
417+
});
418+
};
419+
}
420+
421+
function expectScrollingWithoutOffset(identifierCountMap) {
422+
return expectScrollingWithOffset(identifierCountMap, []);
423+
}
424+
425+
function setupBodyForOffsetTesting() {
426+
return function($window) {
427+
var style = $window.document.body.style;
428+
style.border = 'none';
429+
style.margin = '0';
430+
style.padding = '0';
431+
};
432+
}
433+
434+
function setYOffset(yOffset) {
435+
return function($anchorScroll) {
436+
$anchorScroll.yOffset = yOffset;
437+
};
438+
}
439+
440+
function updateMockPageYOffset() {
441+
return function($window) {
442+
$window.pageYOffset = window.pageYOffset;
443+
};
444+
}
445+
446+
beforeEach(createMockWindow());
447+
beforeEach(inject(setupBodyForOffsetTesting()));
448+
449+
450+
describe('when set as a fixed number', function() {
451+
452+
var yOffsetNumber = 50;
453+
454+
beforeEach(inject(setYOffset(yOffsetNumber)));
455+
456+
457+
it('should scroll with vertical offset', inject(
458+
addElements('id=some'),
459+
changeHashTo('some'),
460+
expectScrollingWithOffset('id=some', yOffsetNumber)));
461+
462+
463+
it('should use the correct vertical offset when changing `yOffset` at runtime', inject(
464+
addElements('id=some'),
465+
changeHashTo('some'),
466+
setYOffset(yOffsetNumber - 10),
467+
callAnchorScroll(),
468+
expectScrollingWithOffset({'id=some': 2}, [yOffsetNumber, yOffsetNumber - 10])));
469+
470+
471+
it('should adjust the vertical offset for elements near the end of the page', function() {
472+
473+
var targetAdjustedOffset = 25;
474+
475+
inject(
476+
addElements('id=some1', 'id=some2'),
477+
function($window) {
478+
// Make sure the elements are just a little shorter than the viewport height
479+
var viewportHeight = $window.document.documentElement.clientHeight;
480+
var elemHeight = viewportHeight - (yOffsetNumber - targetAdjustedOffset);
481+
var cssText = [
482+
'border:none',
483+
'display:block',
484+
'height:' + elemHeight + 'px',
485+
'margin:0',
486+
'padding:0',
487+
''].join(';');
488+
489+
forEach($window.document.body.children, function(elem) {
490+
elem.style.cssText = cssText;
491+
});
492+
493+
// Make sure scrolling does actually take place
494+
// (this is necessary for the current test)
495+
forEach(elmSpy, function(spy, identifier) {
496+
elmSpy[identifier] = spy.andCallThrough();
497+
});
498+
},
499+
changeHashTo('some2'),
500+
updateMockPageYOffset(),
501+
resetAllSpies(),
502+
callAnchorScroll(),
503+
expectScrollingWithOffset('id=some2', targetAdjustedOffset));
504+
});
505+
});
506+
507+
508+
describe('when set as a function', function() {
509+
510+
it('should scroll with vertical offset', function() {
511+
512+
var val = 0;
513+
var increment = 10;
514+
515+
function yOffsetFunction() {
516+
val += increment;
517+
return val;
518+
}
519+
520+
inject(
521+
addElements('id=id1', 'name=name2'),
522+
setYOffset(yOffsetFunction),
523+
changeHashTo('id1'),
524+
changeHashTo('name2'),
525+
changeHashTo('id1'),
526+
callAnchorScroll(),
527+
expectScrollingWithOffset({
528+
'id=id1': 3,
529+
'name=name2': 1
530+
}, [
531+
1 * increment,
532+
2 * increment,
533+
3 * increment,
534+
4 * increment
535+
]));
536+
});
537+
});
538+
539+
540+
describe('when set as a jqLite element', function() {
541+
542+
function createAndSetYOffsetElement(styleSpecs) {
543+
var cssText = '';
544+
forEach(styleSpecs, function(value, key) {
545+
cssText += key + ':' + value + ';';
546+
});
547+
548+
var jqElem = jqLite('<div style="' + cssText + '"></div>');
549+
550+
return function($anchorScroll, $window) {
551+
jqLite($window.document.body).append(jqElem);
552+
$anchorScroll.yOffset = jqElem;
553+
};
554+
}
555+
556+
557+
it('should scroll with vertical offset when `top === 0`', inject(
558+
createAndSetYOffsetElement({
559+
background: 'DarkOrchid',
560+
height: '50px',
561+
position: 'fixed',
562+
top: '0',
563+
}),
564+
addElements('id=some'),
565+
changeHashTo('some'),
566+
expectScrollingWithOffset('id=some', 50)));
567+
568+
569+
it('should scroll with vertical offset when `top > 0`', inject(
570+
createAndSetYOffsetElement({
571+
height: '50px',
572+
position: 'fixed',
573+
top: '50px',
574+
}),
575+
addElements('id=some'),
576+
changeHashTo('some'),
577+
expectScrollingWithOffset('id=some', 100)));
578+
579+
580+
it('should scroll without vertical offset when `position !== fixed`', inject(
581+
createAndSetYOffsetElement({
582+
height: '50px',
583+
position: 'absolute',
584+
top: '0',
585+
}),
586+
addElements('id=some'),
587+
changeHashTo('some'),
588+
expectScrollingWithoutOffset('id=some')));
589+
});
590+
});
591+
});

0 commit comments

Comments
 (0)
Please sign in to comment.