import { createBreakpoints, Box, Flex } from '@semcore/base-components'; import type { BoxProps } from '@semcore/base-components'; import Button from '@semcore/button'; import { createComponent, Component, sstyled, Root } from '@semcore/core'; import i18nEnhance from '@semcore/core/lib/utils/enhances/i18nEnhance'; import { findAllComponents } from '@semcore/core/lib/utils/findComponent'; import logger from '@semcore/core/lib/utils/logger'; import uniqueIDEnhancement from '@semcore/core/lib/utils/uniqueID'; import ChevronLeft from '@semcore/icon/ChevronLeft/l'; import ChevronRight from '@semcore/icon/ChevronRight/l'; import Modal from '@semcore/modal'; import React from 'react'; import type CarouselType from './Carousel.types'; import type { CarouselProps, CarouselState, CarouselContext, CarouselItem, CarouselItemProps, CarouselButtonProps, CarouselIndicatorsProps, CarouselIndicatorProps, } from './Carousel.types'; import style from './style/carousel.shadow.css'; import { localizedMessages } from './translations/__intergalactic-dynamic-locales'; const MAP_TRANSFORM: Record = { ArrowLeft: 'left', ArrowRight: 'right', }; const enhance = [uniqueIDEnhancement(), i18nEnhance(localizedMessages)] as const; const media = ['(min-width: 481px)', '(max-width: 480px)']; const BreakPoints = createBreakpoints(media); const isSmallScreen = (index?: number) => index === 1; class CarouselRoot extends Component< CarouselProps, typeof enhance, { index: any }, CarouselContext, CarouselState > { static displayName = 'Carousel'; static defaultProps = { defaultIndex: 0, duration: 350, step: 100, bounded: false, i18n: localizedMessages, locale: 'en', indicators: 'default', }; static style = style; static enhance = enhance; defaultItemsCount = 0; refCarousel = React.createRef(); refContainer = React.createRef(); refModalContainer = React.createRef(); _touchStartCoord = -1; constructor(props: CarouselProps) { super(props); this.isControlled = props.index !== undefined; this.state = { items: [], isOpenZoom: false, selectedIndex: props.index ?? props.defaultIndex ?? 0, }; } uncontrolledProps() { return { index: [ null, (_index: number) => { this.refCarousel.current?.blur(); setTimeout(() => { this.refCarousel.current?.focus(); }, 0); }, ], }; } componentDidMount() { const { selectedIndex } = this.state; if (selectedIndex !== 0) { if (selectedIndex < 0 || selectedIndex >= this.defaultItemsCount) { logger.warn( true, `You couldn't use value for the \`index\` or \`defaultIndex\` not from \`Item's\` length range.`, CarouselRoot.displayName, ); this.setState({ selectedIndex: 0 }); } else { this.transformContainer(); } } const deprecatedComponents = findAllComponents(this.asProps.Children, [ 'Carousel.Prev', 'Carousel.Next', 'Carousel.Indicators', ]); logger.warn( deprecatedComponents.length > 0, 'Please, try to remove `Prev`, `Next`, `Indicators` and other children components from your Carousel, except only `Item` elements.', CarouselRoot.displayName, ); } componentDidUpdate(prevProps: CarouselProps) { const { index } = this.asProps; if (prevProps.index !== index && this.isControlled && index !== undefined) { this.setState({ selectedIndex: index }, () => this.transformContainer()); } } handlerKeyDown = (e: React.KeyboardEvent) => { const firstSlide = 1; const lastSlide = this.state.items.length + 1; switch (e.key) { case 'ArrowLeft': case 'ArrowRight': { e.preventDefault(); this.transformItem(MAP_TRANSFORM[e.key]); break; } case 'Home': { e.preventDefault(); this.slideToValue(firstSlide); break; } case 'End': { e.preventDefault(); this.slideToValue(lastSlide); break; } } if (e.metaKey) { // like home or end if (e.key === 'ArrowLeft') { e.preventDefault(); this.slideToValue(firstSlide); } else if (e.key === 'ArrowRight') { e.preventDefault(); this.slideToValue(lastSlide); } } if ( (e.key === 'Enter' || e.key === ' ') && e.target instanceof HTMLDivElement && e.target.role === 'tabpanel' ) { this.handleToggleZoomModal(); } }; toggleItem = (item: CarouselItem, removeItem = false) => { this.setState((prevState) => { const newItems = removeItem ? prevState.items.filter((element) => element.node !== item.node) : [...prevState.items, item]; return { items: newItems, }; }); if (!removeItem) { this.defaultItemsCount++; } }; transformContainer = () => { const transform = this.state.selectedIndex * -1 * 100; if (this.refContainer.current) { this.refContainer.current.style.transform = `translateX(${transform}%)`; } if (this.refModalContainer.current) { this.refModalContainer.current.style.transform = `translateX(${transform}%)`; } }; getDirection = (currentIndex: number, nextIndex: number) => { const { bounded } = this.asProps; if (bounded) { return currentIndex < nextIndex ? 'right' : 'left'; } const { items } = this.state; const listIndex = items.map((_, ind) => ind); const tmpArr = [...listIndex]; const minTmpArr = tmpArr[0]; const maxTmpArr = tmpArr[tmpArr.length - 1]; if (tmpArr.length === 2) { return currentIndex < nextIndex ? 'right' : 'left'; } if (currentIndex === minTmpArr) { tmpArr.unshift(maxTmpArr); tmpArr.pop(); } if (currentIndex === maxTmpArr) { tmpArr.shift(); tmpArr.push(minTmpArr); } const tmpCurrentIndex = tmpArr.indexOf(currentIndex); const left = tmpArr.indexOf(nextIndex); return left - tmpCurrentIndex < 0 ? 'left' : 'right'; }; slideToValue = (nextIndex: number) => { const { selectedIndex } = this.state; const direction = selectedIndex < nextIndex ? 'right' : 'left'; let diff = Math.abs(selectedIndex - nextIndex); while (diff > 0) { this.transformItem(direction); diff--; } }; transformItem = (direction: 'left' | 'right') => { const { bounded } = this.asProps; const { items, selectedIndex } = this.state; const maxIndexIndicator = items.length - 1; if (direction === 'right') { if (bounded && selectedIndex === maxIndexIndicator) { this.handlers.index(maxIndexIndicator); return; } if (this.isControlled) { this.handlers.index(selectedIndex === maxIndexIndicator ? 0 : selectedIndex + 1); return; } this.setState( (prevState) => ({ selectedIndex: prevState.selectedIndex + 1, }), () => { this.transformContainer(); this.handlers.index(this.getIndex()); }, ); return; } if (direction === 'left') { if (bounded && selectedIndex === 0) { this.handlers.index(0); return; } if (this.isControlled) { this.handlers.index(selectedIndex === 0 ? maxIndexIndicator : selectedIndex - 1); return; } this.setState( (prevState) => ({ selectedIndex: prevState.selectedIndex - 1, }), () => { this.transformContainer(); this.handlers.index(this.getIndex()); }, ); return; } }; bindHandlerClick = (direction: 'left' | 'right') => { return () => { this.transformItem(direction); }; }; bindHandlerClickIndicator = (value: number) => { return () => { const { selectedIndex, items } = this.state; if (!this.isControlled && value !== selectedIndex) { const newValueIndex = Math.floor(selectedIndex / items.length) * items.length + value; this.slideToValue(newValueIndex); } this.handlers.index(value); }; }; handlerTouchStart = (e: React.TouchEvent) => { this._touchStartCoord = e.changedTouches[0].clientX; }; handlerTouchEnd = (e: React.TouchEvent) => { const touchEndCoord = e.changedTouches[0].clientX; const delta = touchEndCoord - this._touchStartCoord; if (delta > 50) { this.transformItem('left'); } else if (delta < -50) { this.transformItem('right'); } }; getContainerProps() { const { duration } = this.asProps; return { ref: this.refContainer, duration, }; } getItemProps(_props: CarouselItemProps, index: number) { const { zoom } = this.asProps; const isCurrent = this.isSelected(index); return { toggleItem: this.toggleItem, uid: this.asProps.uid, index, current: isCurrent, zoomIn: zoom, onToggleZoomModal: this.handleToggleZoomModal, transform: isCurrent ? this.getTransform() : undefined, isOpenZoom: this.state.isOpenZoom, }; } handleToggleZoomModal = () => { this.setState( (prevState) => { return { isOpenZoom: !prevState.isOpenZoom, }; }, () => { if (this.state.isOpenZoom) { this.transformContainer(); } }, ); }; bindHandlerKeydownControl = (direction: 'left' | 'right') => (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.bindHandlerClick(direction)(); } }; getPrevProps() { const { bounded, getI18nText, uid } = this.asProps; const { items, selectedIndex } = this.state; let disabled = false; if (items.length && bounded) { disabled = selectedIndex === 0; } return { 'onClick': this.bindHandlerClick('left'), 'onKeyDown': this.bindHandlerKeydownControl('left'), disabled, 'label': getI18nText('prev'), 'aria-controls': `igc-${uid}-carousel`, }; } getNextProps() { const { bounded, getI18nText, uid } = this.asProps; const { items, selectedIndex } = this.state; let disabled = false; if (items.length && bounded) { disabled = selectedIndex === items.length - 1; } return { 'onClick': this.bindHandlerClick('right'), 'onKeyDown': this.bindHandlerKeydownControl('right'), disabled, 'label': getI18nText('next'), 'aria-controls': `igc-${uid}-carousel`, }; } getIndicatorsProps() { const { items } = this.state; const { getI18nText } = this.asProps; return { 'items': items.map((_item, key) => ({ active: this.isSelected(key), onClick: this.bindHandlerClickIndicator(key), key, })), 'role': 'tablist', 'tabIndex': 0, 'aria-label': getI18nText('slides'), }; } getIndicatorProps(_: any, index: number) { const isCurrent = this.isSelected(index); const { getI18nText } = this.asProps; return { 'role': 'tab', 'aria-selected': isCurrent, 'aria-controls': `igc-${this.asProps.uid}-carousel-item-${index}`, 'aria-label': getI18nText('slide', { slideNumber: index + 1 }), }; } getTransform() { const { items, selectedIndex } = this.state; const direction = selectedIndex > 0 ? 1 : -1; const count = items.length === 0 ? 0 : Math.floor(selectedIndex / items.length) * direction; const transform = selectedIndex > 0 && selectedIndex < items.length ? 0 : 100 * direction * count * items.length; return transform; } getIndex() { const { items, selectedIndex } = this.state; if (items.length === 0) { return 0; } if (selectedIndex >= 0) { return selectedIndex % items.length; } return (items.length + (selectedIndex % items.length)) % items.length; } isSelected(index: number) { const { items } = this.state; if (items.length === 0) { return true; } return index === this.getIndex(); } renderModal(isSmall: boolean, ComponentItems: any[]) { const SModalContainer = Root; const SModalBox = Box; const SImageBoxContainer = Box; const { styles, uid, duration, zoomWidth } = this.asProps; const { isOpenZoom } = this.state; return sstyled(styles)( {!isSmall && } {ComponentItems.map((item, i) => { return ( ); })} {isSmall ? ( ) : ( )} {!isSmall && } , ); } render() { const SCarousel = Root; const { styles, Children, uid, zoom: hasZoom, 'aria-label': ariaLabel, indicators, getI18nText, } = this.asProps; const ComponentItems = findAllComponents(Children, ['Carousel.Item']); const Controls = findAllComponents(Children, [ 'Carousel.Prev', 'Carousel.Next', 'Carousel.Indicators', ]); return sstyled(styles)( {Controls.length === 0 ? ( <> {indicators === 'default' && } {indicators === 'preview' && ( {() => ComponentItems.map((item, index) => ( ))} )} ) : ( )} {hasZoom && ( {(mediaIndex) => this.renderModal(isSmallScreen(mediaIndex), ComponentItems)} )} , ); } } function Container(props: BoxProps & { duration?: number }) { const SContainer = Root; const { styles, duration } = props; return sstyled(styles)( , ); } function ContentBox(props: BoxProps) { const SContentBox = Root; const { styles } = props; return sstyled(styles)(); } class Item extends Component { refItem = React.createRef(); componentDidMount() { const { toggleItem, transform } = this.props; const refItem = this.refItem.current; if (toggleItem && refItem) { toggleItem({ node: refItem }); } if (transform && refItem) { refItem.style.transform = `translateX(${transform}%)`; } } componentWillUnmount() { const { toggleItem } = this.props; const refItem = this.refItem.current; if (toggleItem && refItem) { toggleItem({ node: refItem }, true); } } componentDidUpdate(prevProps: CarouselItemProps) { const transform = this.props.transform; const refItem = this.refItem.current; if (prevProps.transform !== transform && refItem) { refItem.style.transform = `translateX(${transform}%)`; } } render() { const { styles, index, uid, current, zoomIn, onToggleZoomModal } = this.props; const SItem = Root; return sstyled(styles)( , ); } } function Prev(props: CarouselButtonProps) { const { styles, children, Children, label, top = 0, inverted } = props; const SPrev = Root; const SPrevButton = Button; return sstyled(styles)( {children ? ( ) : ( )} , ); }; function Next(props: CarouselButtonProps) { const { styles, children, Children, label, top = 0, inverted } = props; const SNext = Root; const SNextButton = Button; return sstyled(styles)( {children ? ( ) : ( )} , ); }; function Indicators({ items, styles, Children, inverted }: CarouselIndicatorsProps) { const SIndicators = Root; if (Children.origin) { return sstyled(styles)( , ); } return sstyled(styles)( {items?.map((item: CarouselItem, index: number) => ( ))} , ); }; function Indicator({ styles, Children, inverted }: CarouselIndicatorProps) { const SIndicator = Root; return sstyled(styles)( , ); }; const Carousel = createComponent(CarouselRoot, { Container, ContentBox, Indicators, Indicator, Item, Prev, Next, }) as typeof CarouselType; export default Carousel;