@vvedantb/native-ui 0.13.0 → 0.17.1
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.
- package/README.md +42 -21
- package/package.json +36 -3
- package/src/BottomSheetContainer.tsx +2 -2
- package/src/BulkActionBar.tsx +240 -105
- package/src/Button.tsx +11 -7
- package/src/ConfirmDialog.tsx +25 -35
- package/src/Dialog.tsx +47 -35
- package/src/DividedList.tsx +11 -7
- package/src/DropdownMenu.tsx +22 -8
- package/src/FloatingSurface.tsx +5 -2
- package/src/FloatingTabBar.tsx +13 -22
- package/src/HeaderIconButton.tsx +4 -3
- package/src/Input.tsx +2 -2
- package/src/LoadingState.tsx +132 -0
- package/src/PageTitle.tsx +9 -2
- package/src/Pager.tsx +62 -0
- package/src/PasswordInput.tsx +1 -1
- package/src/PillTabs.tsx +144 -34
- package/src/Progress.tsx +9 -7
- package/src/Select.tsx +4 -4
- package/src/Sheet.tsx +29 -22
- package/src/SheetRow.tsx +27 -9
- package/src/Skeleton.tsx +91 -9
- package/src/SurfaceRow.tsx +111 -0
- package/src/SwipeableRow.tsx +43 -0
- package/src/Switch.tsx +6 -4
- package/src/Tabs.tsx +68 -13
- package/src/Toast.tsx +107 -65
- package/src/ai/activity-step-detail.tsx +1 -3
- package/src/ai/activity-step-label.ts +981 -0
- package/src/ai/activity-step-types.ts +107 -0
- package/src/ai/activity-tasks-utils.ts +199 -0
- package/src/ai/activity-timeline.ts +45 -0
- package/src/ai/attachments.tsx +3 -1
- package/src/ai/chat-text.ts +13 -7
- package/src/ai/chat-thread.tsx +34 -27
- package/src/ai/choice-question.tsx +154 -35
- package/src/ai/code-block.tsx +189 -49
- package/src/ai/collapsible.tsx +23 -13
- package/src/ai/conversation.tsx +394 -57
- package/src/ai/icon-button.tsx +41 -6
- package/src/ai/index.ts +55 -4
- package/src/ai/message-action-sheet.tsx +4 -1
- package/src/ai/message.tsx +60 -31
- package/src/ai/prompt-input-menu.tsx +197 -0
- package/src/ai/prompt-input.tsx +301 -75
- package/src/ai/shimmer.tsx +4 -6
- package/src/ai/subagent-cards.tsx +84 -14
- package/src/ai/tool-approval-card.tsx +139 -8
- package/src/ai/trace.tsx +54 -17
- package/src/ai/unified-diff.tsx +182 -0
- package/src/auth/GoogleSignInButton.tsx +6 -3
- package/src/auth/SignInScreen.tsx +11 -2
- package/src/brand/ProviderIcon.tsx +136 -0
- package/src/brand/index.ts +2 -0
- package/src/configureTheme.ts +39 -11
- package/src/controlStyles.ts +46 -30
- package/src/entries/gesture-handler.ts +4 -0
- package/src/entries/keyboard-controller.ts +4 -0
- package/src/entries/markdown.ts +1 -1
- package/src/entries/primitives.ts +24 -3
- package/src/entries/theme.ts +2 -1
- package/src/entries/video.ts +4 -0
- package/src/entries/webview.ts +6 -0
- package/src/examples/AiChatGallery.tsx +70 -0
- package/src/floating.ts +25 -4
- package/src/haptics.ts +30 -4
- package/src/highlight.ts +302 -1
- package/src/index.ts +27 -5
- package/src/loadingGrid.ts +75 -0
- package/src/markdown/Markdown.tsx +234 -32
- package/src/markdown/MarkdownTable.tsx +188 -0
- package/src/markdown/tableText.ts +58 -0
- package/src/marketing/UpgradeToProCard.tsx +4 -3
- package/src/media/ImageGallery.tsx +224 -0
- package/src/media/MediaCard.tsx +100 -0
- package/src/media/imageGrid.ts +51 -0
- package/src/motion.ts +33 -0
- package/src/pagerSwipe.ts +102 -0
- package/src/palette.ts +8 -1
- package/src/primitives/button.tsx +4 -1
- package/src/primitives/keyboard-controller.tsx +106 -0
- package/src/primitives/keyboard-parts.tsx +104 -0
- package/src/primitives/overlay.tsx +133 -45
- package/src/primitives/pager-gesture.tsx +131 -0
- package/src/primitives/sheet-drag.tsx +305 -0
- package/src/primitives/sheet-gesture.tsx +181 -0
- package/src/primitives/swipe-row-gesture.tsx +290 -0
- package/src/primitives/toast-drag-gesture.tsx +92 -0
- package/src/primitives/web-click-guard.ts +50 -0
- package/src/relativeTime.ts +94 -0
- package/src/router/RouterStackPageHeader.tsx +2 -2
- package/src/settings/SettingsEmptyState.tsx +20 -10
- package/src/settings/SettingsField.tsx +11 -5
- package/src/settings/SettingsSection.tsx +8 -5
- package/src/settings/SettingsToggleRow.tsx +55 -22
- package/src/settings/relaxedSizeStyle.ts +11 -0
- package/src/sheetDrag.ts +67 -0
- package/src/swipeRow.ts +169 -0
- package/src/tabBarRoutes.ts +18 -0
- package/src/tabIndicator.ts +71 -0
- package/src/tailwind-preset.cjs +13 -6
- package/src/tailwind-preset.d.cts +5 -0
- package/src/theme.tsx +6 -2
- package/src/toastDrag.ts +58 -0
- package/src/typography.ts +20 -3
- package/src/useScheme.ts +10 -1
- package/src/video/InlineVideo.tsx +91 -0
- package/src/webview/HtmlView.tsx +91 -0
- package/src/webview/HtmlView.web.tsx +21 -0
- package/src/webview/htmlView.ts +45 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
KeyboardAvoidingView,
|
|
5
|
+
KeyboardChatScrollView,
|
|
6
|
+
KeyboardGestureArea,
|
|
7
|
+
KeyboardStickyView,
|
|
8
|
+
useKeyboardHandler,
|
|
9
|
+
} from "react-native-keyboard-controller";
|
|
10
|
+
import { runOnJS } from "react-native-reanimated";
|
|
11
|
+
import { COMPOSER_INPUT_ID, KeyboardPartsContext, type KeyboardParts } from "./keyboard-parts";
|
|
12
|
+
|
|
13
|
+
// The keyboard parts on `react-native-keyboard-controller`: the keyboard's
|
|
14
|
+
// real position, frame by frame, on the UI thread. No timing of our own, so
|
|
15
|
+
// the composer can neither lag nor lead the keyboard; with Reduce Motion the
|
|
16
|
+
// system shortens the keyboard's own motion and the parts follow it.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A drag moves the keyboard on iOS (`keyboardDismissMode="interactive"`) and
|
|
20
|
+
* on Android 11+ (API 30, `WindowInsetsAnimation`). Android 10 and below has
|
|
21
|
+
* no interactive keyboard: `KeyboardGestureArea` then only renders its
|
|
22
|
+
* children, and the transcript keeps `on-drag`.
|
|
23
|
+
*/
|
|
24
|
+
const INTERACTIVE = Platform.OS === "ios" || (Platform.OS === "android" && Number(Platform.Version) >= 30);
|
|
25
|
+
|
|
26
|
+
function AvoidingView(props: ComponentProps<KeyboardParts["AvoidingView"]>) {
|
|
27
|
+
return <KeyboardAvoidingView {...props} />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function StickyView({ bottomOffset, ...props }: ComponentProps<KeyboardParts["StickyView"]>) {
|
|
31
|
+
return <KeyboardStickyView {...props} offset={{ opened: bottomOffset }} />;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `always`: the content rises with the keyboard wherever the reader is, so
|
|
36
|
+
* what sat on the composer stays there (the newest message, at the end).
|
|
37
|
+
* Inverted, the keyboard's space is the content's top inset, the visual
|
|
38
|
+
* bottom: iOS scrolls below offset 0 into it, so the end moves there; Android
|
|
39
|
+
* moves the content down by it instead, so offset 0 stays the end. While the
|
|
40
|
+
* keyboard moves, the list scrolls with it, through offsets that are not the
|
|
41
|
+
* end on either platform.
|
|
42
|
+
*/
|
|
43
|
+
function ChatScrollView({
|
|
44
|
+
bottomOffset,
|
|
45
|
+
inverted = false,
|
|
46
|
+
onInsetChange,
|
|
47
|
+
onKeyboardMove,
|
|
48
|
+
...props
|
|
49
|
+
}: ComponentProps<KeyboardParts["ChatScrollView"]>) {
|
|
50
|
+
useKeyboardHandler(
|
|
51
|
+
{
|
|
52
|
+
onStart: () => {
|
|
53
|
+
"worklet";
|
|
54
|
+
runOnJS(onKeyboardMove)(true);
|
|
55
|
+
},
|
|
56
|
+
onEnd: () => {
|
|
57
|
+
"worklet";
|
|
58
|
+
runOnJS(onKeyboardMove)(false);
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
[onKeyboardMove],
|
|
62
|
+
);
|
|
63
|
+
return (
|
|
64
|
+
<KeyboardChatScrollView
|
|
65
|
+
{...props}
|
|
66
|
+
inverted={inverted}
|
|
67
|
+
offset={bottomOffset}
|
|
68
|
+
keyboardLiftBehavior="always"
|
|
69
|
+
onContentInsetChange={(insets) =>
|
|
70
|
+
onInsetChange(inverted ? (Platform.OS === "ios" ? insets.top : 0) : insets.bottom)
|
|
71
|
+
}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `ios`: the keyboard follows once the finger reaches the composer, then 1:1,
|
|
78
|
+
* and a release carries the finger's velocity, on both platforms.
|
|
79
|
+
*/
|
|
80
|
+
function GestureArea({ offset, style, children }: ComponentProps<KeyboardParts["GestureArea"]>) {
|
|
81
|
+
return (
|
|
82
|
+
<KeyboardGestureArea interpolator="ios" offset={offset} textInputNativeID={COMPOSER_INPUT_ID} style={style}>
|
|
83
|
+
{children}
|
|
84
|
+
</KeyboardGestureArea>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const PARTS: KeyboardParts = {
|
|
89
|
+
AvoidingView,
|
|
90
|
+
StickyView,
|
|
91
|
+
ChatScrollView,
|
|
92
|
+
GestureArea,
|
|
93
|
+
interactive: INTERACTIVE,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Runs the kit's keyboard handling on `react-native-keyboard-controller`:
|
|
98
|
+
* the chat composer (`ConversationDock`), its scroll button and the
|
|
99
|
+
* transcript (`ConversationList`) follow the keyboard frame by frame, a drag
|
|
100
|
+
* on the transcript moves the keyboard with the finger, and `PromptInput`,
|
|
101
|
+
* `ChatThread`, the overlays and `KeyboardAvoidingView` pad frame by frame.
|
|
102
|
+
* Put it inside the app's `KeyboardProvider`.
|
|
103
|
+
*/
|
|
104
|
+
export function KeyboardControllerProvider({ children }: { children: ReactNode }) {
|
|
105
|
+
return <KeyboardPartsContext.Provider value={PARTS}>{children}</KeyboardPartsContext.Provider>;
|
|
106
|
+
}
|
|
@@ -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
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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={
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
</
|
|
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
|
+
});
|