@rn-tools/sheets 0.1.4 → 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/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";
@@ -6,8 +6,11 @@ import {
6
6
  ViewStyle,
7
7
  Platform,
8
8
  LayoutChangeEvent,
9
+ StyleSheet,
10
+ useWindowDimensions,
9
11
  } from "react-native";
10
12
  import { requireNativeViewManager } from "expo-modules-core";
13
+ import { useSafeAreaInsets } from "@rn-tools/core";
11
14
 
12
15
  type SheetState = "DRAGGING" | "OPEN" | "SETTLING" | "HIDDEN";
13
16
 
@@ -17,25 +20,19 @@ type ChangeEvent<T extends SheetState, P = unknown> = {
17
20
  };
18
21
 
19
22
  type OpenChangeEvent = ChangeEvent<"OPEN", { index: number }>;
20
- type DraggingChangeEvent = ChangeEvent<"DRAGGING">;
21
- type SettlingChangeEvent = ChangeEvent<"SETTLING">;
22
23
  type HiddenChangeEvent = ChangeEvent<"HIDDEN">;
23
24
 
24
- type SheetChangeEvent =
25
- | OpenChangeEvent
26
- | DraggingChangeEvent
27
- | SettlingChangeEvent
28
- | HiddenChangeEvent;
25
+ export type SheetChangeEvent = OpenChangeEvent | HiddenChangeEvent;
29
26
 
30
27
  type NativeOnChangeEvent = NativeSyntheticEvent<SheetChangeEvent>;
31
28
 
32
- type AppearanceIOS = {
29
+ export type AppearanceIOS = {
33
30
  grabberVisible?: boolean;
34
31
  backgroundColor?: string;
35
32
  cornerRadius?: number;
36
33
  };
37
34
 
38
- type AppearanceAndroid = {
35
+ export type AppearanceAndroid = {
39
36
  dimAmount?: number;
40
37
  cornerRadius?: number;
41
38
  backgroundColor?: string;
@@ -45,8 +42,10 @@ type NativeSheetViewProps = {
45
42
  children: React.ReactNode;
46
43
  snapPoints?: number[];
47
44
  isOpen: boolean;
48
- openToIndex: number;
45
+ initialIndex: number;
49
46
  onDismiss: () => void;
47
+ canDismiss?: boolean;
48
+ onDismissPrevented: () => void;
50
49
  onStateChange: (event: NativeOnChangeEvent) => void;
51
50
  appearanceAndroid?: AppearanceAndroid;
52
51
  appearanceIOS?: AppearanceIOS;
@@ -60,17 +59,16 @@ export type BottomSheetProps = {
60
59
  containerStyle?: ViewStyle;
61
60
  snapPoints?: number[];
62
61
  isOpen: boolean;
63
- openToIndex?: number;
64
- onOpenChange: (isOpen: boolean) => void;
62
+ initialIndex?: number;
63
+ setIsOpen: (isOpen: boolean) => void;
64
+ onDismissed?: () => void;
65
+ canDismiss?: boolean;
66
+ onDismissPrevented?: () => void;
65
67
  onStateChange?: (event: SheetChangeEvent) => void;
66
68
  appearanceAndroid?: AppearanceAndroid;
67
69
  appearanceIOS?: AppearanceIOS;
68
70
  };
69
71
 
70
- // TODO:
71
- // - get sheet container height from native side and clamp maxHeight to that value
72
- //
73
-
74
72
  export function BottomSheet(props: BottomSheetProps) {
75
73
  const {
76
74
  onStateChange,
@@ -78,12 +76,22 @@ export function BottomSheet(props: BottomSheetProps) {
78
76
  snapPoints = [],
79
77
  containerStyle,
80
78
  isOpen,
81
- openToIndex = 0,
82
- onOpenChange: setIsOpen,
79
+ initialIndex = 0,
80
+ setIsOpen,
81
+ onDismissed,
83
82
  appearanceAndroid,
84
83
  appearanceIOS,
84
+ canDismiss = true,
85
+ onDismissPrevented,
85
86
  } = props;
86
87
 
88
+ const { height: windowHeight } = useWindowDimensions();
89
+ const insets = useSafeAreaInsets();
90
+ const maxSheetHeight = React.useMemo(
91
+ () => Math.max(0, windowHeight - insets.top - insets.bottom),
92
+ [windowHeight, insets.top, insets.bottom],
93
+ );
94
+
87
95
  const [layout, setLayout] = React.useState<LayoutRectangle>({
88
96
  height: 0,
89
97
  width: 0,
@@ -91,13 +99,35 @@ export function BottomSheet(props: BottomSheetProps) {
91
99
  y: 0,
92
100
  });
93
101
 
102
+ const hasOpened = React.useRef(false);
103
+
94
104
  const computedSnapPoints = React.useMemo(() => {
95
- if (snapPoints.length === 0 && layout.height > 0) {
96
- return [layout.height];
105
+ if (snapPoints.length === 0) {
106
+ if (layout.height === 0) {
107
+ return [];
108
+ }
109
+
110
+ return [Math.round(Math.min(layout.height, maxSheetHeight))];
111
+ }
112
+
113
+ let effectiveSnapPoints =
114
+ Platform.OS === "android" ? snapPoints.slice(0, 2) : [...snapPoints];
115
+
116
+ const snapPointsExceedingMaxHeight = snapPoints.filter(
117
+ (snapPoint) => snapPoint >= maxSheetHeight,
118
+ );
119
+
120
+ if (snapPointsExceedingMaxHeight.length > 0) {
121
+ effectiveSnapPoints = [
122
+ ...effectiveSnapPoints.filter(
123
+ (snapPoint) => snapPoint < maxSheetHeight,
124
+ ),
125
+ maxSheetHeight,
126
+ ];
97
127
  }
98
128
 
99
- return Platform.OS === "android" ? snapPoints.slice(0, 2) : [...snapPoints];
100
- }, [snapPoints, layout]);
129
+ return effectiveSnapPoints.map((snapPoint) => Math.round(snapPoint));
130
+ }, [layout.height, maxSheetHeight, snapPoints]);
101
131
 
102
132
  const maxHeight = React.useMemo(
103
133
  () =>
@@ -110,38 +140,92 @@ export function BottomSheet(props: BottomSheetProps) {
110
140
  const style = React.useMemo(() => {
111
141
  return {
112
142
  height: maxHeight,
143
+ borderTopLeftRadius: 16,
144
+ borderTopRightRadius: 16,
145
+ backgroundColor: "white",
113
146
  ...containerStyle,
114
147
  };
115
- }, [maxHeight]);
148
+ }, [maxHeight, containerStyle]);
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
+ );
116
165
 
117
166
  const handleOnDismiss = React.useCallback(() => {
118
- setIsOpen(false);
119
- }, []);
167
+ notifyDismissed();
168
+ }, [notifyDismissed]);
120
169
 
121
170
  const handleStateChange = React.useCallback(
122
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
+
123
180
  onStateChange?.(event.nativeEvent);
124
181
  },
125
- [onStateChange],
182
+ [onStateChange, notifyDismissed],
183
+ );
184
+
185
+ const handleLayout = React.useCallback(
186
+ (event: LayoutChangeEvent) => {
187
+ setLayout(event.nativeEvent.layout);
188
+ },
189
+ [],
190
+ );
191
+
192
+ const handleDismissWithChanges = React.useCallback(() => {
193
+ onDismissPrevented?.();
194
+ }, [onDismissPrevented]);
195
+
196
+ const isAutosized = React.useMemo(
197
+ () => snapPoints.length === 0,
198
+ [snapPoints],
126
199
  );
127
200
 
128
- const handleLayout = React.useCallback((event: LayoutChangeEvent) => {
129
- setLayout(event.nativeEvent.layout);
130
- }, []);
201
+ const pointerEvents = React.useMemo(() => {
202
+ return isOpen ? "auto" : "none";
203
+ }, [isOpen]);
204
+
205
+ const innerStyle = React.useMemo(
206
+ () => (isAutosized ? undefined : StyleSheet.absoluteFill),
207
+ [isAutosized],
208
+ );
131
209
 
132
210
  return (
133
- <NativeSheetsView
134
- isOpen={isOpen}
135
- openToIndex={openToIndex}
136
- onDismiss={handleOnDismiss}
137
- onStateChange={handleStateChange}
138
- snapPoints={computedSnapPoints}
139
- appearanceAndroid={appearanceAndroid}
140
- appearanceIOS={appearanceIOS}
141
- >
142
- <View style={style} onLayout={handleLayout}>
143
- {children}
144
- </View>
145
- </NativeSheetsView>
211
+ <View style={StyleSheet.absoluteFill} pointerEvents={pointerEvents}>
212
+ <NativeSheetsView
213
+ isOpen={computedIsOpen}
214
+ canDismiss={canDismiss}
215
+ initialIndex={initialIndex}
216
+ onDismiss={handleOnDismiss}
217
+ onStateChange={handleStateChange}
218
+ onDismissPrevented={handleDismissWithChanges}
219
+ snapPoints={computedSnapPoints}
220
+ appearanceAndroid={appearanceAndroid}
221
+ appearanceIOS={appearanceIOS}
222
+ >
223
+ <View style={style} collapsable={false}>
224
+ <View onLayout={handleLayout} style={innerStyle} collapsable={false}>
225
+ {children}
226
+ </View>
227
+ </View>
228
+ </NativeSheetsView>
229
+ </View>
146
230
  );
147
231
  }
@@ -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
+ });