Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/real-buses-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@obosbbl/grunnmuren-react": patch
---

The `Carousel` component now supports controlled state through `selectedIndex` and `onSelectedIndexChange` props
55 changes: 55 additions & 0 deletions packages/react/src/carousel/carousel.stories.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -111,3 +112,57 @@ export const WithNavigationCallbacks = () => (
</Carousel>
</main>
);

export const Controlled = () => {
const [selectedIndex, setSelectedIndex] = useState(0);

useEffect(() => {
const interval = setInterval(() => {
setSelectedIndex((prevIndex) => (prevIndex + 1) % 4);
}, 3000);

return () => clearInterval(interval);
}, []);

return (
<Carousel
selectedIndex={selectedIndex}
onSelectedIndexChange={setSelectedIndex}
>
<CarouselItems>
<CarouselItem>
<Media>
<img
src="https://cdn.sanity.io/media-libraries/mln4u7f3Hc8r/images/410001cfde5211194e0072bf39abd3214befb1c2-1920x1080.jpg?auto=format"
alt=""
/>
</Media>
</CarouselItem>
<CarouselItem>
<Media>
<img
src="https://cdn.sanity.io/media-libraries/mln4u7f3Hc8r/images/7d2285ccee9b9545e018115b8e0ecc8b06aa0729-1620x1080.jpg?auto=format"
alt=""
/>
</Media>
</CarouselItem>
<CarouselItem>
<Media fit="contain">
<img
src="https://cdn.sanity.io/media-libraries/mln4u7f3Hc8r/images/32a53eec782e6cbe15d75961f82ecca48dbe30ed-1920x1080.png?auto=format"
alt=""
/>
</Media>
</CarouselItem>
<CarouselItem>
<Media>
<img
src="https://cdn.sanity.io/media-libraries/mln4u7f3Hc8r/images/a3c4b263f72128f5c6259333a224054ed3b539fe-1440x788.heif?auto=format"
alt=""
/>
</Media>
</CarouselItem>
</CarouselItems>
</Carousel>
);
};
34 changes: 33 additions & 1 deletion packages/react/src/carousel/carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import {
Children,
cloneElement,
createContext,
type Dispatch,
type HTMLProps,
isValidElement,
type JSX,
type SetStateAction,
useContext,
useEffect,
useRef,
Expand All @@ -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<CarouselItemProps, 'id'> & {
/** The index of the item that is currently in view */
Expand All @@ -40,12 +43,22 @@ type CarouselProps = Omit<HTMLProps<HTMLDivElement>, '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<SetStateAction<number>>;
};

const Carousel = ({
className,
children,
onChange,
selectedIndex: controlledIndex,
onSelectedIndexChange: controlledOnIndexChange,
...rest
}: CarouselProps) => {
const carouselRef = useRef<HTMLDivElement>(null);
Expand All @@ -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<NodeJS.Timeout | null>(null);
const scrollQueue = useRef<number[]>([]);
Expand All @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export {
type ScrollDirection,
useHorizontalScroll,
} from './horizontal-scroll';

export { useMountEffect } from './use-mount-effect';
20 changes: 20 additions & 0 deletions packages/react/src/utils/use-mount-effect.ts
Original file line number Diff line number Diff line change
@@ -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);
}