@kubit-ui-web/react-components
Version:
Kubit React Components is a customizable, accessible library of React web components, designed to enhance your application's user experience
515 lines • 24.8 kB
JavaScript
import React from 'react';
import { useScrollBlock } from '../../../hooks/useScrollBlock/useScrollBlock';
import { focusElementOrFirstDescendant, focusNextFocusableElement, focusPreviousFocusableElement, } from '../../../utils/focusHandlers/focusHandlers';
import { CarouselAlignType } from '../types/carouselAlign';
// carousel utils
import { calcNumElementsPerPage, calcNumPages, calcXDistanceBetween2Elements, getFirstAndLastIndexInCarouselView, hideArrowsAndPagination, showArrowsAndPagination, } from '../utils/carousel.utils';
const PROPERTY_NAME_TO_HANDLE_TRANSITION_END = 'left';
const THRESHOLD_TO_DECIDE_IF_ALLOW_DRAGGING = 20;
const THRESHOLD_TO_DECIDE_IF_SWIPE_ON_DRAG_ENDS = 0;
export const useCarousel = ({ carouselContainerRef, carouselContentRef, circular, centerMode, extraPadding, elements, numElementsPerPage: numElementsPerPageProp, numElementsToSlide, defaultPage, onePageAlign = CarouselAlignType.CENTER, disableSwipe, allowModifySliceWidth, centerExtremesWhenExtraPadding, onRightSwipe, onLeftSwipe, onTransition, }) => {
const index = React.useRef(defaultPage);
const allowShift = React.useRef(true);
const numElementsPerPage = React.useRef(numElementsPerPageProp);
const [numPagesState, setNumPagesState] = React.useState(numElementsPerPageProp
? calcNumPages({
elementsLength: elements.length,
numElementsPerPage: numElementsPerPageProp,
numElementsToSlide,
})
: 0);
// We need the ref to avoid update when the state has not changed
const numPagesRef = React.useRef(numElementsPerPageProp
? calcNumPages({
elementsLength: elements.length,
numElementsPerPage: numElementsPerPageProp,
numElementsToSlide,
})
: 0);
// Initialize carousel position
const caouselInitialized = React.useRef(false);
const initializeCarousel = () => {
caouselInitialized.current = false;
const carouselContainer = carouselContainerRef.current;
const carouselContent = carouselContentRef.current;
if (!carouselContainer || !carouselContent || !numElementsPerPage.current) {
return;
}
// When allow update slides width, the container will be 100%, and the slides width will be calculated atteding to the container width and the numElementsPerPage
if (allowModifySliceWidth) {
carouselContainer.style.width = '100%';
// Calc slice width having in mind the gap between the slices and the extraPadding
let gap = 0;
if (carouselContent.childNodes.length > 2) {
const firstElement = carouselContent.childNodes[0];
const secondElement = carouselContent.childNodes[1];
// Delete scale when center mode to calc the gap between elements
if (centerMode && firstElement.style && secondElement.style) {
firstElement.style.transition = 'none';
secondElement.style.transition = 'none';
firstElement.style.transform = 'scale(1)';
secondElement.style.transform = 'scale(1)';
}
const right = firstElement?.getBoundingClientRect()?.right;
const left = secondElement?.getBoundingClientRect()?.left;
if (centerMode && firstElement.style && secondElement.style) {
firstElement.style.removeProperty('transform');
secondElement.style.removeProperty('transform');
// Trigger a reflow, flushing the CSS changes, avoiding transition effect
firstElement.offsetHeight;
secondElement.offsetHeight;
firstElement.style.removeProperty('transition');
secondElement.style.removeProperty('transition');
}
if (right !== undefined && left !== undefined) {
gap = Math.abs(right - left);
}
}
const gapSpace = (numElementsPerPage.current - 1) * gap;
const sliceWidth = (carouselContainer.getBoundingClientRect().width -
gapSpace -
(extraPadding ? 2 * extraPadding : 0)) /
numElementsPerPage.current;
// Use the width calculated for each slice
for (let i = 0; i < carouselContent.childNodes.length; i++) {
const child = carouselContent.childNodes[i];
if (child.style) {
child.style.width = `${sliceWidth}px`;
}
}
}
// If is circular, then clone the first and last numElementsPerPage at first and end of the carusel
// This will create a effect of infinitive carousel
// Delete current nodes if already cloned
if (carouselContent.children.length > elements.length) {
const elementsClonedToEachSide = (carouselContent.children.length - elements.length) / 2;
for (let i = 0; i < elementsClonedToEachSide; i++) {
if (carouselContent.firstElementChild) {
carouselContent.removeChild(carouselContent.firstElementChild);
}
if (carouselContent.lastElementChild) {
carouselContent.removeChild(carouselContent.lastElementChild);
}
}
}
if (circular && elements.length > numElementsPerPage.current) {
const listCloneFirst = [];
const listCloneLast = [];
const numElements = carouselContent.children.length;
// +1 in case of extra padding
const numElementsToClone = numElementsPerPage.current + 1;
for (let i = 0; i < numElementsToClone; i++) {
const nodeFirst = carouselContent.children[i];
if (nodeFirst) {
listCloneFirst.push(nodeFirst.cloneNode(true));
}
const nodeLast = carouselContent.children[numElements - 1 - i];
if (nodeLast) {
listCloneLast.push(nodeLast.cloneNode(true));
}
}
// Add list clone first to the children array (at the back)
listCloneFirst.forEach(node => carouselContent.appendChild(node));
// Add list clone last to the children array (at the front)
listCloneLast.forEach(node => carouselContent.insertBefore(node, carouselContent.firstChild));
}
// Set the page in the right position without animation
handlePageChange(0, false);
caouselInitialized.current = true;
};
const initializeNumElementsPerPage = () => {
const carouselContainer = carouselContainerRef.current;
const carouselContent = carouselContentRef.current;
if (!carouselContainer || !carouselContent) {
return;
}
// Initialize numElementsperPage
// If pagination has been hidden due to newNumPages is <= 1, show pagination pagination
showArrowsAndPagination({ carouselContainer, carouselContent });
numElementsPerPage.current =
numElementsPerPageProp ||
calcNumElementsPerPage({
carouselContainer,
carouselContent,
elementsLength: elements.length,
extraPadding,
}) ||
1;
const newNumPages = calcNumPages({
elementsLength: elements.length,
numElementsPerPage: numElementsPerPage.current,
numElementsToSlide,
});
if (numPagesRef.current !== newNumPages) {
// Initialize index when numPages change
index.current = Math.min(Math.max(0, defaultPage), numPagesRef.current - 1);
}
numPagesRef.current = newNumPages;
// If newNumPages <= 1, then hide pagination
if (numPagesRef.current <= 1) {
hideArrowsAndPagination({
carouselContainer,
carouselContent,
onePageAlign,
allowModifySliceWidth,
});
}
setNumPagesState(numPagesRef.current);
};
const handleResize = () => {
initializeNumElementsPerPage();
// After initialice numElementsPerPage and numPagesRef -> Initialice carousel
initializeCarousel();
};
React.useEffect(() => {
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [onePageAlign, numElementsPerPageProp, elements]);
// In circular carousel will create the effect of infinite
const handleTransitionEnd = (event) => {
const carouselContent = carouselContentRef.current;
// Do not continue if the transition ends due to another property that is not left
if (!carouselContent ||
!numElementsPerPage.current ||
event.propertyName !== PROPERTY_NAME_TO_HANDLE_TRANSITION_END) {
return;
}
// Delete transition effects
carouselContent.classList.remove('shifting');
// Fix index
if (index.current === numPagesRef.current) {
// Return to page 0
// firstIndexInView is the number of elements cloned
const firstIndexInView = numElementsPerPage.current + 1;
let distance = calcXDistanceBetween2Elements(carouselContent.firstChild, carouselContent.children[firstIndexInView]);
if (extraPadding) {
distance -= extraPadding;
}
carouselContent.style.left = `${-distance}px`;
index.current = 0;
}
if (index.current === -1) {
// Return to last page
const firstIndexInView = elements.length + 1;
let distance = calcXDistanceBetween2Elements(carouselContent.firstChild, carouselContent.children[firstIndexInView]);
if (extraPadding) {
distance -= extraPadding;
}
carouselContent.style.left = `${-distance}px`;
index.current = numPagesRef.current - 1;
}
// Go through all carousel elements
// Only the show elements should be visible aria visible
const { firstIndexInView, lastIndexInView } = getFirstAndLastIndexInCarouselView({
elementsLength: elements.length,
numElementsPerPage: numElementsPerPage.current,
numElementsToSlide,
currentPage: index.current,
isCircular: circular,
});
for (let i = 0; i < carouselContent.children.length; i++) {
if (i < firstIndexInView || i > lastIndexInView) {
carouselContent.children[i].setAttribute('aria-hidden', 'true');
}
else {
carouselContent.children[i].removeAttribute('aria-hidden');
}
}
// Apply center modo
if (centerMode && numElementsPerPage.current % 2 !== 0) {
const hightlightIndex = firstIndexInView + (lastIndexInView - firstIndexInView) / 2;
carouselContent.children[hightlightIndex].classList.add('highlight');
}
allowShift.current = true;
if (caouselInitialized.current) {
onTransition?.(false);
}
};
// Variables for the listener
const isDragging = React.useRef(false);
const isHorizontalDragging = React.useRef(false);
const isVerticalDragging = React.useRef(false);
const posInitial = React.useRef();
const posX1 = React.useRef(0);
const posX2 = React.useRef(0);
const posXInitial = React.useRef(0);
const posYInitial = React.useRef(0);
// Decide during the first INITIAL_MOVE_THRESHOLD if is a horizontal o vertical swipe
// In order to allow drag and block the scroll or not
const { allowScroll, blockScroll } = useScrollBlock();
const dragStart = e => {
const carouselContent = carouselContentRef.current;
if (!carouselContent) {
return;
}
e = e || window.event;
posInitial.current = carouselContent.offsetLeft;
if (e.type === 'touchstart') {
posX1.current = e.touches[0].clientX;
posXInitial.current = e.touches[0].clientX;
posYInitial.current = e.touches[0].clientY;
}
else {
posX1.current = e.clientX;
posXInitial.current = e.clientX;
posYInitial.current = e.clientY;
}
isDragging.current = true;
};
const dragAction = e => {
const carouselContent = carouselContentRef.current;
if (!carouselContent || !isDragging.current || !numElementsPerPage.current) {
return;
}
e = e || window.event;
let posXAcc;
let posYAcc;
if (e.type === 'touchmove') {
posX2.current = posX1.current - e.touches[0].clientX;
posX1.current = e.touches[0].clientX;
posXAcc = e.touches[0].clientX - posXInitial.current;
posYAcc = e.touches[0].clientY - posYInitial.current;
}
else {
posX2.current = posX1.current - e.clientX;
posX1.current = e.clientX;
posXAcc = e.clientX - posXInitial.current;
posYAcc = e.clientY - posYInitial.current;
}
const left = carouselContent.offsetLeft - posX2.current;
// Decide during the first INITIAL_MOVE_THRESHOLD if is a horizontal o vertical swipe
// In order to allow drag and block the scroll or not
if (!isHorizontalDragging.current && !isVerticalDragging.current) {
if (Math.abs(posXAcc) > THRESHOLD_TO_DECIDE_IF_ALLOW_DRAGGING) {
// allow drag and block scroll
isHorizontalDragging.current = true;
onTransition?.(true);
blockScroll();
}
else if (Math.abs(posYAcc) > THRESHOLD_TO_DECIDE_IF_ALLOW_DRAGGING) {
// block drag and enable scroll
isVerticalDragging.current = true;
}
}
if (!isHorizontalDragging.current) {
return;
}
// Only set left position in some cases
if (circular && elements.length > numElementsPerPage.current) {
carouselContent.style.left = `${left}px`;
}
else {
const maxDistance = calcXDistanceBetween2Elements(carouselContent.firstChild, carouselContent.children[carouselContent.children.length - numElementsPerPage.current]);
// Depending if the first or the last element of the carousel is centered or not
if (centerExtremesWhenExtraPadding) {
if (left - (extraPadding || 0) < 0 && -left < maxDistance - (extraPadding || 0)) {
carouselContent.style.left = `${left}px`;
}
}
else {
let modifyExtraPadding = circular ? (extraPadding ?? 0) : 0;
if (!circular && extraPadding && index.current === numPagesState - 1) {
modifyExtraPadding = extraPadding * 2;
}
if (left - modifyExtraPadding < 0 && -left < maxDistance - modifyExtraPadding) {
carouselContent.style.left = `${left}px`;
}
}
}
};
const dragEnd = () => {
const carouselContent = carouselContentRef.current;
isVerticalDragging.current = false;
if (isHorizontalDragging.current) {
allowScroll();
}
isHorizontalDragging.current = false;
if (!carouselContent || !isDragging.current || posInitial.current === undefined) {
return;
}
isDragging.current = false;
const posFinal = carouselContent.offsetLeft;
if (posFinal - posInitial.current < -THRESHOLD_TO_DECIDE_IF_SWIPE_ON_DRAG_ENDS) {
onRightSwipe();
}
else if (posFinal - posInitial.current > THRESHOLD_TO_DECIDE_IF_SWIPE_ON_DRAG_ENDS) {
onLeftSwipe();
}
else {
// Return to initial pos
carouselContent.style.left = `${posInitial.current}px`;
}
};
// Need to know when the shift key is pressed to manage the focus direction
const shiftPressed = React.useRef(false);
// On focus carouselContent event
const onFocus = () => {
const carouselContent = carouselContentRef.current;
if (!carouselContent || !numElementsPerPage.current) {
return;
}
const { firstIndexInView, lastIndexInView } = getFirstAndLastIndexInCarouselView({
elementsLength: elements.length,
numElementsPerPage: numElementsPerPage.current,
numElementsToSlide,
currentPage: index.current,
isCircular: circular,
});
for (let i = 0; i < carouselContent.children.length; i++) {
// if there carousel item have focus but should not
if (carouselContent.children[i].contains(document.activeElement) &&
(i < firstIndexInView || i > lastIndexInView)) {
if (i >= lastIndexInView + 1) {
// When shift key pressed should focus in the last element in the carousel content
if (shiftPressed.current) {
focusElementOrFirstDescendant(carouselContent.children[lastIndexInView]);
return;
}
// When no shift, should get focus in the next available focusable element, else first element in body
const hasFocused = focusNextFocusableElement(carouselContent);
if (!hasFocused) {
focusElementOrFirstDescendant(document.body);
}
return;
}
// i < firstIndexInView
// When shift, should get focus in the previous available focusable element
if (shiftPressed.current) {
const hasFocused = focusPreviousFocusableElement(carouselContent);
if (!hasFocused) {
focusElementOrFirstDescendant(carouselContent.children[lastIndexInView]);
}
return;
}
// Focus in the first element availble in the carousel content view
focusElementOrFirstDescendant(carouselContent.children[firstIndexInView]);
return;
}
}
};
const onKeyDown = e => {
shiftPressed.current = !!e.shiftKey;
};
const onKeyUp = e => {
shiftPressed.current = !!e.shiftKey;
};
// Prevent scroll on focus
const onScroll = e => {
if (e.target) {
e.target.scrollLeft = 0;
}
};
// Initialize listener for dragging and when scroll finished
React.useEffect(() => {
if (numPagesState > 1) {
// Transition events
carouselContentRef.current?.addEventListener('transitionend', handleTransitionEnd);
// Touch events
if (!disableSwipe) {
carouselContentRef.current?.addEventListener('touchstart', dragStart);
carouselContentRef.current?.addEventListener('touchend', dragEnd);
carouselContentRef.current?.addEventListener('touchmove', dragAction);
carouselContentRef.current?.addEventListener('mousedown', dragStart);
carouselContentRef.current?.addEventListener('mouseup', dragEnd);
carouselContentRef.current?.addEventListener('mousemove', dragAction);
carouselContentRef.current?.addEventListener('mouseleave', dragEnd);
}
}
// Focus events
carouselContentRef.current?.addEventListener('focusin', onFocus);
carouselContainerRef.current?.addEventListener('scroll', onScroll);
// In order to know if the shift key was pressed when entering the component, we have to put the listener in the document
document?.addEventListener('keydown', onKeyDown);
document?.addEventListener('keyup', onKeyUp);
return () => {
// Transition events
carouselContentRef.current?.removeEventListener('transitionend', handleTransitionEnd);
// Touch events
carouselContentRef.current?.removeEventListener('touchstart', dragStart);
carouselContentRef.current?.removeEventListener('touchend', dragEnd);
carouselContentRef.current?.removeEventListener('touchmove', dragAction);
carouselContentRef.current?.removeEventListener('mousedown', dragStart);
carouselContentRef.current?.removeEventListener('mouseup', dragEnd);
carouselContentRef.current?.removeEventListener('mousemove', dragAction);
carouselContentRef.current?.removeEventListener('mouseleave', dragEnd);
// Focus events
carouselContentRef.current?.removeEventListener('focusin', onFocus);
carouselContainerRef.current?.removeEventListener('scroll', onScroll);
document?.removeEventListener('keydown', onKeyDown);
document?.removeEventListener('keyup', onKeyUp);
};
}, [numPagesState, disableSwipe]);
const handlePageChange = (increment, animation = true) => {
const carouselContent = carouselContentRef.current;
const carouselContainer = carouselContainerRef.current;
if (!carouselContainer ||
!carouselContent ||
!allowShift.current ||
!numElementsPerPage.current ||
!elements.length) {
return;
}
// Add transition effect
if (animation) {
carouselContent.classList.add('shifting');
}
// Go through all carousel elements
// Delete all the highlight elements, in order to do the animation when center mode
for (let i = 0; i < carouselContent.children.length; i++) {
carouselContent.children[i].classList.remove('highlight');
}
index.current += increment;
// Calc first and last index in the carousel view
const { firstIndexInView, lastIndexInView } = getFirstAndLastIndexInCarouselView({
elementsLength: elements.length,
numElementsPerPage: numElementsPerPage.current,
numElementsToSlide,
currentPage: index.current,
isCircular: circular,
});
// When isnt allow to modify the slice width, carousel contiainer width must be calculated
if (!allowModifySliceWidth) {
// Calc the new carousel container width
const firstElementInView = carouselContent.children[firstIndexInView];
const lastElementInView = carouselContent.children[lastIndexInView];
let distanceBetweenElementsInView = calcXDistanceBetween2Elements(firstElementInView, lastElementInView);
distanceBetweenElementsInView += lastElementInView.offsetWidth;
if (extraPadding) {
distanceBetweenElementsInView += 2 * extraPadding;
}
carouselContainer.style.width = `${distanceBetweenElementsInView}px`;
}
// Calc new left translation
let distance = calcXDistanceBetween2Elements(carouselContent.firstChild, carouselContent.children[firstIndexInView]);
if (extraPadding) {
if (centerExtremesWhenExtraPadding) {
distance -= extraPadding;
}
else {
if (circular || (index.current !== 0 && index.current !== numPagesRef.current - 1)) {
distance -= extraPadding;
}
else if (!circular && index.current === numPagesRef.current - 1) {
distance -= extraPadding * 2;
}
}
}
const prevLeftStyle = carouselContent.style.left;
const newLeftStyle = `${-distance}px`;
carouselContent.style.left = newLeftStyle;
if (animation && prevLeftStyle !== newLeftStyle) {
allowShift.current = false;
}
else {
// Call manually to handleTransitionEnd because transitionend listener will be not executed
handleTransitionEnd({
propertyName: PROPERTY_NAME_TO_HANDLE_TRANSITION_END,
});
}
};
return { handlePageChange, allowShift, numPages: numPagesState, innerCurrentPage: index };
};
//# sourceMappingURL=useCarousel.js.map