omni-carousel
Version:
A lightweight carousel to enhance scrollable areas
1,426 lines (1,393 loc) • 47.1 kB
JavaScript
// core/internal-config.ts
var internalConfig = {
ioRootMargin: "0px 1px 0px 1px",
ioThresholds: [0, 1],
scrollBehavior: "smooth",
scrollBlock: "nearest",
centerTolerance: 1,
scrollendTimeoutDelay: 250,
classes: {
setupComplete: "is-omni-ready",
itemCentered: "is-omni-centered",
itemGroupCentered: "is-omni-centered",
indicatorCurrent: "is-omni-current",
indicatorPartCurrent: "is-omni-part-current",
indicatorOverflowing: "is-omni-indicator-overflow",
itemCurrent: "is-omni-current",
itemPartCurrent: "is-omni-part-current",
itemEntering: "is-omni-entering",
itemExiting: "is-omni-exiting"
},
selectors: {
invisibleAnchor: ".omni-invisible-anchor"
},
dataAttributes: {
carouselInstance: "data-omni-carousel"
}
};
// core/carousel.ts
import {
createNanoEvents
} from "nanoevents";
// core/defaults.ts
var defaultSelectors = {
track: "[data-omni-track]",
slide: "[data-omni-slide]",
prevButton: "[data-omni-button-prev]",
nextButton: "[data-omni-button-next]",
startButton: "[data-omni-button-start]",
endButton: "[data-omni-button-end]",
indicatorArea: "[data-omni-indicators]",
indicator: "[data-omni-indicator]"
};
var defaults = {
scrollAlign: "start",
scrollSteps: "one",
selectors: defaultSelectors,
hasEqualWidths: true,
hasCenterMode: false,
indicatorNumbers: false,
transitionHelpers: false,
preloadAdjacentImages: false
};
// core/config.ts
var mergeConfig = (options) => {
if (!options) {
return { ...defaults };
}
const { selectors, ...rest } = options;
return {
...defaults,
...rest,
selectors: { ...defaults.selectors, ...selectors }
};
};
// dom/attributes.ts
var captureInitialAttributes = (context, initialState) => {
const { prevButton, nextButton, startButton, endButton } = context.elements;
if (initialState.buttonAttributes?.size > 0) {
return;
}
[prevButton, nextButton, startButton, endButton].filter((button) => button !== void 0).forEach(
(button) => {
initialState.buttonAttributes.set(button, {
disabled: button.hasAttribute("disabled"),
hidden: button.hasAttribute("hidden")
});
}
);
};
var setElementAttributes = (context) => {
const { root, indicators, prevButton, nextButton, startButton, endButton } = context.elements;
root.classList.add(internalConfig.classes.setupComplete);
indicators.forEach((indicator, index) => {
indicator.setAttribute("aria-label", `Go to slide ${index + 1}`);
});
[prevButton, nextButton, startButton, endButton].filter((button) => button !== void 0).forEach((button) => {
button.removeAttribute("hidden");
});
};
var resetElementAttributes = (context, initialState) => {
const { root, track } = context.elements;
const { state } = context;
root.classList.remove(internalConfig.classes.setupComplete);
initialState.buttonAttributes.forEach((initialState2, button) => {
button.toggleAttribute("disabled", initialState2.disabled);
button.toggleAttribute("hidden", initialState2.hidden);
});
if (state.addedTrackCSSPosition && initialState.trackCSSPosition !== void 0) {
track.style.position = initialState.trackCSSPosition;
state.addedTrackCSSPosition = false;
}
};
var ensureTrackPositioned = (context, initialState) => {
const { track } = context.elements;
const { state } = context;
const computedPosition = window.getComputedStyle(track).position;
if (computedPosition === "static") {
initialState.trackCSSPosition = "static";
track.style.position = "relative";
state.addedTrackCSSPosition = true;
} else {
state.addedTrackCSSPosition = false;
}
};
// dom/elements.ts
var getElements = (root, selectors) => {
const track = root.querySelector(selectors.track);
const slides = [...root.querySelectorAll(selectors.slide)];
const prevButton = root.querySelector(selectors.prevButton) || void 0;
const nextButton = root.querySelector(selectors.nextButton) || void 0;
const startButton = root.querySelector(selectors.startButton) || void 0;
const endButton = root.querySelector(selectors.endButton) || void 0;
const indicatorArea = root.querySelector(selectors.indicatorArea) || void 0;
if (!track || slides.length < 2) {
throw new Error("Carousel requires a track and at least 2 slides.");
}
return {
root,
track,
slides,
prevButton,
nextButton,
startButton,
endButton,
indicatorArea
};
};
// dom/styles.ts
var getScrollSnapAlign = (element) => {
const computedStyle = window.getComputedStyle(element);
const alignValue = computedStyle.scrollSnapAlign;
if (!alignValue) {
return void 0;
}
return alignValue.includes("center") ? "center" : "start";
};
var updateItem = (context, slide, fullIntersecting, partIntersecting, wasFullIntersecting, wasPartIntersecting) => {
const { transitionHelpers } = context.config;
const { classes } = internalConfig;
slide.classList.toggle(classes.itemCurrent, fullIntersecting);
slide.classList.toggle(classes.itemPartCurrent, partIntersecting);
if (!transitionHelpers) {
return;
}
const wasIntersecting = wasFullIntersecting || wasPartIntersecting;
if (wasFullIntersecting && partIntersecting) {
slide.classList.remove(classes.itemEntering);
slide.classList.add(classes.itemExiting);
} else if (!wasIntersecting && partIntersecting) {
slide.classList.remove(classes.itemExiting);
slide.classList.add(classes.itemEntering);
}
};
var updateCenterItem = (context) => {
const { slides } = context.elements;
const { centeredItemIndex, previousCenteredItemIndex } = context.state;
const { itemCentered } = internalConfig.classes;
if (previousCenteredItemIndex !== void 0) {
slides[previousCenteredItemIndex].classList.remove(itemCentered);
}
if (centeredItemIndex !== void 0) {
slides[centeredItemIndex].classList.add(itemCentered);
}
};
var updateCenterGroupItem = (context) => {
const { elements, state, config } = context;
const { slides } = elements;
const { previousCenteredGroupItems, centeredGroupItems } = state;
const { hasEqualWidths, scrollSteps, scrollAlign } = config;
const { itemGroupCentered } = internalConfig.classes;
if (hasEqualWidths == true || scrollAlign !== "center" || scrollSteps !== "auto") {
return;
}
previousCenteredGroupItems.forEach((index) => {
slides[index].classList.remove(itemGroupCentered);
});
centeredGroupItems.forEach((index) => {
slides[index].classList.add(itemGroupCentered);
});
};
var clearItemAttributes = (slide) => {
const { classes } = internalConfig;
slide.classList.remove(
classes.itemCurrent,
classes.itemPartCurrent,
classes.itemCentered,
classes.itemGroupCentered
);
};
// handlers/event-click.ts
var handleClick = (context, event) => {
const target = event.target;
if (!target || !(target instanceof Element)) {
return;
}
const button = target instanceof HTMLButtonElement ? target : target.closest("button");
if (!button) {
return;
}
const { eventEmitter } = context;
const { prevButton, nextButton, startButton, endButton } = context.elements;
if (button === prevButton) {
eventEmitter.emit("omni:nav:prev");
} else if (button === nextButton) {
eventEmitter.emit("omni:nav:next");
} else if (button === startButton) {
eventEmitter.emit("omni:nav:index", { index: 0 });
} else if (button === endButton) {
eventEmitter.emit("omni:nav:index", { index: context.elements.slides.length - 1 });
} else if (button.parentElement === context.elements.indicatorArea) {
const indicatorIndex = context.elements.indicators.indexOf(button);
if (indicatorIndex !== -1 && indicatorIndex < context.elements.slides.length) {
eventEmitter.emit("omni:nav:index", { index: indicatorIndex });
}
}
};
// handlers/event-keyboard.ts
var handleKeyboard = (context, event) => {
const { elements, state, eventEmitter } = context;
const { root, track } = elements;
const activeElement = document.activeElement;
if (!root.contains(activeElement) || activeElement !== track) {
return;
}
if (event.key === "ArrowLeft" && !state.startItemFullIntersecting) {
event.preventDefault();
eventEmitter.emit("omni:nav:prev");
} else if (event.key === "ArrowRight" && !state.endItemFullIntersecting) {
event.preventDefault();
eventEmitter.emit("omni:nav:next");
} else if (event.key === "Home" && !state.startItemFullIntersecting) {
event.preventDefault();
eventEmitter.emit("omni:nav:index", { index: 0 });
} else if (event.key === "End" && !state.endItemFullIntersecting) {
event.preventDefault();
eventEmitter.emit("omni:nav:index", { index: elements.slides.length - 1 });
}
};
// features/utils.ts
var getRectCenterX = (element) => {
const rect = element.getBoundingClientRect();
return rect.left + rect.width / 2;
};
var calculateCenterScrollPosition = (context, container, startItem, width) => {
const measurements = (() => {
const containerWidth = context.state.width;
const containerLeft = context.utils.getContainerLeft();
const startItemRect = startItem.getBoundingClientRect();
return {
containerWidth,
//
// Calculate the left position of the start item relative to the container
//
startLeft: startItemRect.left - containerLeft + container.scrollLeft
};
})();
const leftOffset = Math.max(0, (measurements.containerWidth - width) / 2);
return measurements.startLeft - leftOffset;
};
var getFallbackItem = (context, direction) => {
const { fullItems, partItems } = context.state;
const { slides } = context.elements;
const goingLeft = direction === "left";
if (fullItems.length > 0) {
return goingLeft ? fullItems[0] : fullItems[fullItems.length - 1];
} else if (partItems.length > 0) {
return goingLeft ? partItems[0] : partItems[partItems.length - 1];
}
return goingLeft ? slides[0] : slides[slides.length - 1];
};
// features/indicators.ts
var clearIndicators = (context) => {
const { indicatorArea } = context.elements;
if (!indicatorArea) {
return;
}
indicatorArea.replaceChildren();
};
var addIndicators = (context) => {
const { indicatorArea } = context.elements;
if (!indicatorArea) {
return [];
}
const { slides, track } = context.elements;
const { indicatorNumbers } = context.config;
const trackID = track.id;
const fragment = document.createDocumentFragment();
const slidesLength = slides.length;
const indicators = Array(slidesLength);
for (let index = 0; index < slidesLength; index++) {
const indicator = document.createElement("button");
indicator.type = "button";
indicator.setAttribute("data-omni-indicator", `${index}`);
indicator.setAttribute("aria-label", `Go to slide ${index + 1}`);
if (indicatorNumbers) {
const number = document.createElement("span");
number.textContent = `${index + 1}`;
indicator.appendChild(number);
}
if (trackID) {
indicator.setAttribute("aria-controls", trackID);
}
fragment.appendChild(indicator);
indicators[index] = indicator;
}
indicatorArea.appendChild(fragment);
return indicators;
};
var updateIndicator = (context, index, fullIntersecting, partIntersecting) => {
const indicator = context.elements.indicators[index];
const { classes } = internalConfig;
indicator.toggleAttribute("aria-current", fullIntersecting);
indicator.classList.toggle(classes.indicatorCurrent, fullIntersecting);
indicator.classList.toggle(classes.indicatorPartCurrent, partIntersecting);
};
var updateIndicatorOverflow = (context) => {
const { state } = context;
const { indicatorArea } = context.elements;
const overflow = indicatorArea ? indicatorArea.scrollWidth > indicatorArea.clientWidth : false;
const overflowChanged = state.indicatorOverflow !== overflow;
if (overflowChanged) {
state.indicatorOverflow = overflow;
context.elements.root.classList.toggle(internalConfig.classes.indicatorOverflowing, overflow);
}
return overflowChanged;
};
var centerIndicators = (context) => {
const { state } = context;
const { indicatorArea } = context.elements;
if (!indicatorArea) {
return;
}
const { indicators } = context.elements;
const currentIndicators = state.fullItems.map((slide) => {
const index = context.utils.getItemIndex(slide);
return indicators[index];
}).filter((indicator) => indicator !== void 0);
if (currentIndicators.length === 0) {
return;
}
let indicatorWidth;
let indicatorSpacing;
if (state.indicatorWidth && state.indicatorSpacing) {
indicatorWidth = state.indicatorWidth;
indicatorSpacing = state.indicatorSpacing;
} else {
const rect1 = indicators[0].getBoundingClientRect();
const rect2 = indicators[1].getBoundingClientRect();
indicatorSpacing = rect2.left - rect1.right;
indicatorWidth = rect1.width;
state.indicatorSpacing = indicatorSpacing;
state.indicatorWidth = indicatorWidth;
}
const firstItem = currentIndicators[0];
const count = currentIndicators.length;
const totalWidth = indicatorWidth * count + indicatorSpacing * (count - 1);
const scrollPosition = calculateCenterScrollPosition(
context,
indicatorArea,
firstItem,
totalWidth
);
indicatorArea.scrollTo({
left: scrollPosition,
behavior: internalConfig.scrollBehavior
});
};
var determineAlignmentForIndicator = (context, index) => {
const { fullItems, partItems } = context.state;
const { getItemIndex } = context.utils;
let lastVisibleItemIndex = -1;
if (fullItems.length > 0) {
const lastFullItem = fullItems[fullItems.length - 1];
lastVisibleItemIndex = getItemIndex(lastFullItem);
} else if (partItems.length > 0) {
const lastPartItem = partItems[partItems.length - 1];
lastVisibleItemIndex = getItemIndex(lastPartItem);
}
return index > lastVisibleItemIndex ? "end" : "start";
};
var storeIndicators = (context, indicators) => {
context.elements.indicators = indicators;
};
// handlers/event-scrollend.ts
var startIndicatorCentering = (context) => {
if (!context.state.indicatorOverflow) {
return;
}
setTimeout(() => {
centerIndicators(context);
}, 1);
};
var removeInvisibleAnchors = (context) => {
if (!context.state.hasOldInvisibleAnchors) {
return;
}
const { track } = context.elements;
const oldAnchors = track.querySelectorAll(".omni-invisible-anchor:not(:last-child)");
oldAnchors.forEach((anchor) => anchor.remove());
context.state.hasOldInvisibleAnchors = false;
};
// handlers/event-transitionend.ts
var updateItemTransitionClasses = (context, event) => {
if (!event.target) {
return;
}
const target = event.target;
const { selectors } = context.config;
const slide = target.closest(selectors.slide);
if (!slide) {
return;
}
const { classes } = internalConfig;
slide.classList.remove(classes.itemEntering, classes.itemExiting);
};
// handlers/omni-event-init.ts
var handleInit = (context, initialState, lazyInitObserver) => {
const { elements } = context;
captureInitialAttributes(context, initialState);
lazyInitObserver.observe(elements.root);
return true;
};
// features/buttons.ts
var updateBackwardButtons = (context) => {
const { prevButton, startButton } = context.elements;
const { startItemFullIntersecting, centeredItemIndex } = context.state;
const { hasCenterMode } = context.config;
let atStart;
if (hasCenterMode) {
atStart = startItemFullIntersecting && centeredItemIndex === 0;
} else {
atStart = startItemFullIntersecting;
}
prevButton?.toggleAttribute("disabled", atStart);
startButton?.toggleAttribute("disabled", atStart);
};
var updateForwardButtons = (context) => {
const { nextButton, endButton } = context.elements;
const { endItemFullIntersecting, centeredItemIndex } = context.state;
const { hasCenterMode } = context.config;
const { slides } = context.elements;
let atEnd;
if (hasCenterMode) {
atEnd = endItemFullIntersecting && centeredItemIndex === slides.length - 1;
} else {
atEnd = endItemFullIntersecting;
}
nextButton?.toggleAttribute("disabled", atEnd);
endButton?.toggleAttribute("disabled", atEnd);
};
// utils/arrays.ts
var insertInOrder = (array, element, getOriginalIndex) => {
const elementOriginalIndex = getOriginalIndex(element);
const insertionPoint = array.findIndex(
(item) => getOriginalIndex(item) > elementOriginalIndex
);
const insertAt = insertionPoint === -1 ? array.length : insertionPoint;
array.splice(insertAt, 0, element);
};
var removeAtIndex = (array, index) => {
if (index < 0 || index >= array.length) {
return;
}
array.splice(index, 1);
};
// utils/browser-support.ts
var supportsRequirements = () => {
const scrollBehavior = CSS.supports("scroll-behavior", "smooth");
const aspectRatio = CSS.supports("aspect-ratio", "1");
return {
supported: scrollBehavior && aspectRatio,
details: {
scrollBehavior,
aspectRatio
}
};
};
var supportsScrollend = () => {
return "onscrollend" in window;
};
var hasBlinkEngine = () => {
return (
//
// Chrome, Edge, Opera (Blink-based browsers)
//
CSS.supports("-webkit-app-region", "none") && !CSS.supports("-apple-trailing-word", "none")
);
};
// utils/debounce.ts
function debounce(fn, wait) {
let controller = new AbortController();
return function(...args) {
controller.abort();
controller = new AbortController();
const signal = controller.signal;
setTimeout(() => {
if (!signal.aborted) {
fn.apply(this, args);
}
}, wait);
};
}
// utils/measurements.ts
var getItemSpacing = (context) => {
const { slides } = context.elements;
const { itemSpacing } = context.state;
if (itemSpacing) {
return itemSpacing;
}
const rect1 = slides[0].getBoundingClientRect();
const rect2 = slides[1].getBoundingClientRect();
const spacing = rect2.left - rect1.right;
context.state.itemSpacing = spacing;
return spacing;
};
// features/steps-auto-align-all.ts
var getInboundItems = (context, direction) => {
const { fullItems } = context.state;
const { slides } = context.elements;
const { getItemIndex } = context.utils;
if (fullItems.length === 0) {
return false;
}
const goingLeft = direction === "left";
const goingRight = !goingLeft;
const firstFullItemIndex = fullItems.length > 0 ? getItemIndex(fullItems[0]) : -1;
const lastFullItemIndex = fullItems.length > 0 ? getItemIndex(fullItems[fullItems.length - 1]) : -1;
if (goingLeft && firstFullItemIndex === 0 || goingRight && lastFullItemIndex === slides.length - 1) {
return false;
}
return calculateInboundItems(context, direction);
};
var validateInboundItems = (context, result, goingLeft) => {
const { slides } = context.elements;
if (result.count === 0) {
return false;
}
if (result.startIndex < 0 || result.startIndex >= slides.length) {
return false;
}
const lastItemIndex = goingLeft ? result.startIndex : result.startIndex + result.count - 1;
if (lastItemIndex < 0 || lastItemIndex >= slides.length) {
return false;
}
return result;
};
var calculateInboundItems = (context, direction) => {
const { state, elements } = context;
const { hasEqualWidths } = context.config;
const { fullItems } = state;
const { slides } = elements;
const { getItemIndex, getItemWidth } = context.utils;
const goingLeft = direction === "left";
const firstFullItemIndex = fullItems.length > 0 ? getItemIndex(fullItems[0]) : -1;
const lastFullItemIndex = fullItems.length > 0 ? getItemIndex(fullItems[fullItems.length - 1]) : -1;
const itemSpacing = getItemSpacing(context);
const availableWidth = state.width;
const initialIndex = goingLeft ? firstFullItemIndex - 1 : lastFullItemIndex + 1;
let startIndex = initialIndex;
let accumulatedWidth = 0;
let count = 0;
let currentIndex = initialIndex;
let i = 0;
while (goingLeft ? currentIndex >= 0 : currentIndex < slides.length) {
let itemWidth;
if (i === 0) {
if (hasEqualWidths && state.itemWidth !== void 0) {
itemWidth = state.itemWidth;
} else {
itemWidth = getItemWidth(currentIndex);
if (hasEqualWidths) {
state.itemWidth = itemWidth;
}
}
} else {
const itemBaseWidth = hasEqualWidths && state.itemWidth !== void 0 ? state.itemWidth : getItemWidth(currentIndex);
itemWidth = itemBaseWidth + itemSpacing;
}
if (accumulatedWidth + itemWidth <= availableWidth) {
accumulatedWidth += itemWidth;
count++;
if (goingLeft) {
startIndex = currentIndex;
}
currentIndex += goingLeft ? -1 : 1;
i++;
} else {
break;
}
}
const result = { count, width: accumulatedWidth, startIndex };
return validateInboundItems(context, result, goingLeft);
};
// features/invisible-anchor.ts
var createInvisibleAnchor = (context, forceNew = false) => {
const { elements } = context;
const { track } = elements;
const { selectors } = internalConfig;
const selector = selectors.invisibleAnchor;
if (!forceNew) {
const existingInvisibleAnchor = track.querySelector(selector);
if (existingInvisibleAnchor) {
return existingInvisibleAnchor;
}
}
const invisibleAnchor = document.createElement("div");
invisibleAnchor.classList.add("omni-invisible-anchor");
invisibleAnchor.style.position = "absolute";
invisibleAnchor.style.pointerEvents = "none";
invisibleAnchor.style.top = "50%";
invisibleAnchor.style.transform = "translateY(-50%)";
invisibleAnchor.style.scrollSnapAlign = "center";
track.appendChild(invisibleAnchor);
elements.invisibleAnchor = invisibleAnchor;
return invisibleAnchor;
};
var placeInvisibleAnchor = (context, position, width, forceNew = false) => {
const invisibleAnchor = createInvisibleAnchor(context, forceNew);
invisibleAnchor.style.left = `${position}px`;
if (width !== void 0) {
invisibleAnchor.style.width = `${width}px`;
}
return invisibleAnchor;
};
// features/scroll.ts
var scrollToCenter = (context, element, direction) => {
const { config, elements, state } = context;
const { track } = elements;
const { hasEqualWidths } = config;
const { scrollBehavior, scrollBlock } = internalConfig;
const inboundItems = getInboundItems(context, direction);
if (!inboundItems || inboundItems.width >= state.width + 1) {
element.scrollIntoView({
behavior: scrollBehavior,
block: scrollBlock,
inline: "center"
});
return;
}
if (hasEqualWidths && inboundItems.count % 2 === 1) {
const middleIndex = inboundItems.startIndex + Math.floor(inboundItems.count / 2);
const middleItem = elements.slides[middleIndex];
middleItem.scrollIntoView({
behavior: scrollBehavior,
block: scrollBlock,
inline: "center"
});
return;
}
if (!hasEqualWidths) {
context.state.previousCenteredGroupItems = [...context.state.centeredGroupItems];
context.state.centeredGroupItems = [];
for (let i = 0; i < inboundItems.count; i++) {
const slideIndex = inboundItems.startIndex + i;
context.state.centeredGroupItems.push(slideIndex);
}
}
const firstSlideInGroup = elements.slides[inboundItems.startIndex];
const firstRect = firstSlideInGroup.getBoundingClientRect();
const trackRect = track.getBoundingClientRect();
const leftPosition = firstRect.left - trackRect.left + track.scrollLeft;
if (context.state.detectedBlinkEngine) {
const oldInvisibleAnchor = context.elements.invisibleAnchor;
const invisibleAnchor = placeInvisibleAnchor(
context,
leftPosition,
inboundItems.width,
true
);
context.state.hasOldInvisibleAnchors = true;
invisibleAnchor.scrollIntoView({
behavior: scrollBehavior,
block: scrollBlock,
inline: "center"
});
if (oldInvisibleAnchor) {
if (supportsScrollend()) {
} else {
setTimeout(() => oldInvisibleAnchor.remove(), 0);
}
}
} else {
const invisibleAnchor = placeInvisibleAnchor(
context,
leftPosition,
inboundItems.width
);
invisibleAnchor.scrollIntoView({
behavior: scrollBehavior,
block: scrollBlock,
inline: "center"
});
}
};
// features/steps-all-align-start.ts
var getAdjacentItem = (context, direction) => {
const { fullItems } = context.state;
const { slides } = context.elements;
const { getItemIndex } = context.utils;
const goingLeft = direction === "left";
const anchor = fullItems[0];
const anchorIndex = getItemIndex(anchor);
const targetIndex = goingLeft ? Math.max(0, anchorIndex - 1) : Math.min(slides.length - 1, anchorIndex + 1);
return targetIndex !== anchorIndex ? slides[targetIndex] : anchor;
};
// features/steps-all-align-center.ts
var getCentermostItem = (context, direction) => {
const { slides } = context.elements;
const { fullItems, centeredItemIndex } = context.state;
const { getItemIndex } = context.utils;
const goingLeft = direction === "left";
let item;
let itemIndex;
const oneFullItem = fullItems.length === 1;
const validCenterIndex = centeredItemIndex !== -1 && fullItems.some((item2) => getItemIndex(item2) === centeredItemIndex);
if (oneFullItem || validCenterIndex) {
const anchorIndex = oneFullItem ? getItemIndex(fullItems[0]) : centeredItemIndex ?? 0;
itemIndex = goingLeft ? Math.max(0, anchorIndex - 1) : Math.min(slides.length - 1, anchorIndex + 1);
item = slides[itemIndex];
} else {
item = calculateCentermostItem(context, direction);
itemIndex = getItemIndex(item);
}
if (context.state.centeredItemIndex !== itemIndex) {
context.state.previousCenteredItemIndex = context.state.centeredItemIndex;
context.state.centeredItemIndex = itemIndex;
if (context.config.hasCenterMode) {
updateBackwardButtons(context);
updateForwardButtons(context);
}
}
return item;
};
var calculateCentermostItem = (context, direction) => {
const { track, slides } = context.elements;
const { fullItems, partItems } = context.state;
const { getItemIndex } = context.utils;
const goingLeft = direction === "left";
const { centerTolerance } = internalConfig;
if (fullItems.length === 0 && partItems.length === 0) {
return getFallbackItem(context, direction);
}
const visibleItems = [...fullItems, ...partItems].sort(
(a, b) => getItemIndex(a) - getItemIndex(b)
);
const trackCenter = getRectCenterX(track);
let item;
if (goingLeft) {
const startIndex = getItemIndex(visibleItems[visibleItems.length - 2]);
const itemsToSearch = slides.slice(0, startIndex + 1).reverse();
item = itemsToSearch.find((item2) => {
const itemCenter = getRectCenterX(item2);
return itemCenter < trackCenter - centerTolerance;
});
} else {
const startIndex = getItemIndex(visibleItems[1]);
const itemsToSearch = slides.slice(startIndex);
item = itemsToSearch.find((item2) => {
const itemCenter = getRectCenterX(item2);
return itemCenter > trackCenter + centerTolerance;
});
}
return item || getFallbackItem(context, direction);
};
// state/set.ts
var createState = (trackWidth, trackScrollWidth, config) => {
const debouncedCenterIndicators = !supportsScrollend() && config ? debounce((context) => centerIndicators(context), internalConfig.scrollendTimeoutDelay) : void 0;
return {
width: trackWidth,
scrollWidth: trackScrollWidth,
// containerLeft: undefined by default, will be calculated lazily when needed
fullItems: [],
partItems: [],
startItemFullIntersecting: false,
endItemFullIntersecting: false,
hasIndicators: false,
indicatorOverflow: false,
slideIndexMap: /* @__PURE__ */ new Map(),
itemWidthMap: /* @__PURE__ */ new Map(),
detectedBlinkEngine: hasBlinkEngine(),
centeredGroupItems: [],
previousCenteredGroupItems: [],
debouncedCenterIndicators
};
};
var populateMaps = (context) => {
const { elements, state } = context;
const { slides } = elements;
state.slideIndexMap = new Map(
slides.map((slide, index) => [slide, index])
);
};
// state/update.ts
var updateIntersectionState = (context, data) => {
const { state } = context;
const { slides } = context.elements;
const { getItemIndex } = context.utils;
const { slide, fullIntersecting, partIntersecting } = data;
const index = getItemIndex(slide);
const isStartItem = index === 0;
const isEndItem = index === slides.length - 1;
const wasStartItemFullIntersecting = state.startItemFullIntersecting;
const wasEndItemFullIntersecting = state.endItemFullIntersecting;
const indexInFullItems = state.fullItems.findIndex((item) => item === slide);
const indexInPartItems = state.partItems.findIndex((item) => item === slide);
const wasFullIntersecting = indexInFullItems !== -1;
const wasPartIntersecting = indexInPartItems !== -1;
let visibilityChanged = false;
let startBoundaryChanged = false;
let endBoundaryChanged = false;
if (wasFullIntersecting !== fullIntersecting) {
visibilityChanged = true;
if (fullIntersecting) {
insertInOrder(state.fullItems, slide, getItemIndex);
if (isStartItem && !wasStartItemFullIntersecting) {
startBoundaryChanged = true;
state.startItemFullIntersecting = true;
}
if (isEndItem && !wasEndItemFullIntersecting) {
endBoundaryChanged = true;
state.endItemFullIntersecting = true;
}
} else {
removeAtIndex(state.fullItems, indexInFullItems);
if (isStartItem && wasStartItemFullIntersecting) {
startBoundaryChanged = true;
state.startItemFullIntersecting = false;
}
if (isEndItem && wasEndItemFullIntersecting) {
endBoundaryChanged = true;
state.endItemFullIntersecting = false;
}
}
}
if (wasPartIntersecting !== partIntersecting) {
visibilityChanged = true;
if (partIntersecting) {
insertInOrder(state.partItems, slide, getItemIndex);
} else {
removeAtIndex(state.partItems, indexInPartItems);
}
}
return {
changed: visibilityChanged || startBoundaryChanged || endBoundaryChanged,
slide,
fullIntersecting,
partIntersecting,
wasFullIntersecting,
wasPartIntersecting,
startBoundaryChanged,
endBoundaryChanged
};
};
// handlers/omni-event-setup.ts
var handleSetup = (context, initialState, intersectionObserver, addEventListeners) => {
const { elements, state, config } = context;
const detectedAlignment = getScrollSnapAlign(elements.slides[0]);
if (detectedAlignment) {
config.scrollAlign = detectedAlignment;
}
const indicators = addIndicators(context);
storeIndicators(context, indicators);
state.hasIndicators = indicators.length > 0;
if (state.hasIndicators) {
updateIndicatorOverflow(context);
}
populateMaps(context);
if (config.scrollAlign === "center" && config.scrollSteps === "auto") {
ensureTrackPositioned(context, initialState);
}
setElementAttributes(context);
elements.slides.forEach((slide) => intersectionObserver.observe(slide));
addEventListeners();
};
// handlers/omni-event-destroy.ts
var handleDestroy = (context, initialState, intersectionObserver, resizeObserver, removeEventListeners, eventEmitter, data, lazyInitObserver) => {
const { state } = context;
const { fullItems, partItems } = context.state;
const mode = data?.mode || "full";
intersectionObserver.disconnect();
removeEventListeners();
resetElementAttributes(context, initialState);
clearIndicators(context);
storeIndicators(context, []);
[...fullItems, ...partItems].forEach((slide) => clearItemAttributes(slide));
state.fullItems = [];
state.partItems = [];
state.startItemFullIntersecting = false;
state.endItemFullIntersecting = false;
state.hasIndicators = false;
state.indicatorOverflow = false;
state.centeredItemIndex = void 0;
state.hasOldInvisibleAnchors = false;
if (context.elements.invisibleAnchor) {
context.elements.invisibleAnchor.remove();
context.elements.invisibleAnchor = void 0;
}
state.debouncedCenterIndicators = void 0;
state.slideIndexMap.clear();
state.itemWidthMap.clear();
if (mode === "partial") {
return true;
}
initialState.buttonAttributes.clear();
initialState.trackCSSPosition = void 0;
resizeObserver.disconnect();
lazyInitObserver?.disconnect();
eventEmitter.events = {};
context.elements.root.removeAttribute(internalConfig.dataAttributes.carouselInstance);
return false;
};
// handlers/omni-event-dimensions-change.ts
var updateLayoutData = (context, intersectionObserver) => {
const { state } = context;
const { slides } = context.elements;
state.fullItems = [];
state.partItems = [];
state.startItemFullIntersecting = false;
state.endItemFullIntersecting = false;
state.itemWidth = void 0;
state.containerLeft = void 0;
state.itemWidthMap.clear();
if (state.hasIndicators) {
updateIndicatorOverflow(context);
}
intersectionObserver.disconnect();
slides.forEach((slide) => intersectionObserver.observe(slide));
};
// handlers/omni-event-visibility-change.ts
var updateUI = (context, data) => {
const { utils } = context;
const {
slide,
fullIntersecting,
partIntersecting,
wasFullIntersecting,
wasPartIntersecting
} = data;
updateItem(
context,
slide,
fullIntersecting,
partIntersecting,
wasFullIntersecting,
wasPartIntersecting
);
if (!context.config.hasCenterMode) {
if (data.startBoundaryChanged) {
updateBackwardButtons(context);
}
if (data.endBoundaryChanged) {
updateForwardButtons(context);
}
}
if (context.state.hasIndicators) {
const index = utils.getItemIndex(slide);
updateIndicator(context, index, fullIntersecting, partIntersecting);
if (context.state.indicatorOverflow && !supportsScrollend() && context.state.debouncedCenterIndicators) {
context.state.debouncedCenterIndicators(context);
}
}
if (context.config.hasEqualWidths === false && context.config.scrollSteps === "auto" && context.config.scrollAlign === "center") {
updateCenterGroupItem(context);
}
};
var preloadImages = (context) => {
const { elements, state } = context;
const { slides } = elements;
const visibleSlides = [...state.fullItems, ...state.partItems];
if (visibleSlides.length === 0) {
return;
}
const firstVisibleIndex = slides.indexOf(visibleSlides[0]);
const lastVisibleIndex = slides.indexOf(visibleSlides[visibleSlides.length - 1]);
const adjacentIndexes = /* @__PURE__ */ new Set();
if (firstVisibleIndex > 0) {
adjacentIndexes.add(firstVisibleIndex - 1);
}
if (lastVisibleIndex < slides.length - 1) {
adjacentIndexes.add(lastVisibleIndex + 1);
}
adjacentIndexes.forEach((index) => {
const slide = slides[index];
const lazyImages = slide.querySelectorAll('img[loading="lazy"]');
lazyImages.forEach((img) => {
img.removeAttribute("loading");
});
});
};
// handlers/observation-intersection.ts
var handleIntersection = (context, entries) => {
const { eventEmitter, state } = context;
for (const entry of entries) {
const slide = entry.target;
const fullIntersecting = entry.isIntersecting && entry.intersectionRatio === 1;
const partIntersecting = entry.isIntersecting && entry.intersectionRatio < 1;
const result = updateIntersectionState(context, {
slide,
fullIntersecting,
partIntersecting
});
if (result.changed) {
eventEmitter.emit("omni:visibility:change", {
state,
slide: result.slide,
fullIntersecting: result.fullIntersecting,
partIntersecting: result.partIntersecting,
wasPartIntersecting: result.wasPartIntersecting,
wasFullIntersecting: result.wasFullIntersecting,
startBoundaryChanged: result.startBoundaryChanged,
endBoundaryChanged: result.endBoundaryChanged
});
}
}
};
// handlers/observation-resize.ts
var handleResize = (context) => {
const { track } = context.elements;
const { state, eventEmitter } = context;
const width = track.clientWidth;
const scrollWidth = track.scrollWidth;
if (width !== state.width || scrollWidth !== state.scrollWidth) {
const storedOverflow = state.scrollWidth > state.width;
const overflow = scrollWidth > width;
const overflowChanged = storedOverflow !== overflow;
state.width = width;
state.scrollWidth = scrollWidth;
if (overflowChanged && overflow) {
eventEmitter.emit("omni:setup");
} else if (overflowChanged && !overflow) {
eventEmitter.emit("omni:destroy", { mode: "partial" });
} else if (overflow) {
eventEmitter.emit("omni:dimensions:change", {
width,
scrollWidth
});
}
}
};
// navigation/index.ts
var determineScrollMode = (index, scrollAlign, scrollSteps) => {
if (index !== "none") {
return "indicator";
}
if (scrollSteps === "one") {
return scrollAlign === "center" ? "steps-one-align-center" : "steps-one-align-start";
} else {
return scrollAlign === "center" ? "steps-auto-align-center" : "steps-auto-align-start";
}
};
var navigate = (context, direction = "none", index = "none") => {
if (index === "none" && direction === "none") {
return;
}
const { slides } = context.elements;
const { scrollAlign, scrollSteps } = context.config;
let position = context.config.scrollAlign;
let destination;
let shouldCenterForMany = false;
let shouldUpdateCenterItem = false;
if (context.state.fullItems.length < 1 && index === "none") {
destination = getFallbackItem(context, direction);
} else {
const mode = determineScrollMode(index, scrollAlign, scrollSteps);
if (mode === "indicator") {
if (index !== "none" && index >= 0 && index < slides.length) {
destination = slides[index];
if (scrollAlign === "start") {
position = determineAlignmentForIndicator(context, index);
} else {
context.state.previousCenteredItemIndex = context.state.centeredItemIndex;
context.state.centeredItemIndex = index;
shouldUpdateCenterItem = true;
}
} else {
return;
}
} else if (mode === "steps-one-align-center") {
destination = getCentermostItem(context, direction);
shouldUpdateCenterItem = true;
} else if (mode === "steps-one-align-start") {
destination = getAdjacentItem(context, direction);
} else if (mode === "steps-auto-align-center") {
const inboundItems = getInboundItems(context, direction);
if (inboundItems) {
destination = slides[inboundItems.startIndex];
shouldCenterForMany = true;
} else {
destination = getCentermostItem(context, direction);
}
} else if (mode === "steps-auto-align-start") {
const inboundItems = getInboundItems(context, direction);
if (inboundItems) {
destination = slides[inboundItems.startIndex];
} else {
destination = getAdjacentItem(context, direction);
}
}
}
destination = destination || getFallbackItem(context, direction);
if (shouldUpdateCenterItem) {
updateCenterItem(context);
}
if (shouldCenterForMany) {
scrollToCenter(context, destination, direction);
} else {
destination.scrollIntoView({
behavior: internalConfig.scrollBehavior,
block: internalConfig.scrollBlock,
inline: position
});
}
};
// core/carousel.ts
var createOmniCarousel = (root, options) => {
const requirements = supportsRequirements();
if (!requirements.supported) {
const missing = [];
if (!requirements.details.scrollBehavior) {
missing.push("scroll-behavior");
}
if (!requirements.details.aspectRatio) {
missing.push("aspect-ratio");
}
throw new Error(`Browser requirements not met: ${missing.join(", ")} CSS support missing`);
}
if (root.hasAttribute(internalConfig.dataAttributes.carouselInstance)) {
throw new Error("Carousel instance already exists on this element. Each element can only have one carousel instance.");
}
root.setAttribute(internalConfig.dataAttributes.carouselInstance, "true");
const onEvent = (event, handler) => {
eventEmitter.on(event, handler);
};
const config = mergeConfig(options);
const elements = getElements(root, config.selectors);
const { track, slides } = elements;
const state = createState(track.clientWidth, track.scrollWidth, config);
const eventEmitter = createNanoEvents();
const utils = {
//
// @neededfor scrollAlign:'center' + scrollSteps:'auto'
// @neededfor indicators
//
getElementRect: (element, property) => {
const { width, left } = element.getBoundingClientRect();
if (property === "width") {
return width;
} else if (property === "left") {
return left;
}
return { width, left };
},
getItemIndex: (slide) => {
return slide ? state.slideIndexMap.get(slide) ?? -1 : -1;
},
//
// @neededfor scrollSteps:'auto'
//
getItemWidth: (index) => {
if (!state.itemWidthMap.has(index)) {
const width = slides[index].offsetWidth;
state.itemWidthMap.set(index, width);
}
return state.itemWidthMap.get(index);
},
//
// @neededfor scrollAlign:'center' + scrollSteps:'auto'
//
// Lazily calculates and caches the container's left position
//
getContainerLeft: () => {
if (state.containerLeft === void 0) {
state.containerLeft = track.getBoundingClientRect().left;
}
return state.containerLeft;
}
};
const getContext = (() => {
const context = {
config,
elements: {
...elements,
indicators: []
},
state,
utils,
eventEmitter
};
return () => context;
})();
const initialState = { buttonAttributes: /* @__PURE__ */ new Map() };
const eventHandlers = [
{
handler: (event) => handleClick(getContext(), event),
event: "click",
element: "root",
options: { passive: true }
},
{
handler: (event) => handleKeyboard(getContext(), event),
event: "keydown",
element: "track",
options: {}
},
//
// @neededfor indicators
//
{
handler: () => startIndicatorCentering(getContext()),
event: "scrollend",
element: "track",
options: { passive: true },
condition: () => {
const context = getContext();
return supportsScrollend() && context.state.indicatorOverflow;
}
},
//
// @neededfor scrollAlign:'center' + scrollSteps:'auto'
//
{
handler: () => removeInvisibleAnchors(getContext()),
event: "scrollend",
element: "track",
options: { passive: true },
condition: () => {
const context = getContext();
return !!context.state.detectedBlinkEngine && supportsScrollend() && context.config.scrollAlign === "center" && context.config.scrollSteps === "auto";
}
},
//
// @neededfor transitionHelpers:true
//
{
handler: (event) => updateItemTransitionClasses(getContext(), event),
event: "transitionend",
element: "track",
options: { passive: true },
condition: () => {
const context = getContext();
return context.config.transitionHelpers;
}
}
];
const addEventListeners = () => {
const { elements: elements2 } = getContext();
eventHandlers.filter((config2) => !config2.condition || config2.condition()).forEach((config2) => {
elements2[config2.element].addEventListener(
config2.event,
config2.handler,
config2.options
);
});
};
const removeEventListeners = () => {
const { elements: elements2 } = getContext();
eventHandlers.forEach((config2) => {
elements2[config2.element].removeEventListener(
config2.event,
config2.handler
);
});
};
const setupOmniEventListeners = () => {
onEvent("omni:visibility:change", (data) => {
updateUI(getContext(), data);
if (getContext().config.preloadAdjacentImages) {
preloadImages(getContext());
}
});
onEvent("omni:dimensions:change", () => {
updateLayoutData(getContext(), intersectionObserver);
});
onEvent("omni:nav:prev", () => {
navigate(getContext(), "left", "none");
});
onEvent("omni:nav:next", () => {
navigate(getContext(), "right", "none");
});
onEvent("omni:nav:index", (data) => {
navigate(getContext(), "none", data.index);
});
};
let initialized = false;
const init = () => {
if (initialized) {
console.warn("Carousel already initialized. Use setup() instead if needed.");
return;
}
eventEmitter.emit("omni:init");
};
const destroy = () => {
eventEmitter.emit("omni:destroy", { mode: "full" });
};
const resizeObserver = new ResizeObserver(
() => {
handleResize(getContext());
}
);
const lazyInitObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
resizeObserver.observe(elements.track);
if (state.scrollWidth > state.width) {
eventEmitter.emit("omni:setup");
}
lazyInitObserver.disconnect();
}
}
);
const intersectionObserver = new IntersectionObserver(
(entries) => handleIntersection(getContext(), entries),
{
root: track,
rootMargin: internalConfig.ioRootMargin,
threshold: internalConfig.ioThresholds
}
);
onEvent("omni:init", () => {
initialized = handleInit(
getContext(),
initialState,
lazyInitObserver
);
});
onEvent("omni:setup", () => {
handleSetup(
getContext(),
initialState,
intersectionObserver,
addEventListeners
);
});
onEvent("omni:destroy", (data) => {
initialized = handleDestroy(
getContext(),
initialState,
intersectionObserver,
resizeObserver,
removeEventListeners,
eventEmitter,
data,
lazyInitObserver
);
});
setupOmniEventListeners();
const setup = () => {
if (!initialized) {
init();
return;
}
const { width, scrollWidth } = state;
if (scrollWidth > width) {
eventEmitter.emit("omni:setup");
}
};
return {
//
// Lifecycle methods
//
init,
setup,
destroy: () => {
if (initialized) {
destroy();
}
},
//
// Navigation methods
//
goTo: (index) => eventEmitter.emit("omni:nav:index", { index }),
next: () => eventEmitter.emit("omni:nav:next"),
prev: () => eventEmitter.emit("omni:nav:prev"),
//
// Event subscription
//
on: (event, callback) => eventEmitter.on(event, callback)
};
};
export {
createOmniCarousel
};
//# sourceMappingURL=index.js.map