@vvedantb/native-ui 0.12.1 → 0.17.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 (113) hide show
  1. package/README.md +82 -19
  2. package/package.json +36 -3
  3. package/src/BottomSheetContainer.tsx +2 -2
  4. package/src/BulkActionBar.tsx +240 -105
  5. package/src/Button.tsx +11 -7
  6. package/src/ConfirmDialog.tsx +25 -35
  7. package/src/Dialog.tsx +47 -35
  8. package/src/DividedList.tsx +11 -7
  9. package/src/DropdownMenu.tsx +22 -8
  10. package/src/FloatingSurface.tsx +5 -2
  11. package/src/FloatingTabBar.tsx +13 -22
  12. package/src/HeaderIconButton.tsx +4 -3
  13. package/src/Input.tsx +2 -2
  14. package/src/LoadingState.tsx +132 -0
  15. package/src/PageTitle.tsx +9 -2
  16. package/src/Pager.tsx +62 -0
  17. package/src/PasswordInput.tsx +1 -1
  18. package/src/PillTabs.tsx +144 -34
  19. package/src/Progress.tsx +9 -7
  20. package/src/Select.tsx +4 -4
  21. package/src/Sheet.tsx +29 -22
  22. package/src/SheetRow.tsx +27 -9
  23. package/src/Skeleton.tsx +91 -9
  24. package/src/SurfaceRow.tsx +111 -0
  25. package/src/SwipeableRow.tsx +43 -0
  26. package/src/Switch.tsx +6 -4
  27. package/src/Tabs.tsx +68 -13
  28. package/src/Toast.tsx +107 -65
  29. package/src/ai/activity-step-detail.tsx +1 -3
  30. package/src/ai/activity-step-label.ts +981 -0
  31. package/src/ai/activity-step-types.ts +107 -0
  32. package/src/ai/activity-tasks-utils.ts +199 -0
  33. package/src/ai/activity-timeline.ts +45 -0
  34. package/src/ai/attachments.tsx +3 -1
  35. package/src/ai/card-button.tsx +49 -0
  36. package/src/ai/chat-text.ts +13 -7
  37. package/src/ai/chat-thread.tsx +34 -27
  38. package/src/ai/choice-question.tsx +154 -35
  39. package/src/ai/code-block.tsx +189 -49
  40. package/src/ai/collapsible.tsx +19 -13
  41. package/src/ai/conversation.tsx +390 -57
  42. package/src/ai/icon-button.tsx +41 -6
  43. package/src/ai/index.ts +52 -1
  44. package/src/ai/message-action-sheet.tsx +168 -0
  45. package/src/ai/message-quote.tsx +98 -0
  46. package/src/ai/message.tsx +226 -31
  47. package/src/ai/prompt-input-menu.tsx +197 -0
  48. package/src/ai/prompt-input.tsx +308 -73
  49. package/src/ai/reply-quote-chip.tsx +90 -0
  50. package/src/ai/shimmer.tsx +4 -6
  51. package/src/ai/subagent-cards.tsx +209 -0
  52. package/src/ai/tool-approval-card.tsx +162 -0
  53. package/src/ai/trace.tsx +54 -17
  54. package/src/ai/unified-diff.tsx +182 -0
  55. package/src/auth/GoogleSignInButton.tsx +6 -3
  56. package/src/auth/SignInScreen.tsx +11 -2
  57. package/src/brand/ProviderIcon.tsx +136 -0
  58. package/src/brand/index.ts +2 -0
  59. package/src/configureTheme.ts +39 -11
  60. package/src/controlStyles.ts +46 -30
  61. package/src/entries/gesture-handler.ts +4 -0
  62. package/src/entries/keyboard-controller.ts +4 -0
  63. package/src/entries/markdown.ts +1 -1
  64. package/src/entries/primitives.ts +24 -3
  65. package/src/entries/theme.ts +2 -1
  66. package/src/entries/video.ts +4 -0
  67. package/src/entries/webview.ts +6 -0
  68. package/src/floating.ts +25 -4
  69. package/src/haptics.ts +30 -4
  70. package/src/highlight.ts +302 -1
  71. package/src/index.ts +27 -5
  72. package/src/loadingGrid.ts +75 -0
  73. package/src/markdown/Markdown.tsx +234 -32
  74. package/src/markdown/MarkdownTable.tsx +188 -0
  75. package/src/markdown/tableText.ts +58 -0
  76. package/src/marketing/UpgradeToProCard.tsx +4 -3
  77. package/src/media/ImageGallery.tsx +224 -0
  78. package/src/media/MediaCard.tsx +100 -0
  79. package/src/media/imageGrid.ts +51 -0
  80. package/src/motion.ts +33 -0
  81. package/src/pagerSwipe.ts +102 -0
  82. package/src/palette.ts +8 -1
  83. package/src/primitives/button.tsx +4 -1
  84. package/src/primitives/keyboard-controller.tsx +106 -0
  85. package/src/primitives/keyboard-parts.tsx +104 -0
  86. package/src/primitives/overlay.tsx +133 -45
  87. package/src/primitives/pager-gesture.tsx +131 -0
  88. package/src/primitives/sheet-drag.tsx +305 -0
  89. package/src/primitives/sheet-gesture.tsx +181 -0
  90. package/src/primitives/swipe-row-gesture.tsx +290 -0
  91. package/src/primitives/toast-drag-gesture.tsx +92 -0
  92. package/src/primitives/web-click-guard.ts +50 -0
  93. package/src/relativeTime.ts +94 -0
  94. package/src/router/RouterStackPageHeader.tsx +2 -2
  95. package/src/settings/SettingsEmptyState.tsx +20 -10
  96. package/src/settings/SettingsField.tsx +11 -5
  97. package/src/settings/SettingsSection.tsx +8 -5
  98. package/src/settings/SettingsToggleRow.tsx +55 -22
  99. package/src/settings/relaxedSizeStyle.ts +11 -0
  100. package/src/sheetDrag.ts +67 -0
  101. package/src/swipeRow.ts +169 -0
  102. package/src/tabBarRoutes.ts +18 -0
  103. package/src/tabIndicator.ts +71 -0
  104. package/src/tailwind-preset.cjs +13 -6
  105. package/src/tailwind-preset.d.cts +5 -0
  106. package/src/theme.tsx +6 -2
  107. package/src/toastDrag.ts +58 -0
  108. package/src/typography.ts +20 -3
  109. package/src/useScheme.ts +10 -1
  110. package/src/video/InlineVideo.tsx +91 -0
  111. package/src/webview/HtmlView.tsx +91 -0
  112. package/src/webview/HtmlView.web.tsx +21 -0
  113. package/src/webview/htmlView.ts +45 -0
@@ -0,0 +1,104 @@
1
+ import { createContext, useContext, type ComponentType, type ReactNode } from "react";
2
+ import {
3
+ KeyboardAvoidingView as NativeKeyboardAvoidingView,
4
+ type ScrollViewProps,
5
+ type StyleProp,
6
+ type ViewProps,
7
+ type ViewStyle,
8
+ } from "react-native";
9
+
10
+ /**
11
+ * The `react-native-keyboard-controller` build of the keyboard parts, from
12
+ * `@vvedantb/native-ui/keyboard-controller`. They follow the keyboard frame
13
+ * by frame on the UI thread, and a drag on the chat transcript moves the
14
+ * keyboard with the finger. Without them (the peer is optional) the kit keeps
15
+ * React Native's `KeyboardAvoidingView`. A context, so only that entry imports
16
+ * the peer and an app without it bundles as before.
17
+ */
18
+ export type KeyboardParts = {
19
+ /** A `KeyboardAvoidingView` that pads frame by frame with the keyboard. */
20
+ AvoidingView: ComponentType<KeyboardAvoidingViewProps>;
21
+ /**
22
+ * Moves with the keyboard (`translateY`), less `bottomOffset` while it is
23
+ * open: the bottom inset the content pads, which the keyboard covers.
24
+ */
25
+ StickyView: ComponentType<ViewProps & { bottomOffset: number }>;
26
+ /**
27
+ * The chat transcript's scroll view (a `FlatList` `renderScrollComponent`).
28
+ * Its content gains the keyboard height less `bottomOffset` at the end (at
29
+ * its start when `inverted`, the visual bottom either way), and moves up
30
+ * with the keyboard, so the newest message stays in view. `onInsetChange`
31
+ * gets that extra space as the scroll offset sees it, which `onScroll` does
32
+ * not report: past the content's end, or when `inverted` before offset 0.
33
+ * `onKeyboardMove` is true from the start to the end of each keyboard
34
+ * move, while the keyboard, not the reader, scrolls the list.
35
+ */
36
+ ChatScrollView: ComponentType<
37
+ ScrollViewProps & {
38
+ bottomOffset: number;
39
+ inverted?: boolean;
40
+ onInsetChange: (inset: number) => void;
41
+ onKeyboardMove: (moving: boolean) => void;
42
+ }
43
+ >;
44
+ /**
45
+ * Around the transcript: a drag that reaches `offset` above the keyboard
46
+ * (the composer's top) moves the keyboard with the finger.
47
+ */
48
+ GestureArea: ComponentType<{ offset: number; style?: StyleProp<ViewStyle>; children: ReactNode }>;
49
+ /**
50
+ * Whether a drag on the transcript moves the keyboard (iOS, Android 11+).
51
+ * False on Android 10 and below: there a drag lowers it at once.
52
+ */
53
+ interactive: boolean;
54
+ };
55
+
56
+ export const KeyboardPartsContext = createContext<KeyboardParts | null>(null);
57
+
58
+ /**
59
+ * True inside a part that already keeps its content clear of the keyboard
60
+ * (`ConversationDock` with the keyboard-controller parts, `ChatThread`): the
61
+ * chat parts in it add no lift of their own, so nothing lifts twice.
62
+ */
63
+ export const KeyboardLiftContext = createContext(false);
64
+
65
+ /**
66
+ * The keyboard-controller parts for a chat part that follows the keyboard
67
+ * itself, or `null`: without `KeyboardControllerProvider`, or inside a part
68
+ * that already lifts it (`KeyboardLiftContext`).
69
+ */
70
+ export function useKeyboardParts(): KeyboardParts | null {
71
+ const parts = useContext(KeyboardPartsContext);
72
+ const lifted = useContext(KeyboardLiftContext);
73
+ return lifted ? null : parts;
74
+ }
75
+
76
+ /**
77
+ * The `nativeID` of the `PromptInput` field. On iOS the transcript's gesture
78
+ * area keeps its `offset` (the composer above the keyboard) only for the
79
+ * focused field with this id.
80
+ */
81
+ export const COMPOSER_INPUT_ID = "native-ui-composer";
82
+
83
+ export type KeyboardAvoidingViewProps = {
84
+ /** Default `padding`, on iOS and Android: Android `height` does nothing edge-to-edge. */
85
+ behavior?: "padding" | "height" | "position";
86
+ /** Height of anything above the view's top, such as a navigation header. Default 0. */
87
+ keyboardVerticalOffset?: number;
88
+ enabled?: boolean;
89
+ /** A style, not a class: the keyboard-controller view takes no NativeWind `className`. */
90
+ style?: StyleProp<ViewStyle>;
91
+ testID?: string;
92
+ children?: ReactNode;
93
+ };
94
+
95
+ /**
96
+ * React Native's `KeyboardAvoidingView`, or with
97
+ * `@vvedantb/native-ui/keyboard-controller` the keyboard-controller one, which
98
+ * pads frame by frame with the keyboard instead of in one step at its end.
99
+ */
100
+ export function KeyboardAvoidingView({ behavior = "padding", ...props }: KeyboardAvoidingViewProps) {
101
+ const parts = useContext(KeyboardPartsContext);
102
+ const Avoiding = parts?.AvoidingView ?? NativeKeyboardAvoidingView;
103
+ return <Avoiding behavior={behavior} {...props} />;
104
+ }
@@ -1,22 +1,34 @@
1
1
  import * as Slot from "@rn-primitives/slot";
2
2
  import {
3
3
  createContext,
4
+ Fragment,
4
5
  useContext,
6
+ useEffect,
7
+ useState,
5
8
  type ComponentPropsWithoutRef,
6
9
  type ReactNode,
7
10
  } from "react";
8
11
  import {
9
- KeyboardAvoidingView,
10
12
  Modal,
11
13
  Pressable,
12
14
  StyleSheet,
13
15
  View,
14
16
  type GestureResponderEvent,
15
17
  } from "react-native";
18
+ import Animated from "react-native-reanimated";
16
19
  import { useSafeAreaInsets } from "react-native-safe-area-context";
17
20
  import { palette } from "../palette";
21
+ import { KeyboardAvoidingView, KeyboardLiftContext } from "./keyboard-parts";
18
22
  import { useReduceMotion } from "../useReduceMotion";
19
23
  import { useScheme } from "../useScheme";
24
+ import {
25
+ SheetDragProvider,
26
+ SheetGestureContext,
27
+ slideSheetIn,
28
+ slideSheetOut,
29
+ useSheetBackdropStyle,
30
+ useSheetDragState,
31
+ } from "./sheet-drag";
20
32
  import { useControllableState } from "./use-controllable-state";
21
33
 
22
34
  type OverlayContextValue = {
@@ -98,77 +110,153 @@ export function OverlayClose(props: OverlayPressableProps & { part: string }) {
98
110
  }
99
111
 
100
112
  /**
101
- * RN `Modal` with a tinted backdrop, safe-area padding and keyboard avoidance.
113
+ * RN `Modal` with a tinted backdrop, safe-area padding and keyboard avoidance
114
+ * (`KeyboardAvoidingView`: frame by frame with `KeyboardControllerProvider`).
102
115
  * `placement="center"` centres the child (dialogs) inside the insets on every
103
- * side; `"bottom"` pins it to the bottom edge (sheets), which pad their own
104
- * bottom inset. The backdrop closes the overlay only when `dismissible`;
105
- * Android back and the VoiceOver escape gesture always close it, as Escape
106
- * does on the web. With Reduce Motion on, the fade is off.
116
+ * side, and the `Modal` fades in and out. `"bottom"` pins it to the bottom
117
+ * edge (sheets), which pad their own bottom inset: the sheet slides up on
118
+ * open and back down on close, however it closes, and may be dragged down to
119
+ * close (`SheetDragView`); the backdrop fades with the sheet's position. The
120
+ * `Modal` stays up until the sheet is out of sight. The backdrop closes the
121
+ * overlay only when `dismissible`; Android back and the VoiceOver escape
122
+ * gesture always close it, as Escape does on the web. With Reduce Motion on,
123
+ * overlays appear and leave in place. With the Gesture Handler parts
124
+ * (`GestureHandlerProvider`), the content sits in its own root view: on
125
+ * Android a `Modal` is a separate root, and without one there every gesture
126
+ * in it silently does nothing.
107
127
  */
108
128
  export function OverlayModal({
109
129
  part,
110
130
  placement,
111
131
  dismissible,
132
+ onShow,
112
133
  children,
113
134
  }: {
114
135
  part: string;
115
136
  placement: "center" | "bottom";
116
137
  dismissible: boolean;
138
+ /** Once the overlay is on screen: where to focus a field (see `DialogContent`'s `onShow`). */
139
+ onShow?: () => void;
117
140
  children: ReactNode;
118
141
  }) {
119
142
  const { open, setOpen } = useOverlay(part);
120
- const scheme = useScheme();
121
- const insets = useSafeAreaInsets();
122
143
  const reduceMotion = useReduceMotion();
144
+ const slides = placement === "bottom" && !reduceMotion;
145
+ // Up while open, and after a close until a sliding sheet is out of sight.
146
+ const [shown, setShown] = useState(open);
147
+ if (open && !shown) setShown(true);
148
+ if (!open && shown && !slides) setShown(false);
123
149
  const close = () => setOpen(false);
150
+ const ModalRoot = useContext(SheetGestureContext)?.ModalRoot ?? Fragment;
124
151
 
125
152
  return (
126
153
  <Modal
127
- visible={open}
154
+ visible={shown}
128
155
  transparent
129
- animationType={reduceMotion ? "none" : "fade"}
156
+ animationType={slides || reduceMotion ? "none" : "fade"}
130
157
  statusBarTranslucent
131
158
  onRequestClose={close}
159
+ onShow={onShow}
132
160
  >
161
+ <ModalRoot>
162
+ <OverlayModalBody
163
+ placement={placement}
164
+ dismissible={dismissible}
165
+ open={open}
166
+ slides={slides}
167
+ close={close}
168
+ onHidden={() => setShown(false)}
169
+ part={part}
170
+ >
171
+ {children}
172
+ </OverlayModalBody>
173
+ </ModalRoot>
174
+ </Modal>
175
+ );
176
+ }
177
+
178
+ /** The `Modal`'s content: mounted with each opening, so a sheet starts below the screen (or at rest). */
179
+ function OverlayModalBody({
180
+ part,
181
+ placement,
182
+ dismissible,
183
+ open,
184
+ slides,
185
+ close,
186
+ onHidden,
187
+ children,
188
+ }: {
189
+ part: string;
190
+ placement: "center" | "bottom";
191
+ dismissible: boolean;
192
+ open: boolean;
193
+ slides: boolean;
194
+ close: () => void;
195
+ onHidden: () => void;
196
+ children: ReactNode;
197
+ }) {
198
+ const scheme = useScheme();
199
+ const insets = useSafeAreaInsets();
200
+ const drag = useSheetDragState(close, slides);
201
+ const backdropStyle = useSheetBackdropStyle(drag);
202
+ // The open state lives in React, the sheet's position on the UI thread:
203
+ // each change of `open` starts the slide that matches it. The slide in on
204
+ // opening starts from the sheet's first layout (`SheetDragView`).
205
+ useEffect(() => {
206
+ if (!slides) return;
207
+ if (open) slideSheetIn(drag);
208
+ else slideSheetOut(drag, onHidden);
209
+ // Only a change of `open` starts a slide; `drag` and `onHidden` keep their meaning.
210
+ }, [open, slides]);
211
+ return (
212
+ <SheetDragProvider value={drag}>
133
213
  {/* Android too: a modal window with `statusBarTranslucent` may not
134
214
  resize for the keyboard, which then covers the fields. The padding
135
215
  is the measured overlap, so it drops to 0 where the window does
136
- resize. */}
137
- <KeyboardAvoidingView behavior="padding" style={styles.fill}>
138
- <Pressable
139
- testID={`${part}-backdrop`}
140
- accessible={dismissible}
141
- accessibilityRole="button"
142
- accessibilityLabel="Close"
143
- disabled={!dismissible}
144
- onPress={close}
145
- style={[StyleSheet.absoluteFill, { backgroundColor: palette.backdrop[scheme] }]}
146
- />
147
- <View
148
- pointerEvents="box-none"
149
- onAccessibilityEscape={close}
150
- style={[
151
- styles.fill,
152
- placement === "bottom"
153
- ? {
154
- justifyContent: "flex-end",
155
- paddingTop: insets.top + 16,
156
- paddingLeft: insets.left,
157
- paddingRight: insets.right,
158
- }
159
- : {
160
- justifyContent: "center",
161
- paddingTop: insets.top + 16,
162
- paddingBottom: insets.bottom + 16,
163
- paddingLeft: insets.left + 16,
164
- paddingRight: insets.right + 16,
165
- },
166
- ]}
167
- >
168
- {children}
169
- </View>
216
+ resize. Frame by frame with `KeyboardControllerProvider`. */}
217
+ <KeyboardAvoidingView style={styles.fill}>
218
+ {/* A `Modal` is its own window: a dock it opened from does not lift what is in it. */}
219
+ <KeyboardLiftContext.Provider value={false}>
220
+ <Animated.View
221
+ pointerEvents="box-none"
222
+ style={[StyleSheet.absoluteFill, { backgroundColor: palette.backdrop[scheme] }, backdropStyle]}
223
+ >
224
+ <Pressable
225
+ testID={`${part}-backdrop`}
226
+ accessible={dismissible}
227
+ accessibilityRole="button"
228
+ accessibilityLabel="Close"
229
+ disabled={!dismissible}
230
+ onPress={close}
231
+ style={StyleSheet.absoluteFill}
232
+ />
233
+ </Animated.View>
234
+ <View
235
+ pointerEvents="box-none"
236
+ onAccessibilityEscape={close}
237
+ style={[
238
+ styles.fill,
239
+ placement === "bottom"
240
+ ? {
241
+ justifyContent: "flex-end",
242
+ paddingTop: insets.top + 16,
243
+ paddingLeft: insets.left,
244
+ paddingRight: insets.right,
245
+ }
246
+ : {
247
+ justifyContent: "center",
248
+ paddingTop: insets.top + 16,
249
+ paddingBottom: insets.bottom + 16,
250
+ paddingLeft: insets.left + 16,
251
+ paddingRight: insets.right + 16,
252
+ },
253
+ ]}
254
+ >
255
+ {children}
256
+ </View>
257
+ </KeyboardLiftContext.Provider>
170
258
  </KeyboardAvoidingView>
171
- </Modal>
259
+ </SheetDragProvider>
172
260
  );
173
261
  }
174
262
 
@@ -0,0 +1,131 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
4
+ import Animated, {
5
+ cancelAnimation,
6
+ ReduceMotion,
7
+ runOnJS,
8
+ useAnimatedStyle,
9
+ useSharedValue,
10
+ withSpring,
11
+ withTiming,
12
+ } from "react-native-reanimated";
13
+ import { selectionHaptic } from "../haptics";
14
+ import { InsidePagerContext, type PagerProps } from "../Pager";
15
+ import { PAGER_ACTIVE_OFFSET_X, PAGER_FAIL_OFFSET_Y, pagerPosition, pagerSettle, pagesBetween } from "../pagerSwipe";
16
+ import { useWebClickGuard } from "./web-click-guard";
17
+
18
+ // The pager on Gesture Handler: the track phase and the release run as
19
+ // worklets on the UI thread, so the pages never lag the finger. The rules are
20
+ // plain worklets in `pagerSwipe`.
21
+
22
+ /** The release: springs on at the finger's speed, no bounce (an overshoot would flash the next page's edge). */
23
+ const PAGER_SPRING = { duration: 250, dampingRatio: 1, reduceMotion: ReduceMotion.System } as const;
24
+
25
+ /** A page set from outside (a tab press) slides there; at once with Reduce Motion. */
26
+ const PAGER_SLIDE = { duration: 220, reduceMotion: ReduceMotion.System } as const;
27
+
28
+ export function GesturePager({ index, count, onIndexChange, progress, renderPage }: PagerProps) {
29
+ const width = useSharedValue(0);
30
+ // Where the pages were when the finger caught them, and the finger's travel then.
31
+ const start = useSharedValue(0);
32
+ const startX = useSharedValue(0);
33
+ const [mounted, setMounted] = useState<ReadonlySet<number>>(() => new Set([index]));
34
+ const [shown, setShown] = useState(index);
35
+ // A page reached from outside is drawn from then on, with every page the
36
+ // slide there passes, so it never crosses a blank page.
37
+ if (shown !== index) {
38
+ setShown(index);
39
+ setMounted(new Set([...mounted, ...pagesBetween(shown, index)]));
40
+ }
41
+ // The page a swipe settled on, until the new `index` comes back: that one must not stop the spring.
42
+ const settling = useRef<number | null>(null);
43
+ // A page set from outside slides there. An effect: a shared value is never
44
+ // written in render.
45
+ useEffect(() => {
46
+ const fromSwipe = settling.current === index;
47
+ settling.current = null;
48
+ if (fromSwipe) return;
49
+ progress.set(withTiming(index, PAGER_SLIDE));
50
+ }, [index, progress]);
51
+
52
+ const webClicks = useWebClickGuard();
53
+ const dragStarted = () => {
54
+ webClicks.swallowNext();
55
+ // Both neighbours, so the finger finds a page there.
56
+ setMounted((current) => new Set([...current, index - 1, index + 1].filter((page) => page >= 0 && page < count)));
57
+ };
58
+ const settled = (page: number) => {
59
+ settling.current = page;
60
+ selectionHaptic();
61
+ onIndexChange(page);
62
+ };
63
+
64
+ // Built on each render (for `index` and `count`): the detector keeps the
65
+ // same handler, and a drag in flight, and only takes the new config.
66
+ const pan = Gesture.Pan()
67
+ .enabled(count > 1)
68
+ // Clearly sideways only: a vertical drag is the page's scroll, and a row swipe starts sooner.
69
+ .activeOffsetX([-PAGER_ACTIVE_OFFSET_X, PAGER_ACTIVE_OFFSET_X])
70
+ .failOffsetY([-PAGER_FAIL_OFFSET_Y, PAGER_FAIL_OFFSET_Y])
71
+ .onStart((event) => {
72
+ cancelAnimation(progress);
73
+ start.set(progress.get());
74
+ // Count from here, so the pages do not jump by the slop the drag needed to start.
75
+ startX.set(event.translationX);
76
+ runOnJS(dragStarted)();
77
+ })
78
+ // Track phase: one shared value write per frame, on the UI thread.
79
+ .onUpdate((event) => {
80
+ progress.set(pagerPosition(start.get(), event.translationX - startX.get(), width.get(), count));
81
+ })
82
+ .onEnd((event) => {
83
+ const pageWidth = width.get();
84
+ const target = pagerSettle(progress.get(), event.velocityX, pageWidth, Math.round(start.get()), count);
85
+ // The finger's speed in pages per second, the unit of `progress`.
86
+ const velocity = pageWidth > 0 ? -event.velocityX / pageWidth : 0;
87
+ progress.set(withSpring(target, { ...PAGER_SPRING, velocity }));
88
+ if (target !== index) runOnJS(settled)(target);
89
+ });
90
+
91
+ const trackStyle = useAnimatedStyle(() => ({
92
+ transform: [{ translateX: `${(-progress.get() * 100) / count}%` }],
93
+ }));
94
+ return (
95
+ <InsidePagerContext.Provider value>
96
+ <View style={styles.clip} onLayout={(event) => width.set(event.nativeEvent.layout.width)}>
97
+ <GestureDetector gesture={pan}>
98
+ <Animated.View style={[styles.fill, { width: `${count * 100}%` }, trackStyle]}>
99
+ <View ref={webClicks.ref} style={styles.track}>
100
+ {Array.from({ length: count }, (_, page) => (
101
+ <View
102
+ key={page}
103
+ // Off-screen pages are hidden from screen readers; the tabs above switch pages for them.
104
+ accessibilityElementsHidden={page !== index}
105
+ importantForAccessibility={page === index ? "auto" : "no-hide-descendants"}
106
+ style={{ width: `${100 / count}%` }}
107
+ >
108
+ {mounted.has(page) ? renderPage(page) : null}
109
+ </View>
110
+ ))}
111
+ </View>
112
+ </Animated.View>
113
+ </GestureDetector>
114
+ </View>
115
+ </InsidePagerContext.Provider>
116
+ );
117
+ }
118
+
119
+ const styles = StyleSheet.create({
120
+ clip: {
121
+ flex: 1,
122
+ overflow: "hidden",
123
+ },
124
+ fill: {
125
+ flex: 1,
126
+ },
127
+ track: {
128
+ flex: 1,
129
+ flexDirection: "row",
130
+ },
131
+ });