UNPKG

@carbon/utilities

Version:

Utilities and helpers to drive consistency across software products using the Carbon Design System

450 lines (449 loc) 17.3 kB
//#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; } });