@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.
Files changed (108) hide show
  1. package/README.md +40 -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/chat-text.ts +13 -7
  36. package/src/ai/chat-thread.tsx +34 -27
  37. package/src/ai/choice-question.tsx +154 -35
  38. package/src/ai/code-block.tsx +189 -49
  39. package/src/ai/collapsible.tsx +19 -13
  40. package/src/ai/conversation.tsx +390 -57
  41. package/src/ai/icon-button.tsx +41 -6
  42. package/src/ai/index.ts +38 -1
  43. package/src/ai/message-action-sheet.tsx +4 -1
  44. package/src/ai/message.tsx +60 -31
  45. package/src/ai/prompt-input-menu.tsx +197 -0
  46. package/src/ai/prompt-input.tsx +301 -75
  47. package/src/ai/shimmer.tsx +4 -6
  48. package/src/ai/trace.tsx +54 -17
  49. package/src/ai/unified-diff.tsx +182 -0
  50. package/src/auth/GoogleSignInButton.tsx +6 -3
  51. package/src/auth/SignInScreen.tsx +11 -2
  52. package/src/brand/ProviderIcon.tsx +136 -0
  53. package/src/brand/index.ts +2 -0
  54. package/src/configureTheme.ts +39 -11
  55. package/src/controlStyles.ts +46 -30
  56. package/src/entries/gesture-handler.ts +4 -0
  57. package/src/entries/keyboard-controller.ts +4 -0
  58. package/src/entries/markdown.ts +1 -1
  59. package/src/entries/primitives.ts +24 -3
  60. package/src/entries/theme.ts +2 -1
  61. package/src/entries/video.ts +4 -0
  62. package/src/entries/webview.ts +6 -0
  63. package/src/floating.ts +25 -4
  64. package/src/haptics.ts +30 -4
  65. package/src/highlight.ts +302 -1
  66. package/src/index.ts +27 -5
  67. package/src/loadingGrid.ts +75 -0
  68. package/src/markdown/Markdown.tsx +234 -32
  69. package/src/markdown/MarkdownTable.tsx +188 -0
  70. package/src/markdown/tableText.ts +58 -0
  71. package/src/marketing/UpgradeToProCard.tsx +4 -3
  72. package/src/media/ImageGallery.tsx +224 -0
  73. package/src/media/MediaCard.tsx +100 -0
  74. package/src/media/imageGrid.ts +51 -0
  75. package/src/motion.ts +33 -0
  76. package/src/pagerSwipe.ts +102 -0
  77. package/src/palette.ts +8 -1
  78. package/src/primitives/button.tsx +4 -1
  79. package/src/primitives/keyboard-controller.tsx +106 -0
  80. package/src/primitives/keyboard-parts.tsx +104 -0
  81. package/src/primitives/overlay.tsx +133 -45
  82. package/src/primitives/pager-gesture.tsx +131 -0
  83. package/src/primitives/sheet-drag.tsx +305 -0
  84. package/src/primitives/sheet-gesture.tsx +181 -0
  85. package/src/primitives/swipe-row-gesture.tsx +290 -0
  86. package/src/primitives/toast-drag-gesture.tsx +92 -0
  87. package/src/primitives/web-click-guard.ts +50 -0
  88. package/src/relativeTime.ts +94 -0
  89. package/src/router/RouterStackPageHeader.tsx +2 -2
  90. package/src/settings/SettingsEmptyState.tsx +20 -10
  91. package/src/settings/SettingsField.tsx +11 -5
  92. package/src/settings/SettingsSection.tsx +8 -5
  93. package/src/settings/SettingsToggleRow.tsx +55 -22
  94. package/src/settings/relaxedSizeStyle.ts +11 -0
  95. package/src/sheetDrag.ts +67 -0
  96. package/src/swipeRow.ts +169 -0
  97. package/src/tabBarRoutes.ts +18 -0
  98. package/src/tabIndicator.ts +71 -0
  99. package/src/tailwind-preset.cjs +13 -6
  100. package/src/tailwind-preset.d.cts +5 -0
  101. package/src/theme.tsx +6 -2
  102. package/src/toastDrag.ts +58 -0
  103. package/src/typography.ts +20 -3
  104. package/src/useScheme.ts +10 -1
  105. package/src/video/InlineVideo.tsx +91 -0
  106. package/src/webview/HtmlView.tsx +91 -0
  107. package/src/webview/HtmlView.web.tsx +21 -0
  108. 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 active:opacity-80",
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; `"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