Files
angular.js/test/ng/anchorScrollSpec.js
Peter Bacon Darwin 09c39d2ce6 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 #9368
Closes #2070
Closes #9360
2014-10-12 17:55:43 +01:00

546 lines
16 KiB
JavaScript

'use strict';
describe('$anchorScroll', function() {
var elmSpy;
function createMockWindow() {
return function() {
module(function($provide) {
elmSpy = {};
var mockedWin = {
scrollTo: jasmine.createSpy('$window.scrollTo'),
scrollBy: jasmine.createSpy('$window.scrollBy'),
document: document,
getComputedStyle: function(elem) {
return getComputedStyle(elem);
}
};
$provide.value('$window', mockedWin);
});
};
}
function addElements() {
var elements = sliceArgs(arguments);
return function($window) {
forEach(elements, function(identifier) {
var match = identifier.match(/(?:(\w*) )?(\w*)=(\w*)/),
nodeName = match[1] || 'a',
tmpl = '<' + nodeName + ' ' + match[2] + '="' + match[3] + '">' +
match[3] + // add some content or else Firefox and IE place the element
// in weird ways that break yOffset-testing.
'</' + nodeName + '>',
jqElm = jqLite(tmpl),
elm = jqElm[0];
// Inline elements cause Firefox to report an unexpected value for
// `getBoundingClientRect().top` on some platforms (depending on the default font and
// line-height). Using inline-block elements prevents this.
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1014738
elm.style.display = 'inline-block';
elmSpy[identifier] = spyOn(elm, 'scrollIntoView');
jqLite($window.document.body).append(jqElm);
});
};
}
function callAnchorScroll() {
return function($anchorScroll) {
$anchorScroll();
};
}
function changeHashAndScroll(hash) {
return function($location, $anchorScroll) {
$location.hash(hash);
$anchorScroll();
};
}
function changeHashTo(hash) {
return function($anchorScroll, $location, $rootScope) {
$rootScope.$apply(function() {
$location.hash(hash);
});
};
}
function expectNoScrolling() {
return expectScrollingTo(NaN);
}
function expectScrollingTo(identifierCountMap) {
var map = {};
if (isString(identifierCountMap)) {
map[identifierCountMap] = 1;
} else if (isArray(identifierCountMap)) {
forEach(identifierCountMap, function(identifier) {
map[identifier] = 1;
});
} else {
map = identifierCountMap;
}
return function($window) {
forEach(elmSpy, function(spy, id) {
var count = map[id] || 0;
expect(spy.callCount).toBe(count);
});
expect($window.scrollTo).not.toHaveBeenCalled();
};
}
function expectScrollingToTop($window) {
forEach(elmSpy, function(spy, id) {
expect(spy).not.toHaveBeenCalled();
});
expect($window.scrollTo).toHaveBeenCalledWith(0, 0);
}
function spyOnJQLiteDocumentLoaded(fake) {
return function() {
spyOn(window, 'jqLiteDocumentLoaded');
if (fake) {
window.jqLiteDocumentLoaded.andCallFake(fake);
}
};
}
function unspyOnJQLiteDocumentLoaded() {
return function() {
window.jqLiteDocumentLoaded = window.jqLiteDocumentLoaded.originalValue;
};
}
function simulateDocumentLoaded() {
return spyOnJQLiteDocumentLoaded(function(callback) { callback(); });
}
function fireWindowLoadEvent() {
return function($browser) {
var callback = window.jqLiteDocumentLoaded.mostRecentCall.args[0];
callback();
$browser.defer.flush();
};
}
afterEach(inject(function($browser, $document) {
expect($browser.deferredFns.length).toBe(0);
dealoc($document);
}));
describe('when explicitly called', function() {
beforeEach(createMockWindow());
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')));
});
describe('watcher', function() {
function initLocation(config) {
return function($provide, $locationProvider) {
$provide.value('$sniffer', {history: config.historyApi});
$locationProvider.html5Mode(config.html5Mode);
};
}
function disableAutoScrolling() {
return function($anchorScrollProvider) {
$anchorScrollProvider.disableAutoScrolling();
};
}
beforeEach(createMockWindow());
describe('when document has completed loading', function() {
beforeEach(simulateDocumentLoaded());
afterEach(unspyOnJQLiteDocumentLoaded());
it('should scroll to element when hash change in hashbang mode', function() {
module(initLocation({html5Mode: false, historyApi: true}));
inject(
addElements('id=some'),
changeHashTo('some'),
expectScrollingTo('id=some')
);
});
it('should scroll to element when hash change in html5 mode with no history api', function() {
module(initLocation({html5Mode: true, historyApi: false}));
inject(
addElements('id=some'),
changeHashTo('some'),
expectScrollingTo('id=some')
);
});
it('should not scroll to the top if $anchorScroll is initializing and location hash is empty',
inject(
expectNoScrolling())
);
it('should not scroll when element does not exist', function() {
module(initLocation({html5Mode: false, historyApi: false}));
inject(
addElements('id=some'),
changeHashTo('other'),
expectNoScrolling()
);
});
it('should scroll when html5 mode with history api', function() {
module(initLocation({html5Mode: true, historyApi: true}));
inject(
addElements('id=some'),
changeHashTo('some'),
expectScrollingTo('id=some')
);
});
it('should not scroll when auto-scrolling is disabled', function() {
module(
disableAutoScrolling(),
initLocation({html5Mode: false, historyApi: false})
);
inject(
addElements('id=fake'),
changeHashTo('fake'),
expectNoScrolling()
);
});
it('should scroll when called explicitly (even if auto-scrolling is disabled)', function() {
module(
disableAutoScrolling(),
initLocation({html5Mode: false, historyApi: false})
);
inject(
addElements('id=fake'),
changeHashTo('fake'),
expectNoScrolling(),
callAnchorScroll(),
expectScrollingTo('id=fake')
);
});
});
describe('when document has not completed loading', function() {
beforeEach(spyOnJQLiteDocumentLoaded());
afterEach(unspyOnJQLiteDocumentLoaded());
it('should wait for the document to be completely loaded before auto-scrolling', inject(
addElements('id=some'),
changeHashTo('some'),
expectNoScrolling('id=some'),
fireWindowLoadEvent(),
expectScrollingTo('id=some')
));
});
});
describe('yOffset', function() {
beforeEach(simulateDocumentLoaded());
afterEach(unspyOnJQLiteDocumentLoaded);
function expectScrollingWithOffset(identifierCountMap, offsetList) {
var list = isArray(offsetList) ? offsetList : [offsetList];
return function($rootScope, $window) {
inject(expectScrollingTo(identifierCountMap));
expect($window.scrollBy.callCount).toBe(list.length);
forEach(list, function(offset, idx) {
// Due to sub-pixel rendering, there is a +/-1 error margin in the actual offset
var args = $window.scrollBy.calls[idx].args;
expect(args[0]).toBe(0);
expect(Math.abs(offset + args[1])).toBeLessThan(1);
});
};
}
function expectScrollingWithoutOffset(identifierCountMap) {
return expectScrollingWithOffset(identifierCountMap, []);
}
function mockBoundingClientRect(childValuesMap) {
return function($window) {
var children = $window.document.body.children;
forEach(childValuesMap, function(valuesList, childIdx) {
var elem = children[childIdx];
elem.getBoundingClientRect = function() {
var val = valuesList.shift();
return {
top: val,
bottom: val
};
};
});
};
}
function setYOffset(yOffset) {
return function($anchorScroll) {
$anchorScroll.yOffset = yOffset;
};
}
beforeEach(createMockWindow());
describe('and body with no border/margin/padding', function() {
describe('when set as a fixed number', function() {
var yOffsetNumber = 50;
beforeEach(inject(setYOffset(yOffsetNumber)));
it('should scroll with vertical offset', inject(
addElements('id=some'),
mockBoundingClientRect({0: [0]}),
changeHashTo('some'),
expectScrollingWithOffset('id=some', yOffsetNumber)
));
it('should use the correct vertical offset when changing `yOffset` at runtime', inject(
addElements('id=some'),
mockBoundingClientRect({0: [0, 0]}),
changeHashTo('some'),
setYOffset(yOffsetNumber - 10),
callAnchorScroll(),
expectScrollingWithOffset({'id=some': 2}, [yOffsetNumber, yOffsetNumber - 10])));
it('should adjust the vertical offset for elements near the end of the page', function() {
var targetAdjustedOffset = 20;
inject(
addElements('id=some1', 'id=some2'),
mockBoundingClientRect({1: [yOffsetNumber - targetAdjustedOffset]}),
changeHashTo('some2'),
expectScrollingWithOffset('id=some2', targetAdjustedOffset));
});
});
describe('when set as a function', function() {
it('should scroll with vertical offset', function() {
var val = 0;
var increment = 10;
function yOffsetFunction() {
val += increment;
return val;
}
inject(
addElements('id=id1', 'name=name2'),
mockBoundingClientRect({
0: [0, 0, 0],
1: [0]
}),
setYOffset(yOffsetFunction),
changeHashTo('id1'),
changeHashTo('name2'),
changeHashTo('id1'),
callAnchorScroll(),
expectScrollingWithOffset({
'id=id1': 3,
'name=name2': 1
}, [
1 * increment,
2 * increment,
3 * increment,
4 * increment
]));
});
});
describe('when set as a jqLite element', function() {
var elemBottom = 50;
function createAndSetYOffsetElement(position) {
var jqElem = jqLite('<div></div>');
jqElem[0].style.position = position;
return function($anchorScroll, $window) {
jqLite($window.document.body).append(jqElem);
$anchorScroll.yOffset = jqElem;
};
}
it('should scroll with vertical offset when `position === fixed`', inject(
createAndSetYOffsetElement('fixed'),
addElements('id=some'),
mockBoundingClientRect({0: [elemBottom], 1: [0]}),
changeHashTo('some'),
expectScrollingWithOffset('id=some', elemBottom)));
it('should scroll without vertical offset when `position !== fixed`', inject(
createAndSetYOffsetElement('absolute', elemBottom),
expectScrollingWithoutOffset('id=some')));
});
});
describe('and body with border/margin/padding', function() {
var borderWidth = 4;
var marginWidth = 8;
var paddingWidth = 16;
var yOffsetNumber = 50;
var necessaryYOffset = yOffsetNumber - borderWidth - marginWidth - paddingWidth;
beforeEach(inject(setYOffset(yOffsetNumber)));
it('should scroll with vertical offset', inject(
addElements('id=some'),
mockBoundingClientRect({0: [yOffsetNumber - necessaryYOffset]}),
changeHashTo('some'),
expectScrollingWithOffset('id=some', necessaryYOffset)));
it('should use the correct vertical offset when changing `yOffset` at runtime', inject(
addElements('id=some'),
mockBoundingClientRect({0: [
yOffsetNumber - necessaryYOffset,
yOffsetNumber - necessaryYOffset
]}),
changeHashTo('some'),
setYOffset(yOffsetNumber - 10),
callAnchorScroll(),
expectScrollingWithOffset({'id=some': 2}, [necessaryYOffset, necessaryYOffset - 10])));
it('should adjust the vertical offset for elements near the end of the page', function() {
var targetAdjustedOffset = 20;
inject(
addElements('id=some1', 'id=some2'),
mockBoundingClientRect({1: [yOffsetNumber - targetAdjustedOffset]}),
changeHashTo('some2'),
expectScrollingWithOffset('id=some2', targetAdjustedOffset));
});
});
describe('and body with border/margin/padding and boxSizing', function() {
var borderWidth = 4;
var marginWidth = 8;
var paddingWidth = 16;
var yOffsetNumber = 50;
var necessaryYOffset = yOffsetNumber - borderWidth - marginWidth - paddingWidth;
beforeEach(inject(setYOffset(yOffsetNumber)));
it('should scroll with vertical offset', inject(
addElements('id=some'),
mockBoundingClientRect({0: [yOffsetNumber - necessaryYOffset]}),
changeHashTo('some'),
expectScrollingWithOffset('id=some', necessaryYOffset)));
it('should use the correct vertical offset when changing `yOffset` at runtime', inject(
addElements('id=some'),
mockBoundingClientRect({0: [
yOffsetNumber - necessaryYOffset,
yOffsetNumber - necessaryYOffset
]}),
changeHashTo('some'),
setYOffset(yOffsetNumber - 10),
callAnchorScroll(),
expectScrollingWithOffset({'id=some': 2}, [necessaryYOffset, necessaryYOffset - 10])));
it('should adjust the vertical offset for elements near the end of the page', function() {
var targetAdjustedOffset = 20;
inject(
addElements('id=some1', 'id=some2'),
mockBoundingClientRect({1: [yOffsetNumber - targetAdjustedOffset]}),
changeHashTo('some2'),
expectScrollingWithOffset('id=some2', targetAdjustedOffset));
});
});
});
});