import React from 'react';
import {
I18nManager,
type NativeScrollEvent,
type NativeSyntheticEvent,
ScrollView,
type ViewStyle,
} from 'react-native';
import type {
ContentRailProps,
ContentRailVisibleRangeChangeEvent,
} from '../../../../types/content-rail';
import { IconButton } from '../../../button/public';
import { View } from '../../../layout/public';
import { withZoraThemeScope } from '../../../theme/adapters/inbound/withZoraThemeScope';
import { useZoraTheme } from '../../../theme/composition/useZoraTheme';
import { Text } from '../../../typography/public';
import { resolveContentRailItemWidth } from '../../utils/resolveContentRailItemWidth';
import { resolveContentRailPhysicalOffset } from '../../utils/resolveContentRailPhysicalOffset';
import { resolveContentRailState } from '../../utils/resolveContentRailState';
import { useContentRailReducedMotion } from '../../utils/useContentRailReducedMotion';
import { ContentRailFlow } from './ContentRailFlow';
/**
* Horizontally browsable, manifest-authorable content with responsive item sizing.
*
* ContentRail owns only presentation and scrolling. Apps retain ownership of item data,
* navigation, recommendations, and route behavior.
*/
export const ContentRail = withZoraThemeScope(ContentRailInner);
/*** Selects intrinsic scrolling or sized item navigation. */
function ContentRailInner(props: ContentRailProps) {
return props.itemSize === 'content' ? (
) : (
);
}
/*** Renders equal-width cards with responsive sizing and accessible previous/next controls. */
function ContentRailCarousel({
themeId: _themeId,
mode: _mode,
interactionPolicy,
children,
itemSize = 'responsive',
gap = 'm',
padding = 'm',
peek = 32,
showControls = true,
bounces,
showsScrollIndicator = false,
direction = 'auto',
motion = 'system',
accessibilityLabel = 'Content rail',
previousLabel = 'Previous items',
nextLabel = 'Next items',
onControlPress,
onVisibleRangeChange,
testID,
}: ContentRailProps) {
const { theme } = useZoraTheme();
const items = React.Children.toArray(children);
const scrollViewRef = React.useRef(null);
const lastRangeRef = React.useRef(null);
const logicalOffsetRef = React.useRef(0);
const rtlInitializedRef = React.useRef(false);
const [viewportWidth, setViewportWidth] = React.useState(0);
const [contentWidth, setContentWidth] = React.useState(0);
const [logicalOffset, setLogicalOffset] = React.useState(0);
const reducedMotion = useContentRailReducedMotion(motion);
const isRtl = direction === 'rtl' || (direction === 'auto' && I18nManager.isRTL);
const gapValue = resolveSpacingValue(theme.spacing, gap);
const paddingValue = resolveSpacingValue(theme.spacing, padding);
const itemWidth = resolveContentRailItemWidth({
gap: gapValue,
itemCount: items.length,
itemSize,
padding: paddingValue,
peek,
viewportWidth,
});
const railState = resolveContentRailState({
contentWidth,
gap: gapValue,
itemCount: items.length,
itemWidth,
logicalOffset,
viewportWidth,
});
const passive = interactionPolicy === 'passive';
const hasOverflow = railState.maxOffset > 1;
const emitVisibleRange = React.useCallback(
(nextOffset: number) => {
const next = resolveContentRailState({
contentWidth,
gap: gapValue,
itemCount: items.length,
itemWidth,
logicalOffset: nextOffset,
viewportWidth,
});
const event = {
firstVisibleIndex: next.firstVisibleIndex,
lastVisibleIndex: next.lastVisibleIndex,
itemCount: items.length,
};
const previous = lastRangeRef.current;
if (
previous?.firstVisibleIndex === event.firstVisibleIndex &&
previous.lastVisibleIndex === event.lastVisibleIndex &&
previous.itemCount === event.itemCount
) {
return;
}
lastRangeRef.current = event;
void onVisibleRangeChange?.(event);
},
[contentWidth, gapValue, itemWidth, items.length, onVisibleRangeChange, viewportWidth],
);
React.useEffect(() => {
emitVisibleRange(railState.offset);
}, [emitVisibleRange, railState.offset]);
React.useEffect(() => {
rtlInitializedRef.current = !isRtl;
}, [isRtl]);
React.useEffect(() => {
if (viewportWidth === 0 || contentWidth === 0) return;
const nextOffset = Math.min(logicalOffsetRef.current, railState.maxOffset);
scrollViewRef.current?.scrollTo({
animated: false,
x: resolveContentRailPhysicalOffset({
isRtl,
logicalOffset: nextOffset,
maxOffset: railState.maxOffset,
}),
y: 0,
});
rtlInitializedRef.current = true;
}, [contentWidth, isRtl, itemWidth, railState.maxOffset, viewportWidth]);
const scrollToIndex = (targetIndex: number, controlDirection: 'previous' | 'next') => {
if (passive || !hasOverflow) return;
const clampedIndex = Math.min(Math.max(0, targetIndex), Math.max(0, items.length - 1));
const targetOffset = Math.min(clampedIndex * railState.stride, railState.maxOffset);
logicalOffsetRef.current = targetOffset;
setLogicalOffset(targetOffset);
scrollViewRef.current?.scrollTo({
animated: !reducedMotion,
x: resolveContentRailPhysicalOffset({
isRtl,
logicalOffset: targetOffset,
maxOffset: railState.maxOffset,
}),
y: 0,
});
emitVisibleRange(targetOffset);
void onControlPress?.({ direction: controlDirection, targetIndex: clampedIndex });
};
const handleScroll = (event: NativeSyntheticEvent) => {
if (isRtl && !rtlInitializedRef.current) return;
const physicalOffset = event.nativeEvent.contentOffset.x;
const nextOffset = resolveContentRailPhysicalOffset({
isRtl,
logicalOffset: physicalOffset,
maxOffset: railState.maxOffset,
});
const clampedOffset = Math.min(Math.max(0, nextOffset), railState.maxOffset);
logicalOffsetRef.current = clampedOffset;
setLogicalOffset(clampedOffset);
emitVisibleRange(clampedOffset);
};
const rangeLabel =
items.length === 0
? 'No items'
: `Items ${railState.firstVisibleIndex + 1}–${railState.lastVisibleIndex + 1} of ${items.length}`;
const contentStyle: ViewStyle = {
direction: isRtl ? 'rtl' : 'ltr',
flexDirection: 'row',
gap: gapValue,
paddingHorizontal: paddingValue,
};
return (
{showControls && hasOverflow ? (
{rangeLabel}
scrollToIndex(railState.anchorIndex - 1, 'previous')}
size="l"
/>
scrollToIndex(railState.anchorIndex + 1, 'next')}
size="l"
/>
) : null}
{
if (isRtl) rtlInitializedRef.current = false;
setContentWidth(width);
}}
onLayout={(event) => {
if (isRtl) rtlInitializedRef.current = false;
setViewportWidth(event.nativeEvent.layout.width);
}}
onScroll={handleScroll}
ref={scrollViewRef}
scrollEnabled={!passive}
scrollEventThrottle={16}
bounces={bounces}
showsHorizontalScrollIndicator={showsScrollIndicator}
snapToInterval={itemWidth > 0 ? itemWidth + gapValue : undefined}
>
{items.map((item, index) => (
{item}
))}
);
}
function resolveSpacingValue(
spacing: { none: number; xs: number; s: number; m: number; l: number; xl: number },
token: NonNullable,
): number {
if (token === 'none') return spacing.none;
if (token === 'xs') return spacing.xs;
if (token === 's') return spacing.s;
if (token === 'l') return spacing.l;
if (token === 'xl') return spacing.xl;
return spacing.m;
}