@evermade/overflow-slider
Version:
Accessible slider that is powered by overflow: auto.
646 lines (641 loc) • 28.3 kB
JavaScript
function details(slider) {
var _a;
let instance;
let hasOverflow = false;
let slideCount = 0;
let containerWidth = 0;
let containerHeight = 0;
let scrollableAreaWidth = 0;
let amountOfPages = 0;
let currentPage = 1;
if (Math.floor(slider.getInclusiveScrollWidth()) > Math.floor(slider.getInclusiveClientWidth())) {
hasOverflow = true;
}
slideCount = (_a = slider.slides.length) !== null && _a !== void 0 ? _a : 0;
containerWidth = slider.container.offsetWidth;
containerHeight = slider.container.offsetHeight;
scrollableAreaWidth = slider.getInclusiveScrollWidth();
amountOfPages = Math.ceil(scrollableAreaWidth / containerWidth);
if (Math.floor(slider.getScrollLeft()) >= 0) {
currentPage = Math.floor(slider.getScrollLeft() / containerWidth);
// consider as last page if the scrollLeft + containerWidth is equal to scrollWidth
if (Math.floor(slider.getScrollLeft() + containerWidth) === Math.floor(scrollableAreaWidth)) {
currentPage = amountOfPages - 1;
}
}
instance = {
hasOverflow,
slideCount,
containerWidth,
containerHeight,
scrollableAreaWidth,
amountOfPages,
currentPage,
};
return instance;
}
function generateId(prefix, i = 1) {
const id = `${prefix}-${i}`;
if (document.getElementById(id)) {
return generateId(prefix, i + 1);
}
return id;
}
function objectsAreEqual(obj1, obj2) {
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) {
return false;
}
for (let key of keys1) {
// Use `Object.prototype.hasOwnProperty.call` for better safety
if (!Object.prototype.hasOwnProperty.call(obj2, key) || obj1[key] !== obj2[key]) {
return false;
}
}
return true;
}
function getOutermostChildrenEdgeMarginSum(el) {
if (el.children.length === 0) {
return 0;
}
// get the first child and its left margin
const firstChild = el.children[0];
const firstChildStyle = getComputedStyle(firstChild);
const firstChildMarginLeft = parseFloat(firstChildStyle.marginLeft);
// Get the last child and its right margin
const lastChild = el.children[el.children.length - 1];
const lastChildStyle = getComputedStyle(lastChild);
const lastChildMarginRight = parseFloat(lastChildStyle.marginRight);
return firstChildMarginLeft + lastChildMarginRight;
}
function Slider(container, options, plugins) {
let slider;
let subs = {};
const overrideTransitions = () => {
slider.slides.forEach((slide) => {
slide.style.transition = 'none';
});
};
const restoreTransitions = () => {
slider.slides.forEach((slide) => {
slide.style.removeProperty('transition');
});
};
function init() {
slider.container = container;
// ensure container has id
let containerId = container.getAttribute('id');
if (containerId === null) {
containerId = generateId('overflow-slider');
container.setAttribute('id', containerId);
}
setSlides();
// CSS transitions can cause delays for calculations
overrideTransitions();
setDetails(true);
setActiveSlideIdx();
slider.on('contentsChanged', () => {
setSlides();
setDetails();
setActiveSlideIdx();
});
slider.on('containerSizeChanged', () => setDetails());
let requestId = 0;
const setDetailsDebounce = () => {
if (requestId) {
window.cancelAnimationFrame(requestId);
}
requestId = window.requestAnimationFrame(() => {
setDetails();
setActiveSlideIdx();
});
};
slider.on('scroll', setDetailsDebounce);
addEventListeners();
setDataAttributes();
setCSSVariables();
if (plugins) {
for (const plugin of plugins) {
plugin(slider);
}
// plugins may mutate layout: refresh details and derived data after they run
// setTimeout( () => {
setDetails();
setActiveSlideIdx();
setCSSVariables();
slider.emit('pluginsLoaded');
// }, 250 );
}
slider.on('detailsChanged', () => {
setDataAttributes();
setCSSVariables();
});
slider.emit('created');
restoreTransitions();
slider.container.setAttribute('data-ready', 'true');
}
function setDetails(isInit = false) {
const oldDetails = slider.details;
const newDetails = details(slider);
slider.details = newDetails;
if (!isInit && !objectsAreEqual(oldDetails, newDetails)) {
slider.emit('detailsChanged');
}
else if (isInit) {
slider.emit('detailsChanged');
}
}
function setSlides() {
slider.slides = Array.from(slider.container.querySelectorAll(slider.options.slidesSelector));
}
function addEventListeners() {
// changes to DOM
const observer = new MutationObserver(() => slider.emit('contentsChanged'));
observer.observe(slider.container, { childList: true });
// container size changes
const resizeObserver = new ResizeObserver(() => slider.emit('containerSizeChanged'));
resizeObserver.observe(slider.container);
// scroll event with debouncing
let scrollTimeout;
let nativeScrollTimeout;
let programmaticScrollTimeout;
let scrollLeft = slider.container.scrollLeft;
let nativeScrollLeft = slider.container.scrollLeft;
let programmaticScrollLeft = slider.container.scrollLeft;
let isScrolling = false;
let isUserScrolling = false;
let isProgrammaticScrolling = false;
// all types of scroll
slider.container.addEventListener('scroll', () => {
const newScrollLeft = slider.container.scrollLeft;
if (Math.floor(scrollLeft) !== Math.floor(newScrollLeft)) {
if (!isScrolling) {
isScrolling = true;
slider.emit('scrollStart');
}
scrollLeft = newScrollLeft;
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
isScrolling = false;
slider.emit('scrollEnd');
}, 50);
slider.emit('scroll');
}
// keep up nativeScrolling to take into account scroll-snap
if (isUserScrolling) {
nativeScrollHandler();
}
});
// user initted scroll (touchmove, mouse wheel, etc.)
const nativeScrollHandler = () => {
const newScrollLeft = slider.container.scrollLeft;
if (Math.floor(nativeScrollLeft) !== Math.floor(newScrollLeft) && !isProgrammaticScrolling) {
if (!isUserScrolling) {
slider.emit('nativeScrollStart');
isUserScrolling = true;
}
slider.emit('nativeScroll');
nativeScrollLeft = newScrollLeft;
clearTimeout(nativeScrollTimeout);
nativeScrollTimeout = setTimeout(() => {
isUserScrolling = false;
slider.emit('nativeScrollEnd');
// update programmaticScrollLeft to match nativeScrollLeft
// this prevents programmaticScroll triggering with no real change to scrollLeft
programmaticScrollLeft = nativeScrollLeft;
}, 50);
}
};
slider.container.addEventListener('touchmove', nativeScrollHandler);
slider.container.addEventListener('mousewheel', nativeScrollHandler);
slider.container.addEventListener('wheel', nativeScrollHandler);
// programmatic scroll (scrollTo, etc.)
slider.on('programmaticScrollStart', () => {
isProgrammaticScrolling = true;
});
slider.container.addEventListener('scroll', () => {
const newScrollLeft = slider.container.scrollLeft;
if (Math.floor(programmaticScrollLeft) !== Math.floor(newScrollLeft) && !isUserScrolling && isProgrammaticScrolling) {
programmaticScrollLeft = newScrollLeft;
clearTimeout(programmaticScrollTimeout);
programmaticScrollTimeout = setTimeout(() => {
isProgrammaticScrolling = false;
slider.emit('programmaticScrollEnd');
// update nativeScrollLeft to match programmaticScrollLeft
// this prevents nativeScroll triggering with no real change to scrollLeft
nativeScrollLeft = programmaticScrollLeft;
}, 50);
slider.emit('programmaticScroll');
}
});
// Fix issues on scroll snapping not working on programmatic scroll (it's not smooth)
// by disabling scroll snap if scrolling is programmatic
slider.on('programmaticScrollStart', () => {
slider.container.style.scrollSnapType = 'none';
});
// restore scroll snap if user scroll starts
slider.on('nativeScrollStart', () => {
slider.container.style.scrollSnapType = '';
});
// Listen for mouse down and touch start events on the document
// This handles both mouse clicks and touch interactions
let wasInteractedWith = false;
slider.container.addEventListener('mousedown', () => {
wasInteractedWith = true;
});
slider.container.addEventListener('touchstart', () => {
wasInteractedWith = true;
}, { passive: true });
slider.container.addEventListener('focusin', (e) => {
// move target parents as long as they are not the container
// but only if focus didn't start from mouse or touch
if (!wasInteractedWith) {
let target = e.target;
while (target.parentElement !== slider.container) {
if (target.parentElement) {
target = target.parentElement;
}
else {
break;
}
}
ensureSlideIsInView(target, 'auto');
}
wasInteractedWith = false;
});
}
function setCSSVariables() {
slider.options.cssVariableContainer.style.setProperty('--slider-container-height', `${slider.details.containerHeight}px`);
slider.options.cssVariableContainer.style.setProperty('--slider-container-width', `${slider.details.containerWidth}px`);
slider.options.cssVariableContainer.style.setProperty('--slider-scrollable-width', `${slider.details.scrollableAreaWidth}px`);
slider.options.cssVariableContainer.style.setProperty('--slider-slides-count', `${slider.details.slideCount}`);
slider.options.cssVariableContainer.style.setProperty('--slider-x-offset', `${getLeftOffset()}px`);
if (typeof slider.options.targetWidth === 'function') {
slider.options.cssVariableContainer.style.setProperty('--slider-container-target-width', `${slider.options.targetWidth(slider)}px`);
}
}
function setDataAttributes() {
slider.container.setAttribute('data-has-overflow', slider.details.hasOverflow ? 'true' : 'false');
if (slider.options.rtl) {
slider.container.setAttribute('dir', 'rtl');
}
}
function ensureSlideIsInView(slide, scrollBehavior = null) {
const behavior = scrollBehavior || slider.options.scrollBehavior;
const slideRect = slide.getBoundingClientRect();
const sliderRect = slider.container.getBoundingClientRect();
const containerWidth = slider.container.offsetWidth;
const scrollLeft = slider.container.scrollLeft;
const slideStart = slideRect.left - sliderRect.left + scrollLeft;
const slideEnd = slideStart + slideRect.width;
let scrollTarget = null;
if (Math.floor(slideStart) < Math.floor(scrollLeft)) {
scrollTarget = slideStart;
}
else if (Math.floor(slideEnd) > Math.floor(scrollLeft) + Math.floor(containerWidth)) {
scrollTarget = slideEnd - containerWidth;
}
else if (Math.floor(slideStart) === 0) {
scrollTarget = 0;
}
else {
scrollTarget = slideStart;
}
if (scrollTarget !== null) {
setTimeout((scrollTarget) => {
slider.emit('programmaticScrollStart');
slider.container.scrollTo({ left: scrollTarget, behavior: behavior });
}, 50, scrollTarget);
}
}
function setActiveSlideIdx() {
const sliderRect = slider.container.getBoundingClientRect();
const scrollLeft = slider.getScrollLeft();
const slides = slider.slides;
let activeSlideIdx = 0;
let scrolledPastLastSlide = false;
if (slider.options.rtl) {
const scrolledDistance = slider.getInclusiveScrollWidth() - scrollLeft - slider.getInclusiveClientWidth();
const slidePositions = [];
for (let i = slides.length - 1; i >= 0; i--) {
const slideRect = slides[i].getBoundingClientRect();
const slideEnd = Math.abs(slideRect.left) - Math.abs(sliderRect.left) + scrolledDistance;
slidePositions.push({
slide: slides[i],
slideEnd: slideEnd,
});
}
let closestSlide = null;
let closestDistance = null;
for (let i = 0; i < slidePositions.length; i++) {
const distance = Math.abs(slidePositions[i].slideEnd - scrolledDistance);
if (closestDistance === null || distance < closestDistance) {
closestDistance = distance;
closestSlide = slidePositions[i].slide;
}
}
if (closestSlide) {
activeSlideIdx = slides.indexOf(closestSlide);
}
else {
activeSlideIdx = slides.length - 1;
}
}
else {
for (let i = 0; i < slides.length; i++) {
const slideRect = slides[i].getBoundingClientRect();
const slideStart = slideRect.left - sliderRect.left + scrollLeft + getGapSize();
if (Math.floor(slideStart) >= Math.floor(scrollLeft)) {
activeSlideIdx = i;
break;
}
if (i === slides.length - 1) {
scrolledPastLastSlide = true;
}
}
}
if (scrolledPastLastSlide) {
activeSlideIdx = slides.length - 1;
}
const oldActiveSlideIdx = slider.activeSlideIdx;
slider.activeSlideIdx = activeSlideIdx;
if (oldActiveSlideIdx !== activeSlideIdx) {
slider.emit('activeSlideChanged');
}
}
function moveToSlide(idx) {
const slide = slider.slides[idx];
if (slide) {
ensureSlideIsInView(slide);
}
}
function canMoveToSlide(idx) {
if (idx < 0 || idx >= slider.slides.length) {
return false;
}
if (idx === slider.activeSlideIdx) {
return false;
}
const direction = slider.options.rtl ? (idx < slider.activeSlideIdx ? 'backwards' : 'forwards') : (idx < slider.activeSlideIdx ? 'backwards' : 'forwards');
// check if the slide is already in view
const sliderRect = slider.container.getBoundingClientRect();
const scrollLeft = slider.getScrollLeft();
const containerWidth = slider.details.containerWidth;
const hasUpcomingContent = slider.slides.some((s, i) => {
if (i === slider.activeSlideIdx) {
return false; // skip the slide we are checking
}
const sRect = s.getBoundingClientRect();
const sStart = sRect.left - sliderRect.left + scrollLeft;
const sEnd = sStart + sRect.width;
if (slider.options.rtl) {
if (scrollLeft === 0 && slider.details.hasOverflow) {
return true;
}
return (direction === 'forwards' && i > slider.activeSlideIdx && Math.floor(sStart) < Math.floor(scrollLeft)) ||
(direction === 'backwards' && i < slider.activeSlideIdx && Math.floor(sEnd) > Math.floor(scrollLeft + containerWidth));
}
else {
return (direction === 'forwards' && i > slider.activeSlideIdx && Math.floor(sEnd) > Math.floor(scrollLeft + containerWidth)) ||
(direction === 'backwards' && i < slider.activeSlideIdx && Math.floor(sStart) < Math.floor(scrollLeft));
}
});
return hasUpcomingContent;
}
function moveToSlideInDirection(direction) {
const activeSlideIdx = slider.activeSlideIdx;
if (direction === 'prev') {
if (activeSlideIdx > 0) {
moveToSlide(activeSlideIdx - 1);
}
}
else if (direction === 'next') {
if (activeSlideIdx < slider.slides.length - 1) {
moveToSlide(activeSlideIdx + 1);
}
}
}
function getInclusiveScrollWidth() {
return slider.container.scrollWidth + getOutermostChildrenEdgeMarginSum(slider.container);
}
function getInclusiveClientWidth() {
return slider.container.clientWidth + getOutermostChildrenEdgeMarginSum(slider.container);
}
function getScrollLeft() {
return slider.options.rtl ? Math.abs(slider.container.scrollLeft) : slider.container.scrollLeft;
}
function setScrollLeft(value) {
slider.container.scrollLeft = slider.options.rtl ? -value : value;
}
function getGapSize() {
let gapSize = 0;
if (slider.slides.length > 1) {
const firstSlideRect = slider.slides[0].getBoundingClientRect();
const secondSlideRect = slider.slides[1].getBoundingClientRect();
gapSize = slider.options.rtl ? Math.abs(Math.floor(secondSlideRect.right - firstSlideRect.left)) : Math.floor(secondSlideRect.left - firstSlideRect.right);
}
return gapSize;
}
function getLeftOffset() {
let offset = 0;
const fullWidthOffset = slider.container.getAttribute('data-full-width-offset');
if (fullWidthOffset) {
offset = parseInt(fullWidthOffset);
}
return Math.floor(offset);
}
function moveToDirection(direction = "prev") {
const scrollStrategy = slider.options.scrollStrategy;
const scrollLeft = slider.container.scrollLeft;
const sliderRect = slider.container.getBoundingClientRect();
const containerWidth = slider.container.offsetWidth;
let targetScrollPosition = scrollLeft;
const realDirection = slider.options.rtl ? (direction === 'prev' ? 'next' : 'prev') : direction;
if (realDirection === 'prev') {
targetScrollPosition = Math.max(0, scrollLeft - slider.container.offsetWidth);
}
else if (realDirection === 'next') {
targetScrollPosition = Math.min(slider.getInclusiveScrollWidth(), scrollLeft + slider.container.offsetWidth);
}
if (scrollStrategy === 'fullSlide') {
let fullSlideTargetScrollPosition = null;
// extend targetScrollPosition to include gap
if (realDirection === 'prev') {
fullSlideTargetScrollPosition = Math.max(0, targetScrollPosition - getGapSize());
}
else {
fullSlideTargetScrollPosition = Math.min(slider.getInclusiveScrollWidth(), targetScrollPosition + getGapSize());
}
if (realDirection === 'next') {
let partialSlideFound = false;
for (let slide of slider.slides) {
const slideRect = slide.getBoundingClientRect();
const slideStart = slideRect.left - sliderRect.left + scrollLeft;
const slideEnd = slideStart + slideRect.width;
if (Math.floor(slideStart) < Math.floor(targetScrollPosition) && Math.floor(slideEnd) > Math.floor(targetScrollPosition)) {
fullSlideTargetScrollPosition = slideStart;
partialSlideFound = true;
break;
}
}
if (!partialSlideFound) {
fullSlideTargetScrollPosition = Math.min(targetScrollPosition, slider.getInclusiveScrollWidth() - slider.container.offsetWidth);
}
if (fullSlideTargetScrollPosition) {
if (Math.floor(fullSlideTargetScrollPosition) > Math.floor(scrollLeft)) {
// make sure fullSlideTargetScrollPosition is possible considering the container width
const maxScrollPosition = Math.floor(slider.getInclusiveScrollWidth()) - Math.floor(containerWidth);
targetScrollPosition = Math.min(fullSlideTargetScrollPosition, maxScrollPosition);
}
else {
// cannot snap to slide, move one page worth of distance
targetScrollPosition = Math.min(slider.getInclusiveScrollWidth(), scrollLeft + containerWidth);
}
}
}
else {
let partialSlideFound = false;
for (let slide of slider.slides) {
const slideRect = slide.getBoundingClientRect();
const slideStart = slideRect.left - sliderRect.left + scrollLeft;
const slideEnd = slideStart + slideRect.width;
if (Math.floor(slideStart) < Math.floor(scrollLeft) && Math.floor(slideEnd) > Math.floor(scrollLeft)) {
fullSlideTargetScrollPosition = slideEnd - containerWidth;
partialSlideFound = true;
break;
}
}
if (!partialSlideFound) {
fullSlideTargetScrollPosition = Math.max(0, scrollLeft - containerWidth);
}
if (fullSlideTargetScrollPosition && Math.floor(fullSlideTargetScrollPosition) < Math.floor(scrollLeft)) {
targetScrollPosition = fullSlideTargetScrollPosition;
}
}
}
// add left offset
const offsettedTargetScrollPosition = targetScrollPosition - getLeftOffset();
if (Math.floor(offsettedTargetScrollPosition) >= 0) {
targetScrollPosition = offsettedTargetScrollPosition;
}
slider.emit('programmaticScrollStart');
slider.container.style.scrollBehavior = slider.options.scrollBehavior;
slider.container.scrollLeft = targetScrollPosition;
setTimeout(() => slider.container.style.scrollBehavior = '', 50);
}
function snapToClosestSlide(direction = "prev") {
var _a, _b;
const { slides, options, container } = slider;
const { rtl, emulateScrollSnapMaxThreshold = 10, scrollBehavior = 'smooth', } = options;
const isForward = rtl ? direction === 'prev' : direction === 'next';
const scrollPos = getScrollLeft();
// Get container rect once (includes any CSS transforms)
const containerRect = container.getBoundingClientRect();
const factor = rtl ? -1 : 1;
// Calculate target area offset if targetWidth is defined
let targetAreaOffset = 0;
if (typeof options.targetWidth === 'function') {
try {
const targetWidth = options.targetWidth(slider);
const containerWidth = containerRect.width;
if (Number.isFinite(targetWidth) && targetWidth > 0 && targetWidth < containerWidth) {
targetAreaOffset = (containerWidth - targetWidth) / 2;
}
}
catch (error) {
// ignore errors, use default offset of 0
}
}
// Build slide metadata
const slideData = [...slides].map(slide => {
const { width } = slide.getBoundingClientRect();
const slideRect = slide.getBoundingClientRect();
// position relative to container's left edge
const relativeStart = (slideRect.left - containerRect.left) + scrollPos;
// Adjust trigger point to align with target area start instead of container edge
const alignmentPoint = relativeStart - targetAreaOffset;
const triggerPoint = Math.min(alignmentPoint + width / 2, alignmentPoint + emulateScrollSnapMaxThreshold);
return { start: relativeStart - targetAreaOffset, trigger: triggerPoint };
});
// Pick the target start based on drag direction
let targetStart = null;
if (isForward) {
const found = slideData.find(item => scrollPos <= item.trigger);
targetStart = (_a = found === null || found === void 0 ? void 0 : found.start) !== null && _a !== void 0 ? _a : null;
}
else {
const found = [...slideData].reverse().find(item => scrollPos >= item.trigger);
targetStart = (_b = found === null || found === void 0 ? void 0 : found.start) !== null && _b !== void 0 ? _b : null;
}
if (targetStart == null)
return;
// Clamp to zero and apply RTL factor
const finalLeft = Math.max(0, Math.floor(targetStart)) * factor;
container.scrollTo({ left: finalLeft, behavior: scrollBehavior });
}
function on(name, cb) {
if (!subs[name]) {
subs[name] = [];
}
subs[name].push(cb);
}
function emit(name) {
var _a;
if (subs && subs[name]) {
subs[name].forEach(cb => {
cb(slider);
});
}
const optionCallBack = (_a = slider === null || slider === void 0 ? void 0 : slider.options) === null || _a === void 0 ? void 0 : _a[name];
// Type guard to check if the option callback is a function
if (typeof optionCallBack === 'function') {
optionCallBack(slider); // Type assertion here
}
}
slider = {
emit,
moveToDirection,
canMoveToSlide,
moveToSlide,
moveToSlideInDirection,
snapToClosestSlide,
getInclusiveScrollWidth,
getInclusiveClientWidth,
getScrollLeft,
setScrollLeft,
setActiveSlideIdx,
on,
options,
};
init();
return slider;
}
function OverflowSlider(container, options, plugins) {
try {
// check that container HTML element
if (!(container instanceof Element)) {
throw new Error(`Container must be HTML element, found ${typeof container}`);
}
const defaults = {
cssVariableContainer: container,
scrollBehavior: "smooth",
scrollStrategy: "fullSlide",
slidesSelector: ":scope > *",
emulateScrollSnap: false,
emulateScrollSnapMaxThreshold: 64,
rtl: false,
};
const sliderOptions = Object.assign(Object.assign({}, defaults), options);
// disable smooth scrolling if user prefers reduced motion
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
sliderOptions.scrollBehavior = "auto";
}
return Slider(container, sliderOptions, plugins);
}
catch (e) {
console.error(e);
}
}
export { OverflowSlider };
//# sourceMappingURL=index.esm.js.map