@carbon/utilities
Version:
Utilities and helpers to drive consistency across software products using the Carbon Design System
450 lines (449 loc) • 17.3 kB
JavaScript
//#region src/carousel/defs.ts
/**
* Copyright IBM Corp. 2026
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
const translationIds = {
"carbon.carousel.item": "carbon.carousel.item",
"carbon.carousel.of": "carbon.carousel.of"
};
//#endregion
//#region src/carousel/swipeEvents.ts
/**
* Registers swipe event handlers for a carousel element.
* Handles touch, mouse, and wheel events for navigation.
* @param {HTMLElement} carousel - The carousel element to attach event listeners to.
* @param {() => void} next - Callback function to execute when swiping right.
* @param {() => void} prev - Callback function to execute when swiping left.
* @param {boolean} destroy - If true, removes existing event listeners before adding new ones.
*/
const registerSwipeEvents = (carousel, next, prev, destroy) => {
const minSwipeDistance = 50;
let touchStartX = null;
let touchEndX = null;
let lastScrollTime = 0;
const scrollCooldown = 400;
let isMouseDown = false;
let mouseStartX = null;
let mouseEndX = null;
const touchStartHandler = (e) => {
touchStartX = e.touches[0].clientX;
};
const touchMoveHandler = (e) => {
touchEndX = e.touches[0].clientX;
};
const touchEndHandler = () => {
if (touchStartX !== null && touchEndX !== null) {
const distance = touchStartX - touchEndX;
if (Math.abs(distance) > minSwipeDistance) if (distance > 0) next();
else prev();
}
touchStartX = null;
touchEndX = null;
};
const mouseDownHandler = (e) => {
isMouseDown = true;
mouseStartX = e.clientX;
};
const mouseMoveHandler = (e) => {
if (!isMouseDown) return;
mouseEndX = e.clientX;
};
const mouseUpHandler = () => {
if (isMouseDown && mouseStartX !== null && mouseEndX !== null) {
const distance = mouseStartX - mouseEndX;
if (Math.abs(distance) > minSwipeDistance) if (distance > 0) next();
else prev();
}
isMouseDown = false;
mouseStartX = null;
mouseEndX = null;
};
const wheelHandler = (e) => {
const now = Date.now();
if (Math.abs(e.deltaX) > Math.abs(e.deltaY) && Math.abs(e.deltaX) > 20) {
e.preventDefault();
if (now - lastScrollTime < scrollCooldown) return;
if (e.deltaX > 0) next();
else prev();
lastScrollTime = now;
}
};
if (destroy) {
carousel.removeEventListener("touchstart", touchStartHandler);
carousel.removeEventListener("touchmove", touchMoveHandler);
carousel.removeEventListener("touchend", touchEndHandler);
carousel.removeEventListener("mousedown", mouseDownHandler);
carousel.removeEventListener("mousemove", mouseMoveHandler);
carousel.removeEventListener("mouseup", mouseUpHandler);
carousel.removeEventListener("wheel", wheelHandler);
}
carousel.addEventListener("touchstart", touchStartHandler);
carousel.addEventListener("touchmove", touchMoveHandler);
carousel.addEventListener("touchend", touchEndHandler);
carousel.addEventListener("mousedown", mouseDownHandler);
carousel.addEventListener("mousemove", mouseMoveHandler);
carousel.addEventListener("mouseup", mouseUpHandler);
carousel.addEventListener("wheel", wheelHandler);
};
//#endregion
//#region src/carousel/carousel.ts
const defaultTranslations = {
[translationIds["carbon.carousel.item"]]: "Item",
[translationIds["carbon.carousel.of"]]: "of"
};
/**
* Default translation function
*/
const defaultTranslateWithId = (messageId) => {
return defaultTranslations[messageId];
};
/**
* Initializes a carousel with the given configuration.
* @param carouselContainer - The HTMLElement representing the carousel container.
* @param config - Optional configuration object.
* @returns An object containing methods to control the carousel.
*/
const initCarousel = (carouselContainer, config) => {
const prefix = "carousel";
let viewIndexStack = [0];
let previousViewIndexStack = [0];
const refs = {};
const carouselListeners = /* @__PURE__ */ new WeakMap();
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let supportsAnimations;
const minHeight = 4;
const { onViewChangeStart, onViewChangeEnd, excludeSwipeSupport, useMaxHeight, translateWithId: t = defaultTranslateWithId } = config || {};
/**
* Registers an HTMLElement at a specific index in the refs array.
*
* @param index - The index at which to register the HTMLElement.
* @param ref - The HTMLElement to register.
*
* @example
* registerRef(0, document.getElementById('myElement'));
*/
const registerRef = (index, ref) => {
refs[index] = ref;
};
const getLiveRegion = () => carouselContainer.querySelector(`.${prefix}__live-region`);
const getWrapper = () => carouselContainer.querySelector(`.${prefix}__itemsWrapper`);
const updateLiveRegion = (idx) => {
const div = getLiveRegion();
if (div) div.textContent = `${t("carbon.carousel.item")} ${idx} ${t("carbon.carousel.of")} ${getCarouselItems()?.length}`;
};
const syncLiveRegionWithActiveView = () => {
updateLiveRegion(viewIndexStack[0] + 1);
};
const createLiveRegion = () => {
const childCount = getCarouselItems()?.length;
if (getLiveRegion() || !childCount) return;
const div = document.createElement("div");
div.setAttribute("aria-live", "polite");
div.setAttribute("aria-atomic", "true");
div.setAttribute("class", `${prefix}__live-region`);
div.textContent = `${t("carbon.carousel.item")} 1 ${t("carbon.carousel.of")} ${childCount}`;
carouselContainer.appendChild(div);
};
/**
* Wraps all child elements of a given container into a new div with the specified class.
* If an element with the specified class already exists as a child of the container, the function does nothing.
*/
const wrapAllItems = () => {
if (getWrapper()) return;
const wrapper = document.createElement("div");
wrapper.classList.add(`${prefix}__itemsWrapper`);
while (carouselContainer.firstChild) wrapper.appendChild(carouselContainer.firstChild);
carouselContainer.appendChild(wrapper);
};
const getHistory = () => {
return viewIndexStack.reduce((history, id) => {
const elem = refs[id];
if (elem) history.push({
id,
elem
});
return history;
}, []);
};
/**
* Retrieves the current carousel response based on the view index stack and reference objects.
* @returns {CarouselResponse} - An object containing carousel response details.
*/
const getCallbackResponse = () => {
const totalRefs = Object.keys(refs).length;
const lastElementRef = refs[totalRefs - 1];
const historicalData = getHistory();
return {
currentIndex: viewIndexStack[0],
lastIndex: parseInt(lastElementRef?.dataset.index || viewIndexStack[0].toString(), 10),
totalViews: totalRefs,
historyStack: historicalData
};
};
/**
* Handles the start of a transition in the application.
* This function is responsible for capturing the current state of the view index stack
* and invoking a callback function if it exists.
*
* @function handleTransitionStart
* @returns {void}
*/
const handleTransitionStart = () => {
previousViewIndexStack = [...viewIndexStack];
const callbackData = getCallbackResponse();
onViewChangeStart?.(callbackData);
};
/**
* Handles the 'transitionend' event for a given element.
* This function checks if the element has a 'data-index' attribute and if its value matches the current view index.
* If both conditions are met, it calls the 'onViewChangeEnd' callback with the response from 'getCallbackResponse'.
*
* @param el - The element to handle the 'transitionend' event for.
*/
const handleTransitionEnd = (el) => {
if (!el) return;
const tmpElementIndex = el.dataset.index;
if (tmpElementIndex && viewIndexStack[0] === parseInt(tmpElementIndex, 10)) {
const callbackData = getCallbackResponse();
onViewChangeEnd?.(callbackData);
}
};
/**
* A utility function to sanitize an index value.
* This function ensures the index stays within the bounds of the refs array.
*
* @param idx - The index to be sanitized.
* @returns - The sanitized index.
*/
const sanitizeIndex = (idx) => {
const floorVal = 0;
const ceilVal = Object.keys(refs).length - 1;
return Math.max(floorVal, Math.min(idx, ceilVal));
};
/**
* Handles the 'transitionend' event for a given element.
* This function checks if the element has a 'data-index' attribute and if its value matches the current view index.
* If both conditions are met, it calls the 'onViewChangeEnd' callback with the response from 'getCallbackResponse'.
* @returns {void}
*/
const transitionToViewIndex = (idx) => {
const viewItems = getCarouselItems();
const sanitizedIndex = sanitizeIndex(idx);
if (viewIndexStack[0] !== sanitizedIndex) {
handleTransitionStart();
viewIndexStack = [sanitizedIndex, ...viewIndexStack];
syncLiveRegionWithActiveView();
performAnimation(false);
if ((prefersReducedMotion || prefersReducedMotion === false && supportsAnimations === false) && viewItems) handleTransitionEnd(viewItems[sanitizedIndex]);
}
};
const transitionComplete = (ref) => {
handleTransitionEnd(ref);
};
/**
* Attaches class names to an HTMLElement based on given conditions.
*
* @param viewItem - The HTML element to which class names will be added.
* @param isInViewStack - Indicates if the view item is in the view stack.
* @param isActive - Indicates if the view item is active.
* @param isBeingRecycledOut - Indicates if the view item is being recycled out.
* @param isBeingRecycledIn - Indicates if the view item is being recycled in.
*/
const attachClassNames = (viewItem, isInViewStack, isActive, isBeingRecycledOut, isBeingRecycledIn) => {
viewItem.classList.add(`${prefix}__view`);
viewItem.classList.toggle(`${prefix}__view-in-stack`, isInViewStack && !isActive);
viewItem.classList.toggle(`${prefix}__view-active`, isInViewStack && isActive);
if (isBeingRecycledIn && !isBeingRecycledOut) viewItem.classList.add(`${prefix}__view-recycle-in`);
if (!isBeingRecycledIn && isBeingRecycledOut) viewItem.classList.add(`${prefix}__view-recycle-out`);
if (isActive) {
viewItem.removeAttribute("aria-hidden");
viewItem.removeAttribute("inert");
} else {
viewItem.setAttribute("aria-hidden", "true");
viewItem.setAttribute("inert", "");
}
};
const removeReCycleClasses = (viewItem) => {
viewItem.classList.remove(`${prefix}__view-recycle-in`, `${prefix}__view-recycle-out`);
};
const remToPx = (rem) => {
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
};
/**
* Updates the height of the items wrapper in a carousel based on the smallest item height and a threshold height.
* This function ensures that the items wrapper does not have a height smaller than the threshold, adjusting the item height if necessary.
*
* @param itemHeightSmallest - The smallest height of an item in pixels.
*/
const updateHeightForWrapper = (itemHeightSmallest) => {
const thresholdHeight = remToPx(minHeight);
if (carouselContainer.clientHeight < thresholdHeight) {
if (itemHeightSmallest < thresholdHeight) itemHeightSmallest = thresholdHeight;
const itemsWrapper = getWrapper();
if (itemsWrapper) itemsWrapper.style.blockSize = `${itemHeightSmallest}px`;
}
};
/**
* Performs animation on view items based on their state in the view index stack.
* @param isInitial - A flag indicating if this is the initial animation.
*/
const performAnimation = (isInitial) => {
const viewItems = getCarouselItems();
if (!viewItems) return;
let itemHeightSmallest = 0;
let itemHeightMaximum = 0;
Array.from(viewItems).forEach((viewItem, index) => {
const stackIndex = viewIndexStack.findIndex((idx) => idx === index);
const stackIndexInstanceCount = previousViewIndexStack.filter((viIdx) => viIdx === index).length;
const isBeingRecycledOut = previousViewIndexStack.length > viewIndexStack.length && previousViewIndexStack[0] === index && stackIndexInstanceCount > 0;
const isBeingRecycledIn = previousViewIndexStack.length < viewIndexStack.length && previousViewIndexStack[0] === index && stackIndexInstanceCount > 0;
attachClassNames(viewItem, stackIndex > -1, index === viewIndexStack[0], isBeingRecycledOut, isBeingRecycledIn);
if (isInitial) {
registerRef(index, viewItem);
setTimeout(() => {
if (useMaxHeight) {
const heights = Array.from(viewItems).map((viewItem) => viewItem.scrollHeight);
itemHeightMaximum = Math.max(...heights);
viewItem.style.position = "absolute";
updateHeightForWrapper(itemHeightMaximum);
} else {
if (!itemHeightSmallest || viewItem.offsetHeight < itemHeightSmallest && itemHeightSmallest > remToPx(minHeight)) itemHeightSmallest = viewItem.offsetHeight;
viewItem.style.position = "absolute";
updateHeightForWrapper(itemHeightSmallest);
}
});
const listener = (e) => {
removeReCycleClasses(viewItem);
if (e.target === refs[viewIndexStack[0]]) transitionComplete(viewItem);
if (!supportsAnimations) supportsAnimations = true;
};
carouselListeners.set(viewItem, listener);
viewItem.addEventListener("animationend", listener);
viewItem.addEventListener("transitionend", listener);
viewItem.setAttribute("data-index", index.toString());
}
});
if (isInitial) handleTransitionEnd(Array.from(viewItems)[0]);
};
/**
* A utility function to navigate to the next view in the stack.
* This function increments the current view index and transitions to the new index.
*
* @returns {void} - This function does not return any value.
*/
const navigateNext = () => {
transitionToViewIndex(viewIndexStack[0] + 1);
};
/**
* Navigates to the previous view in the view stack.
* @function navigatePrev
* @description This function checks if there is a previous view in the stack. If so, it triggers a transition start, removes the current view from the stack, and performs an animation to transition to the previous view.
* @returns {void} - This function does not return a value.
*/
const navigatePrev = () => {
if (viewIndexStack.length - 1 >= 1) {
const viewItems = getCarouselItems();
handleTransitionStart();
viewIndexStack = viewIndexStack.slice(1);
syncLiveRegionWithActiveView();
performAnimation(false);
if ((prefersReducedMotion || prefersReducedMotion === false && !supportsAnimations) && viewItems) {
const targetViewIndex = viewIndexStack[0];
handleTransitionEnd(viewItems[targetViewIndex]);
}
}
};
/**
* A function that transitions the view to a specified index.
*
* @param index - The index to transition to.
* @returns - This function does not return a value.
*/
const goToIndex = (index) => {
transitionToViewIndex(index);
};
/**
* Retrieves the currently active item and its index from the view index stack and references.
* @returns An object containing the index and the corresponding item reference.
*/
const getActiveItem = () => {
return {
index: viewIndexStack[0],
item: refs[viewIndexStack[0]]
};
};
/**
* Resets the view index stack and performs an animation.
*
* @returns {void}
*/
const reset = () => {
const viewItems = getCarouselItems();
if (!viewItems) return;
Array.from(viewItems).forEach((viewItem) => {
removeReCycleClasses(viewItem);
});
previousViewIndexStack = [0];
viewIndexStack = [0];
performAnimation(false);
syncLiveRegionWithActiveView();
};
/**
* Removes event listeners for 'animationend' and 'transitionend' events from all elements with references stored in the `refs` object.
* Also registers swipe events if `excludeSwipeSupport` is false.
*/
const destroyEvents = () => {
Object.values(refs).forEach((el) => {
if (!el) return;
const listener = carouselListeners.get(el);
if (listener) {
el.removeEventListener("animationend", listener);
el.removeEventListener("transitionend", listener);
}
carouselListeners.delete(el);
});
if (!excludeSwipeSupport) registerSwipeEvents(carouselContainer, navigateNext, navigatePrev, true);
};
/**
* Retrieves carousel items from a given container element.
* If the container has a 'slot' element, it fetches all elements assigned to that slot.
* Otherwise, it fetches all direct children of the container.
*
* @returns An array of HTMLElements representing the carousel items or nothing if a container is not found.
*
* @example
* const carouselItems = getCarouselItems();
* console.log(carouselItems); // Logs the carousel items as HTMLElements
*/
const getCarouselItems = () => {
const container = getWrapper();
if (!container) return;
const slot = container.querySelector("slot");
if (slot instanceof HTMLSlotElement) return slot.assignedElements({ flatten: true }).filter((item) => item instanceof HTMLElement);
return Array.from(container.children).filter((item) => item instanceof HTMLElement);
};
wrapAllItems();
createLiveRegion();
carouselContainer.classList.add(`${prefix}__view-stack`);
performAnimation(true);
if (!excludeSwipeSupport) registerSwipeEvents(carouselContainer, navigateNext, navigatePrev, false);
return {
next: navigateNext,
prev: navigatePrev,
reset,
goToIndex,
getActiveItem,
destroyEvents,
allViews: refs
};
};
//#endregion
Object.defineProperty(exports, "initCarousel", {
enumerable: true,
get: function() {
return initCarousel;
}
});