@oxyhq/bloom 0.13.0 → 0.14.0
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/lib/commonjs/index.js +15 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +21 -6
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/responsive-sheet/ResponsiveSheet.js +300 -0
- package/lib/commonjs/responsive-sheet/ResponsiveSheet.js.map +1 -0
- package/lib/commonjs/responsive-sheet/ResponsiveSheet.native.js +226 -0
- package/lib/commonjs/responsive-sheet/ResponsiveSheet.native.js.map +1 -0
- package/lib/commonjs/responsive-sheet/const.js +60 -0
- package/lib/commonjs/responsive-sheet/const.js.map +1 -0
- package/lib/commonjs/responsive-sheet/index.js +19 -0
- package/lib/commonjs/responsive-sheet/index.js.map +1 -0
- package/lib/commonjs/responsive-sheet/index.web.js +19 -0
- package/lib/commonjs/responsive-sheet/index.web.js.map +1 -0
- package/lib/commonjs/responsive-sheet/types.js +6 -0
- package/lib/commonjs/responsive-sheet/types.js.map +1 -0
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +2 -0
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/responsive-sheet/ResponsiveSheet.js +293 -0
- package/lib/module/responsive-sheet/ResponsiveSheet.js.map +1 -0
- package/lib/module/responsive-sheet/ResponsiveSheet.native.js +219 -0
- package/lib/module/responsive-sheet/ResponsiveSheet.native.js.map +1 -0
- package/lib/module/responsive-sheet/const.js +56 -0
- package/lib/module/responsive-sheet/const.js.map +1 -0
- package/lib/module/responsive-sheet/index.js +4 -0
- package/lib/module/responsive-sheet/index.js.map +1 -0
- package/lib/module/responsive-sheet/index.web.js +14 -0
- package/lib/module/responsive-sheet/index.web.js.map +1 -0
- package/lib/module/responsive-sheet/types.js +4 -0
- package/lib/module/responsive-sheet/types.js.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +2 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.web.d.ts +2 -0
- package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/responsive-sheet/ResponsiveSheet.d.ts +26 -0
- package/lib/typescript/commonjs/responsive-sheet/ResponsiveSheet.d.ts.map +1 -0
- package/lib/typescript/commonjs/responsive-sheet/ResponsiveSheet.native.d.ts +20 -0
- package/lib/typescript/commonjs/responsive-sheet/ResponsiveSheet.native.d.ts.map +1 -0
- package/lib/typescript/commonjs/responsive-sheet/const.d.ts +43 -0
- package/lib/typescript/commonjs/responsive-sheet/const.d.ts.map +1 -0
- package/lib/typescript/commonjs/responsive-sheet/index.d.ts +3 -0
- package/lib/typescript/commonjs/responsive-sheet/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/responsive-sheet/index.web.d.ts +3 -0
- package/lib/typescript/commonjs/responsive-sheet/index.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/responsive-sheet/types.d.ts +55 -0
- package/lib/typescript/commonjs/responsive-sheet/types.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +2 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/index.web.d.ts +2 -0
- package/lib/typescript/module/index.web.d.ts.map +1 -1
- package/lib/typescript/module/responsive-sheet/ResponsiveSheet.d.ts +26 -0
- package/lib/typescript/module/responsive-sheet/ResponsiveSheet.d.ts.map +1 -0
- package/lib/typescript/module/responsive-sheet/ResponsiveSheet.native.d.ts +20 -0
- package/lib/typescript/module/responsive-sheet/ResponsiveSheet.native.d.ts.map +1 -0
- package/lib/typescript/module/responsive-sheet/const.d.ts +43 -0
- package/lib/typescript/module/responsive-sheet/const.d.ts.map +1 -0
- package/lib/typescript/module/responsive-sheet/index.d.ts +3 -0
- package/lib/typescript/module/responsive-sheet/index.d.ts.map +1 -0
- package/lib/typescript/module/responsive-sheet/index.web.d.ts +3 -0
- package/lib/typescript/module/responsive-sheet/index.web.d.ts.map +1 -0
- package/lib/typescript/module/responsive-sheet/types.d.ts +55 -0
- package/lib/typescript/module/responsive-sheet/types.d.ts.map +1 -0
- package/package.json +18 -1
- package/src/__tests__/ResponsiveSheet.test.tsx +104 -0
- package/src/index.ts +8 -0
- package/src/index.web.ts +8 -0
- package/src/responsive-sheet/ResponsiveSheet.native.tsx +257 -0
- package/src/responsive-sheet/ResponsiveSheet.stories.tsx +54 -0
- package/src/responsive-sheet/ResponsiveSheet.tsx +327 -0
- package/src/responsive-sheet/const.ts +53 -0
- package/src/responsive-sheet/index.ts +6 -0
- package/src/responsive-sheet/index.web.ts +16 -0
- package/src/responsive-sheet/types.ts +57 -0
package/src/index.ts
CHANGED
|
@@ -88,6 +88,14 @@ export type { ComboboxProps, ComboboxOption } from './combobox';
|
|
|
88
88
|
export { BottomSheet } from './bottom-sheet';
|
|
89
89
|
export type { BottomSheetRef, BottomSheetProps } from './bottom-sheet';
|
|
90
90
|
|
|
91
|
+
// Responsive sheet — side-sheet on wide screens, bottom-sheet on narrow.
|
|
92
|
+
export { ResponsiveSheet, RESPONSIVE_SHEET_BACKDROP_TESTID } from './responsive-sheet';
|
|
93
|
+
export type {
|
|
94
|
+
ResponsiveSheetProps,
|
|
95
|
+
ResponsiveSheetSide,
|
|
96
|
+
ResponsiveSheetInset,
|
|
97
|
+
} from './responsive-sheet';
|
|
98
|
+
|
|
91
99
|
// Data display
|
|
92
100
|
export * from './card';
|
|
93
101
|
export * from './badge';
|
package/src/index.web.ts
CHANGED
|
@@ -93,6 +93,14 @@ export type { ComboboxProps, ComboboxOption } from './combobox/index.web';
|
|
|
93
93
|
export { BottomSheet } from './bottom-sheet';
|
|
94
94
|
export type { BottomSheetRef, BottomSheetProps } from './bottom-sheet';
|
|
95
95
|
|
|
96
|
+
// Responsive sheet — side-sheet on wide screens, bottom-sheet on narrow.
|
|
97
|
+
export { ResponsiveSheet, RESPONSIVE_SHEET_BACKDROP_TESTID } from './responsive-sheet/index.web';
|
|
98
|
+
export type {
|
|
99
|
+
ResponsiveSheetProps,
|
|
100
|
+
ResponsiveSheetSide,
|
|
101
|
+
ResponsiveSheetInset,
|
|
102
|
+
} from './responsive-sheet/index.web';
|
|
103
|
+
|
|
96
104
|
// Data display
|
|
97
105
|
export * from './card';
|
|
98
106
|
export * from './badge';
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import {
|
|
9
|
+
Pressable,
|
|
10
|
+
StyleSheet,
|
|
11
|
+
useWindowDimensions,
|
|
12
|
+
View,
|
|
13
|
+
type ViewStyle,
|
|
14
|
+
} from 'react-native';
|
|
15
|
+
import Animated, {
|
|
16
|
+
cancelAnimation,
|
|
17
|
+
Easing,
|
|
18
|
+
runOnJS,
|
|
19
|
+
useAnimatedStyle,
|
|
20
|
+
useSharedValue,
|
|
21
|
+
withTiming,
|
|
22
|
+
} from 'react-native-reanimated';
|
|
23
|
+
|
|
24
|
+
import { Z_INDEX } from '../styles/z-index';
|
|
25
|
+
import { useTheme } from '../theme/use-theme';
|
|
26
|
+
import {
|
|
27
|
+
ANIMATION_DURATION,
|
|
28
|
+
BACKDROP_OPACITY,
|
|
29
|
+
DEFAULT_BREAKPOINT,
|
|
30
|
+
DEFAULT_LABEL,
|
|
31
|
+
DEFAULT_MAX_HEIGHT_RATIO,
|
|
32
|
+
DEFAULT_SIDE,
|
|
33
|
+
DEFAULT_WIDTH,
|
|
34
|
+
HANDLE_HEIGHT,
|
|
35
|
+
HANDLE_RADIUS,
|
|
36
|
+
HANDLE_WIDTH,
|
|
37
|
+
PANEL_RADIUS,
|
|
38
|
+
SIDE_SHEET_MIN_GUTTER,
|
|
39
|
+
} from './const';
|
|
40
|
+
import type { ResponsiveSheetProps } from './types';
|
|
41
|
+
|
|
42
|
+
/** Stable testID for the dimmed backdrop dismiss button. */
|
|
43
|
+
export const RESPONSIVE_SHEET_BACKDROP_TESTID = 'bloom-responsive-sheet-backdrop';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Native variant of `<ResponsiveSheet>` (selected only by Metro).
|
|
47
|
+
*
|
|
48
|
+
* Uses `react-native-reanimated` to drive the same motion as the web/CSS
|
|
49
|
+
* default fork: a shared `progress` value times 0 -> 1 on open and 1 -> 0 on
|
|
50
|
+
* close, mapped to the panel translate/opacity and the backdrop opacity.
|
|
51
|
+
* Native exit animations are safe here — the web-only `removeChild` crash does
|
|
52
|
+
* not apply — so we keep the node mounted through the close timing and unmount
|
|
53
|
+
* from the animation completion callback.
|
|
54
|
+
*
|
|
55
|
+
* The panel itself is measured (not percentage-translated): we offset by the
|
|
56
|
+
* panel's own width/height so the hidden state sits fully off its side, which
|
|
57
|
+
* is the reanimated-friendly equivalent of the web `translate(±100%)`.
|
|
58
|
+
*/
|
|
59
|
+
export function ResponsiveSheet({
|
|
60
|
+
open,
|
|
61
|
+
onClose,
|
|
62
|
+
side = DEFAULT_SIDE,
|
|
63
|
+
breakpoint = DEFAULT_BREAKPOINT,
|
|
64
|
+
width = DEFAULT_WIDTH,
|
|
65
|
+
maxHeightRatio = DEFAULT_MAX_HEIGHT_RATIO,
|
|
66
|
+
inset,
|
|
67
|
+
showHandle = true,
|
|
68
|
+
dismissOnBackdrop = true,
|
|
69
|
+
panelStyle,
|
|
70
|
+
panelClassName,
|
|
71
|
+
containerStyle,
|
|
72
|
+
containerClassName,
|
|
73
|
+
label = DEFAULT_LABEL,
|
|
74
|
+
children,
|
|
75
|
+
}: ResponsiveSheetProps) {
|
|
76
|
+
const theme = useTheme();
|
|
77
|
+
const { width: viewportWidth, height: viewportHeight } = useWindowDimensions();
|
|
78
|
+
|
|
79
|
+
// Keep the tree mounted through the exit timing; unmount once it settles.
|
|
80
|
+
const [mounted, setMounted] = useState(open);
|
|
81
|
+
// `progress`: 0 = fully hidden, 1 = fully shown. Drives both transform and
|
|
82
|
+
// opacity so a single timing controls the whole transition.
|
|
83
|
+
const progress = useSharedValue(open ? 1 : 0);
|
|
84
|
+
|
|
85
|
+
const isBottom = viewportWidth < breakpoint;
|
|
86
|
+
|
|
87
|
+
// Measured panel extent along the travel axis. Falls back to the geometry
|
|
88
|
+
// target so the very first frame is already off-screen before layout lands.
|
|
89
|
+
const insetTop = inset?.top ?? 0;
|
|
90
|
+
const insetBottom = inset?.bottom ?? 0;
|
|
91
|
+
const insetLeft = inset?.left ?? 0;
|
|
92
|
+
const insetRight = inset?.right ?? 0;
|
|
93
|
+
const anchorInset = side === 'left' ? insetLeft : insetRight;
|
|
94
|
+
const oppositeInset = side === 'left' ? insetRight : insetLeft;
|
|
95
|
+
|
|
96
|
+
const sideWidth = useMemo(() => {
|
|
97
|
+
const available = viewportWidth - anchorInset - oppositeInset - SIDE_SHEET_MIN_GUTTER;
|
|
98
|
+
return Math.max(0, Math.min(width, available));
|
|
99
|
+
}, [viewportWidth, anchorInset, oppositeInset, width]);
|
|
100
|
+
|
|
101
|
+
const bottomMaxHeight = useMemo(
|
|
102
|
+
() => Math.round(viewportHeight * maxHeightRatio),
|
|
103
|
+
[viewportHeight, maxHeightRatio],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Travel distance for the hidden state: the panel's measured extent (height
|
|
107
|
+
// for the bottom-sheet, width for the side-sheet), with the geometry target
|
|
108
|
+
// as the pre-measurement fallback.
|
|
109
|
+
const travel = useSharedValue(isBottom ? bottomMaxHeight : sideWidth);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
travel.value = isBottom ? bottomMaxHeight : sideWidth;
|
|
112
|
+
}, [isBottom, bottomMaxHeight, sideWidth, travel]);
|
|
113
|
+
|
|
114
|
+
const finishClose = useCallback(() => {
|
|
115
|
+
setMounted(false);
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
cancelAnimation(progress);
|
|
120
|
+
const timing = { duration: ANIMATION_DURATION, easing: Easing.out(Easing.cubic) };
|
|
121
|
+
if (open) {
|
|
122
|
+
setMounted(true);
|
|
123
|
+
progress.value = withTiming(1, timing);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!mounted) return;
|
|
127
|
+
progress.value = withTiming(0, timing, (finished) => {
|
|
128
|
+
if (finished) runOnJS(finishClose)();
|
|
129
|
+
});
|
|
130
|
+
}, [open, mounted, progress, finishClose]);
|
|
131
|
+
|
|
132
|
+
const handleBackdropPress = useCallback(() => {
|
|
133
|
+
if (dismissOnBackdrop) onClose();
|
|
134
|
+
}, [dismissOnBackdrop, onClose]);
|
|
135
|
+
|
|
136
|
+
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
|
137
|
+
opacity: progress.value * BACKDROP_OPACITY,
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
const hiddenSign = side === 'left' ? -1 : 1;
|
|
141
|
+
const panelAnimatedStyle = useAnimatedStyle(() => {
|
|
142
|
+
const hidden = 1 - progress.value;
|
|
143
|
+
if (isBottom) {
|
|
144
|
+
return {
|
|
145
|
+
opacity: progress.value,
|
|
146
|
+
transform: [{ translateY: hidden * travel.value }],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
opacity: progress.value,
|
|
151
|
+
transform: [{ translateX: hidden * travel.value * hiddenSign }],
|
|
152
|
+
};
|
|
153
|
+
}, [isBottom, hiddenSign]);
|
|
154
|
+
|
|
155
|
+
const measurePanel = useCallback(
|
|
156
|
+
(e: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
|
157
|
+
const measured = isBottom ? e.nativeEvent.layout.height : e.nativeEvent.layout.width;
|
|
158
|
+
if (measured > 0) travel.value = measured;
|
|
159
|
+
},
|
|
160
|
+
[isBottom, travel],
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const panelGeometry = useMemo<ViewStyle>(() => {
|
|
164
|
+
if (isBottom) {
|
|
165
|
+
return {
|
|
166
|
+
left: 0,
|
|
167
|
+
right: 0,
|
|
168
|
+
bottom: 0,
|
|
169
|
+
maxHeight: bottomMaxHeight,
|
|
170
|
+
borderTopLeftRadius: PANEL_RADIUS,
|
|
171
|
+
borderTopRightRadius: PANEL_RADIUS,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
top: insetTop,
|
|
176
|
+
bottom: insetBottom,
|
|
177
|
+
[side]: anchorInset,
|
|
178
|
+
width: sideWidth,
|
|
179
|
+
borderRadius: PANEL_RADIUS,
|
|
180
|
+
} as ViewStyle;
|
|
181
|
+
}, [isBottom, bottomMaxHeight, insetTop, insetBottom, side, anchorInset, sideWidth]);
|
|
182
|
+
|
|
183
|
+
if (!mounted) return null;
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<View
|
|
187
|
+
style={[styles.root, containerStyle]}
|
|
188
|
+
{...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
|
|
189
|
+
pointerEvents="box-none"
|
|
190
|
+
>
|
|
191
|
+
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]} pointerEvents="auto">
|
|
192
|
+
<Pressable
|
|
193
|
+
testID={RESPONSIVE_SHEET_BACKDROP_TESTID}
|
|
194
|
+
accessibilityRole="button"
|
|
195
|
+
accessibilityLabel={`Dismiss ${label}`}
|
|
196
|
+
onPress={handleBackdropPress}
|
|
197
|
+
disabled={!dismissOnBackdrop}
|
|
198
|
+
style={StyleSheet.absoluteFill}
|
|
199
|
+
/>
|
|
200
|
+
</Animated.View>
|
|
201
|
+
|
|
202
|
+
<Animated.View
|
|
203
|
+
accessibilityViewIsModal
|
|
204
|
+
{...(panelClassName ? ({ className: panelClassName } as Record<string, string>) : {})}
|
|
205
|
+
onLayout={measurePanel}
|
|
206
|
+
style={[
|
|
207
|
+
styles.panel,
|
|
208
|
+
{ backgroundColor: theme.colors.background, shadowColor: theme.colors.shadow },
|
|
209
|
+
panelGeometry,
|
|
210
|
+
panelAnimatedStyle,
|
|
211
|
+
panelStyle,
|
|
212
|
+
]}
|
|
213
|
+
pointerEvents="auto"
|
|
214
|
+
>
|
|
215
|
+
{isBottom && showHandle ? (
|
|
216
|
+
<View style={styles.handleRow} pointerEvents="none">
|
|
217
|
+
<View style={[styles.handle, { backgroundColor: theme.colors.border }]} />
|
|
218
|
+
</View>
|
|
219
|
+
) : null}
|
|
220
|
+
{children}
|
|
221
|
+
</Animated.View>
|
|
222
|
+
</View>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const styles = StyleSheet.create({
|
|
227
|
+
root: {
|
|
228
|
+
...StyleSheet.absoluteFill,
|
|
229
|
+
zIndex: Z_INDEX.fullscreen,
|
|
230
|
+
},
|
|
231
|
+
backdrop: {
|
|
232
|
+
...StyleSheet.absoluteFill,
|
|
233
|
+
backgroundColor: '#000',
|
|
234
|
+
},
|
|
235
|
+
panel: {
|
|
236
|
+
position: 'absolute',
|
|
237
|
+
overflow: 'hidden',
|
|
238
|
+
shadowOpacity: 0.18,
|
|
239
|
+
shadowRadius: 24,
|
|
240
|
+
shadowOffset: { width: 0, height: 8 },
|
|
241
|
+
elevation: 12,
|
|
242
|
+
},
|
|
243
|
+
handleRow: {
|
|
244
|
+
width: '100%',
|
|
245
|
+
alignItems: 'center',
|
|
246
|
+
paddingTop: 8,
|
|
247
|
+
paddingBottom: 4,
|
|
248
|
+
},
|
|
249
|
+
handle: {
|
|
250
|
+
width: HANDLE_WIDTH,
|
|
251
|
+
height: HANDLE_HEIGHT,
|
|
252
|
+
borderRadius: HANDLE_RADIUS,
|
|
253
|
+
opacity: 0.5,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
export default ResponsiveSheet;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
4
|
+
|
|
5
|
+
import { ResponsiveSheet } from './index';
|
|
6
|
+
import type { ResponsiveSheetSide } from './types';
|
|
7
|
+
import { Button } from '../button';
|
|
8
|
+
import { Text } from '../typography';
|
|
9
|
+
|
|
10
|
+
const meta: Meta = {
|
|
11
|
+
title: 'Overlays/ResponsiveSheet',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
|
|
16
|
+
type Story = StoryObj;
|
|
17
|
+
|
|
18
|
+
function Demo({ side }: { side: ResponsiveSheetSide }) {
|
|
19
|
+
const [open, setOpen] = useState(false);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<View style={{ padding: 24, minHeight: 480 }}>
|
|
23
|
+
<Button onPress={() => setOpen(true)}>Open sheet ({side})</Button>
|
|
24
|
+
<ResponsiveSheet
|
|
25
|
+
open={open}
|
|
26
|
+
onClose={() => setOpen(false)}
|
|
27
|
+
side={side}
|
|
28
|
+
width={420}
|
|
29
|
+
inset={{ top: 16, bottom: 16, left: 16 }}
|
|
30
|
+
label="Demo sheet"
|
|
31
|
+
>
|
|
32
|
+
<View style={{ padding: 20, gap: 12 }}>
|
|
33
|
+
<Text style={{ fontSize: 20, fontWeight: '700' }}>Responsive sheet</Text>
|
|
34
|
+
<Text>
|
|
35
|
+
On a wide viewport this is an anchored side-sheet; below the
|
|
36
|
+
breakpoint it becomes a bottom-sheet with a drag handle. Resize the
|
|
37
|
+
preview to switch modes.
|
|
38
|
+
</Text>
|
|
39
|
+
<Button variant="secondary" onPress={() => setOpen(false)}>
|
|
40
|
+
Close
|
|
41
|
+
</Button>
|
|
42
|
+
</View>
|
|
43
|
+
</ResponsiveSheet>
|
|
44
|
+
</View>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const SideLeft: Story = {
|
|
49
|
+
render: () => <Demo side="left" />,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const SideRight: Story = {
|
|
53
|
+
render: () => <Demo side="right" />,
|
|
54
|
+
};
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import {
|
|
9
|
+
Pressable,
|
|
10
|
+
StyleSheet,
|
|
11
|
+
useWindowDimensions,
|
|
12
|
+
View,
|
|
13
|
+
type ViewStyle,
|
|
14
|
+
} from 'react-native';
|
|
15
|
+
|
|
16
|
+
import { Z_INDEX } from '../styles/z-index';
|
|
17
|
+
import { useTheme } from '../theme/use-theme';
|
|
18
|
+
import {
|
|
19
|
+
ANIMATION_DURATION,
|
|
20
|
+
BACKDROP_OPACITY,
|
|
21
|
+
DEFAULT_BREAKPOINT,
|
|
22
|
+
DEFAULT_LABEL,
|
|
23
|
+
DEFAULT_MAX_HEIGHT_RATIO,
|
|
24
|
+
DEFAULT_SIDE,
|
|
25
|
+
DEFAULT_WIDTH,
|
|
26
|
+
EASE_OUT,
|
|
27
|
+
HANDLE_HEIGHT,
|
|
28
|
+
HANDLE_RADIUS,
|
|
29
|
+
HANDLE_WIDTH,
|
|
30
|
+
PANEL_RADIUS,
|
|
31
|
+
SIDE_SHEET_MIN_GUTTER,
|
|
32
|
+
} from './const';
|
|
33
|
+
import type { ResponsiveSheetProps } from './types';
|
|
34
|
+
|
|
35
|
+
/** Stable testID for the dimmed backdrop dismiss button. */
|
|
36
|
+
export const RESPONSIVE_SHEET_BACKDROP_TESTID = 'bloom-responsive-sheet-backdrop';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Default / web variant of `<ResponsiveSheet>`.
|
|
40
|
+
*
|
|
41
|
+
* This file is BOTH the package's default entry (`react-native` export
|
|
42
|
+
* condition resolves it for downstream `tsc`, and non-Metro web bundlers pick
|
|
43
|
+
* it via the `browser` condition through `index.web`) AND the canonical web
|
|
44
|
+
* implementation. It MUST NOT import `react-native-reanimated` — reanimated has
|
|
45
|
+
* no web build and its worklets Babel plugin is native-only, so a static import
|
|
46
|
+
* here would break every web bundler and downstream type-check. The native
|
|
47
|
+
* fork (`ResponsiveSheet.native.tsx`, selected only by Metro) drives the same
|
|
48
|
+
* motion with reanimated.
|
|
49
|
+
*
|
|
50
|
+
* Motion is pure CSS transitions: react-native-web emits `transition-*` style
|
|
51
|
+
* props to the DOM, and the panel/backdrop toggle their transform/opacity by a
|
|
52
|
+
* `shown` flag. Presence is handled with a `mounted` + `shown` pair so both the
|
|
53
|
+
* open AND close directions animate while the node stays mounted through the
|
|
54
|
+
* exit. We deliberately do NOT use reanimated `exiting` on web — concurrent
|
|
55
|
+
* React unmounts there throw `Failed to execute 'removeChild' on 'Node'`, the
|
|
56
|
+
* exact bug this component exists to avoid.
|
|
57
|
+
*/
|
|
58
|
+
export function ResponsiveSheet({
|
|
59
|
+
open,
|
|
60
|
+
onClose,
|
|
61
|
+
side = DEFAULT_SIDE,
|
|
62
|
+
breakpoint = DEFAULT_BREAKPOINT,
|
|
63
|
+
width = DEFAULT_WIDTH,
|
|
64
|
+
maxHeightRatio = DEFAULT_MAX_HEIGHT_RATIO,
|
|
65
|
+
inset,
|
|
66
|
+
showHandle = true,
|
|
67
|
+
dismissOnBackdrop = true,
|
|
68
|
+
panelStyle,
|
|
69
|
+
panelClassName,
|
|
70
|
+
containerStyle,
|
|
71
|
+
containerClassName,
|
|
72
|
+
label = DEFAULT_LABEL,
|
|
73
|
+
children,
|
|
74
|
+
}: ResponsiveSheetProps) {
|
|
75
|
+
const theme = useTheme();
|
|
76
|
+
const { width: viewportWidth, height: viewportHeight } = useWindowDimensions();
|
|
77
|
+
|
|
78
|
+
// Presence: `mounted` keeps the node in the tree through the exit transition;
|
|
79
|
+
// `shown` drives the enter/exit transform & opacity. On open we mount first,
|
|
80
|
+
// then flip `shown` on the next frame so the browser registers the start
|
|
81
|
+
// state and animates to the end. On close we flip `shown` off, then unmount
|
|
82
|
+
// after the transition duration.
|
|
83
|
+
const [mounted, setMounted] = useState(open);
|
|
84
|
+
const [shown, setShown] = useState(false);
|
|
85
|
+
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
86
|
+
const frameRef = useRef<FrameToken | null>(null);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (open) {
|
|
90
|
+
if (closeTimerRef.current) {
|
|
91
|
+
clearTimeout(closeTimerRef.current);
|
|
92
|
+
closeTimerRef.current = null;
|
|
93
|
+
}
|
|
94
|
+
setMounted(true);
|
|
95
|
+
// Defer to the next frame so the entry start state paints before we
|
|
96
|
+
// transition to the shown state. Two frames guarantees the start
|
|
97
|
+
// transform has committed before the toggle (a single frame can be
|
|
98
|
+
// coalesced with the mount paint in some browsers).
|
|
99
|
+
frameRef.current = scheduleFrame(() => {
|
|
100
|
+
frameRef.current = scheduleFrame(() => setShown(true));
|
|
101
|
+
});
|
|
102
|
+
return () => {
|
|
103
|
+
if (frameRef.current !== null) {
|
|
104
|
+
cancelFrame(frameRef.current);
|
|
105
|
+
frameRef.current = null;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Closing: only run the exit if something is currently mounted.
|
|
111
|
+
setShown(false);
|
|
112
|
+
if (!mounted) return;
|
|
113
|
+
closeTimerRef.current = setTimeout(() => {
|
|
114
|
+
setMounted(false);
|
|
115
|
+
closeTimerRef.current = null;
|
|
116
|
+
}, ANIMATION_DURATION);
|
|
117
|
+
return () => {
|
|
118
|
+
if (closeTimerRef.current) {
|
|
119
|
+
clearTimeout(closeTimerRef.current);
|
|
120
|
+
closeTimerRef.current = null;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}, [open, mounted]);
|
|
124
|
+
|
|
125
|
+
// Escape-to-close while open (web only; document is undefined elsewhere).
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!open || typeof document === 'undefined') return;
|
|
128
|
+
const handler = (e: KeyboardEvent) => {
|
|
129
|
+
if (e.key === 'Escape') {
|
|
130
|
+
e.stopPropagation();
|
|
131
|
+
onClose();
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
document.addEventListener('keydown', handler);
|
|
135
|
+
return () => document.removeEventListener('keydown', handler);
|
|
136
|
+
}, [open, onClose]);
|
|
137
|
+
|
|
138
|
+
const handleBackdropPress = useCallback(() => {
|
|
139
|
+
if (dismissOnBackdrop) onClose();
|
|
140
|
+
}, [dismissOnBackdrop, onClose]);
|
|
141
|
+
|
|
142
|
+
const isBottom = viewportWidth < breakpoint;
|
|
143
|
+
const panelBackground = theme.colors.background;
|
|
144
|
+
|
|
145
|
+
const panelTransition = useMemo<ViewStyle>(
|
|
146
|
+
() =>
|
|
147
|
+
({
|
|
148
|
+
transitionProperty: 'transform, opacity',
|
|
149
|
+
transitionDuration: `${ANIMATION_DURATION}ms`,
|
|
150
|
+
transitionTimingFunction: EASE_OUT,
|
|
151
|
+
}) as ViewStyle,
|
|
152
|
+
[],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const backdropTransition = useMemo<ViewStyle>(
|
|
156
|
+
() =>
|
|
157
|
+
({
|
|
158
|
+
transitionProperty: 'opacity',
|
|
159
|
+
transitionDuration: `${ANIMATION_DURATION}ms`,
|
|
160
|
+
transitionTimingFunction: EASE_OUT,
|
|
161
|
+
}) as ViewStyle,
|
|
162
|
+
[],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const panelGeometry = useMemo<ViewStyle>(() => {
|
|
166
|
+
if (isBottom) {
|
|
167
|
+
return {
|
|
168
|
+
left: 0,
|
|
169
|
+
right: 0,
|
|
170
|
+
bottom: 0,
|
|
171
|
+
maxHeight: Math.round(viewportHeight * maxHeightRatio),
|
|
172
|
+
borderTopLeftRadius: PANEL_RADIUS,
|
|
173
|
+
borderTopRightRadius: PANEL_RADIUS,
|
|
174
|
+
transform: [{ translateY: shown ? 0 : '100%' }],
|
|
175
|
+
opacity: shown ? 1 : 0,
|
|
176
|
+
} as ViewStyle;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Side-sheet: full height minus top/bottom inset, anchored to `side`,
|
|
180
|
+
// offset by the matching horizontal inset. Width is capped so the panel
|
|
181
|
+
// always leaves a gutter on the opposite edge.
|
|
182
|
+
const insetTop = inset?.top ?? 0;
|
|
183
|
+
const insetBottom = inset?.bottom ?? 0;
|
|
184
|
+
const insetLeft = inset?.left ?? 0;
|
|
185
|
+
const insetRight = inset?.right ?? 0;
|
|
186
|
+
const anchorInset = side === 'left' ? insetLeft : insetRight;
|
|
187
|
+
const oppositeInset = side === 'left' ? insetRight : insetLeft;
|
|
188
|
+
const available = viewportWidth - anchorInset - oppositeInset - SIDE_SHEET_MIN_GUTTER;
|
|
189
|
+
const cappedWidth = Math.max(0, Math.min(width, available));
|
|
190
|
+
const hiddenSign = side === 'left' ? '-100%' : '100%';
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
top: insetTop,
|
|
194
|
+
bottom: insetBottom,
|
|
195
|
+
[side]: anchorInset,
|
|
196
|
+
width: cappedWidth,
|
|
197
|
+
borderRadius: PANEL_RADIUS,
|
|
198
|
+
transform: [{ translateX: shown ? 0 : hiddenSign }],
|
|
199
|
+
opacity: shown ? 1 : 0,
|
|
200
|
+
} as ViewStyle;
|
|
201
|
+
}, [isBottom, shown, side, width, inset, viewportWidth, viewportHeight, maxHeightRatio]);
|
|
202
|
+
|
|
203
|
+
if (!mounted) return null;
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<View
|
|
207
|
+
style={[styles.root, containerStyle]}
|
|
208
|
+
// NativeWind reads `className` at runtime; RN core does not type it on
|
|
209
|
+
// View, so spread it conditionally (matching the Button/Fab pattern).
|
|
210
|
+
{...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
|
|
211
|
+
pointerEvents="box-none"
|
|
212
|
+
>
|
|
213
|
+
<Pressable
|
|
214
|
+
testID={RESPONSIVE_SHEET_BACKDROP_TESTID}
|
|
215
|
+
accessibilityRole="button"
|
|
216
|
+
accessibilityLabel={`Dismiss ${label}`}
|
|
217
|
+
onPress={handleBackdropPress}
|
|
218
|
+
disabled={!dismissOnBackdrop}
|
|
219
|
+
style={[
|
|
220
|
+
styles.backdrop,
|
|
221
|
+
backdropTransition,
|
|
222
|
+
{ opacity: shown ? BACKDROP_OPACITY : 0 },
|
|
223
|
+
isWeb ? webBackdropBlur : null,
|
|
224
|
+
]}
|
|
225
|
+
/>
|
|
226
|
+
|
|
227
|
+
<View
|
|
228
|
+
accessibilityViewIsModal
|
|
229
|
+
aria-label={label}
|
|
230
|
+
{...(panelClassName ? ({ className: panelClassName } as Record<string, string>) : {})}
|
|
231
|
+
style={[
|
|
232
|
+
styles.panel,
|
|
233
|
+
{ backgroundColor: panelBackground, shadowColor: theme.colors.shadow },
|
|
234
|
+
panelGeometry,
|
|
235
|
+
panelTransition,
|
|
236
|
+
panelStyle,
|
|
237
|
+
]}
|
|
238
|
+
pointerEvents="auto"
|
|
239
|
+
>
|
|
240
|
+
{isBottom && showHandle ? (
|
|
241
|
+
<View style={styles.handleRow} pointerEvents="none">
|
|
242
|
+
<View style={[styles.handle, { backgroundColor: theme.colors.border }]} />
|
|
243
|
+
</View>
|
|
244
|
+
) : null}
|
|
245
|
+
{children}
|
|
246
|
+
</View>
|
|
247
|
+
</View>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// This module is the web/default impl, but it is also the fallback resolved by
|
|
252
|
+
// a downstream consumer's `tsc` for the native target, so guard the web-only
|
|
253
|
+
// `backdrop-blur` style behind a runtime check rather than assuming web.
|
|
254
|
+
const isWeb = typeof document !== 'undefined';
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Opaque token returned by `scheduleFrame`. `requestAnimationFrame` returns a
|
|
258
|
+
* number; the `setTimeout` fallback returns a timer handle — both are kept so
|
|
259
|
+
* `cancelFrame` can route to the matching canceller.
|
|
260
|
+
*/
|
|
261
|
+
type FrameToken = { raf: number } | { timer: ReturnType<typeof setTimeout> };
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Schedule a callback for the next paint. Uses `requestAnimationFrame` in the
|
|
265
|
+
* browser; degrades to a 0ms timeout when rAF is unavailable (SSR/hydration,
|
|
266
|
+
* non-DOM test envs) so the enter transition's `shown` flip still fires.
|
|
267
|
+
*/
|
|
268
|
+
function scheduleFrame(cb: () => void): FrameToken {
|
|
269
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
270
|
+
return { raf: requestAnimationFrame(cb) };
|
|
271
|
+
}
|
|
272
|
+
return { timer: setTimeout(cb, 0) };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function cancelFrame(token: FrameToken): void {
|
|
276
|
+
if ('raf' in token) {
|
|
277
|
+
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(token.raf);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
clearTimeout(token.timer);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// `backdrop-filter` is web-only; react-native-web passes it through to the DOM.
|
|
284
|
+
const webBackdropBlur = { backdropFilter: 'blur(2px)' } as unknown as ViewStyle;
|
|
285
|
+
|
|
286
|
+
const styles = StyleSheet.create({
|
|
287
|
+
root: {
|
|
288
|
+
// `position: fixed` pins the overlay to the viewport on web; react-native
|
|
289
|
+
// does not type `'fixed'`, so cast to the RN-accepted `'absolute'`. On
|
|
290
|
+
// native the `.native` fork uses `StyleSheet.absoluteFill` instead.
|
|
291
|
+
position: 'fixed' as 'absolute',
|
|
292
|
+
top: 0,
|
|
293
|
+
left: 0,
|
|
294
|
+
right: 0,
|
|
295
|
+
bottom: 0,
|
|
296
|
+
zIndex: Z_INDEX.fullscreen,
|
|
297
|
+
},
|
|
298
|
+
backdrop: {
|
|
299
|
+
position: 'absolute',
|
|
300
|
+
top: 0,
|
|
301
|
+
left: 0,
|
|
302
|
+
right: 0,
|
|
303
|
+
bottom: 0,
|
|
304
|
+
backgroundColor: '#000',
|
|
305
|
+
},
|
|
306
|
+
panel: {
|
|
307
|
+
position: 'absolute',
|
|
308
|
+
overflow: 'hidden',
|
|
309
|
+
shadowOpacity: 0.18,
|
|
310
|
+
shadowRadius: 24,
|
|
311
|
+
shadowOffset: { width: 0, height: 8 },
|
|
312
|
+
},
|
|
313
|
+
handleRow: {
|
|
314
|
+
width: '100%',
|
|
315
|
+
alignItems: 'center',
|
|
316
|
+
paddingTop: 8,
|
|
317
|
+
paddingBottom: 4,
|
|
318
|
+
},
|
|
319
|
+
handle: {
|
|
320
|
+
width: HANDLE_WIDTH,
|
|
321
|
+
height: HANDLE_HEIGHT,
|
|
322
|
+
borderRadius: HANDLE_RADIUS,
|
|
323
|
+
opacity: 0.5,
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
export default ResponsiveSheet;
|