scradar
Version:
CSS-first scroll interaction library with progress-based animations
349 lines (299 loc) • 9.83 kB
JavaScript
import ScradarController from './controller.js';
import { throttleRaf, eventSpeaker } from './utils.js';
import ScradarDebug from './debug.js';
export default class Scradar {
static version = '1.0.3';
static defaults = {
target: '.scradar',
root: null,
trigger: null,
prefix: '',
visibility: false,
fill: false,
cover: false,
enter: false,
exit: false,
offsetEnter: false,
offsetExit: false,
peak: null,
once: false,
totalProgress: true,
boundary: false,
momentum: false,
horizontal: false,
container: null,
receiver: null,
delay: null,
breakpoint: null,
debug: false,
};
// Global configurations
static configs = {};
#elements = [];
#options;
#root;
#scrollHandler = null;
#resizeHandler = null;
#wheelHandler = null;
#keydownHandler = null;
#observer = null;
#shadowDom = null;
#isDestroyed = false;
#prevScroll = null;
#scrollDir = 0;
#momentum = {
step: 0,
deltaY: 0,
firstValue: 0,
timer: null,
isMomentum: false,
};
#keydownScrollY = null;
#scrollingWithKeydown = false;
#debugger = null;
#boundaryTarget = null;
constructor(target, options = {}) {
// Parse parameters
if (typeof target === 'object' && target !== null && !target.nodeType) {
options = target;
target = null;
}
this.#options = { ...Scradar.defaults, ...options };
if (typeof target === 'string') {
this.#options.target = target;
}
this.#root = this.#options.root
? document.querySelector(this.#options.root)
: window;
this.#init();
}
#init() {
if (this.#isDestroyed) return;
// Find targets
const selector = this.#options.target || '.scradar';
this.#elements = Array.from(document.querySelectorAll(selector));
// Shadow DOM for triggers
const triggerWrapper = document.createElement('div');
triggerWrapper.id = 'scradarTriggerWrapper';
triggerWrapper.style.display = 'none';
document.body.append(triggerWrapper);
this.#shadowDom = triggerWrapper.attachShadow({ mode: 'open' });
// Attach controller to each element
this.#elements.forEach(el => {
el.scradar = new ScradarController(el, this.#options, this.#shadowDom, {
configs: Scradar.configs
});
});
// Setup observer
this.#observer = new IntersectionObserver(
this.#onIntersect.bind(this),
{
root: this.#root === window ? null : this.#root,
threshold: [0, 0.00001, 0.99999, 1]
}
);
this.#elements.forEach(el => this.#observer.observe(el));
// Setup event handlers
this.#scrollHandler = throttleRaf(this.#onScroll.bind(this));
this.#resizeHandler = throttleRaf(this.#onResize.bind(this));
this.#wheelHandler = throttleRaf(this.#onWheel.bind(this));
this.#keydownHandler = this.#onKeydown.bind(this);
const scrollTarget = this.#root === window ? window : this.#root;
scrollTarget.addEventListener('scroll', this.#scrollHandler, { passive: true });
window.addEventListener('resize', this.#resizeHandler);
window.addEventListener('wheel', this.#wheelHandler, { passive: true });
document.addEventListener('keydown', this.#keydownHandler);
// Debug overlay
if (this.#options.debug) {
this.#debugger = new ScradarDebug(this);
// Expose debug instance globally for target toggle functionality
window.scradarDebug = this.#debugger;
}
// Initial calculation
this.update();
}
#onIntersect(entries) {
entries.forEach(entry => {
const ctrl = entry.target.scradar;
if (!ctrl) return;
if (entry.intersectionRatio !== 0 && !ctrl.settings.done) {
ctrl.settings.mount = true;
ctrl.settings.unmount = false;
} else {
ctrl.settings.mount = false;
}
});
}
#onScroll() {
if (this.#isDestroyed) return;
const currentScroll = this.#root === window
? window.scrollY
: this.#root.scrollTop;
const windowHeight = window.innerHeight;
// Scroll direction detection
if (this.#prevScroll !== null) {
if (currentScroll > this.#prevScroll) {
if (this.#scrollDir !== 1) {
this.#scrollDir = 1;
document.documentElement.dataset.scradarScroll = 1;
eventSpeaker(window, 'scrollTurn', { scroll: 1 });
}
} else if (currentScroll < this.#prevScroll) {
if (this.#scrollDir !== -1) {
this.#scrollDir = -1;
document.documentElement.dataset.scradarScroll = -1;
eventSpeaker(window, 'scrollTurn', { scroll: -1 });
}
} else {
this.#scrollDir = 0;
document.documentElement.dataset.scradarScroll = 0;
}
}
// Reset check for instant scroll to top
if (Math.abs(this.#prevScroll - currentScroll) > 300 && currentScroll === 0) {
this.#prevScroll = 0;
this.update();
return;
}
this.#prevScroll = currentScroll;
// Total progress
if (this.#options.totalProgress) {
const docHeight = document.documentElement.scrollHeight;
const progress = currentScroll / (docHeight - windowHeight);
document.documentElement.dataset.scradarProgress = progress;
this.progress = progress;
}
// Update elements
this.#elements.forEach(el => el.scradar && el.scradar.update());
// Boundary target detection
if (this.#options.boundary) {
this.#updateBoundaryTarget();
}
// Debug update
if (this.#debugger) {
this.#debugger.update();
}
}
#onResize() {
if (this.#isDestroyed) return;
this.#elements.forEach(el => el.scradar && el.scradar.update(true));
if (this.#debugger) this.#debugger.update();
}
#onWheel(e) {
if (this.#isDestroyed) return;
// Momentum detection
this.#momentum.deltaY = e.deltaY;
this.#momentum.step++;
clearTimeout(this.#momentum.timer);
if ((this.#momentum.step > 10 && Math.abs(this.#momentum.deltaY) <= 10) ||
Math.abs(this.#momentum.deltaY) <= 2) {
this.#momentum.step = 0;
this.#momentum.firstValue = this.#momentum.deltaY;
this.#momentum.isMomentum = false;
} else if (this.#momentum.step === 1 &&
Math.abs(this.#momentum.deltaY) > Math.abs(this.#momentum.firstValue)) {
this.#momentum.isMomentum = true;
eventSpeaker(window, 'momentum', {
status: this.#momentum.deltaY > 0 ? 1 : -1
});
}
this.#momentum.timer = setTimeout(() => {
this.#momentum.step = 0;
this.#momentum.isMomentum = false;
}, 80);
}
#onKeydown(e) {
if ((e.metaKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) ||
e.key === 'Tab' || e.key === 'Home' || e.key === 'End') {
this.#scrollingWithKeydown = true;
this.#keydownScrollY = this.#root === window
? window.scrollY
: this.#root.scrollTop;
this.#scrollCheck();
}
}
#scrollCheck() {
if (this.#keydownScrollY !== null) {
const currentScroll = this.#root === window
? window.scrollY
: this.#root.scrollTop;
if (this.#scrollingWithKeydown || this.#keydownScrollY !== currentScroll) {
this.#scrollingWithKeydown = false;
this.#keydownScrollY = currentScroll;
throttleRaf(this.#scrollCheck.bind(this))();
} else {
this.update();
this.#keydownScrollY = null;
}
}
}
#updateBoundaryTarget() {
const boundary = typeof this.#options.boundary === 'number'
? this.#options.boundary
: 0.5;
const windowHeight = window.innerHeight;
const boundaryLine = windowHeight * boundary;
const activeElements = this.#elements.filter(el =>
el.dataset.scradarTitle && +el.dataset.scradarIn === 1
);
if (!activeElements.length) {
if (this.#boundaryTarget) {
document.documentElement.dataset.scradarTarget = '# ' + this.#boundaryTarget;
}
} else {
const target = activeElements.length === 1
? activeElements[0]
: activeElements.sort((a, b) => {
const aRect = a.getBoundingClientRect();
const bRect = b.getBoundingClientRect();
const aDistance = Math.abs(aRect.top + aRect.height / 2 - boundaryLine);
const bDistance = Math.abs(bRect.top + bRect.height / 2 - boundaryLine);
return aDistance - bDistance;
})[0];
this.#boundaryTarget = target.dataset.scradarTitle;
document.documentElement.dataset.scradarTarget = this.#boundaryTarget;
}
}
// Public methods
get elements() {
return this.#elements;
}
get scroll() {
return this.#scrollDir;
}
update() {
if (this.#isDestroyed) return;
this.#elements.forEach(el => {
if (el.scradar) {
el.scradar.settings.unmount = false;
el.scradar.update();
}
});
if (this.#debugger) this.#debugger.update();
}
destroy() {
if (this.#isDestroyed) return;
const scrollTarget = this.#root === window ? window : this.#root;
scrollTarget.removeEventListener('scroll', this.#scrollHandler);
window.removeEventListener('resize', this.#resizeHandler);
window.removeEventListener('wheel', this.#wheelHandler);
document.removeEventListener('keydown', this.#keydownHandler);
if (this.#observer) {
this.#observer.disconnect();
}
if (this.#shadowDom && this.#shadowDom.host) {
this.#shadowDom.host.remove();
}
this.#elements.forEach(el => {
if (el.scradar) {
el.scradar.destroy();
delete el.scradar;
}
});
if (this.#debugger) {
this.#debugger.destroy();
}
this.#elements = [];
this.#isDestroyed = true;
}
}