react-modal-sheet 3.2.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -19
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +218 -115
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +214 -111
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -4
- package/src/constants.ts +2 -2
- package/src/hooks.tsx +87 -15
- package/src/sheet.tsx +15 -7
- package/src/types.tsx +2 -0
- package/src/use-prevent-scroll.ts +80 -21
- package/src/utils.ts +41 -42
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React2, { createContext, forwardRef, useRef, useEffect, useImperativeHandle, useMemo, Children, cloneElement, useLayoutEffect, useCallback, useState, useContext } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
|
-
import { useMotionValue, useReducedMotion, useTransform, animate, motion, AnimatePresence } from 'framer-motion';
|
|
4
|
-
import { useLayoutEffect as useLayoutEffect$1, isIOS, chain, getScrollParent } from '@react-aria/utils';
|
|
3
|
+
import { useMotionValue, useReducedMotion, useTransform, animate, motion, AnimatePresence, transform } from 'framer-motion';
|
|
5
4
|
|
|
6
5
|
// src/sheet.tsx
|
|
7
6
|
|
|
@@ -16,107 +15,88 @@ var REDUCED_MOTION_TWEEN_CONFIG = {
|
|
|
16
15
|
ease: "linear",
|
|
17
16
|
duration: 0.01
|
|
18
17
|
};
|
|
19
|
-
var
|
|
20
|
-
var
|
|
18
|
+
var DEFAULT_DRAG_CLOSE_THRESHOLD = 0.6;
|
|
19
|
+
var DEFAULT_DRAG_VELOCITY_THRESHOLD = 500;
|
|
21
20
|
|
|
22
|
-
// src/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
function applyRootStyles(rootId) {
|
|
36
|
-
const body = document.querySelector("body");
|
|
37
|
-
const root = document.querySelector(`#${rootId}`);
|
|
38
|
-
if (root) {
|
|
39
|
-
const p = 24;
|
|
40
|
-
const h = window.innerHeight;
|
|
41
|
-
const s = (h - p) / h;
|
|
21
|
+
// src/hooks.tsx
|
|
22
|
+
var useIsomorphicLayoutEffect = IS_SSR ? useEffect : useLayoutEffect;
|
|
23
|
+
function useModalEffect({
|
|
24
|
+
y,
|
|
25
|
+
rootId,
|
|
26
|
+
sheetRef
|
|
27
|
+
}) {
|
|
28
|
+
const heightRef = useRef(IS_SSR ? 0 : window.innerHeight);
|
|
29
|
+
function setup() {
|
|
30
|
+
const root = document.querySelector(`#${rootId}`);
|
|
31
|
+
const body = document.querySelector("body");
|
|
32
|
+
if (!root)
|
|
33
|
+
return;
|
|
42
34
|
body.style.backgroundColor = "#000";
|
|
43
35
|
root.style.overflow = "hidden";
|
|
44
|
-
root.style.
|
|
45
|
-
root.style.
|
|
46
|
-
root.style.
|
|
47
|
-
root.style.
|
|
48
|
-
root.style.borderTopLeftRadius = "10px";
|
|
36
|
+
root.style.transitionTimingFunction = "cubic-bezier(0.32, 0.72, 0, 1)";
|
|
37
|
+
root.style.transitionProperty = "transform, border-radius";
|
|
38
|
+
root.style.transitionDuration = "0.5s";
|
|
39
|
+
root.style.transformOrigin = "center top";
|
|
49
40
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
function inDescendingOrder(arr) {
|
|
69
|
-
for (let i = 0; i < arr.length; i++) {
|
|
70
|
-
if (arr[i + 1] > arr[i])
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
return true;
|
|
74
|
-
}
|
|
75
|
-
function validateSnapTo({
|
|
76
|
-
snapTo,
|
|
77
|
-
sheetHeight
|
|
78
|
-
}) {
|
|
79
|
-
if (snapTo < 0) {
|
|
80
|
-
console.warn(
|
|
81
|
-
`Snap point is out of bounds. Sheet height is ${sheetHeight} but snap point is ${sheetHeight + Math.abs(snapTo)}.`
|
|
82
|
-
);
|
|
41
|
+
function cleanup() {
|
|
42
|
+
const root = document.querySelector(`#${rootId}`);
|
|
43
|
+
const body = document.querySelector("body");
|
|
44
|
+
if (!root)
|
|
45
|
+
return;
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
body.style.removeProperty("background-color");
|
|
48
|
+
root.style.removeProperty("overflow");
|
|
49
|
+
root.style.removeProperty("transition-timing-function");
|
|
50
|
+
root.style.removeProperty("transition-property");
|
|
51
|
+
root.style.removeProperty("transition-duration");
|
|
52
|
+
root.style.removeProperty("transform-origin");
|
|
53
|
+
root.style.removeProperty("transform");
|
|
54
|
+
root.style.removeProperty("border-top-right-radius");
|
|
55
|
+
root.style.removeProperty("border-top-left-radius");
|
|
56
|
+
}, 100);
|
|
83
57
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
ref(value);
|
|
91
|
-
} else if (ref) {
|
|
92
|
-
ref.current = value;
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
function isTouchDevice() {
|
|
98
|
-
if (typeof window === "undefined")
|
|
99
|
-
return false;
|
|
100
|
-
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// src/hooks.tsx
|
|
104
|
-
var useIsomorphicLayoutEffect = IS_SSR ? useEffect : useLayoutEffect;
|
|
105
|
-
function useModalEffect(isOpen, rootId) {
|
|
106
|
-
const prevOpen = usePrevious(isOpen);
|
|
58
|
+
useIsomorphicLayoutEffect(() => {
|
|
59
|
+
return () => {
|
|
60
|
+
if (rootId)
|
|
61
|
+
cleanup();
|
|
62
|
+
};
|
|
63
|
+
}, []);
|
|
107
64
|
useEffect(() => {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
65
|
+
const root = document.querySelector(`#${rootId}`);
|
|
66
|
+
if (!root)
|
|
67
|
+
return;
|
|
68
|
+
function onCompleted() {
|
|
69
|
+
if (y.get() - 10 >= heightRef.current)
|
|
70
|
+
cleanup();
|
|
112
71
|
}
|
|
113
|
-
|
|
114
|
-
|
|
72
|
+
const removeStartListener = y.on("animationStart", () => {
|
|
73
|
+
var _a;
|
|
74
|
+
heightRef.current = ((_a = sheetRef.current) == null ? void 0 : _a.offsetHeight) || window.innerHeight;
|
|
75
|
+
setup();
|
|
76
|
+
});
|
|
77
|
+
const removeChangeListener = y.on("change", (value) => {
|
|
78
|
+
if (root) {
|
|
79
|
+
const progress = Math.max(0, 1 - value / heightRef.current);
|
|
80
|
+
const pageWidth = window.innerWidth;
|
|
81
|
+
const scale = (pageWidth - 16) / pageWidth;
|
|
82
|
+
const ty = transform(progress, [0, 1], [0, 24]);
|
|
83
|
+
const s = transform(progress, [0, 1], [1, scale]);
|
|
84
|
+
const borderRadius = transform(progress, [0, 1], [0, 10]);
|
|
85
|
+
const inset = "env(safe-area-inset-top)";
|
|
86
|
+
root.style.transform = `scale(${s}) translate3d(0, calc(${inset} + ${ty}px), 0)`;
|
|
87
|
+
root.style.borderTopRightRadius = `${borderRadius}px`;
|
|
88
|
+
root.style.borderTopLeftRadius = `${borderRadius}px`;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
const removeCompleteListener = y.on("animationComplete", onCompleted);
|
|
92
|
+
const removeCancelListener = y.on("animationCancel", onCompleted);
|
|
115
93
|
return () => {
|
|
116
|
-
|
|
117
|
-
|
|
94
|
+
removeStartListener();
|
|
95
|
+
removeChangeListener();
|
|
96
|
+
removeCompleteListener();
|
|
97
|
+
removeCancelListener();
|
|
118
98
|
};
|
|
119
|
-
}, [
|
|
99
|
+
}, [y, rootId]);
|
|
120
100
|
}
|
|
121
101
|
function useEventCallbacks(isOpen, callbacks) {
|
|
122
102
|
const prevOpen = usePrevious(isOpen);
|
|
@@ -142,7 +122,10 @@ function useEventCallbacks(isOpen, callbacks) {
|
|
|
142
122
|
return { handleAnimationComplete };
|
|
143
123
|
}
|
|
144
124
|
function useDimensions() {
|
|
145
|
-
const [dimensions, setDimensions] = useState({
|
|
125
|
+
const [dimensions, setDimensions] = useState({
|
|
126
|
+
height: !IS_SSR ? window.innerHeight : 0,
|
|
127
|
+
width: !IS_SSR ? window.innerWidth : 0
|
|
128
|
+
});
|
|
146
129
|
useIsomorphicLayoutEffect(() => {
|
|
147
130
|
function handler() {
|
|
148
131
|
setDimensions({
|
|
@@ -218,7 +201,120 @@ var useSheetScrollerContext = () => {
|
|
|
218
201
|
throw Error("Sheet scroller context error");
|
|
219
202
|
return context;
|
|
220
203
|
};
|
|
221
|
-
|
|
204
|
+
|
|
205
|
+
// src/utils.ts
|
|
206
|
+
function getClosest(nums, goal) {
|
|
207
|
+
let closest = nums[0];
|
|
208
|
+
let minDifference = Math.abs(nums[0] - goal);
|
|
209
|
+
for (let i = 1; i < nums.length; i++) {
|
|
210
|
+
const difference = Math.abs(nums[i] - goal);
|
|
211
|
+
if (difference < minDifference) {
|
|
212
|
+
closest = nums[i];
|
|
213
|
+
minDifference = difference;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return closest;
|
|
217
|
+
}
|
|
218
|
+
function inDescendingOrder(arr) {
|
|
219
|
+
for (let i = 0; i < arr.length; i++) {
|
|
220
|
+
if (arr[i + 1] > arr[i])
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
function validateSnapTo({
|
|
226
|
+
snapTo,
|
|
227
|
+
sheetHeight
|
|
228
|
+
}) {
|
|
229
|
+
if (snapTo < 0) {
|
|
230
|
+
console.warn(
|
|
231
|
+
`Snap point is out of bounds. Sheet height is ${sheetHeight} but snap point is ${sheetHeight + Math.abs(snapTo)}.`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return Math.max(Math.round(snapTo), 0);
|
|
235
|
+
}
|
|
236
|
+
function mergeRefs(refs) {
|
|
237
|
+
return (value) => {
|
|
238
|
+
refs.forEach((ref) => {
|
|
239
|
+
if (typeof ref === "function") {
|
|
240
|
+
ref(value);
|
|
241
|
+
} else if (ref) {
|
|
242
|
+
ref.current = value;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function isTouchDevice() {
|
|
248
|
+
if (typeof window === "undefined")
|
|
249
|
+
return false;
|
|
250
|
+
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
|
251
|
+
}
|
|
252
|
+
function testPlatform(re) {
|
|
253
|
+
var _a;
|
|
254
|
+
return typeof window !== "undefined" && window.navigator != null ? re.test(
|
|
255
|
+
// @ts-expect-error
|
|
256
|
+
// eslint-disable-next-line @typescript-eslint/dot-notation
|
|
257
|
+
((_a = window.navigator["userAgentData"]) == null ? void 0 : _a.platform) || window.navigator.platform
|
|
258
|
+
) : false;
|
|
259
|
+
}
|
|
260
|
+
function cached(fn) {
|
|
261
|
+
let res = null;
|
|
262
|
+
return () => {
|
|
263
|
+
if (res == null) {
|
|
264
|
+
res = fn();
|
|
265
|
+
}
|
|
266
|
+
return res;
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
var isMac = cached(function() {
|
|
270
|
+
return testPlatform(/^Mac/i);
|
|
271
|
+
});
|
|
272
|
+
var isIPhone = cached(function() {
|
|
273
|
+
return testPlatform(/^iPhone/i);
|
|
274
|
+
});
|
|
275
|
+
var isIPad = cached(function() {
|
|
276
|
+
return testPlatform(/^iPad/i) || // iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
|
|
277
|
+
isMac() && navigator.maxTouchPoints > 1;
|
|
278
|
+
});
|
|
279
|
+
var isIOS = cached(function() {
|
|
280
|
+
return isIPhone() || isIPad();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// src/use-prevent-scroll.ts
|
|
284
|
+
var KEYBOARD_BUFFER = 24;
|
|
285
|
+
function chain(...callbacks) {
|
|
286
|
+
return (...args) => {
|
|
287
|
+
for (const callback of callbacks) {
|
|
288
|
+
if (typeof callback === "function") {
|
|
289
|
+
callback(...args);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
var visualViewport = typeof document !== "undefined" && window.visualViewport;
|
|
295
|
+
function isScrollable(node, checkForOverflow) {
|
|
296
|
+
if (!node) {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
const style = window.getComputedStyle(node);
|
|
300
|
+
let isScrollable2 = /(auto|scroll)/.test(
|
|
301
|
+
style.overflow + style.overflowX + style.overflowY
|
|
302
|
+
);
|
|
303
|
+
if (isScrollable2 && checkForOverflow) {
|
|
304
|
+
isScrollable2 = node.scrollHeight !== node.clientHeight || node.scrollWidth !== node.clientWidth;
|
|
305
|
+
}
|
|
306
|
+
return isScrollable2;
|
|
307
|
+
}
|
|
308
|
+
function getScrollParent(node, checkForOverflow) {
|
|
309
|
+
let scrollableNode = node;
|
|
310
|
+
if (isScrollable(scrollableNode, checkForOverflow)) {
|
|
311
|
+
scrollableNode = scrollableNode.parentElement;
|
|
312
|
+
}
|
|
313
|
+
while (scrollableNode && !isScrollable(scrollableNode, checkForOverflow)) {
|
|
314
|
+
scrollableNode = scrollableNode.parentElement;
|
|
315
|
+
}
|
|
316
|
+
return scrollableNode || document.scrollingElement || document.documentElement;
|
|
317
|
+
}
|
|
222
318
|
var nonTextInputTypes = /* @__PURE__ */ new Set([
|
|
223
319
|
"checkbox",
|
|
224
320
|
"radio",
|
|
@@ -234,7 +330,7 @@ var preventScrollCount = 0;
|
|
|
234
330
|
var restore;
|
|
235
331
|
function usePreventScroll(options = {}) {
|
|
236
332
|
const { isDisabled } = options;
|
|
237
|
-
|
|
333
|
+
useIsomorphicLayoutEffect(() => {
|
|
238
334
|
if (isDisabled) {
|
|
239
335
|
return;
|
|
240
336
|
}
|
|
@@ -249,7 +345,7 @@ function usePreventScroll(options = {}) {
|
|
|
249
345
|
return () => {
|
|
250
346
|
preventScrollCount--;
|
|
251
347
|
if (preventScrollCount === 0) {
|
|
252
|
-
restore();
|
|
348
|
+
restore == null ? void 0 : restore();
|
|
253
349
|
}
|
|
254
350
|
};
|
|
255
351
|
}, [isDisabled]);
|
|
@@ -268,7 +364,7 @@ function preventScrollMobileSafari() {
|
|
|
268
364
|
let scrollable;
|
|
269
365
|
let lastY = 0;
|
|
270
366
|
const onTouchStart = (e) => {
|
|
271
|
-
scrollable = getScrollParent(e.target);
|
|
367
|
+
scrollable = getScrollParent(e.target, true);
|
|
272
368
|
if (scrollable === document.documentElement && scrollable === document.body) {
|
|
273
369
|
return;
|
|
274
370
|
}
|
|
@@ -278,7 +374,7 @@ function preventScrollMobileSafari() {
|
|
|
278
374
|
if (scrollable === void 0) {
|
|
279
375
|
return;
|
|
280
376
|
}
|
|
281
|
-
if (scrollable === document.documentElement || scrollable === document.body) {
|
|
377
|
+
if (!scrollable || scrollable === document.documentElement || scrollable === document.body) {
|
|
282
378
|
e.preventDefault();
|
|
283
379
|
return;
|
|
284
380
|
}
|
|
@@ -318,9 +414,7 @@ function preventScrollMobileSafari() {
|
|
|
318
414
|
} else {
|
|
319
415
|
visualViewport.addEventListener(
|
|
320
416
|
"resize",
|
|
321
|
-
() =>
|
|
322
|
-
scrollIntoView(target);
|
|
323
|
-
},
|
|
417
|
+
() => scrollIntoView(target),
|
|
324
418
|
{ once: true }
|
|
325
419
|
);
|
|
326
420
|
}
|
|
@@ -385,7 +479,9 @@ function scrollIntoView(target) {
|
|
|
385
479
|
if (scrollable !== document.documentElement && scrollable !== document.body && scrollable !== target) {
|
|
386
480
|
const scrollableTop = scrollable.getBoundingClientRect().top;
|
|
387
481
|
const targetTop = target.getBoundingClientRect().top;
|
|
388
|
-
|
|
482
|
+
const targetBottom = target.getBoundingClientRect().bottom;
|
|
483
|
+
const keyboardHeight = scrollable.getBoundingClientRect().bottom + KEYBOARD_BUFFER;
|
|
484
|
+
if (targetBottom > keyboardHeight) {
|
|
389
485
|
scrollable.scrollTop += targetTop - scrollableTop;
|
|
390
486
|
}
|
|
391
487
|
}
|
|
@@ -476,7 +572,7 @@ var Sheet = forwardRef(
|
|
|
476
572
|
children,
|
|
477
573
|
disableScrollLocking = false,
|
|
478
574
|
isOpen,
|
|
479
|
-
snapPoints,
|
|
575
|
+
snapPoints: snapPointsProp,
|
|
480
576
|
rootId,
|
|
481
577
|
mountPoint,
|
|
482
578
|
style,
|
|
@@ -485,6 +581,8 @@ var Sheet = forwardRef(
|
|
|
485
581
|
disableDrag = false,
|
|
486
582
|
prefersReducedMotion = false,
|
|
487
583
|
tweenConfig = DEFAULT_TWEEN_CONFIG,
|
|
584
|
+
dragVelocityThreshold = DEFAULT_DRAG_VELOCITY_THRESHOLD,
|
|
585
|
+
dragCloseThreshold = DEFAULT_DRAG_CLOSE_THRESHOLD,
|
|
488
586
|
...rest
|
|
489
587
|
}, ref) => {
|
|
490
588
|
const sheetRef = useRef(null);
|
|
@@ -496,7 +594,7 @@ var Sheet = forwardRef(
|
|
|
496
594
|
type: "tween",
|
|
497
595
|
...reduceMotion ? REDUCED_MOTION_TWEEN_CONFIG : tweenConfig
|
|
498
596
|
};
|
|
499
|
-
const y = useMotionValue(
|
|
597
|
+
const y = useMotionValue(windowHeight);
|
|
500
598
|
const zIndex = useTransform(
|
|
501
599
|
y,
|
|
502
600
|
(value) => value + 2 >= windowHeight ? -1 : 9999999
|
|
@@ -519,6 +617,7 @@ var Sheet = forwardRef(
|
|
|
519
617
|
onCloseEnd
|
|
520
618
|
};
|
|
521
619
|
});
|
|
620
|
+
let snapPoints = snapPointsProp;
|
|
522
621
|
if (snapPoints) {
|
|
523
622
|
snapPoints = snapPoints.map((point) => {
|
|
524
623
|
if (point > 0 && point <= 1)
|
|
@@ -550,7 +649,7 @@ var Sheet = forwardRef(
|
|
|
550
649
|
}
|
|
551
650
|
});
|
|
552
651
|
const onDragEnd = useEffectEvent((_, { velocity }) => {
|
|
553
|
-
if (velocity.y >
|
|
652
|
+
if (velocity.y > dragVelocityThreshold) {
|
|
554
653
|
onClose();
|
|
555
654
|
} else {
|
|
556
655
|
const sheetHeight = sheetRef.current.getBoundingClientRect().height;
|
|
@@ -564,7 +663,7 @@ var Sheet = forwardRef(
|
|
|
564
663
|
snapToValues.unshift(0);
|
|
565
664
|
}
|
|
566
665
|
snapTo = getClosest(snapToValues, currentY);
|
|
567
|
-
} else if (currentY / sheetHeight >
|
|
666
|
+
} else if (currentY / sheetHeight > dragCloseThreshold) {
|
|
568
667
|
snapTo = sheetHeight;
|
|
569
668
|
}
|
|
570
669
|
snapTo = validateSnapTo({ snapTo, sheetHeight });
|
|
@@ -606,7 +705,11 @@ var Sheet = forwardRef(
|
|
|
606
705
|
}
|
|
607
706
|
}
|
|
608
707
|
}));
|
|
609
|
-
useModalEffect(
|
|
708
|
+
useModalEffect({
|
|
709
|
+
y,
|
|
710
|
+
rootId,
|
|
711
|
+
sheetRef
|
|
712
|
+
});
|
|
610
713
|
usePreventScroll({ isDisabled: disableScrollLocking || !isOpen });
|
|
611
714
|
const dragProps = useMemo(() => {
|
|
612
715
|
const dragProps2 = {
|
|
@@ -786,8 +889,8 @@ var SheetScroller = forwardRef(
|
|
|
786
889
|
const sheetScrollerContext = useSheetScrollerContext();
|
|
787
890
|
function determineDragState(element) {
|
|
788
891
|
const { scrollTop, scrollHeight, clientHeight } = element;
|
|
789
|
-
const
|
|
790
|
-
if (!
|
|
892
|
+
const isScrollable2 = scrollHeight > clientHeight;
|
|
893
|
+
if (!isScrollable2)
|
|
791
894
|
return;
|
|
792
895
|
const isAtTop = scrollTop <= 0;
|
|
793
896
|
const isAtBottom = scrollHeight - scrollTop === clientHeight;
|