@vvedantb/native-ui 0.13.0 → 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.
- package/README.md +40 -19
- 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 +19 -13
- package/src/ai/conversation.tsx +390 -57
- package/src/ai/icon-button.tsx +41 -6
- package/src/ai/index.ts +38 -1
- 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/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/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,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules of a swiped `Pager`, as plain numbers, so they stay testable
|
|
3
|
+
* apart from the gesture. The pager's position is in pages: 0 is the first
|
|
4
|
+
* page, 1.5 is halfway between the second and the third. Distances are in
|
|
5
|
+
* points and velocities in points per second (the Gesture Handler units),
|
|
6
|
+
* positive to the right. Each rule is a worklet, so the drag runs it on the
|
|
7
|
+
* UI thread.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sideways travel (pt) before a drag takes the pager: twice a row swipe's
|
|
12
|
+
* (`SWIPE_ACTIVE_OFFSET_X`), so a swipeable row under the finger takes its
|
|
13
|
+
* own swipe first, and only a clearly sideways drag moves the pages.
|
|
14
|
+
*/
|
|
15
|
+
export const PAGER_ACTIVE_OFFSET_X = 24;
|
|
16
|
+
|
|
17
|
+
/** Vertical travel (pt) that leaves the touch to the page's own scroll. */
|
|
18
|
+
export const PAGER_FAIL_OFFSET_Y = 14;
|
|
19
|
+
|
|
20
|
+
/** A sideways flick at this speed (pt/s) turns the page, however short the drag. */
|
|
21
|
+
const FLICK_VELOCITY = 500;
|
|
22
|
+
|
|
23
|
+
/** How far ahead (s) the release velocity carries the pages when deciding where they settle. */
|
|
24
|
+
const PROJECTION_S = 0.2;
|
|
25
|
+
|
|
26
|
+
/** How far the pages move past the first or last page: less and less the further the finger goes. */
|
|
27
|
+
function rubberband(overshoot: number, dimension: number): number {
|
|
28
|
+
"worklet";
|
|
29
|
+
const constant = 0.55;
|
|
30
|
+
return (overshoot * dimension * constant) / (dimension + constant * overshoot);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Where the pages sit for a finger `dx` points from where the drag caught
|
|
35
|
+
* them at `start`: they follow the finger 1:1 (a drag left shows the next
|
|
36
|
+
* page), and past the first or last page they meet growing resistance.
|
|
37
|
+
*/
|
|
38
|
+
export function pagerPosition(start: number, dx: number, width: number, count: number): number {
|
|
39
|
+
"worklet";
|
|
40
|
+
if (width <= 0) return start;
|
|
41
|
+
const raw = start - dx / width;
|
|
42
|
+
const last = count - 1;
|
|
43
|
+
if (raw < 0) return -rubberband(-raw * width, width) / width;
|
|
44
|
+
if (raw > last) return last + rubberband((raw - last) * width, width) / width;
|
|
45
|
+
return raw;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The page a released drag settles on, from the page it started on
|
|
50
|
+
* (`from`), where it is (`position`) and the finger's speed (`vx`): a flick
|
|
51
|
+
* past `FLICK_VELOCITY` the way it moved turns one page; else the position
|
|
52
|
+
* carried on by its speed rounds to the nearest page. Never more than one
|
|
53
|
+
* page from `from`, and never past the ends.
|
|
54
|
+
*/
|
|
55
|
+
export function pagerSettle(position: number, vx: number, width: number, from: number, count: number): number {
|
|
56
|
+
"worklet";
|
|
57
|
+
const moved = position - from;
|
|
58
|
+
// A drag left (negative `vx`) moves toward the next page (a larger position).
|
|
59
|
+
const toward = -Math.sign(vx);
|
|
60
|
+
let target: number;
|
|
61
|
+
if (Math.abs(vx) >= FLICK_VELOCITY && moved !== 0 && toward === Math.sign(moved)) {
|
|
62
|
+
target = from + toward;
|
|
63
|
+
} else {
|
|
64
|
+
target = Math.round(width > 0 ? position - (vx * PROJECTION_S) / width : position);
|
|
65
|
+
}
|
|
66
|
+
return Math.min(count - 1, Math.max(0, Math.min(from + 1, Math.max(from - 1, target))));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every page from `from` to `to`, both included: the pages a slide from
|
|
71
|
+
* outside (a tab press) passes, which the pager draws so it never crosses a
|
|
72
|
+
* blank page.
|
|
73
|
+
*/
|
|
74
|
+
export function pagesBetween(from: number, to: number): number[] {
|
|
75
|
+
const low = Math.min(from, to);
|
|
76
|
+
return Array.from({ length: Math.abs(to - from) + 1 }, (_, step) => low + step);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The scroll offset (pt) of a sideways tab row that centres the tab at
|
|
81
|
+
* `position` (in pages, as the pager's: 1.5 is halfway between the second
|
|
82
|
+
* and the third tab), given each tab's `x` and `width` in the row and the
|
|
83
|
+
* row's visible `viewport` width. Between two tabs it blends their centres;
|
|
84
|
+
* it never scrolls before the first tab or past the last.
|
|
85
|
+
*/
|
|
86
|
+
export function centredTabScroll(
|
|
87
|
+
tabs: ReadonlyArray<{ x: number; width: number }>,
|
|
88
|
+
position: number,
|
|
89
|
+
viewport: number,
|
|
90
|
+
): number {
|
|
91
|
+
"worklet";
|
|
92
|
+
const last = tabs[tabs.length - 1];
|
|
93
|
+
if (!last || viewport <= 0) return 0;
|
|
94
|
+
const at = Math.min(tabs.length - 1, Math.max(0, position));
|
|
95
|
+
const below = Math.floor(at);
|
|
96
|
+
const left = tabs[below] ?? last;
|
|
97
|
+
const right = tabs[Math.min(tabs.length - 1, below + 1)] ?? last;
|
|
98
|
+
const share = at - below;
|
|
99
|
+
const centre = left.x + left.width / 2 + (right.x + right.width / 2 - (left.x + left.width / 2)) * share;
|
|
100
|
+
const end = Math.max(0, last.x + last.width - viewport);
|
|
101
|
+
return Math.min(end, Math.max(0, centre - viewport / 2));
|
|
102
|
+
}
|
package/src/palette.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Web semantic colours for React Native, converted from `@vvedantb/ui`
|
|
3
3
|
* `tokens.css` (oklch → sRGB hex, clipped to sRGB). `tokens.ts` holds the
|
|
4
4
|
* neutrals the native app ships; these are the colours it lacks (primary,
|
|
5
|
-
* danger, success, warning, overlay, segment, field placeholder, backdrop,
|
|
5
|
+
* danger, success, warning, overlay, segment, tab bar pill, field placeholder, backdrop,
|
|
6
6
|
* dialog description).
|
|
7
7
|
*
|
|
8
8
|
* A test in this package converts `tokens.css` and fails when a value here
|
|
@@ -25,6 +25,13 @@ export const palette = {
|
|
|
25
25
|
overlayForeground: { light: "#181818", dark: "#fcfcfc" },
|
|
26
26
|
segment: { light: "#ffffff", dark: "#474747" },
|
|
27
27
|
segmentForeground: { light: "#181818", dark: "#fcfcfc" },
|
|
28
|
+
/**
|
|
29
|
+
* The `FloatingTabBar` active pill: `--default` in light (it shows on the
|
|
30
|
+
* near-white glass bar) and `--segment` in dark. Its own key, so an app that
|
|
31
|
+
* themes `segment` for the segmented `Tabs` thumb keeps a pill that shows on
|
|
32
|
+
* the dark glass bar.
|
|
33
|
+
*/
|
|
34
|
+
tabBarPill: { light: "#ebebeb", dark: "#474747" },
|
|
28
35
|
surfaceTertiary: { light: "#eaeaea", dark: "#282627" },
|
|
29
36
|
fieldPlaceholder: { light: "#727272", dark: "#a0a0a0" },
|
|
30
37
|
backdrop: { light: "rgba(0, 0, 0, 0.5)", dark: "rgba(0, 0, 0, 0.6)" },
|
|
@@ -3,10 +3,11 @@ import { cn } from "../cn";
|
|
|
3
3
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
4
|
import * as React from "react";
|
|
5
5
|
import { Platform, Pressable } from "react-native";
|
|
6
|
+
import { usePressFeedback } from "../motion";
|
|
6
7
|
|
|
7
8
|
const buttonVariants = cva(
|
|
8
9
|
cn(
|
|
9
|
-
"group flex-row items-center justify-center gap-2 shadow-none
|
|
10
|
+
"group flex-row items-center justify-center gap-2 shadow-none",
|
|
10
11
|
Platform.select({
|
|
11
12
|
web: "outline-none transition-all",
|
|
12
13
|
}),
|
|
@@ -88,12 +89,14 @@ const Button = React.forwardRef<
|
|
|
88
89
|
React.ElementRef<typeof Pressable>,
|
|
89
90
|
ButtonProps
|
|
90
91
|
>(({ className, variant, size, shape, ...props }, ref) => {
|
|
92
|
+
const pressed = usePressFeedback();
|
|
91
93
|
return (
|
|
92
94
|
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
|
|
93
95
|
<Pressable
|
|
94
96
|
ref={ref}
|
|
95
97
|
className={cn(
|
|
96
98
|
buttonVariants({ variant, size, shape }),
|
|
99
|
+
pressed,
|
|
97
100
|
props.disabled && "opacity-45",
|
|
98
101
|
className,
|
|
99
102
|
)}
|
|
@@ -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
|
|