react-native-nitro-modal 0.2.0 → 0.3.1-rc

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 (83) hide show
  1. package/README.md +52 -4
  2. package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +399 -34
  3. package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +67 -22
  4. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalConfig.kt +35 -1
  5. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalContentRoot.kt +48 -5
  6. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalPresenter.kt +63 -4
  7. package/android/src/main/java/com/margelo/nitro/nitromodal/NitroModalHostView.kt +68 -8
  8. package/ios/HybridNitroModal.swift +115 -20
  9. package/ios/ModalConfig.swift +48 -2
  10. package/ios/NitroModalHostView.swift +73 -8
  11. package/ios/SheetView.swift +647 -0
  12. package/ios/SheetViewController.swift +19 -450
  13. package/lib/module/NitroModal.js +54 -6
  14. package/lib/module/NitroModal.js.map +1 -1
  15. package/lib/module/NitroModalView.web.js +86 -26
  16. package/lib/module/NitroModalView.web.js.map +1 -1
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/web/AnimatedValue.js +3 -0
  19. package/lib/module/web/AnimatedValue.js.map +1 -1
  20. package/lib/module/web/ModalConfig.js +10 -4
  21. package/lib/module/web/ModalConfig.js.map +1 -1
  22. package/lib/module/web/ModalController.js +29 -2
  23. package/lib/module/web/ModalController.js.map +1 -1
  24. package/lib/module/web/ModalPresenter.js +47 -12
  25. package/lib/module/web/ModalPresenter.js.map +1 -1
  26. package/lib/module/web/SheetPresenter.js +118 -34
  27. package/lib/module/web/SheetPresenter.js.map +1 -1
  28. package/lib/module/web/dom.js +16 -0
  29. package/lib/module/web/dom.js.map +1 -1
  30. package/lib/typescript/src/NitroModal.d.ts +42 -2
  31. package/lib/typescript/src/NitroModal.d.ts.map +1 -1
  32. package/lib/typescript/src/NitroModal.nitro.d.ts +37 -2
  33. package/lib/typescript/src/NitroModal.nitro.d.ts.map +1 -1
  34. package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -1
  35. package/lib/typescript/src/index.d.ts +2 -2
  36. package/lib/typescript/src/index.d.ts.map +1 -1
  37. package/lib/typescript/src/web/AnimatedValue.d.ts +1 -0
  38. package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -1
  39. package/lib/typescript/src/web/ModalConfig.d.ts +10 -0
  40. package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -1
  41. package/lib/typescript/src/web/ModalController.d.ts +5 -0
  42. package/lib/typescript/src/web/ModalController.d.ts.map +1 -1
  43. package/lib/typescript/src/web/ModalPresenter.d.ts +13 -0
  44. package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -1
  45. package/lib/typescript/src/web/SheetPresenter.d.ts +13 -2
  46. package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -1
  47. package/lib/typescript/src/web/dom.d.ts +4 -0
  48. package/lib/typescript/src/web/dom.d.ts.map +1 -1
  49. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.cpp +78 -6
  50. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.hpp +14 -2
  51. package/nitrogen/generated/android/c++/JNamedSheetDetent.hpp +64 -0
  52. package/nitrogen/generated/android/c++/JSheetDetent.cpp +26 -0
  53. package/nitrogen/generated/android/c++/JSheetDetent.hpp +42 -37
  54. package/nitrogen/generated/android/c++/views/JHybridNitroModalStateUpdater.cpp +30 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/HybridNitroModalSpec.kt +52 -0
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/NamedSheetDetent.kt +25 -0
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/SheetDetent.kt +47 -10
  58. package/nitrogen/generated/android/nitromodal+autolinking.cmake +1 -0
  59. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Bridge.hpp +68 -8
  60. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Umbrella.hpp +4 -3
  61. package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.hpp +46 -5
  62. package/nitrogen/generated/ios/c++/views/HybridNitroModalComponent.mm +36 -0
  63. package/nitrogen/generated/ios/swift/HybridNitroModalSpec.swift +6 -0
  64. package/nitrogen/generated/ios/swift/HybridNitroModalSpec_cxx.swift +158 -5
  65. package/nitrogen/generated/ios/swift/NamedSheetDetent.swift +48 -0
  66. package/nitrogen/generated/ios/swift/SheetDetent.swift +15 -33
  67. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.cpp +12 -0
  68. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.hpp +18 -5
  69. package/nitrogen/generated/shared/c++/{SheetDetent.hpp → NamedSheetDetent.hpp} +17 -17
  70. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.cpp +13 -1
  71. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.hpp +21 -2
  72. package/nitrogen/generated/shared/json/NitroModalConfig.json +6 -0
  73. package/package.json +1 -1
  74. package/src/NitroModal.nitro.ts +38 -2
  75. package/src/NitroModal.tsx +93 -4
  76. package/src/NitroModalView.web.tsx +79 -12
  77. package/src/index.tsx +2 -0
  78. package/src/web/AnimatedValue.ts +4 -0
  79. package/src/web/ModalConfig.ts +19 -3
  80. package/src/web/ModalController.ts +35 -2
  81. package/src/web/ModalPresenter.ts +50 -11
  82. package/src/web/SheetPresenter.ts +169 -37
  83. package/src/web/dom.ts +20 -0
@@ -13,13 +13,20 @@ import type {
13
13
  export type ModalMode = 'bottomSheet' | 'popup';
14
14
 
15
15
  /**
16
- * Resting heights of a `bottomSheet`.
16
+ * Named resting heights of a `bottomSheet`.
17
17
  * - `small`: ~25% of the available height.
18
18
  * - `medium`: ~50% of the available height.
19
19
  * - `large`: the full available height.
20
20
  * - `fitContent`: the measured height of the React content.
21
21
  */
22
- export type SheetDetent = 'small' | 'medium' | 'large' | 'fitContent';
22
+ export type NamedSheetDetent = 'small' | 'medium' | 'large' | 'fitContent';
23
+
24
+ /**
25
+ * A resting height of a `bottomSheet`: a named height, or the height
26
+ * (dp/pt) of the content above the bottom safe area, clamped to the
27
+ * available height.
28
+ */
29
+ export type SheetDetent = NamedSheetDetent | number;
23
30
 
24
31
  /**
25
32
  * How the modal reacts to the software keyboard.
@@ -48,6 +55,12 @@ export interface NitroModalProps extends HybridViewProps {
48
55
  /** Whether the modal should be presented. */
49
56
  isOpen: boolean;
50
57
  mode: ModalMode;
58
+ /**
59
+ * A `bottomSheet` that lives inside the host view's own bounds instead of
60
+ * being presented over the app: no backdrop, touches outside the sheet
61
+ * reach the views behind it, and the user can't dismiss it.
62
+ */
63
+ isInline: boolean;
51
64
  /** Detents of a `bottomSheet`, smallest first. At most 3 are used on Android. */
52
65
  detents: SheetDetent[];
53
66
  /** Index into `detents` that the sheet opens at. */
@@ -68,6 +81,19 @@ export interface NitroModalProps extends HybridViewProps {
68
81
  contentBackgroundColor?: number;
69
82
  keyboardBehavior: KeyboardBehavior;
70
83
  popupAnimation: PopupAnimation;
84
+ /**
85
+ * Pulling a `bottomSheet` down past its lowest detent calls
86
+ * `onPullToRefresh`. Needs `dismissOnSwipe` off (always the case inline).
87
+ */
88
+ pullToRefreshEnabled: boolean;
89
+ /**
90
+ * A refresh is in progress: the sheet rests pulled down below its lowest
91
+ * detent with an activity indicator above it. Native starts refreshing on
92
+ * its own when a pull is released. `undefined` shows no indicator.
93
+ */
94
+ refreshing?: boolean;
95
+ /** Refresh indicator color as a processed ARGB color. Omit for the platform default. */
96
+ refreshIndicatorColor?: number;
71
97
 
72
98
  /** The present transition finished. */
73
99
  onPresent?: () => void;
@@ -80,6 +106,16 @@ export interface NitroModalProps extends HybridViewProps {
80
106
  onBackButtonPress?: () => void;
81
107
  /** The area available to the content changed (rotation, keyboard, sheet sizing). */
82
108
  onContentAreaChange?: (area: ModalContentArea) => void;
109
+ /**
110
+ * The user pulled the sheet down past its lowest detent and let go. The
111
+ * gesture must start with the sheet resting on that detent.
112
+ */
113
+ onPullToRefresh?: () => void;
114
+ /**
115
+ * Inline sheets: where the sheet's top rests within the host view (or
116
+ * will, once it settles). Lets React lay the content out where it is shown.
117
+ */
118
+ onRestingTopChange?: (top: number) => void;
83
119
  }
84
120
 
85
121
  export interface NitroModalMethods extends HybridViewMethods {
@@ -1,4 +1,5 @@
1
1
  import {
2
+ useEffect,
2
3
  useImperativeHandle,
3
4
  useLayoutEffect,
4
5
  useMemo,
@@ -21,12 +22,22 @@ import type {
21
22
  KeyboardBehavior,
22
23
  ModalContentArea,
23
24
  ModalMode,
25
+ NamedSheetDetent,
24
26
  NitroModal as NitroModalHybridView,
25
27
  PopupAnimation,
26
28
  SheetDetent,
27
29
  } from './NitroModal.nitro';
28
30
  import { callback, NitroModalView } from './NitroModalView';
29
31
 
32
+ /**
33
+ * Where a `bottomSheet` lives.
34
+ * - `modal`: presented over the whole app, with a backdrop.
35
+ * - `inline`: inside the component's own frame (`style`), as part of your
36
+ * screen. No backdrop, the views behind it stay interactive, and the user
37
+ * can't dismiss it.
38
+ */
39
+ export type ModalPresentation = 'modal' | 'inline';
40
+
30
41
  export interface NitroModalRef {
31
42
  /** Opens the modal. Only for uncontrolled usage (no `isOpen` prop). */
32
43
  present(): void;
@@ -45,7 +56,23 @@ export interface NitroModalProps {
45
56
  isOpen?: boolean;
46
57
  /** @default 'bottomSheet' */
47
58
  mode?: ModalMode;
48
- /** Sheet heights, smallest first. Android uses at most three. @default ['fitContent'] */
59
+ /**
60
+ * `inline` keeps a `bottomSheet` inside this component's frame, like a
61
+ * persistent sheet that is part of the screen. Popups are always modal.
62
+ * @default 'modal'
63
+ */
64
+ presentation?: ModalPresentation;
65
+ /**
66
+ * Inline only: the frame the sheet lives in, laid out like any view.
67
+ * The sheet rests against its bottom edge and its largest detent reaches its
68
+ * top edge. @default StyleSheet.absoluteFill
69
+ */
70
+ style?: StyleProp<ViewStyle>;
71
+ /**
72
+ * Sheet heights, smallest first: named sizes, or the content height in
73
+ * dp/pt above the bottom safe area. Android uses at most three.
74
+ * @default ['fitContent']
75
+ */
49
76
  detents?: SheetDetent[];
50
77
  /** @default 0 */
51
78
  initialDetentIndex?: number;
@@ -78,6 +105,22 @@ export interface NitroModalProps {
78
105
  onDetentChange?: (index: number) => void;
79
106
  onBackdropPress?: () => void;
80
107
  onBackButtonPress?: () => void;
108
+ /**
109
+ * Pull-to-refresh: the user dragged the sheet down past its lowest detent
110
+ * and let go. Only for sheets that can't be swiped away (inline, or
111
+ * `dismissOnSwipe={false}`), and only from a gesture that starts with the
112
+ * sheet resting on its lowest detent.
113
+ */
114
+ onPullToRefresh?: () => void;
115
+ /**
116
+ * Whether a refresh is in progress, like `RefreshControl`'s `refreshing`.
117
+ * While true, the sheet stays pulled down below its lowest detent with an
118
+ * activity indicator above it. Set it to `true` in `onPullToRefresh` and
119
+ * back to `false` when done. Leave undefined to show no indicator.
120
+ */
121
+ refreshing?: boolean;
122
+ /** Color of the refresh indicator. Defaults to the platform style. */
123
+ refreshIndicatorColor?: ColorValue;
81
124
  children?: ReactNode;
82
125
  testID?: string;
83
126
  ref?: Ref<NitroModalRef>;
@@ -86,6 +129,13 @@ export interface NitroModalProps {
86
129
  const DEFAULT_DETENTS: SheetDetent[] = ['fitContent'];
87
130
  const BLACK = 0xff000000;
88
131
 
132
+ function parseDetent(value: string): SheetDetent {
133
+ const points = Number(value);
134
+ return value !== '' && Number.isFinite(points)
135
+ ? points
136
+ : (value as NamedSheetDetent);
137
+ }
138
+
89
139
  function toNativeColor(color: ColorValue | undefined): number | undefined {
90
140
  if (color == null) return undefined;
91
141
  const processed = processColor(color);
@@ -96,6 +146,8 @@ function toNativeColor(color: ColorValue | undefined): number | undefined {
96
146
  export function NitroModal({
97
147
  isOpen,
98
148
  mode = 'bottomSheet',
149
+ presentation = 'modal',
150
+ style,
99
151
  detents = DEFAULT_DETENTS,
100
152
  initialDetentIndex = 0,
101
153
  backdropColor = 'black',
@@ -115,11 +167,15 @@ export function NitroModal({
115
167
  onDetentChange,
116
168
  onBackdropPress,
117
169
  onBackButtonPress,
170
+ onPullToRefresh,
171
+ refreshing,
172
+ refreshIndicatorColor,
118
173
  children,
119
174
  testID,
120
175
  ref,
121
176
  }: NitroModalProps) {
122
177
  const isControlled = isOpen !== undefined;
178
+ const isInline = presentation === 'inline' && mode === 'bottomSheet';
123
179
  const [internalOpen, setInternalOpen] = useState(false);
124
180
  const open = isControlled ? isOpen : internalOpen;
125
181
 
@@ -134,26 +190,42 @@ export function NitroModal({
134
190
  const renderContent = open || sessionActive;
135
191
 
136
192
  const [area, setArea] = useState<ModalContentArea | null>(null);
193
+ // Inline (iOS/Android): where the sheet's top rests in the host. The content
194
+ // is laid out there so `measure()`, which presses rely on, matches the
195
+ // screen; native cancels the offset out on screen.
196
+ const [restingTop, setRestingTop] = useState(0);
197
+ // Native starts refreshing as soon as a pull is released. Reporting `true`
198
+ // for the render that follows, then `refreshing` again, stops it if the
199
+ // handler didn't start a refresh (unchanged props never reach native).
200
+ const isRefreshControlled = refreshing !== undefined;
201
+ const [pullPending, setPullPending] = useState(false);
202
+ useEffect(() => {
203
+ if (pullPending) setPullPending(false);
204
+ }, [pullPending]);
137
205
  const hybridRef = useRef<NitroModalHybridView | null>(null);
138
206
 
139
207
  // Native callbacks are created once (stable props, no native updates on
140
208
  // re-render) and read the latest handlers from this ref.
141
209
  const latest = useRef({
142
210
  isControlled,
211
+ isRefreshControlled,
143
212
  onPresent,
144
213
  onDismiss,
145
214
  onDetentChange,
146
215
  onBackdropPress,
147
216
  onBackButtonPress,
217
+ onPullToRefresh,
148
218
  });
149
219
  useLayoutEffect(() => {
150
220
  latest.current = {
151
221
  isControlled,
222
+ isRefreshControlled,
152
223
  onPresent,
153
224
  onDismiss,
154
225
  onDetentChange,
155
226
  onBackdropPress,
156
227
  onBackButtonPress,
228
+ onPullToRefresh,
157
229
  };
158
230
  });
159
231
 
@@ -180,6 +252,13 @@ export function NitroModal({
180
252
  onBackButtonPress: callback(() => {
181
253
  latest.current.onBackButtonPress?.();
182
254
  }),
255
+ onPullToRefresh: callback(() => {
256
+ if (latest.current.isRefreshControlled) setPullPending(true);
257
+ latest.current.onPullToRefresh?.();
258
+ }),
259
+ onRestingTopChange: callback((top: number) => {
260
+ setRestingTop(top);
261
+ }),
183
262
  onContentAreaChange: callback((next: ModalContentArea) => {
184
263
  setArea((current) =>
185
264
  current?.width === next.width && current.height === next.height
@@ -220,7 +299,7 @@ export function NitroModal({
220
299
  // Arrays are diffed by identity, so keep one instance per distinct value.
221
300
  const detentsKey = detents.join(',');
222
301
  const nativeDetents = useMemo(
223
- () => [...new Set(detentsKey.split(','))] as SheetDetent[],
302
+ () => [...new Set(detentsKey.split(','))].map(parseDetent),
224
303
  [detentsKey]
225
304
  );
226
305
 
@@ -240,9 +319,11 @@ export function NitroModal({
240
319
 
241
320
  return (
242
321
  <NitroModalView
243
- style={styles.host}
322
+ style={isInline ? [StyleSheet.absoluteFill, style] : styles.host}
323
+ pointerEvents={isInline ? 'box-none' : undefined}
244
324
  isOpen={open}
245
325
  mode={mode}
326
+ isInline={isInline}
246
327
  detents={nativeDetents}
247
328
  initialDetentIndex={initialDetentIndex}
248
329
  backdropColor={toNativeColor(backdropColor) ?? BLACK}
@@ -256,13 +337,21 @@ export function NitroModal({
256
337
  contentBackgroundColor={toNativeColor(backgroundColor)}
257
338
  keyboardBehavior={keyboardBehavior}
258
339
  popupAnimation={popupAnimation}
340
+ pullToRefreshEnabled={onPullToRefresh != null}
341
+ refreshing={isRefreshControlled ? refreshing || pullPending : undefined}
342
+ refreshIndicatorColor={toNativeColor(refreshIndicatorColor)}
259
343
  {...nativeCallbacks}
260
344
  >
261
345
  {renderContent ? (
262
346
  <View
263
347
  collapsable={false}
264
348
  testID={testID}
265
- style={[styles.container, containerStyle, contentContainerStyle]}
349
+ style={[
350
+ styles.container,
351
+ containerStyle,
352
+ contentContainerStyle,
353
+ isInline && restingTop > 0 ? { top: restingTop } : null,
354
+ ]}
266
355
  >
267
356
  {children}
268
357
  </View>
@@ -1,19 +1,23 @@
1
1
  /// <reference lib="dom" />
2
2
 
3
3
  import {
4
+ useCallback,
4
5
  useLayoutEffect,
5
6
  useRef,
6
7
  useState,
7
8
  type CSSProperties,
8
9
  type ReactNode,
9
10
  } from 'react';
10
- import { Modal } from 'react-native';
11
+ import { Modal, View, type StyleProp, type ViewStyle } from 'react-native';
11
12
  import type { ReactNativeView } from 'react-native-nitro-modules';
12
13
  import type { NitroModalMethods, NitroModalProps } from './NitroModal.nitro';
14
+ import { clipOverflow, REFRESH_INDICATOR_SIZE } from './web/dom';
13
15
  import { ModalController } from './web/ModalController';
14
16
 
15
17
  interface WebNitroModalViewProps extends NitroModalProps {
16
18
  hybridRef?: (instance: NitroModalMethods) => void;
19
+ /** Inline: the frame the sheet fills. */
20
+ style?: StyleProp<ViewStyle>;
17
21
  children?: ReactNode;
18
22
  }
19
23
 
@@ -27,9 +31,13 @@ export function callback<T>(func: T): T {
27
31
  * Native Web's `Modal` (a portal with focus trapping, Escape handling and
28
32
  * stacking with other modals) and is laid out and animated by the presenters
29
33
  * in `./web`. In place, it renders nothing.
34
+ *
35
+ * An inline sheet renders in place instead: a view laid out with `style`,
36
+ * holding the sheet's elements.
30
37
  */
31
38
  function WebNitroModalView(props: WebNitroModalViewProps) {
32
39
  const { hybridRef, children } = props;
40
+ const isInline = props.isInline && props.mode === 'bottomSheet';
33
41
  const [mounted, setMounted] = useState(false);
34
42
  const [controller] = useState(() => new ModalController(setMounted));
35
43
  const overlay = useRef<HTMLDivElement>(null);
@@ -37,6 +45,8 @@ function WebNitroModalView(props: WebNitroModalViewProps) {
37
45
  const surface = useRef<HTMLDivElement>(null);
38
46
  const content = useRef<HTMLDivElement>(null);
39
47
  const grabber = useRef<HTMLDivElement>(null);
48
+ const refreshIndicator = useRef<HTMLDivElement>(null);
49
+ const refreshSpinner = useRef<HTMLDivElement>(null);
40
50
 
41
51
  useLayoutEffect(() => {
42
52
  controller.attach();
@@ -59,6 +69,8 @@ function WebNitroModalView(props: WebNitroModalViewProps) {
59
69
  surface: surface.current!,
60
70
  content: content.current!,
61
71
  grabber: grabber.current!,
72
+ refreshIndicator: refreshIndicator.current!,
73
+ refreshSpinner: refreshSpinner.current!,
62
74
  });
63
75
  return () => controller.elementsWillUnmount();
64
76
  }, [controller, mounted]);
@@ -69,10 +81,49 @@ function WebNitroModalView(props: WebNitroModalViewProps) {
69
81
  }
70
82
  });
71
83
 
72
- if (!mounted) return null;
84
+ const hostRef = useCallback(
85
+ (node: unknown) => {
86
+ controller.hostDidChange(node instanceof HTMLElement ? node : null);
87
+ },
88
+ [controller]
89
+ );
73
90
 
74
91
  // Styles set here never change; the presenters write sizes, positions,
75
92
  // colors and animated values straight to the elements.
93
+ const layer = mounted ? (
94
+ // Focusable so the modal's focus trap lands here first rather than on the first input.
95
+ <div
96
+ ref={overlay}
97
+ tabIndex={-1}
98
+ style={
99
+ isInline
100
+ ? { ...styles.inlineOverlay, overflow: clipOverflow() }
101
+ : styles.overlay
102
+ }
103
+ >
104
+ <div ref={backdrop} aria-hidden style={styles.backdrop} />
105
+ <div ref={refreshIndicator} aria-hidden style={styles.refreshIndicator}>
106
+ <div ref={refreshSpinner} style={styles.refreshSpinner} />
107
+ </div>
108
+ <div ref={surface} style={styles.surface}>
109
+ <div ref={content} style={styles.content}>
110
+ {children}
111
+ </div>
112
+ <div ref={grabber} aria-hidden style={styles.grabber} />
113
+ </div>
114
+ </div>
115
+ ) : null;
116
+
117
+ if (isInline) {
118
+ return (
119
+ <View ref={hostRef} style={props.style} pointerEvents="box-none">
120
+ {layer}
121
+ </View>
122
+ );
123
+ }
124
+
125
+ if (!layer) return null;
126
+
76
127
  return (
77
128
  <Modal
78
129
  visible
@@ -80,16 +131,7 @@ function WebNitroModalView(props: WebNitroModalViewProps) {
80
131
  animationType="none"
81
132
  onRequestClose={controller.handleBackPress}
82
133
  >
83
- {/* Focusable so the modal's focus trap lands here first rather than on the first input. */}
84
- <div ref={overlay} tabIndex={-1} style={styles.overlay}>
85
- <div ref={backdrop} aria-hidden style={styles.backdrop} />
86
- <div ref={surface} style={styles.surface}>
87
- <div ref={content} style={styles.content}>
88
- {children}
89
- </div>
90
- <div ref={grabber} aria-hidden style={styles.grabber} />
91
- </div>
92
- </div>
134
+ {layer}
93
135
  </Modal>
94
136
  );
95
137
  }
@@ -100,6 +142,13 @@ const styles = {
100
142
  inset: 0,
101
143
  outline: 'none',
102
144
  },
145
+ // Fills the host view; the sheet hides below its bottom edge.
146
+ inlineOverlay: {
147
+ position: 'absolute',
148
+ inset: 0,
149
+ outline: 'none',
150
+ pointerEvents: 'none',
151
+ },
103
152
  backdrop: {
104
153
  position: 'absolute',
105
154
  inset: 0,
@@ -130,6 +179,24 @@ const styles = {
130
179
  backgroundColor: 'rgba(127, 127, 127, 0.4)',
131
180
  pointerEvents: 'none',
132
181
  },
182
+ refreshIndicator: {
183
+ position: 'absolute',
184
+ left: 0,
185
+ top: 0,
186
+ width: REFRESH_INDICATOR_SIZE,
187
+ height: REFRESH_INDICATOR_SIZE,
188
+ opacity: 0,
189
+ pointerEvents: 'none',
190
+ },
191
+ // Colored through `color` by the presenter.
192
+ refreshSpinner: {
193
+ width: '100%',
194
+ height: '100%',
195
+ boxSizing: 'border-box',
196
+ borderRadius: '50%',
197
+ border: '2.5px solid currentColor',
198
+ borderTopColor: 'transparent',
199
+ },
133
200
  } satisfies Record<string, CSSProperties>;
134
201
 
135
202
  export const NitroModalView = WebNitroModalView as unknown as ReactNativeView<
package/src/index.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  NitroModal,
3
+ type ModalPresentation,
3
4
  type NitroModalProps,
4
5
  type NitroModalRef,
5
6
  } from './NitroModal';
@@ -7,6 +8,7 @@ export type {
7
8
  DismissReason,
8
9
  KeyboardBehavior,
9
10
  ModalMode,
11
+ NamedSheetDetent,
10
12
  PopupAnimation,
11
13
  SheetDetent,
12
14
  } from './NitroModal.nitro';
@@ -71,6 +71,10 @@ export class AnimatedValue {
71
71
  return this.current;
72
72
  }
73
73
 
74
+ get isAnimating(): boolean {
75
+ return this.run != null;
76
+ }
77
+
74
78
  /** Jumps to `value`, cancelling any animation. */
75
79
  set(value: number) {
76
80
  this.stop();
@@ -9,6 +9,11 @@ import type {
9
9
  /** Snapshot of the props that shape a presentation. */
10
10
  export interface ModalConfig {
11
11
  mode: ModalMode;
12
+ /**
13
+ * A `bottomSheet` rendered in place, filling the host view: no backdrop,
14
+ * the page behind stays interactive, and the user can't dismiss it.
15
+ */
16
+ isInline: boolean;
12
17
  detents: SheetDetent[];
13
18
  initialDetentIndex: number;
14
19
  /** 0xAARRGGBB, from `processColor`. */
@@ -25,11 +30,19 @@ export interface ModalConfig {
25
30
  contentBackgroundColor: number | null;
26
31
  keyboardBehavior: KeyboardBehavior;
27
32
  popupAnimation: PopupAnimation;
33
+ pullToRefreshEnabled: boolean;
34
+ /** A refresh is in progress; `null` shows no refresh indicator. */
35
+ refreshing: boolean | null;
36
+ /** 0xAARRGGBB; `null` uses the default. */
37
+ refreshIndicatorColor: number | null;
28
38
  }
29
39
 
30
40
  export function makeConfig(props: NitroModalProps): ModalConfig {
41
+ // An inline sheet is part of the page; the user can't dismiss it.
42
+ const isInline = props.isInline && props.mode === 'bottomSheet';
31
43
  return {
32
44
  mode: props.mode,
45
+ isInline,
33
46
  detents: props.detents,
34
47
  initialDetentIndex: Number.isFinite(props.initialDetentIndex)
35
48
  ? Math.trunc(props.initialDetentIndex)
@@ -37,14 +50,17 @@ export function makeConfig(props: NitroModalProps): ModalConfig {
37
50
  backdropColor: props.backdropColor,
38
51
  backdropOpacity: props.backdropOpacity,
39
52
  backdropBlurRadius: props.backdropBlurRadius,
40
- dismissOnBackdropPress: props.dismissOnBackdropPress,
41
- dismissOnSwipe: props.dismissOnSwipe,
42
- dismissOnBackButton: props.dismissOnBackButton,
53
+ dismissOnBackdropPress: props.dismissOnBackdropPress && !isInline,
54
+ dismissOnSwipe: props.dismissOnSwipe && !isInline,
55
+ dismissOnBackButton: props.dismissOnBackButton && !isInline,
43
56
  grabberVisible: props.grabberVisible,
44
57
  cornerRadius: props.cornerRadius >= 0 ? props.cornerRadius : null,
45
58
  contentBackgroundColor: props.contentBackgroundColor ?? null,
46
59
  keyboardBehavior: props.keyboardBehavior,
47
60
  popupAnimation: props.popupAnimation,
61
+ pullToRefreshEnabled: props.pullToRefreshEnabled,
62
+ refreshing: props.refreshing ?? null,
63
+ refreshIndicatorColor: props.refreshIndicatorColor ?? null,
48
64
  };
49
65
  }
50
66
 
@@ -6,7 +6,12 @@ import type {
6
6
  NitroModalMethods,
7
7
  NitroModalProps,
8
8
  } from '../NitroModal.nitro';
9
- import { canUseDOM, estimateViewport, readSafeAreaInsets } from './dom';
9
+ import {
10
+ canUseDOM,
11
+ estimateViewport,
12
+ inlineInsets,
13
+ readSafeAreaInsets,
14
+ } from './dom';
10
15
  import { isSameConfig, makeConfig, type ModalConfig } from './ModalConfig';
11
16
  import type {
12
17
  ModalElements,
@@ -58,6 +63,8 @@ export class ModalController implements PresenterListener {
58
63
  private isAttached = false;
59
64
  private presenter: ModalPresenter | null = null;
60
65
  private elements: ModalElements | null = null;
66
+ /** Inline: the element the sheet fills. */
67
+ private host: HTMLElement | null = null;
61
68
  private lastContentArea: ModalContentArea | null = null;
62
69
 
63
70
  /** `setMounted` renders (or removes) the modal's elements. */
@@ -110,6 +117,15 @@ export class ModalController implements PresenterListener {
110
117
  }
111
118
  }
112
119
 
120
+ /** Inline: the element the sheet fills, rendered in place. */
121
+ hostDidChange(host: HTMLElement | null) {
122
+ if (host === this.host) return;
123
+ this.host = host;
124
+ if (!this.presenter) {
125
+ this.reportEstimatedContentArea();
126
+ }
127
+ }
128
+
113
129
  elementsDidMount(elements: ModalElements) {
114
130
  this.elements = elements;
115
131
  if (this.presenter && !this.presenter.isShown) {
@@ -273,6 +289,10 @@ export class ModalController implements PresenterListener {
273
289
  this.props?.onDetentChange?.(index);
274
290
  }
275
291
 
292
+ onPullToRefresh() {
293
+ this.props?.onPullToRefresh?.();
294
+ }
295
+
276
296
  onContentAreaChange(width: number, height: number) {
277
297
  const area = { width: Math.floor(width), height: Math.floor(height) };
278
298
  const last = this.lastContentArea;
@@ -288,12 +308,25 @@ export class ModalController implements PresenterListener {
288
308
  private reportEstimatedContentArea() {
289
309
  const { config } = this;
290
310
  if (!canUseDOM || !this.isAttached || !config) return;
311
+ if (config.isInline) {
312
+ const { host } = this;
313
+ if (!host || host.clientWidth === 0 || host.clientHeight === 0) return;
314
+ const viewport = { width: host.clientWidth, height: host.clientHeight };
315
+ const area = sheetContentArea(
316
+ config.detents,
317
+ viewport,
318
+ inlineInsets(host),
319
+ true
320
+ );
321
+ this.onContentAreaChange(area.width, area.height);
322
+ return;
323
+ }
291
324
  const viewport = estimateViewport();
292
325
  const insets = readSafeAreaInsets();
293
326
  const area =
294
327
  config.mode === 'popup'
295
328
  ? popupContentArea(viewport, insets)
296
- : sheetContentArea(config.detents, viewport, insets);
329
+ : sheetContentArea(config.detents, viewport, insets, false);
297
330
  this.onContentAreaChange(area.width, area.height);
298
331
  }
299
332