import { forwardRef, useCallback, useRef, type ReactElement, type ReactNode, type Ref, type RefObject, } from 'react'; import type { AccessibilityRole, AccessibilityState, ViewStyle } from 'react-native'; import { useSharedValue } from 'react-native-reanimated'; import type { SharedValue } from 'react-native-reanimated'; import RNCarousel, { Pagination as RNCarouselPagination, type ICarouselInstance, type TCarouselProps, } from 'react-native-reanimated-carousel'; import { useResolveClassNames } from 'uniwind'; import { cn, createContext, mergeRefs } from '@cdx-ui/utils'; import { resolveCarouselPaginationScrollAction } from './paginationNavigation'; import { carouselPaginationContainerClassName, carouselPaginationDotVariants } from './styles'; export type CarouselRef = ICarouselInstance; /** * Full passthrough of `react-native-reanimated-carousel`'s own props (per `01-api-design.md`, * Resolved Question #1) — re-exported under a Forge-owned name so consumers never import the * adopted library directly. A type alias (not `interface extends`) because `TCarouselProps` * is itself a union-containing type (vertical/mode discriminants), which interfaces cannot extend. */ export type CarouselProps = TCarouselProps & { className?: string; /** Rendered inside the carousel's progress-sharing provider — use to compose `Carousel.Pagination`. */ children?: ReactNode; }; /** Mirrors the adopted library's private `DotStyle` shape (not exported) without importing it. */ type CarouselDotStyle = Omit & { width?: number; height?: number; backgroundColor?: string; borderRadius?: number; }; interface CarouselContextValue { progress: SharedValue; carouselRef: RefObject; loop: boolean; } const [CarouselProgressProvider, useCarouselProgressContext] = createContext( 'CarouselProgress', { strict: false }, ); function isSharedValueProgress(value: unknown): value is SharedValue { return typeof value === 'object' && value !== null && 'value' in value; } function CarouselInner( { className, style, onProgressChange, children, loop = true, defaultIndex = 0, ...rest }: CarouselProps, ref: Ref, ) { const internalRef = useRef(null); const internalProgress = useSharedValue(defaultIndex); const resolvedClassNameStyle = useResolveClassNames(cn(className)); const handleProgressChange = useCallback( (offsetProgress: number, absoluteProgress: number) => { internalProgress.value = absoluteProgress; if (typeof onProgressChange === 'function') { onProgressChange(offsetProgress, absoluteProgress); } }, [onProgressChange, internalProgress], ); const consumerProgress = isSharedValueProgress(onProgressChange) ? onProgressChange : undefined; const contextValue: CarouselContextValue = { progress: consumerProgress ?? internalProgress, carouselRef: internalRef, loop, }; return ( {children} ); } const CarouselRoot = forwardRef(CarouselInner) as ( props: CarouselProps & { ref?: Ref }, ) => ReactElement | null; // ============================================================================= // PAGINATION // ============================================================================= export interface CarouselPaginationItemAccessibilityOverrides { accessibilityLabel?: string; accessibilityHint?: string; accessibilityRole?: AccessibilityRole; accessibilityState?: AccessibilityState; } export interface CarouselPaginationProps { /** Carousel items — must be the same array (or same length) passed to `Carousel`'s `data`. */ data: T[]; /** Lay dots out vertically instead of horizontally. @default true (horizontal) */ horizontal?: boolean; /** Custom content per dot; defaults to the library's built-in dot rendering. */ renderItem?: (item: T, index: number) => ReactNode; /** Dot size in pixels (both width and height). */ size?: number; /** Included in the default accessibility label, e.g. `"Slide 1 of 4 - {carouselName}"`. */ carouselName?: string; /** Per-dot accessibility overrides. Defaults to `"Slide N of M"` / `"Go to Slide N of M"`. */ paginationItemAccessibility?: ( index: number, length: number, ) => CarouselPaginationItemAccessibilityOverrides; className?: string; /** Explicit progress value — only required when rendered outside `` (sibling composition). */ progress?: SharedValue; /** Explicit carousel ref for the default press-to-navigate behavior — only required outside ``. */ carouselRef?: RefObject; /** * Mirrors `Carousel`'s `loop` prop for shortest-path dot navigation. * Defaults to `true` (same as `Carousel`) when omitted outside context. */ loop?: boolean; /** Overrides the default press-to-navigate behavior (`scrollTo({ index })`). */ onPress?: (index: number) => void; } function CarouselPaginationComponent({ className, progress: progressProp, carouselRef: carouselRefProp, loop: loopProp, onPress, ...rest }: CarouselPaginationProps) { // `strict: false` with no `defaultValue` means this returns `undefined` outside a // `` provider at runtime; cast to communicate that to TypeScript, since // `createContext`'s helper type always reports `T` (see `@cdx-ui/utils`). const context = useCarouselProgressContext() as CarouselContextValue | undefined; const progress = progressProp ?? context?.progress; const carouselRef = carouselRefProp ?? context?.carouselRef; const loop = loopProp ?? context?.loop ?? true; if (!progress) { throw new Error( 'Carousel.Pagination must be rendered inside or receive an explicit `progress` prop.', ); } if (!onPress && !carouselRef) { throw new Error( 'Carousel.Pagination must be rendered inside or receive an explicit `carouselRef` prop (or a custom `onPress`).', ); } const handlePress = useCallback( (index: number) => { if (onPress) { onPress(index); return; } const carousel = carouselRef?.current; if (!carousel) return; const scrollAction = resolveCarouselPaginationScrollAction( carousel.getCurrentIndex(), index, rest.data.length, loop, ); if (scrollAction) { carousel.scrollTo(scrollAction); } }, [onPress, carouselRef, loop, rest.data.length], ); const containerStyle = useResolveClassNames(cn(carouselPaginationContainerClassName, className)); const dotStyle = useResolveClassNames(carouselPaginationDotVariants({ active: false })); const activeDotStyle = useResolveClassNames(carouselPaginationDotVariants({ active: true })); return ( ); } CarouselPaginationComponent.displayName = 'Carousel.Pagination'; const CarouselPagination = CarouselPaginationComponent as ( props: CarouselPaginationProps, ) => ReactElement | null; type CarouselCompoundComponent = typeof CarouselRoot & { Pagination: typeof CarouselPagination; }; export const Carousel = Object.assign(CarouselRoot, { Pagination: CarouselPagination, }) as CarouselCompoundComponent; // ============================================================================= // HOOK // ============================================================================= export interface UseCarouselOptions { /** Seed `progress` to match `Carousel`'s `defaultIndex` in sibling composition. @default 0 */ defaultIndex?: number; } export function useCarousel(options?: UseCarouselOptions) { const ref = useRef(null); const progress = useSharedValue(options?.defaultIndex ?? 0); return { ref, /** Bind to `Carousel`'s `onProgressChange` and sibling `Carousel.Pagination`'s `progress`. */ progress, next: (opts?: Parameters[0]) => ref.current?.next(opts), prev: (opts?: Parameters[0]) => ref.current?.prev(opts), scrollTo: (opts?: Parameters[0]) => ref.current?.scrollTo(opts), }; }