Skip to content
Merged
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
4 changes: 4 additions & 0 deletions packages/common/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 9.23.0 ((8/27/2026, 05:05 PM PST))

This is an artificial version bump with no new change.

## 9.22.1 ((8/25/2026, 02:10 PM PST))

This is an artificial version bump with no new change.
Expand Down
2 changes: 1 addition & 1 deletion packages/common/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-common",
"version": "9.22.1",
"version": "9.23.0",
"description": "Coinbase Design System - Common",
"repository": {
"type": "git",
Expand Down
4 changes: 4 additions & 0 deletions packages/mcp-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 9.23.0 ((8/27/2026, 05:05 PM PST))

This is an artificial version bump with no new change.

## 9.22.1 ((8/25/2026, 02:10 PM PST))

This is an artificial version bump with no new change.
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-mcp-server",
"version": "9.22.1",
"version": "9.23.0",
"description": "Coinbase Design System - MCP Server",
"repository": {
"type": "git",
Expand Down
6 changes: 6 additions & 0 deletions packages/mobile/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 9.23.0 (8/27/2026 PST)

#### 🚀 Updates

- Add uncontrolled initialPage prop to Carousel. [[#864](https://github.com/coinbase/cds/pull/864)]

## 9.22.1 (8/25/2026 PST)

#### 🐞 Fixes
Expand Down
2 changes: 1 addition & 1 deletion packages/mobile/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-mobile",
"version": "9.22.1",
"version": "9.23.0",
"description": "Coinbase Design System - Mobile",
"repository": {
"type": "git",
Expand Down
29 changes: 26 additions & 3 deletions packages/mobile/src/carousel/Carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -188,6 +189,12 @@ export type CarouselBaseProps = SharedProps &
* @default 'page'
*/
snapMode?: 'item' | 'page';
/**
* Zero-based page index to show on first layout, with no animation. Uncontrolled —
* updating it later does not move the carousel. Out-of-range values are clamped.
* @default 0
*/
Comment thread
caitlin-coyiuto-cb marked this conversation as resolved.
initialPage?: number;
/**
* Hides the navigation arrows (previous/next buttons).
*/
Expand Down Expand Up @@ -563,6 +570,7 @@ export const Carousel = memo(
paginationVariant,
drag = 'snap',
snapMode = 'page',
initialPage,
NavigationComponent = DefaultCarouselNavigation,
PaginationComponent = DefaultCarouselPagination,
style,
Expand All @@ -581,13 +589,14 @@ export const Carousel = memo(
...props
} = mergedProps;
const carouselScrollX = useRef(0);
const hasAppliedInitialPageRef = useRef(false);

const animationApi = useSpring({
x: carouselScrollX.current,
config: animationConfig,
});

const [activePageIndex, setActivePageIndex] = useState(0);
const [activePageIndex, setActivePageIndex] = useState(() => Math.max(0, initialPage ?? 0));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably use the value from the ref here in case the initialPage prop does in fact change. If that were the case then the activePage state would also change

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caitlin-coyiuto-cb I think we need to clamp this with the max as well right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can here on first render since it'd be 0? totalPages gets derived on line 725 later. Also have tests for clamping on last page (if initialPage > last page) and negative initialPage

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I was wondering if we needed to set activePageIndex when we set hasAppliedInitialPageRef to true. I suppose goToPage does clamp automatically but I wasn't sure if there is an interim side effects if this value was way off.

const [containerSize, onLayout] = useLayout();
const [carouselItemRects, setCarouselItemRects] = useState<{
[itemId: string]: Rect;
Expand Down Expand Up @@ -766,7 +775,7 @@ export const Carousel = memo(
});

const goToPage = useCallback(
(page: number) => {
(page: number, { animate: shouldAnimate = true }: { animate?: boolean } = {}) => {
const newPage = Math.max(0, Math.min(totalPages - 1, page));
updateActivePageIndex(newPage);
updateVisibleCarouselItems(pageOffsets[newPage]);
Expand All @@ -777,7 +786,11 @@ export const Carousel = memo(
: pageOffsets[newPage];

carouselScrollX.current = targetOffset;
animationApi.x.start({ to: targetOffset, config: animationConfig });
if (shouldAnimate) {
animationApi.x.start({ to: targetOffset, config: animationConfig });
} else {
animationApi.x.set(targetOffset);
}
reset();
},
[
Expand All @@ -792,6 +805,16 @@ export const Carousel = memo(
],
);

useLayoutEffect(() => {
if (hasAppliedInitialPageRef.current || totalPages === 0) return;
hasAppliedInitialPageRef.current = true;

const targetPage = initialPage ?? 0;
if (targetPage > 0) {
goToPage(targetPage, { animate: false });
Comment thread
caitlin-coyiuto-cb marked this conversation as resolved.
}
}, [totalPages, initialPage, goToPage]);

useImperativeHandle(
ref,
() => ({
Expand Down
25 changes: 25 additions & 0 deletions packages/mobile/src/carousel/__stories__/Carousel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,30 @@ const ImperativeApiExample = () => {
);
};

const InitialPageExample = () => {
const theme = useTheme();

return (
<Example paddingX={0}>
<Carousel
initialPage={2}
snapMode="page"
styles={{
root: { paddingHorizontal: theme.space[2] },
carousel: { gap: theme.space[2] },
}}
title="Initial Page (opens on page 3)"
>
{sampleItems.slice(0, 5).map((item, index) => (
<CarouselItem key={`initial-page-${index}`} id={`initial-page-${index}`} width="100%">
{item}
</CarouselItem>
))}
</Carousel>
</Example>
);
};

const LoopingExamples = () => {
const theme = useTheme();

Expand Down Expand Up @@ -833,6 +857,7 @@ export default function CarouselScreen() {
<AnimatedPaginationExample />
<LoopingExamples />
<AutoplayExample />
<InitialPageExample />
</ExampleScreen>
);
}
157 changes: 156 additions & 1 deletion packages/mobile/src/carousel/__tests__/Carousel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { Box } from '../../layout/Box';
import { VStack } from '../../layout/VStack';
import { Text } from '../../typography/Text';
import { DefaultThemeProvider } from '../../utils/testHelpers';
import { Carousel, type CarouselImperativeHandle, useCarouselContext } from '../Carousel';
import {
Carousel,
type CarouselImperativeHandle,
type CarouselPaginationComponentProps,
useCarouselContext,
} from '../Carousel';
import { CarouselItem } from '../CarouselItem';

// Mock react-native-gesture-handler with gesture simulation capabilities
Expand Down Expand Up @@ -1529,4 +1534,154 @@ describe('Carousel', () => {
expect(onChangePage).toHaveBeenCalledWith(0);
});
});

describe('Initial Page', () => {
// 8 items at width 200 in a 400px container, snapMode="item" => 7 pages (indices 0-6).
const itemCount = 8;

// Custom pagination that surfaces the active page index for assertions.
const ActivePagePagination = ({
activePageIndex,
totalPages,
}: CarouselPaginationComponentProps) => (
<Text testID="active-page-index">{`${activePageIndex}/${totalPages}`}</Text>
);

beforeEach(() => {
mockGestureHandlers.onStart = undefined;
mockGestureHandlers.onUpdate = undefined;
mockGestureHandlers.onEnd = undefined;
jest.clearAllMocks();
});

it('opens on the provided initialPage without firing onChangePage on mount', async () => {
const onChangePage = jest.fn();

render(
<TestCarouselWithItems
PaginationComponent={ActivePagePagination}
initialPage={3}
itemCount={itemCount}
onChangePage={onChangePage}
snapMode="item"
/>,
);

await waitFor(() => {
expect(screen.getByTestId('active-page-index')).toHaveTextContent('3/7');
});

expect(onChangePage).not.toHaveBeenCalled();
});

it('defaults to the first page when initialPage is omitted', async () => {
render(
<TestCarouselWithItems
PaginationComponent={ActivePagePagination}
itemCount={itemCount}
snapMode="item"
/>,
);

await waitFor(() => {
expect(screen.getByTestId('active-page-index')).toHaveTextContent('0/7');
});
});

it.each`
initialPage | expectedIndex | description
${99} | ${6} | ${'clamps an initialPage above the last page to the last page'}
${-5} | ${0} | ${'clamps a negative initialPage to the first page'}
`('$description', async ({ initialPage, expectedIndex }) => {
render(
<TestCarouselWithItems
PaginationComponent={ActivePagePagination}
initialPage={initialPage}
itemCount={itemCount}
snapMode="item"
/>,
);

await waitFor(() => {
expect(screen.getByTestId('active-page-index')).toHaveTextContent(`${expectedIndex}/7`);
});
});

it('lets a later imperative goToPage override the initial page', async () => {
const onChangePage = jest.fn();

const TestComponent = () => {
const carouselRef = useRef<CarouselImperativeHandle>(null);

return (
<DefaultThemeProvider>
<VStack>
<Text onPress={() => carouselRef.current?.goToPage(1)} testID="go-to-page-1">
Go to Page 1
</Text>
<Carousel
ref={carouselRef}
PaginationComponent={ActivePagePagination}
initialPage={3}
onChangePage={onChangePage}
snapMode="item"
>
{Array.from({ length: itemCount }, (_, index) => {
const itemId = `item-${index}`;
return (
<MockCarouselItem key={itemId} id={itemId} itemIndex={index} width={200}>
<Box height={100} testID={`carousel-item-${itemId}`} width={200}>
<Text>Item {index + 1}</Text>
</Box>
</MockCarouselItem>
);
})}
</Carousel>
</VStack>
</DefaultThemeProvider>
);
};

render(<TestComponent />);

await waitFor(() => {
expect(screen.getByTestId('active-page-index')).toHaveTextContent('3/7');
});

fireEvent.press(screen.getByTestId('go-to-page-1'));

await waitFor(() => {
expect(screen.getByTestId('active-page-index')).toHaveTextContent('1/7');
});
expect(onChangePage).toHaveBeenCalledWith(1);
});

it('lets a later drag override the initial page', async () => {
const onChangePage = jest.fn();

render(
<TestCarouselWithItems
PaginationComponent={ActivePagePagination}
initialPage={3}
itemCount={itemCount}
onChangePage={onChangePage}
snapMode="item"
/>,
);

await waitFor(() => {
expect(screen.getByTestId('active-page-index')).toHaveTextContent('3/7');
});

onChangePage.mockClear();

// Drag toward earlier pages from the seeded page 3.
simulateDragGesture(200, 0);

await waitFor(() => {
expect(screen.getByTestId('active-page-index')).toHaveTextContent('2/7');
});
expect(onChangePage).toHaveBeenCalledWith(2);
});
});
});
6 changes: 6 additions & 0 deletions packages/web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 9.23.0 (8/27/2026 PST)

#### 🚀 Updates

- Add uncontrolled initialPage prop to Carousel. [[#864](https://github.com/coinbase/cds/pull/864)]

Comment thread
caitlin-coyiuto-cb marked this conversation as resolved.
## 9.22.1 (8/25/2026 PST)

#### 🐞 Fixes
Expand Down
2 changes: 1 addition & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-web",
"version": "9.22.1",
"version": "9.23.0",
"description": "Coinbase Design System - Web",
"repository": {
"type": "git",
Expand Down
Loading
Loading