Files
bitvid/js/blog/carousel.js
T

232 lines
6.2 KiB
JavaScript

import { userLogger } from "../utils/logger.js";
function toArray(value) {
if (!value) {
return [];
}
return Array.isArray(value) ? value : Array.from(value);
}
const SLIDE_STATE_IDLE = 'idle';
const SLIDE_STATE_ACTIVE = 'active';
const SLIDE_STATE_LEAVING = 'leaving';
const SLIDE_TRANSITION_TIMEOUT = 420;
function getRange(pageIndex, perPage) {
const start = pageIndex * perPage;
return { start, end: start + perPage };
}
function clearTimer(state, slide) {
const handle = state.timers.get(slide);
if (typeof handle === 'number') {
clearTimeout(handle);
}
state.timers.delete(slide);
}
function scheduleSlideIdle(state, slide) {
clearTimer(state, slide);
const handle = setTimeout(() => {
state.timers.delete(slide);
if (slide.dataset.active !== 'true') {
slide.dataset.state = SLIDE_STATE_IDLE;
}
}, SLIDE_TRANSITION_TIMEOUT);
state.timers.set(slide, handle);
}
function applySlideState(state, slide, { isActive, wasActive, immediate }) {
if (isActive) {
clearTimer(state, slide);
slide.dataset.active = 'true';
slide.dataset.state = SLIDE_STATE_ACTIVE;
slide.dataset.inert = 'false';
slide.setAttribute('aria-hidden', 'false');
return;
}
slide.dataset.active = 'false';
slide.dataset.inert = 'true';
slide.setAttribute('aria-hidden', 'true');
if (!immediate && wasActive) {
slide.dataset.state = SLIDE_STATE_LEAVING;
scheduleSlideIdle(state, slide);
return;
}
clearTimer(state, slide);
slide.dataset.state = SLIDE_STATE_IDLE;
}
function updateSlides(state, { previousIndex, immediate = false } = {}) {
const currentRange = getRange(state.index, state.perPage);
const previousRange =
typeof previousIndex === 'number' ? getRange(previousIndex, state.perPage) : currentRange;
state.slides.forEach((slide, slideIndex) => {
const isActive = slideIndex >= currentRange.start && slideIndex < currentRange.end;
const wasActive = slideIndex >= previousRange.start && slideIndex < previousRange.end;
applySlideState(state, slide, { isActive, wasActive, immediate });
});
}
function syncTransitionMode(container, track, mode) {
if (!container || !track) {
return;
}
if (mode) {
container.dataset.transition = mode;
track.dataset.transition = mode;
} else {
delete container.dataset.transition;
delete track.dataset.transition;
}
}
export function createCarousel(container, options = {}) {
const track = options.track || container?.querySelector('[data-carousel-track]');
const slides = options.slides || toArray(track?.children);
if (!container || !track || slides.length === 0) {
return null;
}
const perPage = Math.max(1, Math.min(options.perPage || 1, slides.length));
const pageCount = Math.max(1, Math.ceil(slides.length / perPage));
const state = {
container,
track,
slides,
perPage,
pageCount,
loop: Boolean(options.loop),
autoPlayInterval: Math.max(0, options.autoPlayInterval || 0),
index: 0,
progress: null,
cleanup: [],
timers: new Map(),
setProgress(progressInstance) {
this.progress = progressInstance || null;
},
};
container.dataset.count = String(pageCount);
container.dataset.perPage = String(perPage);
container.dataset.index = '0';
container.dataset.state = pageCount > 1 ? 'active' : 'idle';
syncTransitionMode(container, track, 'auto');
slides.forEach((slide, slideIndex) => {
slide.dataset.index = String(slideIndex);
slide.dataset.active = 'false';
slide.dataset.state = SLIDE_STATE_IDLE;
slide.dataset.inert = 'true';
slide.setAttribute('aria-hidden', 'true');
});
function handlePrev(event) {
event?.preventDefault?.();
advanceSlide(state, -1);
}
function handleNext(event) {
event?.preventDefault?.();
advanceSlide(state, 1);
}
if (options.controls?.prev) {
options.controls.prev.addEventListener('click', handlePrev);
state.cleanup.push(() => options.controls.prev.removeEventListener('click', handlePrev));
}
if (options.controls?.next) {
options.controls.next.addEventListener('click', handleNext);
state.cleanup.push(() => options.controls.next.removeEventListener('click', handleNext));
}
state.goTo = function goTo(targetPage, { restartProgress = true, instant = false } = {}) {
const maxIndex = state.pageCount - 1;
let nextIndex = targetPage;
if (nextIndex < 0) {
nextIndex = state.loop ? maxIndex : 0;
} else if (nextIndex > maxIndex) {
nextIndex = state.loop ? 0 : maxIndex;
}
const previousIndex = state.index;
state.index = nextIndex;
container.dataset.index = String(nextIndex);
syncTransitionMode(container, track, instant ? 'instant' : 'auto');
updateSlides(state, { previousIndex, immediate: instant });
if (instant) {
syncTransitionMode(container, track, 'auto');
}
if (restartProgress && state.autoPlayInterval > 0 && state.progress) {
state.progress.reset();
state.progress.start(state.autoPlayInterval);
}
};
state.goTo(0, { restartProgress: false, instant: true });
return state;
}
export function advanceSlide(state, step = 1, options = {}) {
if (!state || typeof state.goTo !== 'function') {
return;
}
state.goTo(state.index + step, options);
}
export function destroyCarousel(state) {
if (!state) {
return;
}
state.cleanup?.forEach((dispose) => {
try {
dispose();
} catch (error) {
userLogger.warn('Failed to dispose carousel listener', error);
}
});
state.cleanup = [];
for (const handle of state.timers.values()) {
clearTimeout(handle);
}
state.timers.clear();
if (state.progress && typeof state.progress.destroy === 'function') {
state.progress.destroy();
}
if (state.container) {
delete state.container.dataset.index;
delete state.container.dataset.count;
delete state.container.dataset.perPage;
delete state.container.dataset.state;
delete state.container.dataset.transition;
}
if (state.track) {
delete state.track.dataset.transition;
}
state.slides?.forEach((slide) => {
delete slide.dataset.active;
delete slide.dataset.state;
delete slide.dataset.inert;
slide.setAttribute('aria-hidden', 'false');
});
}