Commit 8ad1510b authored by Daniel Kurowski's avatar Daniel Kurowski
Browse files

Merge branch '4-new-options' into 'master'

Resolve "Expose manual scrollTo*() methods to public API"

See merge request !8
parents 40d25197 e301161b
Pipeline #29223 failed with stages
in 57 seconds
.idea/
.rpt2_cache/
dist/
node_modules/
visual.dist.*
yarn.lock
# @grifart/smoothscroll
As smoothscroll functionality is nice and more user-friendly, this library solves these two things which were often repeating in our code:
Aim of this library is to provide more user-friendly
and less epileptic scrolling effect on a long content
by creating a customized easing and to apply this easing
to all various types of scrolling on a page
(see [Covered scenarios](#Covered scenarios)).
1. Enables smooth scrolling on all anchors starting with `#` character.
2. Enables smooth scrolling when page is entered with `#` character in URL.
Additionally this library comes with custom easing function registered by default which works as basic ease-in-out with one modification that it skips content if it is too long. This results in nicer transition between two parts of a page.
## Installation and usage
## Installation
```bash
npm install @grifart/smoothscroll
# or
yarn add @grifart/smoothscroll
```
```javascript
import SmoothScroll from '@grifart/smoothscroll';
## Usage
`@grifart/smoothscroll` exposes various functions to handle
different types of scrolling in your application. Each function
is importable through standard ES `import` directive.
// use defaults
SmoothScroll.enable();
### Global scrolling behavior
// customize
SmoothScroll.enable({
scrollOnLoad: false,
scrollOnLinkClick: true,
});
| Function | Description |
|---|---|
| **`handleOnLoadScroll()`** | Attaches scrolling to anchored element when the page is loaded.\* |
| **`handleOnLinkClickScroll()`** | Attaches scrolling to given element when user clicks on an `a` tag having `href` starting with `#` character. |
Use these functions **in top-level code**:
```javascript
import {handleOnLoadScroll, handleOnLinkClickScroll} from '@grifart/smoothscroll';
handleOnLoadScroll();
handleOnLinkClickScroll();
```
### Options
\*Note: when page load lasts more than 500 ms, load scroll effect
is disabled as it would lead to user-unfriendly behaviour like
jumping on the page up and down due to browser native behaviour.
### scrollToX functions
|Function|Parameters|
|---|---|
|`scrollToElement(element[, onScrollFinishedCallback])`|`element`: element to scroll to; `onScrollFinishedCallback` (optional): callback to trigger when scrolling is finished|
|`scrollToOffset(topOffset[, onScrollFinishedCallback])`|`topOffset`: scroll offset from top of document; `onScrollFinishedCallback` (optional): callback to trigger when scrolling is finished|
|`scrollToTarget(hashTarget[, onScrollFinishedCallback])`|`hashTarget`: instance of `HashTarget` object\* or `string`\*\*; `onScrollFinishedCallback` (optional): callback to trigger when scrolling is finished|
\* `HashTarget` is a value object representing a target to scroll to.
You can easily initalize it with named constructor:
`HashTarget.fromString('#some-identifier', document)`
\*\* In case of passing `string`, `HashTarget` object will be
instantiated automatically with current `document` context.
## More about
### Custom scrolling effect
| Option | Value | Default value | Description |
| --- | --- | --- | --- |
| `scrollOnLoad`\* | `true`/`false` | `true` | Causes smooth scroll to anchored element when the page is loaded.
| `scrollOnLinkClick` | `true`/`false` | `true` | Causes smooth scroll on given element when user clicks on an `a` tag having `href` starting with `#` character.
Improved scrolling effect (internally called `ease-in-skip-out`) is registered
by default and it works as basic ease-in-out with one modification
that it skips content if it is too long. This results in nicer transition
between two distant parts in a page.
\*Note: when the page load lasts more than 500 ms, load smooth scroll effect is disabled as it would lead to user-unfriendly behaviour like jumping on the page up and down due to browser native behaviour.
### Covered scenarios
- scroll when clicking a link with an anchor (`<a href="#anchor">whatever</a>`)
- scroll when page is entered with an anchor (`https://example.com/whatever#anchor`)
- scroll when programatically needed to scroll to:
- given position (top offset)
- given element
- given target (`id` attribute)
## Development
......@@ -44,5 +80,6 @@ yarn install
yarn dev
```
Every piece of this library comes with its unit test sitting alongside the script. Whole library is covered by integration test sitting in `src` folder.
Every piece of this library comes with its unit test sitting alongside the script.
Whole library is covered by integration test sitting in `src` folder.
Note that you have to build assets first (`yarn build`) before running a test.
export declare class HashTarget {
private readonly value;
private readonly targetElement;
private constructor();
static fromString(value: string, document: HTMLDocument): HashTarget;
getHash(): string;
getElement(): HTMLElement;
}
export declare class AssertionError extends Error {
}
export declare function assert(condition: any, message?: string): asserts condition;
export declare function initializeOnLinkClickScroll(): void;
export declare function initializeOnLoadScroll(): void;
import { scrollToElement } from './scrollers/scrollToElement';
import { scrollToOffset } from './scrollers/scrollToOffset';
import { scrollToTarget } from './scrollers/scrollToTarget';
import { HashTarget } from './HashTarget';
declare function handleOnLoadScroll(): void;
declare function handleOnLinkClickScroll(): void;
export { HashTarget, handleOnLoadScroll, handleOnLinkClickScroll, scrollToElement, scrollToOffset, scrollToTarget, };
import * as Velocity from 'velocity-animate';
import { animate } from 'velocity-animate';
var EASE_IN_SKIP_OUT_EASING = 'ease-in-skip-out'; // e.g. (5, 5, 10, 500, 1000) => 500
// e.g. (5, 0, 10, 500, 1000) => 750
var mapIntervalLinear = function mapIntervalLinear(number, originalFrom, originalTo, newFrom, newTo) {
var oldDistance = originalTo - originalFrom;
var newDistance = newTo - newFrom; // normalize value into interval 0 .. 1
var normalized = (number - originalFrom) / oldDistance; // extend and move normalized value into new interval
return normalized * newDistance + newFrom;
};
/**
* Composes easings together, splits time into half.
*
* @param firstHalfEasingFn first half of easing
* @param secondHalfEasingFn second half of easing
* @return {function(*=, *=, *=)} the composed easing
*/
var composeEasing = function composeEasing(firstHalfEasingFn, secondHalfEasingFn) {
// time: The call's completion percentage (decimal value).
// opts (optional): The options object passed into the triggering Velocity call.
// tweenDelta (optional): The difference between the animating property's ending value and its starting value.
return function (time, opts, tweenDelta) {
if (time < 0.5) {
var normalizedTime = mapIntervalLinear(time, 0, 0.5, 0, 1); // map 0 - 0.5 => 0 - 1
return mapIntervalLinear(firstHalfEasingFn(normalizedTime, opts, tweenDelta), 0, 1, 0, 0.5); // map 1 - 0 => 0 - 0.5
} else {
var _normalizedTime = mapIntervalLinear(time, 0.5, 1, 0, 1); // map 0 - 0.5 => 0 - 1
return mapIntervalLinear(secondHalfEasingFn(_normalizedTime, opts, tweenDelta), 0, 1, 0.5, 1); // map 1 - 0 => 0 - 0.5
}
};
};
var computeHowMuchToSkip = function computeHowMuchToSkip(tweenDelta) {
var howManyScreens = Math.abs(tweenDelta) / window.innerHeight; // 0 .. 1 (percents)
var howMuchToSkip = 0; // by testing in browser we have found following values as smooth:
// howManyScreens .. howMuchToSkip
// 1 .. 0%
// 2 .. 0%
// 3 .. 30%
// 8 .. 60%
// 30 .. 85%
// 60 .. 90%
// 100 .. 90%
if (howManyScreens <= 2) {
howMuchToSkip = 0;
} else if (howManyScreens <= 4) {
// 2 - 4 screens; skip 0% - 30% of content
howMuchToSkip = mapIntervalLinear(howManyScreens, 2, 4, 0, 0.3);
} else if (howManyScreens <= 8) {
// 4 - 8 screens; skip 30% - 60% of content
howMuchToSkip = mapIntervalLinear(howManyScreens, 4, 8, 0.3, 0.6);
} else if (howManyScreens <= 30) {
// 8 - 30 screens; skip 60% - 85% of content
howMuchToSkip = mapIntervalLinear(howManyScreens, 8, 30, 0.6, 0.85);
} else if (howManyScreens <= 60) {
// 30 - 60 screens; skip 85% - 30% of content
howMuchToSkip = mapIntervalLinear(howManyScreens, 30, 60, 0.85, 0.9);
} else {
// > 60 screens; skip 90% of content
howMuchToSkip = 0.9;
}
return howMuchToSkip;
};
var bindEasingToVelocity = function bindEasingToVelocity(velocity) {
velocity.Easings[EASE_IN_SKIP_OUT_EASING] = composeEasing(function (time, opts, tweenDelta) {
return mapIntervalLinear(velocity.Easings['ease-in'](time, opts, tweenDelta), 0, 1, // from interval
0, 1 - computeHowMuchToSkip(tweenDelta) // to interval
);
}, function (time, opts, tweenDelta) {
return mapIntervalLinear(velocity.Easings['ease-out'](time, opts, tweenDelta), 0, 1, // from interval
computeHowMuchToSkip(tweenDelta), 1 // to interval
);
});
};
var HashTarget = (function () {
function HashTarget(value, targetElement) {
this.value = value;
this.targetElement = targetElement;
}
HashTarget.fromString = function (value, document) {
if (value === '' || value === '#') {
throw new Error('Hash does not contain any fragment.');
}
var targetElementId = value.substring(1);
var targetElement = document.getElementById(targetElementId);
if (targetElement === null) {
throw new Error("No referenced element with ID " + targetElementId + " exists.");
}
return new this(value, targetElement);
};
HashTarget.prototype.getHash = function () {
return this.value;
};
HashTarget.prototype.getElement = function () {
return this.targetElement;
};
return HashTarget;
}());
function scrollToElement(element, onScrollFinishedCallback) {
animate(element, 'scroll', {
duration: 1200,
easing: EASE_IN_SKIP_OUT_EASING,
complete: function () { return onScrollFinishedCallback !== undefined && onScrollFinishedCallback(); },
});
}
/*! *****************************************************************************
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at http://www.apache.org/licenses/LICENSE-2.0
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
MERCHANTABLITY OR NON-INFRINGEMENT.
See the Apache Version 2.0 License for specific language governing permissions
and limitations under the License.
***************************************************************************** */
/* global Reflect, Promise */
var extendStatics = function(d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
return extendStatics(d, b);
};
function __extends(d, b) {
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
}
var AssertionError = (function (_super) {
__extends(AssertionError, _super);
function AssertionError() {
return _super !== null && _super.apply(this, arguments) || this;
}
return AssertionError;
}(Error));
function assert(condition, message) {
if (!condition) {
throw new AssertionError(message);
}
}
function scrollToTarget(hashTarget, onScrollFinishedCallback) {
if (typeof hashTarget === 'string') {
hashTarget = HashTarget.fromString(hashTarget, document);
}
scrollToElement(hashTarget.getElement(), function () {
assert(hashTarget instanceof HashTarget);
window.location.hash = hashTarget.getHash();
onScrollFinishedCallback !== undefined && onScrollFinishedCallback();
});
}
function initializeOnLoadScroll() {
var hash = window.location.hash;
if (hash === '' || hash === '#') {
return;
}
var hashTarget = null;
var start = performance.now();
document.addEventListener('DOMContentLoaded', function () {
hashTarget = HashTarget.fromString(hash, document);
});
window.addEventListener('load', function () {
var end = performance.now();
if (end - start > 500) {
return;
}
window.scroll({ top: 0, left: 0 });
assert(hashTarget !== null, 'Hash target should be set on DOM loaded.');
scrollToTarget(hashTarget);
});
}
function initializeOnLinkClickScroll() {
document.addEventListener('DOMContentLoaded', function () {
return document.querySelectorAll('a[href^="#"]').forEach(function (item) {
return item.addEventListener('click', function (event) {
var element = event.currentTarget;
assert(element !== null);
if (element.hash === '' || element.hash === '#') {
return;
}
event.preventDefault();
scrollToTarget(HashTarget.fromString(element.hash, document));
});
});
});
}
function scrollToOffset(topOffset, onScrollFinishedCallback) {
animate(document.documentElement, 'scroll', {
duration: 1200,
offset: topOffset,
easing: EASE_IN_SKIP_OUT_EASING,
complete: onScrollFinishedCallback,
});
}
bindEasingToVelocity(Velocity);
function handleOnLoadScroll() {
initializeOnLoadScroll();
}
function handleOnLinkClickScroll() {
initializeOnLinkClickScroll();
}
export { HashTarget, handleOnLoadScroll, handleOnLinkClickScroll, scrollToElement, scrollToOffset, scrollToTarget };
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('velocity-animate')) :
typeof define === 'function' && define.amd ? define(['exports', 'velocity-animate'], factory) :
(factory((global.SmoothScroll = {}),global.Velocity));
}(this, (function (exports,Velocity) { 'use strict';
var EASE_IN_SKIP_OUT_EASING = 'ease-in-skip-out'; // e.g. (5, 5, 10, 500, 1000) => 500
// e.g. (5, 0, 10, 500, 1000) => 750
var mapIntervalLinear = function mapIntervalLinear(number, originalFrom, originalTo, newFrom, newTo) {
var oldDistance = originalTo - originalFrom;
var newDistance = newTo - newFrom; // normalize value into interval 0 .. 1
var normalized = (number - originalFrom) / oldDistance; // extend and move normalized value into new interval
return normalized * newDistance + newFrom;
};
/**
* Composes easings together, splits time into half.
*
* @param firstHalfEasingFn first half of easing
* @param secondHalfEasingFn second half of easing
* @return {function(*=, *=, *=)} the composed easing
*/
var composeEasing = function composeEasing(firstHalfEasingFn, secondHalfEasingFn) {
// time: The call's completion percentage (decimal value).
// opts (optional): The options object passed into the triggering Velocity call.
// tweenDelta (optional): The difference between the animating property's ending value and its starting value.
return function (time, opts, tweenDelta) {
if (time < 0.5) {
var normalizedTime = mapIntervalLinear(time, 0, 0.5, 0, 1); // map 0 - 0.5 => 0 - 1
return mapIntervalLinear(firstHalfEasingFn(normalizedTime, opts, tweenDelta), 0, 1, 0, 0.5); // map 1 - 0 => 0 - 0.5
} else {
var _normalizedTime = mapIntervalLinear(time, 0.5, 1, 0, 1); // map 0 - 0.5 => 0 - 1
return mapIntervalLinear(secondHalfEasingFn(_normalizedTime, opts, tweenDelta), 0, 1, 0.5, 1); // map 1 - 0 => 0 - 0.5
}
};
};
var computeHowMuchToSkip = function computeHowMuchToSkip(tweenDelta) {
var howManyScreens = Math.abs(tweenDelta) / window.innerHeight; // 0 .. 1 (percents)
var howMuchToSkip = 0; // by testing in browser we have found following values as smooth:
// howManyScreens .. howMuchToSkip
// 1 .. 0%
// 2 .. 0%
// 3 .. 30%
// 8 .. 60%
// 30 .. 85%
// 60 .. 90%
// 100 .. 90%
if (howManyScreens <= 2) {
howMuchToSkip = 0;
} else if (howManyScreens <= 4) {
// 2 - 4 screens; skip 0% - 30% of content
howMuchToSkip = mapIntervalLinear(howManyScreens, 2, 4, 0, 0.3);
} else if (howManyScreens <= 8) {
// 4 - 8 screens; skip 30% - 60% of content
howMuchToSkip = mapIntervalLinear(howManyScreens, 4, 8, 0.3, 0.6);
} else if (howManyScreens <= 30) {
// 8 - 30 screens; skip 60% - 85% of content
howMuchToSkip = mapIntervalLinear(howManyScreens, 8, 30, 0.6, 0.85);
} else if (howManyScreens <= 60) {
// 30 - 60 screens; skip 85% - 30% of content
howMuchToSkip = mapIntervalLinear(howManyScreens, 30, 60, 0.85, 0.9);
} else {
// > 60 screens; skip 90% of content
howMuchToSkip = 0.9;
}
return howMuchToSkip;
};
var bindEasingToVelocity = function bindEasingToVelocity(velocity) {
velocity.Easings[EASE_IN_SKIP_OUT_EASING] = composeEasing(function (time, opts, tweenDelta) {
return mapIntervalLinear(velocity.Easings['ease-in'](time, opts, tweenDelta), 0, 1, // from interval
0, 1 - computeHowMuchToSkip(tweenDelta) // to interval
);
}, function (time, opts, tweenDelta) {
return mapIntervalLinear(velocity.Easings['ease-out'](time, opts, tweenDelta), 0, 1, // from interval
computeHowMuchToSkip(tweenDelta), 1 // to interval
);
});
};
var HashTarget = (function () {
function HashTarget(value, targetElement) {
this.value = value;
this.targetElement = targetElement;
}
HashTarget.fromString = function (value, document) {
if (value === '' || value === '#') {
throw new Error('Hash does not contain any fragment.');
}
var targetElementId = value.substring(1);
var targetElement = document.getElementById(targetElementId);
if (targetElement === null) {
throw new Error("No referenced element with ID " + targetElementId + " exists.");
}
return new this(value, targetElement);
};
HashTarget.prototype.getHash = function () {
return this.value;
};
HashTarget.prototype.getElement = function () {
return this.targetElement;
};
return HashTarget;
}());
function scrollToElement(element, onScrollFinishedCallback) {
Velocity.animate(element, 'scroll', {
duration: 1200,
easing: EASE_IN_SKIP_OUT_EASING,
complete: function () { return onScrollFinishedCallback !== undefined && onScrollFinishedCallback(); },
});
}
/*! *****************************************************************************
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at http://www.apache.org/licenses/LICENSE-2.0
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
MERCHANTABLITY OR NON-INFRINGEMENT.
See the Apache Version 2.0 License for specific language governing permissions
and limitations under the License.
***************************************************************************** */
/* global Reflect, Promise */
var extendStatics = function(d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
return extendStatics(d, b);
};
function __extends(d, b) {
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
}
var AssertionError = (function (_super) {
__extends(AssertionError, _super);
function AssertionError() {
return _super !== null && _super.apply(this, arguments) || this;
}
return AssertionError;
}(Error));
function assert(condition, message) {
if (!condition) {
throw new AssertionError(message);
}
}
function scrollToTarget(hashTarget, onScrollFinishedCallback) {
if (typeof hashTarget === 'string') {
hashTarget = HashTarget.fromString(hashTarget, document);
}
scrollToElement(hashTarget.getElement(), function () {
assert(hashTarget instanceof HashTarget);
window.location.hash = hashTarget.getHash();
onScrollFinishedCallback !== undefined && onScrollFinishedCallback();
});
}
function initializeOnLoadScroll() {
var hash = window.location.hash;
if (hash === '' || hash === '#') {
return;
}
var hashTarget = null;
var start = performance.now();
document.addEventListener('DOMContentLoaded', function () {
hashTarget = HashTarget.fromString(hash, document);
});
window.addEventListener('load', function () {
var end = performance.now();
if (end - start > 500) {
return;
}
window.scroll({ top: 0, left: 0 });
assert(hashTarget !== null, 'Hash target should be set on DOM loaded.');
scrollToTarget(hashTarget);
});
}
function initializeOnLinkClickScroll() {
document.addEventListener('DOMContentLoaded', function () {
return document.querySelectorAll('a[href^="#"]').forEach(function (item) {
return item.addEventListener('click', function (event) {
var element = event.currentTarget;
assert(element !== null);
if (element.hash === '' || element.hash === '#') {
return;
}
event.preventDefault();
scrollToTarget(HashTarget.fromString(element.hash, document));
});
});
});
}
function scrollToOffset(topOffset, onScrollFinishedCallback) {
Velocity.animate(document.documentElement, 'scroll', {
duration: 1200,
offset: topOffset,
easing: EASE_IN_SKIP_OUT_EASING,
complete: onScrollFinishedCallback,
});
}
bindEasingToVelocity(Velocity);
function handleOnLoadScroll() {
initializeOnLoadScroll();
}
function handleOnLinkClickScroll() {
initializeOnLinkClickScroll();
}
exports.HashTarget = HashTarget;