From 353bd4dd8791f17e3d94bde72daffafa0a171ffd Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Tue, 7 Oct 2014 13:25:25 +0100 Subject: [PATCH 1/3] feat($anchorScroll): add support for configurable scroll offset Add support for a configurable scroll offset to $anchorScrollProvider. Related to #9368 Closes #2070 Closes #9371 --- src/ng/anchorScroll.js | 275 ++++++++++++++++++++++++++++-------- test/ng/anchorScrollSpec.js | 250 ++++++++++++++++++++++++++++++-- 2 files changed, 452 insertions(+), 73 deletions(-) diff --git a/src/ng/anchorScroll.js b/src/ng/anchorScroll.js index 3b25bfe48bca..148940066b62 100644 --- a/src/ng/anchorScroll.js +++ b/src/ng/anchorScroll.js @@ -1,93 +1,253 @@ 'use strict'; /** - * @ngdoc service - * @name $anchorScroll - * @kind function - * @requires $window - * @requires $location - * @requires $rootScope + * @ngdoc provider + * @name $anchorScrollProvider * * @description - * When called, it checks current value of `$location.hash()` and scrolls to the related element, - * according to rules specified in - * [Html5 spec](http://dev.w3.org/html5/spec/Overview.html#the-indicated-part-of-the-document). - * - * It also watches the `$location.hash()` and scrolls whenever it changes to match any anchor. - * This can be disabled by calling `$anchorScrollProvider.disableAutoScrolling()`. - * - * @example - - -
- Go to bottom - You're at the bottom! -
-
- - angular.module('anchorScrollExample', []) - .controller('ScrollController', ['$scope', '$location', '$anchorScroll', - function ($scope, $location, $anchorScroll) { - $scope.gotoBottom = function() { - // set the location.hash to the id of - // the element you wish to scroll to. - $location.hash('bottom'); - - // call $anchorScroll() - $anchorScroll(); - }; - }]); - - - #scrollArea { - height: 350px; - overflow: auto; - } - - #bottom { - display: block; - margin-top: 2000px; - } - -
+ * Use `$anchorScrollProvider` to disable automatic scrolling whenever + * {@link ng.$location#hash $location.hash()} changes. */ function $AnchorScrollProvider() { var autoScrollingEnabled = true; + /** + * @ngdoc method + * @name $anchorScrollProvider#disableAutoScrolling + * + * @description + * By default, {@link ng.$anchorScroll $anchorScroll()} will automatically will detect changes to + * {@link ng.$location#hash $location.hash()} and scroll to the element matching the new hash.
+ * Use this method to disable automatic scrolling. + * + * If automatic scrolling is disabled, one must explicitly call + * {@link ng.$anchorScroll $anchorScroll()} in order to scroll to the element related to the + * current hash. + */ this.disableAutoScrolling = function() { autoScrollingEnabled = false; }; + /** + * @ngdoc service + * @name $anchorScroll + * @kind function + * @requires $window + * @requires $location + * @requires $rootScope + * + * @description + * When called, it checks the current value of {@link ng.$location#hash $location.hash()} and + * scrolls to the related element, according to the rules specified in the + * [Html5 spec](http://dev.w3.org/html5/spec/Overview.html#the-indicated-part-of-the-document). + * + * It also watches the {@link ng.$location#hash $location.hash()} and automatically scrolls to + * match any anchor whenever it changes. This can be disabled by calling + * {@link ng.$anchorScrollProvider#disableAutoScrolling $anchorScrollProvider.disableAutoScrolling()}. + * + * Additionally, you can use its {@link ng.$anchorScroll#yOffset yOffset} property to specify a + * vertical scroll-offset (either fixed or dynamic). + * + * @property {(number|function|jqLite)} yOffset + * If set, specifies a vertical scroll-offset. This is often useful when there are fixed + * positioned elements at the top of the page, such as navbars, headers etc. + * + * `yOffset` can be specified in various ways: + * - **number**: A fixed number of pixels to be used as offset.

+ * - **function**: A getter function called everytime `$anchorScroll()` is executed. Must return + * a number representing the offset (in pixels).

+ * - **jqLite**: A jqLite/jQuery element to be used for specifying the offset. The sum of the + * element's height and its distance from the top of the page will be used as offset.
+ * **Note**: The element will be taken into account only as long as its `position` is set to + * `fixed`. This option is useful, when dealing with responsive navbars/headers that adjust + * their height and/or positioning according to the viewport's size. + * + *
+ *
+ * In order for `yOffset` to work properly, scrolling should take place on the document's root and + * not some child element. + *
+ * + * @example + + +
+ Go to bottom + You're at the bottom! +
+
+ + angular.module('anchorScrollExample', []) + .controller('ScrollController', ['$scope', '$location', '$anchorScroll', + function ($scope, $location, $anchorScroll) { + $scope.gotoBottom = function() { + // set the location.hash to the id of + // the element you wish to scroll to. + $location.hash('bottom'); + + // call $anchorScroll() + $anchorScroll(); + }; + }]); + + + #scrollArea { + height: 280px; + overflow: auto; + } + + #bottom { + display: block; + margin-top: 2000px; + } + +
+ * + *
+ * The example below illustrates the use of a vertical scroll-offset (specified as a fixed value). + * See {@link ng.$anchorScroll#yOffset $anchorScroll.yOffset} for more details. + * + * @example + + + +
+ Anchor {{x}} of 5 +
+
+ + angular.module('anchorScrollOffsetExample', []) + .run(['$anchorScroll', function($anchorScroll) { + $anchorScroll.yOffset = 50; // always scroll by 50 extra pixels + }]) + .controller('headerCtrl', ['$anchorScroll', '$location', '$scope', + function ($anchorScroll, $location, $scope) { + $scope.gotoAnchor = function(x) { + var newHash = 'anchor' + x; + if ($location.hash() !== newHash) { + // set the $location.hash to `newHash` and + // $anchorScroll will automatically scroll to it + $location.hash('anchor' + x); + } else { + // call $anchorScroll() explicitly, + // since $location.hash hasn't changed + $anchorScroll(); + } + }; + } + ]); + + + body { + padding-top: 50px; + } + + .anchor { + border: 2px dashed DarkOrchid; + padding: 10px 10px 200px 10px; + } + + .fixed-header { + background-color: rgba(0, 0, 0, 0.2); + height: 50px; + position: fixed; + top: 0; left: 0; right: 0; + } + + .fixed-header > a { + display: inline-block; + margin: 5px 15px; + } + +
+ */ 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 + // Helper function to get first anchor from a NodeList + // (using `Array#some()` instead of `angular#forEach()` since it's more performant + // and working in all supported browsers.) function getFirstAnchor(list) { var result = null; - forEach(list, function(element) { - if (!result && nodeName_(element) === 'a') result = element; + Array.prototype.some.call(list, function(element) { + if (nodeName_(element) === 'a') { + result = element; + return true; + } }); return result; } + function getYOffset() { + + var offset = scroll.yOffset; + + if (isFunction(offset)) { + offset = offset(); + } else if (isElement(offset)) { + var elem = offset[0]; + var style = $window.getComputedStyle(elem); + if (style.position !== 'fixed') { + offset = 0; + } else { + var rect = elem.getBoundingClientRect(); + var top = rect.top; + var height = rect.height; + offset = top + height; + } + } else if (!isNumber(offset)) { + offset = 0; + } + + return offset; + } + + function scrollTo(elem) { + if (elem) { + elem.scrollIntoView(); + + var offset = getYOffset(); + + if (offset) { + // `offset` is the number of pixels we should scroll up in order to align `elem` properly. + // This is true ONLY if the call to `elem.scrollIntoView()` initially aligns `elem` at the + // top of the viewport. IF the number of pixels from the top of `elem` to the end of page's + // content is less than the height of the viewport, then `elem.scrollIntoView()` will NOT + // align the top of `elem` at the top of the viewport (but further down). This is often the + // case for elements near the bottom of the page. + // In such cases we do not need to scroll the whole `offset` up, just the fraction of the + // offset that is necessary to align the top of `elem` at the desired position. + var body = document.body; + var bodyRect = body.getBoundingClientRect(); + var elemRect = elem.getBoundingClientRect(); + var necessaryOffset = offset - (elemRect.top - (bodyRect.top + body.scrollTop)); + + $window.scrollBy(0, -1 * necessaryOffset); + } + } else { + $window.scrollTo(0, 0); + } + } + function scroll() { var hash = $location.hash(), elm; // empty hash, scroll to the top of the page - if (!hash) $window.scrollTo(0, 0); + if (!hash) scrollTo(null); // element with given id - else if ((elm = document.getElementById(hash))) elm.scrollIntoView(); + else if ((elm = document.getElementById(hash))) scrollTo(elm); // first anchor with given name :-D - else if ((elm = getFirstAnchor(document.getElementsByName(hash)))) elm.scrollIntoView(); + else if ((elm = getFirstAnchor(document.getElementsByName(hash)))) scrollTo(elm); // no element and hash == 'top', scroll to the top of the page - else if (hash === 'top') $window.scrollTo(0, 0); + else if (hash === 'top') scrollTo(null); } // does not scroll when user clicks on anchor link that is currently on @@ -102,4 +262,3 @@ function $AnchorScrollProvider() { return scroll; }]; } - diff --git a/test/ng/anchorScrollSpec.js b/test/ng/anchorScrollSpec.js index e7933d630195..b85cb8dd3264 100644 --- a/test/ng/anchorScrollSpec.js +++ b/test/ng/anchorScrollSpec.js @@ -1,4 +1,5 @@ 'use strict'; + describe('$anchorScroll', function() { var elmSpy; @@ -6,18 +7,24 @@ describe('$anchorScroll', function() { function addElements() { var elements = sliceArgs(arguments); - return function() { + return function($window) { 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); + jqLite($window.document.body).append(jqElm); }); }; } + function callAnchorScroll() { + return function ($anchorScroll) { + $anchorScroll(); + }; + } + function changeHashAndScroll(hash) { return function($location, $anchorScroll) { $location.hash(hash); @@ -25,6 +32,14 @@ describe('$anchorScroll', function() { }; } + function changeHashTo(hash) { + return function ($anchorScroll, $location, $rootScope) { + $rootScope.$apply(function() { + $location.hash(hash); + }); + }; + } + function expectScrollingToTop($window) { forEach(elmSpy, function(spy, id) { expect(spy).not.toHaveBeenCalled(); @@ -33,11 +48,22 @@ describe('$anchorScroll', function() { expect($window.scrollTo).toHaveBeenCalledWith(0, 0); } - function expectScrollingTo(identifier) { + 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) { - if (identifier === id) expect(spy).toHaveBeenCalledOnce(); - else expect(spy).not.toHaveBeenCalled(); + var count = map[id] || 0; + expect(spy.calls.length).toBe(count); }); expect($window.scrollTo).not.toHaveBeenCalled(); }; @@ -52,8 +78,12 @@ describe('$anchorScroll', function() { elmSpy = {}; $provide.value('$window', { scrollTo: jasmine.createSpy('$window.scrollTo'), + scrollBy: jasmine.createSpy('$window.scrollBy'), document: document, - navigator: {} + navigator: {}, + getComputedStyle: function(elem) { + return getComputedStyle(elem); + } }); })); @@ -113,14 +143,6 @@ describe('$anchorScroll', function() { }; } - function changeHashTo(hash) { - return function ($location, $rootScope, $anchorScroll) { - $rootScope.$apply(function() { - $location.hash(hash); - }); - }; - } - function disableAutoScrolling() { return function($anchorScrollProvider) { $anchorScrollProvider.disableAutoScrolling(); @@ -172,7 +194,7 @@ describe('$anchorScroll', function() { }); - it('should not scroll when disabled', function() { + it('should not scroll when auto-scrolling is disabled', function() { module( disableAutoScrolling(), initLocation({html5Mode: false, historyApi: false}) @@ -183,6 +205,204 @@ describe('$anchorScroll', function() { 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') + ); + }); + }); + + // TODO: Add tests for with: + // 1. border/margin/padding !== 0 + // 2. box-sizing === border-box + describe('yOffset', function() { + + function expectScrollingWithOffset(identifierCountMap, offsetList) { + var list = isArray(offsetList) ? offsetList : [offsetList]; + + return function($window) { + expectScrollingTo(identifierCountMap)($window); + expect($window.scrollBy.calls.length).toBe(list.length); + forEach(list, function(offset, idx) { + expect($window.scrollBy.calls[idx].args).toEqual([0, -1 * offset]); + }); + }; + } + + function expectScrollingWithoutOffset(identifierCountMap) { + return expectScrollingWithOffset(identifierCountMap, []); + } + + function setupBodyForOffsetTesting() { + return function($window) { + var style = $window.document.body.style; + style.border = 'none'; + style.margin = '0'; + style.padding = '0'; + }; + } + + function setYOffset(yOffset) { + return function ($anchorScroll) { + $anchorScroll.yOffset = yOffset; + }; + } + + beforeEach(inject(setupBodyForOffsetTesting())); + + afterEach(inject(function($document) { + dealoc($document); + })); + + + describe('when set as a fixed number', function() { + + var yOffsetNumber = 50; + + beforeEach(inject( + setupBodyForOffsetTesting(), + setYOffset(yOffsetNumber))); + + + it('should scroll with vertical offset', inject( + addElements('id=some'), + changeHashTo('some'), + expectScrollingWithOffset('id=some', yOffsetNumber))); + + + it('should use the correct vertical offset when changing `yOffset` at runtime', inject( + addElements('id=some'), + 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 = 25; + + inject( + addElements('id=some1', 'id=some2'), + function($window) { + // Make sure the elements are just a little shorter than the viewport height + var viewportHeight = $window.document.documentElement.clientHeight; + var elemHeight = viewportHeight - (yOffsetNumber - targetAdjustedOffset); + var cssText = [ + 'border:none', + 'display:block', + 'height:' + elemHeight + 'px', + 'margin:0', + 'padding:0', + ''].join(';'); + + forEach($window.document.body.children, function (elem) { + elem.style.cssText = cssText; + }); + + // Make sure scrolling does actually take place (it is necessary for this test) + forEach(elmSpy, function (spy, identifier) { + elmSpy[identifier] = spy.andCallThrough(); + }); + }, + 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'), + 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() { + + function createAndSetYOffsetElement(styleSpecs) { + var cssText = ''; + forEach(styleSpecs, function(value, key) { + cssText += key + ':' + value + ';'; + }); + + var jqElem = jqLite('
'); + + return function ($anchorScroll, $window) { + jqLite($window.document.body).append(jqElem); + $anchorScroll.yOffset = jqElem; + }; + } + + + it('should scroll with vertical offset when `top === 0`', inject( + createAndSetYOffsetElement({ + background: 'DarkOrchid', + height: '50px', + position: 'fixed', + top: '0', + }), + addElements('id=some'), + changeHashTo('some'), + expectScrollingWithOffset('id=some', 50))); + + + it('should scroll with vertical offset when `top > 0`', inject( + createAndSetYOffsetElement({ + height: '50px', + position: 'fixed', + top: '50px', + }), + addElements('id=some'), + changeHashTo('some'), + expectScrollingWithOffset('id=some', 100))); + + + it('should scroll without vertical offset when `position !== fixed`', inject( + createAndSetYOffsetElement({ + height: '50px', + position: 'absolute', + top: '0', + }), + addElements('id=some'), + changeHashTo('some'), + expectScrollingWithoutOffset('id=some'))); + }); }); }); From 2c995eb73ddbacdde6aa3d362898c2cb770b0697 Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Tue, 7 Oct 2014 13:26:27 +0100 Subject: [PATCH 2/3] chore(docs): use new $anchorScroll offset feature in docs Closes #9360 --- docs/app/src/app.js | 3 +-- docs/app/src/directives.js | 7 ++++++- docs/config/templates/indexPage.template.html | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/app/src/app.js b/docs/app/src/app.js index af94d44a2b8d..7285aa8ccdd8 100644 --- a/docs/app/src/app.js +++ b/docs/app/src/app.js @@ -17,7 +17,6 @@ angular.module('docsApp', [ 'ui.bootstrap.dropdown' ]) - .config(['$locationProvider', function($locationProvider) { $locationProvider.html5Mode(true).hashPrefix('!'); -}]); +}]); \ No newline at end of file diff --git a/docs/app/src/directives.js b/docs/app/src/directives.js index 30077e83ff94..4c3acf781000 100644 --- a/docs/app/src/directives.js +++ b/docs/app/src/directives.js @@ -28,5 +28,10 @@ angular.module('directives', []) element.html(window.prettyPrintOne(html, lang, linenums)); } }; -}); +}) +.directive('scrollYOffsetElement', ['$anchorScroll', function($anchorScroll) { + return function(scope, element) { + $anchorScroll.yOffset = element; + }; +}]); diff --git a/docs/config/templates/indexPage.template.html b/docs/config/templates/indexPage.template.html index 852468434054..b96494943c06 100644 --- a/docs/config/templates/indexPage.template.html +++ b/docs/config/templates/indexPage.template.html @@ -70,7 +70,7 @@
-
+