diff --git a/README.md b/README.md index c071c672..6f705a9a 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,19 @@ ember install ember-router-scroll ``` ### Options -You can specify the id of an element for which the scroll position is saved and set. 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. +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. ```javascript ENV['routerScroll'] = { scrollElement: '#mainScrollElement' }; +If you want to scroll to a target element on the page, you can specify the id or class of the element on the page. This is particularly useful if instead of scrolling to the top of the window, you want to scroll to the top of the main content area (that does not generate a vertical scrollbar). + +ENV['routerScroll'] = { + targetElement: '#main-target-element' // or .main-target-element +}; + 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`. ```javascript @@ -84,7 +90,7 @@ historySupportMiddleware: true, This location type inherits from Ember's `HistoryLocation`. -4. Tests +4. If using old style QUnit tests. If tests based on [RFC](https://github.com/emberjs/rfcs/pull/232), you can ignore this. In your router and controller tests, add `'service:router-scroll'` and `'service:scheduler'` as dependencies in the `needs: []` block: ```js @@ -92,15 +98,6 @@ In your router and controller tests, add `'service:router-scroll'` and `'service needs:[ 'service:router-scroll', 'service:scheduler' ], ``` -### Options -You can specify the id of an element for which the scroll position is saved and set. 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. - -```javascript -ENV['routerScroll'] = { - scrollElement: '#mainScrollElement' -}; -``` - ## Issues with nested routes ### Before: @@ -124,9 +121,9 @@ Add `preserveScrollPosition` as a queryParam in the controller for the route tha Example: ```javascript -import Ember from 'ember'; +import Controller from '@ember/controller'; -export default Ember.Controller.extend({ +export default Controller.extend({ queryParams: [ 'preserveScrollPosition', ], @@ -154,9 +151,9 @@ In this example we have `preserveScrollPosition` initially set to false so that Example: ```javascript -import Ember from 'ember'; +import Controller from '@ember/controller'; -export default Ember.Controller.extend({ +export default Controller.extend({ queryParams: ['filter'], preserveScrollPosition: false, @@ -175,9 +172,9 @@ export default Ember.Controller.extend({ If your controller is changing the preserveScrollPosition property, you'll probably need to reset `preserveScrollPosition` back to the default behavior whenever the controller is reset. This is not necceary on routes where `preserveScrollPosition` is always set to true. ```javascript -import Ember from 'ember'; +import Router from '@ember/routing/route'; -export default Ember.Route.extend({ +export default Route.extend({ resetController(controller) { controller.set('preserveScrollPosition', false); } diff --git a/addon/index.js b/addon/index.js index 85ec46f1..d061932e 100644 --- a/addon/index.js +++ b/addon/index.js @@ -39,7 +39,7 @@ export default Mixin.create({ } }, - updateScrollPosition (transitions) { + updateScrollPosition(transitions) { const lastTransition = transitions[transitions.length - 1]; let routerPath @@ -58,12 +58,15 @@ export default Mixin.create({ } else { scrollPosition = get(this, 'service.position'); } - const scrollElement = get(this, 'service.scrollElement'); - const preserveScrollPosition = get(lastTransition, 'handler.controller.preserveScrollPosition'); if (!preserveScrollPosition) { - if ('window' === scrollElement) { + 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); } else if ('#' === scrollElement.charAt(0)) { const element = document.getElementById(scrollElement.substring(1)); diff --git a/addon/services/router-scroll.js b/addon/services/router-scroll.js index 2e05d55b..25114bc5 100644 --- a/addon/services/router-scroll.js +++ b/addon/services/router-scroll.js @@ -1,6 +1,7 @@ import Service from '@ember/service'; import { getWithDefault, computed, set, get } from '@ember/object'; import { typeOf } from '@ember/utils'; +import { assert } from '@ember/debug'; import { getOwner } from '@ember/application'; export default Service.extend({ @@ -10,23 +11,39 @@ export default Service.extend({ }), scrollElement: 'window', + targetElement: null, delayScrollTop: false, init(...args) { this._super(...args); this._loadConfig(); - set(this, 'scrollMap', {}); + set(this, 'scrollMap', { default: { x: 0, y: 0 }}); set(this, 'key', null); }, update() { const scrollElement = get(this, 'scrollElement'); + const targetElement = get(this, 'targetElement'); const scrollMap = get(this, 'scrollMap'); const key = get(this, 'key'); let x; let y; - if ('window' === scrollElement) { + if (targetElement) { + if (get(this, 'isFastBoot')) { + return; + } + + let element = document.querySelector(targetElement); + if (element) { + x = element.offsetLeft; + y = element.offsetTop; + + // 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 }); + } + } else if ('window' === scrollElement) { x = window.scrollX; y = window.scrollY; } else if ('#' === scrollElement.charAt(0)) { @@ -54,19 +71,26 @@ export default Service.extend({ set(this, 'key', stateUuid); // eslint-disable-line ember/no-side-effects const key = getWithDefault(this, 'key', '-1'); - return getWithDefault(scrollMap, key, { x: 0, y: 0 }); + return getWithDefault(scrollMap, key, scrollMap.default); }).volatile(), _loadConfig() { const config = getOwner(this).resolveRegistration('config:environment'); - if (config && config.routerScroll && config.routerScroll.scrollElement) { + if (config && config.routerScroll) { const scrollElement = config.routerScroll.scrollElement; + const targetElement = config.routerScroll.targetElement; + + assert('You defined both scrollElement and targetElement in your config. We currently only support definining one of them', !(scrollElement && targetElement)); if ('string' === typeOf(scrollElement)) { set(this, 'scrollElement', scrollElement); } + if ('string' === typeOf(targetElement)) { + set(this, 'targetElement', targetElement); + } + const delayScrollTop = config.routerScroll.delayScrollTop; if (delayScrollTop === true) { set(this, 'delayScrollTop', true); diff --git a/package.json b/package.json index c6f2f84d..be4b3309 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ember-router-scroll", - "version": "0.6.1", + "version": "0.7.0", "description": "Scroll to top with preserved browser history scroll position", "keywords": [ "ember-addon", @@ -62,7 +62,7 @@ "test:all": "ember try:each" }, "dependencies": { - "ember-app-scheduler": "^0.2.0", + "ember-app-scheduler": "^0.2.2", "ember-cli-babel": "^6.6.0", "ember-getowner-polyfill": "^2.0.1" }, diff --git a/tests/acceptance/basic-functionality-test.js b/tests/acceptance/basic-functionality-test.js index bccf1d74..cee13b66 100644 --- a/tests/acceptance/basic-functionality-test.js +++ b/tests/acceptance/basic-functionality-test.js @@ -1,15 +1,55 @@ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; -import { visit, click, currentURL } from '@ember/test-helpers'; +import { visit, click, currentURL, triggerEvent } from '@ember/test-helpers'; +import config from 'dummy/config/environment'; module('Acceptance | basic functionality', function(hooks) { setupApplicationTest(hooks); + hooks.beforeEach(function() { + document.getElementById('ember-testing-container').scrollTop = 0; + }); + hooks.afterEach(function() { + config['routerScroll'] = {}; + }); + test('The application should work when loading a page and clicking a link', async function(assert) { + config['routerScroll'] = { + scrollElement: '#ember-testing-container' + } + await visit('/'); + // testing specific + let container = document.getElementById('ember-testing-container'); + assert.equal(container.scrollTop, 0); + + await document.getElementById('monster').scrollIntoView(false); + await triggerEvent(window, 'scroll'); + + assert.ok(container.scrollTop > 0); + await click('a[href="/next-page"]'); assert.equal(currentURL(), '/next-page'); }); + + test('The application should work when loading a page and clicking a link to target an element to scroll to', async function(assert) { + config['routerScroll'] = { + scrollElement: '#target-main' + } + + await visit('/target'); + + // testing specific + let container = document.getElementById('ember-testing-container'); + assert.equal(container.scrollTop, 0); + + await document.getElementById('monster').scrollIntoView(false); + assert.ok(container.scrollTop > 0); + + await click('a[href="/target-next-page"]'); + + assert.equal(currentURL(), '/target-next-page'); + }); }); diff --git a/tests/dummy/app/router.js b/tests/dummy/app/router.js index 01edfcb0..e91532c7 100644 --- a/tests/dummy/app/router.js +++ b/tests/dummy/app/router.js @@ -9,6 +9,8 @@ const Router = EmberRouter.extend(RouterScroll, { Router.map(function () { this.route('next-page'); + this.route('target'); + this.route('target-next-page'); }); export default Router; diff --git a/tests/dummy/app/routes/index.js b/tests/dummy/app/routes/index.js new file mode 100644 index 00000000..6c74252a --- /dev/null +++ b/tests/dummy/app/routes/index.js @@ -0,0 +1,4 @@ +import Route from '@ember/routing/route'; + +export default Route.extend({ +}); diff --git a/tests/dummy/app/routes/target-next-page.js b/tests/dummy/app/routes/target-next-page.js new file mode 100644 index 00000000..6c74252a --- /dev/null +++ b/tests/dummy/app/routes/target-next-page.js @@ -0,0 +1,4 @@ +import Route from '@ember/routing/route'; + +export default Route.extend({ +}); diff --git a/tests/dummy/app/routes/target.js b/tests/dummy/app/routes/target.js new file mode 100644 index 00000000..6c74252a --- /dev/null +++ b/tests/dummy/app/routes/target.js @@ -0,0 +1,4 @@ +import Route from '@ember/routing/route'; + +export default Route.extend({ +}); diff --git a/tests/dummy/app/styles/app.css b/tests/dummy/app/styles/app.css index 2b178068..e4fc7ec0 100644 --- a/tests/dummy/app/styles/app.css +++ b/tests/dummy/app/styles/app.css @@ -1,5 +1,9 @@ @import url(https://fonts.googleapis.com/css?family=VT323); +body { + margin: 0; +} + #death-star { position:absolute; margin: 1550px auto 0 60%; @@ -52,6 +56,12 @@ div { margin: 12% 10% -200px 10%; } +#nav { + height: 44px; + background-color: white; + color: black; +} + .depth { font-size: 26px; } @@ -74,6 +84,6 @@ div { .left { margin: 300px 0 0 10%; } - .right { - margin: 300px 0 0 75%; - } \ No newline at end of file +.right { + margin: 300px 0 0 75%; +} diff --git a/tests/dummy/app/templates/application.hbs b/tests/dummy/app/templates/application.hbs index 0a3e622a..49a00a7d 100644 --- a/tests/dummy/app/templates/application.hbs +++ b/tests/dummy/app/templates/application.hbs @@ -2,7 +2,7 @@
Click on the submarine to launch!
- {{#link-to 'next-page'}}{{/link-to}} + {{#link-to 'next-page'}}Submarine{{/link-to}}
— 500 fathoms
@@ -33,6 +33,6 @@
— 5000 fathoms
- + Monster -{{outlet}} \ No newline at end of file +{{outlet}} diff --git a/tests/dummy/app/templates/next-page.hbs b/tests/dummy/app/templates/next-page.hbs index cd15f83c..fadde389 100644 --- a/tests/dummy/app/templates/next-page.hbs +++ b/tests/dummy/app/templates/next-page.hbs @@ -9,7 +9,7 @@ Using the back button will take you to your previous scroll position. - {{#link-to 'application'}}{{/link-to}} + {{#link-to 'application'}}Submarine{{/link-to}}
— Orbit -500 km @@ -42,4 +42,4 @@ — Orbit -5000 km
- \ No newline at end of file + diff --git a/tests/dummy/app/templates/target-next-page.hbs b/tests/dummy/app/templates/target-next-page.hbs new file mode 100644 index 00000000..a18a04c1 --- /dev/null +++ b/tests/dummy/app/templates/target-next-page.hbs @@ -0,0 +1,48 @@ +
+ +
+
+ Orbital velocity achieved!
+ Ember-router-scroll has taken you to the top of the page!
+
+ + Clicking the submarine will take you to the top of the previous page.
+ Using the back button will take you to your previous scroll position. +
+
+ Death Star +
+ — Orbit -500 km +
+
+ — Orbit -1000 km +
+
+ — Orbit -1500 km +
+
+ — Orbit -2000 km +
+
+ — Orbit -2500 km +
+
+ — Orbit -3000 km +
+
+ — Orbit -3500 km +
+
+ — Orbit -4000 km +
+
+ — Orbit -4500 km +
+
+ — Orbit -5000 km +
+
+
+{{#link-to "target"}}Submarine{{/link-to}} diff --git a/tests/dummy/app/templates/target.hbs b/tests/dummy/app/templates/target.hbs new file mode 100644 index 00000000..9ff3be51 --- /dev/null +++ b/tests/dummy/app/templates/target.hbs @@ -0,0 +1,42 @@ +
+ +
+
+ Click on the submarine to launch! +
+
+ — 500 fathoms +
+
+ — 1000 fathoms +
+
+ — 1500 fathoms +
+
+ — 2000 fathoms +
+
+ — 2500 fathoms +
+
+ — 3000 fathoms +
+
+ — 3500 fathoms +
+
+ — 4000 fathoms +
+
+ — 4500 fathoms +
+
+ — 5000 fathoms +
+ Monster +
+
+{{#link-to "target-next-page"}}Submarine{{/link-to}} diff --git a/tests/dummy/config/environment.js b/tests/dummy/config/environment.js index 389e9d6a..f33e201b 100644 --- a/tests/dummy/config/environment.js +++ b/tests/dummy/config/environment.js @@ -18,6 +18,10 @@ module.exports = function(environment) { } }, + // routerScroll: { + // targetElement: '#target-main' + // }, + APP: { // Here you can pass flags/options to your application instance // when it is created diff --git a/tests/unit/mixins/router-scroll-test.js b/tests/unit/mixins/router-scroll-test.js index c9882f37..761a6db2 100644 --- a/tests/unit/mixins/router-scroll-test.js +++ b/tests/unit/mixins/router-scroll-test.js @@ -82,6 +82,28 @@ module('mixin:router-scroll', function(hooks) { }); }); + test('when the application is not FastBooted with targetElement', (assert) => { + assert.expect(1); + + const done = assert.async(); + const RouterScrollObject = EmberObject.extend(RouterScroll); + const subject = RouterScrollObject.create({ + isFastBoot: false, + scheduler: getSchedulerMock(), + service: { + targetElement: '#myElement', + }, + updateScrollPosition () { + assert.ok(true, 'it should call updateScrollPosition.'); + done(); + }, + }); + + run(() => { + subject.didTransition(); + }); + }); + test('when the application is not FastBooted with delayScrollTop', (assert) => { assert.expect(1); diff --git a/tests/unit/services/router-scroll-test.js b/tests/unit/services/router-scroll-test.js index 00264779..6c36157a 100644 --- a/tests/unit/services/router-scroll-test.js +++ b/tests/unit/services/router-scroll-test.js @@ -2,50 +2,63 @@ import { set, get } from '@ember/object'; import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; -module('service:router-scroll', (hooks) => { +const defaultScrollMap = { + default: { + x: 0, + y: 0 + } +}; + +module('service:router-scroll', function(hooks) { setupTest(hooks); - test('it inits `scrollMap` and `key`', function init (assert) { + test('it inits `scrollMap` and `key`', function(assert) { const service = this.owner.lookup('service:router-scroll'); - assert.deepEqual(get(service, 'scrollMap'), {}); + assert.deepEqual(get(service, 'scrollMap'), defaultScrollMap); assert.deepEqual(get(service, 'key'), null); }); - test('it inits `scrollMap` and `key` with scrollElement other than window', function init (assert) { + test('it inits `scrollMap` and `key` with scrollElement other than window', function(assert) { const service = this.owner.factoryFor('service:router-scroll').create({ scrollElement: '#other-elem' }); - assert.deepEqual(get(service, 'scrollMap'), {}); + assert.deepEqual(get(service, 'scrollMap'), defaultScrollMap); assert.deepEqual(get(service, 'key'), null); }); - test('updating will not set `scrollMap` to the current scroll position if `key` is not yet set', - function scrollMapCurrentPos (assert) { + test('updating will not set `scrollMap` to the current scroll position if `key` is not yet set', function(assert) { const service = this.owner.lookup('service:router-scroll'); service.update(); - assert.deepEqual(get(service, 'scrollMap'), { }); + assert.deepEqual(get(service, 'scrollMap'), defaultScrollMap); }); - test('updating will set `scrollMap` to the current scroll position', function scrollMap (assert) { + test('updating will set `scrollMap` to the current scroll position', function(assert) { const service = this.owner.lookup('service:router-scroll'); const expected = { x: window.scrollX, y: window.scrollY }; set(service, 'key', '123'); service.update(); - assert.deepEqual(get(service, 'scrollMap'), { 123: expected }); + assert.deepEqual(get(service, 'scrollMap'), { 123: expected, default: { x: 0, y: 0 } }); }); - test('updating will not set `scrollMap` if scrollElement is defined', - function scrollMapCurrentPos (assert) { + test('updating will not set `scrollMap` if scrollElement is defined and element is not found', function(assert) { const service = this.owner.factoryFor('service:router-scroll').create({ scrollElement: '#other-elem' }); service.update(); const expected = { x: 0, y: 0 }; assert.deepEqual(get(service, 'position'), expected); - assert.deepEqual(get(service, 'scrollMap'), { }); + assert.deepEqual(get(service, 'scrollMap'), defaultScrollMap); }); - test('updating will not set `scrollMap` if scrollElement is defined and in fastboot', - function scrollMapCurrentPos (assert) { + test('updating will not set `scrollMap` if targetElement is defined and element is not found', function(assert) { + const service = this.owner.factoryFor('service:router-scroll').create({ targetElement: '#other-elem' }); + + service.update(); + const expected = { x: 0, y: 0 }; + assert.deepEqual(get(service, 'position'), expected); + assert.deepEqual(get(service, 'scrollMap'), defaultScrollMap); + }); + + test('updating will not set `scrollMap` if scrollElement is defined and in fastboot', function(assert) { const otherElem = document.createElement('div'); otherElem.setAttribute('id', 'other-elem'); const testing = document.querySelector('#ember-testing'); @@ -56,11 +69,24 @@ module('service:router-scroll', (hooks) => { let expected = { x: 0, y: 0 }; assert.deepEqual(get(service, 'position'), expected, 'position is defaulted'); service.update(); - assert.deepEqual(get(service, 'scrollMap'), { }, 'does not set scrollMap b/c in fastboot'); + assert.deepEqual(get(service, 'scrollMap'), defaultScrollMap, 'does not set scrollMap b/c in fastboot'); + }); + + test('updating will not set `scrollMap` if targetElement is defined and in fastboot', function(assert) { + const otherElem = document.createElement('div'); + otherElem.setAttribute('id', 'other-elem'); + const testing = document.querySelector('#ember-testing'); + testing.appendChild(otherElem); + const service = this.owner.factoryFor('service:router-scroll').create({ targetElement: '#other-elem', isFastBoot: true }); + window.history.replaceState({ uuid: '123' }, null); + + let expected = { x: 0, y: 0 }; + assert.deepEqual(get(service, 'position'), expected, 'position is defaulted'); + service.update(); + assert.deepEqual(get(service, 'scrollMap'), defaultScrollMap, 'does not set scrollMap b/c in fastboot'); }); - test('updating will set `scrollMap` if scrollElement is defined', - function scrollMapCurrentPos (assert) { + test('updating will set `scrollMap` if scrollElement is defined', function(assert) { const otherElem = document.createElement('div'); otherElem.setAttribute('id', 'other-elem'); const testing = document.querySelector('#ember-testing'); @@ -71,11 +97,26 @@ module('service:router-scroll', (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 }, '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) { + const otherElem = document.createElement('div'); + otherElem.setAttribute('id', 'other-elem'); + otherElem.style.position = 'relative'; + otherElem.style.top = '100px'; + const testing = document.querySelector('#ember-testing'); + testing.appendChild(otherElem); + const service = this.owner.factoryFor('service:router-scroll').create({ targetElement: '#other-elem' }); + window.history.replaceState({ uuid: '123' }, null); + + let expected = { x: 0, y: 0 }; + assert.deepEqual(get(service, 'position'), expected, 'position is defaulted'); + service.update(); + assert.deepEqual(get(service, 'scrollMap'), { '123': { x: 0, y: 100 }, default: { x: 0, y: 100 } }, 'sets scrollMap'); }); - test('computing the position for an existing state uuid return the coords', - function existingUUID (assert) { + test('computing the position for an existing state uuid return the coords', function(assert) { const service = this.owner.lookup('service:router-scroll'); window.history.replaceState({ uuid: '123' }, null); @@ -84,8 +125,7 @@ module('service:router-scroll', (hooks) => { assert.deepEqual(get(service, 'position'), expected); }); - test('computing the position for a state without a cached scroll position returns default', - function cachedScroll (assert) { + test('computing the position for a state without a cached scroll position returns default', function(assert) { const service = this.owner.lookup('service:router-scroll'); const state = window.history.state; window.history.replaceState({ uuid: '123' }, null); @@ -95,11 +135,10 @@ module('service:router-scroll', (hooks) => { window.history.replaceState(state, null); }); - test('computing the position for a non-existant state returns default', - function nonExistantState (assert) { + test('computing the position for a non-existant state returns default', function(assert) { const service = this.owner.lookup('service:router-scroll'); const expected = { x: 0, y: 0 }; assert.deepEqual(get(service, 'position'), expected); }); -}); \ No newline at end of file +}); diff --git a/yarn.lock b/yarn.lock index 18b9678e..fb40bfb6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1966,9 +1966,9 @@ electron-to-chromium@^1.3.30: version "1.3.42" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.42.tgz#95c33bf01d0cc405556aec899fe61fd4d76ea0f9" -ember-app-scheduler@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/ember-app-scheduler/-/ember-app-scheduler-0.2.1.tgz#4af70ce7b4d5792a96eff46c0c2f4a737ec3ad63" +ember-app-scheduler@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/ember-app-scheduler/-/ember-app-scheduler-0.2.2.tgz#e4a66275d1789d1b054815ee230c6f759b4b75eb" dependencies: ember-cli-babel "^6.3.0"