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.
- package/README.md +52 -4
- package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +399 -34
- package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +67 -22
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalConfig.kt +35 -1
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalContentRoot.kt +48 -5
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalPresenter.kt +63 -4
- package/android/src/main/java/com/margelo/nitro/nitromodal/NitroModalHostView.kt +68 -8
- package/ios/HybridNitroModal.swift +115 -20
- package/ios/ModalConfig.swift +48 -2
- package/ios/NitroModalHostView.swift +73 -8
- package/ios/SheetView.swift +647 -0
- package/ios/SheetViewController.swift +19 -450
- package/lib/module/NitroModal.js +54 -6
- package/lib/module/NitroModal.js.map +1 -1
- package/lib/module/NitroModalView.web.js +86 -26
- package/lib/module/NitroModalView.web.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/web/AnimatedValue.js +3 -0
- package/lib/module/web/AnimatedValue.js.map +1 -1
- package/lib/module/web/ModalConfig.js +10 -4
- package/lib/module/web/ModalConfig.js.map +1 -1
- package/lib/module/web/ModalController.js +29 -2
- package/lib/module/web/ModalController.js.map +1 -1
- package/lib/module/web/ModalPresenter.js +47 -12
- package/lib/module/web/ModalPresenter.js.map +1 -1
- package/lib/module/web/SheetPresenter.js +118 -34
- package/lib/module/web/SheetPresenter.js.map +1 -1
- package/lib/module/web/dom.js +16 -0
- package/lib/module/web/dom.js.map +1 -1
- package/lib/typescript/src/NitroModal.d.ts +42 -2
- package/lib/typescript/src/NitroModal.d.ts.map +1 -1
- package/lib/typescript/src/NitroModal.nitro.d.ts +37 -2
- package/lib/typescript/src/NitroModal.nitro.d.ts.map +1 -1
- package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/web/AnimatedValue.d.ts +1 -0
- package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -1
- package/lib/typescript/src/web/ModalConfig.d.ts +10 -0
- package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -1
- package/lib/typescript/src/web/ModalController.d.ts +5 -0
- package/lib/typescript/src/web/ModalController.d.ts.map +1 -1
- package/lib/typescript/src/web/ModalPresenter.d.ts +13 -0
- package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -1
- package/lib/typescript/src/web/SheetPresenter.d.ts +13 -2
- package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -1
- package/lib/typescript/src/web/dom.d.ts +4 -0
- package/lib/typescript/src/web/dom.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridNitroModalSpec.cpp +78 -6
- package/nitrogen/generated/android/c++/JHybridNitroModalSpec.hpp +14 -2
- package/nitrogen/generated/android/c++/JNamedSheetDetent.hpp +64 -0
- package/nitrogen/generated/android/c++/JSheetDetent.cpp +26 -0
- package/nitrogen/generated/android/c++/JSheetDetent.hpp +42 -37
- package/nitrogen/generated/android/c++/views/JHybridNitroModalStateUpdater.cpp +30 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/HybridNitroModalSpec.kt +52 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/NamedSheetDetent.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/SheetDetent.kt +47 -10
- package/nitrogen/generated/android/nitromodal+autolinking.cmake +1 -0
- package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Bridge.hpp +68 -8
- package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Umbrella.hpp +4 -3
- package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.hpp +46 -5
- package/nitrogen/generated/ios/c++/views/HybridNitroModalComponent.mm +36 -0
- package/nitrogen/generated/ios/swift/HybridNitroModalSpec.swift +6 -0
- package/nitrogen/generated/ios/swift/HybridNitroModalSpec_cxx.swift +158 -5
- package/nitrogen/generated/ios/swift/NamedSheetDetent.swift +48 -0
- package/nitrogen/generated/ios/swift/SheetDetent.swift +15 -33
- package/nitrogen/generated/shared/c++/HybridNitroModalSpec.cpp +12 -0
- package/nitrogen/generated/shared/c++/HybridNitroModalSpec.hpp +18 -5
- package/nitrogen/generated/shared/c++/{SheetDetent.hpp → NamedSheetDetent.hpp} +17 -17
- package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.cpp +13 -1
- package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.hpp +21 -2
- package/nitrogen/generated/shared/json/NitroModalConfig.json +6 -0
- package/package.json +1 -1
- package/src/NitroModal.nitro.ts +38 -2
- package/src/NitroModal.tsx +93 -4
- package/src/NitroModalView.web.tsx +79 -12
- package/src/index.tsx +2 -0
- package/src/web/AnimatedValue.ts +4 -0
- package/src/web/ModalConfig.ts +19 -3
- package/src/web/ModalController.ts +35 -2
- package/src/web/ModalPresenter.ts +50 -11
- package/src/web/SheetPresenter.ts +169 -37
- package/src/web/dom.ts +20 -0
package/src/NitroModal.nitro.ts
CHANGED
|
@@ -13,13 +13,20 @@ import type {
|
|
|
13
13
|
export type ModalMode = 'bottomSheet' | 'popup';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
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
|
|
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 {
|
package/src/NitroModal.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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(','))]
|
|
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={[
|
|
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
|
-
|
|
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
|
-
{
|
|
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';
|
package/src/web/AnimatedValue.ts
CHANGED
package/src/web/ModalConfig.ts
CHANGED
|
@@ -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 {
|
|
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
|
|