diff --git a/.changeset/real-buses-scream.md b/.changeset/real-buses-scream.md new file mode 100644 index 000000000..c1b27f4a7 --- /dev/null +++ b/.changeset/real-buses-scream.md @@ -0,0 +1,5 @@ +--- +"@obosbbl/grunnmuren-react": patch +--- + +The `Carousel` component now supports controlled state through `selectedIndex` and `onSelectedIndexChange` props diff --git a/packages/react/src/carousel/carousel.stories.tsx b/packages/react/src/carousel/carousel.stories.tsx index 06cabbb01..4104cfadf 100644 --- a/packages/react/src/carousel/carousel.stories.tsx +++ b/packages/react/src/carousel/carousel.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useEffect, useState } from 'react'; import { UNSAFE_Carousel as Carousel, UNSAFE_CarouselItem as CarouselItem, @@ -111,3 +112,57 @@ export const WithNavigationCallbacks = () => ( ); + +export const Controlled = () => { + const [selectedIndex, setSelectedIndex] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setSelectedIndex((prevIndex) => (prevIndex + 1) % 4); + }, 3000); + + return () => clearInterval(interval); + }, []); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/react/src/carousel/carousel.tsx b/packages/react/src/carousel/carousel.tsx index dc82179d6..ee054b963 100644 --- a/packages/react/src/carousel/carousel.tsx +++ b/packages/react/src/carousel/carousel.tsx @@ -5,9 +5,11 @@ import { Children, cloneElement, createContext, + type Dispatch, type HTMLProps, isValidElement, type JSX, + type SetStateAction, useContext, useEffect, useRef, @@ -19,6 +21,7 @@ import { Button, ButtonContext } from '../button'; import { MediaContext } from '../content'; import { translations } from '../translations'; import { useLocale } from '../use-locale'; +import { useMountEffect } from '../utils'; type CarouselItem = Pick & { /** The index of the item that is currently in view */ @@ -40,12 +43,22 @@ type CarouselProps = Omit, 'onChange'> & { * @param item { index: number; id?: string; prevIndex: number; prevId?: string } */ onChange?: (item: CarouselItem) => void; + /** + * For controlled selection, the index of the item that should be currently in view. + */ + selectedIndex?: number; + /** + * For controlled selection, callback that is called when the selected index changes. + */ + onSelectedIndexChange?: Dispatch>; }; const Carousel = ({ className, children, onChange, + selectedIndex: controlledIndex, + onSelectedIndexChange: controlledOnIndexChange, ...rest }: CarouselProps) => { const carouselRef = useRef(null); @@ -54,7 +67,14 @@ const Carousel = ({ const locale = useLocale(); const { previous, next } = translations; - const [scrollTargetIndex, setScrollTargetIndex] = useState(0); + // Internal state for uncontrolled usage + const [_scrollTargetIndex, _setScrollTargetIndex] = useState(0); + // Resolve controlled vs uncontrolled state + const [scrollTargetIndex, setScrollTargetIndex] = [ + controlledIndex ?? _scrollTargetIndex, + controlledOnIndexChange ?? _setScrollTargetIndex, + ]; + const isScrollingProgrammatically = useRef(false); const scrollTimeoutRef = useRef(null); const scrollQueue = useRef([]); @@ -68,6 +88,18 @@ const Carousel = ({ carouselItemsRef.current.children.length - 1 === scrollTargetIndex, ); + // Scroll to the correct item on mount if controlled + useMountEffect(() => { + if (controlledIndex !== undefined) { + // The carousel's selected index is controlled, ensure we scroll to the correct item on mount + carouselItemsRef.current?.children[controlledIndex]?.scrollIntoView({ + behavior: 'instant', + inline: 'start', + block: 'nearest', + }); + } + }, [controlledIndex]); + useEffect(() => { setHasReachedScrollStart(scrollTargetIndex === 0); setHasReachedScrollEnd( diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts index 958abe5fe..eef87bb6f 100644 --- a/packages/react/src/utils/index.ts +++ b/packages/react/src/utils/index.ts @@ -3,3 +3,5 @@ export { type ScrollDirection, useHorizontalScroll, } from './horizontal-scroll'; + +export { useMountEffect } from './use-mount-effect'; diff --git a/packages/react/src/utils/use-mount-effect.ts b/packages/react/src/utils/use-mount-effect.ts new file mode 100644 index 000000000..f9d87e31d --- /dev/null +++ b/packages/react/src/utils/use-mount-effect.ts @@ -0,0 +1,20 @@ +import { type EffectCallback, useEffect, useRef } from 'react'; + +/** + * This hook is called only once when the component is mounted. + * @param effectCallback The effect callback to call on mount + * @param deps Deps of the effect + */ +export function useMountEffect( + effectCallback: EffectCallback, + deps?: readonly unknown[], +) { + const hasMountedRef = useRef(false); + useEffect(() => { + if (!hasMountedRef.current) { + hasMountedRef.current = true; + effectCallback(); + } + // biome-ignore lint/correctness/useExhaustiveDependencies: The dependency array is unknown here + }, deps); +}