@delacour/react-native-bottom-sheet 0.1.0-alpha.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/README.md +132 -0
- package/package.json +57 -0
- package/src/animation/AGENTS.md +58 -0
- package/src/animation/animation.types.ts +35 -0
- package/src/animation/resolve-animation.ts +75 -0
- package/src/animation/use-animate-to.ts +107 -0
- package/src/animation/use-settle-callbacks.ts +60 -0
- package/src/components/AGENTS.md +80 -0
- package/src/components/bottom-sheet-background.tsx +34 -0
- package/src/components/bottom-sheet-close.tsx +45 -0
- package/src/components/bottom-sheet-container.tsx +79 -0
- package/src/components/bottom-sheet-content.tsx +102 -0
- package/src/components/bottom-sheet-description.tsx +33 -0
- package/src/components/bottom-sheet-footer.tsx +97 -0
- package/src/components/bottom-sheet-handle.tsx +72 -0
- package/src/components/bottom-sheet-overlay.tsx +91 -0
- package/src/components/bottom-sheet-portal.tsx +96 -0
- package/src/components/bottom-sheet-text-input.tsx +36 -0
- package/src/components/bottom-sheet-title.tsx +30 -0
- package/src/components/bottom-sheet-trigger.tsx +51 -0
- package/src/components/bottom-sheet.context.tsx +134 -0
- package/src/components/bottom-sheet.tsx +475 -0
- package/src/components/bottom-sheet.types.ts +235 -0
- package/src/core/AGENTS.md +160 -0
- package/src/core/animation/select-animation.test.ts +57 -0
- package/src/core/animation/select-animation.ts +104 -0
- package/src/core/backdrop/backdrop-opacity.test.ts +49 -0
- package/src/core/backdrop/backdrop-opacity.ts +21 -0
- package/src/core/detents/dynamic-detent.test.ts +40 -0
- package/src/core/detents/dynamic-detent.ts +41 -0
- package/src/core/detents/index-for-height.test.ts +65 -0
- package/src/core/detents/index-for-height.ts +44 -0
- package/src/core/detents/normalize-detents.test.ts +72 -0
- package/src/core/detents/normalize-detents.ts +63 -0
- package/src/core/detents/over-drag.test.ts +46 -0
- package/src/core/detents/over-drag.ts +23 -0
- package/src/core/detents/select-snap-height.test.ts +52 -0
- package/src/core/detents/select-snap-height.ts +44 -0
- package/src/core/detents/sheet-state.test.ts +42 -0
- package/src/core/detents/sheet-state.ts +32 -0
- package/src/core/footer/body-inset.test.ts +95 -0
- package/src/core/footer/body-inset.ts +65 -0
- package/src/core/footer/bottom-band.test.ts +46 -0
- package/src/core/footer/bottom-band.ts +30 -0
- package/src/core/footer/footer-top.test.ts +61 -0
- package/src/core/footer/footer-top.ts +27 -0
- package/src/core/footer/sheet-insets.test.ts +27 -0
- package/src/core/footer/sheet-insets.ts +30 -0
- package/src/core/geometry/clamp-height.test.ts +25 -0
- package/src/core/geometry/clamp-height.ts +12 -0
- package/src/core/geometry/closed-height.test.ts +37 -0
- package/src/core/geometry/closed-height.ts +29 -0
- package/src/core/geometry/detached-frame.test.ts +50 -0
- package/src/core/geometry/detached-frame.ts +49 -0
- package/src/core/geometry/position.test.ts +21 -0
- package/src/core/geometry/position.ts +13 -0
- package/src/core/geometry/surface-height.test.ts +30 -0
- package/src/core/geometry/surface-height.ts +22 -0
- package/src/core/haptic/crossed-detent.test.ts +43 -0
- package/src/core/haptic/crossed-detent.ts +33 -0
- package/src/core/index.ts +92 -0
- package/src/core/intent/layout-ready.test.ts +37 -0
- package/src/core/intent/layout-ready.ts +24 -0
- package/src/core/intent/resolve-intent.test.ts +163 -0
- package/src/core/intent/resolve-intent.ts +98 -0
- package/src/core/keyboard/container-layout-guard.test.ts +32 -0
- package/src/core/keyboard/container-layout-guard.ts +31 -0
- package/src/core/keyboard/content-area.test.ts +34 -0
- package/src/core/keyboard/content-area.ts +26 -0
- package/src/core/keyboard/keyboard-lift.test.ts +54 -0
- package/src/core/keyboard/keyboard-lift.ts +47 -0
- package/src/core/keyboard/keyboard-owner.test.ts +35 -0
- package/src/core/keyboard/keyboard-owner.ts +25 -0
- package/src/core/keyboard/keyboard-ownership.test.ts +69 -0
- package/src/core/keyboard/keyboard-ownership.ts +39 -0
- package/src/core/keyboard/keyboard-reset-guard.test.ts +33 -0
- package/src/core/keyboard/keyboard-reset-guard.ts +33 -0
- package/src/core/keyboard/keyboard-step.test.ts +66 -0
- package/src/core/keyboard/keyboard-step.ts +53 -0
- package/src/core/machine/define-sheet-machine.test.ts +187 -0
- package/src/core/machine/define-sheet-machine.ts +35 -0
- package/src/core/machine/index.ts +23 -0
- package/src/core/machine/machine.types.ts +80 -0
- package/src/core/machine/step-transition.test.ts +46 -0
- package/src/core/machine/step-transition.ts +59 -0
- package/src/core/machine/transition.test.ts +51 -0
- package/src/core/machine/transition.ts +58 -0
- package/src/core/result.test.ts +15 -0
- package/src/core/result.ts +20 -0
- package/src/core/scroll/scroll-lock.test.ts +40 -0
- package/src/core/scroll/scroll-lock.ts +35 -0
- package/src/core/scroll/scroll-pan.test.ts +99 -0
- package/src/core/scroll/scroll-pan.ts +96 -0
- package/src/core/sheet.types.ts +93 -0
- package/src/display-name.test.ts +181 -0
- package/src/docs.test.ts +45 -0
- package/src/flat-worklet.test.ts +130 -0
- package/src/gesture/AGENTS.md +71 -0
- package/src/gesture/gesture.types.ts +37 -0
- package/src/gesture/use-scroll-lock.ts +162 -0
- package/src/gesture/use-sheet-pan.ts +250 -0
- package/src/index.ts +127 -0
- package/src/keyboard/AGENTS.md +84 -0
- package/src/keyboard/use-bottom-sheet-text-input.ts +79 -0
- package/src/keyboard/use-keyboard-animation-guard.ts +59 -0
- package/src/keyboard/use-sheet-keyboard.ts +163 -0
- package/src/layout/AGENTS.md +52 -0
- package/src/layout/use-container-layout.ts +63 -0
- package/src/layout/use-measure-height.ts +40 -0
- package/src/lib/AGENTS.md +35 -0
- package/src/lib/compose-refs.ts +37 -0
- package/src/lib/merge-props.ts +42 -0
- package/src/lib/slot.tsx +44 -0
- package/src/lib/use-controllable-state.ts +45 -0
- package/src/no-classname.test.ts +27 -0
- package/src/portal/AGENTS.md +101 -0
- package/src/portal/bottom-sheet-host.tsx +67 -0
- package/src/portal/bottom-sheet-provider.tsx +33 -0
- package/src/portal/host.context.tsx +25 -0
- package/src/portal/sheet-registry.context.tsx +124 -0
- package/src/portal/sheet-registry.test.ts +87 -0
- package/src/portal/sheet-registry.ts +78 -0
- package/src/purity.test.ts +90 -0
- package/src/scrollable/AGENTS.md +123 -0
- package/src/scrollable/bottom-sheet-flat-list.tsx +15 -0
- package/src/scrollable/bottom-sheet-scroll-view.tsx +15 -0
- package/src/scrollable/bottom-sheet-section-list.tsx +22 -0
- package/src/scrollable/create-bottom-sheet-scrollable.tsx +248 -0
- package/src/scrollable/scrollable.types.ts +76 -0
- package/src/source-tree.test.ts +33 -0
- package/src/state/AGENTS.md +63 -0
- package/src/state/state.types.ts +134 -0
- package/src/state/use-detents.ts +43 -0
- package/src/state/use-sheet-geometry.ts +178 -0
- package/src/state/use-sheet-intents.ts +144 -0
- package/src/state/use-sheet-state.ts +157 -0
- package/src/steps/AGENTS.md +100 -0
- package/src/steps/bottom-sheet-step.tsx +61 -0
- package/src/steps/bottom-sheet-steps.tsx +215 -0
- package/src/steps/steps.context.tsx +73 -0
- package/src/steps/steps.types.ts +61 -0
- package/src/steps/use-sheet-machine.ts +70 -0
- package/src/steps/use-sheet-step.ts +29 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ReactElement, useCallback } from "react";
|
|
2
|
+
import { type GestureResponderEvent, Pressable } from "react-native";
|
|
3
|
+
import { Slot } from "../lib/slot";
|
|
4
|
+
import { useBottomSheet } from "./bottom-sheet.context";
|
|
5
|
+
import type { BottomSheetCloseProps } from "./bottom-sheet.types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The dismiss control. A `Pressable` on its own; with `asChild` it hands
|
|
9
|
+
* `onPress` to the child, the way `Trigger` does and for the same reason.
|
|
10
|
+
*
|
|
11
|
+
* Closing goes through the same path a swipe-down and an overlay press do, so
|
|
12
|
+
* a caller has one `onOpenChange` to watch rather than three.
|
|
13
|
+
*/
|
|
14
|
+
export function BottomSheetClose({
|
|
15
|
+
asChild = false,
|
|
16
|
+
children,
|
|
17
|
+
onPress,
|
|
18
|
+
accessibilityLabel = "Close",
|
|
19
|
+
...props
|
|
20
|
+
}: BottomSheetCloseProps): ReactElement {
|
|
21
|
+
const { close } = useBottomSheet();
|
|
22
|
+
|
|
23
|
+
const handlePress = useCallback(
|
|
24
|
+
(event: GestureResponderEvent) => {
|
|
25
|
+
close();
|
|
26
|
+
onPress?.(event);
|
|
27
|
+
},
|
|
28
|
+
[close, onPress]
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
if (asChild) {
|
|
32
|
+
return (
|
|
33
|
+
<Slot accessibilityLabel={accessibilityLabel} onPress={handlePress} {...props}>
|
|
34
|
+
{children}
|
|
35
|
+
</Slot>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Pressable accessibilityLabel={accessibilityLabel} accessibilityRole="button" onPress={handlePress} {...props}>
|
|
41
|
+
{children}
|
|
42
|
+
</Pressable>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
BottomSheetClose.displayName = "DelacourBottomSheet.BottomSheet.Close";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type ReactElement, useEffect } from "react";
|
|
2
|
+
import { StyleSheet } from "react-native";
|
|
3
|
+
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|
4
|
+
import { detachedFrame } from "../core";
|
|
5
|
+
import { useBottomSheet, useBottomSheetInternal } from "./bottom-sheet.context";
|
|
6
|
+
import type { BottomSheetContainerProps } from "./bottom-sheet.types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The panel — the surface that moves.
|
|
10
|
+
*
|
|
11
|
+
* It fills the frame and is translated down by the geometry's `position`, so
|
|
12
|
+
* `height` pixels of it show above the resting bottom line; over-dragged past
|
|
13
|
+
* the top it never uncovers its own bottom edge, because it is as tall as the
|
|
14
|
+
* frame. `Background`, `Handle` and `Content` are written inside it in flow
|
|
15
|
+
* order; the background is an absolute fill, the handle and the body stack.
|
|
16
|
+
*
|
|
17
|
+
* **It is not `accessible`.** An accessible container collapses its whole
|
|
18
|
+
* subtree into one element on iOS, and every field, button and line of copy
|
|
19
|
+
* inside becomes unreachable to VoiceOver. Instead the panel is a modal view
|
|
20
|
+
* while open under an overlay, so a screen reader stays inside it, and its
|
|
21
|
+
* descendants are hidden while it is closed, so the app behind it is what a
|
|
22
|
+
* screen reader finds. The title labels it.
|
|
23
|
+
*
|
|
24
|
+
* A container written without a `Handle` reports a handle height of zero on
|
|
25
|
+
* mount, so a handle-less sheet is not a sheet that never becomes ready.
|
|
26
|
+
*/
|
|
27
|
+
export function BottomSheetContainer({ children, style, ref, ...props }: BottomSheetContainerProps): ReactElement {
|
|
28
|
+
const { geometry, state, hasOverlay, handleMounted, titleId, detached } = useBottomSheetInternal();
|
|
29
|
+
const { isOpen } = useBottomSheet();
|
|
30
|
+
const position = geometry.position;
|
|
31
|
+
const containerWidth = state.containerWidth;
|
|
32
|
+
const config = state.config;
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!handleMounted.current && state.handleHeight.value < 0) state.handleHeight.value = 0;
|
|
36
|
+
}, [handleMounted, state]);
|
|
37
|
+
|
|
38
|
+
// A detached card is inset from the frame's sides by `detachedFrame`; an
|
|
39
|
+
// attached panel spans it. Both on the UI thread, off the measured width,
|
|
40
|
+
// so a rotation re-derives the inset with no render. The panel stays as
|
|
41
|
+
// tall as the frame either way, so it is `box-none` when detached: the
|
|
42
|
+
// surface, the handle and the body take their touches, and a touch in the
|
|
43
|
+
// gap under the card — where only the transparent panel is — falls through
|
|
44
|
+
// to the overlay behind it.
|
|
45
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
46
|
+
const options = config.value.detached;
|
|
47
|
+
if (options === null || !(containerWidth.value > 0)) {
|
|
48
|
+
return { left: 0, right: 0, width: undefined, transform: [{ translateY: position.value }] };
|
|
49
|
+
}
|
|
50
|
+
const frame = detachedFrame({
|
|
51
|
+
containerWidth: containerWidth.value,
|
|
52
|
+
horizontalMargin: options.horizontalMargin,
|
|
53
|
+
bottomOffset: options.bottomOffset,
|
|
54
|
+
bottomInset: config.value.bottomInset,
|
|
55
|
+
});
|
|
56
|
+
return { left: frame.left, right: undefined, width: frame.width, transform: [{ translateY: position.value }] };
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Animated.View
|
|
61
|
+
accessibilityElementsHidden={!isOpen}
|
|
62
|
+
accessibilityLabelledBy={titleId}
|
|
63
|
+
accessibilityViewIsModal={isOpen && hasOverlay}
|
|
64
|
+
importantForAccessibility={isOpen ? "auto" : "no-hide-descendants"}
|
|
65
|
+
pointerEvents={detached === null ? "auto" : "box-none"}
|
|
66
|
+
ref={ref}
|
|
67
|
+
style={[styles.panel, detached === null ? null : styles.detached, style, animatedStyle]}
|
|
68
|
+
{...props}
|
|
69
|
+
>
|
|
70
|
+
{children}
|
|
71
|
+
</Animated.View>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
BottomSheetContainer.displayName = "DelacourBottomSheet.BottomSheet.Container";
|
|
75
|
+
|
|
76
|
+
const styles = StyleSheet.create({
|
|
77
|
+
panel: { bottom: 0, flexDirection: "column", left: 0, position: "absolute", right: 0, top: 0 },
|
|
78
|
+
detached: { overflow: "visible" },
|
|
79
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { type ReactElement, useCallback } from "react";
|
|
2
|
+
import { type LayoutChangeEvent, StyleSheet, View } from "react-native";
|
|
3
|
+
import { GestureDetector } from "react-native-gesture-handler";
|
|
4
|
+
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|
5
|
+
import { useMeasureHeight } from "../layout/use-measure-height";
|
|
6
|
+
import { useBottomSheetInternal } from "./bottom-sheet.context";
|
|
7
|
+
import type { BottomSheetContentProps } from "./bottom-sheet.types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Before the first layout there is no content area to clamp to, and a
|
|
11
|
+
* `maxHeight` of zero would clip the body while it measures itself. Tall
|
|
12
|
+
* enough that no sheet reaches it.
|
|
13
|
+
*/
|
|
14
|
+
const UNCLAMPED = 100_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The static body — what a caller writes the title, the copy and the controls
|
|
18
|
+
* into.
|
|
19
|
+
*
|
|
20
|
+
* Three boxes. The outermost is the content pan's detector and the clip: its
|
|
21
|
+
* `maxHeight` follows the geometry's `bodyClip` on the UI thread, so under a
|
|
22
|
+
* sticky footer nothing of the body is drawn below the footer's top edge, at
|
|
23
|
+
* rest or while the sheet is dragged below its detent and the footer holds
|
|
24
|
+
* the screen's bottom. The middle box is the layout: `contentArea` plus the
|
|
25
|
+
* `bodyInset` the body reserves at its end, which reaches the sheet's bottom
|
|
26
|
+
* line, and it never reflows during a snap between detents. The innermost is
|
|
27
|
+
* the caller's — it takes the `style` — and its measured height is what a
|
|
28
|
+
* sheet sized to its content grows to. Yoga lays the inner box out at its
|
|
29
|
+
* natural height whatever the outer clamps say, which is what makes the
|
|
30
|
+
* measurement honest.
|
|
31
|
+
*
|
|
32
|
+
* A trailing spacer the height of the body's inset — the sticky footer, band
|
|
33
|
+
* included, or the band alone — keeps the last line of content above either,
|
|
34
|
+
* and `footerGap` adds to it above a footer.
|
|
35
|
+
*/
|
|
36
|
+
export function BottomSheetContent({
|
|
37
|
+
children,
|
|
38
|
+
style,
|
|
39
|
+
ref,
|
|
40
|
+
footerGap = 0,
|
|
41
|
+
...props
|
|
42
|
+
}: BottomSheetContentProps): ReactElement {
|
|
43
|
+
const { state, geometry, pans, detached, contentHeightSource } = useBottomSheetInternal();
|
|
44
|
+
const measure = useMeasureHeight(state.contentHeight);
|
|
45
|
+
// A `Steps` body drives `contentHeight` itself; its layout events here
|
|
46
|
+
// would be the stack mid-animation, a frame stale.
|
|
47
|
+
const onLayout = useCallback(
|
|
48
|
+
(event: LayoutChangeEvent) => {
|
|
49
|
+
if (contentHeightSource.current === "steps") return;
|
|
50
|
+
measure(event);
|
|
51
|
+
},
|
|
52
|
+
[contentHeightSource, measure]
|
|
53
|
+
);
|
|
54
|
+
const { contentArea, bodyInset, bodyClip, layoutReady, surfaceHeight } = geometry;
|
|
55
|
+
const isDetached = detached !== null;
|
|
56
|
+
|
|
57
|
+
// A detached card's surface is only as tall as the sheet, so its body is
|
|
58
|
+
// also held to what the surface shows, or the opening animation would draw
|
|
59
|
+
// the body under the card. The surface keeps its detent's height while the
|
|
60
|
+
// card slides closed, so the body slides with it, unchanged.
|
|
61
|
+
const clip = useAnimatedStyle(() => {
|
|
62
|
+
if (!layoutReady.value) return { maxHeight: UNCLAMPED };
|
|
63
|
+
let height = bodyClip.value;
|
|
64
|
+
if (isDetached) {
|
|
65
|
+
const shown = surfaceHeight.value - state.handleHeight.value;
|
|
66
|
+
height = Math.min(height, shown > 0 ? shown : 0);
|
|
67
|
+
}
|
|
68
|
+
return { maxHeight: height };
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// A ceiling, so the body keeps its natural height under it — except under
|
|
72
|
+
// `fillParent`, where the body is the space above the keyboard and a flex
|
|
73
|
+
// child inside it is meant to shrink to fit.
|
|
74
|
+
const layout = useAnimatedStyle(() => {
|
|
75
|
+
const area = layoutReady.value ? contentArea.value + bodyInset.value : UNCLAMPED;
|
|
76
|
+
return state.config.value.keyboardBehavior === "fillParent" && layoutReady.value
|
|
77
|
+
? { height: area, maxHeight: area }
|
|
78
|
+
: { height: undefined, maxHeight: area };
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const spacer = useAnimatedStyle(() => ({
|
|
82
|
+
height: bodyInset.value + (state.config.value.hasFooter ? footerGap : 0),
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<GestureDetector gesture={pans.content}>
|
|
87
|
+
<Animated.View style={[styles.clip, clip]}>
|
|
88
|
+
<Animated.View style={layout}>
|
|
89
|
+
<View onLayout={onLayout} ref={ref} style={style} {...props}>
|
|
90
|
+
{children}
|
|
91
|
+
</View>
|
|
92
|
+
<Animated.View style={spacer} />
|
|
93
|
+
</Animated.View>
|
|
94
|
+
</Animated.View>
|
|
95
|
+
</GestureDetector>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
BottomSheetContent.displayName = "DelacourBottomSheet.BottomSheet.Content";
|
|
99
|
+
|
|
100
|
+
const styles = StyleSheet.create({
|
|
101
|
+
clip: { overflow: "hidden" },
|
|
102
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { Text } from "react-native";
|
|
3
|
+
import { Slot } from "../lib/slot";
|
|
4
|
+
import { useBottomSheetInternal } from "./bottom-sheet.context";
|
|
5
|
+
import type { BottomSheetDescriptionProps } from "./bottom-sheet.types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Supporting copy under the title. Publishes a `nativeID` for the same reason
|
|
9
|
+
* `Title` does, so a skin can describe the panel by it. A `Text` on its own;
|
|
10
|
+
* with `asChild`, the id lands on the child.
|
|
11
|
+
*/
|
|
12
|
+
export function BottomSheetDescription({
|
|
13
|
+
asChild = false,
|
|
14
|
+
children,
|
|
15
|
+
...props
|
|
16
|
+
}: BottomSheetDescriptionProps): ReactElement {
|
|
17
|
+
const { descriptionId } = useBottomSheetInternal();
|
|
18
|
+
|
|
19
|
+
if (asChild) {
|
|
20
|
+
return (
|
|
21
|
+
<Slot nativeID={descriptionId} {...props}>
|
|
22
|
+
{children}
|
|
23
|
+
</Slot>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Text nativeID={descriptionId} {...props}>
|
|
29
|
+
{children}
|
|
30
|
+
</Text>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
BottomSheetDescription.displayName = "DelacourBottomSheet.BottomSheet.Description";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { type ReactElement, useLayoutEffect, useMemo } from "react";
|
|
2
|
+
import { StyleSheet, View, type ViewStyle } from "react-native";
|
|
3
|
+
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|
4
|
+
import { bottomBand } from "../core";
|
|
5
|
+
import { useMeasureHeight } from "../layout/use-measure-height";
|
|
6
|
+
import { useBottomSheetInternal } from "./bottom-sheet.context";
|
|
7
|
+
import type { BottomSheetFooterProps } from "./bottom-sheet.types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The footer — a row that stays put while the body scrolls and rides the
|
|
11
|
+
* keyboard up by transform.
|
|
12
|
+
*
|
|
13
|
+
* Sticky (the default), it is absolutely positioned at the top of the panel
|
|
14
|
+
* and translated to the geometry's `footerTop`, which the core proves is
|
|
15
|
+
* constant for the whole of a keyboard animation: the keyboard's pixels come
|
|
16
|
+
* off the lift in step with `progress`, so the footer's bottom lands on the
|
|
17
|
+
* keyboard's top edge without ever moving. Its one view — the one that takes
|
|
18
|
+
* `style` and `padding` — pads through the resting safe-area band as well, so
|
|
19
|
+
* the surface a caller styles reaches the sheet's bottom line and its
|
|
20
|
+
* background covers the band: while the sheet is dragged below its detent the
|
|
21
|
+
* footer holds the screen's bottom edge and the body slides down behind it,
|
|
22
|
+
* and a transparent band would show every line that passed. The box is
|
|
23
|
+
* measured less the band into `footerContentHeight`, which the dynamic detent
|
|
24
|
+
* counts. The band is constant on purpose — the footer's translate does the
|
|
25
|
+
* collapsing, and padding that shrank as well would move the footer twice.
|
|
26
|
+
*
|
|
27
|
+
* The bottom padding is composed on the JS side from the flattened style, so a
|
|
28
|
+
* `paddingBottom`, `paddingVertical` or `padding` a caller writes still
|
|
29
|
+
* applies above the band; one given as a string cannot be added to and is
|
|
30
|
+
* read as zero.
|
|
31
|
+
*
|
|
32
|
+
* Mounting tells the root there is a footer — synchronously, so the flag is
|
|
33
|
+
* in the config before any measurement lands — and the layout then waits
|
|
34
|
+
* for the footer's own measurement before the first open, while the body
|
|
35
|
+
* reserves a trailing space its height. Written inside `Container`, after
|
|
36
|
+
* the body.
|
|
37
|
+
*
|
|
38
|
+
* `sticky={false}` is a plain `View` for a footer that should scroll with
|
|
39
|
+
* the content; write it inside the body then.
|
|
40
|
+
*/
|
|
41
|
+
export function BottomSheetFooter({
|
|
42
|
+
children,
|
|
43
|
+
style,
|
|
44
|
+
ref,
|
|
45
|
+
sticky = true,
|
|
46
|
+
padding,
|
|
47
|
+
...props
|
|
48
|
+
}: BottomSheetFooterProps): ReactElement {
|
|
49
|
+
const { state, geometry, setHasFooter, detached, bottomInset } = useBottomSheetInternal();
|
|
50
|
+
const band = bottomBand(detached !== null, bottomInset);
|
|
51
|
+
const onLayout = useMeasureHeight(state.footerContentHeight, band);
|
|
52
|
+
const { footerTop } = geometry;
|
|
53
|
+
|
|
54
|
+
// A layout effect, not a passive one: the flag has to reach the root's
|
|
55
|
+
// config before the handle and the content report their heights, or the
|
|
56
|
+
// first open resolves on a detent with no footer in it and the mount
|
|
57
|
+
// animation, already running, keeps the corrected list from applying.
|
|
58
|
+
useLayoutEffect(() => {
|
|
59
|
+
if (!sticky) return;
|
|
60
|
+
setHasFooter(true);
|
|
61
|
+
return () => setHasFooter(false);
|
|
62
|
+
}, [sticky, setHasFooter]);
|
|
63
|
+
|
|
64
|
+
const translate = useAnimatedStyle(() => ({
|
|
65
|
+
transform: [{ translateY: footerTop.value }],
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
const inner = useMemo<ViewStyle>(() => {
|
|
69
|
+
const flat = StyleSheet.flatten([padding === undefined ? null : { padding }, style]) ?? {};
|
|
70
|
+
return flat;
|
|
71
|
+
}, [padding, style]);
|
|
72
|
+
const surface = useMemo<ViewStyle>(() => {
|
|
73
|
+
if (!sticky) return inner;
|
|
74
|
+
const own = inner.paddingBottom ?? inner.paddingVertical ?? inner.padding ?? 0;
|
|
75
|
+
return { ...inner, paddingBottom: (typeof own === "number" ? own : 0) + band };
|
|
76
|
+
}, [inner, sticky, band]);
|
|
77
|
+
|
|
78
|
+
if (!sticky) {
|
|
79
|
+
return (
|
|
80
|
+
<View ref={ref} style={surface} {...props}>
|
|
81
|
+
{children}
|
|
82
|
+
</View>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return (
|
|
86
|
+
<Animated.View pointerEvents="box-none" style={[styles.sticky, translate]}>
|
|
87
|
+
<View onLayout={onLayout} ref={ref} style={surface} {...props}>
|
|
88
|
+
{children}
|
|
89
|
+
</View>
|
|
90
|
+
</Animated.View>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
BottomSheetFooter.displayName = "DelacourBottomSheet.BottomSheet.Footer";
|
|
94
|
+
|
|
95
|
+
const styles = StyleSheet.create({
|
|
96
|
+
sticky: { left: 0, position: "absolute", right: 0, top: 0, zIndex: 1 },
|
|
97
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type ReactElement, useCallback, useEffect } from "react";
|
|
2
|
+
import { type AccessibilityActionEvent, View } from "react-native";
|
|
3
|
+
import { GestureDetector } from "react-native-gesture-handler";
|
|
4
|
+
import { useMeasureHeight } from "../layout/use-measure-height";
|
|
5
|
+
import { useBottomSheet, useBottomSheetInternal } from "./bottom-sheet.context";
|
|
6
|
+
import type { BottomSheetHandleProps } from "./bottom-sheet.types";
|
|
7
|
+
|
|
8
|
+
const ACTIONS = [{ name: "increment" }, { name: "decrement" }, { name: "escape" }] as const;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The grabber's row, and the sheet's one adjustable accessibility element.
|
|
12
|
+
*
|
|
13
|
+
* It draws nothing of its own — the skin puts the pill inside it — but it
|
|
14
|
+
* owns the handle pan and reports its height into the layout, so a sheet
|
|
15
|
+
* sized to its content counts it. A handle that is never written is a handle
|
|
16
|
+
* of height zero; see `Container`.
|
|
17
|
+
*
|
|
18
|
+
* For a screen reader it is the sheet: `adjustable`, with the detent as its
|
|
19
|
+
* value, increment and decrement moving one detent either way, and the escape
|
|
20
|
+
* gesture closing. Nothing else in the sheet has to know about detents.
|
|
21
|
+
*/
|
|
22
|
+
export function BottomSheetHandle({
|
|
23
|
+
style,
|
|
24
|
+
ref,
|
|
25
|
+
enablePanningGesture,
|
|
26
|
+
accessibilityLabel = "Bottom sheet",
|
|
27
|
+
...props
|
|
28
|
+
}: BottomSheetHandleProps): ReactElement {
|
|
29
|
+
const { state, pans, handleMounted, enableHandlePanningGesture } = useBottomSheetInternal();
|
|
30
|
+
const { index, detentCount: count, snapToIndex, close } = useBottomSheet();
|
|
31
|
+
const onLayout = useMeasureHeight(state.handleHeight);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
handleMounted.current = true;
|
|
35
|
+
return () => {
|
|
36
|
+
handleMounted.current = false;
|
|
37
|
+
};
|
|
38
|
+
}, [handleMounted]);
|
|
39
|
+
|
|
40
|
+
const onAccessibilityAction = useCallback(
|
|
41
|
+
(event: AccessibilityActionEvent) => {
|
|
42
|
+
const action = event.nativeEvent.actionName;
|
|
43
|
+
if (action === "increment") snapToIndex(index + 1);
|
|
44
|
+
else if (action === "decrement") snapToIndex(index - 1);
|
|
45
|
+
else if (action === "escape") close();
|
|
46
|
+
},
|
|
47
|
+
[index, snapToIndex, close]
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// The pan is shared with every handle written in this sheet, and a gesture
|
|
51
|
+
// builder mutates in place, so a per-handle opt-out leaves the detector out
|
|
52
|
+
// rather than flipping a flag on the shared object.
|
|
53
|
+
const enabled = enablePanningGesture ?? enableHandlePanningGesture;
|
|
54
|
+
const row = (
|
|
55
|
+
<View
|
|
56
|
+
accessibilityActions={ACTIONS}
|
|
57
|
+
accessibilityLabel={accessibilityLabel}
|
|
58
|
+
accessibilityRole="adjustable"
|
|
59
|
+
accessibilityValue={{ text: index < 0 ? "Closed" : `Detent ${index + 1} of ${count}` }}
|
|
60
|
+
accessible
|
|
61
|
+
onAccessibilityAction={onAccessibilityAction}
|
|
62
|
+
onAccessibilityEscape={close}
|
|
63
|
+
onLayout={onLayout}
|
|
64
|
+
ref={ref}
|
|
65
|
+
style={style}
|
|
66
|
+
{...props}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
if (!enabled) return row;
|
|
70
|
+
return <GestureDetector gesture={pans.handle}>{row}</GestureDetector>;
|
|
71
|
+
}
|
|
72
|
+
BottomSheetHandle.displayName = "DelacourBottomSheet.BottomSheet.Handle";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type ReactElement, useCallback, useEffect } from "react";
|
|
2
|
+
import { Pressable, StyleSheet, type ViewProps } from "react-native";
|
|
3
|
+
import Animated, { useAnimatedProps, useAnimatedStyle } from "react-native-reanimated";
|
|
4
|
+
import { backdropInteractive, backdropOpacity } from "../core";
|
|
5
|
+
import { useBottomSheet, useBottomSheetInternal } from "./bottom-sheet.context";
|
|
6
|
+
import type { BottomSheetOverlayProps } from "./bottom-sheet.types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The scrim over the app behind the sheet.
|
|
10
|
+
*
|
|
11
|
+
* Its opacity follows the keyboard-free index on the UI thread, so a keyboard
|
|
12
|
+
* lift never dims the app and a higher detent is no darker. Omit it and the
|
|
13
|
+
* sheet has no scrim at all.
|
|
14
|
+
*
|
|
15
|
+
* **It is a React Native `Pressable`, never a Gesture Handler tap, and it is
|
|
16
|
+
* written before the panel.** Gesture Handler resolves a touch between
|
|
17
|
+
* competing gestures, so a full-screen tap gesture behind the sheet wins a
|
|
18
|
+
* touch on a `TextInput`, which has no gesture of its own, and the field never
|
|
19
|
+
* focuses. Native hit-testing resolves a touch by view order instead: the
|
|
20
|
+
* panel is the later sibling, so a touch on it never reaches this view, and a
|
|
21
|
+
* touch beside it does. Nothing here has to know where the sheet is.
|
|
22
|
+
*
|
|
23
|
+
* While the index is at or below `disappearsOnIndex` the view takes no
|
|
24
|
+
* touches and is hidden from assistive technology, so a closed sheet's scrim
|
|
25
|
+
* is not a transparent wall over the app.
|
|
26
|
+
*/
|
|
27
|
+
export function BottomSheetOverlay({
|
|
28
|
+
appearsOnIndex = 0,
|
|
29
|
+
disappearsOnIndex = -1,
|
|
30
|
+
opacity = 1,
|
|
31
|
+
pressBehavior = "close",
|
|
32
|
+
enableTouchThrough = false,
|
|
33
|
+
accessibilityLabel = "Close",
|
|
34
|
+
onPress,
|
|
35
|
+
style,
|
|
36
|
+
ref,
|
|
37
|
+
...props
|
|
38
|
+
}: BottomSheetOverlayProps): ReactElement {
|
|
39
|
+
const { geometry, setHasOverlay, stepOverride } = useBottomSheetInternal();
|
|
40
|
+
const { isOpen, close, collapse, snapToIndex } = useBottomSheet();
|
|
41
|
+
const index = geometry.index;
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
setHasOverlay(true);
|
|
45
|
+
return () => setHasOverlay(false);
|
|
46
|
+
}, [setHasOverlay]);
|
|
47
|
+
|
|
48
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
49
|
+
opacity: backdropOpacity(index.value, disappearsOnIndex, appearsOnIndex, opacity),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const animatedProps = useAnimatedProps<ViewProps>(() => ({
|
|
53
|
+
pointerEvents: !enableTouchThrough && backdropInteractive(index.value, disappearsOnIndex) ? "auto" : "none",
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
// A step that is not dismissible takes the press away for as long as it is current.
|
|
57
|
+
const behavior = stepOverride?.dismissible === false ? "none" : pressBehavior;
|
|
58
|
+
const handlePress = useCallback(() => {
|
|
59
|
+
onPress?.();
|
|
60
|
+
if (behavior === "close") close();
|
|
61
|
+
else if (behavior === "collapse") collapse();
|
|
62
|
+
else if (typeof behavior === "number") snapToIndex(behavior);
|
|
63
|
+
}, [onPress, behavior, close, collapse, snapToIndex]);
|
|
64
|
+
|
|
65
|
+
const pressable = behavior !== "none";
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Animated.View
|
|
69
|
+
accessibilityElementsHidden={!isOpen}
|
|
70
|
+
animatedProps={animatedProps}
|
|
71
|
+
importantForAccessibility={isOpen ? "auto" : "no-hide-descendants"}
|
|
72
|
+
ref={ref}
|
|
73
|
+
style={[StyleSheet.absoluteFill, style, animatedStyle]}
|
|
74
|
+
{...props}
|
|
75
|
+
>
|
|
76
|
+
<Pressable
|
|
77
|
+
accessibilityLabel={accessibilityLabel}
|
|
78
|
+
accessibilityRole={pressable ? "button" : undefined}
|
|
79
|
+
accessible={pressable}
|
|
80
|
+
disabled={!pressable}
|
|
81
|
+
onPress={handlePress}
|
|
82
|
+
style={styles.fill}
|
|
83
|
+
/>
|
|
84
|
+
</Animated.View>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
BottomSheetOverlay.displayName = "DelacourBottomSheet.BottomSheet.Overlay";
|
|
88
|
+
|
|
89
|
+
const styles = StyleSheet.create({
|
|
90
|
+
fill: { flex: 1 },
|
|
91
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type ReactElement, useEffect, useMemo } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { Portal } from "react-native-teleport";
|
|
4
|
+
import { composeRefs } from "../lib/compose-refs";
|
|
5
|
+
import { useOptionalBottomSheetHostName } from "../portal/host.context";
|
|
6
|
+
import { zIndexOf } from "../portal/sheet-registry";
|
|
7
|
+
import { useOptionalSheetRegistry } from "../portal/sheet-registry.context";
|
|
8
|
+
import { useBottomSheetInternal } from "./bottom-sheet.context";
|
|
9
|
+
import type { BottomSheetPortalProps } from "./bottom-sheet.types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Everything drawn over the app once the sheet is open, inside the frame the
|
|
13
|
+
* sheet measures itself against.
|
|
14
|
+
*
|
|
15
|
+
* **One implementation, two destinations.** With a `BottomSheetProvider`
|
|
16
|
+
* above and no `inline`, the frame is written inside a teleport `Portal`
|
|
17
|
+
* bound to the nearest `BottomSheet.Host` — `root` under a bare provider, or
|
|
18
|
+
* the host a native modal screen wrote last — and the native view moves
|
|
19
|
+
* there while the React tree stays put, so every context the trigger's
|
|
20
|
+
* screen provides still reaches the sheet. Without a provider, or with
|
|
21
|
+
* `inline`, the frame renders where it is written: an absolute fill of the
|
|
22
|
+
* nearest positioned ancestor.
|
|
23
|
+
*
|
|
24
|
+
* The frame is `box-none`: it takes no touch of its own, so a closed sheet
|
|
25
|
+
* leaves the app under it fully interactive. `overflow: hidden` keeps a
|
|
26
|
+
* closed panel — translated to the frame's full height — from drawing under
|
|
27
|
+
* the parent's edge; a detached frame is `overflow: visible`, so nothing of a
|
|
28
|
+
* card sliding out through the gap is cut at any line but the screen's own. The `zIndex` comes from the registry: sheets in one host
|
|
29
|
+
* are siblings, and the later open is the later number.
|
|
30
|
+
*
|
|
31
|
+
* Children mount while the sheet is presented, and stay mounted with
|
|
32
|
+
* `keepMounted` or with `unmountOnClose` off — which is the default for an
|
|
33
|
+
* inline sheet, a persistent drawer. While presented the sheet is in the
|
|
34
|
+
* registry, in its host, and `stackBehavior: "replace"` closes the others
|
|
35
|
+
* there.
|
|
36
|
+
*/
|
|
37
|
+
export function BottomSheetPortal({
|
|
38
|
+
children,
|
|
39
|
+
style,
|
|
40
|
+
ref,
|
|
41
|
+
inline = false,
|
|
42
|
+
hostName,
|
|
43
|
+
unmountOnClose,
|
|
44
|
+
}: BottomSheetPortalProps): ReactElement | null {
|
|
45
|
+
const { presented, keepMounted, topInset, containerLayout, sheetId, stackBehavior, detached } =
|
|
46
|
+
useBottomSheetInternal();
|
|
47
|
+
const registry = useOptionalSheetRegistry();
|
|
48
|
+
const nearest = useOptionalBottomSheetHostName();
|
|
49
|
+
const frameRef = useMemo(() => composeRefs(containerLayout.ref, ref), [containerLayout.ref, ref]);
|
|
50
|
+
|
|
51
|
+
const teleported = !inline && nearest !== null;
|
|
52
|
+
const host = hostName ?? nearest ?? "root";
|
|
53
|
+
// An inline sheet is a host of its own: nothing replaces it, and it is always its own top.
|
|
54
|
+
const hostKey = teleported ? host : `inline:${sheetId}`;
|
|
55
|
+
const unmount = keepMounted ? false : (unmountOnClose ?? teleported);
|
|
56
|
+
const mounted = presented || !unmount;
|
|
57
|
+
|
|
58
|
+
// Keyed on the two stable callbacks, never on the registry object: its
|
|
59
|
+
// identity carries the state that `present` itself changes, and an effect
|
|
60
|
+
// keyed on it would present, re-render, dismiss and present without end.
|
|
61
|
+
const present = registry?.present;
|
|
62
|
+
const dismissed = registry?.dismissed;
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!presented || present === undefined || dismissed === undefined) return;
|
|
65
|
+
present(sheetId, hostKey, stackBehavior);
|
|
66
|
+
return () => dismissed(sheetId);
|
|
67
|
+
}, [presented, present, dismissed, sheetId, hostKey, stackBehavior]);
|
|
68
|
+
|
|
69
|
+
const zIndex = registry === null ? 0 : zIndexOf(registry.state, sheetId);
|
|
70
|
+
|
|
71
|
+
if (!mounted) return null;
|
|
72
|
+
|
|
73
|
+
const frame = (
|
|
74
|
+
<View
|
|
75
|
+
onLayout={containerLayout.onLayout}
|
|
76
|
+
pointerEvents="box-none"
|
|
77
|
+
ref={frameRef}
|
|
78
|
+
style={[styles.frame, detached === null ? null : styles.frameDetached, { top: topInset, zIndex }, style]}
|
|
79
|
+
>
|
|
80
|
+
{children}
|
|
81
|
+
</View>
|
|
82
|
+
);
|
|
83
|
+
if (!teleported) return frame;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Portal hostName={host} style={[StyleSheet.absoluteFill, { zIndex }]}>
|
|
87
|
+
{frame}
|
|
88
|
+
</Portal>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
BottomSheetPortal.displayName = "DelacourBottomSheet.BottomSheet.Portal";
|
|
92
|
+
|
|
93
|
+
const styles = StyleSheet.create({
|
|
94
|
+
frame: { bottom: 0, left: 0, overflow: "hidden", position: "absolute", right: 0 },
|
|
95
|
+
frameDetached: { overflow: "visible" },
|
|
96
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ReactElement, useCallback, useMemo } from "react";
|
|
2
|
+
import { type BlurEvent, type FocusEvent, TextInput } from "react-native";
|
|
3
|
+
import { useBottomSheetTextInput } from "../keyboard/use-bottom-sheet-text-input";
|
|
4
|
+
import { composeRefs } from "../lib/compose-refs";
|
|
5
|
+
import type { BottomSheetTextInputProps } from "./bottom-sheet.types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* React Native's `TextInput`, registered with the sheet.
|
|
9
|
+
*
|
|
10
|
+
* `useBottomSheetTextInput` over a plain `TextInput`, for the case with no
|
|
11
|
+
* skin in front of it: the keyboard it raises is the sheet's under any
|
|
12
|
+
* `keyboardScope`, including `registered`. A field of your own takes the
|
|
13
|
+
* hook instead and keeps its component.
|
|
14
|
+
*/
|
|
15
|
+
export function BottomSheetTextInput({ ref, onFocus, onBlur, ...props }: BottomSheetTextInputProps): ReactElement {
|
|
16
|
+
const handlers = useBottomSheetTextInput();
|
|
17
|
+
const composed = useMemo(() => composeRefs<TextInput | null>(ref, handlers.ref), [ref, handlers.ref]);
|
|
18
|
+
|
|
19
|
+
const handleFocus = useCallback(
|
|
20
|
+
(event: FocusEvent) => {
|
|
21
|
+
handlers.onFocus();
|
|
22
|
+
onFocus?.(event);
|
|
23
|
+
},
|
|
24
|
+
[handlers.onFocus, onFocus]
|
|
25
|
+
);
|
|
26
|
+
const handleBlur = useCallback(
|
|
27
|
+
(event: BlurEvent) => {
|
|
28
|
+
handlers.onBlur();
|
|
29
|
+
onBlur?.(event);
|
|
30
|
+
},
|
|
31
|
+
[handlers.onBlur, onBlur]
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return <TextInput onBlur={handleBlur} onFocus={handleFocus} ref={composed} {...props} />;
|
|
35
|
+
}
|
|
36
|
+
BottomSheetTextInput.displayName = "DelacourBottomSheet.BottomSheet.TextInput";
|