@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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 723c379: Incorporate sheets into navigation and cleanup APIs
8
+ - Updated dependencies [723c379]
9
+ - @rn-tools/core@3.0.2
10
+
3
11
  ## 3.0.1
4
12
 
5
13
  ### Patch Changes
package/README.md CHANGED
@@ -1,113 +1,142 @@
1
1
  # @rn-tools/sheets
2
2
 
3
- An expo module for rendering native bottom sheet components in iOS and Android.
3
+ Native bottom sheets for React Native + Expo with iOS `UISheetPresentationController` and Android `BottomSheetDialog`.
4
4
 
5
- Uses native iOS sheet presentation and Android's BottomSheetDialog to render React Native children in a modal bottom sheet.
5
+ ## Install
6
6
 
7
- Supports stacking multiple sheets on top of each other
8
-
9
- https://github.com/user-attachments/assets/426c77e6-74c6-4748-8010-477267fa9433
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
- Update your minimum iOS deployment target to 16 in `app.json`:
11
+ Set iOS deployment target to `16.0` in `app.json`:
23
12
 
24
13
  ```json
25
14
  {
26
- "plugins": [
27
- [
28
- "expo-build-properties",
29
- {
30
- "ios": {
31
- "deploymentTarget": "16.0"
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
- As with most non-core expo modules this requires a new native build
28
+ Then rebuild the native app.
29
+
30
+ ## APIs
40
31
 
32
+ This package supports two usage styles:
41
33
 
42
- ## Usage
34
+ 1. Declarative `BottomSheet`
35
+ 2. Store-driven `createSheets` + `SheetsProvider`
36
+
37
+ ### Declarative `BottomSheet`
43
38
 
44
39
  ```tsx
45
- import { BottomSheet } from '@rn-tools/sheets'
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 App() {
44
+ export default function Example() {
48
45
  const [isOpen, setIsOpen] = React.useState(false);
49
46
 
50
47
  return (
51
- <View className="flex-1">
52
- <Button title="Show sheet" onPress={() => setIsOpen(true)} />
53
-
54
- <BottomSheet
55
- isOpen={isOpen}
56
- setIsOpen={setIsOpen}
57
- initialIndex={1}
58
- onStateChange={(event) => console.log({ event })}
59
- canDismiss={true}
60
- onDismissPrevented={() => console.log("dismiss prevented")}
61
- snapPoints={[400, 600, 750]}
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
- ## Props
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
- - { type: "HIDDEN" }
89
- - { type: "OPEN", payload: { index: number }}
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
- - `canDismiss` - controls whether the user can dismiss the sheet via swipe/back/gesture (default: true)
92
-
93
- - `onDismissPrevented` - called when a dismiss gesture is blocked by `canDismiss={false}`
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
- - if you do not specify snapPoints, the sheet will size to its content. This means any flex based layout needs to have an explicit container size
74
+ const sheets = createSheets();
98
75
 
99
- - **Android will only use the first two snapPoints!**
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
- ## Caveats
102
+ function SheetContent() {
103
+ return <View style={{ padding: 24 }} />;
104
+ }
105
+ ```
104
106
 
105
- - iOS uses an overlay window to present the sheet.
107
+ `useSheets()` is still available when you prefer resolving the client from context.
106
108
 
107
- - Default appearance values if not provided:
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
- - (Android) can have a maximum of 2 snap points
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
- - (Android) use the `nestedScrollEnabled` prop for nested scrollviews
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 { return }
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
  {
@@ -0,0 +1,9 @@
1
+ export function requireNativeViewManager() {
2
+ return "div";
3
+ }
4
+
5
+ export function requireNativeModule() {
6
+ return {
7
+ addListener: () => ({ remove: () => {} }),
8
+ };
9
+ }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@rn-tools/sheets",
3
- "version": "3.0.1",
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
- "@types/react": "18.3.12"
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.1"
30
+ "@rn-tools/core": "3.0.2"
28
31
  },
29
32
  "peerDependencies": {
30
33
  "expo": "*",
package/src/index.ts CHANGED
@@ -1 +1,4 @@
1
- export * from './native-sheets-view'
1
+ export * from "./native-sheets-view";
2
+ export * from "./sheets-client";
3
+ export * from "./sheet-slot";
4
+ export * from "./sheets-provider";
@@ -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
- setIsOpen(false);
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((event: LayoutChangeEvent) => {
158
- setLayout(event.nativeEvent.layout);
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 ? "box-none" : "none";
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
+ });