overlapping-cards-scroll 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,868 @@
1
+ import {
2
+ Children,
3
+ Fragment,
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react'
12
+ import type { ReactElement } from 'react'
13
+ import { Animated, Easing, Platform, Pressable, StyleSheet, Text, View } from 'react-native'
14
+ import type {
15
+ OverlappingCardsScrollRNFocusTransitionMode,
16
+ OverlappingCardsScrollRNFocusTriggerProps,
17
+ OverlappingCardsScrollRNItem,
18
+ OverlappingCardsScrollRNProps,
19
+ OverlappingCardsScrollRNTabsContainerProps,
20
+ OverlappingCardsScrollRNTabProps,
21
+ OverlappingCardsScrollRNTabsPosition,
22
+ } from './OverlappingCardsScrollRN.types'
23
+
24
+ export type {
25
+ OverlappingCardsScrollRNFocusTransitionMode,
26
+ OverlappingCardsScrollRNFocusTriggerBehavior,
27
+ OverlappingCardsScrollRNFocusTriggerProps,
28
+ OverlappingCardsScrollRNItem,
29
+ OverlappingCardsScrollRNPageDotsPosition,
30
+ OverlappingCardsScrollRNProps,
31
+ OverlappingCardsScrollRNSnapDecelerationRate,
32
+ OverlappingCardsScrollRNTabsContainerProps,
33
+ OverlappingCardsScrollRNTabProps,
34
+ OverlappingCardsScrollRNTabsPosition,
35
+ } from './OverlappingCardsScrollRN.types'
36
+
37
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
38
+ const PAGE_DOT_POSITIONS = new Set(['above', 'below', 'overlay'])
39
+ const TAB_POSITIONS = new Set(['above', 'below'])
40
+
41
+ const normalizePageDotsPosition = (value) =>
42
+ PAGE_DOT_POSITIONS.has(value) ? value : 'below'
43
+
44
+ const normalizeTabsPosition = (value) =>
45
+ TAB_POSITIONS.has(value) ? value : 'above'
46
+
47
+ const toNumericOffset = (value, fallback = 0) => {
48
+ if (typeof value === 'number' && Number.isFinite(value)) {
49
+ return value
50
+ }
51
+
52
+ if (typeof value === 'string') {
53
+ const parsed = Number.parseFloat(value.trim())
54
+ if (Number.isFinite(parsed)) {
55
+ return parsed
56
+ }
57
+ }
58
+
59
+ return fallback
60
+ }
61
+
62
+ const toNativeDimension = (
63
+ value,
64
+ fallback = 0,
65
+ ): number | 'auto' | `${number}%` => {
66
+ if (typeof value === 'number' && Number.isFinite(value)) {
67
+ return value
68
+ }
69
+
70
+ if (typeof value === 'string') {
71
+ const trimmed = value.trim()
72
+ if (trimmed === 'auto') {
73
+ return 'auto'
74
+ }
75
+
76
+ if (trimmed.endsWith('%')) {
77
+ const percent = Number.parseFloat(trimmed.slice(0, -1))
78
+ if (Number.isFinite(percent)) {
79
+ return `${percent}%` as `${number}%`
80
+ }
81
+ }
82
+
83
+ const numeric = Number.parseFloat(trimmed)
84
+ if (Number.isFinite(numeric)) {
85
+ return numeric
86
+ }
87
+ }
88
+
89
+ return fallback
90
+ }
91
+
92
+ const resolveCardXAtProgress = (index, progress, layout) => {
93
+ const principalIndex = Math.floor(progress)
94
+ const transitionProgress = progress - principalIndex
95
+
96
+ if (index <= principalIndex) {
97
+ return index * layout.peek
98
+ }
99
+
100
+ let cardX =
101
+ principalIndex * layout.peek +
102
+ layout.cardWidth +
103
+ (index - principalIndex - 1) * layout.peek
104
+
105
+ if (index === principalIndex + 1) {
106
+ cardX -= transitionProgress * (layout.cardWidth - layout.peek)
107
+ }
108
+
109
+ return cardX
110
+ }
111
+
112
+ const OverlappingCardsScrollRNControllerContext = createContext(null)
113
+ const OverlappingCardsScrollRNCardIndexContext = createContext(null)
114
+
115
+ function useOverlappingCardsScrollRNCardControl() {
116
+ const controller = useContext(OverlappingCardsScrollRNControllerContext)
117
+ const cardIndex = useContext(OverlappingCardsScrollRNCardIndexContext)
118
+
119
+ const canFocus = controller !== null && cardIndex !== null
120
+ const focusCard = useCallback(
121
+ (
122
+ options: {
123
+ animated?: boolean
124
+ transitionMode?: OverlappingCardsScrollRNFocusTransitionMode
125
+ duration?: number
126
+ } = {},
127
+ ) => {
128
+ if (!canFocus) {
129
+ return
130
+ }
131
+ controller.focusCard(cardIndex, options)
132
+ },
133
+ [canFocus, cardIndex, controller],
134
+ )
135
+
136
+ return {
137
+ cardIndex,
138
+ canFocus,
139
+ focusCard,
140
+ }
141
+ }
142
+
143
+ export function OverlappingCardsScrollRNFocusTrigger({
144
+ children = 'Make principal',
145
+ style = undefined,
146
+ textStyle = undefined,
147
+ behavior = 'smooth',
148
+ transitionMode = 'swoop',
149
+ disabled = false,
150
+ accessibilityLabel = undefined,
151
+ testID = undefined,
152
+ onPress = undefined,
153
+ onClick = undefined,
154
+ ...pressableProps
155
+ }: OverlappingCardsScrollRNFocusTriggerProps) {
156
+ const { canFocus, focusCard } = useOverlappingCardsScrollRNCardControl()
157
+
158
+ const handlePress = (event) => {
159
+ onClick?.(event)
160
+ onPress?.(event)
161
+ focusCard({
162
+ animated: behavior !== 'auto',
163
+ transitionMode,
164
+ })
165
+ }
166
+
167
+ return (
168
+ <Pressable
169
+ style={({ pressed }) => [styles.focusTrigger, pressed && styles.focusTriggerPressed, style]}
170
+ disabled={disabled || !canFocus}
171
+ accessibilityLabel={accessibilityLabel}
172
+ testID={testID}
173
+ onPress={handlePress}
174
+ {...pressableProps}
175
+ >
176
+ <Text style={[styles.focusTriggerText, textStyle]}>{children}</Text>
177
+ </Pressable>
178
+ )
179
+ }
180
+
181
+ function DefaultTabsContainerComponent({
182
+ children,
183
+ style,
184
+ ariaLabel,
185
+ }: OverlappingCardsScrollRNTabsContainerProps) {
186
+ return (
187
+ <View accessibilityRole="tablist" accessibilityLabel={ariaLabel} style={style}>
188
+ {children}
189
+ </View>
190
+ )
191
+ }
192
+
193
+ function DefaultTabsComponent({
194
+ name,
195
+ style,
196
+ textStyle,
197
+ accessibilityLabel,
198
+ accessibilityState,
199
+ onPress,
200
+ onClick,
201
+ }: OverlappingCardsScrollRNTabProps) {
202
+ const handlePress = () => {
203
+ onClick()
204
+ onPress()
205
+ }
206
+
207
+ return (
208
+ <Pressable
209
+ accessibilityRole="tab"
210
+ accessibilityLabel={accessibilityLabel}
211
+ accessibilityState={accessibilityState}
212
+ onPress={handlePress}
213
+ style={({ pressed }) => [styles.tab, pressed && styles.tabPressed, style]}
214
+ >
215
+ <Text style={[styles.tabText, textStyle]}>{name}</Text>
216
+ </Pressable>
217
+ )
218
+ }
219
+
220
+ const resolveCardWidth = (cardWidth, viewportWidth, fallbackRatio) => {
221
+ if (typeof cardWidth === 'number' && Number.isFinite(cardWidth) && cardWidth > 0) {
222
+ return cardWidth
223
+ }
224
+
225
+ if (typeof cardWidth === 'string') {
226
+ const value = cardWidth.trim()
227
+ if (value.endsWith('%')) {
228
+ const percent = Number.parseFloat(value.slice(0, -1))
229
+ if (Number.isFinite(percent) && percent > 0) {
230
+ return (viewportWidth * percent) / 100
231
+ }
232
+ }
233
+
234
+ const numeric = Number.parseFloat(value)
235
+ if (Number.isFinite(numeric) && numeric > 0) {
236
+ return numeric
237
+ }
238
+ }
239
+
240
+ return viewportWidth * fallbackRatio
241
+ }
242
+
243
+ export function OverlappingCardsScrollRN(props: OverlappingCardsScrollRNProps) {
244
+ const {
245
+ style = undefined,
246
+ cardHeight = 300,
247
+ cardWidth = undefined,
248
+ cardWidthRatio = 1 / 3,
249
+ basePeek = 64,
250
+ minPeek = 10,
251
+ maxPeek = 84,
252
+ showsHorizontalScrollIndicator = true,
253
+ snapToCardOnRelease = true,
254
+ snapDecelerationRate = 'normal',
255
+ snapDisableIntervalMomentum = false,
256
+ showPageDots = false,
257
+ pageDotsPosition = 'below',
258
+ pageDotsOffset = 10,
259
+ focusTransitionDuration = 420,
260
+ cardContainerStyle = undefined,
261
+ showTabs = false,
262
+ tabsPosition = 'above',
263
+ tabsOffset = 10,
264
+ tabsComponent: TabsComponent = DefaultTabsComponent,
265
+ tabsContainerComponent: TabsContainerComponent = DefaultTabsContainerComponent,
266
+ } = props
267
+
268
+ const hasItems = 'items' in props && Array.isArray(props.items)
269
+ const hasChildren = 'children' in props && props.children != null
270
+
271
+ useEffect(() => {
272
+ if (hasItems && hasChildren) {
273
+ console.warn(
274
+ 'OverlappingCardsScrollRN: Both `items` and `children` were provided. `items` takes precedence.',
275
+ )
276
+ }
277
+ }, [hasItems, hasChildren])
278
+
279
+ const itemsProp: OverlappingCardsScrollRNItem[] | null = hasItems ? props.items : null
280
+ const childrenProp = hasChildren ? props.children : null
281
+
282
+ const cards = useMemo(() => {
283
+ if (itemsProp) {
284
+ return itemsProp.map((item) => <Fragment key={item.id}>{item.jsx}</Fragment>)
285
+ }
286
+ return Children.toArray(childrenProp) as ReactElement[]
287
+ }, [childrenProp, itemsProp])
288
+
289
+ const cardNames: string[] | null = useMemo(() => {
290
+ if (itemsProp) {
291
+ return itemsProp.map((item) => item.name)
292
+ }
293
+ return null
294
+ }, [itemsProp])
295
+
296
+ const cardCount = cards.length
297
+ const resolvedTabsPosition = normalizeTabsPosition(tabsPosition)
298
+ const showNavigationTabs = showTabs && cardCount > 1 && cardNames !== null
299
+ const resolvedPageDotsOffset = toNumericOffset(pageDotsOffset, 10)
300
+ const resolvedTabsOffset = toNumericOffset(tabsOffset, 10)
301
+ const resolvedCardHeight = toNativeDimension(cardHeight, 300)
302
+
303
+ useEffect(() => {
304
+ if (showTabs && cardNames === null) {
305
+ console.warn(
306
+ 'OverlappingCardsScrollRN: `showTabs` requires the `items` prop to provide card names. Tabs will not render.',
307
+ )
308
+ }
309
+ }, [cardNames, showTabs])
310
+
311
+ const scrollRef = useRef(null)
312
+ const scrollX = useRef(new Animated.Value(0)).current
313
+ const scrollXValueRef = useRef(0)
314
+ const focusTransitionProgress = useRef(new Animated.Value(1)).current
315
+ const focusTransitionAnimationRef = useRef(null)
316
+ const focusTransitionIdRef = useRef(0)
317
+
318
+ const [viewportWidth, setViewportWidth] = useState(1)
319
+ const [focusTransition, setFocusTransition] = useState(null)
320
+ const [scrollProgress, setScrollProgress] = useState(0)
321
+
322
+ const layout = useMemo(() => {
323
+ const safeWidth = Math.max(1, viewportWidth)
324
+ const safeRatio = clamp(cardWidthRatio, 0.2, 0.95)
325
+ const resolvedCardWidth = Math.max(1, resolveCardWidth(cardWidth, safeWidth, safeRatio))
326
+
327
+ if (cardCount < 2) {
328
+ return {
329
+ cardWidth: resolvedCardWidth,
330
+ peek: 0,
331
+ stepDistance: 1,
332
+ scrollRange: 0,
333
+ trackWidth: safeWidth,
334
+ }
335
+ }
336
+
337
+ const availableStackWidth = Math.max(0, safeWidth - resolvedCardWidth)
338
+ const preferredPeek = clamp(basePeek, minPeek, maxPeek)
339
+ const maxVisiblePeek = availableStackWidth / (cardCount - 1)
340
+ const peek = Math.min(preferredPeek, maxVisiblePeek)
341
+
342
+ const stepDistance = Math.max(1, resolvedCardWidth - peek)
343
+ const scrollRange = stepDistance * (cardCount - 1)
344
+
345
+ return {
346
+ cardWidth: resolvedCardWidth,
347
+ peek,
348
+ stepDistance,
349
+ scrollRange,
350
+ trackWidth: safeWidth + scrollRange,
351
+ }
352
+ }, [basePeek, cardCount, cardWidth, cardWidthRatio, maxPeek, minPeek, viewportWidth])
353
+
354
+ const stopFocusTransitionAnimation = useCallback(() => {
355
+ if (focusTransitionAnimationRef.current) {
356
+ focusTransitionAnimationRef.current.stop()
357
+ focusTransitionAnimationRef.current = null
358
+ }
359
+ focusTransitionProgress.stopAnimation()
360
+ }, [focusTransitionProgress])
361
+
362
+ const cancelFocusTransition = useCallback(() => {
363
+ focusTransitionIdRef.current += 1
364
+ stopFocusTransitionAnimation()
365
+ setFocusTransition(null)
366
+ }, [stopFocusTransitionAnimation])
367
+
368
+ useEffect(() => {
369
+ const id = scrollX.addListener(({ value }) => {
370
+ scrollXValueRef.current = value
371
+
372
+ if (!showNavigationTabs) {
373
+ return
374
+ }
375
+
376
+ const nextProgress =
377
+ cardCount > 1
378
+ ? clamp(value / layout.stepDistance, 0, cardCount - 1)
379
+ : 0
380
+
381
+ setScrollProgress((currentProgress) =>
382
+ Math.abs(currentProgress - nextProgress) < 0.001
383
+ ? currentProgress
384
+ : nextProgress,
385
+ )
386
+ })
387
+
388
+ return () => {
389
+ scrollX.removeListener(id)
390
+ }
391
+ }, [cardCount, layout.stepDistance, scrollX, showNavigationTabs])
392
+
393
+ useEffect(() => {
394
+ if (!showNavigationTabs) {
395
+ setScrollProgress(0)
396
+ return
397
+ }
398
+
399
+ const nextProgress =
400
+ cardCount > 1
401
+ ? clamp(scrollXValueRef.current / layout.stepDistance, 0, cardCount - 1)
402
+ : 0
403
+
404
+ setScrollProgress(nextProgress)
405
+ }, [cardCount, layout.stepDistance, showNavigationTabs])
406
+
407
+ useEffect(() => () => stopFocusTransitionAnimation(), [stopFocusTransitionAnimation])
408
+
409
+ useEffect(() => {
410
+ if (cardCount > 1) {
411
+ return
412
+ }
413
+
414
+ cancelFocusTransition()
415
+ }, [cancelFocusTransition, cardCount])
416
+
417
+ useEffect(() => {
418
+ if (!scrollRef.current) {
419
+ return
420
+ }
421
+
422
+ if (scrollXValueRef.current > layout.scrollRange) {
423
+ scrollRef.current.scrollTo({ x: layout.scrollRange, y: 0, animated: false })
424
+ scrollX.setValue(layout.scrollRange)
425
+ scrollXValueRef.current = layout.scrollRange
426
+ }
427
+ }, [layout.scrollRange, scrollX])
428
+
429
+ const onScroll = useMemo(
430
+ () =>
431
+ Animated.event([{ nativeEvent: { contentOffset: { x: scrollX } } }], {
432
+ useNativeDriver: true,
433
+ }),
434
+ [scrollX],
435
+ )
436
+
437
+ const focusCard = useCallback(
438
+ (
439
+ targetIndex: number,
440
+ options: {
441
+ animated?: boolean
442
+ transitionMode?: OverlappingCardsScrollRNFocusTransitionMode
443
+ duration?: number
444
+ } = {},
445
+ ) => {
446
+ const scrollElement = scrollRef.current
447
+ if (!scrollElement || cardCount === 0) {
448
+ return
449
+ }
450
+
451
+ const safeIndex = clamp(Math.round(targetIndex), 0, cardCount - 1)
452
+ const nextScrollLeft = clamp(safeIndex * layout.stepDistance, 0, layout.scrollRange)
453
+ const transitionMode = options.transitionMode ?? 'swoop'
454
+
455
+ if (transitionMode === 'swoop' && cardCount > 1) {
456
+ if (showNavigationTabs) {
457
+ setScrollProgress(safeIndex)
458
+ }
459
+
460
+ const fromProgress = clamp(
461
+ scrollXValueRef.current / layout.stepDistance,
462
+ 0,
463
+ cardCount - 1,
464
+ )
465
+ const toProgress = safeIndex
466
+ const duration = Number.isFinite(options.duration)
467
+ ? Math.max(0, options.duration)
468
+ : focusTransitionDuration
469
+
470
+ stopFocusTransitionAnimation()
471
+ const transitionId = focusTransitionIdRef.current + 1
472
+ focusTransitionIdRef.current = transitionId
473
+ focusTransitionProgress.setValue(0)
474
+ setFocusTransition({ fromProgress, toProgress })
475
+
476
+ if (duration <= 0 || Math.abs(toProgress - fromProgress) < 0.001) {
477
+ setFocusTransition(null)
478
+ focusTransitionProgress.setValue(1)
479
+ scrollElement.scrollTo({
480
+ x: nextScrollLeft,
481
+ y: 0,
482
+ animated: false,
483
+ })
484
+ scrollX.setValue(nextScrollLeft)
485
+ scrollXValueRef.current = nextScrollLeft
486
+ return
487
+ }
488
+
489
+ focusTransitionAnimationRef.current = Animated.timing(focusTransitionProgress, {
490
+ toValue: 1,
491
+ duration,
492
+ easing: Easing.out(Easing.cubic),
493
+ useNativeDriver: true,
494
+ })
495
+
496
+ focusTransitionAnimationRef.current.start(({ finished }) => {
497
+ if (!finished || focusTransitionIdRef.current !== transitionId) {
498
+ return
499
+ }
500
+ focusTransitionAnimationRef.current = null
501
+ scrollElement.scrollTo({
502
+ x: nextScrollLeft,
503
+ y: 0,
504
+ animated: false,
505
+ })
506
+ scrollX.setValue(nextScrollLeft)
507
+ scrollXValueRef.current = nextScrollLeft
508
+ focusTransitionProgress.setValue(1)
509
+ setFocusTransition(null)
510
+ })
511
+ return
512
+ }
513
+
514
+ cancelFocusTransition()
515
+ scrollElement.scrollTo({
516
+ x: nextScrollLeft,
517
+ y: 0,
518
+ animated: options.animated ?? true,
519
+ })
520
+
521
+ if ((options.animated ?? true) === false) {
522
+ scrollX.setValue(nextScrollLeft)
523
+ scrollXValueRef.current = nextScrollLeft
524
+ if (showNavigationTabs) {
525
+ setScrollProgress(safeIndex)
526
+ }
527
+ }
528
+ },
529
+ [
530
+ cancelFocusTransition,
531
+ cardCount,
532
+ focusTransitionDuration,
533
+ focusTransitionProgress,
534
+ layout.scrollRange,
535
+ layout.stepDistance,
536
+ scrollX,
537
+ showNavigationTabs,
538
+ stopFocusTransitionAnimation,
539
+ ],
540
+ )
541
+
542
+ const controllerContextValue = useMemo(
543
+ () => ({
544
+ focusCard,
545
+ }),
546
+ [focusCard],
547
+ )
548
+
549
+ const shouldSnapToCard =
550
+ snapToCardOnRelease && Platform.OS === 'ios' && cardCount > 1 && layout.stepDistance > 1
551
+ const resolvedPageDotsPosition = normalizePageDotsPosition(pageDotsPosition)
552
+ const showNavigationDots = showPageDots && cardCount > 1
553
+ const dotScrollX = useMemo(() => {
554
+ if (!focusTransition) {
555
+ return scrollX
556
+ }
557
+
558
+ const fromX = focusTransition.fromProgress * layout.stepDistance
559
+ const toX = focusTransition.toProgress * layout.stepDistance
560
+
561
+ return focusTransitionProgress.interpolate({
562
+ inputRange: [0, 1],
563
+ outputRange: [fromX, toX],
564
+ extrapolate: 'clamp',
565
+ })
566
+ }, [focusTransition, focusTransitionProgress, layout.stepDistance, scrollX])
567
+ const progress = showNavigationTabs ? scrollProgress : 0
568
+ const activeIndex = Math.floor(progress)
569
+
570
+ const renderPageDots = (placement) => {
571
+ if (!showNavigationDots || resolvedPageDotsPosition !== placement) {
572
+ return null
573
+ }
574
+
575
+ const rowStyle =
576
+ placement === 'above'
577
+ ? [styles.pageDotsRow, { marginBottom: resolvedPageDotsOffset }]
578
+ : placement === 'below'
579
+ ? [styles.pageDotsRow, { marginTop: resolvedPageDotsOffset }]
580
+ : [styles.pageDotsRow, styles.pageDotsOverlay, { bottom: resolvedPageDotsOffset }]
581
+
582
+ return (
583
+ <View
584
+ pointerEvents={placement === 'overlay' ? 'box-none' : 'auto'}
585
+ style={rowStyle}
586
+ >
587
+ {cards.map((_, index) => {
588
+ const inputRange = [
589
+ (index - 1) * layout.stepDistance,
590
+ index * layout.stepDistance,
591
+ (index + 1) * layout.stepDistance,
592
+ ]
593
+ const opacity = dotScrollX.interpolate({
594
+ inputRange,
595
+ outputRange: [0.25, 1, 0.25],
596
+ extrapolate: 'clamp',
597
+ })
598
+ const scale = dotScrollX.interpolate({
599
+ inputRange,
600
+ outputRange: [0.9, 1.12, 0.9],
601
+ extrapolate: 'clamp',
602
+ })
603
+
604
+ return (
605
+ <Pressable
606
+ key={`rn-ocs-page-dot-${placement}-${index}`}
607
+ accessibilityLabel={`Go to card ${index + 1}`}
608
+ onPress={() => focusCard(index, { animated: true, transitionMode: 'swoop' })}
609
+ style={styles.pageDotPressable}
610
+ >
611
+ <Animated.View style={[styles.pageDot, { opacity, transform: [{ scale }] }]} />
612
+ </Pressable>
613
+ )
614
+ })}
615
+ </View>
616
+ )
617
+ }
618
+
619
+ const renderTabs = (position: OverlappingCardsScrollRNTabsPosition) => {
620
+ if (!showNavigationTabs || resolvedTabsPosition !== position || cardNames === null) {
621
+ return null
622
+ }
623
+
624
+ const containerStyle =
625
+ position === 'above'
626
+ ? [styles.tabsRow, { marginBottom: resolvedTabsOffset }]
627
+ : [styles.tabsRow, { marginTop: resolvedTabsOffset }]
628
+
629
+ return (
630
+ <TabsContainerComponent
631
+ position={position}
632
+ className={`rn-ocs-tabs rn-ocs-tabs--${position}`}
633
+ style={containerStyle}
634
+ ariaLabel="Card tabs"
635
+ cardNames={cardNames}
636
+ activeIndex={activeIndex}
637
+ progress={progress}
638
+ >
639
+ {cardNames.map((name, index) => {
640
+ const influence = clamp(1 - Math.abs(progress - index), 0, 1)
641
+ const isPrincipal = influence > 0.98
642
+ const animate = {
643
+ opacity: 0.45 + influence * 0.55,
644
+ }
645
+ const pressTab = () =>
646
+ focusCard(index, {
647
+ animated: true,
648
+ transitionMode: 'swoop',
649
+ })
650
+
651
+ return (
652
+ <TabsComponent
653
+ key={`rn-ocs-tab-${position}-${index}`}
654
+ name={name}
655
+ index={index}
656
+ position={position}
657
+ isPrincipal={isPrincipal}
658
+ influence={influence}
659
+ animate={animate}
660
+ className={isPrincipal ? 'rn-ocs-tab rn-ocs-tab--active' : 'rn-ocs-tab'}
661
+ style={{ opacity: animate.opacity }}
662
+ textStyle={isPrincipal ? styles.tabTextActive : undefined}
663
+ ariaLabel={`Go to ${name}`}
664
+ ariaCurrent={isPrincipal ? 'page' : undefined}
665
+ accessibilityLabel={`Go to ${name}`}
666
+ accessibilityState={{ selected: isPrincipal }}
667
+ onPress={pressTab}
668
+ onClick={pressTab}
669
+ />
670
+ )
671
+ })}
672
+ </TabsContainerComponent>
673
+ )
674
+ }
675
+
676
+ return (
677
+ <OverlappingCardsScrollRNControllerContext.Provider value={controllerContextValue}>
678
+ <View style={[styles.shell, style]}>
679
+ {renderTabs('above')}
680
+ {renderPageDots('above')}
681
+ <View
682
+ style={[styles.root, { height: resolvedCardHeight }]}
683
+ onLayout={(event) => {
684
+ const width = event.nativeEvent.layout.width || 1
685
+ setViewportWidth(Math.max(1, width))
686
+ }}
687
+ >
688
+ <Animated.ScrollView
689
+ ref={scrollRef}
690
+ horizontal
691
+ style={[styles.scrollRegion, { height: resolvedCardHeight }]}
692
+ contentContainerStyle={{ width: layout.trackWidth, height: resolvedCardHeight }}
693
+ onScroll={onScroll}
694
+ onScrollBeginDrag={cancelFocusTransition}
695
+ onMomentumScrollBegin={cancelFocusTransition}
696
+ scrollEventThrottle={16}
697
+ showsHorizontalScrollIndicator={showsHorizontalScrollIndicator}
698
+ snapToInterval={shouldSnapToCard ? layout.stepDistance : undefined}
699
+ snapToAlignment={shouldSnapToCard ? 'start' : undefined}
700
+ decelerationRate={
701
+ shouldSnapToCard
702
+ ? (snapDecelerationRate as number | 'normal' | 'fast')
703
+ : 'normal'
704
+ }
705
+ disableIntervalMomentum={shouldSnapToCard ? snapDisableIntervalMomentum : false}
706
+ >
707
+ <View style={[styles.track, { width: layout.trackWidth, height: resolvedCardHeight }]}>
708
+ {cards.map((card, index) => {
709
+ const restingRightX = index === 0 ? 0 : (index - 1) * layout.peek + layout.cardWidth
710
+ const restingLeftX = index * layout.peek
711
+
712
+ const cardXDuringNormalScroll =
713
+ index === 0
714
+ ? 0
715
+ : scrollX.interpolate({
716
+ inputRange:
717
+ index === 1
718
+ ? [0, layout.stepDistance]
719
+ : [(index - 1) * layout.stepDistance, index * layout.stepDistance],
720
+ outputRange: [restingRightX, restingLeftX],
721
+ extrapolate: 'clamp',
722
+ })
723
+
724
+ const cardXDuringFocusTransition = focusTransition
725
+ ? focusTransitionProgress.interpolate({
726
+ inputRange: [0, 1],
727
+ outputRange: [
728
+ resolveCardXAtProgress(index, focusTransition.fromProgress, layout),
729
+ resolveCardXAtProgress(index, focusTransition.toProgress, layout),
730
+ ],
731
+ extrapolate: 'clamp',
732
+ })
733
+ : null
734
+
735
+ const animatedCardX = cardXDuringFocusTransition ?? cardXDuringNormalScroll
736
+
737
+ return (
738
+ <Animated.View
739
+ key={card.key ?? `rn-ocs-card-${index}`}
740
+ style={[
741
+ styles.card,
742
+ {
743
+ width: layout.cardWidth,
744
+ height: resolvedCardHeight,
745
+ transform: [
746
+ {
747
+ translateX: Animated.add(scrollX, animatedCardX),
748
+ },
749
+ ],
750
+ },
751
+ cardContainerStyle,
752
+ ]}
753
+ >
754
+ <OverlappingCardsScrollRNCardIndexContext.Provider value={index}>
755
+ {card}
756
+ </OverlappingCardsScrollRNCardIndexContext.Provider>
757
+ </Animated.View>
758
+ )
759
+ })}
760
+ </View>
761
+ </Animated.ScrollView>
762
+ {renderPageDots('overlay')}
763
+ </View>
764
+ {renderPageDots('below')}
765
+ {renderTabs('below')}
766
+ </View>
767
+ </OverlappingCardsScrollRNControllerContext.Provider>
768
+ )
769
+ }
770
+
771
+ const styles = StyleSheet.create({
772
+ shell: {
773
+ width: '100%',
774
+ minWidth: 0,
775
+ },
776
+ root: {
777
+ width: '100%',
778
+ minWidth: 0,
779
+ position: 'relative',
780
+ },
781
+ scrollRegion: {
782
+ width: '100%',
783
+ minWidth: 0,
784
+ },
785
+ track: {
786
+ position: 'relative',
787
+ minHeight: 1,
788
+ },
789
+ card: {
790
+ position: 'absolute',
791
+ left: 0,
792
+ top: 0,
793
+ },
794
+ pageDotsRow: {
795
+ width: '100%',
796
+ flexDirection: 'row',
797
+ alignItems: 'center',
798
+ justifyContent: 'center',
799
+ zIndex: 6,
800
+ },
801
+ pageDotsOverlay: {
802
+ position: 'absolute',
803
+ left: 0,
804
+ right: 0,
805
+ },
806
+ pageDotPressable: {
807
+ width: 16,
808
+ height: 16,
809
+ marginHorizontal: 4,
810
+ alignItems: 'center',
811
+ justifyContent: 'center',
812
+ },
813
+ pageDot: {
814
+ width: 10,
815
+ height: 10,
816
+ borderRadius: 999,
817
+ backgroundColor: '#1f4666',
818
+ },
819
+ tabsRow: {
820
+ width: '100%',
821
+ flexDirection: 'row',
822
+ alignItems: 'center',
823
+ justifyContent: 'center',
824
+ flexWrap: 'wrap',
825
+ zIndex: 6,
826
+ },
827
+ tab: {
828
+ borderRadius: 999,
829
+ borderWidth: 1,
830
+ borderColor: 'rgba(30, 67, 99, 0.2)',
831
+ backgroundColor: '#eef5ff',
832
+ paddingHorizontal: 12,
833
+ paddingVertical: 6,
834
+ marginHorizontal: 4,
835
+ marginVertical: 4,
836
+ },
837
+ tabPressed: {
838
+ opacity: 0.85,
839
+ },
840
+ tabText: {
841
+ color: '#275070',
842
+ fontSize: 12,
843
+ fontWeight: '700',
844
+ letterSpacing: 0.2,
845
+ },
846
+ tabTextActive: {
847
+ color: '#173047',
848
+ },
849
+ focusTrigger: {
850
+ alignSelf: 'flex-start',
851
+ borderRadius: 99,
852
+ borderWidth: 1,
853
+ borderColor: 'rgba(30, 67, 99, 0.25)',
854
+ backgroundColor: '#f3f8ff',
855
+ paddingHorizontal: 10,
856
+ paddingVertical: 5,
857
+ marginBottom: 6,
858
+ },
859
+ focusTriggerPressed: {
860
+ opacity: 0.85,
861
+ },
862
+ focusTriggerText: {
863
+ color: '#1f4666',
864
+ fontSize: 12,
865
+ fontWeight: '700',
866
+ letterSpacing: 0.3,
867
+ },
868
+ })