From b731d1a5dd26492511340f35ac38feef67b72a05 Mon Sep 17 00:00:00 2001 From: caitlin-coyiuto-cb Date: Thu, 27 Aug 2026 14:09:08 -0700 Subject: [PATCH] feat(carousel): add initialPage prop to web and mobile Carousel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an uncontrolled `initialPage` prop to the cds-mobile and cds-web Carousel so a consumer can open it on a non-zero page with no animation. Seeds `activePageIndex` at init (no `onChangePage` on mount) and performs a one-time, `didInitRef`-guarded non-animated seek on the first successful measurement — reading `initialPage` via a ref and routing through `findNearestLoopOffset` when looping. Adds `initialPage` tests and a story example on both platforms. CB1-2048 Co-Authored-By: Claude --- packages/common/CHANGELOG.md | 4 + packages/common/package.json | 2 +- packages/mcp-server/CHANGELOG.md | 4 + packages/mcp-server/package.json | 2 +- packages/mobile/CHANGELOG.md | 6 + packages/mobile/package.json | 2 +- packages/mobile/src/carousel/Carousel.tsx | 29 +++- .../carousel/__stories__/Carousel.stories.tsx | 25 +++ .../src/carousel/__tests__/Carousel.test.tsx | 157 +++++++++++++++++- packages/web/CHANGELOG.md | 6 + packages/web/package.json | 2 +- packages/web/src/carousel/Carousel.tsx | 29 +++- .../carousel/__stories__/Carousel.stories.tsx | 16 ++ .../src/carousel/__tests__/Carousel.test.tsx | 115 ++++++++++++- 14 files changed, 387 insertions(+), 12 deletions(-) diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 9cdb7f9378..bfe46efeb3 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/common/package.json b/packages/common/package.json index cda58e4228..17536768a5 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "9.22.1", + "version": "9.23.0", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 881ab1fb39..81907a3b55 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 1f242f008b..553f38ce95 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -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", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 208220f348..5289095593 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 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 diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 914715cbe9..87cf42ab33 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "9.22.1", + "version": "9.23.0", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/carousel/Carousel.tsx b/packages/mobile/src/carousel/Carousel.tsx index a86c0cb217..70890470b4 100644 --- a/packages/mobile/src/carousel/Carousel.tsx +++ b/packages/mobile/src/carousel/Carousel.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useImperativeHandle, + useLayoutEffect, useMemo, useRef, useState, @@ -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 + */ + initialPage?: number; /** * Hides the navigation arrows (previous/next buttons). */ @@ -563,6 +570,7 @@ export const Carousel = memo( paginationVariant, drag = 'snap', snapMode = 'page', + initialPage, NavigationComponent = DefaultCarouselNavigation, PaginationComponent = DefaultCarouselPagination, style, @@ -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)); const [containerSize, onLayout] = useLayout(); const [carouselItemRects, setCarouselItemRects] = useState<{ [itemId: string]: Rect; @@ -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]); @@ -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(); }, [ @@ -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 }); + } + }, [totalPages, initialPage, goToPage]); + useImperativeHandle( ref, () => ({ diff --git a/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx b/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx index 2ed4cb3a50..400aaf0e01 100644 --- a/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx +++ b/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx @@ -605,6 +605,30 @@ const ImperativeApiExample = () => { ); }; +const InitialPageExample = () => { + const theme = useTheme(); + + return ( + + + {sampleItems.slice(0, 5).map((item, index) => ( + + {item} + + ))} + + + ); +}; + const LoopingExamples = () => { const theme = useTheme(); @@ -833,6 +857,7 @@ export default function CarouselScreen() { + ); } diff --git a/packages/mobile/src/carousel/__tests__/Carousel.test.tsx b/packages/mobile/src/carousel/__tests__/Carousel.test.tsx index d304996017..1176dfe944 100644 --- a/packages/mobile/src/carousel/__tests__/Carousel.test.tsx +++ b/packages/mobile/src/carousel/__tests__/Carousel.test.tsx @@ -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 @@ -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) => ( + {`${activePageIndex}/${totalPages}`} + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(null); + + return ( + + + carouselRef.current?.goToPage(1)} testID="go-to-page-1"> + Go to Page 1 + + + {Array.from({ length: itemCount }, (_, index) => { + const itemId = `item-${index}`; + return ( + + + Item {index + 1} + + + ); + })} + + + + ); + }; + + render(); + + 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( + , + ); + + 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); + }); + }); }); diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index dff9085370..b713bc8ddb 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 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 diff --git a/packages/web/package.json b/packages/web/package.json index ee5f206be8..48ee7ca7aa 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "9.22.1", + "version": "9.23.0", "description": "Coinbase Design System - Web", "repository": { "type": "git", diff --git a/packages/web/src/carousel/Carousel.tsx b/packages/web/src/carousel/Carousel.tsx index 6105bd3ffc..914e66a42b 100644 --- a/packages/web/src/carousel/Carousel.tsx +++ b/packages/web/src/carousel/Carousel.tsx @@ -29,6 +29,7 @@ import { import { cx } from '../cx'; import { useComponentConfig } from '../hooks/useComponentConfig'; +import { useIsoEffect } from '../hooks/useIsoEffect'; import { type BoxBaseProps, type BoxDefaultElement, type BoxProps } from '../layout/Box'; import { HStack } from '../layout/HStack'; import { VStack } from '../layout/VStack'; @@ -226,6 +227,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 + */ + initialPage?: number; /** * Hides the navigation arrows (previous/next buttons and autoplay control). * @@ -719,6 +726,7 @@ export const Carousel = memo( paginationVariant, drag = 'snap', snapMode = 'page', + initialPage, NavigationComponent = DefaultCarouselNavigation, PaginationComponent = DefaultCarouselPagination, className, @@ -742,8 +750,9 @@ export const Carousel = memo( const animationApi = useAnimation(); const carouselScrollX = useMotionValue(0); const dragControls = useDragControls(); + const hasAppliedInitialPageRef = useRef(false); - const [activePageIndex, setActivePageIndex] = useState(0); + const [activePageIndex, setActivePageIndex] = useState(() => Math.max(0, initialPage ?? 0)); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const carouselItemRefMap = useRefMap(); @@ -1041,7 +1050,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]); @@ -1051,7 +1060,11 @@ export const Carousel = memo( .offset : pageOffsets[newPage]; - animate(carouselScrollX, -targetOffset, animationConfig); + if (shouldAnimate) { + animate(carouselScrollX, -targetOffset, animationConfig); + } else { + carouselScrollX.set(-targetOffset); + } reset(); }, [ @@ -1066,6 +1079,16 @@ export const Carousel = memo( ], ); + useIsoEffect(() => { + if (hasAppliedInitialPageRef.current || totalPages === 0) return; + hasAppliedInitialPageRef.current = true; + + const targetPage = initialPage ?? 0; + if (targetPage > 0) { + goToPage(targetPage, { animate: false }); + } + }, [totalPages, initialPage, goToPage]); + useImperativeHandle( ref, () => ({ diff --git a/packages/web/src/carousel/__stories__/Carousel.stories.tsx b/packages/web/src/carousel/__stories__/Carousel.stories.tsx index eb565af765..7e4c60606f 100644 --- a/packages/web/src/carousel/__stories__/Carousel.stories.tsx +++ b/packages/web/src/carousel/__stories__/Carousel.stories.tsx @@ -658,6 +658,21 @@ const LoopingExamples = () => ( ); +const InitialPageExample = () => ( + + {sampleItems.slice(0, 5).map((item, index) => ( + + {item} + + ))} + +); + export const All = () => ( @@ -666,5 +681,6 @@ export const All = () => ( + ); diff --git a/packages/web/src/carousel/__tests__/Carousel.test.tsx b/packages/web/src/carousel/__tests__/Carousel.test.tsx index 64cef9ad01..7e8d75b02d 100644 --- a/packages/web/src/carousel/__tests__/Carousel.test.tsx +++ b/packages/web/src/carousel/__tests__/Carousel.test.tsx @@ -7,7 +7,7 @@ import { Box } from '../../layout/Box'; import { VStack } from '../../layout/VStack'; import { Text } from '../../typography/Text'; import { DefaultThemeProvider } from '../../utils/test'; -import type { CarouselImperativeHandle } from '../Carousel'; +import type { CarouselImperativeHandle, CarouselPaginationComponentProps } from '../Carousel'; import { Carousel } from '../Carousel'; import { CarouselItem } from '../CarouselItem'; @@ -1656,4 +1656,117 @@ describe('Carousel', () => { }); }); }); + + describe('Initial Page', () => { + // 8 items at width 200 in an 800px container, snapMode="item" => 5 pages (indices 0-4). + const itemCount = 8; + + // Custom pagination that surfaces the active page index for assertions. + const ActivePagePagination = ({ + activePageIndex, + totalPages, + }: CarouselPaginationComponentProps) => ( + {`${activePageIndex}/${totalPages}`} + ); + + it('opens on the provided initialPage without firing onChangePage on mount', async () => { + const onChangePage = jest.fn(); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('active-page-index')).toHaveTextContent('3/5'); + }); + + expect(onChangePage).not.toHaveBeenCalled(); + }); + + it('defaults to the first page when initialPage is omitted', async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('active-page-index')).toHaveTextContent('0/5'); + }); + }); + + it.each` + initialPage | expectedIndex | description + ${99} | ${4} | ${'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( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('active-page-index')).toHaveTextContent(`${expectedIndex}/5`); + }); + }); + + it('lets a later imperative goToPage override the initial page', async () => { + const onChangePage = jest.fn(); + + const TestComponent = () => { + const carouselRef = useRef(null); + + return ( + + + + + {Array.from({ length: itemCount }, (_, index) => ( + + + Item {index + 1} + + + ))} + + + + ); + }; + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByTestId('active-page-index')).toHaveTextContent('3/5'); + }); + + await user.click(screen.getByTestId('go-to-page-1')); + + await waitFor(() => { + expect(screen.getByTestId('active-page-index')).toHaveTextContent('1/5'); + }); + expect(onChangePage).toHaveBeenCalledWith(1); + }); + }); });