@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,79 @@
|
|
|
1
|
+
import { type RefCallback, useCallback, useRef } from "react";
|
|
2
|
+
import { findNodeHandle, TextInput } from "react-native";
|
|
3
|
+
import { useOptionalBottomSheetInternal } from "../components/bottom-sheet.context";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `findNodeHandle` for a value React Native's own types do not admit.
|
|
7
|
+
*
|
|
8
|
+
* `TextInput.State.currentlyFocusedInput()` returns a Fabric host instance and
|
|
9
|
+
* the shipped signature still describes the pre-Fabric parameter; the runtime
|
|
10
|
+
* accepts it. One cast, in one place.
|
|
11
|
+
*/
|
|
12
|
+
function nodeHandleOf(instance: unknown): number | null {
|
|
13
|
+
return findNodeHandle(instance as Parameters<typeof findNodeHandle>[0]);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type BottomSheetTextInputHandlers = {
|
|
17
|
+
ref: RefCallback<TextInput | null>;
|
|
18
|
+
onFocus: () => void;
|
|
19
|
+
onBlur: () => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Registers a text field as the sheet's own, so the keyboard it raises is the
|
|
24
|
+
* sheet's to lift for whatever `keyboardScope` says.
|
|
25
|
+
*
|
|
26
|
+
* Spread the result onto any `TextInput` — yours, a skin's, a library's:
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* <TextInput {...useBottomSheetTextInput()} placeholder="Name" />
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* `BottomSheet.TextInput` is this over React Native's `TextInput`, for the
|
|
33
|
+
* plain case. All three handlers matter: `ref` puts the field's native node
|
|
34
|
+
* in the sheet's registry, `onFocus` claims the keyboard, and `onBlur` gives
|
|
35
|
+
* it up — unless focus moved to another registered field in the same sheet,
|
|
36
|
+
* which React Native's own focus registry already knows. Without that check
|
|
37
|
+
* a tap from one field to the next would read as the keyboard closing and
|
|
38
|
+
* reopening, and the sheet would resize twice between two taps that never
|
|
39
|
+
* dismissed it.
|
|
40
|
+
*
|
|
41
|
+
* Outside a sheet the handlers are inert, so a form shared between a screen
|
|
42
|
+
* and a sheet needs no branch.
|
|
43
|
+
*/
|
|
44
|
+
export function useBottomSheetTextInput(): BottomSheetTextInputHandlers {
|
|
45
|
+
const registry = useOptionalBottomSheetInternal()?.keyboard ?? null;
|
|
46
|
+
const registered = useRef<number | null>(null);
|
|
47
|
+
|
|
48
|
+
const ref = useCallback<RefCallback<TextInput | null>>(
|
|
49
|
+
(instance) => {
|
|
50
|
+
if (registry === null) return;
|
|
51
|
+
const previous = registered.current;
|
|
52
|
+
if (previous !== null) {
|
|
53
|
+
registry.nodes.current.delete(previous);
|
|
54
|
+
registered.current = null;
|
|
55
|
+
}
|
|
56
|
+
if (instance === null) return;
|
|
57
|
+
const node = nodeHandleOf(instance);
|
|
58
|
+
if (node === null) return;
|
|
59
|
+
registry.nodes.current.add(node);
|
|
60
|
+
registered.current = node;
|
|
61
|
+
},
|
|
62
|
+
[registry]
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const onFocus = useCallback(() => {
|
|
66
|
+
if (registry === null) return;
|
|
67
|
+
registry.focused.value = true;
|
|
68
|
+
}, [registry]);
|
|
69
|
+
|
|
70
|
+
const onBlur = useCallback(() => {
|
|
71
|
+
if (registry === null) return;
|
|
72
|
+
const focused = TextInput.State.currentlyFocusedInput();
|
|
73
|
+
const node = focused == null ? null : nodeHandleOf(focused);
|
|
74
|
+
if (node !== null && registry.nodes.current.has(node)) return;
|
|
75
|
+
registry.focused.value = false;
|
|
76
|
+
}, [registry]);
|
|
77
|
+
|
|
78
|
+
return { ref, onFocus, onBlur };
|
|
79
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useCallback, useEffect } from "react";
|
|
2
|
+
import { TextInput } from "react-native";
|
|
3
|
+
import { useKeyboardContext } from "react-native-keyboard-controller";
|
|
4
|
+
import type { SharedValue } from "react-native-reanimated";
|
|
5
|
+
import { scheduleOnUI } from "react-native-worklets";
|
|
6
|
+
import { shouldResetKeyboardAnimation } from "../core";
|
|
7
|
+
|
|
8
|
+
/** The one pair of animation values `KeyboardProvider` shares with the whole app. */
|
|
9
|
+
export type KeyboardAnimationValues = {
|
|
10
|
+
progress: SharedValue<number>;
|
|
11
|
+
height: SharedValue<number>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Snaps `KeyboardProvider`'s shared progress and height back to closed when
|
|
16
|
+
* they are provably stale.
|
|
17
|
+
*
|
|
18
|
+
* The focus check stays on the JS thread — React Native's focus registry
|
|
19
|
+
* lives there, and it is the one signal keyboard-controller's own missed
|
|
20
|
+
* notifications cannot poison — and the read-and-write happens inside one
|
|
21
|
+
* worklet, so the decision is made against the UI thread's own values.
|
|
22
|
+
* A snap, never an animation: the keyboard is already gone.
|
|
23
|
+
*/
|
|
24
|
+
export function reconcileKeyboardAnimation({ progress, height }: KeyboardAnimationValues): void {
|
|
25
|
+
if (TextInput.State.currentlyFocusedInput() != null) return;
|
|
26
|
+
|
|
27
|
+
scheduleOnUI(() => {
|
|
28
|
+
"worklet";
|
|
29
|
+
if (!shouldResetKeyboardAnimation({ hasFocusedInput: false, height: height.value, progress: progress.value })) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
progress.value = 0;
|
|
33
|
+
height.value = 0;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Repairs a keyboard `KeyboardProvider` still believes is up.
|
|
39
|
+
*
|
|
40
|
+
* On iOS the provider's shared values are written by `keyboardWillShow` /
|
|
41
|
+
* `keyboardWillHide` alone, so any teardown that produces no `willHide` — a
|
|
42
|
+
* native-stack pop over a focused field, an interrupted interactive dismiss,
|
|
43
|
+
* an app suspend — leaves them pinned open, app-wide. A sheet mounted into
|
|
44
|
+
* that state would lift for a keyboard that is not there.
|
|
45
|
+
*
|
|
46
|
+
* Idempotent and cheap: a no-op whenever a field is genuinely focused. The
|
|
47
|
+
* sheet runs it every time its portal presents; a screen of your own can run
|
|
48
|
+
* it on mount the same way. The same decision as
|
|
49
|
+
* `@delacour/react-native-ui`'s `useKeyboardAnimationGuard`, over the same
|
|
50
|
+
* core function, so the two never disagree about what stale means.
|
|
51
|
+
*/
|
|
52
|
+
export function useKeyboardAnimationGuard(): void {
|
|
53
|
+
const { reanimated } = useKeyboardContext();
|
|
54
|
+
const reconcile = useCallback(() => reconcileKeyboardAnimation(reanimated), [reanimated]);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
reconcile();
|
|
58
|
+
}, [reconcile]);
|
|
59
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { type RefObject, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { useWindowDimensions } from "react-native";
|
|
3
|
+
import { useKeyboardContext, useReanimatedFocusedInput } from "react-native-keyboard-controller";
|
|
4
|
+
import { type SharedValue, useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
|
5
|
+
import type { AnimateTo } from "../animation/animation.types";
|
|
6
|
+
import {
|
|
7
|
+
CLOSED_INDEX,
|
|
8
|
+
GESTURE_SOURCE,
|
|
9
|
+
heightForIndex,
|
|
10
|
+
indexForHeight,
|
|
11
|
+
isInputInsideSheet,
|
|
12
|
+
keyboardInContainer,
|
|
13
|
+
keyboardStep,
|
|
14
|
+
resolveKeyboardOwner,
|
|
15
|
+
} from "../core";
|
|
16
|
+
import { ANIM_STATUS, type SheetGeometry, type SheetSharedState } from "../state/state.types";
|
|
17
|
+
import { reconcileKeyboardAnimation } from "./use-keyboard-animation-guard";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What `useBottomSheetTextInput` writes into: the native nodes of every
|
|
21
|
+
* registered field, and whether one of them holds focus.
|
|
22
|
+
*/
|
|
23
|
+
export type SheetKeyboardRegistry = {
|
|
24
|
+
nodes: RefObject<Set<number>>;
|
|
25
|
+
focused: SharedValue<boolean>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type UseSheetKeyboardOptions = {
|
|
29
|
+
state: SheetSharedState;
|
|
30
|
+
geometry: SheetGeometry;
|
|
31
|
+
animateTo: AnimateTo;
|
|
32
|
+
/** Whether the portal's children are mounted; the stale-keyboard guard runs on each presentation. */
|
|
33
|
+
presented: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The keyboard, from keyboard-controller's shared values into the sheet's.
|
|
38
|
+
*
|
|
39
|
+
* Two reactions. The first is the writer: every frame the keyboard's `height`
|
|
40
|
+
* (negative, from the window's bottom edge) or `progress` moves, or the
|
|
41
|
+
* focused input's frame changes, it writes `keyboardHeight` (positive, within
|
|
42
|
+
* the container), `keyboardProgress` and `keyboardOwned`. Ownership goes
|
|
43
|
+
* through `resolveKeyboardOwner` — a registered field, or under `inside` a
|
|
44
|
+
* focused input whose frame overlaps the sheet, and sticky through a
|
|
45
|
+
* transition once claimed. Everything the geometry derives from those three
|
|
46
|
+
* — the lift, the body's area, the footer's place — follows on the UI thread
|
|
47
|
+
* with no further code here.
|
|
48
|
+
*
|
|
49
|
+
* The second is the behaviour. `interactive` and `none` need none: one lifts
|
|
50
|
+
* by derivation, the other ignores the keyboard. `extend` and `fillParent`
|
|
51
|
+
* snap — to the highest detent or the container — when an owned keyboard
|
|
52
|
+
* rises, and `keyboardBlurBehavior: "restore"` sends the sheet back to the
|
|
53
|
+
* detent it held when the keyboard leaves. `keyboardStep` in the core makes
|
|
54
|
+
* that call from two consecutive samples; this reaction only carries it out,
|
|
55
|
+
* and stays out of the way of a gesture that owns `base`.
|
|
56
|
+
*/
|
|
57
|
+
export function useSheetKeyboard({
|
|
58
|
+
state,
|
|
59
|
+
geometry,
|
|
60
|
+
animateTo,
|
|
61
|
+
presented,
|
|
62
|
+
}: UseSheetKeyboardOptions): SheetKeyboardRegistry {
|
|
63
|
+
const { reanimated } = useKeyboardContext();
|
|
64
|
+
const { height, progress } = reanimated;
|
|
65
|
+
const { input } = useReanimatedFocusedInput();
|
|
66
|
+
const { height: windowHeight } = useWindowDimensions();
|
|
67
|
+
const nodes = useRef(new Set<number>());
|
|
68
|
+
const focused = useSharedValue(false);
|
|
69
|
+
const indexBefore = useSharedValue(CLOSED_INDEX);
|
|
70
|
+
const applied = useSharedValue(false);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (presented) reconcileKeyboardAnimation(reanimated);
|
|
74
|
+
}, [presented, reanimated]);
|
|
75
|
+
|
|
76
|
+
useAnimatedReaction(
|
|
77
|
+
() => ({
|
|
78
|
+
raw: height.value,
|
|
79
|
+
progress: progress.value,
|
|
80
|
+
layout: input.value,
|
|
81
|
+
registered: focused.value,
|
|
82
|
+
offset: state.containerBottomOffset.value,
|
|
83
|
+
container: state.containerHeight.value,
|
|
84
|
+
position: geometry.position.value,
|
|
85
|
+
}),
|
|
86
|
+
(current) => {
|
|
87
|
+
const keyboardHeight = keyboardInContainer(current.raw, current.offset);
|
|
88
|
+
const containerBottom = windowHeight - current.offset;
|
|
89
|
+
const layout = current.layout;
|
|
90
|
+
const inside =
|
|
91
|
+
layout !== null &&
|
|
92
|
+
current.container > 0 &&
|
|
93
|
+
isInputInsideSheet({
|
|
94
|
+
inputY: layout.layout.absoluteY,
|
|
95
|
+
inputHeight: layout.layout.height,
|
|
96
|
+
sheetTop: containerBottom - current.container + current.position,
|
|
97
|
+
containerBottom,
|
|
98
|
+
});
|
|
99
|
+
const owned = resolveKeyboardOwner({
|
|
100
|
+
scope: state.config.value.keyboardScope,
|
|
101
|
+
registeredFocused: current.registered,
|
|
102
|
+
inputInside: inside,
|
|
103
|
+
inputFocused: layout !== null,
|
|
104
|
+
progress: current.progress,
|
|
105
|
+
previousOwned: state.keyboardOwned.value,
|
|
106
|
+
});
|
|
107
|
+
if (state.keyboardHeight.value !== keyboardHeight) state.keyboardHeight.value = keyboardHeight;
|
|
108
|
+
if (state.keyboardProgress.value !== current.progress) state.keyboardProgress.value = current.progress;
|
|
109
|
+
if (state.keyboardOwned.value !== owned) state.keyboardOwned.value = owned;
|
|
110
|
+
},
|
|
111
|
+
[state, geometry, height, progress, input, focused, windowHeight]
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
useAnimatedReaction(
|
|
115
|
+
() => ({ progress: progress.value, owned: state.keyboardOwned.value }),
|
|
116
|
+
(current, previous) => {
|
|
117
|
+
if (previous === null) return;
|
|
118
|
+
const config = state.config.value;
|
|
119
|
+
const closed = geometry.closedHeight.value;
|
|
120
|
+
const running = state.animStatus.value === ANIM_STATUS.RUNNING;
|
|
121
|
+
const sheetOpen = running ? state.animTarget.value > closed : state.base.value > closed;
|
|
122
|
+
|
|
123
|
+
const step = keyboardStep({
|
|
124
|
+
behavior: config.keyboardBehavior,
|
|
125
|
+
blurBehavior: config.keyboardBlurBehavior,
|
|
126
|
+
progress: current.progress,
|
|
127
|
+
previousProgress: previous.progress,
|
|
128
|
+
owned: current.owned,
|
|
129
|
+
previousOwned: previous.owned,
|
|
130
|
+
applied: applied.value,
|
|
131
|
+
sheetOpen,
|
|
132
|
+
});
|
|
133
|
+
if (step === null) return;
|
|
134
|
+
|
|
135
|
+
// A finger on the sheet owns `base`; the release will settle it.
|
|
136
|
+
const dragging = state.gestureSource.value !== GESTURE_SOURCE.NONE;
|
|
137
|
+
|
|
138
|
+
if (step === "apply") {
|
|
139
|
+
if (dragging) return;
|
|
140
|
+
applied.value = true;
|
|
141
|
+
indexBefore.value = running
|
|
142
|
+
? Math.round(indexForHeight(state.animTarget.value, geometry.detents.value, closed))
|
|
143
|
+
: state.currentIndex.value;
|
|
144
|
+
const target = config.keyboardBehavior === "fillParent" ? geometry.maxHeight.value : geometry.highest.value;
|
|
145
|
+
if (running || Math.abs(target - state.base.value) > 0.5) animateTo(target, "keyboard", 0);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
applied.value = false;
|
|
150
|
+
const index = indexBefore.value;
|
|
151
|
+
indexBefore.value = CLOSED_INDEX;
|
|
152
|
+
if (step !== "restore" || dragging || !sheetOpen || index < 0) return;
|
|
153
|
+
|
|
154
|
+
const detents = geometry.detents.value;
|
|
155
|
+
if (detents.length === 0) return;
|
|
156
|
+
const target = heightForIndex(Math.min(index, detents.length - 1), detents, closed);
|
|
157
|
+
if (Math.abs(target - state.base.value) > 0.5) animateTo(target, "keyboard", 0);
|
|
158
|
+
},
|
|
159
|
+
[state, geometry, animateTo, progress, applied, indexBefore]
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return useMemo<SheetKeyboardRegistry>(() => ({ nodes, focused }), [focused]);
|
|
163
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# layout
|
|
2
|
+
|
|
3
|
+
How the sheet learns the sizes the geometry needs.
|
|
4
|
+
|
|
5
|
+
## Files
|
|
6
|
+
|
|
7
|
+
| Path | What |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `use-measure-height.ts` | An `onLayout` that writes a view's height into a shared value, and resets it to `UNMEASURED` on unmount |
|
|
10
|
+
| `use-container-layout.ts` | The frame's `onLayout` and ref: height and width, plus `measureInWindow` for the offset between the frame's bottom and the window's |
|
|
11
|
+
|
|
12
|
+
## What is measured
|
|
13
|
+
|
|
14
|
+
| Value | By | Read for |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| `containerHeight`, `containerWidth` | the frame `Portal` renders | every detent, `position`, the detached frame |
|
|
17
|
+
| `containerBottomOffset` | the same frame, via `measureInWindow` | how much of a keyboard overlaps the sheet at all (BSHEET-3) |
|
|
18
|
+
| `handleHeight` | `Handle` | the dynamic detent, `contentArea` |
|
|
19
|
+
| `contentHeight` | `Content`'s inner box | the dynamic detent |
|
|
20
|
+
| `footerContentHeight` | `Footer`'s inner box (BSHEET-3) | the dynamic detent, `footerTop` |
|
|
21
|
+
|
|
22
|
+
Scrollables (BSHEET-4) feed `contentHeight` from `onContentSizeChange`
|
|
23
|
+
instead of a layout.
|
|
24
|
+
|
|
25
|
+
## The reset is the point
|
|
26
|
+
|
|
27
|
+
A portal that unmounts its children on close takes the handle and the content
|
|
28
|
+
with it. If their heights stayed, `layoutReady` would be true for a sheet with
|
|
29
|
+
nothing in it and the next open would animate before the new content had
|
|
30
|
+
measured. Every measuring hook resets its value to `UNMEASURED` on unmount, so
|
|
31
|
+
the open goes back on the intent queue until the measurement lands again.
|
|
32
|
+
|
|
33
|
+
A handle that is never written is a handle of height zero: `Container` reports
|
|
34
|
+
that on mount when no `Handle` registered, so a handle-less sheet is not a
|
|
35
|
+
sheet that never becomes ready.
|
|
36
|
+
|
|
37
|
+
## Container resize
|
|
38
|
+
|
|
39
|
+
The frame's height goes through `acceptContainerLayout`, which refuses
|
|
40
|
+
Android's adjustResize double-count while a keyboard is up. Inert until
|
|
41
|
+
BSHEET-3 writes `keyboardHeight`, and correct once it does. A height that is
|
|
42
|
+
accepted while the sheet is open makes the intent reaction jump the sheet to
|
|
43
|
+
the same index in the re-derived detents (`state/`).
|
|
44
|
+
|
|
45
|
+
## No synchronous first read
|
|
46
|
+
|
|
47
|
+
The plan mentioned a Fabric `getBoundingClientRect` in a layout effect for a
|
|
48
|
+
synchronous first measurement. It is not here: the open intent waits on
|
|
49
|
+
`layoutReady` anyway, so the only thing a synchronous read would buy is one
|
|
50
|
+
frame off the first open, and it would buy it with a host-instance method the
|
|
51
|
+
type definitions do not yet name. Add it here, in these two hooks, if that
|
|
52
|
+
frame ever shows.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type RefObject, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { Dimensions, type LayoutChangeEvent, type View } from "react-native";
|
|
3
|
+
import { acceptContainerLayout, UNMEASURED } from "../core";
|
|
4
|
+
import type { SheetSharedState } from "../state/state.types";
|
|
5
|
+
|
|
6
|
+
export type ContainerLayout = {
|
|
7
|
+
ref: RefObject<View | null>;
|
|
8
|
+
onLayout: (event: LayoutChangeEvent) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Measures the frame every sheet renders around itself.
|
|
13
|
+
*
|
|
14
|
+
* `onLayout` gives the height and width; `measureInWindow` gives where the
|
|
15
|
+
* frame's bottom edge sits relative to the window's, which is what a keyboard
|
|
16
|
+
* has to clear before it overlaps the sheet — a frame inside a tab bar's
|
|
17
|
+
* screen ends above the window, and the keyboard's first pixels overlap
|
|
18
|
+
* nothing.
|
|
19
|
+
*
|
|
20
|
+
* The height goes through `acceptContainerLayout`, which refuses Android's
|
|
21
|
+
* adjustResize double-count while a keyboard is up. Inert until BSHEET-3
|
|
22
|
+
* writes `keyboardHeight`, and correct once it does.
|
|
23
|
+
*
|
|
24
|
+
* Unmounting resets every value to `UNMEASURED`, for the same reason
|
|
25
|
+
* `useMeasureHeight` does.
|
|
26
|
+
*/
|
|
27
|
+
export function useContainerLayout(state: SheetSharedState): ContainerLayout {
|
|
28
|
+
const ref = useRef<View | null>(null);
|
|
29
|
+
const { containerHeight, containerWidth, containerBottomOffset, keyboardHeight, keyboardProgress } = state;
|
|
30
|
+
|
|
31
|
+
useEffect(
|
|
32
|
+
() => () => {
|
|
33
|
+
containerHeight.value = UNMEASURED;
|
|
34
|
+
containerWidth.value = UNMEASURED;
|
|
35
|
+
containerBottomOffset.value = 0;
|
|
36
|
+
},
|
|
37
|
+
[containerHeight, containerWidth, containerBottomOffset]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const onLayout = useCallback(
|
|
41
|
+
(event: LayoutChangeEvent) => {
|
|
42
|
+
const { height, width } = event.nativeEvent.layout;
|
|
43
|
+
if (containerWidth.value !== width) containerWidth.value = width;
|
|
44
|
+
|
|
45
|
+
const accepted = acceptContainerLayout({
|
|
46
|
+
prev: containerHeight.value,
|
|
47
|
+
next: height,
|
|
48
|
+
keyboardHeight: keyboardHeight.value,
|
|
49
|
+
progress: keyboardProgress.value,
|
|
50
|
+
});
|
|
51
|
+
if (accepted && containerHeight.value !== height) containerHeight.value = height;
|
|
52
|
+
|
|
53
|
+
ref.current?.measureInWindow((_x, y, _width, measuredHeight) => {
|
|
54
|
+
const offset = Dimensions.get("window").height - (y + measuredHeight);
|
|
55
|
+
const clamped = Number.isFinite(offset) && offset > 0 ? offset : 0;
|
|
56
|
+
if (containerBottomOffset.value !== clamped) containerBottomOffset.value = clamped;
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
[containerHeight, containerWidth, containerBottomOffset, keyboardHeight, keyboardProgress]
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return { ref, onLayout };
|
|
63
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useCallback, useEffect } from "react";
|
|
2
|
+
import type { LayoutChangeEvent } from "react-native";
|
|
3
|
+
import type { SharedValue } from "react-native-reanimated";
|
|
4
|
+
import { UNMEASURED } from "../core";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Feeds a view's height into a shared value from its `onLayout`, and resets
|
|
8
|
+
* it to `UNMEASURED` when the view goes away.
|
|
9
|
+
*
|
|
10
|
+
* The reset is the important half. A portal that unmounts its children on
|
|
11
|
+
* close takes the handle and the content with it; if their heights stayed,
|
|
12
|
+
* `layoutReady` would be true for a sheet with nothing in it and the next open
|
|
13
|
+
* would animate before the new content had measured. `-1` puts the open back
|
|
14
|
+
* on the queue until the measurement lands again.
|
|
15
|
+
*
|
|
16
|
+
* `subtract` takes a constant off every measurement, never below zero: the
|
|
17
|
+
* footer measures its styled box, which pads through the safe-area band, and
|
|
18
|
+
* records the box less the band.
|
|
19
|
+
*
|
|
20
|
+
* Written once per part rather than shared with the container, because a part
|
|
21
|
+
* only wants its own height; the container also wants its offset from the
|
|
22
|
+
* window's bottom.
|
|
23
|
+
*/
|
|
24
|
+
export function useMeasureHeight(target: SharedValue<number>, subtract = 0): (event: LayoutChangeEvent) => void {
|
|
25
|
+
useEffect(
|
|
26
|
+
() => () => {
|
|
27
|
+
target.value = UNMEASURED;
|
|
28
|
+
},
|
|
29
|
+
[target]
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return useCallback(
|
|
33
|
+
(event: LayoutChangeEvent) => {
|
|
34
|
+
const measured = event.nativeEvent.layout.height - subtract;
|
|
35
|
+
const height = measured > 0 ? measured : 0;
|
|
36
|
+
if (target.value !== height) target.value = height;
|
|
37
|
+
},
|
|
38
|
+
[target, subtract]
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# lib
|
|
2
|
+
|
|
3
|
+
Four utilities copied from `@delacour/react-native-ui`, with the one thing
|
|
4
|
+
this package must not have removed.
|
|
5
|
+
|
|
6
|
+
## Files
|
|
7
|
+
|
|
8
|
+
| Path | What |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| `slot.tsx` | `Slot` — renders its props into its single child; what backs every `asChild` |
|
|
11
|
+
| `merge-props.ts` | Child props win; event handlers chain slot-first; `style` arrays flatten with the child last |
|
|
12
|
+
| `compose-refs.ts` | Fans one node out to several refs, honouring React 19 cleanup returns |
|
|
13
|
+
| `use-controllable-state.ts` | Controlled or uncontrolled from one hook; the mode is locked in on first render |
|
|
14
|
+
|
|
15
|
+
## What changed in the copy
|
|
16
|
+
|
|
17
|
+
`merge-props.ts` in the skin also merges class strings through its `cn`. This
|
|
18
|
+
package has no class strings to merge — `src/no-classname.test.ts` — so the
|
|
19
|
+
branch is gone and nothing here imports `cn`. Everything else is the same
|
|
20
|
+
file, and should stay the same file: a fix in one belongs in both.
|
|
21
|
+
|
|
22
|
+
## Why copied rather than imported
|
|
23
|
+
|
|
24
|
+
`@delacour/react-native-ui` is the skin over this engine, and it depends on
|
|
25
|
+
the engine as an optional peer. The engine importing the skin's `lib` would
|
|
26
|
+
close that cycle, and a published package cannot reach into a sibling
|
|
27
|
+
workspace's `src` anyway. Four small files is the price of a clean direction.
|
|
28
|
+
|
|
29
|
+
## Slot and animated styles
|
|
30
|
+
|
|
31
|
+
Do not pass a Reanimated style through `Slot`. React Native deep freezes style
|
|
32
|
+
props on a non-animated component in development, and Reanimated's effect then
|
|
33
|
+
throws trying to write to the frozen object. No part here does; a part that
|
|
34
|
+
needs to would render the child through an animated counterpart of its own
|
|
35
|
+
type, the way the skin's `Pressable` does.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Ref } from "react";
|
|
2
|
+
|
|
3
|
+
type RefCleanup = () => void;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fans a single node out to several refs.
|
|
7
|
+
*
|
|
8
|
+
* React 19 lets a callback ref return a cleanup function. When any of the given
|
|
9
|
+
* refs does, the composed ref returns a cleanup of its own that runs each child
|
|
10
|
+
* cleanup and nulls out every object ref.
|
|
11
|
+
*/
|
|
12
|
+
export function composeRefs<T>(...refs: (Ref<T> | undefined)[]): (node: T | null) => RefCleanup | undefined {
|
|
13
|
+
return (node) => {
|
|
14
|
+
const cleanups: RefCleanup[] = [];
|
|
15
|
+
|
|
16
|
+
for (const ref of refs) {
|
|
17
|
+
if (!ref) continue;
|
|
18
|
+
|
|
19
|
+
if (typeof ref === "function") {
|
|
20
|
+
const result = ref(node);
|
|
21
|
+
if (typeof result === "function") cleanups.push(result);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ref.current = node;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (cleanups.length === 0) return undefined;
|
|
29
|
+
|
|
30
|
+
return () => {
|
|
31
|
+
for (const cleanup of cleanups) cleanup();
|
|
32
|
+
for (const ref of refs) {
|
|
33
|
+
if (ref && typeof ref !== "function") ref.current = null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
type UnknownProps = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
function isEventHandlerKey(key: string): boolean {
|
|
4
|
+
return key.startsWith("on") && key.length > 2 && key[2] === key[2]?.toUpperCase();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Merges a slot's own props with those of the element it renders into.
|
|
9
|
+
*
|
|
10
|
+
* Child props win, with two exceptions: event handlers are chained
|
|
11
|
+
* slot-first-then-child so neither side is silently dropped, and `style` is
|
|
12
|
+
* flattened into an array with the child last (React Native resolves style
|
|
13
|
+
* arrays left to right).
|
|
14
|
+
*
|
|
15
|
+
* The copy in `@delacour/react-native-ui` also merges class strings; this
|
|
16
|
+
* package has none to merge, which is the whole point of it.
|
|
17
|
+
*/
|
|
18
|
+
export function mergeProps<T extends UnknownProps>(slotProps: UnknownProps, childProps: UnknownProps): T {
|
|
19
|
+
const merged: UnknownProps = { ...slotProps };
|
|
20
|
+
|
|
21
|
+
for (const key of Object.keys(childProps)) {
|
|
22
|
+
const slotValue = slotProps[key];
|
|
23
|
+
const childValue = childProps[key];
|
|
24
|
+
|
|
25
|
+
if (isEventHandlerKey(key) && typeof slotValue === "function" && typeof childValue === "function") {
|
|
26
|
+
merged[key] = (...args: unknown[]) => {
|
|
27
|
+
(slotValue as (...a: unknown[]) => unknown)(...args);
|
|
28
|
+
return (childValue as (...a: unknown[]) => unknown)(...args);
|
|
29
|
+
};
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (key === "style" && slotValue != null && childValue != null) {
|
|
34
|
+
merged[key] = [slotValue, childValue].flat();
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
merged[key] = childValue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return merged as T;
|
|
42
|
+
}
|
package/src/lib/slot.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode, type Ref } from "react";
|
|
2
|
+
import { composeRefs } from "./compose-refs";
|
|
3
|
+
import { mergeProps } from "./merge-props";
|
|
4
|
+
|
|
5
|
+
type SlotProps = {
|
|
6
|
+
children?: ReactNode;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type ChildProps = Record<string, unknown> & { ref?: Ref<unknown> };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Renders its props into its single child instead of emitting an element.
|
|
14
|
+
*
|
|
15
|
+
* This is what backs every `asChild` prop here: `<BottomSheet.Trigger asChild>`
|
|
16
|
+
* hands its `onPress` to the button inside it with no extra view in the tree.
|
|
17
|
+
* Props are combined by {@link mergeProps} and refs by {@link composeRefs}.
|
|
18
|
+
*
|
|
19
|
+
* React 19 exposes `ref` as an ordinary prop, so no `forwardRef` is involved.
|
|
20
|
+
*
|
|
21
|
+
* Do not pass a Reanimated style through this. React Native deep freezes style
|
|
22
|
+
* props on a non-animated component in development, and Reanimated's effect
|
|
23
|
+
* then throws trying to write to the frozen object.
|
|
24
|
+
*/
|
|
25
|
+
export function Slot({ children, ...slotProps }: SlotProps): ReactElement {
|
|
26
|
+
const count = Children.count(children);
|
|
27
|
+
|
|
28
|
+
if (count !== 1) {
|
|
29
|
+
throw new Error(`Slot expects exactly one child element, received ${count}. Did you mean to omit \`asChild\`?`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const child = Children.only(children);
|
|
33
|
+
|
|
34
|
+
if (!isValidElement(child)) {
|
|
35
|
+
throw new Error("Slot expects a single React element child; text and fragments cannot receive props.");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const childProps = child.props as ChildProps;
|
|
39
|
+
const merged = mergeProps<ChildProps>(slotProps, childProps);
|
|
40
|
+
merged.ref = composeRefs(slotProps.ref as Ref<unknown> | undefined, childProps.ref);
|
|
41
|
+
|
|
42
|
+
return cloneElement(child, merged);
|
|
43
|
+
}
|
|
44
|
+
Slot.displayName = "DelacourBottomSheet.Slot";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
type UseControllableStateOptions<T> = {
|
|
4
|
+
/** Controlled value. When not `undefined` the hook defers to the caller. */
|
|
5
|
+
value?: T;
|
|
6
|
+
/** Initial value used while uncontrolled. */
|
|
7
|
+
defaultValue: T;
|
|
8
|
+
onChange?: (value: T) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Supports both controlled and uncontrolled usage from one hook.
|
|
13
|
+
*
|
|
14
|
+
* While `value` is undefined the state is held internally; once a value is
|
|
15
|
+
* passed the caller owns it and the setter only reports upward. Which mode is
|
|
16
|
+
* in play is locked in on first render — switching later is a caller bug and
|
|
17
|
+
* warns in development rather than silently changing behaviour.
|
|
18
|
+
*/
|
|
19
|
+
export function useControllableState<T>({
|
|
20
|
+
value,
|
|
21
|
+
defaultValue,
|
|
22
|
+
onChange,
|
|
23
|
+
}: UseControllableStateOptions<T>): [T, (next: T) => void] {
|
|
24
|
+
const isControlled = value !== undefined;
|
|
25
|
+
const wasControlled = useRef(isControlled);
|
|
26
|
+
const [uncontrolled, setUncontrolled] = useState<T>(defaultValue);
|
|
27
|
+
|
|
28
|
+
if (process.env.NODE_ENV !== "production" && wasControlled.current !== isControlled) {
|
|
29
|
+
console.warn(
|
|
30
|
+
`useControllableState: switched from ${wasControlled.current ? "controlled" : "uncontrolled"} to ` +
|
|
31
|
+
`${isControlled ? "controlled" : "uncontrolled"}. Pick one for the lifetime of the component.`
|
|
32
|
+
);
|
|
33
|
+
wasControlled.current = isControlled;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const setValue = useCallback(
|
|
37
|
+
(next: T) => {
|
|
38
|
+
if (!isControlled) setUncontrolled(next);
|
|
39
|
+
onChange?.(next);
|
|
40
|
+
},
|
|
41
|
+
[isControlled, onChange]
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return [isControlled ? value : uncontrolled, setValue];
|
|
45
|
+
}
|