-
Notifications
You must be signed in to change notification settings - Fork 27.3k
ANCHOR SCROLL: WIP - feat($anchorScroll): add support for configurable scroll offset #9371
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,66 +1,174 @@ | ||
| '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 | ||
| <example module="anchorScrollExample"> | ||
| <file name="index.html"> | ||
| <div id="scrollArea" ng-controller="ScrollController"> | ||
| <a ng-click="gotoBottom()">Go to bottom</a> | ||
| <a id="bottom"></a> You're at the bottom! | ||
| </div> | ||
| </file> | ||
| <file name="script.js"> | ||
| 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(); | ||
| }; | ||
| }]); | ||
| </file> | ||
| <file name="style.css"> | ||
| #scrollArea { | ||
| height: 350px; | ||
| overflow: auto; | ||
| } | ||
|
|
||
| #bottom { | ||
| display: block; | ||
| margin-top: 2000px; | ||
| } | ||
| </file> | ||
| </example> | ||
| * 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.<br /> | ||
| * 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.<br /><br /> | ||
| * - **function**: A getter function called everytime `$anchorScroll()` is executed. Must return | ||
| * a number representing the offset (in pixels).<br /><br /> | ||
| * - **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.<br /> | ||
| * **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. | ||
| * | ||
| * <br /> | ||
| * <div class="alert alert-warning"> | ||
| * In order for `yOffset` to work properly, scrolling should take place on the document's root and | ||
| * not some child element. | ||
| * </div> | ||
| * | ||
| * @example | ||
| <example module="anchorScrollExample"> | ||
| <file name="index.html"> | ||
| <div id="scrollArea" ng-controller="ScrollController"> | ||
| <a ng-click="gotoBottom()">Go to bottom</a> | ||
| <a id="bottom"></a> You're at the bottom! | ||
| </div> | ||
| </file> | ||
| <file name="script.js"> | ||
| 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(); | ||
| }; | ||
| }]); | ||
| </file> | ||
| <file name="style.css"> | ||
| #scrollArea { | ||
| height: 280px; | ||
| overflow: auto; | ||
| } | ||
|
|
||
| #bottom { | ||
| display: block; | ||
| margin-top: 2000px; | ||
| } | ||
| </file> | ||
| </example> | ||
| * | ||
| * <hr /> | ||
| * 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 | ||
| <example module="anchorScrollOffsetExample"> | ||
| <file name="index.html"> | ||
| <div class="fixed-header" ng-controller="headerCtrl"> | ||
| <a href="" ng-click="gotoAnchor(x)" ng-repeat="x in [1,2,3,4,5]"> | ||
| Go to anchor {{x}} | ||
| </a> | ||
| </div> | ||
| <div id="anchor{{x}}" class="anchor" ng-repeat="x in [1,2,3,4,5]"> | ||
| Anchor {{x}} of 5 | ||
| </div> | ||
| </file> | ||
| <file name="script.js"> | ||
| 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(); | ||
| } | ||
| }; | ||
| } | ||
| ]); | ||
| </file> | ||
| <file name="style.css"> | ||
| 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; | ||
| } | ||
| </file> | ||
| </example> | ||
| */ | ||
| this.$get = ['$window', '$location', '$rootScope', function($window, $location, $rootScope) { | ||
| var document = $window.document; | ||
| var scrollScheduled = false; | ||
|
|
||
| // Helper function to get first anchor from a NodeList | ||
| // (using `Array#some()` instead of `angular#forEach()` since it's more performant | ||
|
|
@@ -76,20 +184,90 @@ function $AnchorScrollProvider() { | |
| 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; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This set of if statements seems overly complex... Why not the following? Am I missing some subtlety? if(isElement(offset)) {
...
else if (isFunction(offset)) {
...
} else {
offset = offset || 0;
}
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You missed an
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we need the |
||
| } 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 the | ||
| // 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 elemTop = elem.getBoundingClientRect().top; | ||
| var bodyTop = document.body.getBoundingClientRect().top; | ||
| var scrollTop = $window.pageYOffset; | ||
| var necessaryOffset = offset - (elemTop - (bodyTop + scrollTop)); | ||
|
|
||
| $window.scrollBy(0, -1 * necessaryOffset); | ||
| } | ||
| } else { | ||
| $window.scrollTo(0, 0); | ||
| } | ||
| } | ||
|
|
||
| function scrollWhenReady() { | ||
| if (document.readyState === 'complete') { | ||
| $rootScope.$evalAsync(scroll); | ||
| } else if (!scrollScheduled) { | ||
| scrollScheduled = true; | ||
| document.addEventListener('readystatechange', function unbindAndScroll() { | ||
| // When navigating to a page with a URL including a hash, | ||
| // Firefox overwrites our `yOffset` if `$apply()` is used instead. | ||
| $rootScope.$evalAsync(function() { | ||
| if (document.readyState === 'complete') { | ||
| scrollScheduled = false; | ||
| document.removeEventListener('readystatechange', unbindAndScroll); | ||
| scroll(); | ||
| } | ||
| }); | ||
| }); | ||
| } | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this should be: function scrollTo(elem) {
if (elem) {
elem.scrollIntoView();
var offset = scrollOffsetGetter();
if (offset) {
$window.scrollBy(0, -1 * offset);
}
} else {
$window.scrollTo(0, 0);
}
}Otherwise scrolling to the top never gets to 0, no?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are right. I forgot about the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So this function is called from within a $watch handler so it is already in a digest phase, but the readystatechange handler is not inside a $digest. So I think this should look like: function scrollWhenReady() {
if (document.readyState === 'complete') {
$rootScope.$evalAsync(scroll);
} else if (!scrollScheduled) {
scrollScheduled = true;
document.addEventListener('readystatechange', function unbindAndScroll() {
$rootScope.$apply(function() {
if (document.readyState === 'complete') {
document.removeEventListener('readystatechange', unbindAndScroll);
scroll();
}
});
});
}
}
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For good measure it might be best to set |
||
|
|
||
| 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 | ||
|
|
@@ -100,11 +278,10 @@ function $AnchorScrollProvider() { | |
| // skip the initial scroll if $location.hash is empty | ||
| if (newVal === oldVal && newVal === '') return; | ||
|
|
||
| $rootScope.$evalAsync(scroll); | ||
| scrollWhenReady(); | ||
| }); | ||
| } | ||
|
|
||
| return scroll; | ||
| }]; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@petebacondarwin
Right now we only need the "raw" DOM element (not the jqLite element), but still expect a jqLite element.
Should we modify the implementation so both raw and jqLite work (or do you think it complicates the "contract" for no real benefit) ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's leave it as a wrapped element for now, it is easier to add public API that remove it.