@rific/scroll-view 0.2.0 → 0.4.0

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.
Files changed (35) hide show
  1. package/README.md +2 -0
  2. package/dist/index.d.mts +23 -17
  3. package/dist/index.d.ts +23 -17
  4. package/dist/index.js +720 -1960
  5. package/dist/index.mjs +697 -1939
  6. package/package.json +10 -10
  7. package/src/CustomList.tsx +32 -8
  8. package/src/FlatList.tsx +102 -20
  9. package/src/PullSearch.tsx +4 -3
  10. package/src/ScrollView.tsx +28 -8
  11. package/src/ScrollViewContext.ts +17 -4
  12. package/src/ScrollViewHeader.tsx +34 -31
  13. package/src/ScrollViewProvider.tsx +22 -6
  14. package/src/SectionList.tsx +31 -10
  15. package/src/internal/useScrollHandler.ts +19 -21
  16. package/src/internal/useScrollHandlerJS.ts +141 -0
  17. package/src/internal/useScrollList.ts +16 -11
  18. package/src/redux/scrollViewSlice.ts +19 -11
  19. package/src/useScrollInit.ts +200 -19
  20. package/src/__mocks__/auto-paper.ts +0 -9
  21. package/src/__mocks__/flash-list.ts +0 -20
  22. package/src/__mocks__/react-native-gesture-handler.ts +0 -13
  23. package/src/__mocks__/react-native-keyboard-controller.ts +0 -11
  24. package/src/__mocks__/react-native-paper.ts +0 -22
  25. package/src/__mocks__/react-native-reanimated.ts +0 -30
  26. package/src/__mocks__/react-native-safe-area-context.ts +0 -2
  27. package/src/__mocks__/react-native.ts +0 -30
  28. package/src/__tests__/CustomList.test.tsx +0 -61
  29. package/src/__tests__/FlatList.test.tsx +0 -52
  30. package/src/__tests__/PullSearch.test.tsx +0 -38
  31. package/src/__tests__/ScrollView.test.tsx +0 -23
  32. package/src/__tests__/ScrollViewHeader.test.tsx +0 -28
  33. package/src/__tests__/ScrollViewProvider.test.tsx +0 -65
  34. package/src/__tests__/SectionList.test.tsx +0 -88
  35. package/src/out.txt +0 -61
@@ -1,8 +1,22 @@
1
1
  import { type ComponentType, createElement, type ReactElement, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
2
2
  import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'
3
+ import { type SharedValue, useSharedValue } from 'react-native-reanimated'
3
4
 
4
5
  import { ScrollViewContext } from './ScrollViewContext'
5
6
 
7
+ const REMOUNT_SYNC_MAX_ATTEMPTS = 8
8
+ const REMOUNT_SYNC_TOLERANCE = 1
9
+ const REMOUNT_SYNC_FALLBACK_MS = 500
10
+ const REMOUNT_PRIME_FRAMES = 40
11
+ const SNAP_VERIFY_TOLERANCE = 1
12
+ const SNAP_VERIFY_MAX_ATTEMPTS = 5
13
+ // Long enough to comfortably outlast RN's default animated scrollTo duration (iOS's native
14
+ // setContentOffset:animated: for a typical pull-search distance settles well within this) — see
15
+ // the comment on scheduleSnapVerification for why a native onMomentumScrollEnd event can't be used
16
+ // as this signal instead.
17
+ const SNAP_VERIFY_INITIAL_DELAY_MS = 400
18
+ const SNAP_VERIFY_RETRY_DELAY_MS = 150
19
+
6
20
  export type UseScrollInitOptions = {
7
21
  listHeaderComponent?: ComponentType<object> | ReactElement | null
8
22
  onMomentumScrollEnd?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
@@ -19,16 +33,129 @@ export type UseScrollInitResult = {
19
33
  handleScrollBeginDrag: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
20
34
  handleScrollEndDrag: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
21
35
  hiddenHeader: ReactElement | null
36
+ onRemountSyncRetry: (currentY: number) => void
37
+ onRemountSynced: (y: number) => void
22
38
  pullSearchMinHeight: number
39
+ remountSyncTarget: SharedValue<number | null>
40
+ remountTarget: number | null
41
+ scrollReady: boolean
23
42
  }
24
43
 
25
44
  export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pullSearchHeight, scrollTo }: UseScrollInitOptions): UseScrollInitResult {
26
45
  const { headerHeight, scrollPosition, setProgressing } = useContext(ScrollViewContext)
27
46
  const hasPullSearch = pullSearchHeight !== undefined
28
- const [phase, setPhase] = useState<'measuring' | 'ready'>(hasPullSearch ? 'measuring' : 'ready')
47
+ // Start 'ready' immediately if headerHeight is already known (e.g. FlatList remount on mode
48
+ // switch). Only enter 'measuring' on the very first mount when headerHeight hasn't been set yet.
49
+ const [phase, setPhase] = useState<'measuring' | 'ready'>(hasPullSearch && !headerHeight ? 'measuring' : 'ready')
29
50
  const [refreshing, setRefreshing] = useState(false)
51
+ // A plain-ref mirror of `refreshing`, read from inside the deferred timeout below — that
52
+ // callback can fire well after the render that scheduled it, so it needs the *current* value,
53
+ // not whatever `refreshing` closed over at schedule time.
54
+ const refreshingRef = useRef(refreshing)
55
+ useEffect(() => {
56
+ refreshingRef.current = refreshing
57
+ }, [refreshing])
30
58
  const dragStartY = useRef<number | null>(null)
31
- const deliberatelyOpened = useRef(false)
59
+ // Which pull-search resting position (showY or hideY) a drag-release most recently committed
60
+ // to, so a late fallback in handleMomentumScrollEnd knows not to fight it — see
61
+ // scheduleSnapVerification below for how this actually gets enforced.
62
+ const pendingSnapTarget = useRef<number | null>(null)
63
+ const snapVerifyTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
64
+ const snapVerifyAttempts = useRef(0)
65
+
66
+ // Verifies a pull-search drag-release (handleScrollEndDrag) actually landed on `target`, and
67
+ // keeps correcting (non-animated, so it lands immediately) until it does. This is deliberately
68
+ // time-based rather than triggered by onMomentumScrollEnd: a slow/controlled release with near-
69
+ // zero velocity fires onMomentumScrollEnd within a couple of milliseconds of the drag ending —
70
+ // long before the *animated* scrollTo call issued moments earlier has had any chance to actually
71
+ // move — so reacting to that event by immediately snapping (non-animated) to the target replaced
72
+ // the intended smooth spring-back animation with a jarring instant jump on every normal pull,
73
+ // not just the pathological case this was built for. Waiting out a fixed delay long enough for a
74
+ // normal animated scrollTo to have finished, then checking the *actual* position via
75
+ // scrollPosition.value (kept live by every onScroll), correctly leaves a genuinely-still-
76
+ // animating transition alone while still catching the real failure mode: a hard, fast fling from
77
+ // deep overscroll where iOS's own rubber-band deceleration outruns our animated scrollTo command
78
+ // and it never arrives on its own.
79
+ const scheduleSnapVerification = useCallback(
80
+ (target: number, delay: number) => {
81
+ if (snapVerifyTimeout.current !== null) clearTimeout(snapVerifyTimeout.current)
82
+ pendingSnapTarget.current = target
83
+ snapVerifyAttempts.current = 0
84
+ const check = () => {
85
+ snapVerifyTimeout.current = null
86
+ if (pendingSnapTarget.current !== target) return
87
+ if (Math.abs(scrollPosition.value - target) <= SNAP_VERIFY_TOLERANCE) {
88
+ pendingSnapTarget.current = null
89
+ return
90
+ }
91
+ snapVerifyAttempts.current += 1
92
+ if (snapVerifyAttempts.current > SNAP_VERIFY_MAX_ATTEMPTS || refreshingRef.current) {
93
+ pendingSnapTarget.current = null
94
+ return
95
+ }
96
+ scrollTo(target, false)
97
+ snapVerifyTimeout.current = setTimeout(check, SNAP_VERIFY_RETRY_DELAY_MS)
98
+ }
99
+ snapVerifyTimeout.current = setTimeout(check, delay)
100
+ },
101
+ [scrollPosition, scrollTo]
102
+ )
103
+
104
+ useEffect(
105
+ () => () => {
106
+ if (snapVerifyTimeout.current !== null) clearTimeout(snapVerifyTimeout.current)
107
+ },
108
+ []
109
+ )
110
+
111
+ // Remount sync: this instance mounted with headerHeight already known — a key-forced remount
112
+ // (e.g. numColumns toggling between a list and a grid), not a true first mount. The native
113
+ // scroll view's actual position right after such a remount isn't reliable (Fabric's ScrollView
114
+ // recycling and Reanimated's Animated.FlatList can both leave it at a stale or zeroed offset
115
+ // for the first render), so the list stays hidden until a confirmed onScroll event reports the
116
+ // intended resting position, re-issuing the imperative scrollTo in between as needed. This
117
+ // replaces trying to guess how many spurious events to skip.
118
+ const [remountTarget] = useState<number | null>(() => (headerHeight !== null ? -headerHeight + (pullSearchHeight ?? 0) : null))
119
+ const remountSyncTarget = useSharedValue<number | null>(remountTarget)
120
+ const [scrollReady, setScrollReady] = useState(remountTarget === null)
121
+ const remountAttempts = useRef(0)
122
+
123
+ const onRemountSynced = useCallback(
124
+ (y: number) => {
125
+ scrollPosition.value = y
126
+ setScrollReady(true)
127
+ },
128
+ [scrollPosition]
129
+ )
130
+
131
+ const onRemountSyncRetry = useCallback(
132
+ (currentY: number) => {
133
+ if (remountTarget === null) return
134
+ remountAttempts.current += 1
135
+ if (remountAttempts.current > REMOUNT_SYNC_MAX_ATTEMPTS) {
136
+ remountSyncTarget.value = null
137
+ onRemountSynced(currentY)
138
+ return
139
+ }
140
+ scrollTo(remountTarget, false)
141
+ },
142
+ [onRemountSynced, remountSyncTarget, remountTarget, scrollTo]
143
+ )
144
+
145
+ useEffect(() => {
146
+ if (remountTarget === null) return
147
+ // Prime the correction right after mount in case native doesn't emit an initial onScroll
148
+ // event on its own to kick off the retry loop above.
149
+ scrollTo(remountTarget, false)
150
+ const fallback = setTimeout(() => {
151
+ if (remountSyncTarget.value === null) return
152
+ remountSyncTarget.value = null
153
+ onRemountSynced(remountTarget)
154
+ }, REMOUNT_SYNC_FALLBACK_MS)
155
+ return () => clearTimeout(fallback)
156
+ // Intentionally runs once per mount only, using whatever remountTarget was captured at
157
+ // construction — not a dep that should retrigger this effect.
158
+ }, [])
32
159
 
33
160
  // Transition to 'ready' once both measurements are available
34
161
  useEffect(() => {
@@ -37,23 +164,62 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
37
164
  setPhase('ready')
38
165
  }, [phase, headerHeight, pullSearchHeight, scrollPosition])
39
166
 
40
- // Effect A: scroll to natural top the first time headerHeight is measured
167
+ // Effect A applies ONLY on an actual remount (remountTarget !== null — this instance mounted with
168
+ // headerHeight already known, e.g. a key-forced remount elsewhere in the tree). It syncs
169
+ // scrollPosition synchronously before first paint and issues a short bounded chain of imperative
170
+ // scrollTo calls, alternating between two offsets far enough apart (30pt — comfortably more than
171
+ // any plausible rounding/measurement noise) to guarantee at least one call can't be mistaken for a
172
+ // no-op. The declarative `contentOffset` prop alone isn't enough on a remount: when the target is
173
+ // numerically identical to what it was before (headerHeight/pullSearchHeight don't change across a
174
+ // mode toggle), Fabric's prop diffing can skip reapplying it to a recycled native view, which can
175
+ // be left at whatever position it actually drifted to. Worse, iOS's imperative scrollToOffset
176
+ // short-circuits with a CGPointEqualToPoint check against the native view's *actual* current
177
+ // contentOffset before it will fire the native scroll-finished callback that RN turns into an
178
+ // onScroll event — so a single differing call can still go unnoticed if it happens to race content
179
+ // that's still growing. Alternating across the *entire* correction window, not just the first
180
+ // couple of frames, forces a real scroll-finished callback so drift becomes observable and the
181
+ // onScroll-driven retry loop in useScrollHandlerJS can correct it.
182
+ //
183
+ // On a true first mount (remountTarget === null) this effect does nothing beyond a single
184
+ // insurance call against the destination view silently ignoring the very first scroll command
185
+ // because it isn't fully installed in the window hierarchy yet (e.g. mid native-stack push
186
+ // transition) — a fresh, non-recycled native view reliably honors the declarative contentOffset
187
+ // prop otherwise. Repeating or oscillating that call here fights Effect B below, whose entire job
188
+ // on a true first mount with pull-search is to push the position on from here to hideY: if this
189
+ // effect keeps re-asserting its own target for tens of frames, it can win the race and leave the
190
+ // pull-search bar stuck visible instead of pulled away, or eat scroll gestures attempted during
191
+ // that window.
41
192
  const headerInitialized = useRef(false)
42
193
  useLayoutEffect(() => {
43
194
  if (!headerHeight || headerInitialized.current) return
44
195
  headerInitialized.current = true
45
- scrollPosition.value = -headerHeight
46
- scrollTo(-headerHeight, false)
47
- }, [headerHeight, scrollPosition, scrollTo])
196
+ const target = remountTarget ?? -headerHeight
197
+ scrollPosition.value = target
198
+ scrollTo(target, false)
199
+ if (remountTarget === null) return
200
+ let framesLeft = REMOUNT_PRIME_FRAMES
201
+ let handle: number | null = null
202
+ const tick = () => {
203
+ const offset = framesLeft === 1 ? target : framesLeft % 2 === 0 ? target + 30 : target - 30
204
+ scrollTo(offset, false)
205
+ framesLeft -= 1
206
+ if (framesLeft > 0) handle = requestAnimationFrame(tick)
207
+ }
208
+ handle = requestAnimationFrame(tick)
209
+ return () => {
210
+ if (handle !== null) cancelAnimationFrame(handle)
211
+ }
212
+ }, [remountTarget, headerHeight, scrollPosition, scrollTo])
48
213
 
49
- // Effect B: scroll to hideY when pullSearch phase is ready (same Fabric commit as ListHeaderComponent addition)
214
+ // Effect B: true first-mount only, with pull search. Pushes from showY to hideY once ready.
215
+ // Remounts already land directly on hideY via the remount-sync mechanism above.
50
216
  const pullSearchInitialized = useRef(false)
51
217
  useLayoutEffect(() => {
52
- if (!hasPullSearch || phase !== 'ready' || pullSearchInitialized.current || !headerHeight || !pullSearchHeight) return
218
+ if (remountTarget !== null || !hasPullSearch || phase !== 'ready' || pullSearchInitialized.current || !headerHeight || !pullSearchHeight || !scrollReady) return
53
219
  pullSearchInitialized.current = true
54
220
  scrollPosition.value = -headerHeight + pullSearchHeight
55
221
  scrollTo(-headerHeight + pullSearchHeight, false)
56
- }, [hasPullSearch, phase, headerHeight, pullSearchHeight, scrollPosition, scrollTo])
222
+ }, [remountTarget, hasPullSearch, phase, headerHeight, pullSearchHeight, scrollPosition, scrollReady, scrollTo])
57
223
 
58
224
  useEffect(() => {
59
225
  if (pullSearchHeight) setProgressing(refreshing)
@@ -61,7 +227,11 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
61
227
 
62
228
  const handleScrollBeginDrag = useCallback(
63
229
  (e: NativeSyntheticEvent<NativeScrollEvent>) => {
64
- deliberatelyOpened.current = false
230
+ if (snapVerifyTimeout.current !== null) {
231
+ clearTimeout(snapVerifyTimeout.current)
232
+ snapVerifyTimeout.current = null
233
+ }
234
+ pendingSnapTarget.current = null
65
235
  if (pullSearchHeight) dragStartY.current = e.nativeEvent.contentOffset.y
66
236
  externalScrollBeginDrag?.(e)
67
237
  },
@@ -74,39 +244,50 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
74
244
  const y = e.nativeEvent.contentOffset.y
75
245
  const showY = -headerHeight
76
246
  const hideY = -headerHeight + pullSearchHeight
77
- const pulledDown = (dragStartY.current ?? hideY) <= hideY
247
+ const startY = dragStartY.current
78
248
  dragStartY.current = null
79
- if (pulledDown && y <= showY + pullSearchHeight * 0.5) {
80
- deliberatelyOpened.current = true
249
+ const pulledDown = (startY ?? hideY) <= hideY
250
+ const dragDistance = startY !== null ? startY - y : 0
251
+ if (pulledDown && dragDistance >= pullSearchHeight * 0.5) {
81
252
  if (y <= showY && !refreshing && onRefresh) {
82
253
  setRefreshing(true)
83
254
  Promise.resolve(onRefresh()).finally(() => setRefreshing(false))
84
255
  }
85
256
  scrollTo(showY, true)
257
+ scheduleSnapVerification(showY, SNAP_VERIFY_INITIAL_DELAY_MS)
86
258
  } else if (y < hideY) {
87
259
  scrollTo(hideY, true)
260
+ scheduleSnapVerification(hideY, SNAP_VERIFY_INITIAL_DELAY_MS)
88
261
  }
89
262
  }
90
263
  externalScrollEndDrag?.(e)
91
264
  },
92
- [externalScrollEndDrag, headerHeight, onRefresh, pullSearchHeight, refreshing, scrollTo]
265
+ [externalScrollEndDrag, headerHeight, onRefresh, pullSearchHeight, refreshing, scheduleSnapVerification, scrollTo]
93
266
  )
94
267
 
95
268
  const handleMomentumScrollEnd = useCallback(
96
269
  (e: NativeSyntheticEvent<NativeScrollEvent>) => {
97
- if (pullSearchHeight && headerHeight && !deliberatelyOpened.current && !refreshing) {
270
+ if (pullSearchHeight && headerHeight && !refreshing && pendingSnapTarget.current === null) {
271
+ // No drag committed to a target above (e.g. a fast fling whose whole reveal/overscroll
272
+ // motion happened during momentum, never during the drag itself, so handleScrollEndDrag
273
+ // never saw a y near the pull-search zone) — fall back to auto-closing if we've ended up
274
+ // revealed without ever meaning to.
98
275
  const y = e.nativeEvent.contentOffset.y
99
276
  const hideY = -headerHeight + pullSearchHeight
100
- if (y < hideY - 1) scrollTo(hideY, true)
277
+ if (y < hideY - 1) {
278
+ scrollTo(hideY, true)
279
+ scheduleSnapVerification(hideY, SNAP_VERIFY_INITIAL_DELAY_MS)
280
+ }
101
281
  }
102
- deliberatelyOpened.current = false
103
282
  externalMomentumScrollEnd?.(e)
104
283
  },
105
- [externalMomentumScrollEnd, headerHeight, pullSearchHeight, refreshing, scrollTo]
284
+ [externalMomentumScrollEnd, headerHeight, pullSearchHeight, refreshing, scheduleSnapVerification, scrollTo]
106
285
  )
107
286
 
108
287
  const activeListHeader = hasPullSearch && phase === 'measuring' ? null : listHeaderComponent
109
288
  const hiddenHeader = phase === 'measuring' && listHeaderComponent != null ? (typeof listHeaderComponent === 'function' ? createElement(listHeaderComponent as ComponentType<object>) : (listHeaderComponent as ReactElement)) : null
110
289
 
111
- return { activeListHeader, handleMomentumScrollEnd, handleScrollBeginDrag, handleScrollEndDrag, hiddenHeader, pullSearchMinHeight: pullSearchHeight ?? 0 }
290
+ return { activeListHeader, handleMomentumScrollEnd, handleScrollBeginDrag, handleScrollEndDrag, hiddenHeader, onRemountSyncRetry, onRemountSynced, pullSearchMinHeight: pullSearchHeight ?? 0, remountSyncTarget, remountTarget, scrollReady }
112
291
  }
292
+
293
+ export { REMOUNT_SYNC_TOLERANCE }
@@ -1,9 +0,0 @@
1
- import React from 'react'
2
-
3
- const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
-
5
- export const BlurView = jest.fn(stub)
6
- export const Chip = jest.fn(stub)
7
- export const FAB = jest.fn(stub)
8
- export const usePaperDefaults = () => ({ FAB: {} })
9
- export const useBlur = (blur?: boolean) => blur ?? false
@@ -1,20 +0,0 @@
1
- import React from 'react'
2
-
3
- const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
-
5
- // Minimal type surface so ts-jest can resolve FlashListProps without the real package
6
- export type FlashListProps<T> = {
7
- data?: readonly T[] | null
8
- estimatedItemSize?: number
9
- renderItem?: ((info: { item: T; index: number }) => React.ReactElement | null) | null
10
- contentInset?: { top?: number; bottom?: number; left?: number; right?: number }
11
- contentOffset?: { x: number; y: number }
12
- onRefresh?: (() => void) | null
13
- onScroll?: unknown
14
- refreshControl?: React.ReactElement | null
15
- refreshing?: boolean | null
16
- scrollEventThrottle?: number
17
- [key: string]: unknown
18
- }
19
-
20
- export const FlashList = jest.fn(stub)
@@ -1,13 +0,0 @@
1
- import React from 'react'
2
-
3
- const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
-
5
- export const GestureDetector = stub
6
- export const useNativeGesture = jest.fn(() => ({}))
7
- export const useSimultaneousGestures = jest.fn((...gestures: unknown[]) => gestures[0])
8
- export const Gesture = {
9
- Native: () => ({}),
10
- Simultaneous: (..._gestures: unknown[]) => ({})
11
- }
12
- export type GestureType = object
13
- export type SingleGesture = object
@@ -1,11 +0,0 @@
1
- import React from 'react'
2
-
3
- const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
-
5
- export const KeyboardAwareScrollView = stub
6
- export const KeyboardAwareFlatList = stub
7
- export const useKeyboardHandler = jest.fn()
8
- export const useReanimatedKeyboardAnimation = () => ({
9
- height: { value: 0 },
10
- progress: { value: 0 }
11
- })
@@ -1,22 +0,0 @@
1
- import React from 'react'
2
-
3
- const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
-
5
- export const ProgressBar = jest.fn(() => null)
6
- export const Surface = jest.fn(stub)
7
- export const Chip = jest.fn(stub)
8
- export const Searchbar = jest.fn(() => null)
9
-
10
- const BackAction = jest.fn(() => null)
11
- export const Appbar = { BackAction }
12
-
13
- export const useTheme = () => ({
14
- dark: false,
15
- colors: {
16
- primary: '#6200ee',
17
- surface: '#ffffff',
18
- onSurface: '#000000',
19
- onSurfaceVariant: '#49454f',
20
- outlineVariant: '#cac4d0'
21
- }
22
- })
@@ -1,30 +0,0 @@
1
- import React from 'react'
2
-
3
- const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
-
5
- export const useSharedValue = (init: number) => ({ value: init })
6
- export const useAnimatedStyle = (_factory: () => object) => ({})
7
- export const useAnimatedProps = (_factory: () => object) => ({})
8
- export const useAnimatedScrollHandler = (_handler: unknown) => jest.fn()
9
- export const useAnimatedReaction = jest.fn()
10
- export const useDerivedValue = (factory: () => unknown) => ({ value: factory() })
11
- export const withTiming = (value: number) => value
12
- export const withSpring = (value: number) => value
13
- export const runOnJS =
14
- <T extends (...args: unknown[]) => unknown>(fn: T) =>
15
- (...args: Parameters<T>) =>
16
- fn(...args)
17
-
18
- export const createAnimatedComponent = (C: React.ComponentType) => C
19
-
20
- // The default export IS the Animated namespace — `import Animated from 'reanimated'` → Animated.View
21
- const Animated = {
22
- View: stub,
23
- Text: stub,
24
- ScrollView: stub,
25
- FlatList: stub,
26
- createAnimatedComponent
27
- }
28
-
29
- export { Animated }
30
- export default Animated
@@ -1,2 +0,0 @@
1
- export const useSafeAreaInsets = () => ({ top: 44, bottom: 34, left: 0, right: 0 })
2
- export const SafeAreaProvider = ({ children }: { children?: React.ReactNode }) => children ?? null
@@ -1,30 +0,0 @@
1
- import React from 'react'
2
-
3
- const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
-
5
- const StyleSheet = {
6
- absoluteFill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
7
- create: <T extends object>(styles: T): T => styles,
8
- flatten: (style: unknown) => style
9
- }
10
-
11
- const Dimensions = {
12
- get: (_dim: string) => ({ width: 390, height: 844 })
13
- }
14
-
15
- const Platform = {
16
- OS: 'ios' as 'ios' | 'android',
17
- select: (obj: Record<string, unknown>) => obj[Platform.OS] ?? obj.default
18
- }
19
-
20
- export { Dimensions, Platform, StyleSheet }
21
-
22
- export const View = stub
23
- export const Text = stub
24
- export const Pressable = stub
25
- export const TouchableOpacity = stub
26
- export const FlatList = jest.fn(stub)
27
- export const RefreshControl = jest.fn(stub)
28
- export const SectionList = jest.fn(stub)
29
- export const ScrollView = jest.fn(stub)
30
- export const useWindowDimensions = () => ({ width: 390, height: 844, fontScale: 1, scale: 1 })
@@ -1,61 +0,0 @@
1
- import { render } from '@testing-library/react'
2
- import React from 'react'
3
-
4
- import { CustomList } from '../CustomList'
5
- import { ScrollViewProvider } from '../ScrollViewProvider'
6
-
7
- const wrapper = ({ children }: { children: React.ReactNode }) => <ScrollViewProvider>{children}</ScrollViewProvider>
8
-
9
- type MockListProps = {
10
- contentInset?: unknown
11
- contentOffset?: unknown
12
- onScroll?: unknown
13
- scrollEventThrottle?: number
14
- onMomentumScrollEnd?: unknown
15
- onScrollBeginDrag?: unknown
16
- onScrollEndDrag?: unknown
17
- [key: string]: unknown
18
- }
19
-
20
- const MockList = jest.fn((_props: MockListProps) => null)
21
-
22
- describe('CustomList', () => {
23
- beforeEach(() => MockList.mockClear())
24
-
25
- it('renders without crashing', () => {
26
- render(<CustomList component={MockList} />, { wrapper })
27
- })
28
-
29
- it('wires up contentInset and contentOffset', () => {
30
- render(<CustomList component={MockList} />, { wrapper })
31
- const props = MockList.mock.calls[0]?.[0] as MockListProps | undefined
32
- expect(props).toHaveProperty('contentInset')
33
- expect(props).toHaveProperty('contentOffset')
34
- })
35
-
36
- it('passes scrollEventThrottle={16}', () => {
37
- render(<CustomList component={MockList} />, { wrapper })
38
- const props = MockList.mock.calls[0]?.[0] as MockListProps | undefined
39
- expect(props?.scrollEventThrottle).toBe(16)
40
- })
41
-
42
- it('passes onScroll handler', () => {
43
- render(<CustomList component={MockList} />, { wrapper })
44
- const props = MockList.mock.calls[0]?.[0] as MockListProps | undefined
45
- expect(typeof props?.onScroll).toBe('function')
46
- })
47
-
48
- it('passes momentum and drag scroll handlers', () => {
49
- render(<CustomList component={MockList} />, { wrapper })
50
- const props = MockList.mock.calls[0]?.[0] as MockListProps | undefined
51
- expect(typeof props?.onMomentumScrollEnd).toBe('function')
52
- expect(typeof props?.onScrollBeginDrag).toBe('function')
53
- expect(typeof props?.onScrollEndDrag).toBe('function')
54
- })
55
-
56
- it('forwards extra props to the inner component', () => {
57
- render(<CustomList component={MockList} testID='my-list' />, { wrapper })
58
- const props = MockList.mock.calls[0]?.[0] as MockListProps | undefined
59
- expect(props?.testID).toBe('my-list')
60
- })
61
- })
@@ -1,52 +0,0 @@
1
- import { render } from '@testing-library/react'
2
- import React from 'react'
3
-
4
- import { FlatList } from '../FlatList'
5
- import { ScrollViewProvider } from '../ScrollViewProvider'
6
-
7
- const wrapper = ({ children }: { children: React.ReactNode }) => <ScrollViewProvider>{children}</ScrollViewProvider>
8
-
9
- describe('FlatList', () => {
10
- it('renders without crashing', () => {
11
- render(<FlatList data={[]} renderItem={() => null} />, { wrapper })
12
- })
13
-
14
- it('renders with data items', () => {
15
- render(
16
- <FlatList data={['a', 'b', 'c']} renderItem={({ item }) => <>{item}</>} keyExtractor={(item) => item} />,
17
- { wrapper }
18
- )
19
- })
20
-
21
- it('renders horizontal', () => {
22
- render(<FlatList data={[]} renderItem={() => null} horizontal />, { wrapper })
23
- })
24
-
25
- it('renders horizontal paged list with HorizontalDots for multiple items', () => {
26
- render(
27
- <FlatList
28
- data={['a', 'b', 'c']}
29
- renderItem={({ item }) => <>{item}</>}
30
- keyExtractor={(item) => item}
31
- horizontal
32
- pagingEnabled
33
- />,
34
- { wrapper }
35
- )
36
- })
37
-
38
- it('renders with pullSearchHeight', () => {
39
- render(<FlatList data={[]} renderItem={() => null} pullSearchHeight={60} />, { wrapper })
40
- })
41
-
42
- it('renders with renderFilters', () => {
43
- render(
44
- <FlatList data={[]} renderItem={() => null} renderFilters={<></>} />,
45
- { wrapper }
46
- )
47
- })
48
-
49
- it('renders with keyboardAware', () => {
50
- render(<FlatList data={[]} renderItem={() => null} keyboardAware />, { wrapper })
51
- })
52
- })
@@ -1,38 +0,0 @@
1
- import { render } from '@testing-library/react'
2
- import React from 'react'
3
-
4
- import { PullSearch, type PullSearchHandle } from '../PullSearch'
5
- import { ScrollViewProvider } from '../ScrollViewProvider'
6
-
7
- const wrapper = ({ children }: { children: React.ReactNode }) => <ScrollViewProvider>{children}</ScrollViewProvider>
8
-
9
- describe('PullSearch', () => {
10
- it('renders without crashing', () => {
11
- render(<PullSearch onChangeText={jest.fn()} onHeightChange={jest.fn()} />, { wrapper })
12
- })
13
-
14
- it('renders with placeholder', () => {
15
- render(
16
- <PullSearch onChangeText={jest.fn()} onHeightChange={jest.fn()} placeholder='Search items...' />,
17
- { wrapper }
18
- )
19
- })
20
-
21
- it('renders with initial value', () => {
22
- render(<PullSearch onChangeText={jest.fn()} onHeightChange={jest.fn()} value='hello' />, { wrapper })
23
- })
24
-
25
- it('exposes blur and focus via imperative ref', () => {
26
- const ref = React.createRef<PullSearchHandle>()
27
- render(<PullSearch ref={ref} onChangeText={jest.fn()} onHeightChange={jest.fn()} />, { wrapper })
28
- expect(typeof ref.current?.blur).toBe('function')
29
- expect(typeof ref.current?.focus).toBe('function')
30
- })
31
-
32
- it('does not throw when blur/focus called with no input mounted', () => {
33
- const ref = React.createRef<PullSearchHandle>()
34
- render(<PullSearch ref={ref} onChangeText={jest.fn()} onHeightChange={jest.fn()} />, { wrapper })
35
- expect(() => ref.current?.blur()).not.toThrow()
36
- expect(() => ref.current?.focus()).not.toThrow()
37
- })
38
- })
@@ -1,23 +0,0 @@
1
- import { render } from '@testing-library/react'
2
- import React from 'react'
3
-
4
- import { ScrollView } from '../ScrollView'
5
- import { ScrollViewProvider } from '../ScrollViewProvider'
6
-
7
- const wrapper = ({ children }: { children: React.ReactNode }) => <ScrollViewProvider>{children}</ScrollViewProvider>
8
-
9
- describe('ScrollView', () => {
10
- it('renders without crashing', () => {
11
- render(<ScrollView />, { wrapper })
12
- })
13
-
14
- it('renders children', () => {
15
- const { getByText } = render(
16
- <ScrollView>
17
- <></>
18
- </ScrollView>,
19
- { wrapper }
20
- )
21
- expect(getByText).toBeDefined()
22
- })
23
- })
@@ -1,28 +0,0 @@
1
- import { render } from '@testing-library/react'
2
- import React from 'react'
3
-
4
- import { ScrollViewFooter } from '../ScrollViewFooter'
5
- import { ScrollViewHeader } from '../ScrollViewHeader'
6
- import { ScrollViewProvider } from '../ScrollViewProvider'
7
-
8
- const wrapper = ({ children }: { children: React.ReactNode }) => <ScrollViewProvider>{children}</ScrollViewProvider>
9
-
10
- describe('ScrollViewHeader', () => {
11
- it('renders without crashing', () => {
12
- render(<ScrollViewHeader />, { wrapper })
13
- })
14
-
15
- it('renders with children', () => {
16
- render(<ScrollViewHeader><></></ScrollViewHeader>, { wrapper })
17
- })
18
- })
19
-
20
- describe('ScrollViewFooter', () => {
21
- it('renders without crashing', () => {
22
- render(<ScrollViewFooter />, { wrapper })
23
- })
24
-
25
- it('renders with children', () => {
26
- render(<ScrollViewFooter><></></ScrollViewFooter>, { wrapper })
27
- })
28
- })