@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
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* How tall a detached card's surface is right now.
|
|
3
3
|
*
|
|
4
|
-
* Between its
|
|
5
|
-
* line and the top follows `height`. Below the first
|
|
6
|
-
* close, the close animation, a rubber-band under the lowest
|
|
4
|
+
* Between its snap points the card resizes: its bottom edge stays on the resting
|
|
5
|
+
* line and the top follows `height`. Below the first snap point — a drag down to
|
|
6
|
+
* close, the close animation, a rubber-band under the lowest snap point — and
|
|
7
7
|
* above the last — an over-drag, a keyboard lift — the card is a rigid body:
|
|
8
|
-
* its surface keeps the
|
|
8
|
+
* its surface keeps the snap point's height and `translateY` alone moves it, so
|
|
9
9
|
* the bottom corners slide through the gap and off-screen intact rather than
|
|
10
10
|
* collapsing onto the resting line.
|
|
11
11
|
*
|
|
12
12
|
* An attached sheet never needs this: its panel is as tall as the frame and
|
|
13
13
|
* its bottom edge is always off-screen.
|
|
14
14
|
*/
|
|
15
|
-
export function surfaceHeight(height: number,
|
|
15
|
+
export function surfaceHeight(height: number, lowestSnapPoint: number, highestSnapPoint: number): number {
|
|
16
16
|
"worklet";
|
|
17
|
-
const low =
|
|
18
|
-
const high =
|
|
17
|
+
const low = lowestSnapPoint > 0 ? lowestSnapPoint : 0;
|
|
18
|
+
const high = highestSnapPoint > low ? highestSnapPoint : low;
|
|
19
19
|
if (height < low) return low;
|
|
20
20
|
if (height > high) return high;
|
|
21
21
|
return height;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { crossedSnapPoint, snapPointUnder } from "./crossed-snap-point";
|
|
3
|
+
|
|
4
|
+
const snapPoints = [200, 400, 800];
|
|
5
|
+
|
|
6
|
+
describe("snapPointUnder", () => {
|
|
7
|
+
test("the index of the highest snap point at or below the height, -1 below the first", () => {
|
|
8
|
+
expect(snapPointUnder(100, snapPoints)).toBe(-1);
|
|
9
|
+
expect(snapPointUnder(200, snapPoints)).toBe(0);
|
|
10
|
+
expect(snapPointUnder(399, snapPoints)).toBe(0);
|
|
11
|
+
expect(snapPointUnder(400, snapPoints)).toBe(1);
|
|
12
|
+
expect(snapPointUnder(900, snapPoints)).toBe(2);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("a hair below a snap point counts as on it, so a settled spring is not a phantom crossing", () => {
|
|
16
|
+
expect(snapPointUnder(399.8, snapPoints)).toBe(1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("with no snap points nothing is ever under the sheet", () => {
|
|
20
|
+
expect(snapPointUnder(300, [])).toBe(-1);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("crossedSnapPoint", () => {
|
|
25
|
+
test("true when the drag crosses a snap point in either direction", () => {
|
|
26
|
+
expect(crossedSnapPoint(0, 401, snapPoints)).toBe(true);
|
|
27
|
+
expect(crossedSnapPoint(1, 399, snapPoints)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("false while the drag stays between the same snap points", () => {
|
|
31
|
+
expect(crossedSnapPoint(0, 250, snapPoints)).toBe(false);
|
|
32
|
+
expect(crossedSnapPoint(0, 399, snapPoints)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("crossing the lowest snap point downward counts, so a haptic fires on the way to closed too", () => {
|
|
36
|
+
expect(crossedSnapPoint(0, 150, snapPoints)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a frame that skips a snap point is one crossing, and snapPointUnder says where it landed", () => {
|
|
40
|
+
expect(crossedSnapPoint(-1, 850, snapPoints)).toBe(true);
|
|
41
|
+
expect(snapPointUnder(850, snapPoints)).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Settle tolerance: a spring at rest is within this of its target. */
|
|
2
|
+
const SETTLE_EPSILON = 0.5;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The index of the highest snap point at or below `height`, `-1` below the
|
|
6
|
+
* first. A hair under a snap point counts as on it, so a spring settling a
|
|
7
|
+
* fraction short is not a crossing.
|
|
8
|
+
*/
|
|
9
|
+
export function snapPointUnder(height: number, snapPoints: readonly number[]): number {
|
|
10
|
+
"worklet";
|
|
11
|
+
let under = -1;
|
|
12
|
+
for (let index = 0; index < snapPoints.length; index += 1) {
|
|
13
|
+
if (height + SETTLE_EPSILON >= (snapPoints[index] as number)) under = index;
|
|
14
|
+
else break;
|
|
15
|
+
}
|
|
16
|
+
return under;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a drag crossed a snap point this frame: the snap point now under the sheet
|
|
21
|
+
* differs from `lastIndex`, the one the pan stored last frame. On `true` the
|
|
22
|
+
* pan fires its haptic and stores `snapPointUnder(height, snapPoints)`. A frame
|
|
23
|
+
* that leaps two snap points is one crossing, not a burst.
|
|
24
|
+
*/
|
|
25
|
+
export function crossedSnapPoint(lastIndex: number, height: number, snapPoints: readonly number[]): boolean {
|
|
26
|
+
"worklet";
|
|
27
|
+
let under = -1;
|
|
28
|
+
for (let index = 0; index < snapPoints.length; index += 1) {
|
|
29
|
+
if (height + SETTLE_EPSILON >= (snapPoints[index] as number)) under = index;
|
|
30
|
+
else break;
|
|
31
|
+
}
|
|
32
|
+
return under !== lastIndex;
|
|
33
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -9,12 +9,6 @@ export {
|
|
|
9
9
|
selectAnimation,
|
|
10
10
|
} from "./animation/select-animation";
|
|
11
11
|
export { backdropInteractive, backdropOpacity } from "./backdrop/backdrop-opacity";
|
|
12
|
-
export { type DynamicDetentInput, dynamicDetent } from "./detents/dynamic-detent";
|
|
13
|
-
export { heightForIndex, indexForHeight } from "./detents/index-for-height";
|
|
14
|
-
export { type DetentError, normalizeDetents, type ParsedDetent, parseDetent } from "./detents/normalize-detents";
|
|
15
|
-
export { resistOverDrag } from "./detents/over-drag";
|
|
16
|
-
export { type SelectSnapHeightInput, selectSnapHeight } from "./detents/select-snap-height";
|
|
17
|
-
export { sheetState } from "./detents/sheet-state";
|
|
18
12
|
export { type BodyClipInput, bodyClip, bodyInset, scrollContentHeight } from "./footer/body-inset";
|
|
19
13
|
export { bandNow, bottomBand, footerHeight } from "./footer/bottom-band";
|
|
20
14
|
export { type FooterTopInput, footerTop } from "./footer/footer-top";
|
|
@@ -32,7 +26,7 @@ export {
|
|
|
32
26
|
} from "./geometry/detached-frame";
|
|
33
27
|
export { positionFor } from "./geometry/position";
|
|
34
28
|
export { surfaceHeight } from "./geometry/surface-height";
|
|
35
|
-
export {
|
|
29
|
+
export { crossedSnapPoint, snapPointUnder } from "./haptic/crossed-snap-point";
|
|
36
30
|
export { isLayoutReady, type LayoutReadyInput } from "./intent/layout-ready";
|
|
37
31
|
export { type IntentResolution, type IntentState, resolveIntent } from "./intent/resolve-intent";
|
|
38
32
|
export { acceptContainerLayout, type ContainerLayoutInput } from "./keyboard/container-layout-guard";
|
|
@@ -71,13 +65,12 @@ export {
|
|
|
71
65
|
type ListOwnsReleaseInput,
|
|
72
66
|
listDragHeight,
|
|
73
67
|
listOwnsRelease,
|
|
74
|
-
|
|
68
|
+
restingSnapPoint,
|
|
75
69
|
scrollLockTarget,
|
|
76
70
|
} from "./scroll/scroll-pan";
|
|
77
71
|
export {
|
|
78
72
|
type AnimationSource,
|
|
79
73
|
CLOSED_INDEX,
|
|
80
|
-
type DetentSpec,
|
|
81
74
|
GESTURE_SOURCE,
|
|
82
75
|
type GestureSource,
|
|
83
76
|
type KeyboardBehavior,
|
|
@@ -88,5 +81,17 @@ export {
|
|
|
88
81
|
SHEET_STATE,
|
|
89
82
|
type SheetIntent,
|
|
90
83
|
type SheetState,
|
|
84
|
+
type SnapPointSpec,
|
|
91
85
|
UNMEASURED,
|
|
92
86
|
} from "./sheet.types";
|
|
87
|
+
export { type DynamicSnapPointInput, dynamicSnapPoint } from "./snap-points/dynamic-snap-point";
|
|
88
|
+
export { heightForIndex, indexForHeight } from "./snap-points/index-for-height";
|
|
89
|
+
export {
|
|
90
|
+
normalizeSnapPoints,
|
|
91
|
+
type ParsedSnapPoint,
|
|
92
|
+
parseSnapPoint,
|
|
93
|
+
type SnapPointError,
|
|
94
|
+
} from "./snap-points/normalize-snap-points";
|
|
95
|
+
export { resistOverDrag } from "./snap-points/over-drag";
|
|
96
|
+
export { type SelectSnapHeightInput, selectSnapHeight } from "./snap-points/select-snap-height";
|
|
97
|
+
export { sheetState } from "./snap-points/sheet-state";
|
|
@@ -3,7 +3,7 @@ export type LayoutReadyInput = {
|
|
|
3
3
|
handleHeight: number;
|
|
4
4
|
contentHeight: number;
|
|
5
5
|
footerContentHeight: number;
|
|
6
|
-
/** Whether the content's height is a
|
|
6
|
+
/** Whether the content's height is a snap point, and so has to be known. */
|
|
7
7
|
dynamicSizing: boolean;
|
|
8
8
|
hasFooter: boolean;
|
|
9
9
|
};
|
|
@@ -6,7 +6,7 @@ const ready: IntentState = {
|
|
|
6
6
|
currentIndex: -1,
|
|
7
7
|
base: 0,
|
|
8
8
|
layoutReady: true,
|
|
9
|
-
|
|
9
|
+
snapPoints: [200, 400, 800],
|
|
10
10
|
closedHeight: 0,
|
|
11
11
|
maxHeight: 800,
|
|
12
12
|
initialIndex: 0,
|
|
@@ -14,7 +14,7 @@ const ready: IntentState = {
|
|
|
14
14
|
const open: IntentState = { ...ready, currentIndex: 1, base: 400 };
|
|
15
15
|
/** A sheet visibly open whose settled index never caught up — a release the list owned. */
|
|
16
16
|
const staleClosed: IntentState = { ...ready, currentIndex: -1, base: 800 };
|
|
17
|
-
const unmeasured: IntentState = { ...ready, layoutReady: false,
|
|
17
|
+
const unmeasured: IntentState = { ...ready, layoutReady: false, snapPoints: [] };
|
|
18
18
|
|
|
19
19
|
const intent = (kind: SheetIntent["kind"], extra: Record<string, unknown> = {}): SheetIntent =>
|
|
20
20
|
({ id: 1, kind, ...extra }) as SheetIntent;
|
|
@@ -26,7 +26,7 @@ describe("resolveIntent", () => {
|
|
|
26
26
|
expect(resolveIntent({ ...ready, initialIndex: 2 }, intent("open"))).toEqual({ action: "animate", target: 800 });
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
test("an initial index past the last
|
|
29
|
+
test("an initial index past the last snap point lands on the last", () => {
|
|
30
30
|
expect(resolveIntent({ ...ready, initialIndex: 9 }, intent("open"))).toEqual({ action: "animate", target: 800 });
|
|
31
31
|
});
|
|
32
32
|
|
|
@@ -40,7 +40,7 @@ describe("resolveIntent", () => {
|
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
test("with nothing to open to there is nothing to do", () => {
|
|
43
|
-
expect(resolveIntent({ ...ready,
|
|
43
|
+
expect(resolveIntent({ ...ready, snapPoints: [] }, intent("open"))).toBeNull();
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
46
|
|
|
@@ -90,7 +90,7 @@ describe("resolveIntent", () => {
|
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
describe("snapToIndex", () => {
|
|
93
|
-
test("animates to the
|
|
93
|
+
test("animates to the snap point at that index", () => {
|
|
94
94
|
expect(resolveIntent(open, intent("snapToIndex", { index: 2 }))).toEqual({ action: "animate", target: 800 });
|
|
95
95
|
});
|
|
96
96
|
|
|
@@ -110,7 +110,7 @@ describe("resolveIntent", () => {
|
|
|
110
110
|
});
|
|
111
111
|
|
|
112
112
|
describe("snapToPosition", () => {
|
|
113
|
-
test("a pixel height animates as written, without becoming a
|
|
113
|
+
test("a pixel height animates as written, without becoming a snap point", () => {
|
|
114
114
|
expect(resolveIntent(open, intent("snapToPosition", { position: 333 }))).toEqual({
|
|
115
115
|
action: "animate",
|
|
116
116
|
target: 333,
|
|
@@ -146,7 +146,7 @@ describe("resolveIntent", () => {
|
|
|
146
146
|
});
|
|
147
147
|
|
|
148
148
|
describe("expand and collapse", () => {
|
|
149
|
-
test("expand animates to the highest
|
|
149
|
+
test("expand animates to the highest snap point, collapse to the lowest", () => {
|
|
150
150
|
expect(resolveIntent(open, intent("expand"))).toEqual({ action: "animate", target: 800 });
|
|
151
151
|
expect(resolveIntent(open, intent("collapse"))).toEqual({ action: "animate", target: 200 });
|
|
152
152
|
});
|
|
@@ -155,9 +155,9 @@ describe("resolveIntent", () => {
|
|
|
155
155
|
expect(resolveIntent(ready, intent("expand"))).toEqual({ action: "animate", target: 800 });
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
-
test("both wait for layout and refuse an empty
|
|
158
|
+
test("both wait for layout and refuse an empty snap point list", () => {
|
|
159
159
|
expect(resolveIntent(unmeasured, intent("expand"))).toEqual({ action: "wait" });
|
|
160
|
-
expect(resolveIntent({ ...ready,
|
|
160
|
+
expect(resolveIntent({ ...ready, snapPoints: [] }, intent("collapse"))).toBeNull();
|
|
161
161
|
});
|
|
162
162
|
});
|
|
163
163
|
});
|
|
@@ -14,8 +14,8 @@ export type IntentState = {
|
|
|
14
14
|
*/
|
|
15
15
|
base: number;
|
|
16
16
|
layoutReady: boolean;
|
|
17
|
-
/** Ascending, normalised
|
|
18
|
-
|
|
17
|
+
/** Ascending, normalised snap points. */
|
|
18
|
+
snapPoints: readonly number[];
|
|
19
19
|
closedHeight: number;
|
|
20
20
|
/** The height the sheet may occupy — what a `%` position resolves against. */
|
|
21
21
|
maxHeight: number;
|
|
@@ -42,17 +42,17 @@ export type IntentResolution =
|
|
|
42
42
|
* open is `null`, not a queued dismiss that fires when the sheet finally
|
|
43
43
|
* presents; an open before the first layout waits for it rather than animating
|
|
44
44
|
* from a guess; `forceClose` jumps. `snapToPosition` resolves a `%` against
|
|
45
|
-
* `maxHeight` and clamps, without ever adding a
|
|
45
|
+
* `maxHeight` and clamps, without ever adding a snap point.
|
|
46
46
|
*
|
|
47
47
|
* Runs on the UI thread inside the intent reaction, so it is a flat worklet
|
|
48
|
-
* and re-implements the little it needs of `heightForIndex` and `
|
|
48
|
+
* and re-implements the little it needs of `heightForIndex` and `parseSnapPoint`
|
|
49
49
|
* inline.
|
|
50
50
|
*/
|
|
51
51
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the flat-worklet rule forbids extracting helpers; see the doc comment above.
|
|
52
52
|
export function resolveIntent(state: IntentState, intent: SheetIntent): IntentResolution | null {
|
|
53
53
|
"worklet";
|
|
54
|
-
const
|
|
55
|
-
const count =
|
|
54
|
+
const snapPoints = state.snapPoints;
|
|
55
|
+
const count = snapPoints.length;
|
|
56
56
|
// A detached sheet's closed height is negative and `base` starts at 0, so a
|
|
57
57
|
// never-opened card must not read as open: a sheet is visibly open only when
|
|
58
58
|
// its height is positive, hence the floor at 0.
|
|
@@ -71,13 +71,13 @@ export function resolveIntent(state: IntentState, intent: SheetIntent): IntentRe
|
|
|
71
71
|
if (count === 0) return null;
|
|
72
72
|
const index =
|
|
73
73
|
state.initialIndex < 0 ? 0 : state.initialIndex > count - 1 ? count - 1 : Math.floor(state.initialIndex);
|
|
74
|
-
return { action: "animate", target:
|
|
74
|
+
return { action: "animate", target: snapPoints[index] as number };
|
|
75
75
|
}
|
|
76
76
|
if (intent.kind === "snapToIndex") {
|
|
77
77
|
const index = intent.index;
|
|
78
78
|
if (index === -1) return isOpen ? { action: "animate", target: state.closedHeight } : null;
|
|
79
79
|
if (!Number.isInteger(index) || index < 0 || index > count - 1) return null;
|
|
80
|
-
return { action: "animate", target:
|
|
80
|
+
return { action: "animate", target: snapPoints[index] as number };
|
|
81
81
|
}
|
|
82
82
|
if (intent.kind === "snapToPosition") {
|
|
83
83
|
const position = intent.position;
|
|
@@ -93,6 +93,6 @@ export function resolveIntent(state: IntentState, intent: SheetIntent): IntentRe
|
|
|
93
93
|
return { action: "animate", target: clamped };
|
|
94
94
|
}
|
|
95
95
|
if (count === 0) return null;
|
|
96
|
-
if (intent.kind === "expand") return { action: "animate", target:
|
|
97
|
-
return { action: "animate", target:
|
|
96
|
+
if (intent.kind === "expand") return { action: "animate", target: snapPoints[count - 1] as number };
|
|
97
|
+
return { action: "animate", target: snapPoints[0] as number };
|
|
98
98
|
}
|
|
@@ -17,7 +17,7 @@ const TOLERANCE = 2;
|
|
|
17
17
|
* shrinking the window under a keyboard the sheet is already lifting for.
|
|
18
18
|
*
|
|
19
19
|
* Accepting the second would count the keyboard twice: once as the lift, once
|
|
20
|
-
* as a smaller container with every percent
|
|
20
|
+
* as a smaller container with every percent snap point re-derived against it.
|
|
21
21
|
* So while the keyboard is in play, a shrink no larger than the keyboard is
|
|
22
22
|
* refused. A shrink larger than that — a rotation, a split view — is real
|
|
23
23
|
* whatever the keyboard is doing, and growth and first measurement always
|
|
@@ -18,10 +18,10 @@ export type KeyboardStepInput = {
|
|
|
18
18
|
/**
|
|
19
19
|
* What the behaviour reaction does this frame.
|
|
20
20
|
*
|
|
21
|
-
* `apply` — snap for the keyboard: `extend` to the highest
|
|
21
|
+
* `apply` — snap for the keyboard: `extend` to the highest snap point,
|
|
22
22
|
* `fillParent` to the container. `restore` — the keyboard is leaving or focus
|
|
23
23
|
* left the sheet, and `keyboardBlurBehavior: "restore"` sends the sheet back
|
|
24
|
-
* to the
|
|
24
|
+
* to the snap point it held before. `release` — the same moment under
|
|
25
25
|
* `blurBehavior: "none"`: nothing moves, but the record clears so the next
|
|
26
26
|
* rise applies again. `null` — nothing.
|
|
27
27
|
*/
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* controller.
|
|
9
9
|
*/
|
|
10
10
|
import type { Result } from "../result";
|
|
11
|
-
import type {
|
|
11
|
+
import type { SnapPointSpec } from "../sheet.types";
|
|
12
12
|
|
|
13
13
|
/** The least an event can be. Discriminate on `type`; carry anything else. */
|
|
14
14
|
export type SheetEvent = { type: string };
|
|
@@ -37,7 +37,7 @@ export type SheetTransitionTarget<S extends string, C, E extends SheetEvent> = S
|
|
|
37
37
|
*/
|
|
38
38
|
export type SheetStateNode<S extends string, C, E extends SheetEvent> = {
|
|
39
39
|
on?: { [T in E["type"]]?: SheetTransitionTarget<S, C, Extract<E, { type: T }>> };
|
|
40
|
-
snapPoints?: readonly
|
|
40
|
+
snapPoints?: readonly SnapPointSpec[];
|
|
41
41
|
dismissible?: boolean;
|
|
42
42
|
direction?: SheetStepDirection;
|
|
43
43
|
};
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* frame from it here. Kept in core so the three transitions are a table a
|
|
7
7
|
* test can read rather than three `useAnimatedStyle`s a device has to show.
|
|
8
8
|
*/
|
|
9
|
-
import type {
|
|
9
|
+
import type { SnapPointSpec } from "../sheet.types";
|
|
10
10
|
import type { SheetEvent, SheetStateNode, SheetStepDirection } from "./machine.types";
|
|
11
11
|
|
|
12
12
|
/** The `transition` prop of `BottomSheet.Steps`. */
|
|
@@ -47,7 +47,7 @@ export function stepFrame(
|
|
|
47
47
|
|
|
48
48
|
/** What a step asks the root to change while it is current. */
|
|
49
49
|
export type SheetStepOverride = {
|
|
50
|
-
snapPoints: readonly
|
|
50
|
+
snapPoints: readonly SnapPointSpec[] | undefined;
|
|
51
51
|
dismissible: boolean | undefined;
|
|
52
52
|
};
|
|
53
53
|
|
|
@@ -3,12 +3,12 @@ import { SHEET_STATE } from "../sheet.types";
|
|
|
3
3
|
import { contentPanDrivesSheet, shouldLockScroll } from "./scroll-lock";
|
|
4
4
|
|
|
5
5
|
describe("shouldLockScroll", () => {
|
|
6
|
-
test("locked below the highest
|
|
6
|
+
test("locked below the highest snap point — a drag on the list moves the sheet, not the rows", () => {
|
|
7
7
|
expect(shouldLockScroll(SHEET_STATE.CLOSED, true)).toBe(true);
|
|
8
8
|
expect(shouldLockScroll(SHEET_STATE.OPENED, true)).toBe(true);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
|
-
test("unlocked at the highest
|
|
11
|
+
test("unlocked at the highest snap point and beyond", () => {
|
|
12
12
|
expect(shouldLockScroll(SHEET_STATE.EXTENDED, true)).toBe(false);
|
|
13
13
|
expect(shouldLockScroll(SHEET_STATE.OVER_EXTENDED, true)).toBe(false);
|
|
14
14
|
expect(shouldLockScroll(SHEET_STATE.FILL, true)).toBe(false);
|
|
@@ -3,8 +3,8 @@ import { SHEET_STATE, type SheetState } from "../sheet.types";
|
|
|
3
3
|
/**
|
|
4
4
|
* Whether a scrollable inside the sheet is pinned in place.
|
|
5
5
|
*
|
|
6
|
-
* Below the highest
|
|
7
|
-
* so the list is held at its lock offset every frame. At the highest
|
|
6
|
+
* Below the highest snap point a drag on the list moves the sheet, not the rows,
|
|
7
|
+
* so the list is held at its lock offset every frame. At the highest snap point
|
|
8
8
|
* and beyond it scrolls. With the content pan disabled there is nothing else
|
|
9
9
|
* the drag could do, so the list is never locked.
|
|
10
10
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { SCROLLABLE_TYPE } from "../sheet.types";
|
|
3
|
-
import { listDragHeight, listOwnsRelease,
|
|
3
|
+
import { listDragHeight, listOwnsRelease, restingSnapPoint, scrollLockTarget } from "./scroll-pan";
|
|
4
4
|
|
|
5
5
|
describe("listDragHeight", () => {
|
|
6
6
|
const highest = 600;
|
|
@@ -41,7 +41,7 @@ describe("listDragHeight", () => {
|
|
|
41
41
|
describe("listOwnsRelease", () => {
|
|
42
42
|
const highest = 600;
|
|
43
43
|
|
|
44
|
-
test("a content pan released with the list scrolled and the sheet at the highest
|
|
44
|
+
test("a content pan released with the list scrolled and the sheet at the highest snap point is the list's", () => {
|
|
45
45
|
expect(listOwnsRelease({ scrollable: true, offset: 120, base: 600, highest })).toBe(true);
|
|
46
46
|
expect(listOwnsRelease({ scrollable: true, offset: 120, base: 599.7, highest })).toBe(true);
|
|
47
47
|
});
|
|
@@ -51,7 +51,7 @@ describe("listOwnsRelease", () => {
|
|
|
51
51
|
expect(listOwnsRelease({ scrollable: true, offset: -12, base: 600, highest })).toBe(false);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
test("below the highest
|
|
54
|
+
test("below the highest snap point the sheet takes it whatever the offset", () => {
|
|
55
55
|
expect(listOwnsRelease({ scrollable: true, offset: 120, base: 300, highest })).toBe(false);
|
|
56
56
|
});
|
|
57
57
|
|
|
@@ -60,22 +60,22 @@ describe("listOwnsRelease", () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
describe("
|
|
64
|
-
const
|
|
63
|
+
describe("restingSnapPoint", () => {
|
|
64
|
+
const snapPoints = [200, 400, 600];
|
|
65
65
|
|
|
66
|
-
test("a base on a
|
|
67
|
-
expect(
|
|
68
|
-
expect(
|
|
69
|
-
expect(
|
|
66
|
+
test("a base on a snap point, or within the settle tolerance of one, is resting on it", () => {
|
|
67
|
+
expect(restingSnapPoint(600, snapPoints)).toBe(600);
|
|
68
|
+
expect(restingSnapPoint(599.7, snapPoints)).toBe(600);
|
|
69
|
+
expect(restingSnapPoint(400.4, snapPoints)).toBe(400);
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
test("a base between
|
|
73
|
-
expect(
|
|
74
|
-
expect(
|
|
72
|
+
test("a base between snap points rests on none", () => {
|
|
73
|
+
expect(restingSnapPoint(500, snapPoints)).toBeNull();
|
|
74
|
+
expect(restingSnapPoint(598, snapPoints)).toBeNull();
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
test("with no
|
|
78
|
-
expect(
|
|
77
|
+
test("with no snap points nothing can be rested on", () => {
|
|
78
|
+
expect(restingSnapPoint(0, [])).toBeNull();
|
|
79
79
|
});
|
|
80
80
|
});
|
|
81
81
|
|
|
@@ -7,7 +7,7 @@ export type ListDragInput = {
|
|
|
7
7
|
startOffset: number;
|
|
8
8
|
/**
|
|
9
9
|
* Whether the list is being held by the lock at that offset — it began
|
|
10
|
-
* scrolled *and* below the highest
|
|
10
|
+
* scrolled *and* below the highest snap point — and the sheet has not reached
|
|
11
11
|
* the top yet this gesture. A held list scrolls nothing, so it has no
|
|
12
12
|
* budget to spend.
|
|
13
13
|
*/
|
|
@@ -45,43 +45,43 @@ export type ListOwnsReleaseInput = {
|
|
|
45
45
|
highest: number;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
-
/** Settle tolerance: a sheet within this of a
|
|
49
|
-
const
|
|
48
|
+
/** Settle tolerance: a sheet within this of a snap point is on it. */
|
|
49
|
+
const AT_SNAP_POINT = 0.5;
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* Whether a released content pan belongs to the list rather than the sheet:
|
|
53
|
-
* the sheet is on its highest
|
|
53
|
+
* the sheet is on its highest snap point and the list is scrolled, so the finger
|
|
54
54
|
* was scrolling rows and the release velocity is the list's momentum, not a
|
|
55
55
|
* snap. Snapping here is what made the library this replaces hop from the
|
|
56
|
-
* top to a lower
|
|
56
|
+
* top to a lower snap point under a series of interrupted scrolls.
|
|
57
57
|
*/
|
|
58
58
|
export function listOwnsRelease(input: ListOwnsReleaseInput): boolean {
|
|
59
59
|
"worklet";
|
|
60
60
|
if (!input.scrollable) return false;
|
|
61
61
|
if (input.offset <= 0) return false;
|
|
62
|
-
return input.base >= input.highest -
|
|
62
|
+
return input.base >= input.highest - AT_SNAP_POINT;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* The
|
|
66
|
+
* The snap point a base is resting on — the one within the settle tolerance of
|
|
67
67
|
* it — or `null` when it sits between two.
|
|
68
68
|
*
|
|
69
69
|
* A release the list owns still has to settle the sheet's bookkeeping: the
|
|
70
70
|
* finger carried the sheet to the top and kept going, so no animation ran and
|
|
71
|
-
* nothing wrote `currentIndex`. The pan asks which
|
|
72
|
-
* settles there without motion; between
|
|
71
|
+
* nothing wrote `currentIndex`. The pan asks which snap point `base` is on and
|
|
72
|
+
* settles there without motion; between snap points it snaps instead.
|
|
73
73
|
*/
|
|
74
|
-
export function
|
|
74
|
+
export function restingSnapPoint(base: number, snapPoints: readonly number[]): number | null {
|
|
75
75
|
"worklet";
|
|
76
|
-
for (let index = 0; index <
|
|
77
|
-
const
|
|
78
|
-
if (Math.abs(base -
|
|
76
|
+
for (let index = 0; index < snapPoints.length; index += 1) {
|
|
77
|
+
const snapPoint = snapPoints[index] as number;
|
|
78
|
+
if (Math.abs(base - snapPoint) <= AT_SNAP_POINT) return snapPoint;
|
|
79
79
|
}
|
|
80
80
|
return null;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* Where a list is held while the sheet is below its highest
|
|
84
|
+
* Where a list is held while the sheet is below its highest snap point.
|
|
85
85
|
*
|
|
86
86
|
* Wherever it was when the lock engaged, never above the top — a handle drag
|
|
87
87
|
* or a `snapToIndex` on a scrolled list keeps its rows. A content pan is the
|
package/src/core/sheet.types.ts
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* A
|
|
11
|
+
* A snap point as the consumer writes it — pixels of sheet visible above its
|
|
12
12
|
* resting bottom line, or a percentage of the height available to the sheet.
|
|
13
13
|
*
|
|
14
|
-
* Height space throughout: `0` is closed,
|
|
14
|
+
* Height space throughout: `0` is closed, snap points ascend, and `translateY` is
|
|
15
15
|
* derived from a height only at the moment a view needs it.
|
|
16
16
|
*/
|
|
17
|
-
export type
|
|
17
|
+
export type SnapPointSpec = number | `${number}%`;
|
|
18
18
|
|
|
19
19
|
/** What the sheet does when a keyboard it owns appears. */
|
|
20
20
|
export type KeyboardBehavior = "interactive" | "extend" | "fillParent" | "none";
|
|
@@ -47,7 +47,7 @@ export type SheetIntent =
|
|
|
47
47
|
| { id: number; kind: "close" }
|
|
48
48
|
| { id: number; kind: "forceClose" }
|
|
49
49
|
| { id: number; kind: "snapToIndex"; index: number }
|
|
50
|
-
| { id: number; kind: "snapToPosition"; position:
|
|
50
|
+
| { id: number; kind: "snapToPosition"; position: SnapPointSpec }
|
|
51
51
|
| { id: number; kind: "expand" }
|
|
52
52
|
| { id: number; kind: "collapse" };
|
|
53
53
|
|
|
@@ -60,7 +60,7 @@ export const GESTURE_SOURCE = {
|
|
|
60
60
|
|
|
61
61
|
export type GestureSource = (typeof GESTURE_SOURCE)[keyof typeof GESTURE_SOURCE];
|
|
62
62
|
|
|
63
|
-
/** Where the sheet sits relative to its
|
|
63
|
+
/** Where the sheet sits relative to its snap points. */
|
|
64
64
|
export const SHEET_STATE = {
|
|
65
65
|
CLOSED: 0,
|
|
66
66
|
OPENED: 1,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { dynamicSnapPoint } from "./dynamic-snap-point";
|
|
3
|
+
|
|
4
|
+
const measured = { handleHeight: 24, contentHeight: 300, footerContentHeight: 56, band: 34, available: 800 };
|
|
5
|
+
|
|
6
|
+
describe("dynamicSnapPoint", () => {
|
|
7
|
+
test("without a footer the sheet is handle, content and the safe-area band", () => {
|
|
8
|
+
expect(dynamicSnapPoint({ ...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(dynamicSnapPoint({ ...measured, hasFooter: true })).toBe(24 + 300 + 56 + 34);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("a detached sheet has no band, so it contributes nothing", () => {
|
|
16
|
+
expect(dynamicSnapPoint({ ...measured, band: 0, hasFooter: true })).toBe(24 + 300 + 56);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("never exceeds the available height", () => {
|
|
20
|
+
expect(dynamicSnapPoint({ ...measured, contentHeight: 2000, hasFooter: true })).toBe(800);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("maxDynamicContentSize caps below the available height", () => {
|
|
24
|
+
expect(dynamicSnapPoint({ ...measured, contentHeight: 2000, hasFooter: false, maxDynamicContentSize: 500 })).toBe(
|
|
25
|
+
500
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("maxDynamicContentSize above the available height changes nothing", () => {
|
|
30
|
+
expect(dynamicSnapPoint({ ...measured, contentHeight: 2000, hasFooter: false, maxDynamicContentSize: 5000 })).toBe(
|
|
31
|
+
800
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("unmeasured parts count as zero rather than pulling the sum negative", () => {
|
|
36
|
+
expect(dynamicSnapPoint({ ...measured, handleHeight: -1, footerContentHeight: -1, hasFooter: true })).toBe(
|
|
37
|
+
300 + 34
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("an unmeasured container yields zero, never a negative height", () => {
|
|
42
|
+
expect(dynamicSnapPoint({ ...measured, available: -1, hasFooter: false })).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/** Everything the dynamic
|
|
2
|
-
export type
|
|
1
|
+
/** Everything the dynamic snap point is a sum of, in height space. */
|
|
2
|
+
export type DynamicSnapPointInput = {
|
|
3
3
|
/** Measured handle height; `UNMEASURED` counts as zero. */
|
|
4
4
|
handleHeight: number;
|
|
5
5
|
/** Measured content height; `UNMEASURED` counts as zero. */
|
|
@@ -11,19 +11,19 @@ export type DynamicDetentInput = {
|
|
|
11
11
|
hasFooter: boolean;
|
|
12
12
|
/** The height the sheet may occupy. */
|
|
13
13
|
available: number;
|
|
14
|
-
/** An optional cap on the whole
|
|
14
|
+
/** An optional cap on the whole snap point, below `available`. */
|
|
15
15
|
maxDynamicContentSize?: number;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* The
|
|
19
|
+
* The snap point a sheet sized to its content settles at.
|
|
20
20
|
*
|
|
21
21
|
* Handle, content and — unlike the library this replaces — the footer's
|
|
22
22
|
* content are all in, and the safe-area band exactly once: it lives inside the
|
|
23
23
|
* footer when there is one and under the content when there is not. Capped by
|
|
24
24
|
* `maxDynamicContentSize` and always by `available`.
|
|
25
25
|
*/
|
|
26
|
-
export function
|
|
26
|
+
export function dynamicSnapPoint(input: DynamicSnapPointInput): number {
|
|
27
27
|
"worklet";
|
|
28
28
|
const handle = input.handleHeight > 0 ? input.handleHeight : 0;
|
|
29
29
|
const content = input.contentHeight > 0 ? input.contentHeight : 0;
|