Skip to content

Commit f1595dd

Browse files
authored
Merge pull request #622 from jdkahn/master
Add offset support for scroll-into-view
2 parents 9d769d3 + 0d54577 commit f1595dd

File tree

6 files changed

+314
-9
lines changed

6 files changed

+314
-9
lines changed

addon-test-support/scroll-into-view-mock.js

+44-4
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,20 @@ export default function mockScrollIntoView() {
55
let mockScrollIntoViewFunction = function () {
66
elementsInvokedOn.push(this);
77
};
8+
9+
let mockScrollToElementWithOffsetFunction = function (options) {
10+
elementsInvokedOn.push(options);
11+
};
12+
813
// manually mocking native function
9-
let preExistingScrollFunction = window.Element.prototype.scrollIntoView;
14+
let preExistingScrollIntoViewFunction =
15+
window.Element.prototype.scrollIntoView;
16+
let preExistingScrollToFunction = window.scrollTo;
1017
window.Element.prototype.scrollIntoView = mockScrollIntoViewFunction;
18+
window.scrollTo = mockScrollToElementWithOffsetFunction;
19+
1120
// helper fuctions that will be returned
12-
let scrollIntoViewCalledWith = (selector) => {
21+
let scrollIntoViewCalledWith = (selector, options = {}) => {
1322
let element;
1423
// check if it's a string and get the object
1524
if (typeof selector === 'string') {
@@ -18,11 +27,42 @@ export default function mockScrollIntoView() {
1827
// element was passed in
1928
element = selector;
2029
}
21-
return elementsInvokedOn.includes(element);
30+
31+
if (options?.topOffset === undefined && options?.leftOffset === undefined) {
32+
return elementsInvokedOn.includes(element);
33+
}
34+
35+
if (!element || !document) {
36+
return;
37+
}
38+
const { behavior = 'smooth', leftOffset, topOffset } = options;
39+
40+
const elementLeft =
41+
leftOffset === undefined
42+
? 0
43+
: element.getBoundingClientRect().left -
44+
document.body.getBoundingClientRect().left -
45+
leftOffset;
46+
47+
const elementTop =
48+
topOffset === undefined
49+
? 0
50+
: element.getBoundingClientRect().top -
51+
document.body.getBoundingClientRect().top -
52+
topOffset;
53+
54+
return elementsInvokedOn.some((calledOptions) => {
55+
return (
56+
behavior === calledOptions.behavior &&
57+
elementTop === calledOptions.top &&
58+
elementLeft === calledOptions.left
59+
);
60+
});
2261
};
2362

2463
let resetMock = () => {
25-
window.Element.prototype.scrollIntoView = preExistingScrollFunction;
64+
window.Element.prototype.scrollIntoView = preExistingScrollIntoViewFunction;
65+
window.scrollTo = preExistingScrollToFunction;
2666
};
2767

2868
return {

addon/modifiers/scroll-into-view.js

+28-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,34 @@ export default modifier(function scrollIntoView(
1212

1313
shouldScrollPromise.then((shouldScrollValue) => {
1414
if (shouldScrollValue && element && !hasBeenRemoved) {
15-
element.scrollIntoView(options);
15+
if (
16+
options?.topOffset === undefined &&
17+
options?.leftOffset === undefined
18+
) {
19+
element.scrollIntoView(options);
20+
} else {
21+
const { behavior = 'auto', topOffset, leftOffset } = options;
22+
23+
const left =
24+
leftOffset === undefined
25+
? 0
26+
: element.getBoundingClientRect().left -
27+
document.body.getBoundingClientRect().left -
28+
leftOffset;
29+
30+
const top =
31+
topOffset === undefined
32+
? 0
33+
: element.getBoundingClientRect().top -
34+
document.body.getBoundingClientRect().top -
35+
topOffset;
36+
37+
window?.scrollTo({
38+
behavior,
39+
top,
40+
left,
41+
});
42+
}
1643
}
1744
});
1845

docs/modifiers/scroll-into-view.js

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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 shouldScrollWithOffset;
7+
@tracked shouldScroll;
8+
@tracked topOffset = 25;
9+
@tracked leftOffset = 25;
10+
11+
@action
12+
onScrollIntoView() {
13+
this.shouldScroll = true;
14+
}
15+
16+
@action
17+
onScrollIntoViewWithOffset() {
18+
this.shouldScrollWithOffset = true;
19+
}
20+
21+
@action
22+
onTopOffsetChange(event) {
23+
// clear the shouldScroll value to prevent scrolling on offset change
24+
this.shouldScrollWithOffset = false;
25+
this.topOffset = event.target.value;
26+
}
27+
28+
@action
29+
onLeftOffsetChange(event) {
30+
// clear the shouldScroll value to prevent scrolling on offset change
31+
this.shouldScrollWithOffset = false;
32+
this.leftOffset = event.target.value;
33+
}
34+
}

docs/modifiers/scroll-into-view.md

+44-4
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,49 @@
11
# scroll-into-view
22

3-
This modifier calls `scrollIntoView` on the modified element.
3+
This modifier scrolls to the associated element. By default it uses `scrollIntoView`, but if a top or left offset is passed as an option it uses `scrollTo` and calculates the `options.top` and/or `options.left` attribute.
44

55

66
## When you should use this modifier
77

8-
You should use this modifier whenever you need to have an element scrolled into view on element insert.
8+
You should use this modifier whenever you need to have an element scrolled into view. If there is a element, such as a fixed header or sidebar, passing in a `topOffset` or `leftOffset` will scroll to the element minus the that offset value.
9+
910

1011

1112
## Basic Usage
1213

1314
`scroll-into-view` expects the named `shouldScroll` parameter and an optional `options` named parameter. See [scrollIntoView](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) for the list of possible values and properties of `options`.
1415

15-
```handlebars{data-execute=false}
16-
<div {{scroll-into-view shouldScroll=this.shouldScrollPromise options=true}}></div>
16+
17+
```handlebars
18+
<div {{scroll-into-view shouldScroll=this.shouldScroll options=(hash behavior="smooth")}}>
19+
<button type="button" {{on "click" this.onScrollIntoView}}>
20+
Trigger scroll-into-view on click
21+
</button>
22+
</div>
23+
```
24+
25+
`shouldScroll` can be either a Boolean or a Promise that resolves to a truthy or falsy value. It does not handle a rejected Promise.
26+
27+
28+
### Usage with offset
29+
30+
When passing in an offset, it will call [scrollTo](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTo), and the `options` parameter is designed to correspond to its `options`. The `options.behavior` operates the same, however, instead of `top` and `left` there are `topOffset` and `leftOffset`, respectively. As with `top` and `left`, `topOffset` and `leftOffset` are in pixels. If an offset value is not set then the value passed to `scrollTo` is 0, e.g. `options = { topOffset: 10 }` results in `element.scrollTo({ top: [computedValue], left: 0 })`. Experiment with the below example, you may need to zoom and resize the window to see a horizontal scrollbar.
31+
32+
33+
```handlebars
34+
<div {{scroll-into-view shouldScroll=this.shouldScrollWithOffset options=(hash topOffset=this.topOffset leftOffset=this.leftOffset behavior="smooth")}}>
35+
<div>
36+
<label for="topOffset">Top Offset: </label>
37+
<input name="topOffset" type="number" value={{this.topOffset}} {{on "change" this.onTopOffsetChange}}>
38+
</div>
39+
<div>
40+
<label for="leftOffset">Left Offset: </label>
41+
<input name="leftOffset" type="number" value={{this.leftOffset}} {{on "change" this.onLeftOffsetChange}}>
42+
</div>
43+
<button type="button" {{on "click" this.onScrollIntoViewWithOffset}}>
44+
Trigger scroll-into-view with offset on click
45+
</button>
46+
</div>
1747
```
1848

1949
`shouldScroll` can be either a Boolean or a Promise that resolves to a truthy or falsy value. It does not handle a rejected Promise.
@@ -45,9 +75,19 @@ function('test scroll into view', (assert) => {
4575
...
4676
assert.ok(this.mockHelperFunctions.scrollIntoViewCalledWith('[data-test-scroll-into-view-selector]'), 'element scrolled into view');
4777
});
78+
79+
function('test scroll into view with offset', (assert) => {
80+
...
81+
await render(
82+
hbs`<div {{scroll-into-view shouldScroll=true options=(hash offset=25)}} data-test-scroll-into-view-selector></div>`
83+
);
84+
...
85+
assert.ok(this.mockHelperFunctions.scrollIntoViewCalledWith('[data-test-scroll-into-view-selector]', { behavior: 'smooth', top: 25, left: 0 }), 'scrolled to element');
86+
});
4887
```
4988

5089

5190
## Browser Support
5291

5392
This feature is [supported](https://caniuse.com/?search=scrollIntoView) in the latest versions of every browser.
93+
This feature is [supported](https://caniuse.com/?search=scrollTo) in the latest versions of every browser.

tests/integration/modifiers/scoll-into-view-mock-test.js tests/integration/modifiers/scroll-into-view-mock-test.js

+41
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,46 @@ module(
4646
)
4747
);
4848
});
49+
50+
test('scroll into view returns correct parameters with using offset', async function (assert) {
51+
this.options = {
52+
behavior: 'auto',
53+
leftOffset: 10,
54+
topOffset: 20,
55+
};
56+
57+
await render(
58+
hbs`<div style="height: 10px" {{scroll-into-view shouldScroll=true options=this.options}} data-test-scroll-into-view-selector></div>
59+
<div style="height: 10px" data-test-scroll-into-view-not-modified></div>
60+
<div style="height: 10px" {{scroll-into-view shouldScroll=false options=this.options}} data-test-scroll-into-view-not-scrolled></div>`
61+
);
62+
63+
assert.ok(
64+
this.mockHelperFunctions.scrollIntoViewCalledWith(
65+
'[data-test-scroll-into-view-selector]',
66+
this.options
67+
),
68+
'scrollIntoViewCalledWith should return true for the given element selector'
69+
);
70+
assert.ok(
71+
this.mockHelperFunctions.scrollIntoViewCalledWith(
72+
find('[data-test-scroll-into-view-selector]'),
73+
this.options
74+
),
75+
'scrollIntoViewCalledWith should return true for the given element'
76+
);
77+
assert.notOk(
78+
this.mockHelperFunctions.scrollIntoViewCalledWith(
79+
'[data-test-scroll-into-view-not-modified]'
80+
),
81+
"scrollIntoViewCalledWith should return false if the element doesn't have the modifier"
82+
);
83+
assert.notOk(
84+
this.mockHelperFunctions.scrollIntoViewCalledWith(
85+
'[data-test-scroll-into-view-not-scrolled]'
86+
),
87+
'scrollIntoViewCalledWith should return false if the modifier was not triggered'
88+
);
89+
});
4990
}
5091
);

tests/integration/modifiers/scroll-into-view-test.js

+123
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,127 @@ module('Integration | Modifier | scroll-into-view', function (hooks) {
9797
'scrollIntoView was not called'
9898
);
9999
});
100+
101+
module('with offsets', function (offsetHooks) {
102+
offsetHooks.beforeEach(function () {
103+
this.scrollToSpy = sinon.spy(window, 'scrollTo');
104+
this.getBoundingClientRectStub = sinon.stub(
105+
Element.prototype,
106+
'getBoundingClientRect'
107+
);
108+
109+
this.getBoundingClientRectStub.returns({ left: 100, top: 100 });
110+
});
111+
112+
test('it renders and passes default `behavior` to scrollTo', async function (assert) {
113+
this.options = {
114+
topOffset: 50,
115+
};
116+
117+
await render(
118+
hbs`<div {{scroll-into-view shouldScroll=true options=this.options}}></div>`
119+
);
120+
121+
assert.strictEqual(
122+
this.scrollToSpy.args[0][0].behavior,
123+
'auto',
124+
'scrollTo was called with correct params'
125+
);
126+
});
127+
128+
test('it renders and passes behavior to scrollTo', async function (assert) {
129+
this.options = {
130+
behavior: 'smooth',
131+
topOffset: 50,
132+
};
133+
134+
await render(
135+
hbs`<div {{scroll-into-view shouldScroll=true options=this.options}}></div>`
136+
);
137+
138+
assert.strictEqual(
139+
this.scrollToSpy.args[0][0].behavior,
140+
'smooth',
141+
'scrollTo was called with correct params'
142+
);
143+
});
144+
145+
test('it renders and calculates correct top offset for scrollTo when offset is passed in', async function (assert) {
146+
this.options = {
147+
topOffset: 50,
148+
leftOffset: 40,
149+
};
150+
151+
this.getBoundingClientRectStub.onCall(0).returns({ left: 100 });
152+
this.getBoundingClientRectStub.onCall(1).returns({ left: 25 });
153+
154+
this.getBoundingClientRectStub.onCall(2).returns({ top: 100 });
155+
this.getBoundingClientRectStub.onCall(3).returns({ top: 25 });
156+
157+
await render(
158+
hbs`<div id="test" {{scroll-into-view shouldScroll=true options=this.options}}></div>`
159+
);
160+
161+
assert.deepEqual(
162+
this.scrollToSpy.args[0][0],
163+
{
164+
behavior: 'auto',
165+
left: 35,
166+
top: 25,
167+
},
168+
'scrollTo was called with correct params'
169+
);
170+
});
171+
172+
test('it does not call scrollTo when shouldScroll is false', async function (assert) {
173+
this.options = {
174+
topOffset: 50,
175+
leftOffset: 40,
176+
};
177+
178+
await render(
179+
hbs`<div {{scroll-into-view shouldScroll=false options=this.options}}></div>`
180+
);
181+
182+
assert.notOk(this.scrollToSpy.called, 'scrollTo was not called');
183+
});
184+
185+
test('it renders when shouldScroll resolves to true', async function (assert) {
186+
this.options = {
187+
topOffset: 50,
188+
leftOffset: 40,
189+
};
190+
191+
let resolvePromise;
192+
this.shouldScroll = new Promise((resolve) => (resolvePromise = resolve));
193+
194+
await render(
195+
hbs`<div {{scroll-into-view shouldScroll=this.shouldScroll options=this.options}}></div>`
196+
);
197+
198+
assert.ok(this.scrollToSpy.notCalled, 'scrollTo was not called');
199+
200+
await resolvePromise(true);
201+
202+
assert.ok(this.scrollToSpy.called, 'scrollTo was called');
203+
});
204+
205+
test('it does not render when shouldScroll resolves to false', async function (assert) {
206+
this.options = {
207+
topOffset: 50,
208+
leftOffset: 40,
209+
};
210+
211+
let resolvePromise;
212+
this.shouldScroll = new Promise((resolve) => (resolvePromise = resolve));
213+
214+
await render(
215+
hbs`<div {{scroll-into-view shouldScroll=this.shouldScroll options=this.options}}></div>`
216+
);
217+
218+
await resolvePromise(false);
219+
220+
assert.ok(this.scrollToSpy.notCalled, 'scrollTo was not called');
221+
});
222+
});
100223
});

0 commit comments

Comments
 (0)