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

ANCHOR SCROLL: WIP - feat($anchorScroll): add support for configurable scroll offset #9371

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/app/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ angular.module('docsApp', [
'ui.bootstrap.dropdown'
])


.config(['$locationProvider', function($locationProvider) {
$locationProvider.html5Mode(true).hashPrefix('!');
}]);
}]);
7 changes: 6 additions & 1 deletion docs/app/src/directives.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}]);
2 changes: 1 addition & 1 deletion docs/config/templates/indexPage.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
</head>
<body>
<div id="wrapper">
<header class="header header-fixed">
<header scroll-y-offset-element class="header header-fixed">
<section class="navbar navbar-inverse docs-navbar-primary" ng-controller="DocsSearchCtrl">
<div class="container">
<div class="row">
Expand Down
283 changes: 230 additions & 53 deletions src/ng/anchorScroll.js
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
Copy link
Member Author

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) ?

Copy link
Contributor

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.

* 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
Expand All @@ -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;
}
Copy link
Contributor

Choose a reason for hiding this comment

The 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;
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You missed an isNumber() check in the end, but other than that the statements are functionally identical.
I tried to micro-optimize here (i.e. adding cheaper checks near the top) and I don't find the result overly complex, but if you think it's not worth it, I can change it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need the isNumber check, that is covered in the else clause.
If it is not a number won't it just get converted in the scrollTo function below?

} 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();
}
});
});
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right. I forgot about the .main-body-grid { margin-top: 120px; CSS style.

Copy link
Contributor

Choose a reason for hiding this comment

The 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();
        }
      });
    });
  }
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For good measure it might be best to set scrollScheduled back to false in the handler just in case in the future we want to call it again.


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
Expand All @@ -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;
}];
}

Loading