From 085e7afac0525a6c114d68512d8ea5eb135a1e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Mon, 3 Nov 2025 09:08:43 +0100 Subject: [PATCH 1/5] Support controlled state in the carousel component --- .../react/src/carousel/carousel.stories.tsx | 47 +++++++++++++++++++ packages/react/src/carousel/carousel.tsx | 34 +++++++++++++- packages/react/src/utils/index.ts | 2 + packages/react/src/utils/useMountEffect.ts | 20 ++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/utils/useMountEffect.ts diff --git a/packages/react/src/carousel/carousel.stories.tsx b/packages/react/src/carousel/carousel.stories.tsx index 06cabbb01..0960b4320 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 { useState } from 'react'; import { UNSAFE_Carousel as Carousel, UNSAFE_CarouselItem as CarouselItem, @@ -111,3 +112,49 @@ export const WithNavigationCallbacks = () => ( ); + +export const Controlled = () => { + const [selectedIndex, setSelectedIndex] = useState(1); + + 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..be7f36553 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 './useMountEffect'; diff --git a/packages/react/src/utils/useMountEffect.ts b/packages/react/src/utils/useMountEffect.ts new file mode 100644 index 000000000..f9d87e31d --- /dev/null +++ b/packages/react/src/utils/useMountEffect.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); +} From bf2d7642f2355ee5ecd3c1d31ea23a2593ac32b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Mon, 3 Nov 2025 09:12:02 +0100 Subject: [PATCH 2/5] Fix file casing --- .../react/src/utils/{useMountEffect.ts => use-mount-effect.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react/src/utils/{useMountEffect.ts => use-mount-effect.ts} (100%) diff --git a/packages/react/src/utils/useMountEffect.ts b/packages/react/src/utils/use-mount-effect.ts similarity index 100% rename from packages/react/src/utils/useMountEffect.ts rename to packages/react/src/utils/use-mount-effect.ts From 31d2d1e4d0e4156cc5e80c1dd313c3c90265d65c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 13 Nov 2025 12:03:38 +0100 Subject: [PATCH 3/5] Fix rename of import --- packages/react/src/utils/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts index be7f36553..eef87bb6f 100644 --- a/packages/react/src/utils/index.ts +++ b/packages/react/src/utils/index.ts @@ -4,4 +4,4 @@ export { useHorizontalScroll, } from './horizontal-scroll'; -export { useMountEffect } from './useMountEffect'; +export { useMountEffect } from './use-mount-effect'; From 8771a1851241da78a512c4f33230e6c053dddaba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 13 Nov 2025 12:12:38 +0100 Subject: [PATCH 4/5] Update controlled example --- packages/react/src/carousel/carousel.stories.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/react/src/carousel/carousel.stories.tsx b/packages/react/src/carousel/carousel.stories.tsx index 0960b4320..4104cfadf 100644 --- a/packages/react/src/carousel/carousel.stories.tsx +++ b/packages/react/src/carousel/carousel.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { UNSAFE_Carousel as Carousel, UNSAFE_CarouselItem as CarouselItem, @@ -114,7 +114,15 @@ export const WithNavigationCallbacks = () => ( ); export const Controlled = () => { - const [selectedIndex, setSelectedIndex] = useState(1); + const [selectedIndex, setSelectedIndex] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setSelectedIndex((prevIndex) => (prevIndex + 1) % 4); + }, 3000); + + return () => clearInterval(interval); + }, []); return ( Date: Thu, 13 Nov 2025 12:12:43 +0100 Subject: [PATCH 5/5] Add changeset --- .changeset/real-buses-scream.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/real-buses-scream.md 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