@cocreate/scroll
Version:
A simple scroll component in vanilla javascript. Easily configured using HTML5 attributes and/or JavaScript API.
374 lines (323 loc) • 13.3 kB
JavaScript
import Observer from "@cocreate/observer";
import Actions from "@cocreate/actions";
const selector =
"[scroll], [scroll-to], [scrollable-x], [scrollable-y], [scroll-up], [scroll-down], [scroll-top], [scroll-bottom], [scroll-limbo], [scroll-intersect], [scrolling]";
const CoCreateScroll = {
delta: 3,
observer: null,
// timer: null, // Removed: Timer needs to be instance-specific, not global
firedEvents: new Map(),
init: function () {
let elements = document.querySelectorAll(selector);
this.__initIntersectionObserver();
this.initElements(elements);
},
initElements: function (elements) {
for (let el of elements) this.initElement(el);
},
initElement: function (element) {
const self = this;
const upSize = this.__getSize(element.getAttribute("scroll-up"));
const downSize = this.__getSize(element.getAttribute("scroll-down"));
const attrName = element.getAttribute("scroll-attribute") || "class";
const targetSelector = element.getAttribute("scroll-query");
const scrollSelector = element.getAttribute("scroll-element");
const intersectValue = element.getAttribute("scroll-intersect");
const scrollTo = element.getAttribute("scroll-to");
updateScrollableAttributes(element);
let values =
element.getAttribute("scroll") ||
element.getAttribute("scroll-value");
if (values || values === "")
values = values.split(",").map((x) => x.trim());
let scrollInfo = {
attrName: attrName,
values: values,
upSize: upSize,
downSize: downSize,
scrollTop: element.getAttribute("scroll-top"),
scrollLimbo: element.getAttribute("scroll-limbo"),
scrollBottom: element.getAttribute("scroll-bottom"),
scrolling: element.getAttribute("scrolling"),
scrollTo,
timer: null // Added: Store timer here to persist across events
};
let elements = [element];
if (targetSelector) {
elements = document.querySelectorAll(targetSelector);
}
elements.forEach((el) => {
el.scrollStatus = { currentPos: 0 };
});
// this.__runScrollEvent(element, scrollInfo);
let scrollableElements;
if (scrollSelector)
scrollableElements = document.querySelectorAll(scrollSelector);
else if (element.hasAttribute("scroll-element"))
scrollableElements = [element];
if (scrollableElements) {
for (let scrollableEl of scrollableElements) {
scrollableEl.addEventListener("scroll", function (event) {
self._scrollEvent(
elements,
element,
scrollInfo,
scrollableEl
);
});
}
} else {
// this.WindowInit = true;
window.addEventListener("scroll", function (event) {
self._scrollEvent(elements, element, scrollInfo);
});
}
if (intersectValue && window.IntersectionObserver && this.observer) {
this.observer.observe(element);
}
if (scrollTo) {
this.setScrollPosition(element, scrollTo);
}
},
_scrollEvent: function (elements, element, scrollInfo, scrollableEl) {
const self = this;
if (!element.scrollStatus) return;
let scrollEl = scrollableEl || window;
if (
Math.abs(
scrollEl.scrollTop ||
scrollEl.scrollY - element.scrollStatus.currentPos
) <= self.delta
) {
return;
}
// Fix: Use the timer stored in scrollInfo so it persists
if (scrollInfo.timer != null) {
clearTimeout(scrollInfo.timer);
}
elements.forEach((el) => {
self.__setScrolling(el, scrollInfo, false);
self.__runScrollEvent(el, scrollInfo, scrollableEl);
});
scrollInfo.timer = setTimeout(function () {
elements.forEach((el) => {
self.__setScrolling(el, scrollInfo, true);
});
}, 500);
},
setScrollPosition: function (element, scrollTo) {
if (!scrollTo) return;
if (scrollTo.includes("top")) {
element.scrollTop = 0;
} else if (scrollTo.includes("bottom")) {
element.scrollTop = element.scrollHeight;
}
if (scrollTo.includes("left")) {
element.scrollLeft = 0;
} else if (scrollTo.includes("right")) {
element.scrollLeft = element.scrollWidth;
}
},
__initIntersectionObserver: function () {
const self = this;
this.observer = new IntersectionObserver((entries) => {
// Deduplicate: Keep only the latest entry for each target element
const uniqueEntries = new Map();
entries.forEach((entry) => {
uniqueEntries.set(entry.target, entry);
});
// Process only the unique, latest entries
uniqueEntries.forEach((entry) => {
let element = entry.target;
const attrName =
element.getAttribute("scroll-attribute") || "class";
const targetSelector = element.getAttribute("scroll-query");
const intersectValue = element.getAttribute("scroll-intersect");
let targetElements = [element];
if (targetSelector) {
targetElements = document.querySelectorAll(targetSelector);
}
if (entry.isIntersecting) {
targetElements.forEach((el) =>
self.__addAttributeValue(el, attrName, intersectValue)
);
} else {
targetElements.forEach((el) =>
self.__removeAttributeValue(el, attrName, intersectValue)
);
}
});
});
},
__setScrolling: function (element, info, stopped = false) {
const { scrolling, attrName } = info;
if (stopped) {
this.__removeAttributeValue(element, attrName, scrolling);
} else {
this.__addAttributeValue(element, attrName, scrolling);
}
},
__runScrollEvent: function (element, info, scrollableEl) {
if (!element.scrollStatus) return;
const currentPos = element.scrollStatus.currentPos;
let scrollY, scrollHeight, innerHeight;
if (scrollableEl) {
scrollY = scrollableEl.scrollTop;
scrollHeight = scrollableEl.scrollHeight;
innerHeight = scrollableEl.clientHeight;
} else {
scrollY = window.scrollY;
scrollHeight = document.body.scrollHeight;
innerHeight = window.innerHeight;
}
const {
upSize,
downSize,
attrName,
values,
scrollTop,
scrollBottom,
scrollLimbo
} = info;
let newTime = new Date().getTime();
if ((values && !info.datetime) || newTime - info.datetime > 200) {
info["datetime"] = newTime;
if (upSize <= currentPos - scrollY) {
this.__addAttributeValue(element, attrName, values[0]);
this.__removeAttributeValue(element, attrName, values[1]);
} else if (downSize <= scrollY - currentPos) {
this.__removeAttributeValue(element, attrName, values[0]);
this.__addAttributeValue(element, attrName, values[1]);
}
}
//. scroll top case
if (scrollY <= this.delta) {
if (values) {
this.__removeAttributeValue(element, attrName, values[0]);
this.__removeAttributeValue(element, attrName, values[1]);
}
this.__addAttributeValue(element, attrName, scrollTop);
} else {
this.__removeAttributeValue(element, attrName, scrollTop);
}
//. scroll bottom case
// if ((window.innerHeight + scrollY) >= document.body.scrollHeight) {
if (innerHeight + scrollY >= scrollHeight) {
// this.__removeAttributeValue(element, attrName, values[0]);
// this.__removeAttributeValue(element, attrName, values[1]);
this.__addAttributeValue(element, attrName, scrollBottom);
} else {
this.__removeAttributeValue(element, attrName, scrollBottom);
}
// if (scrollY != 0 && (scrollY + window.innerHeight) != document.body.scrollHeight){
if (scrollY != 0 && scrollY + innerHeight != scrollHeight) {
this.__addAttributeValue(element, attrName, scrollLimbo);
} else {
this.__removeAttributeValue(element, attrName, scrollLimbo);
}
element.scrollStatus.currentPos = scrollY;
},
__addAttributeValue: function (element, attrName, value) {
if (!value) return;
// Optimization: Use classList if the attribute is 'class'
if (attrName === "class") {
element.classList.add(value);
return;
}
let check = new RegExp("(\\s|^)" + value + "(\\s|$)");
let attrValue = element.getAttribute(attrName) || "";
if (!check.test(attrValue)) {
// Logic note: For non-class attributes, we append with a space
// This mimics class-like behavior for custom attributes
if (attrValue.length > 0) attrValue += " " + value;
else attrValue = value;
element.setAttribute(attrName, attrValue);
}
},
__removeAttributeValue: function (element, attrName, value) {
if (!value) return;
// Optimization: Use classList if the attribute is 'class'
if (attrName === "class") {
element.classList.remove(value);
return;
}
let check = new RegExp("(\\s|^)" + value + "(\\s|$)");
let attrValue = element.getAttribute(attrName) || "";
if (check.test(attrValue)) {
attrValue = attrValue.replace(check, " ").trim();
element.setAttribute(attrName, attrValue);
}
},
__getSize: function (attrValue, isWidth) {
let size = 0;
if (!attrValue) {
return 0;
}
if (attrValue.includes("%")) {
size = attrValue.replace("%", "").trim();
size = Number(size) || 0;
// Fix: Logic was inverted (dividing screen by size).
// Changed to standard percentage calculation: size% of screen.
size = isWidth
? window.innerWidth * (size / 100)
: window.innerHeight * (size / 100);
} else {
size = attrValue.replace("px", "").trim();
size = Number(size) || 0;
}
return size;
}
};
function updateScrollableAttributes(element) {
if (element.hasAttribute("scrollable-y")) {
if (element.scrollWidth > element.clientWidth)
element.setAttribute("scrollable-y", "true");
else element.setAttribute("scrollable-y", "false");
}
if (element.hasAttribute("scrollable-x")) {
if (element.scrollHeight > element.clientHeight)
element.setAttribute("scrollable-x", "true");
else element.setAttribute("scrollable-x", "false");
}
}
Observer.init({
name: "CoCreateScrollCreate",
types: ["addedNodes"],
selector: selector,
callback: function (mutation) {
CoCreateScroll.initElement(mutation.target);
}
});
Observer.init({
name: "CoCreateScrollAttributes",
types: ["attributes"],
attributeFilter: ["scroll-to"],
// target: selector, // blocks mutations when applied
callback: function (mutation) {
CoCreateScroll.setScrollPosition(
mutation.target,
mutation.target.getAttribute("scroll-to")
);
}
});
Observer.init({
name: "CoCreateScrollAttributes",
types: ["attributes"],
attributeFilter: ["scrollable-x", "scrollable-y"],
// target: selector, // blocks mutations when applied
callback: function (mutation) {
if (
mutation.oldValue !==
mutation.target.getAttribute(mutation.attributeName)
)
updateScrollableAttributes(mutation.target);
}
});
Actions.init({
name: "scroll-to",
callback: (action) => {
// CoCreateScroll.setScrollPosition(mutation.target)
}
});
CoCreateScroll.init();
export default CoCreateScroll;