@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,235 @@
|
|
|
1
|
+
import type { ReactNode, Ref } from "react";
|
|
2
|
+
import type { PressableProps, TextInput, TextInputProps, TextProps, View, ViewProps } from "react-native";
|
|
3
|
+
import type { SharedValue } from "react-native-reanimated";
|
|
4
|
+
import type { SheetAnimation } from "../animation/animation.types";
|
|
5
|
+
import type {
|
|
6
|
+
AnimationSource,
|
|
7
|
+
DetachedProp,
|
|
8
|
+
DetentSpec,
|
|
9
|
+
KeyboardBehavior,
|
|
10
|
+
KeyboardBlurBehavior,
|
|
11
|
+
KeyboardScope,
|
|
12
|
+
ReduceMotionMode,
|
|
13
|
+
} from "../core";
|
|
14
|
+
import type { SheetHaptics } from "../gesture/gesture.types";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The imperative surface, for the things with no declarative form.
|
|
18
|
+
*
|
|
19
|
+
* `open` and `close` are the same state a controlled `isOpen` drives;
|
|
20
|
+
* `dismiss` is `close` under the name the library this replaces used. Every
|
|
21
|
+
* method is an intent: none of them animates from the JS thread, and each is
|
|
22
|
+
* a no-op when it makes no sense — `close` on a closed sheet, `snapToIndex(4)`
|
|
23
|
+
* with two detents.
|
|
24
|
+
*/
|
|
25
|
+
export type BottomSheetRef = {
|
|
26
|
+
snapToIndex: (index: number) => void;
|
|
27
|
+
/** A height or a `%` of the available height; clamped, never added as a detent. */
|
|
28
|
+
snapToPosition: (position: DetentSpec) => void;
|
|
29
|
+
expand: () => void;
|
|
30
|
+
collapse: () => void;
|
|
31
|
+
close: () => void;
|
|
32
|
+
/** Closes with no animation. */
|
|
33
|
+
forceClose: () => void;
|
|
34
|
+
open: () => void;
|
|
35
|
+
dismiss: () => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type BottomSheetProps = SheetHaptics & {
|
|
39
|
+
children?: ReactNode;
|
|
40
|
+
ref?: Ref<BottomSheetRef>;
|
|
41
|
+
/** Controlled open state. Leave it off and the sheet holds its own. */
|
|
42
|
+
isOpen?: boolean;
|
|
43
|
+
/** Initial open state while uncontrolled. */
|
|
44
|
+
defaultOpen?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Called whenever the sheet opens or closes — by a trigger, a swipe down, a
|
|
47
|
+
* press on the overlay, `BottomSheet.Close`, the ref, or a controlled
|
|
48
|
+
* `isOpen`. One callback for every path.
|
|
49
|
+
*/
|
|
50
|
+
onOpenChange?: (isOpen: boolean) => void;
|
|
51
|
+
/** Controlled detent index. Changing it snaps an open sheet. */
|
|
52
|
+
index?: number;
|
|
53
|
+
/** The detent `open` lands on. @default 0 */
|
|
54
|
+
initialIndex?: number;
|
|
55
|
+
/** The settled index changed — `-1` on close. */
|
|
56
|
+
onIndexChange?: (index: number, height: number, source: AnimationSource) => void;
|
|
57
|
+
/** An animation started. */
|
|
58
|
+
onAnimate?: (
|
|
59
|
+
fromIndex: number,
|
|
60
|
+
toIndex: number,
|
|
61
|
+
fromHeight: number,
|
|
62
|
+
toHeight: number,
|
|
63
|
+
source: AnimationSource
|
|
64
|
+
) => void;
|
|
65
|
+
/** The sheet reached its closed height, by any path. `onOpenChange(false)` follows. */
|
|
66
|
+
onClose?: () => void;
|
|
67
|
+
/** Heights above the resting bottom, or `%` of the available height. @default [] */
|
|
68
|
+
snapPoints?: readonly DetentSpec[];
|
|
69
|
+
/** Adds the content's own height as a detent. @default true */
|
|
70
|
+
dynamicSizing?: boolean;
|
|
71
|
+
/** Caps the dynamic detent. */
|
|
72
|
+
maxDynamicContentSize?: number;
|
|
73
|
+
/** Pixels the frame leaves clear at the top. @default 0 */
|
|
74
|
+
topInset?: number;
|
|
75
|
+
/** The safe-area inset an attached sheet reserves under its content. @default 0 */
|
|
76
|
+
bottomInset?: number;
|
|
77
|
+
/**
|
|
78
|
+
* A floating card: inset by `horizontalMargin` on both sides, resting
|
|
79
|
+
* `bottomOffset` above `bottomInset`, every corner the `Background`'s to
|
|
80
|
+
* round. `true` is `{ horizontalMargin: 16, bottomOffset: 16 }`. Closed is
|
|
81
|
+
* fully off-screen, `%` detents resolve against the height above the resting
|
|
82
|
+
* line, and a tap in the margins or the gap closes.
|
|
83
|
+
*/
|
|
84
|
+
detached?: DetachedProp;
|
|
85
|
+
/** @default true */
|
|
86
|
+
enablePanDownToClose?: boolean;
|
|
87
|
+
/** @default true */
|
|
88
|
+
enableHandlePanningGesture?: boolean;
|
|
89
|
+
/** @default true */
|
|
90
|
+
enableContentPanningGesture?: boolean;
|
|
91
|
+
/** Rubber-band past the detents. @default true */
|
|
92
|
+
enableOverDrag?: boolean;
|
|
93
|
+
/** Larger allows more travel. @default 2.5 */
|
|
94
|
+
overDragResistanceFactor?: number;
|
|
95
|
+
/** @default "interactive" */
|
|
96
|
+
keyboardBehavior?: KeyboardBehavior;
|
|
97
|
+
/** @default "restore" */
|
|
98
|
+
keyboardBlurBehavior?: KeyboardBlurBehavior;
|
|
99
|
+
/** @default "inside" */
|
|
100
|
+
keyboardScope?: KeyboardScope;
|
|
101
|
+
/** @default true */
|
|
102
|
+
enableBlurKeyboardOnGesture?: boolean;
|
|
103
|
+
/** A spring or a timing. Platform default when omitted. */
|
|
104
|
+
animation?: SheetAnimation;
|
|
105
|
+
/** Beats the config's own `reduceMotion`. */
|
|
106
|
+
overrideReduceMotion?: ReduceMotionMode;
|
|
107
|
+
/** Animate the first open, or land on it. @default true */
|
|
108
|
+
animateOnMount?: boolean;
|
|
109
|
+
/** Keep the portal's children mounted while closed. @default false */
|
|
110
|
+
keepMounted?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* What opening does to the other sheets already open in the same host:
|
|
113
|
+
* `push` stacks on top of them, `replace` closes them. @default "push"
|
|
114
|
+
*/
|
|
115
|
+
stackBehavior?: "push" | "replace";
|
|
116
|
+
/** Android's back button closes the sheet while it is the top one in its host. @default true */
|
|
117
|
+
closeOnBack?: boolean;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export type BottomSheetTriggerProps = Omit<PressableProps, "children"> & {
|
|
121
|
+
children?: ReactNode;
|
|
122
|
+
/** Hand `onPress` to the child rather than rendering a Pressable around it. */
|
|
123
|
+
asChild?: boolean;
|
|
124
|
+
ref?: Ref<View>;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export type BottomSheetPortalProps = {
|
|
128
|
+
children?: ReactNode;
|
|
129
|
+
style?: ViewProps["style"];
|
|
130
|
+
ref?: Ref<View>;
|
|
131
|
+
/**
|
|
132
|
+
* Render where written — an absolute fill of the nearest positioned
|
|
133
|
+
* ancestor — rather than teleporting to a host. A persistent drawer, a map's
|
|
134
|
+
* result list. Never unmounts unless `unmountOnClose` says so.
|
|
135
|
+
*/
|
|
136
|
+
inline?: boolean;
|
|
137
|
+
/** The `BottomSheet.Host` to teleport to. Default: the nearest one — `root` under a bare provider. */
|
|
138
|
+
hostName?: string;
|
|
139
|
+
/**
|
|
140
|
+
* Unmount the children once a close has settled. Default `true` when
|
|
141
|
+
* teleported and `false` when inline; the root's `keepMounted` forces it off.
|
|
142
|
+
*/
|
|
143
|
+
unmountOnClose?: boolean;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export type BottomSheetOverlayProps = Omit<ViewProps, "style"> & {
|
|
147
|
+
style?: ViewProps["style"];
|
|
148
|
+
ref?: Ref<View>;
|
|
149
|
+
/** The index the scrim is fully in by. @default 0 */
|
|
150
|
+
appearsOnIndex?: number;
|
|
151
|
+
/** The index the scrim is gone by. @default -1 */
|
|
152
|
+
disappearsOnIndex?: number;
|
|
153
|
+
/** Opacity at full appearance. @default 1 */
|
|
154
|
+
opacity?: number;
|
|
155
|
+
/** What a press does: close, collapse to the first detent, snap to an index, or nothing. @default "close" */
|
|
156
|
+
pressBehavior?: "close" | "collapse" | "none" | number;
|
|
157
|
+
/** Let touches reach the app behind the scrim. @default false */
|
|
158
|
+
enableTouchThrough?: boolean;
|
|
159
|
+
/** Announced on the pressable scrim. @default "Close" */
|
|
160
|
+
accessibilityLabel?: string;
|
|
161
|
+
onPress?: () => void;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export type BottomSheetContainerProps = ViewProps & {
|
|
165
|
+
ref?: Ref<View>;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export type BottomSheetBackgroundProps = ViewProps & {
|
|
169
|
+
ref?: Ref<View>;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export type BottomSheetHandleProps = ViewProps & {
|
|
173
|
+
ref?: Ref<View>;
|
|
174
|
+
/** Overrides the root's `enableHandlePanningGesture` for this handle. */
|
|
175
|
+
enablePanningGesture?: boolean;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export type BottomSheetContentProps = ViewProps & {
|
|
179
|
+
ref?: Ref<View>;
|
|
180
|
+
/** Extra space between the content and a sticky footer. @default 0 */
|
|
181
|
+
footerGap?: number;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export type BottomSheetFooterProps = ViewProps & {
|
|
185
|
+
ref?: Ref<View>;
|
|
186
|
+
/**
|
|
187
|
+
* Stay put over the body and ride the keyboard by transform. Off, it is a
|
|
188
|
+
* plain `View` for a footer that scrolls with the content. @default true
|
|
189
|
+
*/
|
|
190
|
+
sticky?: boolean;
|
|
191
|
+
/** Padding on the measured inner view, so it counts in the sheet's height. */
|
|
192
|
+
padding?: number;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export type BottomSheetTextInputProps = TextInputProps & {
|
|
196
|
+
ref?: Ref<TextInput>;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export type BottomSheetCloseProps = Omit<PressableProps, "children"> & {
|
|
200
|
+
children?: ReactNode;
|
|
201
|
+
asChild?: boolean;
|
|
202
|
+
ref?: Ref<View>;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export type BottomSheetTitleProps = TextProps & {
|
|
206
|
+
asChild?: boolean;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export type BottomSheetDescriptionProps = TextProps & {
|
|
210
|
+
asChild?: boolean;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** What `useBottomSheet()` returns: the open state and every ref method. */
|
|
214
|
+
export type BottomSheetContextValue = BottomSheetRef & {
|
|
215
|
+
isOpen: boolean;
|
|
216
|
+
setOpen: (open: boolean) => void;
|
|
217
|
+
/** The settled index, `-1` closed. React state, updated on settle. */
|
|
218
|
+
index: number;
|
|
219
|
+
/** How many detents there were at the last settle. React state, for the handle's accessibility value. */
|
|
220
|
+
detentCount: number;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** What `useBottomSheetAnimated()` returns: the shared values a skin animates against. */
|
|
224
|
+
export type BottomSheetAnimatedValue = {
|
|
225
|
+
animatedIndex: SharedValue<number>;
|
|
226
|
+
animatedPosition: SharedValue<number>;
|
|
227
|
+
animatedHeight: SharedValue<number>;
|
|
228
|
+
containerHeight: SharedValue<number>;
|
|
229
|
+
handleHeight: SharedValue<number>;
|
|
230
|
+
contentHeight: SharedValue<number>;
|
|
231
|
+
footerHeight: SharedValue<number>;
|
|
232
|
+
keyboardLift: SharedValue<number>;
|
|
233
|
+
detents: SharedValue<readonly number[]>;
|
|
234
|
+
closedHeight: SharedValue<number>;
|
|
235
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# core
|
|
2
|
+
|
|
3
|
+
Every number the sheet computes, and nothing that renders.
|
|
4
|
+
|
|
5
|
+
## The rule
|
|
6
|
+
|
|
7
|
+
**No runtime import of `react`, `react-native`, `react-native-reanimated`,
|
|
8
|
+
`react-native-gesture-handler`, `react-native-worklets`,
|
|
9
|
+
`react-native-keyboard-controller`, `react-native-safe-area-context`,
|
|
10
|
+
`react-native-teleport` or `react-native-pulsar`.** Type-only imports are
|
|
11
|
+
fine. Nothing here imports from outside `core/` either — a `../` would pull a
|
|
12
|
+
hook or a component down into the maths, and into `bun test` with it.
|
|
13
|
+
|
|
14
|
+
`src/purity.test.ts` enforces both. See the package `AGENTS.md` for why the
|
|
15
|
+
failure mode makes a test necessary rather than a convention sufficient.
|
|
16
|
+
|
|
17
|
+
## Height space
|
|
18
|
+
|
|
19
|
+
Every value here is a **height**: pixels of sheet visible above its resting
|
|
20
|
+
bottom line. `0` is closed, detents ascend, `-1` (`UNMEASURED`) is a
|
|
21
|
+
measurement that has not happened. `translateY` is derived from a height only
|
|
22
|
+
at the moment a view needs it, by `geometry/position.ts`, and nowhere else.
|
|
23
|
+
|
|
24
|
+
## Files
|
|
25
|
+
|
|
26
|
+
| Path | What |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `sheet.types.ts` | `DetentSpec`, the keyboard behaviours, `AnimationSource`, `SheetIntent`, the numeric enums and the two sentinels |
|
|
29
|
+
| `result.ts` | The house `Result` union, copied from the private `@delacour/types` |
|
|
30
|
+
| `detents/normalize-detents.ts` | `parseDetent` (JS, a `Result`) and W `normalizeDetents(spec, available)` — ascending, unique, clamped, invalid entries skipped |
|
|
31
|
+
| `detents/dynamic-detent.ts` | W `dynamicDetent` — handle + content + footer content + band once, capped |
|
|
32
|
+
| `detents/index-for-height.ts` | W `indexForHeight` / `heightForIndex` — piecewise-linear over `[closed, …detents]`, clamped |
|
|
33
|
+
| `detents/select-snap-height.ts` | W `selectSnapHeight` — nearest candidate to `height + projection · velocity`; closes only when given a closed height |
|
|
34
|
+
| `detents/over-drag.ts` | W `resistOverDrag` — slope 1 at the edge, square-root beyond; factor 0 clamps |
|
|
35
|
+
| `detents/sheet-state.ts` | W `sheetState` — closed / opened / extended / overExtended / fill |
|
|
36
|
+
| `keyboard/keyboard-lift.ts` | W `keyboardInContainer`, W `keyboardLift` — the lift, band and gap subtracted in step with progress |
|
|
37
|
+
| `keyboard/keyboard-owner.ts` | W `isInputInsideSheet` — the `keyboardScope: "inside"` geometry test |
|
|
38
|
+
| `keyboard/content-area.ts` | W `contentArea` — what the body may fill, never negative |
|
|
39
|
+
| `keyboard/container-layout-guard.ts` | `acceptContainerLayout` — refuses Android's adjustResize double-count |
|
|
40
|
+
| `keyboard/keyboard-reset-guard.ts` | W `shouldResetKeyboardAnimation` — the stale keyboard-controller guard, ported from `react-native-ui` |
|
|
41
|
+
| `footer/footer-top.ts` | W `footerTop` — constant across a keyboard animation; the sweep test proves it |
|
|
42
|
+
| `footer/bottom-band.ts` | W `bottomBand`, W `bandNow`, W `footerHeight` |
|
|
43
|
+
| `footer/body-inset.ts` | W `bodyInset` — what trails the body, footer or band; W `bodyClip` — the body's clipping box, held to the footer's live top edge; `scrollContentHeight` — a scrollable's content size less its trailing spacer |
|
|
44
|
+
| `footer/sheet-insets.ts` | `resolveSheetBottomInset` (what the engine reserves under static content), `resolveSheetScrollEndPadding` (what a skin adds at the end of scroll content — the `footerGap`, never the band) — re-exported by the skin |
|
|
45
|
+
| `geometry/closed-height.ts` | W `restingBottom`, W `closedHeight`, W `availableHeight` |
|
|
46
|
+
| `geometry/position.ts` | W `positionFor` — height to `translateY`, the only conversion |
|
|
47
|
+
| `geometry/clamp-height.ts` | W `clampHeight` — `NaN` collapses to closed |
|
|
48
|
+
| `geometry/detached-frame.ts` | `resolveDetached`, `DETACHED_DEFAULTS`, W `detachedFrame` — left, width, resting bottom |
|
|
49
|
+
| `backdrop/backdrop-opacity.ts` | W `backdropOpacity`, W `backdropInteractive` |
|
|
50
|
+
| `intent/resolve-intent.ts` | W `resolveIntent(state, intent)` — `wait` / `animate` / `jump` / `null`; open means `currentIndex ≥ 0` **or** `base` above the closed height, so a stale index never strands a visible sheet |
|
|
51
|
+
| `intent/layout-ready.ts` | W `isLayoutReady` |
|
|
52
|
+
| `haptic/crossed-detent.ts` | W `crossedDetent` (boolean), W `detentUnder` (index) |
|
|
53
|
+
| `scroll/scroll-lock.ts` | W `shouldLockScroll`, W `contentPanDrivesSheet` |
|
|
54
|
+
| `scroll/scroll-pan.ts` | W `listDragHeight` — the start offset as a budget the finger spends before the sheet moves; W `listOwnsRelease`; W `restingDetent` — the detent within the settle tolerance of a base, for a release the list owned; W `scrollLockTarget` |
|
|
55
|
+
| `animation/select-animation.ts` | `selectAnimation`, `IOS_SPRING`, `ANDROID_TIMING` — platform defaults as data, easing by name |
|
|
56
|
+
|
|
57
|
+
W marks a module-scope `"worklet"`; each is flat (see the package `AGENTS.md`)
|
|
58
|
+
and re-implements the little it needs of its siblings inline. Every export
|
|
59
|
+
goes through `index.ts` by name.
|
|
60
|
+
|
|
61
|
+
## Formulas
|
|
62
|
+
|
|
63
|
+
With `C` the container height, `inset` the safe-area bottom, `kb` the owned
|
|
64
|
+
keyboard within the container (positive) and `p` keyboard-controller's
|
|
65
|
+
`progress`:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
restingBottom = detached ? bottomOffset + inset : 0 closedHeight = −restingBottom
|
|
69
|
+
available = C − restingBottom maxHeight = available
|
|
70
|
+
band = detached ? 0 : inset bandNow = band · (1 − p)
|
|
71
|
+
footerHeight = hasFooter ? footerContent + bandNow : 0
|
|
72
|
+
dynamicDetent = min(handle + content + (hasFooter ? footerContent : 0) + band, maxDynamicContentSize, available)
|
|
73
|
+
detents = sortAsc(unique(normalize(spec, available) ∪ {dynamicDetent if dynamicSizing}))
|
|
74
|
+
keyboardLift = behavior ∈ {interactive, extend} ? max(0, kb − band · p − (detached ? restingBottom : 0)) : 0
|
|
75
|
+
height = clamp(base + keyboardLift, closedHeight, maxHeight)
|
|
76
|
+
translateY = C − restingBottom − height
|
|
77
|
+
index = piecewise(base, [closedHeight, …detents] → [−1, 0, 1, …]) // base, never height
|
|
78
|
+
contentArea = max(0, min(maxHeight, highest + keyboardLift) − handle − footerHeight − kb − (hasFooter ? 0 : bandNow))
|
|
79
|
+
footerTop = max(0, height − kb − footerContent − bandNow)
|
|
80
|
+
bodyInset = hasFooter ? footerHeight : bandNow
|
|
81
|
+
bodyClip = hasFooter ? max(0, min(contentArea + bodyInset, height − kb − handle − bodyInset)) : contentArea + bodyInset
|
|
82
|
+
backdrop = index ≥ appearsOn ? opacity : index ≤ disappearsOn ? 0 : linear
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The footer proof: `footerTop = base + kb − band·p − kb − footerContent − band + band·p = base − footerContent − band`,
|
|
86
|
+
independent of `p`. `footer/footer-top.test.ts` sweeps it in twenty steps.
|
|
87
|
+
|
|
88
|
+
The body — `Content`'s layout box, a scrollable's viewport — is laid out
|
|
89
|
+
`contentArea + bodyInset` tall, to the sheet's bottom line, and reserves
|
|
90
|
+
`bodyInset` at its end as a spacer inside itself, so a list's rows run under
|
|
91
|
+
the footer or the band and its last row can still be scrolled clear of them.
|
|
92
|
+
`bodyClip` is what of that may show: without a footer all of it, so a sheet
|
|
93
|
+
slides as one body; under a footer only what is above the footer's live top
|
|
94
|
+
edge — `height − kb − handle − footerHeight`, which is `footerTop − handle` —
|
|
95
|
+
so while the sheet is dragged below its detent and the footer holds the
|
|
96
|
+
screen's bottom edge, no line of the body is drawn under it. On the detent the
|
|
97
|
+
clip is exactly `contentArea`. A scrollable's `contentHeight` is
|
|
98
|
+
`scrollContentHeight(contentSize, spacer)`: the spacer is the footer and the
|
|
99
|
+
band, which `dynamicDetent` adds once already.
|
|
100
|
+
|
|
101
|
+
Two deviations from the plan's signatures, both on purpose:
|
|
102
|
+
|
|
103
|
+
- `resolveIntent` returns `{ action } | null` rather than `number | null`,
|
|
104
|
+
because `forceClose` needs a jump the caller can tell from an animate, and
|
|
105
|
+
an open before layout needs a *wait* the caller can tell from a no-op.
|
|
106
|
+
- `crossedDetent` returns a boolean and `detentUnder` the index, rather than
|
|
107
|
+
one function doing both — a number that means "unchanged" when it equals its
|
|
108
|
+
input is a comparison the caller has to make anyway, spelled worse.
|
|
109
|
+
|
|
110
|
+
## Design
|
|
111
|
+
|
|
112
|
+
- **Pure means testable, and testable is the point.** `bun test` cannot render
|
|
113
|
+
a React Native component, so anything worth asserting has to live here. A
|
|
114
|
+
decision that looks like it belongs in a `.tsx` because it is only used once
|
|
115
|
+
is only used once *today*. Put it here.
|
|
116
|
+
|
|
117
|
+
- **A `"worklet"` directive is a string, not an import.** It costs a module
|
|
118
|
+
nothing in purity, which is how the per-frame maths stays testable while
|
|
119
|
+
running on the UI thread.
|
|
120
|
+
|
|
121
|
+
- **Enums are numbers.** A shared value holding a string serialises on every
|
|
122
|
+
write, and `gestureSource` is written every frame of a pan.
|
|
123
|
+
|
|
124
|
+
- **A degenerate input returns a finite number, never `NaN`.** One `NaN`
|
|
125
|
+
written into `base` freezes a sheet permanently, with nothing on screen to
|
|
126
|
+
say why.
|
|
127
|
+
|
|
128
|
+
## Machine
|
|
129
|
+
|
|
130
|
+
`machine/` is the typed multi-step engine `BottomSheet.Steps` runs on, and it
|
|
131
|
+
is deliberately nothing more than a config and a reducer.
|
|
132
|
+
|
|
133
|
+
| Path | What |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `machine/machine.types.ts` | `SheetMachineConfig`, `SheetStateNode`, `SheetMachineSnapshot`, `SheetTransitionError`, `SheetMachine` |
|
|
136
|
+
| `machine/transition.ts` | `transition(states, snapshot, event)` — the one step, as a `Result` |
|
|
137
|
+
| `machine/define-sheet-machine.ts` | `defineSheetMachine(config)` — `initial`, `transition`, `can`, `steps`, `directionOf`, `nodeOf` |
|
|
138
|
+
| `machine/step-transition.ts` | `stepFrame(transition, role, direction, progress, width)` — the opacity and offset of a step mid-change, for `crossfade` / `slide` / `none`; `stepOverride(node)` — the `snapPoints` / `dismissible` a step asks of the root |
|
|
139
|
+
|
|
140
|
+
- **A snapshot is a value.** `{ value, context, history }` in, a new one out;
|
|
141
|
+
`assign` returns a fresh context and the incoming snapshot is never touched.
|
|
142
|
+
That is what makes `useSheetMachine` a `useReducer` and lets a test replay a
|
|
143
|
+
whole form in three lines.
|
|
144
|
+
- **A refusal is data.** `transition` returns `err({ code: "no-transition" })`
|
|
145
|
+
or `err({ code: "guard-rejected", from, to })` rather than throwing, and
|
|
146
|
+
`can` is just `transition(...).success` — which is how a Next button is
|
|
147
|
+
disabled while a guard says no.
|
|
148
|
+
- **A self-target edits in place.** `EDIT: { target: "details", assign }`
|
|
149
|
+
keeps `value`, updates `context` and does not push `history`; only a change
|
|
150
|
+
of step appends the step it left.
|
|
151
|
+
- **`steps` is declaration order**, and `directionOf(from, to)` reads the
|
|
152
|
+
target's explicit `direction` first, else compares indices. Per-step
|
|
153
|
+
`snapPoints` and `dismissible` are surfaced untouched through `nodeOf` for
|
|
154
|
+
the nested config override in `steps/`.
|
|
155
|
+
- **`on` narrows per event type.** The handler under `on.EDIT` sees
|
|
156
|
+
`Extract<E, { type: "EDIT" }>`, so `assign` can read `event.field` without a
|
|
157
|
+
cast. `transition.ts` widens back to `E` once, at dispatch, where the type
|
|
158
|
+
has already chosen the handler.
|
|
159
|
+
- **Anything with the same shape may stand in.** An XState adapter is any
|
|
160
|
+
object satisfying `SheetMachine`; nothing here is checked by identity.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ANDROID_TIMING, IOS_SPRING, selectAnimation } from "./select-animation";
|
|
3
|
+
|
|
4
|
+
describe("selectAnimation", () => {
|
|
5
|
+
test("iOS defaults to the house spring, reduce motion following the system", () => {
|
|
6
|
+
expect(selectAnimation(undefined, "ios", undefined)).toEqual({ ...IOS_SPRING, reduceMotion: "system" });
|
|
7
|
+
expect(IOS_SPRING).toEqual({
|
|
8
|
+
type: "spring",
|
|
9
|
+
damping: 500,
|
|
10
|
+
stiffness: 1000,
|
|
11
|
+
mass: 3,
|
|
12
|
+
overshootClamping: true,
|
|
13
|
+
restDisplacementThreshold: 10,
|
|
14
|
+
restSpeedThreshold: 10,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("Android defaults to a 250ms timing with an exponential ease-out, named not imported", () => {
|
|
19
|
+
expect(selectAnimation(undefined, "android", undefined)).toEqual({ ...ANDROID_TIMING, reduceMotion: "system" });
|
|
20
|
+
expect(ANDROID_TIMING).toEqual({ type: "timing", duration: 250, easing: "outExp" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("any other platform takes the timing — a spring with no native driver is the worse guess", () => {
|
|
24
|
+
expect(selectAnimation(undefined, "web", undefined).type).toBe("timing");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a partial spring fills in the house defaults", () => {
|
|
28
|
+
expect(selectAnimation({ type: "spring", damping: 30 }, "android", undefined)).toEqual({
|
|
29
|
+
...IOS_SPRING,
|
|
30
|
+
damping: 30,
|
|
31
|
+
reduceMotion: "system",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a partial timing fills in the house defaults", () => {
|
|
36
|
+
expect(selectAnimation({ type: "timing", easing: "linear" }, "ios", undefined)).toEqual({
|
|
37
|
+
type: "timing",
|
|
38
|
+
duration: 250,
|
|
39
|
+
easing: "linear",
|
|
40
|
+
reduceMotion: "system",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("overrideReduceMotion wins over the system setting", () => {
|
|
45
|
+
expect(selectAnimation(undefined, "ios", "never").reduceMotion).toBe("never");
|
|
46
|
+
expect(selectAnimation({ type: "timing" }, "ios", "always").reduceMotion).toBe("always");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a config's own reduceMotion is kept unless overridden", () => {
|
|
50
|
+
expect(selectAnimation({ type: "spring", reduceMotion: "never" }, "ios", undefined).reduceMotion).toBe("never");
|
|
51
|
+
expect(selectAnimation({ type: "spring", reduceMotion: "never" }, "ios", "always").reduceMotion).toBe("always");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("returns a fresh object each time, so a caller may mutate the velocity in", () => {
|
|
55
|
+
expect(selectAnimation(undefined, "ios", undefined)).not.toBe(selectAnimation(undefined, "ios", undefined));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An easing by name. `src/animation` maps each to a Reanimated `Easing`; the
|
|
3
|
+
* core never imports one, so the defaults stay plain data and testable.
|
|
4
|
+
*/
|
|
5
|
+
export type EasingName = "linear" | "outExp" | "outCubic" | "inOutCubic";
|
|
6
|
+
|
|
7
|
+
/** Reanimated's `ReduceMotion`, by name for the same reason. */
|
|
8
|
+
export type ReduceMotionMode = "system" | "always" | "never";
|
|
9
|
+
|
|
10
|
+
/** `Platform.OS`, widened so the core does not import it. */
|
|
11
|
+
export type PlatformName = "ios" | "android" | "web" | "windows" | "macos" | (string & {});
|
|
12
|
+
|
|
13
|
+
/** The `animation` prop: a spring or a timing, every field optional. */
|
|
14
|
+
export type SheetAnimationConfig =
|
|
15
|
+
| {
|
|
16
|
+
type: "spring";
|
|
17
|
+
damping?: number;
|
|
18
|
+
stiffness?: number;
|
|
19
|
+
mass?: number;
|
|
20
|
+
overshootClamping?: boolean;
|
|
21
|
+
restDisplacementThreshold?: number;
|
|
22
|
+
restSpeedThreshold?: number;
|
|
23
|
+
reduceMotion?: ReduceMotionMode;
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
type: "timing";
|
|
27
|
+
duration?: number;
|
|
28
|
+
easing?: EasingName;
|
|
29
|
+
reduceMotion?: ReduceMotionMode;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** A config with every field filled, ready for `withSpring` / `withTiming`. */
|
|
33
|
+
export type ResolvedAnimation =
|
|
34
|
+
| {
|
|
35
|
+
type: "spring";
|
|
36
|
+
damping: number;
|
|
37
|
+
stiffness: number;
|
|
38
|
+
mass: number;
|
|
39
|
+
overshootClamping: boolean;
|
|
40
|
+
restDisplacementThreshold: number;
|
|
41
|
+
restSpeedThreshold: number;
|
|
42
|
+
reduceMotion: ReduceMotionMode;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
type: "timing";
|
|
46
|
+
duration: number;
|
|
47
|
+
easing: EasingName;
|
|
48
|
+
reduceMotion: ReduceMotionMode;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The iOS default — a stiff, over-damped spring that settles in about a frame's worth of frames. */
|
|
52
|
+
export const IOS_SPRING = {
|
|
53
|
+
type: "spring",
|
|
54
|
+
damping: 500,
|
|
55
|
+
stiffness: 1000,
|
|
56
|
+
mass: 3,
|
|
57
|
+
overshootClamping: true,
|
|
58
|
+
restDisplacementThreshold: 10,
|
|
59
|
+
restSpeedThreshold: 10,
|
|
60
|
+
} as const satisfies Omit<Extract<ResolvedAnimation, { type: "spring" }>, "reduceMotion">;
|
|
61
|
+
|
|
62
|
+
/** The Android default — 250ms with an exponential ease-out. */
|
|
63
|
+
export const ANDROID_TIMING = {
|
|
64
|
+
type: "timing",
|
|
65
|
+
duration: 250,
|
|
66
|
+
easing: "outExp",
|
|
67
|
+
} as const satisfies Omit<Extract<ResolvedAnimation, { type: "timing" }>, "reduceMotion">;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The animation a sheet moves with: the consumer's config over the platform
|
|
71
|
+
* default, every field filled in, reduce motion resolved.
|
|
72
|
+
*
|
|
73
|
+
* iOS gets the spring and everything else the timing — a spring is the worse
|
|
74
|
+
* guess wherever there is no native driver tuned for it. `overrideReduceMotion`
|
|
75
|
+
* beats the config's own `reduceMotion`, which beats `"system"`. Returns a new
|
|
76
|
+
* object each call so the caller may write a velocity into it.
|
|
77
|
+
*/
|
|
78
|
+
export function selectAnimation(
|
|
79
|
+
config: SheetAnimationConfig | undefined,
|
|
80
|
+
platform: PlatformName,
|
|
81
|
+
overrideReduceMotion: ReduceMotionMode | undefined
|
|
82
|
+
): ResolvedAnimation {
|
|
83
|
+
const reduceMotion = overrideReduceMotion ?? config?.reduceMotion ?? "system";
|
|
84
|
+
const resolved: SheetAnimationConfig = config ?? (platform === "ios" ? IOS_SPRING : ANDROID_TIMING);
|
|
85
|
+
|
|
86
|
+
if (resolved.type === "spring") {
|
|
87
|
+
return {
|
|
88
|
+
type: "spring",
|
|
89
|
+
damping: resolved.damping ?? IOS_SPRING.damping,
|
|
90
|
+
stiffness: resolved.stiffness ?? IOS_SPRING.stiffness,
|
|
91
|
+
mass: resolved.mass ?? IOS_SPRING.mass,
|
|
92
|
+
overshootClamping: resolved.overshootClamping ?? IOS_SPRING.overshootClamping,
|
|
93
|
+
restDisplacementThreshold: resolved.restDisplacementThreshold ?? IOS_SPRING.restDisplacementThreshold,
|
|
94
|
+
restSpeedThreshold: resolved.restSpeedThreshold ?? IOS_SPRING.restSpeedThreshold,
|
|
95
|
+
reduceMotion,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
type: "timing",
|
|
100
|
+
duration: resolved.duration ?? ANDROID_TIMING.duration,
|
|
101
|
+
easing: resolved.easing ?? ANDROID_TIMING.easing,
|
|
102
|
+
reduceMotion,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { backdropInteractive, backdropOpacity } from "./backdrop-opacity";
|
|
3
|
+
|
|
4
|
+
describe("backdropOpacity", () => {
|
|
5
|
+
test("closed is transparent, the appearing index is fully opaque", () => {
|
|
6
|
+
expect(backdropOpacity(-1, -1, 0, 1)).toBe(0);
|
|
7
|
+
expect(backdropOpacity(0, -1, 0, 1)).toBe(1);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("fades in between the disappearing and appearing indices", () => {
|
|
11
|
+
expect(backdropOpacity(-0.5, -1, 0, 1)).toBeCloseTo(0.5);
|
|
12
|
+
expect(backdropOpacity(-0.25, -1, 0, 0.6)).toBeCloseTo(0.45);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("plateaus above the appearing index — a higher detent is no darker", () => {
|
|
16
|
+
expect(backdropOpacity(1, -1, 0, 1)).toBe(1);
|
|
17
|
+
expect(backdropOpacity(2.5, -1, 0, 0.7)).toBe(0.7);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("stays transparent up to the disappearing index when it is above closed", () => {
|
|
21
|
+
expect(backdropOpacity(-1, 0, 1, 1)).toBe(0);
|
|
22
|
+
expect(backdropOpacity(0, 0, 1, 1)).toBe(0);
|
|
23
|
+
expect(backdropOpacity(0.5, 0, 1, 1)).toBeCloseTo(0.5);
|
|
24
|
+
expect(backdropOpacity(1, 0, 1, 1)).toBe(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("an appearing index at or below the disappearing one is a step, not a division by zero", () => {
|
|
28
|
+
expect(backdropOpacity(-1, 0, 0, 1)).toBe(0);
|
|
29
|
+
expect(backdropOpacity(0, 0, 0, 1)).toBe(1);
|
|
30
|
+
expect(Number.isFinite(backdropOpacity(0, 1, 0, 1))).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("an over-drag below closed never goes negative", () => {
|
|
34
|
+
expect(backdropOpacity(-1.3, -1, 0, 1)).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("backdropInteractive", () => {
|
|
39
|
+
test("the backdrop takes taps once the sheet is past the index it disappears at", () => {
|
|
40
|
+
expect(backdropInteractive(-1, -1)).toBe(false);
|
|
41
|
+
expect(backdropInteractive(-0.5, -1)).toBe(true);
|
|
42
|
+
expect(backdropInteractive(0, -1)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("with a higher disappearing index the lowest detent leaves the app tappable", () => {
|
|
46
|
+
expect(backdropInteractive(0, 0)).toBe(false);
|
|
47
|
+
expect(backdropInteractive(1, 0)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The overlay's opacity at an index: `0` up to `disappearsOn`, rising
|
|
3
|
+
* linearly to `opacity` at `appearsOn`, and a plateau above — a higher detent
|
|
4
|
+
* is no darker. Fed the keyboard-free index, so a keyboard lift never dims
|
|
5
|
+
* the app behind the sheet.
|
|
6
|
+
*
|
|
7
|
+
* `appearsOn ≤ disappearsOn` is a step at `appearsOn` rather than a division
|
|
8
|
+
* by zero.
|
|
9
|
+
*/
|
|
10
|
+
export function backdropOpacity(index: number, disappearsOn: number, appearsOn: number, opacity: number): number {
|
|
11
|
+
"worklet";
|
|
12
|
+
if (index >= appearsOn) return opacity;
|
|
13
|
+
if (index <= disappearsOn) return 0;
|
|
14
|
+
return ((index - disappearsOn) / (appearsOn - disappearsOn)) * opacity;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Whether the overlay should take taps — anywhere above the index it disappears at. */
|
|
18
|
+
export function backdropInteractive(index: number, disappearsOn: number): boolean {
|
|
19
|
+
"worklet";
|
|
20
|
+
return index > disappearsOn;
|
|
21
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { dynamicDetent } from "./dynamic-detent";
|
|
3
|
+
|
|
4
|
+
const measured = { handleHeight: 24, contentHeight: 300, footerContentHeight: 56, band: 34, available: 800 };
|
|
5
|
+
|
|
6
|
+
describe("dynamicDetent", () => {
|
|
7
|
+
test("without a footer the sheet is handle, content and the safe-area band", () => {
|
|
8
|
+
expect(dynamicDetent({ ...measured, hasFooter: false })).toBe(24 + 300 + 34);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("with a footer the footer content is included, and the band is counted once", () => {
|
|
12
|
+
expect(dynamicDetent({ ...measured, hasFooter: true })).toBe(24 + 300 + 56 + 34);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("a detached sheet has no band, so it contributes nothing", () => {
|
|
16
|
+
expect(dynamicDetent({ ...measured, band: 0, hasFooter: true })).toBe(24 + 300 + 56);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("never exceeds the available height", () => {
|
|
20
|
+
expect(dynamicDetent({ ...measured, contentHeight: 2000, hasFooter: true })).toBe(800);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("maxDynamicContentSize caps below the available height", () => {
|
|
24
|
+
expect(dynamicDetent({ ...measured, contentHeight: 2000, hasFooter: false, maxDynamicContentSize: 500 })).toBe(500);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("maxDynamicContentSize above the available height changes nothing", () => {
|
|
28
|
+
expect(dynamicDetent({ ...measured, contentHeight: 2000, hasFooter: false, maxDynamicContentSize: 5000 })).toBe(
|
|
29
|
+
800
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("unmeasured parts count as zero rather than pulling the sum negative", () => {
|
|
34
|
+
expect(dynamicDetent({ ...measured, handleHeight: -1, footerContentHeight: -1, hasFooter: true })).toBe(300 + 34);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("an unmeasured container yields zero, never a negative height", () => {
|
|
38
|
+
expect(dynamicDetent({ ...measured, available: -1, hasFooter: false })).toBe(0);
|
|
39
|
+
});
|
|
40
|
+
});
|