Skip to content

Commit d7e00f3

Browse files
committed
Adds scroll-to-element-with-offset modifier
1 parent 17ca730 commit d7e00f3

8 files changed

+363
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { find } from '@ember/test-helpers';
2+
3+
export default function mockScrollToElementWithOffset() {
4+
let optionsCalledWith = [];
5+
let mockScrollToElementWithOffsetFunction = function (options) {
6+
optionsCalledWith.push(options);
7+
};
8+
// manually mocking native function
9+
let preExistingScrollFunction = window.scrollTo;
10+
window.scrollTo = mockScrollToElementWithOffsetFunction;
11+
12+
// helper fuctions that will be returned
13+
let scrollToCalledWith = (element, options = {}) => {
14+
if (!element || !document) {
15+
return;
16+
}
17+
18+
if (typeof element === 'string') {
19+
element = find(element);
20+
}
21+
22+
const { behavior = 'smooth', offset = 0, left = 0 } = options;
23+
24+
const elementTop =
25+
element.getBoundingClientRect().top -
26+
document.body.getBoundingClientRect().top -
27+
offset;
28+
29+
return optionsCalledWith.some((calledOptions) => {
30+
return (
31+
behavior === calledOptions.behavior &&
32+
elementTop === calledOptions.top &&
33+
left === calledOptions.left
34+
);
35+
});
36+
};
37+
38+
let resetMock = () => {
39+
window.Element.prototype.scrollIntoView = preExistingScrollFunction;
40+
};
41+
42+
return {
43+
scrollToCalledWith,
44+
resetMock,
45+
};
46+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { modifier } from 'ember-modifier';
2+
3+
export default modifier(function scrollToElementWithOffset(
4+
element,
5+
positional,
6+
named = {}
7+
) {
8+
const { options = {}, shouldScroll } = named;
9+
let hasBeenRemoved;
10+
11+
const shouldScrollPromise = Promise.resolve(shouldScroll);
12+
13+
shouldScrollPromise.then((shouldScrollValue) => {
14+
if (shouldScrollValue && element && window && !hasBeenRemoved) {
15+
const { behavior = 'smooth', offset = 0, left = 0 } = options;
16+
17+
window.scrollTo({
18+
behavior,
19+
top:
20+
element.getBoundingClientRect().top -
21+
document.body.getBoundingClientRect().top -
22+
offset,
23+
left,
24+
});
25+
}
26+
});
27+
28+
return () => {
29+
hasBeenRemoved = true;
30+
};
31+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from 'ember-scroll-modifiers/modifiers/scroll-to-element-with-offset';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Component from '@glimmer/component';
2+
import { tracked } from '@glimmer/tracking';
3+
import { action } from '@ember/object';
4+
5+
export default class EsButtonComponent extends Component {
6+
@tracked shouldScroll;
7+
@tracked offset = 25;
8+
9+
@action
10+
onScrollToElementWithOffset() {
11+
this.shouldScroll = true;
12+
}
13+
14+
@action
15+
onOffsetChange(event) {
16+
// clear the shouldScroll value to prevent scrolling on offset change
17+
this.shouldScroll = false;
18+
this.offset = event.target.value;
19+
}
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# scroll-to-element-with-offset
2+
3+
This modifier calls `scrollTo` on the window, setting `options.top` parameter to the top of the target element minus a desired offset.
4+
5+
6+
## When you should use this modifier
7+
8+
You should use this modifier whenever you want to scroll to an element, but need to set an offset (e.g. when there is a fixed navigation menu). If you do not need to set an offset value, then use the `scroll-into-view` modifier.
9+
10+
11+
## Basic Usage
12+
13+
`scroll-to-element-with-offset` expects the named `shouldScroll` parameter and an optional `options` named parameter. See [scrollTo](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTo) for the list of possible values and properties of `options`.
14+
15+
```handlebars
16+
<div {{scroll-to-element-with-offset shouldScroll=this.shouldScroll options=(hash offset=this.offset)}}>
17+
<input type="number" value={{this.offset}} {{on "change" this.onOffsetChange}}>
18+
<button type="button" {{on "click" this.onScrollToElementWithOffset}}>
19+
Trigger scroll-to-element-with-offset on click
20+
</button>
21+
</div>
22+
```
23+
24+
`shouldScroll` can be either a Boolean or a Promise that resolves to a truthy or falsy value. It does not handle a rejected Promise.
25+
26+
27+
## Testing
28+
`scroll-to-element-with-offset-mock` provides a function that will mock the native browser `scrollTo` and allow testing which elements invoked the modifier
29+
30+
`mockScrollTo()` - will mock the native API and return an object with the following 2 functions
31+
* `scrollToCalledWith(Element|DOM selector, options)` - tests if the modifier was invoked on the element. The options param is optional, and checks against values passed into the modifier.
32+
* `resetMock()` - restores the native scrollTo function
33+
34+
```javascript
35+
import mockScrollTo from 'ember-scroll-modifiers/test-support/scroll-to-element-with-offset-mock';
36+
...
37+
hooks.beforeEach(function () {
38+
this.mockHelperFunctions = mockScrollTo();
39+
});
40+
41+
hooks.afterEach(function () {
42+
this.mockHelperFunctions.resetMock();
43+
});
44+
...
45+
function('test scroll-to-element-with-offset', (assert) => {
46+
...
47+
await render(
48+
hbs`<div {{scroll-to-element-with-offset shouldScroll=true options=(hash offset=25)}} data-test-scroll-to-element-with-offset-selector></div>`
49+
);
50+
...
51+
assert.ok(this.mockHelperFunctions.scrollToCalledWith('[data-test-scroll-to-element-with-offset-selector]', { behavior: 'smooth', top: 25, left: 0 }), 'scrolled to element');
52+
});
53+
```
54+
55+
56+
## Browser Support
57+
58+
This feature is [supported](https://caniuse.com/?search=scrollTo) in the latest versions of every browser.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { module, test } from 'qunit';
2+
import { setupRenderingTest } from 'ember-qunit';
3+
import { render, find } from '@ember/test-helpers';
4+
import hbs from 'htmlbars-inline-precompile';
5+
import mockScrollTo from 'ember-scroll-modifiers/test-support/scroll-to-element-with-offset-mock';
6+
7+
module(
8+
'Integration | Modifier | addon-test-support/scroll-to-element-with-offset-mock',
9+
function (hooks) {
10+
setupRenderingTest(hooks);
11+
hooks.beforeEach(function () {
12+
this.mockHelperFunctions = mockScrollTo();
13+
});
14+
15+
hooks.afterEach(function () {
16+
this.mockHelperFunctions.resetMock();
17+
});
18+
19+
test('scrollTo returns correct default parameters', async function (assert) {
20+
await render(
21+
hbs`<div style="height: 10px" {{scroll-to-element-with-offset shouldScroll=true}} data-test-scroll-to-element-with-offset-selector></div>
22+
<div style="height: 10px" data-test-scroll-to-element-with-offset-not-modified></div>
23+
<div style="height: 10px" {{scroll-to-element-with-offset shouldScroll=false}} data-test-scroll-to-element-with-offset-not-scrolled></div>`
24+
);
25+
26+
assert.ok(
27+
this.mockHelperFunctions.scrollToCalledWith(
28+
'[data-test-scroll-to-element-with-offset-selector]'
29+
),
30+
'scrollToCalledWith should return true for the given element selector'
31+
);
32+
assert.ok(
33+
this.mockHelperFunctions.scrollToCalledWith(
34+
find('[data-test-scroll-to-element-with-offset-selector]')
35+
),
36+
'scrollToCalledWith should return true for the given element'
37+
);
38+
assert.notOk(
39+
this.mockHelperFunctions.scrollToCalledWith(
40+
'[data-test-scroll-to-element-with-offset-not-modified]'
41+
),
42+
"scrollToCalledWith should return false if the element doesn't have the modifier"
43+
);
44+
assert.notOk(
45+
this.mockHelperFunctions.scrollToCalledWith(
46+
'[data-test-scroll-to-element-with-offset-not-scrolled]'
47+
),
48+
'scrollToCalledWith should return false if the modifier was not triggered'
49+
);
50+
});
51+
52+
test('scrollTo returns correct options params', async function (assert) {
53+
this.options = {
54+
offset: 10,
55+
behavior: 'auto',
56+
left: 10,
57+
};
58+
59+
await render(
60+
hbs`<div style="height: 10px" {{scroll-to-element-with-offset shouldScroll=true options=this.options}} data-test-scroll-to-element-with-offset-selector></div>`
61+
);
62+
63+
assert.ok(
64+
this.mockHelperFunctions.scrollToCalledWith(
65+
'[data-test-scroll-to-element-with-offset-selector]',
66+
this.options
67+
),
68+
'scrollToCalledWith should return true for the given element selector'
69+
);
70+
assert.ok(
71+
this.mockHelperFunctions.scrollToCalledWith(
72+
find('[data-test-scroll-to-element-with-offset-selector]'),
73+
this.options
74+
),
75+
'scrollToCalledWith should return true for the given element'
76+
);
77+
});
78+
}
79+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { module, test } from 'qunit';
2+
import { setupRenderingTest } from 'ember-qunit';
3+
import { render } from '@ember/test-helpers';
4+
import { hbs } from 'ember-cli-htmlbars';
5+
import sinon from 'sinon';
6+
7+
module(
8+
'Integration | Modifier | scroll-to-element-with-offset',
9+
function (hooks) {
10+
setupRenderingTest(hooks);
11+
const sandbox = sinon.createSandbox();
12+
13+
hooks.beforeEach(function () {
14+
this.scrollToSpy = sandbox.spy(window, 'scrollTo');
15+
this.getBoundingClientRectStub = sandbox.stub(
16+
Element.prototype,
17+
'getBoundingClientRect'
18+
);
19+
this.getBoundingClientRectStub.onCall(0).returns({ top: 100 });
20+
this.getBoundingClientRectStub.onCall(1).returns({ top: 25 });
21+
});
22+
23+
hooks.afterEach(function () {
24+
this.scrollToSpy = null;
25+
sandbox.restore();
26+
});
27+
28+
test('it renders and calls scrollTo when shouldScroll is true', async function (assert) {
29+
await render(
30+
hbs`<div {{scroll-to-element-with-offset shouldScroll=true}}></div>`
31+
);
32+
33+
assert.ok(this.scrollToSpy.called, 'scrollTo was called');
34+
});
35+
36+
test('it renders and passes default options to scrollTo', async function (assert) {
37+
await render(
38+
hbs`<div {{scroll-to-element-with-offset shouldScroll=true}}></div>`
39+
);
40+
41+
assert.deepEqual(
42+
this.scrollToSpy.args[0][0].behavior,
43+
'smooth',
44+
'scrollTo was called with correct params'
45+
);
46+
47+
assert.deepEqual(
48+
this.scrollToSpy.args[0][0].left,
49+
0,
50+
'scrollTo was called with correct params'
51+
);
52+
});
53+
54+
test('it renders and calculates correct default top offset for scrollTo', async function (assert) {
55+
await render(
56+
hbs`<div id="test" {{scroll-to-element-with-offset shouldScroll=true}}></div>`
57+
);
58+
59+
assert.deepEqual(
60+
this.scrollToSpy.args[0][0],
61+
{
62+
behavior: 'smooth',
63+
left: 0,
64+
top: 75,
65+
},
66+
'scrollTo was called with correct params'
67+
);
68+
});
69+
70+
test('it renders and calculates correct top offset for scrollTo when offset is passed in', async function (assert) {
71+
this.options = {
72+
offset: 50,
73+
};
74+
75+
await render(
76+
hbs`<div id="test" {{scroll-to-element-with-offset shouldScroll=true options=this.options}}></div>`
77+
);
78+
79+
assert.deepEqual(
80+
this.scrollToSpy.args[0][0],
81+
{
82+
behavior: 'smooth',
83+
left: 0,
84+
top: 25,
85+
},
86+
'scrollTo was called with correct params'
87+
);
88+
});
89+
90+
test('it does not call scrollTo when shouldScroll is false', async function (assert) {
91+
await render(
92+
hbs`<div {{scroll-to-element-with-offset shouldScroll=false}}></div>`
93+
);
94+
95+
assert.notOk(this.scrollToSpy.called, 'scrollTo was not called');
96+
});
97+
98+
test('it renders when shouldScroll resolves to true', async function (assert) {
99+
this.options = { test: true };
100+
let resolvePromise;
101+
this.shouldScroll = new Promise((resolve) => (resolvePromise = resolve));
102+
103+
await render(
104+
hbs`<div {{scroll-to-element-with-offset shouldScroll=this.shouldScroll}}></div>`
105+
);
106+
107+
assert.ok(this.scrollToSpy.notCalled, 'scrollTo was not called');
108+
109+
await resolvePromise(true);
110+
111+
assert.ok(this.scrollToSpy.called, 'scrollTo was called');
112+
});
113+
114+
test('it does not render when shouldScroll resolves to false', async function (assert) {
115+
this.options = { test: true };
116+
let resolvePromise;
117+
this.shouldScroll = new Promise((resolve) => (resolvePromise = resolve));
118+
119+
await render(
120+
hbs`<div {{scroll-to-element-with-offset shouldScroll=this.shouldScroll}}></div>`
121+
);
122+
123+
await resolvePromise(false);
124+
125+
assert.ok(this.scrollToSpy.notCalled, 'scrollTo was not called');
126+
});
127+
}
128+
);

0 commit comments

Comments
 (0)