@delacour/react-native-bottom-sheet 0.1.0-alpha.0 → 0.1.0-alpha.20261006140348
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 +2 -2
- package/package.json +1 -1
- package/src/animation/AGENTS.md +1 -1
- package/src/animation/animation.types.ts +1 -1
- package/src/animation/use-animate-to.ts +4 -4
- package/src/animation/use-settle-callbacks.ts +3 -3
- package/src/components/AGENTS.md +2 -2
- package/src/components/bottom-sheet-background.tsx +1 -1
- package/src/components/bottom-sheet-content.tsx +3 -3
- package/src/components/bottom-sheet-footer.tsx +3 -3
- package/src/components/bottom-sheet-handle.tsx +5 -5
- package/src/components/bottom-sheet-overlay.tsx +1 -1
- package/src/components/bottom-sheet.context.tsx +1 -1
- package/src/components/bottom-sheet.tsx +13 -11
- package/src/components/bottom-sheet.types.ts +15 -15
- package/src/core/AGENTS.md +17 -17
- package/src/core/backdrop/backdrop-opacity.test.ts +2 -2
- package/src/core/backdrop/backdrop-opacity.ts +1 -1
- package/src/core/footer/body-inset.test.ts +3 -3
- package/src/core/footer/body-inset.ts +3 -3
- package/src/core/geometry/position.ts +1 -1
- package/src/core/geometry/surface-height.test.ts +5 -5
- package/src/core/geometry/surface-height.ts +7 -7
- package/src/core/haptic/crossed-snap-point.test.ts +43 -0
- package/src/core/haptic/crossed-snap-point.ts +33 -0
- package/src/core/index.ts +14 -9
- package/src/core/intent/layout-ready.ts +1 -1
- package/src/core/intent/resolve-intent.test.ts +9 -9
- package/src/core/intent/resolve-intent.ts +10 -10
- package/src/core/keyboard/container-layout-guard.ts +1 -1
- package/src/core/keyboard/keyboard-step.ts +2 -2
- package/src/core/machine/machine.types.ts +2 -2
- package/src/core/machine/step-transition.ts +2 -2
- package/src/core/scroll/scroll-lock.test.ts +2 -2
- package/src/core/scroll/scroll-lock.ts +2 -2
- package/src/core/scroll/scroll-pan.test.ts +14 -14
- package/src/core/scroll/scroll-pan.ts +14 -14
- package/src/core/sheet.types.ts +5 -5
- package/src/core/snap-points/dynamic-snap-point.test.ts +44 -0
- package/src/core/{detents/dynamic-detent.ts → snap-points/dynamic-snap-point.ts} +5 -5
- package/src/core/snap-points/index-for-height.test.ts +65 -0
- package/src/core/{detents → snap-points}/index-for-height.ts +11 -11
- package/src/core/snap-points/normalize-snap-points.test.ts +72 -0
- package/src/core/{detents/normalize-detents.ts → snap-points/normalize-snap-points.ts} +9 -9
- package/src/core/{detents → snap-points}/select-snap-height.test.ts +10 -8
- package/src/core/{detents → snap-points}/select-snap-height.ts +5 -5
- package/src/core/snap-points/sheet-state.test.ts +42 -0
- package/src/core/{detents → snap-points}/sheet-state.ts +6 -6
- package/src/gesture/AGENTS.md +11 -11
- package/src/gesture/gesture.types.ts +3 -3
- package/src/gesture/use-scroll-lock.ts +3 -3
- package/src/gesture/use-sheet-pan.ts +43 -32
- package/src/keyboard/AGENTS.md +2 -2
- package/src/keyboard/use-sheet-keyboard.ts +6 -6
- package/src/layout/AGENTS.md +5 -5
- package/src/portal/AGENTS.md +14 -5
- package/src/portal/bottom-sheet-provider.tsx +19 -7
- package/src/scrollable/AGENTS.md +5 -5
- package/src/scrollable/bottom-sheet-scroll-view.tsx +1 -1
- package/src/scrollable/create-bottom-sheet-scrollable.tsx +3 -3
- package/src/state/AGENTS.md +4 -4
- package/src/state/state.types.ts +11 -11
- package/src/state/use-sheet-geometry.ts +11 -11
- package/src/state/use-sheet-intents.ts +12 -9
- package/src/state/use-sheet-state.ts +9 -9
- package/src/state/{use-detents.ts → use-snap-points.ts} +13 -13
- package/src/steps/AGENTS.md +7 -7
- package/src/steps/bottom-sheet-steps.tsx +9 -9
- package/src/core/detents/dynamic-detent.test.ts +0 -40
- package/src/core/detents/index-for-height.test.ts +0 -65
- package/src/core/detents/normalize-detents.test.ts +0 -72
- package/src/core/detents/sheet-state.test.ts +0 -42
- package/src/core/haptic/crossed-detent.test.ts +0 -43
- package/src/core/haptic/crossed-detent.ts +0 -33
- /package/src/core/{detents → snap-points}/over-drag.test.ts +0 -0
- /package/src/core/{detents → snap-points}/over-drag.ts +0 -0
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ import { BottomSheet } from "@delacour/react-native-bottom-sheet";
|
|
|
104
104
|
|
|
105
105
|
`Container` is the panel that moves; `Content` is the static body inside it.
|
|
106
106
|
Swap the body for `BottomSheet.ScrollView`, `FlatList` or `SectionList` and
|
|
107
|
-
the list's content size becomes the sheet's own
|
|
107
|
+
the list's content size becomes the sheet's own snap point; swap it for
|
|
108
108
|
`BottomSheet.Steps` with a controller from `useSheetMachine` and the sheet's
|
|
109
109
|
height glides between the steps of a typed machine. `Footer` is sticky by
|
|
110
110
|
default, standing on the safe-area band and riding the keyboard's edge.
|
|
@@ -119,7 +119,7 @@ take `asChild` to hand their behaviour to an element of yours.
|
|
|
119
119
|
|
|
120
120
|
## The maths on its own
|
|
121
121
|
|
|
122
|
-
`@delacour/react-native-bottom-sheet/core` is every
|
|
122
|
+
`@delacour/react-native-bottom-sheet/core` is every snap point, keyboard, footer
|
|
123
123
|
and geometry calculation in the package, plus the step machine, importable
|
|
124
124
|
with no native module in the module graph:
|
|
125
125
|
|
package/package.json
CHANGED
package/src/animation/AGENTS.md
CHANGED
|
@@ -29,7 +29,7 @@ index the sheet never reached. Settling writes `currentIndex` and schedules
|
|
|
29
29
|
`animateOnMount: false`.
|
|
30
30
|
|
|
31
31
|
`settleAt` is the completion alone: no `onAnimate`, no target recorded, `base`
|
|
32
|
-
written to the
|
|
32
|
+
written to the snap point it is already within a settle tolerance of, then the same
|
|
33
33
|
`currentIndex` write and `onSettle`. The content pan uses it for a release the
|
|
34
34
|
list owns — the finger carried the sheet to the top and kept scrolling, so
|
|
35
35
|
nothing animated and nothing else would report the index.
|
|
@@ -22,7 +22,7 @@ export type JumpTo = (target: number, source: AnimationSource) => void;
|
|
|
22
22
|
*/
|
|
23
23
|
export type SettleAt = (target: number, source: AnimationSource) => void;
|
|
24
24
|
|
|
25
|
-
/** What the JS thread hears when an animation lands — `count` is how many
|
|
25
|
+
/** What the JS thread hears when an animation lands — `count` is how many snap points there were, for the handle's accessibility value. */
|
|
26
26
|
export type SettleListener = (index: number, height: number, source: AnimationSource, count: number) => void;
|
|
27
27
|
|
|
28
28
|
/** What the JS thread hears when an animation starts. */
|
|
@@ -25,7 +25,7 @@ export type UseAnimateToOptions = {
|
|
|
25
25
|
* timing whose completion writes `currentIndex` and reports the settle.
|
|
26
26
|
* `jumpTo` is the same without the motion — `forceClose`, a container resize,
|
|
27
27
|
* and `animateOnMount: false`. `settleAt` is the completion alone: no
|
|
28
|
-
* `onAnimate`, no target recorded, `base` written to the
|
|
28
|
+
* `onAnimate`, no target recorded, `base` written to the snap point it is already
|
|
29
29
|
* within a settle tolerance of. It is for the content pan whose release the
|
|
30
30
|
* list owns — the finger carried the sheet to the top and kept scrolling, so
|
|
31
31
|
* nothing animated and nothing would otherwise write `currentIndex`.
|
|
@@ -57,9 +57,9 @@ export function useAnimateTo(options: UseAnimateToOptions): {
|
|
|
57
57
|
const settle = (target: number, source: AnimationSource): void => {
|
|
58
58
|
"worklet";
|
|
59
59
|
state.animStatus.value = ANIM_STATUS.IDLE;
|
|
60
|
-
const index = Math.round(indexForHeight(target, geometry.
|
|
60
|
+
const index = Math.round(indexForHeight(target, geometry.snapPoints.value, geometry.closedHeight.value));
|
|
61
61
|
state.currentIndex.value = index;
|
|
62
|
-
scheduleOnRN(onSettle, index, target, source, geometry.
|
|
62
|
+
scheduleOnRN(onSettle, index, target, source, geometry.snapPoints.value.length);
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
const begin = (target: number, source: AnimationSource): void => {
|
|
@@ -67,7 +67,7 @@ export function useAnimateTo(options: UseAnimateToOptions): {
|
|
|
67
67
|
cancelAnimation(state.base);
|
|
68
68
|
const fromHeight = state.base.value;
|
|
69
69
|
const fromIndex = geometry.index.value;
|
|
70
|
-
const toIndex = indexForHeight(target, geometry.
|
|
70
|
+
const toIndex = indexForHeight(target, geometry.snapPoints.value, geometry.closedHeight.value);
|
|
71
71
|
state.animStatus.value = ANIM_STATUS.RUNNING;
|
|
72
72
|
state.animSource.value = source;
|
|
73
73
|
state.animTarget.value = target;
|
|
@@ -7,9 +7,9 @@ export type UseSettleCallbacksOptions = {
|
|
|
7
7
|
setOpen: (open: boolean) => void;
|
|
8
8
|
/** Unmounts the portal's children once a close has landed, unless the sheet is kept mounted. */
|
|
9
9
|
setPresented: (presented: boolean) => void;
|
|
10
|
-
/** The settled index and the
|
|
10
|
+
/** The settled index and the snap point count, for the handle's accessibility value. */
|
|
11
11
|
setIndex: (index: number) => void;
|
|
12
|
-
|
|
12
|
+
setSnapPointCount: (count: number) => void;
|
|
13
13
|
onIndexChange: ((index: number, height: number, source: AnimationSource) => void) | undefined;
|
|
14
14
|
onClose: (() => void) | undefined;
|
|
15
15
|
onAnimate: AnimateListener | undefined;
|
|
@@ -41,7 +41,7 @@ export function useSettleCallbacks(options: UseSettleCallbacksOptions): {
|
|
|
41
41
|
const onSettle = useCallback<SettleListener>((index, height, source, count) => {
|
|
42
42
|
const current = latest.current;
|
|
43
43
|
current.setIndex(index);
|
|
44
|
-
current.
|
|
44
|
+
current.setSnapPointCount(count);
|
|
45
45
|
if (index !== lastReported.current) {
|
|
46
46
|
lastReported.current = index;
|
|
47
47
|
current.onIndexChange?.(index, height, source);
|
package/src/components/AGENTS.md
CHANGED
|
@@ -67,8 +67,8 @@ No `accessible` container: one would collapse the whole sheet into a single
|
|
|
67
67
|
VoiceOver element. Instead the panel is `accessibilityViewIsModal` while open
|
|
68
68
|
under an overlay, its descendants are hidden while it is closed, and the title
|
|
69
69
|
labels it through `accessibilityLabelledBy`. The handle is the sheet's one
|
|
70
|
-
`adjustable` element: increment and decrement move a
|
|
71
|
-
and its value reads `
|
|
70
|
+
`adjustable` element: increment and decrement move a snap point, escape closes,
|
|
71
|
+
and its value reads `SnapPoint i of n`. The overlay is hidden from assistive
|
|
72
72
|
technology while it is transparent.
|
|
73
73
|
|
|
74
74
|
## Contexts
|
|
@@ -13,7 +13,7 @@ import type { BottomSheetBackgroundProps } from "./bottom-sheet.types";
|
|
|
13
13
|
* The panel is as tall as the frame, so an attached surface fills it and its
|
|
14
14
|
* bottom edge is always off-screen. A detached card has a visible bottom
|
|
15
15
|
* edge: the surface is then the geometry's `surfaceHeight` tall, on the UI
|
|
16
|
-
* thread — the sheet's height between its
|
|
16
|
+
* thread — the sheet's height between its snap points, and the nearest snap point's
|
|
17
17
|
* height beyond them — so the corners the skin rounds are on screen, the gap
|
|
18
18
|
* under the card is the overlay's, and a close or an over-drag moves the
|
|
19
19
|
* whole card as one rigid body rather than shrinking it onto the resting
|
|
@@ -20,10 +20,10 @@ const UNCLAMPED = 100_000;
|
|
|
20
20
|
* Three boxes. The outermost is the content pan's detector and the clip: its
|
|
21
21
|
* `maxHeight` follows the geometry's `bodyClip` on the UI thread, so under a
|
|
22
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
|
|
23
|
+
* rest or while the sheet is dragged below its snap point and the footer holds
|
|
24
24
|
* the screen's bottom. The middle box is the layout: `contentArea` plus the
|
|
25
25
|
* `bodyInset` the body reserves at its end, which reaches the sheet's bottom
|
|
26
|
-
* line, and it never reflows during a snap between
|
|
26
|
+
* line, and it never reflows during a snap between snap points. The innermost is
|
|
27
27
|
* the caller's — it takes the `style` — and its measured height is what a
|
|
28
28
|
* sheet sized to its content grows to. Yoga lays the inner box out at its
|
|
29
29
|
* natural height whatever the outer clamps say, which is what makes the
|
|
@@ -56,7 +56,7 @@ export function BottomSheetContent({
|
|
|
56
56
|
|
|
57
57
|
// A detached card's surface is only as tall as the sheet, so its body is
|
|
58
58
|
// also held to what the surface shows, or the opening animation would draw
|
|
59
|
-
// the body under the card. The surface keeps its
|
|
59
|
+
// the body under the card. The surface keeps its snap point's height while the
|
|
60
60
|
// card slides closed, so the body slides with it, unchanged.
|
|
61
61
|
const clip = useAnimatedStyle(() => {
|
|
62
62
|
if (!layoutReady.value) return { maxHeight: UNCLAMPED };
|
|
@@ -17,10 +17,10 @@ import type { BottomSheetFooterProps } from "./bottom-sheet.types";
|
|
|
17
17
|
* keyboard's top edge without ever moving. Its one view — the one that takes
|
|
18
18
|
* `style` and `padding` — pads through the resting safe-area band as well, so
|
|
19
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
|
|
20
|
+
* background covers the band: while the sheet is dragged below its snap point the
|
|
21
21
|
* footer holds the screen's bottom edge and the body slides down behind it,
|
|
22
22
|
* and a transparent band would show every line that passed. The box is
|
|
23
|
-
* measured less the band into `footerContentHeight`, which the dynamic
|
|
23
|
+
* measured less the band into `footerContentHeight`, which the dynamic snap point
|
|
24
24
|
* counts. The band is constant on purpose — the footer's translate does the
|
|
25
25
|
* collapsing, and padding that shrank as well would move the footer twice.
|
|
26
26
|
*
|
|
@@ -53,7 +53,7 @@ export function BottomSheetFooter({
|
|
|
53
53
|
|
|
54
54
|
// A layout effect, not a passive one: the flag has to reach the root's
|
|
55
55
|
// config before the handle and the content report their heights, or the
|
|
56
|
-
// first open resolves on a
|
|
56
|
+
// first open resolves on a snap point with no footer in it and the mount
|
|
57
57
|
// animation, already running, keeps the corrected list from applying.
|
|
58
58
|
useLayoutEffect(() => {
|
|
59
59
|
if (!sticky) return;
|
|
@@ -15,9 +15,9 @@ const ACTIONS = [{ name: "increment" }, { name: "decrement" }, { name: "escape"
|
|
|
15
15
|
* sized to its content counts it. A handle that is never written is a handle
|
|
16
16
|
* of height zero; see `Container`.
|
|
17
17
|
*
|
|
18
|
-
* For a screen reader it is the sheet: `adjustable`, with the
|
|
19
|
-
* value, increment and decrement moving one
|
|
20
|
-
* gesture closing. Nothing else in the sheet has to know about
|
|
18
|
+
* For a screen reader it is the sheet: `adjustable`, with the snap point as its
|
|
19
|
+
* value, increment and decrement moving one snap point either way, and the escape
|
|
20
|
+
* gesture closing. Nothing else in the sheet has to know about snap points.
|
|
21
21
|
*/
|
|
22
22
|
export function BottomSheetHandle({
|
|
23
23
|
style,
|
|
@@ -27,7 +27,7 @@ export function BottomSheetHandle({
|
|
|
27
27
|
...props
|
|
28
28
|
}: BottomSheetHandleProps): ReactElement {
|
|
29
29
|
const { state, pans, handleMounted, enableHandlePanningGesture } = useBottomSheetInternal();
|
|
30
|
-
const { index,
|
|
30
|
+
const { index, snapPointCount: count, snapToIndex, close } = useBottomSheet();
|
|
31
31
|
const onLayout = useMeasureHeight(state.handleHeight);
|
|
32
32
|
|
|
33
33
|
useEffect(() => {
|
|
@@ -56,7 +56,7 @@ export function BottomSheetHandle({
|
|
|
56
56
|
accessibilityActions={ACTIONS}
|
|
57
57
|
accessibilityLabel={accessibilityLabel}
|
|
58
58
|
accessibilityRole="adjustable"
|
|
59
|
-
accessibilityValue={{ text: index < 0 ? "Closed" : `
|
|
59
|
+
accessibilityValue={{ text: index < 0 ? "Closed" : `Snap point ${index + 1} of ${count}` }}
|
|
60
60
|
accessible
|
|
61
61
|
onAccessibilityAction={onAccessibilityAction}
|
|
62
62
|
onAccessibilityEscape={close}
|
|
@@ -9,7 +9,7 @@ import type { BottomSheetOverlayProps } from "./bottom-sheet.types";
|
|
|
9
9
|
* The scrim over the app behind the sheet.
|
|
10
10
|
*
|
|
11
11
|
* Its opacity follows the keyboard-free index on the UI thread, so a keyboard
|
|
12
|
-
* lift never dims the app and a higher
|
|
12
|
+
* lift never dims the app and a higher snap point is no darker. Omit it and the
|
|
13
13
|
* sheet has no scrim at all.
|
|
14
14
|
*
|
|
15
15
|
* **It is a React Native `Pressable`, never a Gesture Handler tap, and it is
|
|
@@ -70,7 +70,7 @@ export type BottomSheetInternalValue = {
|
|
|
70
70
|
setHasOverlay: (has: boolean) => void;
|
|
71
71
|
/** A handle sets this on mount; a container with none reports a zero handle height. */
|
|
72
72
|
handleMounted: RefObject<boolean>;
|
|
73
|
-
/** A sticky footer sets this on mount; the dynamic
|
|
73
|
+
/** A sticky footer sets this on mount; the dynamic snap point counts it and the body reserves space for it. */
|
|
74
74
|
hasFooter: boolean;
|
|
75
75
|
setHasFooter: (has: boolean) => void;
|
|
76
76
|
/** Where `useBottomSheetTextInput` registers its field. */
|
|
@@ -118,7 +118,7 @@ function BottomSheetRoot({
|
|
|
118
118
|
keepMounted = false,
|
|
119
119
|
stackBehavior = "push",
|
|
120
120
|
closeOnBack = true,
|
|
121
|
-
|
|
121
|
+
onSnapPointHaptic,
|
|
122
122
|
onCloseHaptic,
|
|
123
123
|
onOverDragHaptic,
|
|
124
124
|
}: BottomSheetProps): ReactElement {
|
|
@@ -129,7 +129,7 @@ function BottomSheetRoot({
|
|
|
129
129
|
});
|
|
130
130
|
const [presented, setPresented] = useState(isOpen);
|
|
131
131
|
const [index, setIndex] = useState(CLOSED_INDEX);
|
|
132
|
-
const [
|
|
132
|
+
const [snapPointCount, setSnapPointCount] = useState(0);
|
|
133
133
|
const [hasOverlay, setHasOverlay] = useState(false);
|
|
134
134
|
const [hasFooter, setHasFooter] = useState(false);
|
|
135
135
|
const handleMounted = useRef(false);
|
|
@@ -140,7 +140,7 @@ function BottomSheetRoot({
|
|
|
140
140
|
|
|
141
141
|
const detachedOptions = useMemo(() => resolveDetached(detached), [detached]);
|
|
142
142
|
|
|
143
|
-
// A `Steps` body may override the
|
|
143
|
+
// A `Steps` body may override the snap points and the dismissibility for the
|
|
144
144
|
// step it is on. A step that names its `snapPoints` is sized by them, so
|
|
145
145
|
// dynamic sizing is off while it is current.
|
|
146
146
|
const [stepOverride, setStepOverride] = useState<SheetStepOverride | null>(null);
|
|
@@ -196,7 +196,7 @@ function BottomSheetRoot({
|
|
|
196
196
|
setOpen,
|
|
197
197
|
setPresented,
|
|
198
198
|
setIndex,
|
|
199
|
-
|
|
199
|
+
setSnapPointCount,
|
|
200
200
|
onIndexChange,
|
|
201
201
|
onClose,
|
|
202
202
|
onAnimate,
|
|
@@ -214,7 +214,7 @@ function BottomSheetRoot({
|
|
|
214
214
|
const pans = useSheetPan(state, geometry, animateTo, settleAt, {
|
|
215
215
|
enableHandlePanningGesture,
|
|
216
216
|
enableContentPanningGesture,
|
|
217
|
-
|
|
217
|
+
onSnapPointHaptic,
|
|
218
218
|
onCloseHaptic,
|
|
219
219
|
onOverDragHaptic,
|
|
220
220
|
});
|
|
@@ -246,7 +246,9 @@ function BottomSheetRoot({
|
|
|
246
246
|
// the way there.
|
|
247
247
|
const isEffectivelyOpen = useCallback((): boolean => {
|
|
248
248
|
if (state.animStatus.value === ANIM_STATUS.RUNNING) {
|
|
249
|
-
return
|
|
249
|
+
return (
|
|
250
|
+
indexForHeight(state.animTarget.value, geometry.snapPoints.value, geometry.closedHeight.value) > CLOSED_INDEX
|
|
251
|
+
);
|
|
250
252
|
}
|
|
251
253
|
return state.currentIndex.value > CLOSED_INDEX;
|
|
252
254
|
}, [state, geometry]);
|
|
@@ -262,7 +264,7 @@ function BottomSheetRoot({
|
|
|
262
264
|
if (isOpen) {
|
|
263
265
|
setPresented(true);
|
|
264
266
|
// A move queued by the ref on a closed sheet is a more specific open;
|
|
265
|
-
// it waits on the same layout and lands on its own
|
|
267
|
+
// it waits on the same layout and lands on its own snap point.
|
|
266
268
|
if (!moveQueued.current) dispatch({ kind: "open" });
|
|
267
269
|
moveQueued.current = false;
|
|
268
270
|
return;
|
|
@@ -288,7 +290,7 @@ function BottomSheetRoot({
|
|
|
288
290
|
if (latestOpen.current) setOpen(false);
|
|
289
291
|
};
|
|
290
292
|
// A move on a closed sheet is an open: the state flips first so the
|
|
291
|
-
// portal mounts, and the queued intent lands on the requested
|
|
293
|
+
// portal mounts, and the queued intent lands on the requested snap point.
|
|
292
294
|
const move = (request: IntentRequest): void => {
|
|
293
295
|
if (!isEffectivelyOpen() && !latestOpen.current) {
|
|
294
296
|
moveQueued.current = true;
|
|
@@ -335,8 +337,8 @@ function BottomSheetRoot({
|
|
|
335
337
|
}, [presented, closeOnBack, registryTop, methods]);
|
|
336
338
|
|
|
337
339
|
const contextValue = useMemo<BottomSheetContextValue>(
|
|
338
|
-
() => ({ ...methods, isOpen, setOpen, index,
|
|
339
|
-
[methods, isOpen, setOpen, index,
|
|
340
|
+
() => ({ ...methods, isOpen, setOpen, index, snapPointCount }),
|
|
341
|
+
[methods, isOpen, setOpen, index, snapPointCount]
|
|
340
342
|
);
|
|
341
343
|
|
|
342
344
|
const animatedValue = useMemo<BottomSheetAnimatedValue>(
|
|
@@ -349,7 +351,7 @@ function BottomSheetRoot({
|
|
|
349
351
|
contentHeight: state.contentHeight,
|
|
350
352
|
footerHeight: geometry.footerHeight,
|
|
351
353
|
keyboardLift: geometry.keyboardLift,
|
|
352
|
-
|
|
354
|
+
snapPoints: geometry.snapPoints,
|
|
353
355
|
closedHeight: geometry.closedHeight,
|
|
354
356
|
}),
|
|
355
357
|
[geometry, state]
|
|
@@ -5,11 +5,11 @@ import type { SheetAnimation } from "../animation/animation.types";
|
|
|
5
5
|
import type {
|
|
6
6
|
AnimationSource,
|
|
7
7
|
DetachedProp,
|
|
8
|
-
DetentSpec,
|
|
9
8
|
KeyboardBehavior,
|
|
10
9
|
KeyboardBlurBehavior,
|
|
11
10
|
KeyboardScope,
|
|
12
11
|
ReduceMotionMode,
|
|
12
|
+
SnapPointSpec,
|
|
13
13
|
} from "../core";
|
|
14
14
|
import type { SheetHaptics } from "../gesture/gesture.types";
|
|
15
15
|
|
|
@@ -20,12 +20,12 @@ import type { SheetHaptics } from "../gesture/gesture.types";
|
|
|
20
20
|
* `dismiss` is `close` under the name the library this replaces used. Every
|
|
21
21
|
* method is an intent: none of them animates from the JS thread, and each is
|
|
22
22
|
* a no-op when it makes no sense — `close` on a closed sheet, `snapToIndex(4)`
|
|
23
|
-
* with two
|
|
23
|
+
* with two snap points.
|
|
24
24
|
*/
|
|
25
25
|
export type BottomSheetRef = {
|
|
26
26
|
snapToIndex: (index: number) => void;
|
|
27
|
-
/** A height or a `%` of the available height; clamped, never added as a
|
|
28
|
-
snapToPosition: (position:
|
|
27
|
+
/** A height or a `%` of the available height; clamped, never added as a snap point. */
|
|
28
|
+
snapToPosition: (position: SnapPointSpec) => void;
|
|
29
29
|
expand: () => void;
|
|
30
30
|
collapse: () => void;
|
|
31
31
|
close: () => void;
|
|
@@ -48,9 +48,9 @@ export type BottomSheetProps = SheetHaptics & {
|
|
|
48
48
|
* `isOpen`. One callback for every path.
|
|
49
49
|
*/
|
|
50
50
|
onOpenChange?: (isOpen: boolean) => void;
|
|
51
|
-
/** Controlled
|
|
51
|
+
/** Controlled snap point index. Changing it snaps an open sheet. */
|
|
52
52
|
index?: number;
|
|
53
|
-
/** The
|
|
53
|
+
/** The snap point `open` lands on. @default 0 */
|
|
54
54
|
initialIndex?: number;
|
|
55
55
|
/** The settled index changed — `-1` on close. */
|
|
56
56
|
onIndexChange?: (index: number, height: number, source: AnimationSource) => void;
|
|
@@ -65,10 +65,10 @@ export type BottomSheetProps = SheetHaptics & {
|
|
|
65
65
|
/** The sheet reached its closed height, by any path. `onOpenChange(false)` follows. */
|
|
66
66
|
onClose?: () => void;
|
|
67
67
|
/** Heights above the resting bottom, or `%` of the available height. @default [] */
|
|
68
|
-
snapPoints?: readonly
|
|
69
|
-
/** Adds the content's own height as a
|
|
68
|
+
snapPoints?: readonly SnapPointSpec[];
|
|
69
|
+
/** Adds the content's own height as a snap point. @default true */
|
|
70
70
|
dynamicSizing?: boolean;
|
|
71
|
-
/** Caps the dynamic
|
|
71
|
+
/** Caps the dynamic snap point. */
|
|
72
72
|
maxDynamicContentSize?: number;
|
|
73
73
|
/** Pixels the frame leaves clear at the top. @default 0 */
|
|
74
74
|
topInset?: number;
|
|
@@ -78,7 +78,7 @@ export type BottomSheetProps = SheetHaptics & {
|
|
|
78
78
|
* A floating card: inset by `horizontalMargin` on both sides, resting
|
|
79
79
|
* `bottomOffset` above `bottomInset`, every corner the `Background`'s to
|
|
80
80
|
* round. `true` is `{ horizontalMargin: 16, bottomOffset: 16 }`. Closed is
|
|
81
|
-
* fully off-screen, `%`
|
|
81
|
+
* fully off-screen, `%` snap points resolve against the height above the resting
|
|
82
82
|
* line, and a tap in the margins or the gap closes.
|
|
83
83
|
*/
|
|
84
84
|
detached?: DetachedProp;
|
|
@@ -88,7 +88,7 @@ export type BottomSheetProps = SheetHaptics & {
|
|
|
88
88
|
enableHandlePanningGesture?: boolean;
|
|
89
89
|
/** @default true */
|
|
90
90
|
enableContentPanningGesture?: boolean;
|
|
91
|
-
/** Rubber-band past the
|
|
91
|
+
/** Rubber-band past the snap points. @default true */
|
|
92
92
|
enableOverDrag?: boolean;
|
|
93
93
|
/** Larger allows more travel. @default 2.5 */
|
|
94
94
|
overDragResistanceFactor?: number;
|
|
@@ -152,7 +152,7 @@ export type BottomSheetOverlayProps = Omit<ViewProps, "style"> & {
|
|
|
152
152
|
disappearsOnIndex?: number;
|
|
153
153
|
/** Opacity at full appearance. @default 1 */
|
|
154
154
|
opacity?: number;
|
|
155
|
-
/** What a press does: close, collapse to the first
|
|
155
|
+
/** What a press does: close, collapse to the first snap point, snap to an index, or nothing. @default "close" */
|
|
156
156
|
pressBehavior?: "close" | "collapse" | "none" | number;
|
|
157
157
|
/** Let touches reach the app behind the scrim. @default false */
|
|
158
158
|
enableTouchThrough?: boolean;
|
|
@@ -216,8 +216,8 @@ export type BottomSheetContextValue = BottomSheetRef & {
|
|
|
216
216
|
setOpen: (open: boolean) => void;
|
|
217
217
|
/** The settled index, `-1` closed. React state, updated on settle. */
|
|
218
218
|
index: number;
|
|
219
|
-
/** How many
|
|
220
|
-
|
|
219
|
+
/** How many snap points there were at the last settle. React state, for the handle's accessibility value. */
|
|
220
|
+
snapPointCount: number;
|
|
221
221
|
};
|
|
222
222
|
|
|
223
223
|
/** What `useBottomSheetAnimated()` returns: the shared values a skin animates against. */
|
|
@@ -230,6 +230,6 @@ export type BottomSheetAnimatedValue = {
|
|
|
230
230
|
contentHeight: SharedValue<number>;
|
|
231
231
|
footerHeight: SharedValue<number>;
|
|
232
232
|
keyboardLift: SharedValue<number>;
|
|
233
|
-
|
|
233
|
+
snapPoints: SharedValue<readonly number[]>;
|
|
234
234
|
closedHeight: SharedValue<number>;
|
|
235
235
|
};
|
package/src/core/AGENTS.md
CHANGED
|
@@ -17,7 +17,7 @@ failure mode makes a test necessary rather than a convention sufficient.
|
|
|
17
17
|
## Height space
|
|
18
18
|
|
|
19
19
|
Every value here is a **height**: pixels of sheet visible above its resting
|
|
20
|
-
bottom line. `0` is closed,
|
|
20
|
+
bottom line. `0` is closed, snap points ascend, `-1` (`UNMEASURED`) is a
|
|
21
21
|
measurement that has not happened. `translateY` is derived from a height only
|
|
22
22
|
at the moment a view needs it, by `geometry/position.ts`, and nowhere else.
|
|
23
23
|
|
|
@@ -25,14 +25,14 @@ at the moment a view needs it, by `geometry/position.ts`, and nowhere else.
|
|
|
25
25
|
|
|
26
26
|
| Path | What |
|
|
27
27
|
| --- | --- |
|
|
28
|
-
| `sheet.types.ts` | `
|
|
28
|
+
| `sheet.types.ts` | `SnapPointSpec`, the keyboard behaviours, `AnimationSource`, `SheetIntent`, the numeric enums and the two sentinels |
|
|
29
29
|
| `result.ts` | The house `Result` union, copied from the private `@delacour/types` |
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
30
|
+
| `snap-points/normalize-snap-points.ts` | `parseSnapPoint` (JS, a `Result`) and W `normalizeSnapPoints(spec, available)` — ascending, unique, clamped, invalid entries skipped |
|
|
31
|
+
| `snap-points/dynamic-snap-point.ts` | W `dynamicSnapPoint` — handle + content + footer content + band once, capped |
|
|
32
|
+
| `snap-points/index-for-height.ts` | W `indexForHeight` / `heightForIndex` — piecewise-linear over `[closed, …snapPoints]`, clamped |
|
|
33
|
+
| `snap-points/select-snap-height.ts` | W `selectSnapHeight` — nearest candidate to `height + projection · velocity`; closes only when given a closed height |
|
|
34
|
+
| `snap-points/over-drag.ts` | W `resistOverDrag` — slope 1 at the edge, square-root beyond; factor 0 clamps |
|
|
35
|
+
| `snap-points/sheet-state.ts` | W `sheetState` — closed / opened / extended / overExtended / fill |
|
|
36
36
|
| `keyboard/keyboard-lift.ts` | W `keyboardInContainer`, W `keyboardLift` — the lift, band and gap subtracted in step with progress |
|
|
37
37
|
| `keyboard/keyboard-owner.ts` | W `isInputInsideSheet` — the `keyboardScope: "inside"` geometry test |
|
|
38
38
|
| `keyboard/content-area.ts` | W `contentArea` — what the body may fill, never negative |
|
|
@@ -49,9 +49,9 @@ at the moment a view needs it, by `geometry/position.ts`, and nowhere else.
|
|
|
49
49
|
| `backdrop/backdrop-opacity.ts` | W `backdropOpacity`, W `backdropInteractive` |
|
|
50
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
51
|
| `intent/layout-ready.ts` | W `isLayoutReady` |
|
|
52
|
-
| `haptic/crossed-
|
|
52
|
+
| `haptic/crossed-snap-point.ts` | W `crossedSnapPoint` (boolean), W `snapPointUnder` (index) |
|
|
53
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 `
|
|
54
|
+
| `scroll/scroll-pan.ts` | W `listDragHeight` — the start offset as a budget the finger spends before the sheet moves; W `listOwnsRelease`; W `restingSnapPoint` — the snap point within the settle tolerance of a base, for a release the list owned; W `scrollLockTarget` |
|
|
55
55
|
| `animation/select-animation.ts` | `selectAnimation`, `IOS_SPRING`, `ANDROID_TIMING` — platform defaults as data, easing by name |
|
|
56
56
|
|
|
57
57
|
W marks a module-scope `"worklet"`; each is flat (see the package `AGENTS.md`)
|
|
@@ -69,12 +69,12 @@ restingBottom = detached ? bottomOffset + inset : 0 closedHeight = −r
|
|
|
69
69
|
available = C − restingBottom maxHeight = available
|
|
70
70
|
band = detached ? 0 : inset bandNow = band · (1 − p)
|
|
71
71
|
footerHeight = hasFooter ? footerContent + bandNow : 0
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
dynamicSnapPoint = min(handle + content + (hasFooter ? footerContent : 0) + band, maxDynamicContentSize, available)
|
|
73
|
+
snapPoints = sortAsc(unique(normalize(spec, available) ∪ {dynamicSnapPoint if dynamicSizing}))
|
|
74
74
|
keyboardLift = behavior ∈ {interactive, extend} ? max(0, kb − band · p − (detached ? restingBottom : 0)) : 0
|
|
75
75
|
height = clamp(base + keyboardLift, closedHeight, maxHeight)
|
|
76
76
|
translateY = C − restingBottom − height
|
|
77
|
-
index = piecewise(base, [closedHeight, …
|
|
77
|
+
index = piecewise(base, [closedHeight, …snapPoints] → [−1, 0, 1, …]) // base, never height
|
|
78
78
|
contentArea = max(0, min(maxHeight, highest + keyboardLift) − handle − footerHeight − kb − (hasFooter ? 0 : bandNow))
|
|
79
79
|
footerTop = max(0, height − kb − footerContent − bandNow)
|
|
80
80
|
bodyInset = hasFooter ? footerHeight : bandNow
|
|
@@ -92,18 +92,18 @@ the footer or the band and its last row can still be scrolled clear of them.
|
|
|
92
92
|
`bodyClip` is what of that may show: without a footer all of it, so a sheet
|
|
93
93
|
slides as one body; under a footer only what is above the footer's live top
|
|
94
94
|
edge — `height − kb − handle − footerHeight`, which is `footerTop − handle` —
|
|
95
|
-
so while the sheet is dragged below its
|
|
96
|
-
screen's bottom edge, no line of the body is drawn under it. On the
|
|
95
|
+
so while the sheet is dragged below its snap point and the footer holds the
|
|
96
|
+
screen's bottom edge, no line of the body is drawn under it. On the snap point the
|
|
97
97
|
clip is exactly `contentArea`. A scrollable's `contentHeight` is
|
|
98
98
|
`scrollContentHeight(contentSize, spacer)`: the spacer is the footer and the
|
|
99
|
-
band, which `
|
|
99
|
+
band, which `dynamicSnapPoint` adds once already.
|
|
100
100
|
|
|
101
101
|
Two deviations from the plan's signatures, both on purpose:
|
|
102
102
|
|
|
103
103
|
- `resolveIntent` returns `{ action } | null` rather than `number | null`,
|
|
104
104
|
because `forceClose` needs a jump the caller can tell from an animate, and
|
|
105
105
|
an open before layout needs a *wait* the caller can tell from a no-op.
|
|
106
|
-
- `
|
|
106
|
+
- `crossedSnapPoint` returns a boolean and `snapPointUnder` the index, rather than
|
|
107
107
|
one function doing both — a number that means "unchanged" when it equals its
|
|
108
108
|
input is a comparison the caller has to make anyway, spelled worse.
|
|
109
109
|
|
|
@@ -12,7 +12,7 @@ describe("backdropOpacity", () => {
|
|
|
12
12
|
expect(backdropOpacity(-0.25, -1, 0, 0.6)).toBeCloseTo(0.45);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
test("plateaus above the appearing index — a higher
|
|
15
|
+
test("plateaus above the appearing index — a higher snap point is no darker", () => {
|
|
16
16
|
expect(backdropOpacity(1, -1, 0, 1)).toBe(1);
|
|
17
17
|
expect(backdropOpacity(2.5, -1, 0, 0.7)).toBe(0.7);
|
|
18
18
|
});
|
|
@@ -42,7 +42,7 @@ describe("backdropInteractive", () => {
|
|
|
42
42
|
expect(backdropInteractive(0, -1)).toBe(true);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
test("with a higher disappearing index the lowest
|
|
45
|
+
test("with a higher disappearing index the lowest snap point leaves the app tappable", () => {
|
|
46
46
|
expect(backdropInteractive(0, 0)).toBe(false);
|
|
47
47
|
expect(backdropInteractive(1, 0)).toBe(true);
|
|
48
48
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
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
|
|
3
|
+
* linearly to `opacity` at `appearsOn`, and a plateau above — a higher snap point
|
|
4
4
|
* is no darker. Fed the keyboard-free index, so a keyboard lift never dims
|
|
5
5
|
* the app behind the sheet.
|
|
6
6
|
*
|
|
@@ -24,18 +24,18 @@ describe("bodyClip", () => {
|
|
|
24
24
|
const at = (sheetHeight: number) =>
|
|
25
25
|
bodyClip({ contentArea: 486, inset: 90, hasFooter: true, sheetHeight, handleHeight: 24, keyboardHeight: 0 });
|
|
26
26
|
|
|
27
|
-
test("at rest on its
|
|
27
|
+
test("at rest on its snap point the body is clipped exactly at the footer's top edge", () => {
|
|
28
28
|
// contentArea 486 = 600 − 24 − 90; the body is laid out 90 deeper, to the
|
|
29
29
|
// sheet's bottom line, and the clip hides that 90 behind the footer.
|
|
30
30
|
expect(at(600)).toBe(486);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
test("dragged below its
|
|
33
|
+
test("dragged below its snap point the clip follows the live height, so nothing shows under the footer", () => {
|
|
34
34
|
expect(at(500)).toBe(386);
|
|
35
35
|
expect(at(200)).toBe(86);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
test("above its
|
|
38
|
+
test("above its snap point — an over-drag — the clip stays at the laid-out body", () => {
|
|
39
39
|
expect(at(700)).toBe(486 + 90);
|
|
40
40
|
});
|
|
41
41
|
|
|
@@ -16,7 +16,7 @@ export function bodyInset(hasFooter: boolean, footerHeight: number, band: number
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export type BodyClipInput = {
|
|
19
|
-
/** The geometry's `contentArea` — the body sized against its
|
|
19
|
+
/** The geometry's `contentArea` — the body sized against its snap point. */
|
|
20
20
|
contentArea: number;
|
|
21
21
|
/** `bodyInset` — what the body reserves at its end. */
|
|
22
22
|
inset: number;
|
|
@@ -35,7 +35,7 @@ export type BodyClipInput = {
|
|
|
35
35
|
* — and without a footer that is the clip too: the sheet slides as one body
|
|
36
36
|
* and nothing needs hiding. Under a sticky footer the clip also follows the
|
|
37
37
|
* footer's top edge, `sheetHeight − keyboard − handle − inset`, which is where
|
|
38
|
-
* `footerTop` puts it. On the
|
|
38
|
+
* `footerTop` puts it. On the snap point the two agree and the clip is exactly
|
|
39
39
|
* `contentArea`; below it — a drag to close, the close animation — the footer
|
|
40
40
|
* stays on the screen's bottom edge while the panel slides down under it, and
|
|
41
41
|
* the clip shrinks in step so no line of the body is ever drawn below the
|
|
@@ -56,7 +56,7 @@ export function bodyClip(input: BodyClipInput): number {
|
|
|
56
56
|
/**
|
|
57
57
|
* The rows' own height: a scrollable's reported content size less the
|
|
58
58
|
* trailing spacer it renders inside that content. This is what the dynamic
|
|
59
|
-
*
|
|
59
|
+
* snap point counts — the spacer is the footer and the band, which the snap point
|
|
60
60
|
* already adds once. An unmeasured spacer subtracts nothing; never negative.
|
|
61
61
|
*/
|
|
62
62
|
export function scrollContentHeight(contentSize: number, spacer: number): number {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Height space is the engine's coordinate system; this is the one place it is
|
|
6
6
|
* converted to a transform. A detached sheet rests `restingBottom` above the
|
|
7
7
|
* container's bottom edge, so its closed position is the full container
|
|
8
|
-
* height and every
|
|
8
|
+
* height and every snap point sits that much higher.
|
|
9
9
|
*/
|
|
10
10
|
export function positionFor(containerHeight: number, restingBottom: number, height: number): number {
|
|
11
11
|
"worklet";
|
|
@@ -2,28 +2,28 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { surfaceHeight } from "./surface-height";
|
|
3
3
|
|
|
4
4
|
describe("surfaceHeight", () => {
|
|
5
|
-
test("between the
|
|
5
|
+
test("between the snap points the surface follows the height", () => {
|
|
6
6
|
expect(surfaceHeight(300, 200, 400)).toBe(300);
|
|
7
7
|
expect(surfaceHeight(200, 200, 400)).toBe(200);
|
|
8
8
|
expect(surfaceHeight(400, 200, 400)).toBe(400);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
|
-
test("below the first
|
|
11
|
+
test("below the first snap point the surface keeps the snap point's height, so a close slides the card rigidly", () => {
|
|
12
12
|
expect(surfaceHeight(120, 200, 400)).toBe(200);
|
|
13
13
|
expect(surfaceHeight(0, 200, 400)).toBe(200);
|
|
14
14
|
expect(surfaceHeight(-50, 200, 400)).toBe(200);
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
test("above the last
|
|
17
|
+
test("above the last snap point the surface keeps the snap point's height, so an over-drag lifts the card rigidly", () => {
|
|
18
18
|
expect(surfaceHeight(450, 200, 400)).toBe(400);
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
test("one
|
|
21
|
+
test("one snap point is a rigid card at every height", () => {
|
|
22
22
|
expect(surfaceHeight(10, 320, 320)).toBe(320);
|
|
23
23
|
expect(surfaceHeight(500, 320, 320)).toBe(320);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
test("no
|
|
26
|
+
test("no snap points yet: never negative, never above zero", () => {
|
|
27
27
|
expect(surfaceHeight(-16, 0, 0)).toBe(0);
|
|
28
28
|
expect(surfaceHeight(-16, -16, -16)).toBe(0);
|
|
29
29
|
});
|