@rn-tools/sheets 3.0.1 → 3.0.2
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/CHANGELOG.md +8 -0
- package/README.md +107 -78
- package/ios/RNToolsSheetsView.swift +90 -16
- package/mocks/expo-modules-core.mock.ts +9 -0
- package/package.json +7 -4
- package/src/index.ts +4 -1
- package/src/native-sheets-view.tsx +43 -17
- package/src/sheet-slot.tsx +70 -0
- package/src/sheets-client.test.tsx +239 -0
- package/src/sheets-client.tsx +233 -0
- package/src/sheets-provider.tsx +20 -0
- package/vitest.config.mts +25 -0
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -1,113 +1,142 @@
|
|
|
1
1
|
# @rn-tools/sheets
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Native bottom sheets for React Native + Expo with iOS `UISheetPresentationController` and Android `BottomSheetDialog`.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Install
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
## Motivation
|
|
13
|
-
|
|
14
|
-
- Better performance and responsiveness than JS based solutions
|
|
15
|
-
|
|
16
|
-
- Native OS handling for gestures, keyboard, and navigation
|
|
17
|
-
|
|
18
|
-
## Installation
|
|
19
|
-
|
|
20
|
-
`yarn add @rntools/sheets expo-build-properties`
|
|
7
|
+
```bash
|
|
8
|
+
yarn add @rn-tools/sheets expo-build-properties
|
|
9
|
+
```
|
|
21
10
|
|
|
22
|
-
|
|
11
|
+
Set iOS deployment target to `16.0` in `app.json`:
|
|
23
12
|
|
|
24
13
|
```json
|
|
25
14
|
{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
15
|
+
"plugins": [
|
|
16
|
+
[
|
|
17
|
+
"expo-build-properties",
|
|
18
|
+
{
|
|
19
|
+
"ios": {
|
|
20
|
+
"deploymentTarget": "16.0"
|
|
33
21
|
}
|
|
34
|
-
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
]
|
|
35
25
|
}
|
|
36
|
-
|
|
37
26
|
```
|
|
38
27
|
|
|
39
|
-
|
|
28
|
+
Then rebuild the native app.
|
|
29
|
+
|
|
30
|
+
## APIs
|
|
40
31
|
|
|
32
|
+
This package supports two usage styles:
|
|
41
33
|
|
|
42
|
-
|
|
34
|
+
1. Declarative `BottomSheet`
|
|
35
|
+
2. Store-driven `createSheets` + `SheetsProvider`
|
|
36
|
+
|
|
37
|
+
### Declarative `BottomSheet`
|
|
43
38
|
|
|
44
39
|
```tsx
|
|
45
|
-
import
|
|
40
|
+
import * as React from "react";
|
|
41
|
+
import { Button, View } from "react-native";
|
|
42
|
+
import { BottomSheet } from "@rn-tools/sheets";
|
|
46
43
|
|
|
47
|
-
export default function
|
|
44
|
+
export default function Example() {
|
|
48
45
|
const [isOpen, setIsOpen] = React.useState(false);
|
|
49
46
|
|
|
50
47
|
return (
|
|
51
|
-
<View
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
appearanceAndroid={{
|
|
63
|
-
dimAmount: 0,
|
|
64
|
-
cornerRadius: 32.0,
|
|
65
|
-
backgroundColor: "#ffffff",
|
|
66
|
-
}}
|
|
67
|
-
appearanceIOS={{
|
|
68
|
-
cornerRadius: 16.0,
|
|
69
|
-
grabberVisible: true,
|
|
70
|
-
backgroundColor: "#ffffff",
|
|
71
|
-
}}
|
|
72
|
-
>
|
|
73
|
-
{isOpen && <MyContent />}
|
|
74
|
-
</BottomSheet>
|
|
48
|
+
<View style={{ flex: 1 }}>
|
|
49
|
+
<Button title="Open" onPress={() => setIsOpen(true)} />
|
|
50
|
+
|
|
51
|
+
<BottomSheet
|
|
52
|
+
isOpen={isOpen}
|
|
53
|
+
setIsOpen={setIsOpen}
|
|
54
|
+
snapPoints={[300, 500]}
|
|
55
|
+
initialIndex={0}
|
|
56
|
+
>
|
|
57
|
+
<View style={{ padding: 24 }}>{/* content */}</View>
|
|
58
|
+
</BottomSheet>
|
|
75
59
|
</View>
|
|
76
60
|
);
|
|
77
61
|
}
|
|
78
62
|
```
|
|
79
63
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
- `isOpen / setIsOpen` - Controller props for toggling the sheet open and closed - this is required
|
|
83
|
-
|
|
84
|
-
- `initialIndex` - will open the bottom sheet to the defined snapPoint index
|
|
85
|
-
|
|
86
|
-
- `onStateChange` - callback to track the internal state of the sheet. The following events are emitted:
|
|
64
|
+
### Store-driven sheets
|
|
87
65
|
|
|
88
|
-
|
|
89
|
-
|
|
66
|
+
Use this for imperative sheet presentation from anywhere in your app.
|
|
67
|
+
You do not need a hook for this pattern; you can call the external sheets store directly.
|
|
90
68
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
- `snapPoints` - a list of sizes that the sheet will "snap" to
|
|
69
|
+
```tsx
|
|
70
|
+
import * as React from "react";
|
|
71
|
+
import { Button, View } from "react-native";
|
|
72
|
+
import { createSheets, SheetsProvider } from "@rn-tools/sheets";
|
|
96
73
|
|
|
97
|
-
|
|
74
|
+
const sheets = createSheets();
|
|
98
75
|
|
|
99
|
-
|
|
100
|
-
|
|
76
|
+
export default function App() {
|
|
77
|
+
return (
|
|
78
|
+
<SheetsProvider sheets={sheets}>
|
|
79
|
+
<Screen />
|
|
80
|
+
</SheetsProvider>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
101
83
|
|
|
84
|
+
function Screen() {
|
|
85
|
+
return (
|
|
86
|
+
<View>
|
|
87
|
+
<Button
|
|
88
|
+
title="Present"
|
|
89
|
+
onPress={() => {
|
|
90
|
+
sheets.present(<SheetContent />, {
|
|
91
|
+
id: "edit",
|
|
92
|
+
snapPoints: [320, 520],
|
|
93
|
+
});
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
<Button title="Dismiss" onPress={() => sheets.dismiss()} />
|
|
97
|
+
<Button title="Dismiss all" onPress={() => sheets.dismissAll()} />
|
|
98
|
+
</View>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
102
101
|
|
|
103
|
-
|
|
102
|
+
function SheetContent() {
|
|
103
|
+
return <View style={{ padding: 24 }} />;
|
|
104
|
+
}
|
|
105
|
+
```
|
|
104
106
|
|
|
105
|
-
|
|
107
|
+
`useSheets()` is still available when you prefer resolving the client from context.
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
- iOS: grabber visible, white background, system default corner radius unless set
|
|
109
|
-
- Android: white background, 32dp top corner radius, dim amount 0.56
|
|
109
|
+
## `createSheets` client
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
```ts
|
|
112
|
+
type SheetsClient = {
|
|
113
|
+
store: SheetsStore;
|
|
114
|
+
present: (element: React.ReactElement, options?: SheetOptions) => string;
|
|
115
|
+
dismiss: (id?: string) => void;
|
|
116
|
+
dismissAll: () => void;
|
|
117
|
+
};
|
|
118
|
+
```
|
|
112
119
|
|
|
113
|
-
-
|
|
120
|
+
- `present` returns a sheet key.
|
|
121
|
+
- `options.id` lets you target a logical sheet instance.
|
|
122
|
+
- `dismiss(id?)` closes by key/id, or top-most if omitted.
|
|
123
|
+
- `dismissAll()` closes all active sheets.
|
|
124
|
+
|
|
125
|
+
## `BottomSheet` props
|
|
126
|
+
|
|
127
|
+
- `isOpen`: whether the sheet should be open.
|
|
128
|
+
- `setIsOpen(next)`: called when native requests a visibility change.
|
|
129
|
+
- `snapPoints?: number[]`: snap heights (dp). Android uses first 2 only.
|
|
130
|
+
- `initialIndex?: number`: initial snap point index.
|
|
131
|
+
- `canDismiss?: boolean`: allow swipe/back dismissal (default `true`).
|
|
132
|
+
- `onDismissPrevented?: () => void`: called when dismissal is blocked.
|
|
133
|
+
- `onStateChange?: (event) => void`: emits `{ type: "OPEN" }` and `{ type: "HIDDEN" }`.
|
|
134
|
+
- `containerStyle?: ViewStyle`
|
|
135
|
+
- `appearanceIOS?: { grabberVisible?: boolean; backgroundColor?: string; cornerRadius?: number }`
|
|
136
|
+
- `appearanceAndroid?: { dimAmount?: number; backgroundColor?: string; cornerRadius?: number }`
|
|
137
|
+
|
|
138
|
+
## Notes
|
|
139
|
+
|
|
140
|
+
- If `snapPoints` is omitted, the sheet auto-sizes to measured content height.
|
|
141
|
+
- On Android, nested scroll content should use `nestedScrollEnabled` where needed.
|
|
142
|
+
- iOS uses an overlay window to host the presented sheet.
|
|
@@ -42,6 +42,15 @@ public class RNToolsSheetsView: ExpoView, RNToolsSheetsViewDelegate {
|
|
|
42
42
|
|
|
43
43
|
func updateSnapPoints(_ snapPoints: [CGFloat]) {
|
|
44
44
|
props.snapPoints = snapPoints
|
|
45
|
+
if props.isOpen {
|
|
46
|
+
sheetVC.updateSheetConfiguration(
|
|
47
|
+
openTo: props.initialIndex,
|
|
48
|
+
snapPoints: props.snapPoints,
|
|
49
|
+
grabberVisible: props.grabberVisible,
|
|
50
|
+
backgroundColor: props.backgroundColor,
|
|
51
|
+
cornerRadius: props.cornerRadius
|
|
52
|
+
)
|
|
53
|
+
}
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
func updateIsOpen(_ isOpen: Bool) {
|
|
@@ -61,6 +70,15 @@ public class RNToolsSheetsView: ExpoView, RNToolsSheetsViewDelegate {
|
|
|
61
70
|
|
|
62
71
|
func updateInitialIndex(_ initialIndex: Int) {
|
|
63
72
|
props.initialIndex = initialIndex
|
|
73
|
+
if props.isOpen {
|
|
74
|
+
sheetVC.updateSheetConfiguration(
|
|
75
|
+
openTo: props.initialIndex,
|
|
76
|
+
snapPoints: props.snapPoints,
|
|
77
|
+
grabberVisible: props.grabberVisible,
|
|
78
|
+
backgroundColor: props.backgroundColor,
|
|
79
|
+
cornerRadius: props.cornerRadius
|
|
80
|
+
)
|
|
81
|
+
}
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
func updateCanDismiss(_ canDismiss: Bool) {
|
|
@@ -75,6 +93,15 @@ public class RNToolsSheetsView: ExpoView, RNToolsSheetsViewDelegate {
|
|
|
75
93
|
props.grabberVisible = grabberVisible
|
|
76
94
|
props.backgroundColor = backgroundColor
|
|
77
95
|
props.cornerRadius = cornerRadius
|
|
96
|
+
if props.isOpen {
|
|
97
|
+
sheetVC.updateSheetConfiguration(
|
|
98
|
+
openTo: props.initialIndex,
|
|
99
|
+
snapPoints: props.snapPoints,
|
|
100
|
+
grabberVisible: props.grabberVisible,
|
|
101
|
+
backgroundColor: props.backgroundColor,
|
|
102
|
+
cornerRadius: props.cornerRadius
|
|
103
|
+
)
|
|
104
|
+
}
|
|
78
105
|
}
|
|
79
106
|
|
|
80
107
|
func handleSheetDismissed() {
|
|
@@ -165,10 +192,52 @@ final class SheetViewController: UIViewController,
|
|
|
165
192
|
backgroundColor: String?,
|
|
166
193
|
cornerRadius: Float?
|
|
167
194
|
) {
|
|
168
|
-
guard overlayWindow == nil else {
|
|
195
|
+
guard overlayWindow == nil else {
|
|
196
|
+
updateSheetConfiguration(
|
|
197
|
+
openTo: index,
|
|
198
|
+
snapPoints: snapPoints,
|
|
199
|
+
grabberVisible: grabberVisible,
|
|
200
|
+
backgroundColor: backgroundColor,
|
|
201
|
+
cornerRadius: cornerRadius
|
|
202
|
+
)
|
|
203
|
+
return
|
|
204
|
+
}
|
|
169
205
|
|
|
170
206
|
modalPresentationStyle = .pageSheet
|
|
171
207
|
|
|
208
|
+
updateSheetConfiguration(
|
|
209
|
+
openTo: index,
|
|
210
|
+
snapPoints: snapPoints,
|
|
211
|
+
grabberVisible: grabberVisible,
|
|
212
|
+
backgroundColor: backgroundColor,
|
|
213
|
+
cornerRadius: cornerRadius
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
let w = UIWindow(frame: UIScreen.main.bounds)
|
|
217
|
+
w.windowLevel = .statusBar + 2
|
|
218
|
+
w.rootViewController = UIViewController()
|
|
219
|
+
w.makeKeyAndVisible()
|
|
220
|
+
|
|
221
|
+
overlayWindow = w
|
|
222
|
+
|
|
223
|
+
let host = UIViewController()
|
|
224
|
+
host.modalPresentationStyle = .overFullScreen
|
|
225
|
+
host.view.backgroundColor = .clear
|
|
226
|
+
|
|
227
|
+
w.rootViewController?.present(host, animated: false) {
|
|
228
|
+
host.present(self, animated: true) { [weak self] in
|
|
229
|
+
self?.emitInitialOpenState(requestedIndex: index)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
func updateSheetConfiguration(
|
|
235
|
+
openTo index: Int = 0,
|
|
236
|
+
snapPoints: [CGFloat],
|
|
237
|
+
grabberVisible: Bool,
|
|
238
|
+
backgroundColor: String?,
|
|
239
|
+
cornerRadius: Float?
|
|
240
|
+
) {
|
|
172
241
|
if let sheet = sheetPresentationController {
|
|
173
242
|
sheet.delegate = self
|
|
174
243
|
sheet.prefersGrabberVisible = grabberVisible
|
|
@@ -187,21 +256,6 @@ final class SheetViewController: UIViewController,
|
|
|
187
256
|
}
|
|
188
257
|
|
|
189
258
|
view.backgroundColor = UIColor(hex: backgroundColor) ?? .white
|
|
190
|
-
|
|
191
|
-
let w = UIWindow(frame: UIScreen.main.bounds)
|
|
192
|
-
w.windowLevel = .statusBar + 2
|
|
193
|
-
w.rootViewController = UIViewController()
|
|
194
|
-
w.makeKeyAndVisible()
|
|
195
|
-
|
|
196
|
-
overlayWindow = w
|
|
197
|
-
|
|
198
|
-
let host = UIViewController()
|
|
199
|
-
host.modalPresentationStyle = .overFullScreen
|
|
200
|
-
host.view.backgroundColor = .clear
|
|
201
|
-
|
|
202
|
-
w.rootViewController?.present(host, animated: false) {
|
|
203
|
-
host.present(self, animated: true)
|
|
204
|
-
}
|
|
205
259
|
}
|
|
206
260
|
|
|
207
261
|
func dismissSheet() {
|
|
@@ -256,6 +310,26 @@ final class SheetViewController: UIViewController,
|
|
|
256
310
|
cleanup()
|
|
257
311
|
}
|
|
258
312
|
|
|
313
|
+
private func emitInitialOpenState(requestedIndex: Int) {
|
|
314
|
+
guard let sheet = sheetPresentationController else { return }
|
|
315
|
+
|
|
316
|
+
if
|
|
317
|
+
let selectedID = sheet.selectedDetentIdentifier,
|
|
318
|
+
let index = sheet.detents.firstIndex(where: { $0.identifier == selectedID })
|
|
319
|
+
{
|
|
320
|
+
delegate?.handleSheetStateChange(index: index)
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let fallbackIndex: Int
|
|
325
|
+
if sheet.detents.indices.contains(requestedIndex) {
|
|
326
|
+
fallbackIndex = requestedIndex
|
|
327
|
+
} else {
|
|
328
|
+
fallbackIndex = 0
|
|
329
|
+
}
|
|
330
|
+
delegate?.handleSheetStateChange(index: fallbackIndex)
|
|
331
|
+
}
|
|
332
|
+
|
|
259
333
|
private func makeDetents(from points: [CGFloat])
|
|
260
334
|
-> [UISheetPresentationController.Detent]
|
|
261
335
|
{
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rn-tools/sheets",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"description": "A React Native library for creating and managing native sheets in Expo applications.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"open:ios": "xed example/ios",
|
|
8
|
-
"open:android": "open -a \"Android Studio\" example/android"
|
|
8
|
+
"open:android": "open -a \"Android Studio\" example/android",
|
|
9
|
+
"test": "NODE_OPTIONS='--no-experimental-detect-module' vitest"
|
|
9
10
|
},
|
|
10
11
|
"keywords": [
|
|
11
12
|
"react-native",
|
|
@@ -21,10 +22,12 @@
|
|
|
21
22
|
"license": "MIT",
|
|
22
23
|
"homepage": "https://github.com/ajsmth/rn-tools#readme",
|
|
23
24
|
"devDependencies": {
|
|
24
|
-
"@
|
|
25
|
+
"@testing-library/react": "^14.2.1",
|
|
26
|
+
"@types/react": "18.3.12",
|
|
27
|
+
"vitest": "^1.6.0"
|
|
25
28
|
},
|
|
26
29
|
"dependencies": {
|
|
27
|
-
"@rn-tools/core": "3.0.
|
|
30
|
+
"@rn-tools/core": "3.0.2"
|
|
28
31
|
},
|
|
29
32
|
"peerDependencies": {
|
|
30
33
|
"expo": "*",
|
package/src/index.ts
CHANGED
|
@@ -22,17 +22,17 @@ type ChangeEvent<T extends SheetState, P = unknown> = {
|
|
|
22
22
|
type OpenChangeEvent = ChangeEvent<"OPEN", { index: number }>;
|
|
23
23
|
type HiddenChangeEvent = ChangeEvent<"HIDDEN">;
|
|
24
24
|
|
|
25
|
-
type SheetChangeEvent = OpenChangeEvent | HiddenChangeEvent;
|
|
25
|
+
export type SheetChangeEvent = OpenChangeEvent | HiddenChangeEvent;
|
|
26
26
|
|
|
27
27
|
type NativeOnChangeEvent = NativeSyntheticEvent<SheetChangeEvent>;
|
|
28
28
|
|
|
29
|
-
type AppearanceIOS = {
|
|
29
|
+
export type AppearanceIOS = {
|
|
30
30
|
grabberVisible?: boolean;
|
|
31
31
|
backgroundColor?: string;
|
|
32
32
|
cornerRadius?: number;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
type AppearanceAndroid = {
|
|
35
|
+
export type AppearanceAndroid = {
|
|
36
36
|
dimAmount?: number;
|
|
37
37
|
cornerRadius?: number;
|
|
38
38
|
backgroundColor?: string;
|
|
@@ -61,6 +61,7 @@ export type BottomSheetProps = {
|
|
|
61
61
|
isOpen: boolean;
|
|
62
62
|
initialIndex?: number;
|
|
63
63
|
setIsOpen: (isOpen: boolean) => void;
|
|
64
|
+
onDismissed?: () => void;
|
|
64
65
|
canDismiss?: boolean;
|
|
65
66
|
onDismissPrevented?: () => void;
|
|
66
67
|
onStateChange?: (event: SheetChangeEvent) => void;
|
|
@@ -77,6 +78,7 @@ export function BottomSheet(props: BottomSheetProps) {
|
|
|
77
78
|
isOpen,
|
|
78
79
|
initialIndex = 0,
|
|
79
80
|
setIsOpen,
|
|
81
|
+
onDismissed,
|
|
80
82
|
appearanceAndroid,
|
|
81
83
|
appearanceIOS,
|
|
82
84
|
canDismiss = true,
|
|
@@ -97,13 +99,15 @@ export function BottomSheet(props: BottomSheetProps) {
|
|
|
97
99
|
y: 0,
|
|
98
100
|
});
|
|
99
101
|
|
|
102
|
+
const hasOpened = React.useRef(false);
|
|
103
|
+
|
|
100
104
|
const computedSnapPoints = React.useMemo(() => {
|
|
101
105
|
if (snapPoints.length === 0) {
|
|
102
106
|
if (layout.height === 0) {
|
|
103
107
|
return [];
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
return [Math.min(layout.height, maxSheetHeight)];
|
|
110
|
+
return [Math.round(Math.min(layout.height, maxSheetHeight))];
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
let effectiveSnapPoints =
|
|
@@ -122,7 +126,7 @@ export function BottomSheet(props: BottomSheetProps) {
|
|
|
122
126
|
];
|
|
123
127
|
}
|
|
124
128
|
|
|
125
|
-
return effectiveSnapPoints;
|
|
129
|
+
return effectiveSnapPoints.map((snapPoint) => Math.round(snapPoint));
|
|
126
130
|
}, [layout.height, maxSheetHeight, snapPoints]);
|
|
127
131
|
|
|
128
132
|
const maxHeight = React.useMemo(
|
|
@@ -143,20 +147,47 @@ export function BottomSheet(props: BottomSheetProps) {
|
|
|
143
147
|
};
|
|
144
148
|
}, [maxHeight, containerStyle]);
|
|
145
149
|
|
|
150
|
+
const computedIsOpen = React.useMemo(
|
|
151
|
+
() => isOpen && computedSnapPoints.length > 0,
|
|
152
|
+
[isOpen, computedSnapPoints],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const notifyDismissed = React.useCallback(
|
|
156
|
+
() => {
|
|
157
|
+
if (hasOpened.current) {
|
|
158
|
+
setIsOpen(false);
|
|
159
|
+
}
|
|
160
|
+
onDismissed?.();
|
|
161
|
+
hasOpened.current = false;
|
|
162
|
+
},
|
|
163
|
+
[setIsOpen, onDismissed],
|
|
164
|
+
);
|
|
165
|
+
|
|
146
166
|
const handleOnDismiss = React.useCallback(() => {
|
|
147
|
-
|
|
148
|
-
}, []);
|
|
167
|
+
notifyDismissed();
|
|
168
|
+
}, [notifyDismissed]);
|
|
149
169
|
|
|
150
170
|
const handleStateChange = React.useCallback(
|
|
151
171
|
(event: NativeOnChangeEvent) => {
|
|
172
|
+
if (event.nativeEvent.type === "OPEN") {
|
|
173
|
+
hasOpened.current = true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (event.nativeEvent.type === "HIDDEN") {
|
|
177
|
+
notifyDismissed();
|
|
178
|
+
}
|
|
179
|
+
|
|
152
180
|
onStateChange?.(event.nativeEvent);
|
|
153
181
|
},
|
|
154
|
-
[onStateChange],
|
|
182
|
+
[onStateChange, notifyDismissed],
|
|
155
183
|
);
|
|
156
184
|
|
|
157
|
-
const handleLayout = React.useCallback(
|
|
158
|
-
|
|
159
|
-
|
|
185
|
+
const handleLayout = React.useCallback(
|
|
186
|
+
(event: LayoutChangeEvent) => {
|
|
187
|
+
setLayout(event.nativeEvent.layout);
|
|
188
|
+
},
|
|
189
|
+
[],
|
|
190
|
+
);
|
|
160
191
|
|
|
161
192
|
const handleDismissWithChanges = React.useCallback(() => {
|
|
162
193
|
onDismissPrevented?.();
|
|
@@ -168,14 +199,9 @@ export function BottomSheet(props: BottomSheetProps) {
|
|
|
168
199
|
);
|
|
169
200
|
|
|
170
201
|
const pointerEvents = React.useMemo(() => {
|
|
171
|
-
return isOpen ? "
|
|
202
|
+
return isOpen ? "auto" : "none";
|
|
172
203
|
}, [isOpen]);
|
|
173
204
|
|
|
174
|
-
const computedIsOpen = React.useMemo(
|
|
175
|
-
() => isOpen && computedSnapPoints.length > 0,
|
|
176
|
-
[isOpen, computedSnapPoints],
|
|
177
|
-
);
|
|
178
|
-
|
|
179
205
|
const innerStyle = React.useMemo(
|
|
180
206
|
() => (isAutosized ? undefined : StyleSheet.absoluteFill),
|
|
181
207
|
[isAutosized],
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useStore } from "@rn-tools/core";
|
|
3
|
+
import { BottomSheet } from "./native-sheets-view";
|
|
4
|
+
import type { SheetChangeEvent } from "./native-sheets-view";
|
|
5
|
+
import { SheetsContext, SheetsStoreContext } from "./sheets-client";
|
|
6
|
+
import type { SheetEntry } from "./sheets-client";
|
|
7
|
+
|
|
8
|
+
function SheetSlotEntry({ entry }: { entry: SheetEntry }) {
|
|
9
|
+
const sheets = React.useContext(SheetsContext);
|
|
10
|
+
const isOpen = entry.status !== "closing";
|
|
11
|
+
|
|
12
|
+
const handleStateChange = React.useCallback(
|
|
13
|
+
(event: SheetChangeEvent) => {
|
|
14
|
+
if (event.type === "OPEN") {
|
|
15
|
+
sheets?.markDidOpen(entry.key);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (event.type === "HIDDEN") {
|
|
19
|
+
sheets?.markDidDismiss(entry.key);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
entry.options.onStateChange?.(event);
|
|
23
|
+
},
|
|
24
|
+
[sheets, entry.key, entry.options.onStateChange],
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const handleSetIsOpen = React.useCallback(
|
|
28
|
+
(nextIsOpen: boolean) => {
|
|
29
|
+
if (!nextIsOpen) {
|
|
30
|
+
sheets?.dismiss(entry.key);
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
[sheets, entry.key],
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const handleDismissed = React.useCallback(() => {
|
|
37
|
+
sheets?.markDidDismiss(entry.key);
|
|
38
|
+
}, [sheets, entry.key]);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<BottomSheet
|
|
42
|
+
isOpen={isOpen}
|
|
43
|
+
setIsOpen={handleSetIsOpen}
|
|
44
|
+
onDismissed={handleDismissed}
|
|
45
|
+
snapPoints={entry.options.snapPoints}
|
|
46
|
+
initialIndex={entry.options.initialIndex}
|
|
47
|
+
canDismiss={entry.options.canDismiss}
|
|
48
|
+
onDismissPrevented={entry.options.onDismissPrevented}
|
|
49
|
+
onStateChange={handleStateChange}
|
|
50
|
+
containerStyle={entry.options.containerStyle}
|
|
51
|
+
appearanceAndroid={entry.options.appearanceAndroid}
|
|
52
|
+
appearanceIOS={entry.options.appearanceIOS}
|
|
53
|
+
>
|
|
54
|
+
{entry.element}
|
|
55
|
+
</BottomSheet>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function SheetSlot() {
|
|
60
|
+
const store = React.useContext(SheetsStoreContext);
|
|
61
|
+
const sheets = useStore(store, (state) => state.sheets);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
{sheets.map((entry) => (
|
|
66
|
+
<SheetSlotEntry key={entry.key} entry={entry} />
|
|
67
|
+
))}
|
|
68
|
+
</>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { createSheets } from "./sheets-client";
|
|
4
|
+
|
|
5
|
+
describe("createSheets", () => {
|
|
6
|
+
it("returns the expected client API", () => {
|
|
7
|
+
const sheets = createSheets();
|
|
8
|
+
|
|
9
|
+
expect(sheets.store).toBeDefined();
|
|
10
|
+
expect(typeof sheets.present).toBe("function");
|
|
11
|
+
expect(typeof sheets.dismiss).toBe("function");
|
|
12
|
+
expect(typeof sheets.dismissAll).toBe("function");
|
|
13
|
+
expect(typeof sheets.remove).toBe("function");
|
|
14
|
+
expect(typeof sheets.markDidOpen).toBe("function");
|
|
15
|
+
expect(typeof sheets.markDidDismiss).toBe("function");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("starts with empty state", () => {
|
|
19
|
+
const sheets = createSheets();
|
|
20
|
+
expect(sheets.store.getState().sheets).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("present", () => {
|
|
25
|
+
it("adds a new sheet in opening state", () => {
|
|
26
|
+
const sheets = createSheets();
|
|
27
|
+
const key = sheets.present(<span>hello</span>);
|
|
28
|
+
|
|
29
|
+
const state = sheets.store.getState().sheets;
|
|
30
|
+
expect(typeof key).toBe("string");
|
|
31
|
+
expect(state).toHaveLength(1);
|
|
32
|
+
expect(state[0].key).toBe(key);
|
|
33
|
+
expect(state[0].status).toBe("opening");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("stores element and options", () => {
|
|
37
|
+
const sheets = createSheets();
|
|
38
|
+
const element = <span>content</span>;
|
|
39
|
+
const options = { id: "edit", snapPoints: [300, 500] };
|
|
40
|
+
|
|
41
|
+
sheets.present(element, options);
|
|
42
|
+
|
|
43
|
+
const entry = sheets.store.getState().sheets[0];
|
|
44
|
+
expect(entry.element).toBe(element);
|
|
45
|
+
expect(entry.options).toBe(options);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("reuses key and replaces entry when id already exists", () => {
|
|
49
|
+
const sheets = createSheets();
|
|
50
|
+
|
|
51
|
+
const key1 = sheets.present(<span>a</span>, { id: "edit", snapPoints: [240] });
|
|
52
|
+
sheets.markDidOpen(key1);
|
|
53
|
+
|
|
54
|
+
const key2 = sheets.present(<span>b</span>, { id: "edit", snapPoints: [320] });
|
|
55
|
+
|
|
56
|
+
const state = sheets.store.getState().sheets;
|
|
57
|
+
expect(key2).toBe(key1);
|
|
58
|
+
expect(state).toHaveLength(1);
|
|
59
|
+
expect(state[0].key).toBe(key1);
|
|
60
|
+
expect(state[0].status).toBe("opening");
|
|
61
|
+
expect(state[0].options.snapPoints).toEqual([320]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("markDidOpen", () => {
|
|
66
|
+
it("transitions opening to open", () => {
|
|
67
|
+
const sheets = createSheets();
|
|
68
|
+
const key = sheets.present(<span>a</span>);
|
|
69
|
+
|
|
70
|
+
sheets.markDidOpen(key);
|
|
71
|
+
|
|
72
|
+
expect(sheets.store.getState().sheets[0].status).toBe("open");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("is a no-op for closing sheets", () => {
|
|
76
|
+
const sheets = createSheets();
|
|
77
|
+
const key = sheets.present(<span>a</span>);
|
|
78
|
+
sheets.dismiss(key);
|
|
79
|
+
|
|
80
|
+
const before = sheets.store.getState();
|
|
81
|
+
sheets.markDidOpen(key);
|
|
82
|
+
|
|
83
|
+
expect(sheets.store.getState()).toBe(before);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("is a no-op for unknown key", () => {
|
|
87
|
+
const sheets = createSheets();
|
|
88
|
+
const before = sheets.store.getState();
|
|
89
|
+
|
|
90
|
+
sheets.markDidOpen("missing");
|
|
91
|
+
|
|
92
|
+
expect(sheets.store.getState()).toBe(before);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("dismiss", () => {
|
|
97
|
+
it("marks the top non-closing sheet as closing", () => {
|
|
98
|
+
const sheets = createSheets();
|
|
99
|
+
const keyA = sheets.present(<span>a</span>);
|
|
100
|
+
const keyB = sheets.present(<span>b</span>);
|
|
101
|
+
sheets.markDidOpen(keyA);
|
|
102
|
+
sheets.markDidOpen(keyB);
|
|
103
|
+
|
|
104
|
+
sheets.dismiss();
|
|
105
|
+
|
|
106
|
+
const state = sheets.store.getState().sheets;
|
|
107
|
+
expect(state[0].status).toBe("open");
|
|
108
|
+
expect(state[1].status).toBe("closing");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("can dismiss by id", () => {
|
|
112
|
+
const sheets = createSheets();
|
|
113
|
+
const key = sheets.present(<span>a</span>, { id: "edit" });
|
|
114
|
+
sheets.markDidOpen(key);
|
|
115
|
+
|
|
116
|
+
sheets.dismiss("edit");
|
|
117
|
+
|
|
118
|
+
expect(sheets.store.getState().sheets[0].status).toBe("closing");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("can dismiss by key", () => {
|
|
122
|
+
const sheets = createSheets();
|
|
123
|
+
const key = sheets.present(<span>a</span>);
|
|
124
|
+
sheets.markDidOpen(key);
|
|
125
|
+
|
|
126
|
+
sheets.dismiss(key);
|
|
127
|
+
|
|
128
|
+
expect(sheets.store.getState().sheets[0].status).toBe("closing");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("is a no-op when empty", () => {
|
|
132
|
+
const sheets = createSheets();
|
|
133
|
+
const before = sheets.store.getState();
|
|
134
|
+
|
|
135
|
+
sheets.dismiss();
|
|
136
|
+
|
|
137
|
+
expect(sheets.store.getState()).toBe(before);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("is a no-op for unknown id", () => {
|
|
141
|
+
const sheets = createSheets();
|
|
142
|
+
sheets.present(<span>a</span>);
|
|
143
|
+
const before = sheets.store.getState();
|
|
144
|
+
|
|
145
|
+
sheets.dismiss("missing");
|
|
146
|
+
|
|
147
|
+
expect(sheets.store.getState()).toBe(before);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("markDidDismiss", () => {
|
|
152
|
+
it("removes a closing sheet", () => {
|
|
153
|
+
const sheets = createSheets();
|
|
154
|
+
const key = sheets.present(<span>a</span>);
|
|
155
|
+
sheets.markDidOpen(key);
|
|
156
|
+
sheets.dismiss(key);
|
|
157
|
+
|
|
158
|
+
sheets.markDidDismiss(key);
|
|
159
|
+
|
|
160
|
+
expect(sheets.store.getState().sheets).toHaveLength(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("does not remove opening sheet", () => {
|
|
164
|
+
const sheets = createSheets();
|
|
165
|
+
const key = sheets.present(<span>a</span>);
|
|
166
|
+
const before = sheets.store.getState();
|
|
167
|
+
|
|
168
|
+
sheets.markDidDismiss(key);
|
|
169
|
+
|
|
170
|
+
expect(sheets.store.getState()).toBe(before);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("is a no-op for unknown key", () => {
|
|
174
|
+
const sheets = createSheets();
|
|
175
|
+
const before = sheets.store.getState();
|
|
176
|
+
|
|
177
|
+
sheets.markDidDismiss("missing");
|
|
178
|
+
|
|
179
|
+
expect(sheets.store.getState()).toBe(before);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("dismissAll", () => {
|
|
184
|
+
it("marks every non-closing sheet as closing", () => {
|
|
185
|
+
const sheets = createSheets();
|
|
186
|
+
const keyA = sheets.present(<span>a</span>);
|
|
187
|
+
const keyB = sheets.present(<span>b</span>);
|
|
188
|
+
const keyC = sheets.present(<span>c</span>);
|
|
189
|
+
sheets.markDidOpen(keyA);
|
|
190
|
+
sheets.markDidOpen(keyB);
|
|
191
|
+
sheets.markDidOpen(keyC);
|
|
192
|
+
sheets.dismiss(keyB);
|
|
193
|
+
|
|
194
|
+
sheets.dismissAll();
|
|
195
|
+
|
|
196
|
+
const state = sheets.store.getState().sheets;
|
|
197
|
+
expect(state).toHaveLength(3);
|
|
198
|
+
expect(state.every((entry) => entry.status === "closing")).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("is a no-op when empty", () => {
|
|
202
|
+
const sheets = createSheets();
|
|
203
|
+
const before = sheets.store.getState();
|
|
204
|
+
|
|
205
|
+
sheets.dismissAll();
|
|
206
|
+
|
|
207
|
+
expect(sheets.store.getState()).toBe(before);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("remove", () => {
|
|
212
|
+
it("removes by key", () => {
|
|
213
|
+
const sheets = createSheets();
|
|
214
|
+
const key = sheets.present(<span>a</span>);
|
|
215
|
+
|
|
216
|
+
sheets.remove(key);
|
|
217
|
+
|
|
218
|
+
expect(sheets.store.getState().sheets).toHaveLength(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("removes by id", () => {
|
|
222
|
+
const sheets = createSheets();
|
|
223
|
+
sheets.present(<span>a</span>, { id: "edit" });
|
|
224
|
+
|
|
225
|
+
sheets.remove("edit");
|
|
226
|
+
|
|
227
|
+
expect(sheets.store.getState().sheets).toHaveLength(0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("is a no-op when no match", () => {
|
|
231
|
+
const sheets = createSheets();
|
|
232
|
+
sheets.present(<span>a</span>);
|
|
233
|
+
const before = sheets.store.getState();
|
|
234
|
+
|
|
235
|
+
sheets.remove("missing");
|
|
236
|
+
|
|
237
|
+
expect(sheets.store.getState()).toBe(before);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { createStore } from "@rn-tools/core";
|
|
3
|
+
import type { Store } from "@rn-tools/core";
|
|
4
|
+
import type {
|
|
5
|
+
AppearanceAndroid,
|
|
6
|
+
AppearanceIOS,
|
|
7
|
+
SheetChangeEvent,
|
|
8
|
+
} from "./native-sheets-view";
|
|
9
|
+
import type { ViewStyle } from "react-native";
|
|
10
|
+
|
|
11
|
+
export type SheetOptions = {
|
|
12
|
+
id?: string;
|
|
13
|
+
snapPoints?: number[];
|
|
14
|
+
initialIndex?: number;
|
|
15
|
+
canDismiss?: boolean;
|
|
16
|
+
onDismissPrevented?: () => void;
|
|
17
|
+
onStateChange?: (event: SheetChangeEvent) => void;
|
|
18
|
+
containerStyle?: ViewStyle;
|
|
19
|
+
appearanceAndroid?: AppearanceAndroid;
|
|
20
|
+
appearanceIOS?: AppearanceIOS;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SheetStatus = "opening" | "open" | "closing";
|
|
24
|
+
|
|
25
|
+
export type SheetEntry = {
|
|
26
|
+
key: string;
|
|
27
|
+
element: React.ReactElement;
|
|
28
|
+
options: SheetOptions;
|
|
29
|
+
status: SheetStatus;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type SheetsState = {
|
|
33
|
+
sheets: SheetEntry[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type SheetsStore = Store<SheetsState>;
|
|
37
|
+
|
|
38
|
+
export type SheetsClient = {
|
|
39
|
+
store: SheetsStore;
|
|
40
|
+
present: (element: React.ReactElement, options?: SheetOptions) => string;
|
|
41
|
+
dismiss: (id?: string) => void;
|
|
42
|
+
dismissAll: () => void;
|
|
43
|
+
remove: (id: string) => void;
|
|
44
|
+
markDidOpen: (key: string) => void;
|
|
45
|
+
markDidDismiss: (key: string) => void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const SheetsContext = React.createContext<SheetsClient | null>(null);
|
|
49
|
+
export const SheetsStoreContext = React.createContext<SheetsStore | null>(null);
|
|
50
|
+
|
|
51
|
+
let counter = 0;
|
|
52
|
+
|
|
53
|
+
export function createSheets(): SheetsClient {
|
|
54
|
+
const store = createStore<SheetsState>({ sheets: [] });
|
|
55
|
+
|
|
56
|
+
function present(
|
|
57
|
+
element: React.ReactElement,
|
|
58
|
+
options: SheetOptions = {},
|
|
59
|
+
): string {
|
|
60
|
+
const generatedKey = `sheet-${++counter}`;
|
|
61
|
+
let presentedKey = generatedKey;
|
|
62
|
+
|
|
63
|
+
store.setState((prev) => {
|
|
64
|
+
if (options.id == null) {
|
|
65
|
+
return {
|
|
66
|
+
...prev,
|
|
67
|
+
sheets: [
|
|
68
|
+
...prev.sheets,
|
|
69
|
+
{ key: generatedKey, element, options, status: "opening" },
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const duplicateIndex = prev.sheets.findIndex(
|
|
75
|
+
(entry) => entry.options.id === options.id,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (duplicateIndex === -1) {
|
|
79
|
+
return {
|
|
80
|
+
...prev,
|
|
81
|
+
sheets: [
|
|
82
|
+
...prev.sheets,
|
|
83
|
+
{ key: generatedKey, element, options, status: "opening" },
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const duplicate = prev.sheets[duplicateIndex];
|
|
89
|
+
presentedKey = duplicate.key;
|
|
90
|
+
const nextEntry: SheetEntry = {
|
|
91
|
+
key: duplicate.key,
|
|
92
|
+
element,
|
|
93
|
+
options,
|
|
94
|
+
status: "opening",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const withoutDuplicate = prev.sheets.filter((_, i) => i !== duplicateIndex);
|
|
98
|
+
return {
|
|
99
|
+
...prev,
|
|
100
|
+
sheets: [...withoutDuplicate, nextEntry],
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return presentedKey;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dismiss(id?: string) {
|
|
108
|
+
store.setState((prev) => {
|
|
109
|
+
if (prev.sheets.length === 0) return prev;
|
|
110
|
+
|
|
111
|
+
let targetIndex = -1;
|
|
112
|
+
|
|
113
|
+
if (id == null) {
|
|
114
|
+
for (let i = prev.sheets.length - 1; i >= 0; i--) {
|
|
115
|
+
if (prev.sheets[i].status !== "closing") {
|
|
116
|
+
targetIndex = i;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
targetIndex = prev.sheets.findIndex(
|
|
122
|
+
(entry) => entry.options.id === id || entry.key === id,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (targetIndex === -1) return prev;
|
|
127
|
+
|
|
128
|
+
const entry = prev.sheets[targetIndex];
|
|
129
|
+
if (entry.status === "closing") return prev;
|
|
130
|
+
|
|
131
|
+
const sheets = [...prev.sheets];
|
|
132
|
+
sheets[targetIndex] = { ...entry, status: "closing" };
|
|
133
|
+
return { ...prev, sheets };
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function dismissAll() {
|
|
139
|
+
store.setState((prev) => {
|
|
140
|
+
if (prev.sheets.length === 0) return prev;
|
|
141
|
+
|
|
142
|
+
let changed = false;
|
|
143
|
+
const sheets = prev.sheets.map((entry) => {
|
|
144
|
+
if (entry.status === "closing") return entry;
|
|
145
|
+
changed = true;
|
|
146
|
+
return { ...entry, status: "closing" as const };
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!changed) return prev;
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
...prev,
|
|
153
|
+
sheets,
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function remove(id: string) {
|
|
160
|
+
store.setState((prev) => {
|
|
161
|
+
const targetIndex = prev.sheets.findIndex(
|
|
162
|
+
(entry) => entry.options.id === id || entry.key === id,
|
|
163
|
+
);
|
|
164
|
+
if (targetIndex === -1) return prev;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
...prev,
|
|
168
|
+
sheets: prev.sheets.filter((_, i) => i !== targetIndex),
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function markDidOpen(key: string) {
|
|
175
|
+
store.setState((prev) => {
|
|
176
|
+
const index = prev.sheets.findIndex((entry) => entry.key === key);
|
|
177
|
+
if (index === -1) return prev;
|
|
178
|
+
|
|
179
|
+
const entry = prev.sheets[index];
|
|
180
|
+
if (entry.status !== "opening") return prev;
|
|
181
|
+
|
|
182
|
+
const sheets = [...prev.sheets];
|
|
183
|
+
sheets[index] = { ...entry, status: "open" };
|
|
184
|
+
return { ...prev, sheets };
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function markDidDismiss(key: string) {
|
|
190
|
+
store.setState((prev) => {
|
|
191
|
+
const index = prev.sheets.findIndex((entry) => entry.key === key);
|
|
192
|
+
if (index === -1) return prev;
|
|
193
|
+
|
|
194
|
+
const entry = prev.sheets[index];
|
|
195
|
+
if (entry.status !== "closing") {
|
|
196
|
+
// Ignore dismiss notifications unless close was requested.
|
|
197
|
+
return prev;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
...prev,
|
|
202
|
+
sheets: prev.sheets.filter((_, i) => i !== index),
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
store,
|
|
210
|
+
present,
|
|
211
|
+
dismiss,
|
|
212
|
+
dismissAll,
|
|
213
|
+
remove,
|
|
214
|
+
markDidOpen,
|
|
215
|
+
markDidDismiss,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function useSheets(): SheetsClient {
|
|
220
|
+
const sheets = React.useContext(SheetsContext);
|
|
221
|
+
if (!sheets) {
|
|
222
|
+
throw new Error("SheetsProvider is missing from the component tree.");
|
|
223
|
+
}
|
|
224
|
+
return sheets;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function useSheetsStore(): SheetsStore {
|
|
228
|
+
const store = React.useContext(SheetsStoreContext);
|
|
229
|
+
if (!store) {
|
|
230
|
+
throw new Error("SheetsProvider is missing from the component tree.");
|
|
231
|
+
}
|
|
232
|
+
return store;
|
|
233
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { SheetsContext, SheetsStoreContext } from "./sheets-client";
|
|
3
|
+
import type { SheetsClient } from "./sheets-client";
|
|
4
|
+
import { SheetSlot } from "./sheet-slot";
|
|
5
|
+
|
|
6
|
+
export type SheetsProviderProps = {
|
|
7
|
+
sheets: SheetsClient;
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function SheetsProvider({ sheets, children }: SheetsProviderProps) {
|
|
12
|
+
return (
|
|
13
|
+
<SheetsContext.Provider value={sheets}>
|
|
14
|
+
<SheetsStoreContext.Provider value={sheets.store}>
|
|
15
|
+
{children}
|
|
16
|
+
<SheetSlot />
|
|
17
|
+
</SheetsStoreContext.Provider>
|
|
18
|
+
</SheetsContext.Provider>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { defineConfig } from "vitest/config";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
resolve: {
|
|
9
|
+
alias: {
|
|
10
|
+
"expo-modules-core": path.resolve(
|
|
11
|
+
__dirname,
|
|
12
|
+
"mocks/expo-modules-core.mock.ts",
|
|
13
|
+
),
|
|
14
|
+
"react-native": path.resolve(
|
|
15
|
+
__dirname,
|
|
16
|
+
"../core/mocks/react-native.mock.ts",
|
|
17
|
+
),
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
test: {
|
|
21
|
+
environment: "jsdom",
|
|
22
|
+
globals: true,
|
|
23
|
+
setupFiles: [path.resolve(__dirname, "../core/mocks/setup.ts")],
|
|
24
|
+
},
|
|
25
|
+
});
|