diff --git a/.eslintrc.js b/.eslintrc.js index ec0142c1..0afe5adc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -33,7 +33,8 @@ module.exports = { 'addon/**', 'addon-test-support/**', 'app/**', - 'tests/dummy/app/**' + 'tests/dummy/app/**', + 'tests/helpers/**' ], parserOptions: { sourceType: 'script', diff --git a/README.md b/README.md index fb0420d2..c824626c 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ needs:[ 'service:router-scroll', 'service:scheduler' ], ### Options +#### Target Elements + If you need to scroll to the top of an area that generates a vertical scroll bar, you can specify the id of an element of the scrollable area. Default is `window` for using the scroll position of the whole viewport. You can pass an options object in your application's `config/environment.js` file. @@ -93,8 +95,22 @@ ENV['routerScroll'] = { }; ``` -Moreover, if your route breaks up render into multiple phases, you may need to delay scrollTop functionality until after -the First Meaningful Paint using `delayScrollTop: true` in your config. `delayScrollTop` defaults to `false`. +#### Scroll Timing + +You may want the default "out of the box" behaviour. We schedule scroll after Ember's `render`. This occurs on the tightest schedule between route transition start and end + +However, you have other options. You may need to delay scroll functionality until after +the First Meaningful Paint using `scrollWhenPainted: true` in your config. `scrollWhenPainted` defaults to `false`. + +Then next two config properties uses [`ember-app-scheduler`](https://github.com/ember-app-scheduler/ember-app-scheduler), so be sure to follow the instructions in the README. We include the `setupRouter` and `reset`. This all happens after `routeDidChange`. + +```javascript +ENV['routerScroll'] = { + scrollWhenPainted: true +}; +``` + +Also, if you need to perform the logic when the route is idle or if your route breaks up render into multiple phases, add `delayScrollTop: true` in your config. `delayScrollTop` defaults to `false`. This will be renamed to `scrollWhenIdle` in a major release. ```javascript ENV['routerScroll'] = { @@ -102,6 +118,8 @@ ENV['routerScroll'] = { }; ``` +I would suggest trying all of them out and seeing which works best for your app! + ## A working example diff --git a/addon/index.js b/addon/index.js index a44a5ce7..c23c824f 100644 --- a/addon/index.js +++ b/addon/index.js @@ -3,8 +3,45 @@ import { get, getWithDefault, computed } from '@ember/object'; import { inject } from '@ember/service'; import { getOwner } from '@ember/application'; import { scheduleOnce } from '@ember/runloop'; -import { setupRouter, reset, whenRouteIdle } from 'ember-app-scheduler'; +import { setupRouter, reset, whenRouteIdle, whenRoutePainted } from 'ember-app-scheduler'; import { gte } from 'ember-compatibility-helpers'; +import { getScrollBarWidth } from './utils/scrollbar-width'; + +let scrollBarWidth = getScrollBarWidth(); +const body = document.body; +const html = document.documentElement; +let ATTEMPTS = 0; +const MAX_ATTEMPTS = 100; // rAF runs every 16ms ideally, so 60x a second +let requestId; + +/** + * By default, we start checking to see if the document height is >= the last known `y` position + * we want to scroll to. This is important for content heavy pages that might try to scrollTo + * before the content has painted + * + * @method tryScrollRecursively + * @param {Function} fn + * @param {Object} scrollHash + * @void + */ +function tryScrollRecursively(fn, scrollHash) { + requestId = window.requestAnimationFrame(() => { + const documentWidth = Math.max(body.scrollWidth, body.offsetWidth, + html.clientWidth, html.scrollWidth, html.offsetWidth); + const documentHeight = Math.max(body.scrollHeight, body.offsetHeight, + html.clientHeight, html.scrollHeight, html.offsetHeight); + + if (documentWidth + scrollBarWidth - window.innerWidth >= scrollHash.x + && documentHeight + scrollBarWidth - window.innerHeight >= scrollHash.y + || ATTEMPTS >= MAX_ATTEMPTS) { + ATTEMPTS = 0; + fn.call(null, scrollHash.x, scrollHash.y); + } else { + ATTEMPTS++; + tryScrollRecursively(fn, scrollHash) + } + }) +} let RouterScrollMixin = Mixin.create({ service: inject('router-scroll'), @@ -33,15 +70,21 @@ let RouterScrollMixin = Mixin.create({ destroy() { reset(); + if (requestId) { + window.cancelAnimationFrame(requestId); + } + this._super(...arguments); }, /** * Updates the scroll position - * @param {transition|transition[]} transition If before Ember 3.6, this will be an array of transitions, otherwise * it will be a single transition + * @method updateScrollPosition + * @param {transition|transition[]} transition If before Ember 3.6, this will be an array of transitions, otherwise + * @param {Boolean} recursiveCheck - if "true", check until document height is >= y. `y` is the last coordinate the target page was on */ - updateScrollPosition(transition) { + updateScrollPosition(transition, recursiveCheck) { const url = get(this, 'currentURL'); const hashElement = url ? document.getElementById(url.split('#').pop()) : null; @@ -74,10 +117,14 @@ let RouterScrollMixin = Mixin.create({ const scrollElement = get(this, 'service.scrollElement'); const targetElement = get(this, 'service.targetElement'); - if (targetElement) { - window.scrollTo(scrollPosition.x, scrollPosition.y); - } else if ('window' === scrollElement) { - window.scrollTo(scrollPosition.x, scrollPosition.y); + if (targetElement || 'window' === scrollElement) { + if (recursiveCheck) { + // our own implementation + tryScrollRecursively(window.scrollTo, scrollPosition); + } else { + // using ember-app-scheduler + window.scrollTo(scrollPosition.x, scrollPosition.y); + } } else if ('#' === scrollElement.charAt(0)) { const element = document.getElementById(scrollElement.substring(1)); @@ -103,12 +150,20 @@ let RouterScrollMixin = Mixin.create({ } const delayScrollTop = get(this, 'service.delayScrollTop'); + const scrollWhenPainted = get(this, 'service.scrollWhenPainted'); + const scrollWhenIdle = get(this, 'service.scrollWhenIdle'); - if (!delayScrollTop) { - scheduleOnce('render', this, () => this.updateScrollPosition(transition)); - } else { + if (!delayScrollTop && !scrollWhenPainted && !scrollWhenIdle) { + // out of the 3 options, this happens on the tightest schedule + scheduleOnce('render', this, () => this.updateScrollPosition(transition, true)); + } else if (scrollWhenPainted) { // as described in ember-app-scheduler, this addon can be used to delay rendering until after First Meaningful Paint. // If you loading your routes progressively, this may be a good option to delay scrollTop until the remaining DOM elements are painted. + whenRoutePainted().then(() => { + this.updateScrollPosition(transition); + }); + } else { + // as described in ember-app-scheduler, this addon can be used to delay rendering until after the route is idle whenRouteIdle().then(() => { this.updateScrollPosition(transition); }); diff --git a/addon/services/router-scroll.js b/addon/services/router-scroll.js index 7880cbb3..8b6a6b4a 100644 --- a/addon/services/router-scroll.js +++ b/addon/services/router-scroll.js @@ -13,14 +13,21 @@ const RouterScroll = Service.extend({ key: null, scrollElement: 'window', targetElement: null, - delayScrollTop: false, isFirstLoad: true, preserveScrollPosition: false, + delayScrollTop: false, + // ember-app-scheduler properties + scrollWhenPainted: false, + scrollWhenIdle: false, init(...args) { this._super(...args); this._loadConfig(); - set(this, 'scrollMap', { default: { x: 0, y: 0 }}); + set(this, 'scrollMap', { + default: { + x: 0, y: 0 + } + }); }, unsetFirstLoad() { @@ -47,7 +54,10 @@ const RouterScroll = Service.extend({ // if we are looking to where to transition to next, we need to set the default to the position // of the targetElement on screen - set(scrollMap, 'default', { x, y }); + set(scrollMap, 'default', { + x, + y + }); } } else if ('window' === scrollElement) { x = window.scrollX; @@ -63,7 +73,10 @@ const RouterScroll = Service.extend({ // only a `key` present after first load if (key && 'number' === typeOf(x) && 'number' === typeOf(y)) { - set(scrollMap, key, { x, y }); + set(scrollMap, key, { + x, + y + }); } }, @@ -84,10 +97,14 @@ const RouterScroll = Service.extend({ set(this, 'targetElement', targetElement); } - const delayScrollTop = config.routerScroll.delayScrollTop; - if (delayScrollTop === true) { - set(this, 'delayScrollTop', true); - } + const { + scrollWhenPainted = false, + scrollWhenIdle = false, + delayScrollTop = false + } = config.routerScroll; + set(this, 'delayScrollTop', delayScrollTop); + set(this, 'scrollWhenPainted', scrollWhenPainted); + set(this, 'scrollWhenIdle', scrollWhenIdle); } } }); diff --git a/addon/utils/scrollbar-width.js b/addon/utils/scrollbar-width.js new file mode 100644 index 00000000..478fb143 --- /dev/null +++ b/addon/utils/scrollbar-width.js @@ -0,0 +1,25 @@ +// https://stackoverflow.com/questions/13382516/getting-scroll-bar-width-using-javascript +export function getScrollBarWidth() { + let outer = document.createElement('div'); + outer.style.visibility = 'hidden'; + outer.style.width = '100px'; + outer.style.msOverflowStyle = 'scrollbar'; + + document.body.appendChild(outer); + + let widthNoScroll = outer.offsetWidth; + // force scrollbars + outer.style.overflow = 'scroll'; + + // add innerdiv + let inner = document.createElement('div'); + inner.style.width = '100%'; + outer.appendChild(inner); + + let widthWithScroll = inner.offsetWidth; + + // remove divs + outer.parentNode.removeChild(outer); + + return widthNoScroll - widthWithScroll; +} diff --git a/tests/unit/mixins/router-scroll-test.js b/tests/unit/mixins/router-scroll-test.js index 54cb7ccf..9416eb3e 100644 --- a/tests/unit/mixins/router-scroll-test.js +++ b/tests/unit/mixins/router-scroll-test.js @@ -1,9 +1,10 @@ -import { run, next } from '@ember/runloop'; +import { run } from '@ember/runloop'; import EmberObject from '@ember/object'; import Evented from '@ember/object/evented'; import RouterScroll from 'ember-router-scroll'; import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; +import { settled } from '@ember/test-helpers'; import { gte } from 'ember-compatibility-helpers'; let scrollTo, subject; @@ -44,7 +45,7 @@ module('mixin:router-scroll', function(hooks) { return gte('3.6.0-beta.1') ? transition : [transition]; } - test('when the application is FastBooted', function(assert) { + test('when the application is FastBooted', async function(assert) { assert.expect(1); const done = assert.async(); @@ -58,17 +59,15 @@ module('mixin:router-scroll', function(hooks) { subject = this.owner.factoryFor('router:main').create(); - run(() => { - if(gte('3.6.0-beta.1')) { - subject.trigger('routeDidChange'); - } else { - subject.didTransition(); - } - next(() => { - assert.ok(true, 'it should not call updateScrollPosition.'); - done(); - }); - }); + if(gte('3.6.0-beta.1')) { + subject.trigger('routeDidChange'); + } else { + subject.didTransition(); + } + + assert.ok(true, 'it should not call updateScrollPosition.'); + await settled(); + done(); }); test('when the application is not FastBooted', function(assert) { @@ -168,6 +167,30 @@ module('mixin:router-scroll', function(hooks) { }); }); + test('when the application is not FastBooted with scrollWhenPainted', function(assert) { + assert.expect(1); + const done = assert.async(); + + this.owner.register('service:fastboot', EmberObject.extend({ isFastBoot: false })); + this.owner.register('service:router-scroll', EmberObject.extend({ scrollWhenPainted: true })); + this.owner.register('router:main', EmberObject.extend(Evented, RouterScroll, { + updateScrollPosition() { + assert.ok(true, 'it should call updateScrollPosition.'); + done(); + } + })); + + subject = this.owner.factoryFor('router:main').create(); + + run(() => { + if(gte('3.6.0-beta.1')) { + subject.trigger('routeDidChange'); + } else { + subject.didTransition(); + } + }); + }); + test('Update Scroll Position: Can preserve position using routerService', function(assert) { assert.expect(0); const done = assert.async(); @@ -202,8 +225,10 @@ module('mixin:router-scroll', function(hooks) { const elem = document.createElement('div'); elem.id = 'World'; document.body.insertBefore(elem, null); - window.scrollTo = (x, y) => + window.scrollTo = (x, y) => { assert.ok(x === elem.offsetLeft && y === elem.offsetTop, 'Scroll to called with correct offsets'); + done() + } this.owner.register('service:fastboot', EmberObject.extend({ isFastBoot: false })); this.owner.register('service:router-scroll', EmberObject.extend({ @@ -220,7 +245,6 @@ module('mixin:router-scroll', function(hooks) { } else { subject.didTransition(getTransitionsMock('Hello/#World', false)); } - done(); }); }); @@ -231,8 +255,10 @@ module('mixin:router-scroll', function(hooks) { const elem = document.createElement('div'); elem.id = 'World'; document.body.insertBefore(elem, null); - window.scrollTo = (x, y) => + window.scrollTo = (x, y) => { assert.ok(x === elem.offsetLeft && y === elem.offsetTop, 'Scroll to called with correct offsets'); + done(); + } this.owner.register('service:fastboot', EmberObject.extend({ isFastBoot: false })); this.owner.register('service:router-scroll', EmberObject.extend({ @@ -249,7 +275,6 @@ module('mixin:router-scroll', function(hooks) { } else { subject.didTransition(getTransitionsMock('Hello/#World', false)); } - done(); }); }); @@ -257,8 +282,10 @@ module('mixin:router-scroll', function(hooks) { assert.expect(1); const done = assert.async(); - window.scrollTo = (x, y) => + window.scrollTo = (x, y) => { assert.ok(x === 1 && y === 2, 'Scroll to called with correct offsets'); + done(); + } this.owner.register('service:fastboot', EmberObject.extend({ isFastBoot: false })); this.owner.register('service:router-scroll', EmberObject.extend({ @@ -277,8 +304,7 @@ module('mixin:router-scroll', function(hooks) { } else { subject.didTransition(getTransitionsMock('Hello/#')); } - done(); - }); + }) }); test('Update Scroll Position: URL has nonexistent element after anchor', function(assert) { @@ -288,8 +314,10 @@ module('mixin:router-scroll', function(hooks) { const elem = document.createElement('div'); elem.id = 'World'; document.body.insertBefore(elem, null); - window.scrollTo = (x, y) => + window.scrollTo = (x, y) => { assert.ok(x === 1 && y === 2, 'Scroll to called with correct offsets'); + done(); + } this.owner.register('service:fastboot', EmberObject.extend({ isFastBoot: false })); this.owner.register('service:router-scroll', EmberObject.extend({ @@ -308,7 +336,6 @@ module('mixin:router-scroll', function(hooks) { } else { subject.didTransition(getTransitionsMock('Hello/#Bar')); } - done(); }); }); @@ -316,13 +343,15 @@ module('mixin:router-scroll', function(hooks) { assert.expect(1); const done = assert.async(); - window.scrollTo = (x, y) => - assert.ok(x === 1 && y === 2, 'Scroll to was called with correct offsets'); + window.scrollTo = (x, y) => { + assert.ok(x === 1 && y === 20, 'Scroll to was called with correct offsets'); + done(); + } this.owner.register('service:fastboot', EmberObject.extend({ isFastBoot: false })); this.owner.register('service:router-scroll', EmberObject.extend({ get position() { - return { x: 1, y: 2 }; + return { x: 1, y: 20 }; }, scrollElement: 'window' })); @@ -336,9 +365,6 @@ module('mixin:router-scroll', function(hooks) { } else { subject.didTransition(getTransitionsMock('Hello/World')); } - next(() => { - done(); - }); }); }); }); diff --git a/tests/unit/services/router-scroll-test.js b/tests/unit/services/router-scroll-test.js index 9f59a7f2..77d130c3 100644 --- a/tests/unit/services/router-scroll-test.js +++ b/tests/unit/services/router-scroll-test.js @@ -99,7 +99,9 @@ module('service:router-scroll', function(hooks) { let expected = { x: 0, y: 0 }; assert.deepEqual(get(service, 'position'), expected, 'position is defaulted'); service.update(); - assert.deepEqual(get(service, 'scrollMap'), { '123': expected, default: { x: 0, y: 0 } }, 'sets scrollMap'); + assert.deepEqual(get(service, 'scrollMap'), { + '123': expected, + default: { x: 0, y: 0 } }, 'sets scrollMap'); }); test('updating will set default `scrollMap` if targetElement is defined', function(assert) {