From 7f1ae500ec574132b947e8d7cc271d644fa8703e Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Tue, 26 Jul 2022 12:27:05 -0700 Subject: [PATCH 1/3] feat: allow changing slides via a CarouselRef; add more className props --- README.md | 43 +- packages/nuka/src/carousel.tsx | 1201 ++++++++++--------- packages/nuka/src/hooks/use-frame-height.ts | 18 +- packages/nuka/src/slide.tsx | 67 +- packages/nuka/src/types.ts | 27 + packages/nuka/stories/carousel.stories.tsx | 68 +- 6 files changed, 798 insertions(+), 626 deletions(-) diff --git a/README.md b/README.md index 68ae5c40..f3f06988 100644 --- a/README.md +++ b/README.md @@ -88,12 +88,14 @@ You can play with `¶ms` url parameter to add or remove any carousel paramete | frameAriaLabel | `string` | Customize the aria-label of the frame container of the carousel. This is useful when you have more than one carousel on the page. | `''` | | innerRef | `MutableRefObject` | React `ref` that should be set on the carousel element | | | keyCodeConfig |
interface KeyCodeConfig { 
  firstSlide?: number[]; 
  lastSlide?: number[];
  nextSlide?: number[]; 
  pause?: number[]; 
  previousSlide?: number[]; 
}
| If `enableKeyboardControls` prop is true, you can pass configuration for the keyCode so you can override the default keyboard keys configured. | `{ nextSlide: [39, 68, 38, 87], previousSlide: [37, 65, 40, 83], firstSlide: [81], lastSlide: [69], pause: [32] }` | +| listClassName | `string` | Extra className to be added to the scrollable list wrapper | | | onDragStart | `(e?: React.TouchEvent \| React.MouseEvent) => void;` | Adds a callback to capture event at the start of swiping/dragging slides | | | onDrag | `(e?: React.TouchEvent \| React.MouseEvent) => void;` | Adds a callback to capture swiping/dragging event on slides | | | onDragEnd | `(e?: React.TouchEvent \| React.MouseEvent) => void;` | Adds a callback to capture event at the ent of swiping/dragging slides | | | pauseOnHover | `boolean` | Pause autoPlay when mouse is over carousel. | `true` | | renderAnnounceSlideMessage | `(props: Pick) => string` | Renders message in the ARIA live region that is announcing the current slide on slide change | Render function that returns `"Slide {currentSlide + 1} of {slideCount}"` | | scrollMode | `'page' \| 'remainder'` | Set `scrollMode="remainder"` if you don't want to see the white space when you scroll to the end of a non-infinite carousel. scrollMode property is ignored when wrapAround is enabled | `'page'` | +| slideClassName | `string` | Extra className to be added to the container for each slide | | | slideIndex | `number` | Manually set the index of the slide to be shown | | | slidesToScroll | `number` | Slides to scroll at once. The property is overridden to `slidesToShow` when `animation="fade"` | 1 | | slidesToShow | `number` | Number of slides to show at once. Will be cast to an `integer` when `animation="fade"` | 1 | @@ -186,25 +188,31 @@ defaultControlsConfig={{ }} ``` - +export default MyComponent; +``` ### Contributing diff --git a/packages/nuka/src/carousel.tsx b/packages/nuka/src/carousel.tsx index 875a195d..950ed08a 100644 --- a/packages/nuka/src/carousel.tsx +++ b/packages/nuka/src/carousel.tsx @@ -1,8 +1,19 @@ -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import React, { + useEffect, + useState, + useRef, + useCallback, + useImperativeHandle +} from 'react'; import Slide from './slide'; import AnnounceSlide from './announce-slide'; import { getSliderListStyles } from './slider-list'; -import { CarouselProps, InternalCarouselProps, KeyCodeFunction } from './types'; +import { + CarouselProps, + CarouselRef, + InternalCarouselProps, + KeyCodeFunction +} from './types'; import renderControls from './controls'; import defaultProps from './default-carousel-props'; import { @@ -18,632 +29,654 @@ interface KeyboardEvent { keyCode: number; } -export const Carousel = (rawProps: CarouselProps): React.ReactElement => { - /** - * We need this cast because we want the component's properties to seem - * optional to external users, but always-present for the internal - * implementation. - * - * This cast is safe due to the `Carousel.defaultProps = defaultProps;` - * statement below. That guarantees all the properties are present, since - * `defaultProps` has type `InternalCarouselProps`. - */ - const props = rawProps as InternalCarouselProps; - - const { - adaptiveHeight, - adaptiveHeightAnimation, - afterSlide, - animation, - autoplay, - autoplayInterval, - autoplayReverse, - beforeSlide, - cellAlign, - cellSpacing, - children, - className, - disableAnimation, - disableEdgeSwiping, - dragging, - dragThreshold: propsDragThreshold, - enableKeyboardControls, - frameAriaLabel, - innerRef, - keyCodeConfig, - onDrag, - onDragEnd, - onDragStart, - pauseOnHover, - renderAnnounceSlideMessage, - scrollMode, - slideIndex, - slidesToScroll: propsSlidesToScroll, - slidesToShow, - speed: propsSpeed, - style, - swiping, - wrapAround, - zoomScale - } = props; - - const count = React.Children.count(children); - - const [currentSlide, setCurrentSlide] = useState( - autoplayReverse ? count - slidesToShow : slideIndex - ); - const [animationEnabled, setAnimationEnabled] = useState(false); - const [pause, setPause] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const [move, setMove] = useState(0); - const [keyboardMove, setKeyboardMove] = useState(null); - const carouselWidth = useRef(null); - - const focus = useRef(false); - const prevMove = useRef(0); - const carouselEl = useRef(null); - const timer = useRef | null>(null); - const isMounted = useRef(true); - - const slidesToScroll = - animation === 'fade' ? slidesToShow : propsSlidesToScroll; - - const dragThreshold = - ((carouselWidth.current || 0) / slidesToShow) * propsDragThreshold; - - const [slide] = getIndexes( - currentSlide, - currentSlide - slidesToScroll, - count - ); - - useEffect(() => { - isMounted.current = true; - return () => { - isMounted.current = false; - }; - }, []); - - useEffect(() => { - // disable img draggable attribute by default, this will improve the dragging - document - .querySelectorAll('.slider-list img') - .forEach((el) => el.setAttribute('draggable', 'false')); - }, []); - - const carouselRef = innerRef || carouselEl; - - const getNextIndex = useCallback( - (to?: number) => { - const index = to ?? currentSlide; - if (index < 0) { - return index + count; - } - if (index === count) { - return 0; - } - return index; - }, - [count, currentSlide] - ); +export const Carousel = React.forwardRef( + function Carousel( + rawProps: CarouselProps, + ref: React.Ref + ): React.ReactElement { + /** + * We need this cast because we want the component's properties to seem + * optional to external users, but always-present for the internal + * implementation. + * + * This cast is safe due to the `Carousel.defaultProps = defaultProps;` + * statement below. That guarantees all the properties are present, since + * `defaultProps` has type `InternalCarouselProps`. + */ + const props = rawProps as InternalCarouselProps; + + const { + adaptiveHeight, + adaptiveHeightAnimation, + afterSlide, + animation, + autoplay, + autoplayInterval, + autoplayReverse, + beforeSlide, + cellAlign, + cellSpacing, + children, + className, + disableAnimation, + disableEdgeSwiping, + dragging, + dragThreshold: propsDragThreshold, + enableKeyboardControls, + frameAriaLabel, + innerRef, + keyCodeConfig, + listClassName, + onDrag, + onDragEnd, + onDragStart, + pauseOnHover, + renderAnnounceSlideMessage, + scrollMode, + slideClassName, + slideIndex, + slidesToScroll: propsSlidesToScroll, + slidesToShow, + speed: propsSpeed, + style, + swiping, + wrapAround, + zoomScale + } = props; + + const count = React.Children.count(children); + + const [currentSlide, setCurrentSlide] = useState( + autoplayReverse ? count - slidesToShow : slideIndex + ); + const [animationEnabled, setAnimationEnabled] = useState(false); + const [pause, setPause] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [move, setMove] = useState(0); + const [keyboardMove, setKeyboardMove] = useState(null); + const carouselWidth = useRef(null); + + const focus = useRef(false); + const prevMove = useRef(0); + const carouselEl = useRef(null); + const timer = useRef | null>(null); + const isMounted = useRef(true); + + const slidesToScroll = + animation === 'fade' ? slidesToShow : propsSlidesToScroll; + + const dragThreshold = + ((carouselWidth.current || 0) / slidesToShow) * propsDragThreshold; + + const [slide] = getIndexes( + currentSlide, + currentSlide - slidesToScroll, + count + ); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + useEffect(() => { + // disable img draggable attribute by default, this will improve the dragging + document + .querySelectorAll('.slider-list img') + .forEach((el) => el.setAttribute('draggable', 'false')); + }, []); + + const carouselRef = innerRef || carouselEl; + + const getNextIndex = useCallback( + (to?: number) => { + const index = to ?? currentSlide; + if (index < 0) { + return index + count; + } + if (index === count) { + return 0; + } + return index; + }, + [count, currentSlide] + ); + + const moveSlide = useCallback( + (to?: number) => { + const nextIndex = getNextIndex(to); + typeof to === 'number' && beforeSlide(slide, nextIndex); - const moveSlide = useCallback( - (to?: number) => { - const nextIndex = getNextIndex(to); - typeof to === 'number' && beforeSlide(slide, nextIndex); + !disableAnimation && setAnimationEnabled(true); - !disableAnimation && setAnimationEnabled(true); + if (typeof to === 'number') { + setCurrentSlide(to); + } - if (typeof to === 'number') { - setCurrentSlide(to); + setTimeout( + () => { + if (!isMounted.current) return; + typeof to === 'number' && afterSlide(nextIndex); + !disableAnimation && setAnimationEnabled(false); + }, + !disableAnimation ? propsSpeed || 500 : 40 + ); // if animation is disabled decrease the speed to 40 + }, + [ + slide, + afterSlide, + beforeSlide, + disableAnimation, + getNextIndex, + propsSpeed + ] + ); + + const nextSlide = useCallback(() => { + if (wrapAround || currentSlide < count - propsSlidesToScroll) { + const nextPosition = getNextMoveIndex( + scrollMode, + wrapAround, + currentSlide, + count, + propsSlidesToScroll, + slidesToShow + ); + + moveSlide(nextPosition); } + }, [ + count, + currentSlide, + moveSlide, + propsSlidesToScroll, + scrollMode, + wrapAround, + slidesToShow + ]); + + const prevSlide = useCallback(() => { + // boundary + if (wrapAround || currentSlide > 0) { + const prevPosition = getPrevMoveIndex( + scrollMode, + wrapAround, + currentSlide, + propsSlidesToScroll + ); + + moveSlide(prevPosition); + } + }, [currentSlide, moveSlide, propsSlidesToScroll, scrollMode, wrapAround]); + + useImperativeHandle( + ref, + (): CarouselRef => ({ moveSlide, nextSlide, prevSlide }) + ); + + // When user changed the slideIndex property from outside. + const prevMovedToSlideIndex = useRef(slideIndex); + useEffect(() => { + if (slideIndex !== prevMovedToSlideIndex.current && !autoplayReverse) { + moveSlide(slideIndex); + prevMovedToSlideIndex.current = slideIndex; + } + }, [slideIndex, currentSlide, autoplayReverse, moveSlide]); - setTimeout( - () => { - if (!isMounted.current) return; - typeof to === 'number' && afterSlide(nextIndex); - !disableAnimation && setAnimationEnabled(false); - }, - !disableAnimation ? propsSpeed || 500 : 40 - ); // if animation is disabled decrease the speed to 40 - }, - [slide, afterSlide, beforeSlide, disableAnimation, getNextIndex, propsSpeed] - ); - - const nextSlide = useCallback(() => { - if (wrapAround || currentSlide < count - propsSlidesToScroll) { - const nextPosition = getNextMoveIndex( - scrollMode, - wrapAround, - currentSlide, - count, - propsSlidesToScroll, - slidesToShow - ); - - moveSlide(nextPosition); - } - }, [ - count, - currentSlide, - moveSlide, - propsSlidesToScroll, - scrollMode, - wrapAround, - slidesToShow - ]); - - const prevSlide = useCallback(() => { - // boundary - if (wrapAround || currentSlide > 0) { - const prevPosition = getPrevMoveIndex( - scrollMode, - wrapAround, - currentSlide, - propsSlidesToScroll - ); - - moveSlide(prevPosition); - } - }, [currentSlide, moveSlide, propsSlidesToScroll, scrollMode, wrapAround]); - - // When user changed the slideIndex property from outside. - const prevMovedToSlideIndex = useRef(slideIndex); - useEffect(() => { - if (slideIndex !== prevMovedToSlideIndex.current && !autoplayReverse) { - moveSlide(slideIndex); - prevMovedToSlideIndex.current = slideIndex; - } - }, [slideIndex, currentSlide, autoplayReverse, moveSlide]); - - // Makes the carousel infinity when autoplay and wrapAround are enabled - useEffect(() => { - if (autoplay && !animationEnabled && wrapAround) { - if (currentSlide > count) { - setCurrentSlide(currentSlide - count); - if (timer?.current) { - clearTimeout(timer.current); - } - } else if (currentSlide < 0) { - setCurrentSlide(count - -currentSlide); - if (timer?.current) { - clearTimeout(timer.current); + // Makes the carousel infinity when autoplay and wrapAround are enabled + useEffect(() => { + if (autoplay && !animationEnabled && wrapAround) { + if (currentSlide > count) { + setCurrentSlide(currentSlide - count); + if (timer?.current) { + clearTimeout(timer.current); + } + } else if (currentSlide < 0) { + setCurrentSlide(count - -currentSlide); + if (timer?.current) { + clearTimeout(timer.current); + } } } - } - }, [animationEnabled, currentSlide, count, wrapAround, autoplay]); - - useEffect(() => { - if (autoplay && !pause) { - timer.current = setTimeout(() => { - if (autoplayReverse) { - if (!wrapAround && currentSlide > 0) { - prevSlide(); + }, [animationEnabled, currentSlide, count, wrapAround, autoplay]); + + useEffect(() => { + if (autoplay && !pause) { + timer.current = setTimeout(() => { + if (autoplayReverse) { + if (!wrapAround && currentSlide > 0) { + prevSlide(); + } else if (wrapAround) { + prevSlide(); + } + } else if (!wrapAround && currentSlide < count - slidesToShow) { + nextSlide(); } else if (wrapAround) { - prevSlide(); + nextSlide(); } - } else if (!wrapAround && currentSlide < count - slidesToShow) { - nextSlide(); - } else if (wrapAround) { - nextSlide(); - } - }, autoplayInterval); - } - - // Clear the timeout if user hover on carousel - if (autoplay && pause && timer?.current) { - clearTimeout(timer.current); - } + }, autoplayInterval); + } - return () => { - if (timer.current) { + // Clear the timeout if user hover on carousel + if (autoplay && pause && timer?.current) { clearTimeout(timer.current); } - }; - }, [ - currentSlide, - slidesToShow, - count, - pause, - autoplay, - autoplayInterval, - autoplayReverse, - wrapAround, - prevSlide, - nextSlide - ]); - - // Makes the carousel infinity when wrapAround is enabled, but autoplay is disabled - useEffect(() => { - let prevTimeout: ReturnType | null = null; - let nextTimeout: ReturnType | null = null; - - if (wrapAround && !autoplay) { - // if animation is disabled decrease the speed to 0 - const speed = !disableAnimation ? propsSpeed || 500 : 0; - - if (currentSlide <= -slidesToShow) { - // prev - prevTimeout = setTimeout(() => { - if (!isMounted.current) return; - setCurrentSlide(count - -currentSlide); - }, speed + 10); - } else if (currentSlide >= count) { - // next - nextTimeout = setTimeout(() => { - if (!isMounted.current) return; - setCurrentSlide(currentSlide - count); - }, speed + 10); - } - } - return function cleanup() { - if (prevTimeout) { - clearTimeout(prevTimeout); - } - if (nextTimeout) { - clearTimeout(nextTimeout); + return () => { + if (timer.current) { + clearTimeout(timer.current); + } + }; + }, [ + currentSlide, + slidesToShow, + count, + pause, + autoplay, + autoplayInterval, + autoplayReverse, + wrapAround, + prevSlide, + nextSlide + ]); + + // Makes the carousel infinity when wrapAround is enabled, but autoplay is disabled + useEffect(() => { + let prevTimeout: ReturnType | null = null; + let nextTimeout: ReturnType | null = null; + + if (wrapAround && !autoplay) { + // if animation is disabled decrease the speed to 0 + const speed = !disableAnimation ? propsSpeed || 500 : 0; + + if (currentSlide <= -slidesToShow) { + // prev + prevTimeout = setTimeout(() => { + if (!isMounted.current) return; + setCurrentSlide(count - -currentSlide); + }, speed + 10); + } else if (currentSlide >= count) { + // next + nextTimeout = setTimeout(() => { + if (!isMounted.current) return; + setCurrentSlide(currentSlide - count); + }, speed + 10); + } } - }; - }, [ - currentSlide, - autoplay, - wrapAround, - disableAnimation, - propsSpeed, - slidesToShow, - count - ]); - - useEffect(() => { - if (enableKeyboardControls && keyboardMove && focus.current) { - switch (keyboardMove) { - case 'nextSlide': - nextSlide(); - break; - case 'previousSlide': - prevSlide(); - break; - case 'firstSlide': - setCurrentSlide(0); - break; - case 'lastSlide': - setCurrentSlide(count - slidesToShow); - break; - case 'pause': - if (pause && autoplay) { - setPause(false); + + return function cleanup() { + if (prevTimeout) { + clearTimeout(prevTimeout); + } + if (nextTimeout) { + clearTimeout(nextTimeout); + } + }; + }, [ + currentSlide, + autoplay, + wrapAround, + disableAnimation, + propsSpeed, + slidesToShow, + count + ]); + + useEffect(() => { + if (enableKeyboardControls && keyboardMove && focus.current) { + switch (keyboardMove) { + case 'nextSlide': + nextSlide(); break; - } else if (autoplay) { - setPause(true); + case 'previousSlide': + prevSlide(); break; - } - break; + case 'firstSlide': + setCurrentSlide(0); + break; + case 'lastSlide': + setCurrentSlide(count - slidesToShow); + break; + case 'pause': + if (pause && autoplay) { + setPause(false); + break; + } else if (autoplay) { + setPause(true); + break; + } + break; + } + setKeyboardMove(null); } - setKeyboardMove(null); - } - }, [ - keyboardMove, - enableKeyboardControls, - count, - slidesToShow, - pause, - autoplay, - nextSlide, - prevSlide - ]); - - const onKeyPress = useCallback( - (e: Event) => { - if ( - enableKeyboardControls && - focus.current && - (e as unknown as KeyboardEvent).keyCode - ) { - const keyConfig = keyCodeConfig; - for (const func in keyConfig) { - if ( - keyConfig[func as keyof typeof keyConfig]?.includes( - (e as unknown as KeyboardEvent).keyCode - ) - ) { - setKeyboardMove(func as KeyCodeFunction); + }, [ + keyboardMove, + enableKeyboardControls, + count, + slidesToShow, + pause, + autoplay, + nextSlide, + prevSlide + ]); + + const onKeyPress = useCallback( + (e: Event) => { + if ( + enableKeyboardControls && + focus.current && + (e as unknown as KeyboardEvent).keyCode + ) { + const keyConfig = keyCodeConfig; + for (const func in keyConfig) { + if ( + keyConfig[func as keyof typeof keyConfig]?.includes( + (e as unknown as KeyboardEvent).keyCode + ) + ) { + setKeyboardMove(func as KeyCodeFunction); + } } } + }, + [enableKeyboardControls, keyCodeConfig] + ); + + useEffect(() => { + if (carouselEl && carouselEl.current) { + carouselWidth.current = carouselEl.current.offsetWidth; + } else if (innerRef) { + carouselWidth.current = innerRef.current.offsetWidth; + } + + if (enableKeyboardControls) { + addEvent(document, 'keydown', onKeyPress); } - }, - [enableKeyboardControls, keyCodeConfig] - ); - - useEffect(() => { - if (carouselEl && carouselEl.current) { - carouselWidth.current = carouselEl.current.offsetWidth; - } else if (innerRef) { - carouselWidth.current = innerRef.current.offsetWidth; - } - - if (enableKeyboardControls) { - addEvent(document, 'keydown', onKeyPress); - } - - return () => { - removeEvent(document, 'keydown', onKeyPress); - }; - }, [enableKeyboardControls, innerRef, onKeyPress]); - const handleDragEnd = useCallback( - ( - e?: React.MouseEvent | React.TouchEvent - ) => { - if (!dragging || !isDragging) return; + return () => { + removeEvent(document, 'keydown', onKeyPress); + }; + }, [enableKeyboardControls, innerRef, onKeyPress]); + + const handleDragEnd = useCallback( + ( + e?: React.MouseEvent | React.TouchEvent + ) => { + if (!dragging || !isDragging) return; + + setIsDragging(false); + onDragEnd(e); + + if (Math.abs(move) <= dragThreshold) { + moveSlide(); + setMove(0); + prevMove.current = 0; + return; + } - setIsDragging(false); - onDragEnd(e); + if (move > 0) { + nextSlide(); + } else { + prevSlide(); + } - if (Math.abs(move) <= dragThreshold) { - moveSlide(); setMove(0); prevMove.current = 0; - return; - } + }, + [ + dragThreshold, + isDragging, + move, + moveSlide, + nextSlide, + onDragEnd, + prevSlide, + dragging + ] + ); + + const onTouchStart = useCallback( + (e?: React.TouchEvent) => { + if (!swiping) { + return; + } + setIsDragging(true); + onDragStart(e); + }, + [onDragStart, swiping] + ); + + const handlePointerMove = useCallback( + (m: number) => { + if (!dragging || !isDragging) return; + + const moveValue = m * 0.75; // Friction + const moveState = move + (moveValue - prevMove.current); + + // Exit drag early if passed threshold + if (Math.abs(move) > dragThreshold) { + handleDragEnd(); + return; + } - if (move > 0) { - nextSlide(); - } else { - prevSlide(); - } + if ( + !wrapAround && + disableEdgeSwiping && + ((currentSlide <= 0 && moveState <= 0) || + (moveState > 0 && currentSlide >= count - slidesToShow)) + ) { + prevMove.current = moveValue; + return; + } - setMove(0); - prevMove.current = 0; - }, - [ - dragThreshold, - isDragging, - move, - moveSlide, - nextSlide, - onDragEnd, - prevSlide, - dragging - ] - ); - - const onTouchStart = useCallback( - (e?: React.TouchEvent) => { - if (!swiping) { - return; - } - setIsDragging(true); - onDragStart(e); - }, - [onDragStart, swiping] - ); - - const handlePointerMove = useCallback( - (m: number) => { - if (!dragging || !isDragging) return; - - const moveValue = m * 0.75; // Friction - const moveState = move + (moveValue - prevMove.current); - - // Exit drag early if passed threshold - if (Math.abs(move) > dragThreshold) { - handleDragEnd(); - return; - } + if (prevMove.current !== 0) { + setMove(moveState); + } - if ( - !wrapAround && - disableEdgeSwiping && - ((currentSlide <= 0 && moveState <= 0) || - (moveState > 0 && currentSlide >= count - slidesToShow)) - ) { prevMove.current = moveValue; - return; + }, + [ + count, + currentSlide, + disableEdgeSwiping, + dragThreshold, + isDragging, + handleDragEnd, + move, + dragging, + slidesToShow, + wrapAround + ] + ); + + const onTouchMove = useCallback( + (e: React.TouchEvent) => { + if (!dragging || !isDragging) return; + + onDragStart(e); + + const moveValue = (carouselWidth?.current || 0) - e.touches[0].pageX; + + handlePointerMove(moveValue); + }, + [dragging, isDragging, handlePointerMove, onDragStart] + ); + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + if (!dragging) return; + + carouselRef?.current?.focus(); + + setIsDragging(true); + onDragStart(e); + }, + [carouselRef, dragging, onDragStart] + ); + + const onMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!dragging || !isDragging) return; + + onDrag(e); + + const offsetX = + e.clientX - (carouselRef.current?.getBoundingClientRect().left || 0); + const moveValue = (carouselWidth?.current || 0) - offsetX; + + handlePointerMove(moveValue); + }, + [carouselRef, isDragging, handlePointerMove, onDrag, dragging] + ); + + const onMouseUp = useCallback( + (e: React.MouseEvent) => { + e?.preventDefault(); + handleDragEnd(e); + }, + [handleDragEnd] + ); + + const onMouseEnter = useCallback(() => { + if (pauseOnHover) { + setPause(true); } + }, [pauseOnHover]); - if (prevMove.current !== 0) { - setMove(moveState); + const onMouseLeave = useCallback(() => { + if (pauseOnHover) { + setPause(false); } - - prevMove.current = moveValue; - }, - [ - count, - currentSlide, - disableEdgeSwiping, - dragThreshold, - isDragging, - handleDragEnd, - move, - dragging, + }, [pauseOnHover]); + + const { + frameHeight, + handleVisibleSlideHeightChange, + initializedAdaptiveHeight + } = useFrameHeight({ + adaptiveHeight, slidesToShow, - wrapAround - ] - ); - - const onTouchMove = useCallback( - (e: React.TouchEvent) => { - if (!dragging || !isDragging) return; - - onDragStart(e); - - const moveValue = (carouselWidth?.current || 0) - e.touches[0].pageX; - - handlePointerMove(moveValue); - }, - [dragging, isDragging, handlePointerMove, onDragStart] - ); - - const onMouseDown = useCallback( - (e: React.MouseEvent) => { - if (!dragging) return; - - carouselRef?.current?.focus(); - - setIsDragging(true); - onDragStart(e); - }, - [carouselRef, dragging, onDragStart] - ); - - const onMouseMove = useCallback( - (e: React.MouseEvent) => { - if (!dragging || !isDragging) return; - - onDrag(e); - - const offsetX = - e.clientX - (carouselRef.current?.getBoundingClientRect().left || 0); - const moveValue = (carouselWidth?.current || 0) - offsetX; - - handlePointerMove(moveValue); - }, - [carouselRef, isDragging, handlePointerMove, onDrag, dragging] - ); - - const onMouseUp = useCallback( - (e: React.MouseEvent) => { - e?.preventDefault(); - handleDragEnd(e); - }, - [handleDragEnd] - ); - - const onMouseEnter = useCallback(() => { - if (pauseOnHover) { - setPause(true); - } - }, [pauseOnHover]); - - const onMouseLeave = useCallback(() => { - if (pauseOnHover) { - setPause(false); - } - }, [pauseOnHover]); - - const { - frameHeight, - handleVisibleSlideHeightChange, - initializedAdaptiveHeight - } = useFrameHeight({ - adaptiveHeight, - slidesToShow, - numSlides: count - }); - - const renderSlides = (typeOfSlide?: 'prev-cloned' | 'next-cloned') => { - const slides = React.Children.map(children, (child, index) => { - const isCurrentSlide = wrapAround - ? currentSlide === index || - currentSlide === index + count || - currentSlide === index - count - : currentSlide === index; - - return ( - - {child} - - ); + numSlides: count }); - return slides; - }; - - return ( -
- - - {renderControls( - props, - count, - currentSlide, - moveSlide, - nextSlide, - prevSlide, - slidesToScroll - )} + const renderSlides = (typeOfSlide?: 'prev-cloned' | 'next-cloned') => { + const slides = React.Children.map(children, (child, index) => { + const isCurrentSlide = wrapAround + ? currentSlide === index || + currentSlide === index + count || + currentSlide === index - count + : currentSlide === index; + + return ( + + {child} + + ); + }); + + return slides; + }; + return (
(focus.current = true)} - onBlur={() => (focus.current = false)} - ref={innerRef || carouselEl} - onMouseUp={onMouseUp} - onMouseDown={onMouseDown} - onMouseMove={onMouseMove} - onMouseLeave={onMouseUp} - onTouchStart={onTouchStart} - onTouchEnd={handleDragEnd} - onTouchMove={onTouchMove} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} > + + + {renderControls( + props, + count, + currentSlide, + moveSlide, + nextSlide, + prevSlide, + slidesToScroll + )} +
(focus.current = true)} + onBlur={() => (focus.current = false)} + ref={innerRef || carouselEl} + onMouseUp={onMouseUp} + onMouseDown={onMouseDown} + onMouseMove={onMouseMove} + onMouseLeave={onMouseUp} + onTouchStart={onTouchStart} + onTouchEnd={handleDragEnd} + onTouchMove={onTouchMove} > - {wrapAround ? renderSlides('prev-cloned') : null} - {renderSlides()} - {wrapAround ? renderSlides('next-cloned') : null} +
value) + .join(' ')} + style={getSliderListStyles( + children, + currentSlide, + animationEnabled, + slidesToShow, + cellAlign, + wrapAround, + propsSpeed, + move, + animation + )} + > + {wrapAround ? renderSlides('prev-cloned') : null} + {renderSlides()} + {wrapAround ? renderSlides('next-cloned') : null} +
-
- ); -}; + ); + } +); Carousel.defaultProps = defaultProps; diff --git a/packages/nuka/src/hooks/use-frame-height.ts b/packages/nuka/src/hooks/use-frame-height.ts index 0ded5807..c442898c 100644 --- a/packages/nuka/src/hooks/use-frame-height.ts +++ b/packages/nuka/src/hooks/use-frame-height.ts @@ -56,13 +56,29 @@ export const useFrameHeight = ({ // Use the ref's value since it's always the latest value const latestVisibleHeights = visibleHeightsRef.current; let newVisibleHeights: SlideHeight[]; + if (height === null) { + // Remove the entry newVisibleHeights = latestVisibleHeights.filter( (slideHeight) => slideHeight.slideIndex !== slideIndex ); } else { - newVisibleHeights = [...latestVisibleHeights, { slideIndex, height }]; + // Replace the entry if it exists + let foundSlide = false; + newVisibleHeights = latestVisibleHeights.map((heightInfo) => { + if (heightInfo.slideIndex === slideIndex) { + foundSlide = true; + return { slideIndex, height }; + } + return heightInfo; + }); + + // Add the height if it wasn't found + if (!foundSlide) { + newVisibleHeights = [...latestVisibleHeights, { slideIndex, height }]; + } } + setVisibleHeights(newVisibleHeights); if ( diff --git a/packages/nuka/src/slide.tsx b/packages/nuka/src/slide.tsx index a2c6b86c..34d264d4 100644 --- a/packages/nuka/src/slide.tsx +++ b/packages/nuka/src/slide.tsx @@ -1,4 +1,10 @@ -import React, { CSSProperties, ReactNode, useRef, useEffect } from 'react'; +import React, { + CSSProperties, + ReactNode, + useRef, + useEffect, + useCallback +} from 'react'; import { Alignment } from './types'; const getSlideWidth = (count: number, wrapAround?: boolean): string => @@ -112,7 +118,8 @@ const Slide = ({ cellAlign, onVisibleSlideHeightChange, adaptiveHeight, - initializedAdaptiveHeight + initializedAdaptiveHeight, + slideClassName }: { count: number; children: ReactNode | ReactNode[]; @@ -134,6 +141,7 @@ const Slide = ({ onVisibleSlideHeightChange: (index: number, height: number | null) => unknown; adaptiveHeight: boolean; initializedAdaptiveHeight: boolean; + slideClassName: string | undefined; }): JSX.Element => { const customIndex = wrapAround ? generateIndex(index, count, typeOfSlide) @@ -147,42 +155,57 @@ const Slide = ({ const slideRef = useRef(null); - const prevIsVisibleRef = useRef(false); - useEffect(() => { + const prevSlideHeight = useRef(null); + + const handleHeightOrVisibilityChange = useCallback(() => { const node = slideRef.current; + if (node) { - const slideHeight = node.getBoundingClientRect()?.height; if (isVisible) { node.removeAttribute('inert'); } else { node.setAttribute('inert', 'true'); } - const prevIsVisible = prevIsVisibleRef.current; - if (isVisible && !prevIsVisible) { + const slideHeight = isVisible + ? node.getBoundingClientRect().height + : null; + + if (slideHeight !== prevSlideHeight.current) { + prevSlideHeight.current = slideHeight; onVisibleSlideHeightChange(customIndex, slideHeight); - } else if (!isVisible && prevIsVisible) { - onVisibleSlideHeightChange(customIndex, null); } - - prevIsVisibleRef.current = isVisible; } - }, [ - adaptiveHeight, - customIndex, - isVisible, - onVisibleSlideHeightChange, - slidesToShow - ]); + }, [customIndex, isVisible, onVisibleSlideHeightChange]); - const currentSlideClass = isCurrentSlide && isVisible ? ' slide-current' : ''; + // Update status if any dependencies change + useEffect(() => { + handleHeightOrVisibilityChange(); + }, [handleHeightOrVisibilityChange]); + + // Also allow for re-measuring height even if none of the props or state + // changes. This is useful if a carousel item is expandable. + useEffect(() => { + const node = slideRef.current; + if (node && typeof ResizeObserver !== 'undefined') { + const resizeObserver = new ResizeObserver(handleHeightOrVisibilityChange); + resizeObserver.observe(node); + return () => resizeObserver.disconnect(); + } + }, [handleHeightOrVisibilityChange]); return (
value) + .join(' ')} style={getSlideStyles( count, isCurrentSlide, diff --git a/packages/nuka/src/types.ts b/packages/nuka/src/types.ts index a192ec66..2035de35 100644 --- a/packages/nuka/src/types.ts +++ b/packages/nuka/src/types.ts @@ -359,6 +359,11 @@ export interface InternalCarouselProps { */ keyCodeConfig: KeyCodeConfig; + /** + * Extra className to be added to the scrollable list wrapper + */ + listClassName?: string; + /** * optional callback function */ @@ -442,6 +447,11 @@ export interface InternalCarouselProps { */ scrollMode: ScrollMode; + /** + * Extra className to be added to the container for each slide + */ + slideClassName?: string; + /** * Manually set the index of the initial slide to be shown */ @@ -499,6 +509,23 @@ export interface InternalCarouselProps { zoomScale?: number; } +export interface CarouselRef { + /** + * Moves to the specified slide index + */ + moveSlide: (to?: number) => void; + + /** + * Go to the next slide + */ + nextSlide: () => void; + + /** + * Go to the previous slide + */ + prevSlide: () => void; +} + /** * This component has no required props. */ diff --git a/packages/nuka/stories/carousel.stories.tsx b/packages/nuka/stories/carousel.stories.tsx index 09e3cde0..b1f1a519 100644 --- a/packages/nuka/stories/carousel.stories.tsx +++ b/packages/nuka/stories/carousel.stories.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useRef, useState } from 'react'; import { ComponentMeta, Story } from '@storybook/react'; import { renderToString } from 'react-dom/server'; import Carousel, { Alignment, + CarouselRef, ControlProps, InternalCarouselProps } from '../src/index'; @@ -21,6 +22,7 @@ export default { interface StoryProps { storySlideCount: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; slideHeights?: number[]; + carouselRef?: React.Ref; } const colors = [ @@ -38,6 +40,7 @@ const colors = [ const Template: Story = ({ storySlideCount = 9, slideHeights, + carouselRef, ...args }) => { const slides = colors.slice(0, storySlideCount).map((color, index) => ( @@ -53,6 +56,7 @@ const Template: Story = ({ }} /> )); + return (
= ({ margin: '0px auto' }} > - {slides} + + {slides} +
); @@ -88,6 +94,34 @@ const StaticTemplate: Story = (args) => { export const Default = Template.bind({}); Default.args = {}; +/** Template that lets us use a ref to change the slides */ +const RefControlsTemplate: Story< + Omit +> = (args) => { + const carouselRef = useRef(null); + + return ( + <> +
+ Controls using ref:{' '} + + + +
+