Skip to content

Commit 708cad2

Browse files
authored
refactor: simplify logic that prevents immediate overscroll (#10128)
1 parent f54fe98 commit 708cad2

File tree

2 files changed

+32
-252
lines changed

2 files changed

+32
-252
lines changed

packages/component-base/src/virtualizer-iron-list-adapter.js

Lines changed: 13 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export class IronListAdapter {
3333

3434
this.timeouts = {
3535
SCROLL_REORDER: 500,
36-
IGNORE_WHEEL: 500,
36+
PREVENT_OVERSCROLL: 500,
3737
FIX_INVALID_ITEM_POSITIONING: 100,
3838
};
3939

@@ -65,7 +65,6 @@ export class IronListAdapter {
6565
attachObserver.observe(this.scrollTarget);
6666

6767
this._scrollLineHeight = this._getScrollLineHeight();
68-
this.scrollTarget.addEventListener('wheel', (e) => this.__onWheel(e));
6968

7069
this.scrollTarget.addEventListener('virtualizer-element-focused', (e) => this.__onElementFocused(e));
7170
this.elementsContainer.addEventListener('focusin', () => {
@@ -580,6 +579,18 @@ export class IronListAdapter {
580579
timeOut.after(this.timeouts.FIX_INVALID_ITEM_POSITIONING),
581580
() => this.__fixInvalidItemPositioning(),
582581
);
582+
583+
if (!this.__overscrollDebouncer?.isActive()) {
584+
this.scrollTarget.style.overscrollBehavior = 'none';
585+
}
586+
587+
this.__overscrollDebouncer = Debouncer.debounce(
588+
this.__overscrollDebouncer,
589+
timeOut.after(this.timeouts.PREVENT_OVERSCROLL),
590+
() => {
591+
this.scrollTarget.style.overscrollBehavior = null;
592+
},
593+
);
583594
}
584595

585596
if (this.reorderElements) {
@@ -654,96 +665,6 @@ export class IronListAdapter {
654665
}
655666
}
656667

657-
/** @private */
658-
__onWheel(e) {
659-
if (e.ctrlKey || this._hasScrolledAncestor(e.target, e.deltaX, e.deltaY)) {
660-
return;
661-
}
662-
663-
let deltaY = e.deltaY;
664-
if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) {
665-
// Scrolling by "lines of text" instead of pixels
666-
deltaY *= this._scrollLineHeight;
667-
} else if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
668-
// Scrolling by "pages" instead of pixels
669-
deltaY *= this._scrollPageHeight;
670-
}
671-
672-
if (!this._deltaYAcc) {
673-
this._deltaYAcc = 0;
674-
}
675-
676-
if (this._wheelAnimationFrame) {
677-
// Accumulate wheel delta while a frame is being processed
678-
this._deltaYAcc += deltaY;
679-
e.preventDefault();
680-
return;
681-
}
682-
683-
deltaY += this._deltaYAcc;
684-
this._deltaYAcc = 0;
685-
686-
this._wheelAnimationFrame = true;
687-
this.__debouncerWheelAnimationFrame = Debouncer.debounce(
688-
this.__debouncerWheelAnimationFrame,
689-
animationFrame,
690-
() => {
691-
this._wheelAnimationFrame = false;
692-
},
693-
);
694-
695-
const momentum = Math.abs(e.deltaX) + Math.abs(deltaY);
696-
697-
if (this._canScroll(this.scrollTarget, e.deltaX, deltaY)) {
698-
e.preventDefault();
699-
this.scrollTarget.scrollTop += deltaY;
700-
this.scrollTarget.scrollLeft += e.deltaX;
701-
702-
this._hasResidualMomentum = true;
703-
704-
this._ignoreNewWheel = true;
705-
this._debouncerIgnoreNewWheel = Debouncer.debounce(
706-
this._debouncerIgnoreNewWheel,
707-
timeOut.after(this.timeouts.IGNORE_WHEEL),
708-
() => {
709-
this._ignoreNewWheel = false;
710-
},
711-
);
712-
} else if ((this._hasResidualMomentum && momentum <= this._previousMomentum) || this._ignoreNewWheel) {
713-
e.preventDefault();
714-
} else if (momentum > this._previousMomentum) {
715-
this._hasResidualMomentum = false;
716-
}
717-
this._previousMomentum = momentum;
718-
}
719-
720-
/**
721-
* Determines if the element has an ancestor that handles the scroll delta prior to this
722-
*
723-
* @private
724-
*/
725-
_hasScrolledAncestor(el, deltaX, deltaY) {
726-
if (el === this.scrollTarget || el === this.scrollTarget.getRootNode().host) {
727-
return false;
728-
} else if (
729-
this._canScroll(el, deltaX, deltaY) &&
730-
['auto', 'scroll'].indexOf(getComputedStyle(el).overflow) !== -1
731-
) {
732-
return true;
733-
} else if (el !== this && el.parentElement) {
734-
return this._hasScrolledAncestor(el.parentElement, deltaX, deltaY);
735-
}
736-
}
737-
738-
_canScroll(el, deltaX, deltaY) {
739-
return (
740-
(deltaY > 0 && el.scrollTop < el.scrollHeight - el.offsetHeight) ||
741-
(deltaY < 0 && el.scrollTop > 0) ||
742-
(deltaX > 0 && el.scrollLeft < el.scrollWidth - el.offsetWidth) ||
743-
(deltaX < 0 && el.scrollLeft > 0)
744-
);
745-
}
746-
747668
/**
748669
* Increases the pool size.
749670
* @override

packages/component-base/test/virtualizer-scrolling.test.js

Lines changed: 19 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -1,195 +1,54 @@
11
import { expect } from '@vaadin/chai-plugins';
2-
import { fixtureSync, nextFrame, nextRender } from '@vaadin/testing-helpers';
3-
import Sinon from 'sinon';
2+
import { fixtureSync, nextFrame } from '@vaadin/testing-helpers';
3+
import sinon from 'sinon';
44
import { Virtualizer } from '../src/virtualizer.js';
55

6-
function canScroll(el, deltaY) {
7-
const isScrollableElement = ['auto', 'scroll'].indexOf(getComputedStyle(el).overflow) !== -1;
8-
const canScrollAndScrollingDownwards = deltaY > 0 && el.scrollTop < el.scrollHeight - el.offsetHeight;
9-
const canScrollAndScrollingUpwards = deltaY < 0 && el.scrollTop > 0;
10-
11-
return isScrollableElement && (canScrollAndScrollingDownwards || canScrollAndScrollingUpwards);
12-
}
13-
14-
describe('virtualizer - wheel scrolling', () => {
15-
const IGNORE_WHEEL_TIMEOUT = 500;
16-
let wrapper;
6+
describe('virtualizer - overscroll', () => {
7+
const PREVENT_OVERSCROLL_TIMEOUT = 500;
178
let virtualizer;
189
let scrollTarget;
1910
let clock;
20-
let child;
21-
let grandchild;
2211

2312
beforeEach(() => {
24-
wrapper = fixtureSync(`
25-
<div style="height: 100px; overflow: auto;">
26-
<div style="height: 200px;">
27-
<div></div>
28-
</div>
13+
scrollTarget = fixtureSync(`
14+
<div style="height: 200px;">
15+
<div></div>
2916
</div>
3017
`);
31-
scrollTarget = wrapper.firstElementChild;
3218
const scrollContainer = scrollTarget.firstElementChild;
3319

34-
const wheelListener = (e) => {
35-
if (!e.defaultPrevented && canScroll(e.currentTarget, e.deltaY)) {
36-
e.currentTarget.scrollTop += e.deltaY;
37-
e.preventDefault();
38-
}
39-
};
40-
41-
wrapper.addEventListener('wheel', wheelListener);
42-
4320
virtualizer = new Virtualizer({
4421
createElements: (count) => Array.from(Array(count)).map(() => document.createElement('div')),
4522
updateElement: (el, index) => {
4623
el.index = index;
4724
el.id = `item-${index}`;
48-
49-
if (!el.firstElementChild) {
50-
el.innerHTML = `
51-
<div class="child" style="height: 20px; overflow: auto;">
52-
<div class="grandchild">
53-
<div class="content" style="height: 40px;">
54-
</div>
55-
</div>
56-
</div>
57-
`;
58-
el.querySelector('.child').addEventListener('wheel', wheelListener);
59-
el.querySelector('.grandchild').addEventListener('wheel', wheelListener);
60-
}
61-
62-
el.querySelector('.content').textContent = index;
25+
el.textContent = index;
6326
},
6427
scrollTarget,
6528
scrollContainer,
6629
});
6730

6831
virtualizer.size = 100;
6932

70-
child = scrollContainer.firstElementChild.querySelector('.child');
71-
grandchild = scrollContainer.firstElementChild.querySelector('.grandchild');
72-
73-
clock = Sinon.useFakeTimers();
33+
clock = sinon.useFakeTimers({
34+
shouldClearNativeTimers: true,
35+
});
7436
});
7537

7638
afterEach(() => {
7739
clock.restore();
7840
});
7941

80-
function wheel({
81-
element = scrollTarget,
82-
deltaX = 0,
83-
deltaY = 0,
84-
deltaMode = WheelEvent.DOM_DELTA_PIXEL,
85-
skipFlush = false,
86-
ctrlKey = false,
87-
}) {
88-
const e = new CustomEvent('wheel', { bubbles: true, cancelable: true });
89-
e.deltaY = deltaY;
90-
e.deltaX = deltaX;
91-
e.deltaMode = deltaMode;
92-
e.ctrlKey = ctrlKey;
93-
element.dispatchEvent(e);
94-
if (!skipFlush) {
95-
virtualizer.flush();
96-
}
97-
}
98-
99-
it('should scroll by pixels when deltaMode is DOM_DELTA_PIXEL (default)', () => {
100-
wheel({ deltaY: 1, deltaMode: WheelEvent.DOM_DELTA_PIXEL });
101-
expect(scrollTarget.scrollTop).to.equal(1);
102-
});
103-
104-
it('should scroll by lines when deltaMode is DOM_DELTA_LINE', () => {
105-
wheel({ deltaY: 1, deltaMode: WheelEvent.DOM_DELTA_LINE });
106-
expect(scrollTarget.scrollTop).to.equal(16);
107-
});
108-
109-
it('should scroll by pages when deltaMode is DOM_DELTA_PAGE', () => {
110-
wheel({ deltaY: 1, deltaMode: WheelEvent.DOM_DELTA_PAGE });
111-
expect(scrollTarget.scrollTop).to.equal(184);
112-
});
113-
114-
it('should not scroll the wrapper right after virtualizer scrolled to end', () => {
115-
// Wheel scroll to end
116-
wheel({ deltaY: scrollTarget.scrollHeight });
117-
// Wheel momentum settled down
118-
wheel({ deltaY: 0 });
119-
// Wheel scroll again
120-
wheel({ deltaY: 1 });
121-
// Expect the underlying wrapper not to have been scrolled
122-
expect(wrapper.scrollTop).to.equal(0);
123-
});
124-
125-
it('should scroll the wrapper a while after virtualizer scrolled to end', () => {
126-
wheel({ deltaY: scrollTarget.scrollHeight });
127-
wheel({ deltaY: 0 });
128-
clock.tick(IGNORE_WHEEL_TIMEOUT);
129-
wheel({ deltaY: 1 });
130-
expect(wrapper.scrollTop).to.equal(1);
131-
});
132-
133-
it('should skip the custom wheel scrolling logic on ctrl-wheel', () => {
134-
wheel({ deltaY: scrollTarget.scrollHeight });
135-
wheel({ deltaY: 1, ctrlKey: true });
136-
expect(wrapper.scrollTop).to.equal(1);
137-
});
138-
139-
it('should not scroll the wrapper while the scroll momentum slowly settles', () => {
140-
let deltaY = scrollTarget.scrollHeight;
141-
const step = Math.floor(deltaY / 10);
142-
while (deltaY > 0) {
143-
wheel({ deltaY });
144-
deltaY -= step;
145-
clock.tick(100);
146-
}
147-
expect(wrapper.scrollTop).to.equal(0);
148-
});
149-
150-
it('should scroll the wrapper normally if already scrolled to end', () => {
151-
wheel({ deltaY: scrollTarget.scrollHeight });
152-
wheel({ deltaY: 0 });
153-
clock.tick(IGNORE_WHEEL_TIMEOUT);
154-
wheel({ deltaY: 2 });
155-
wheel({ deltaY: 1 });
156-
expect(wrapper.scrollTop).to.equal(3);
157-
});
158-
159-
it('should process wheel delta one per frame', async () => {
160-
clock.restore();
161-
162-
wheel({ deltaY: 1, skipFlush: true });
163-
wheel({ deltaY: 1, skipFlush: true });
164-
expect(scrollTarget.scrollTop).to.equal(1);
165-
166-
await nextRender();
167-
wheel({ deltaY: 1, skipFlush: true });
168-
await nextRender();
169-
wheel({ deltaY: 1, skipFlush: true });
170-
expect(scrollTarget.scrollTop).to.equal(4);
171-
});
172-
173-
it('should scroll a scrollable child', () => {
174-
wheel({ deltaY: 1, element: child });
175-
expect(child.scrollTop).to.equal(1);
176-
expect(scrollTarget.scrollTop).to.equal(0);
177-
expect(wrapper.scrollTop).to.equal(0);
178-
});
179-
180-
it('should scroll a scrollable child from wheel over grandchild', () => {
181-
wheel({ deltaY: 1, element: grandchild });
182-
expect(child.scrollTop).to.equal(1);
183-
expect(scrollTarget.scrollTop).to.equal(0);
184-
expect(wrapper.scrollTop).to.equal(0);
42+
it('should prevent outer scrolling right after reaching the end', async () => {
43+
scrollTarget.scrollTop = scrollTarget.scrollHeight;
44+
await clock.tickAsync();
45+
expect(scrollTarget.style.overscrollBehavior).to.equal('none');
18546
});
18647

187-
it('should scroll the scrollTarget from wheel over grandchild', () => {
188-
child.style.overflow = 'hidden';
189-
wheel({ deltaY: 1, element: grandchild });
190-
expect(child.scrollTop).to.equal(0);
191-
expect(scrollTarget.scrollTop).to.equal(1);
192-
expect(wrapper.scrollTop).to.equal(0);
48+
it('should allow outer scrolling again after timeout', async () => {
49+
scrollTarget.scrollTop = scrollTarget.scrollHeight;
50+
await clock.tickAsync(PREVENT_OVERSCROLL_TIMEOUT);
51+
expect(scrollTarget.style.overscrollBehavior).to.be.empty;
19352
});
19453
});
19554

0 commit comments

Comments
 (0)