diff --git a/src/use-snap-carousel.tsx b/src/use-snap-carousel.tsx index 464b179..cdf2ff0 100644 --- a/src/use-snap-carousel.tsx +++ b/src/use-snap-carousel.tsx @@ -105,6 +105,10 @@ export const useSnapCarousel = ({ const rect = getOffsetRect(item, item.parentElement); if ( !currPage || + // We allow items to explicitly mark themselves as snap points via the `data-should-snap` + // attribute. This allows callsites to augment and/or define their own "page" logic. + item.dataset.shouldSnap === 'true' || + // Otherwise, we determine pages via the layout. rect[farSidePos] - currPageStartPos > Math.ceil(scrollPort[dimension]) ) { acc.push([i]); diff --git a/stories/infinite-carousel.module.css b/stories/infinite-carousel.module.css new file mode 100644 index 0000000..5460385 --- /dev/null +++ b/stories/infinite-carousel.module.css @@ -0,0 +1,156 @@ +.root { + position: relative; + margin: 0 -1rem; /* bust out of storybook margin (to demonstrate full bleed carousel) */ +} + +.y.root { + margin: -1rem 0; + height: 100vh; + width: 300px; + display: flex; + flex-direction: column; +} + +.scroll { + position: relative; + display: flex; + overflow: auto; + scroll-snap-type: x mandatory; + -ms-overflow-style: none; + scrollbar-width: none; + overscroll-behavior: contain; + scroll-padding: 0 16px; + padding: 0 16px; +} + +.scroll::-webkit-scrollbar { + display: none; +} + +.y .scroll { + display: block; + scroll-snap-type: y mandatory; + scroll-padding: 16px 0; + padding: 16px 0; +} + +.item { + font-family: Futura, Trebuchet MS, Arial, sans-serif; + font-size: 125px; + line-height: 1; + width: 300px; + height: 300px; + max-width: 100%; + flex-shrink: 0; + color: white; + display: flex; + justify-content: end; + align-items: end; + padding: 16px 20px; + text-transform: uppercase; + text-shadow: 6px 6px 0px rgba(0, 0, 0, 0.2); + margin-right: 0.6rem; + overflow: hidden; +} + +.scrollMargin .item:nth-child(9) { + scroll-margin-left: 200px; + background: black !important; +} + +.item:last-child { + margin-right: 0; +} + +.y .item { + margin-right: 0; + margin-bottom: 0.6rem; +} + +.y .item:last-child { + margin-bottom: 0; +} + +.pageIndicator { + font-family: Futura, Trebuchet MS, Arial, sans-serif; + font-weight: bold; + font-size: 14px; + position: absolute; + top: 10px; + right: 10px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.5); + pointer-events: none; + border-radius: 5px; + color: #374151; +} + +.controls { + margin: 1rem 0; + display: flex; + justify-content: center; + align-items: center; + color: #374151; + padding: 0 1rem; +} + +.prevButton, +.nextButton { + font-size: 18px; + transition: opacity 100ms ease-out; +} + +.prevButton[disabled], +.nextButton[disabled] { + opacity: 0.4; +} + +.pagination { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin: 0 10px; +} + +.paginationItem { + display: flex; + justify-content: center; +} + +.paginationButton { + display: block; + text-indent: -99999px; + overflow: hidden; + background: #374151; + width: 12px; + height: 12px; + border-radius: 50%; + margin: 5px; + transition: opacity 100ms ease-out; +} + +.paginationItemActive .paginationButton { + opacity: 0.3; +} + +@media only screen and (max-width: 480px) { + .item { + width: 280px; + height: 280px; + } + + .pagination { + margin: 0 8px; + } + + .prevButton, + .nextButton { + font-size: 15px; + } + + .paginationButton { + width: 9px; + height: 9px; + margin: 4px; + } +} diff --git a/stories/infinite-carousel.stories.tsx b/stories/infinite-carousel.stories.tsx new file mode 100644 index 0000000..6d6e4f6 --- /dev/null +++ b/stories/infinite-carousel.stories.tsx @@ -0,0 +1,168 @@ +import React, { useLayoutEffect, useRef, useState } from 'react'; +import { + InfiniteCarousel, + InfiniteCarouselItem, + InfiniteCarouselRef +} from './infinite-carousel'; +import { Button } from './lib/button'; +import { Select } from './lib/select'; + +export default { + title: 'Infinite Carousel', + component: InfiniteCarousel +}; + +export const Default = () => { + const items = Array.from({ length: 18 }).map((_, index) => ({ + id: index, + index + })); + return ( + ( + + {item.index + 1} + + )} + /> + ); +}; + +export const VariableWidth = () => { + const items = [ + 110, 300, 500, 120, 250, 300, 500, 400, 180, 300, 350, 700, 400, 230, 300 + ].map((width, index) => ({ id: index, index, width })); + return ( + ( + + {item.index + 1} + + )} + /> + ); +}; + +export const VerticalAxis = () => { + const items = Array.from({ length: 18 }).map((_, index) => ({ + id: index, + index + })); + return ( + ( + + {item.index + 1} + + )} + /> + ); +}; + +export const DynamicItems = () => { + const carouselRef = useRef(null); + const [items, setItems] = useState(() => + Array.from({ length: 6 }).map((_, index) => ({ id: index, index })) + ); + const addItem = () => { + setItems((prev) => [...prev, { id: prev.length, index: prev.length }]); + }; + const removeItem = () => { + setItems((prev) => prev.slice(0, -1)); + }; + useLayoutEffect(() => { + if (!carouselRef.current) { + return; + } + carouselRef.current.refresh(); + }, [items]); + return ( + <> +
+ + +
+ ( + + {item.index + 1} + + )} + /> + + ); +}; + +export const ScrollBehavior = () => { + const scrollBehaviors: ScrollBehavior[] = ['smooth', 'instant', 'auto']; + const [scrollBehavior, setScrollBehavior] = useState(scrollBehaviors[0]); + const items = Array.from({ length: 18 }).map((_, index) => ({ + id: index, + index + })); + return ( + <> +
+ +
+ ( + + {item.index + 1} + + )} + /> + + ); +}; + +/* Utils */ + +const getColor = (i: number) => { + return `hsl(-${i * 12} 100% 50%)`; +}; diff --git a/stories/infinite-carousel.tsx b/stories/infinite-carousel.tsx new file mode 100644 index 0000000..47316a1 --- /dev/null +++ b/stories/infinite-carousel.tsx @@ -0,0 +1,183 @@ +import React, { useImperativeHandle, useMemo, useLayoutEffect } from 'react'; +import classNames from 'classnames'; +import { useSnapCarousel } from '../src/use-snap-carousel'; +import './reset.css'; +const styles = require('./infinite-carousel.module.css'); + +/** + * This is an example of an infinite carousel built on top of `useSnapCarousel` + * + * NOTE: This is not truly infinite, but rather it's as-good-as-infinite because, in order to keep + * complexity low, it merely duplicates items in either direction rather than trying to dynamically + * change the scroll position. + */ + +export interface InfiniteCarouselProps { + readonly axis?: 'x' | 'y'; + readonly items: T[]; + readonly renderItem: ( + props: InfiniteCarouselRenderItemProps + ) => React.ReactElement; + readonly scrollMargin?: boolean; + readonly scrollBehavior?: ScrollBehavior; +} + +export interface InfiniteCarouselRenderItemProps { + readonly item: T; + readonly index: number; + readonly isSnapPoint: boolean; + readonly shouldSnap: boolean; +} + +export interface InfiniteCarouselRef { + readonly refresh: () => void; +} + +export const InfiniteCarousel = React.forwardRef< + InfiniteCarouselRef, + InfiniteCarouselProps +>( + ({ axis, items, renderItem, scrollMargin = false, scrollBehavior }, ref) => { + const { + scrollRef, + next, + prev, + goTo, + pages, + activePageIndex, + snapPointIndexes, + refresh + } = useSnapCarousel({ axis }); + + useImperativeHandle(ref, () => ({ refresh })); + + // 1. Duplicate the items to create the illusion of infinity. + const duplicationFactor = items.length ? Math.ceil(250 / items.length) : 0; + const itemsToRender = useMemo( + () => Array.from({ length: duplicationFactor }).flatMap(() => items), + [duplicationFactor, items] + ); + + // 2. Jump to the middle-most "first" page to minimize the chance of scrolling to either end. + useLayoutEffect(() => { + const itemsByPage = pages.map((page) => + page.map((idx) => itemsToRender[idx]) + ); + const allFirstPages = itemsByPage.filter( + (page) => page[0] === itemsToRender[0] + ); + const mid = allFirstPages[Math.floor(allFirstPages.length / 2)]; + goTo(itemsByPage.indexOf(mid), { + behavior: 'instant' + }); + // Need `useEffectEvent` + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pages, itemsToRender]); + + // 3. Define the logical pages and the active page index to render based on the actual unique `items`. + const pagesToRender = pages.filter((page) => page[0] <= items.length - 1); + const activePageIndexToRender = pagesToRender.length + ? activePageIndex % pagesToRender.length + : -1; + + return ( +
+
    + {itemsToRender.map((item, index) => + renderItem({ + item, + index, + isSnapPoint: snapPointIndexes.has(index), + // 4. Force snapping to the first item so that pages are always made up of the equivalent + // items, even if the number of items isn't wholly divisible by the number of pages. + // -- this simplifies the logic for rendering the controls. + shouldSnap: item === itemsToRender[0] + }) + )} +
+
+ {activePageIndexToRender + 1} / {pagesToRender.length} +
+
+ +
    + {pagesToRender.map((_, i) => ( +
  1. + +
  2. + ))} +
+ +
+
+ ); + } + // https://fettblog.eu/typescript-react-generic-forward-refs/ +) as ( + props: InfiniteCarouselProps & { + ref?: React.ForwardedRef; + } +) => React.ReactElement; + +export interface InfiniteCarouselItemProps { + readonly isSnapPoint: boolean; + readonly shouldSnap: boolean; + readonly bgColor: string; + readonly width?: number; + readonly children?: React.ReactNode; +} + +export const InfiniteCarouselItem = ({ + isSnapPoint, + shouldSnap, + bgColor, + width, + children +}: InfiniteCarouselItemProps) => { + return ( +
  • + {children} +
  • + ); +};