@nadir2k/manual-scroll-detector
Version:
Detects user-initiated manual scrolling (mouse, touch, keyboard, wheel) on a scrollable element.
133 lines (115 loc) • 3.79 kB
text/typescript
export type ScrollCallback = (manual: boolean) => void;
export enum ScrollKeys {
ArrowUp = "ArrowUp",
ArrowDown = "ArrowDown",
ArrowLeft = "ArrowLeft",
ArrowRight = "ArrowRight",
PageUp = "PageUp",
PageDown = "PageDown",
Home = "Home",
End = "End",
}
export interface ManualScrollOptions {
ignoreKeys?: ScrollKeys[];
}
export function attachManualScrollDetector(
el: HTMLElement,
callback: ScrollCallback,
options: ManualScrollOptions = {}
) {
let isUserScrolling = false;
let lastScrollTop = el.scrollTop;
let lastScrollLeft = el.scrollLeft;
// Detect scrollbar drag
let isDraggingScrollbar = false;
const rect = () => el.getBoundingClientRect();
const onMouseDown = (e: MouseEvent) => {
const r = rect();
const scrollbarWidth = el.offsetWidth - el.clientWidth;
const scrollbarHeight = el.offsetHeight - el.clientHeight;
const onVerticalScrollbar = e.clientX > r.right - scrollbarWidth;
const onHorizontalScrollbar = e.clientY > r.bottom - scrollbarHeight;
if (onVerticalScrollbar || onHorizontalScrollbar) {
isDraggingScrollbar = true;
callback(true);
}
};
const stopDrag = () => {
if (isDraggingScrollbar) {
isDraggingScrollbar = false;
callback(false);
}
};
el.addEventListener("mousedown", onMouseDown);
window.addEventListener("mouseup", stopDrag);
window.addEventListener("mouseleave", stopDrag);
// Detect keyboard scrolling (only if element is active)
const keyScrollKeys: ScrollKeys[] = [
ScrollKeys.ArrowUp,
ScrollKeys.ArrowDown,
ScrollKeys.ArrowLeft,
ScrollKeys.ArrowRight,
ScrollKeys.PageUp,
ScrollKeys.PageDown,
ScrollKeys.Home,
ScrollKeys.End,
];
const isElementActive = () => el.matches(":focus, :focus-within");
const onKeyDown = (e: KeyboardEvent) => {
if (
keyScrollKeys.includes(e.key as ScrollKeys) &&
!options.ignoreKeys?.includes(e.key as ScrollKeys) &&
isElementActive()
) {
isUserScrolling = true;
callback(true);
}
};
window.addEventListener("keydown", onKeyDown);
// Detect wheel scrolling (always counts)
const onWheel = () => {
isUserScrolling = true;
callback(true);
};
el.addEventListener("wheel", onWheel);
// Detect touch scrolling (always counts)
const onTouchStart = () => {
isUserScrolling = true;
callback(true);
};
el.addEventListener("touchstart", onTouchStart);
// Main scroll listener
const onScroll = () => {
const scrolled =
el.scrollTop !== lastScrollTop || el.scrollLeft !== lastScrollLeft;
if (scrolled && (isDraggingScrollbar || isUserScrolling)) {
callback(true);
}
lastScrollTop = el.scrollTop;
lastScrollLeft = el.scrollLeft;
};
el.addEventListener("scroll", onScroll);
// Reset after short timeout
let resetTimeout: ReturnType<typeof setTimeout>;
const reset = () => {
clearTimeout(resetTimeout);
resetTimeout = setTimeout(() => {
isUserScrolling = false;
callback(false);
}, 100);
};
el.addEventListener("scroll", reset);
window.addEventListener("mouseup", reset);
// Return detach function
return () => {
el.removeEventListener("mousedown", onMouseDown);
window.removeEventListener("mouseup", stopDrag);
window.removeEventListener("mouseleave", stopDrag);
window.removeEventListener("keydown", onKeyDown);
el.removeEventListener("wheel", onWheel);
el.removeEventListener("touchstart", onTouchStart);
el.removeEventListener("scroll", onScroll);
el.removeEventListener("scroll", reset);
window.removeEventListener("mouseup", reset);
};
}