@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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { heightForIndex, indexForHeight } from "./index-for-height";
|
|
3
|
+
|
|
4
|
+
const snapPoints = [200, 400, 800];
|
|
5
|
+
|
|
6
|
+
describe("indexForHeight", () => {
|
|
7
|
+
test("each snap point maps to its own integer index", () => {
|
|
8
|
+
expect(indexForHeight(200, snapPoints, 0)).toBe(0);
|
|
9
|
+
expect(indexForHeight(400, snapPoints, 0)).toBe(1);
|
|
10
|
+
expect(indexForHeight(800, snapPoints, 0)).toBe(2);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("the closed height is -1", () => {
|
|
14
|
+
expect(indexForHeight(0, snapPoints, 0)).toBe(-1);
|
|
15
|
+
expect(indexForHeight(-32, snapPoints, -32)).toBe(-1);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("between snap points the index is fractional, so the backdrop can follow a drag", () => {
|
|
19
|
+
expect(indexForHeight(100, snapPoints, 0)).toBeCloseTo(-0.5);
|
|
20
|
+
expect(indexForHeight(300, snapPoints, 0)).toBeCloseTo(0.5);
|
|
21
|
+
expect(indexForHeight(600, snapPoints, 0)).toBeCloseTo(1.5);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("clamps beyond either end — an over-drag never reports a snap point that does not exist", () => {
|
|
25
|
+
expect(indexForHeight(-100, snapPoints, 0)).toBe(-1);
|
|
26
|
+
expect(indexForHeight(900, snapPoints, 0)).toBe(2);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("with no snap points every height is closed", () => {
|
|
30
|
+
expect(indexForHeight(300, [], 0)).toBe(-1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a snap point coinciding with the closed height does not divide by zero", () => {
|
|
34
|
+
expect(Number.isFinite(indexForHeight(0, [0, 400], 0))).toBe(true);
|
|
35
|
+
expect(indexForHeight(400, [0, 400], 0)).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("heightForIndex", () => {
|
|
40
|
+
test("round-trips every snap point", () => {
|
|
41
|
+
for (const [index, height] of snapPoints.entries()) {
|
|
42
|
+
expect(heightForIndex(index, snapPoints, 0)).toBe(height);
|
|
43
|
+
expect(indexForHeight(heightForIndex(index, snapPoints, 0), snapPoints, 0)).toBe(index);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("-1 is the closed height, resting line included", () => {
|
|
48
|
+
expect(heightForIndex(-1, snapPoints, 0)).toBe(0);
|
|
49
|
+
expect(heightForIndex(-1, snapPoints, -32)).toBe(-32);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("clamps an out-of-range index to the nearest end", () => {
|
|
53
|
+
expect(heightForIndex(5, snapPoints, 0)).toBe(800);
|
|
54
|
+
expect(heightForIndex(-3, snapPoints, 0)).toBe(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a fractional index interpolates, mirroring indexForHeight", () => {
|
|
58
|
+
expect(heightForIndex(0.5, snapPoints, 0)).toBe(300);
|
|
59
|
+
expect(heightForIndex(-0.5, snapPoints, 0)).toBe(100);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("with no snap points every index is closed", () => {
|
|
63
|
+
expect(heightForIndex(0, [], -16)).toBe(-16);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { CLOSED_INDEX } from "../sheet.types";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The index a height corresponds to: `-1` closed, `0` the lowest
|
|
4
|
+
* The index a height corresponds to: `-1` closed, `0` the lowest snap point,
|
|
5
5
|
* fractional in between, clamped at both ends.
|
|
6
6
|
*
|
|
7
|
-
* Piecewise-linear over `[closedHeight, ...
|
|
7
|
+
* Piecewise-linear over `[closedHeight, ...snapPoints]` → `[-1, 0, 1, …]`. It is
|
|
8
8
|
* fed `base`, never `height`, so a keyboard lift moves the sheet without
|
|
9
9
|
* moving the index or the backdrop that follows it.
|
|
10
10
|
*/
|
|
11
|
-
export function indexForHeight(height: number,
|
|
11
|
+
export function indexForHeight(height: number, snapPoints: readonly number[], closedHeight: number): number {
|
|
12
12
|
"worklet";
|
|
13
|
-
const count =
|
|
13
|
+
const count = snapPoints.length;
|
|
14
14
|
if (count === 0 || height <= closedHeight) return CLOSED_INDEX;
|
|
15
15
|
|
|
16
16
|
let lower = closedHeight;
|
|
17
17
|
for (let index = 0; index < count; index += 1) {
|
|
18
|
-
const upper =
|
|
18
|
+
const upper = snapPoints[index] as number;
|
|
19
19
|
if (height <= upper) {
|
|
20
20
|
const span = upper - lower;
|
|
21
21
|
if (span <= 0) return index;
|
|
@@ -28,17 +28,17 @@ export function indexForHeight(height: number, detents: readonly number[], close
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* The inverse: the height at an index, `-1` being the closed height. Integers
|
|
31
|
-
* land on
|
|
31
|
+
* land on snap points, fractions interpolate, and an out-of-range index clamps.
|
|
32
32
|
*/
|
|
33
|
-
export function heightForIndex(index: number,
|
|
33
|
+
export function heightForIndex(index: number, snapPoints: readonly number[], closedHeight: number): number {
|
|
34
34
|
"worklet";
|
|
35
|
-
const count =
|
|
35
|
+
const count = snapPoints.length;
|
|
36
36
|
if (count === 0 || index <= CLOSED_INDEX) return closedHeight;
|
|
37
|
-
if (index >= count - 1) return
|
|
37
|
+
if (index >= count - 1) return snapPoints[count - 1] as number;
|
|
38
38
|
|
|
39
39
|
const upperIndex = Math.ceil(index);
|
|
40
|
-
const lower = upperIndex === 0 ? closedHeight : (
|
|
41
|
-
const upper =
|
|
40
|
+
const lower = upperIndex === 0 ? closedHeight : (snapPoints[upperIndex - 1] as number);
|
|
41
|
+
const upper = snapPoints[upperIndex] as number;
|
|
42
42
|
const fraction = index - (upperIndex - 1);
|
|
43
43
|
return lower + (upper - lower) * fraction;
|
|
44
44
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { SnapPointSpec } from "../sheet.types";
|
|
3
|
+
import { normalizeSnapPoints, parseSnapPoint } from "./normalize-snap-points";
|
|
4
|
+
|
|
5
|
+
describe("parseSnapPoint", () => {
|
|
6
|
+
test("a finite non-negative number is pixels", () => {
|
|
7
|
+
expect(parseSnapPoint(300)).toEqual({ success: true, data: { kind: "px", value: 300 } });
|
|
8
|
+
expect(parseSnapPoint(0)).toEqual({ success: true, data: { kind: "px", value: 0 } });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("a percentage string is a fraction of the available height", () => {
|
|
12
|
+
expect(parseSnapPoint("50%")).toEqual({ success: true, data: { kind: "percent", value: 50 } });
|
|
13
|
+
expect(parseSnapPoint("12.5%")).toEqual({ success: true, data: { kind: "percent", value: 12.5 } });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("refuses NaN, infinities and negatives by name", () => {
|
|
17
|
+
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, -1] as const) {
|
|
18
|
+
expect(parseSnapPoint(bad)).toEqual({ success: false, error: { code: "invalid-snap-point", value: bad } });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("refuses a string that is not a percentage", () => {
|
|
23
|
+
for (const bad of ["50", "abc%", "%", "-10%", "50px"]) {
|
|
24
|
+
expect(parseSnapPoint(bad as SnapPointSpec)).toEqual({
|
|
25
|
+
success: false,
|
|
26
|
+
error: { code: "invalid-snap-point", value: bad },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("refuses anything that is not a number or a string", () => {
|
|
32
|
+
expect(parseSnapPoint(undefined as unknown as SnapPointSpec)).toEqual({
|
|
33
|
+
success: false,
|
|
34
|
+
error: { code: "invalid-snap-point", value: undefined },
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("normalizeSnapPoints", () => {
|
|
40
|
+
test("pixels pass through", () => {
|
|
41
|
+
expect(normalizeSnapPoints([200, 400], 800)).toEqual([200, 400]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("percentages resolve against the available height", () => {
|
|
45
|
+
expect(normalizeSnapPoints(["25%", "50%", "100%"], 800)).toEqual([200, 400, 800]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("mixed specs sort ascending regardless of authoring order", () => {
|
|
49
|
+
expect(normalizeSnapPoints(["100%", 100, "50%"], 800)).toEqual([100, 400, 800]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("duplicates collapse, including a pixel and a percentage that agree", () => {
|
|
53
|
+
expect(normalizeSnapPoints([400, "50%", 400], 800)).toEqual([400]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("clamps to the available height and to zero", () => {
|
|
57
|
+
expect(normalizeSnapPoints([1000, "150%"], 800)).toEqual([800]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("skips an invalid spec rather than poisoning the list", () => {
|
|
61
|
+
expect(normalizeSnapPoints([Number.NaN, "x%" as SnapPointSpec, 300], 800)).toEqual([300]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("an unmeasured or zero available height yields nothing to snap to", () => {
|
|
65
|
+
expect(normalizeSnapPoints(["50%"], -1)).toEqual([]);
|
|
66
|
+
expect(normalizeSnapPoints([300], 0)).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("an empty spec is an empty list", () => {
|
|
70
|
+
expect(normalizeSnapPoints([], 800)).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { err, ok, type Result } from "../result";
|
|
2
|
-
import type {
|
|
2
|
+
import type { SnapPointSpec } from "../sheet.types";
|
|
3
3
|
|
|
4
|
-
/** A
|
|
5
|
-
export type
|
|
4
|
+
/** A snap point with its unit made explicit. */
|
|
5
|
+
export type ParsedSnapPoint = { kind: "px"; value: number } | { kind: "percent"; value: number };
|
|
6
6
|
|
|
7
|
-
/** Why a
|
|
8
|
-
export type
|
|
7
|
+
/** Why a snap point could not be read. `value` is what the consumer wrote. */
|
|
8
|
+
export type SnapPointError = { code: "invalid-snap-point"; value: unknown };
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Reads one
|
|
11
|
+
* Reads one snap point as the consumer wrote it — `300` or `"50%"` — refusing
|
|
12
12
|
* `NaN`, infinities, negatives and any string that is not a percentage.
|
|
13
13
|
*
|
|
14
14
|
* This is the JS-thread validator, for `snapToPosition` and for surfacing a
|
|
15
15
|
* bad `snapPoints` prop by name. The worklet below re-implements the same
|
|
16
16
|
* parse inline, because a module-scope worklet may not call it.
|
|
17
17
|
*/
|
|
18
|
-
export function
|
|
18
|
+
export function parseSnapPoint(spec: SnapPointSpec): Result<ParsedSnapPoint, SnapPointError> {
|
|
19
19
|
if (typeof spec === "number") {
|
|
20
20
|
if (!Number.isFinite(spec) || spec < 0) return err({ code: "invalid-snap-point", value: spec });
|
|
21
21
|
return ok({ kind: "px", value: spec });
|
|
@@ -36,10 +36,10 @@ export function parseDetent(spec: DetentSpec): Result<ParsedDetent, DetentError>
|
|
|
36
36
|
* the resting bottom for a detached sheet. While it is unmeasured (or zero)
|
|
37
37
|
* there is nothing to snap to, and the result is empty rather than a list of
|
|
38
38
|
* zeros. An invalid entry is skipped, never written into the list: one `NaN`
|
|
39
|
-
* in `
|
|
39
|
+
* in `snapPoints` freezes a sheet.
|
|
40
40
|
*/
|
|
41
41
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the flat-worklet rule forbids extracting helpers; see the doc comment above.
|
|
42
|
-
export function
|
|
42
|
+
export function normalizeSnapPoints(spec: readonly SnapPointSpec[], available: number): number[] {
|
|
43
43
|
"worklet";
|
|
44
44
|
if (!(available > 0)) return [];
|
|
45
45
|
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { selectSnapHeight } from "./select-snap-height";
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
const snapPoints = [200, 400, 800];
|
|
5
5
|
const at = (height: number, velocity = 0, closedHeight: number | null = 0) =>
|
|
6
|
-
selectSnapHeight({ height, velocity,
|
|
6
|
+
selectSnapHeight({ height, velocity, snapPoints, closedHeight, projection: 0.2 });
|
|
7
7
|
|
|
8
8
|
describe("selectSnapHeight", () => {
|
|
9
|
-
test("at rest, the nearest
|
|
9
|
+
test("at rest, the nearest snap point wins", () => {
|
|
10
10
|
expect(at(250)).toBe(200);
|
|
11
11
|
expect(at(350)).toBe(400);
|
|
12
12
|
expect(at(700)).toBe(800);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
test("velocity projects the release point — a flick upward reaches the next
|
|
15
|
+
test("velocity projects the release point — a flick upward reaches the next snap point", () => {
|
|
16
16
|
expect(at(250, 1000)).toBe(400);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
test("a flick downward from just above a
|
|
19
|
+
test("a flick downward from just above a snap point falls through it", () => {
|
|
20
20
|
expect(at(420, -1500)).toBe(200);
|
|
21
21
|
});
|
|
22
22
|
|
|
@@ -34,16 +34,18 @@ describe("selectSnapHeight", () => {
|
|
|
34
34
|
expect(at(700, -4000, null)).toBe(200);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
test("a projection past the highest
|
|
37
|
+
test("a projection past the highest snap point still lands on it", () => {
|
|
38
38
|
expect(at(700, 5000)).toBe(800);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
test("exactly between two
|
|
41
|
+
test("exactly between two snap points, the lower one wins", () => {
|
|
42
42
|
expect(at(300)).toBe(200);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
test("with nothing to snap to, the height is left where it is", () => {
|
|
46
|
-
expect(selectSnapHeight({ height: 123, velocity: 0,
|
|
46
|
+
expect(selectSnapHeight({ height: 123, velocity: 0, snapPoints: [], closedHeight: null, projection: 0.2 })).toBe(
|
|
47
|
+
123
|
|
48
|
+
);
|
|
47
49
|
});
|
|
48
50
|
|
|
49
51
|
test("a NaN velocity is treated as rest rather than poisoning the target", () => {
|
|
@@ -3,8 +3,8 @@ export type SelectSnapHeightInput = {
|
|
|
3
3
|
height: number;
|
|
4
4
|
/** Release velocity in height space — upward positive, so pass `-velocityY`. */
|
|
5
5
|
velocity: number;
|
|
6
|
-
/** Ascending
|
|
7
|
-
|
|
6
|
+
/** Ascending snap points. */
|
|
7
|
+
snapPoints: readonly number[];
|
|
8
8
|
/** The closed height when closing is allowed from this gesture, else `null`. */
|
|
9
9
|
closedHeight: number | null;
|
|
10
10
|
/** Seconds of velocity to project — the `0.2` the previous engine used. */
|
|
@@ -12,7 +12,7 @@ export type SelectSnapHeightInput = {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* The
|
|
15
|
+
* The snap point a released drag settles at: the candidate nearest to where the
|
|
16
16
|
* finger was heading, `height + projection · velocity`.
|
|
17
17
|
*
|
|
18
18
|
* Closing is a candidate only when `closedHeight` is given; the pan passes
|
|
@@ -31,8 +31,8 @@ export function selectSnapHeight(input: SelectSnapHeightInput): number {
|
|
|
31
31
|
bestDistance = Math.abs(projected - input.closedHeight);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
for (let index = 0; index < input.
|
|
35
|
-
const candidate = input.
|
|
34
|
+
for (let index = 0; index < input.snapPoints.length; index += 1) {
|
|
35
|
+
const candidate = input.snapPoints[index] as number;
|
|
36
36
|
const distance = Math.abs(projected - candidate);
|
|
37
37
|
if (distance < bestDistance) {
|
|
38
38
|
best = candidate;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SHEET_STATE } from "../sheet.types";
|
|
3
|
+
import { sheetState } from "./sheet-state";
|
|
4
|
+
|
|
5
|
+
const snapPoints = [200, 400];
|
|
6
|
+
const maxHeight = 800;
|
|
7
|
+
|
|
8
|
+
describe("sheetState", () => {
|
|
9
|
+
test("at or below the closed height the sheet is closed", () => {
|
|
10
|
+
expect(sheetState(0, 0, snapPoints, 0, maxHeight)).toBe(SHEET_STATE.CLOSED);
|
|
11
|
+
expect(sheetState(-10, -10, snapPoints, 0, maxHeight)).toBe(SHEET_STATE.CLOSED);
|
|
12
|
+
expect(sheetState(-32, -32, snapPoints, -32, maxHeight)).toBe(SHEET_STATE.CLOSED);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("between closed and the highest snap point it is opened", () => {
|
|
16
|
+
expect(sheetState(200, 200, snapPoints, 0, maxHeight)).toBe(SHEET_STATE.OPENED);
|
|
17
|
+
expect(sheetState(300, 300, snapPoints, 0, maxHeight)).toBe(SHEET_STATE.OPENED);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("at the highest snap point it is extended", () => {
|
|
21
|
+
expect(sheetState(400, 400, snapPoints, 0, maxHeight)).toBe(SHEET_STATE.EXTENDED);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("a hair below the highest snap point still counts as extended, so a settled spring unlocks the list", () => {
|
|
25
|
+
expect(sheetState(399.7, 399.7, snapPoints, 0, maxHeight)).toBe(SHEET_STATE.EXTENDED);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("above the highest snap point — over-drag or a keyboard lift — it is over-extended", () => {
|
|
29
|
+
expect(sheetState(450, 450, snapPoints, 0, maxHeight)).toBe(SHEET_STATE.OVER_EXTENDED);
|
|
30
|
+
expect(sheetState(400, 600, snapPoints, 0, maxHeight)).toBe(SHEET_STATE.OVER_EXTENDED);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("filling the available height is fill, whatever the snap points say", () => {
|
|
34
|
+
expect(sheetState(800, 800, snapPoints, 0, maxHeight)).toBe(SHEET_STATE.FILL);
|
|
35
|
+
expect(sheetState(400, 800, snapPoints, 0, maxHeight)).toBe(SHEET_STATE.FILL);
|
|
36
|
+
expect(sheetState(800, 800, [200, 800], 0, maxHeight)).toBe(SHEET_STATE.FILL);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("with no snap points anything open is extended, so a dynamic sheet still unlocks its list", () => {
|
|
40
|
+
expect(sheetState(300, 300, [], 0, maxHeight)).toBe(SHEET_STATE.EXTENDED);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -4,19 +4,19 @@ import { SHEET_STATE, type SheetState } from "../sheet.types";
|
|
|
4
4
|
const SETTLE_EPSILON = 0.5;
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Where the sheet is relative to its
|
|
7
|
+
* Where the sheet is relative to its snap points.
|
|
8
8
|
*
|
|
9
9
|
* `base` decides closed and extended — the keyboard-free height is what the
|
|
10
10
|
* gesture and the scroll lock reason about. `height` decides fill and
|
|
11
11
|
* over-extended, because a keyboard lift or an over-drag pushes the visible
|
|
12
|
-
* sheet past the highest
|
|
13
|
-
* dynamic sheet whose content is the only
|
|
12
|
+
* sheet past the highest snap point without moving `base`. With no snap points (a
|
|
13
|
+
* dynamic sheet whose content is the only snap point, before it measures) any
|
|
14
14
|
* open height is extended, so the list under it is never locked.
|
|
15
15
|
*/
|
|
16
16
|
export function sheetState(
|
|
17
17
|
base: number,
|
|
18
18
|
height: number,
|
|
19
|
-
|
|
19
|
+
snapPoints: readonly number[],
|
|
20
20
|
closedHeight: number,
|
|
21
21
|
maxHeight: number
|
|
22
22
|
): SheetState {
|
|
@@ -24,8 +24,8 @@ export function sheetState(
|
|
|
24
24
|
if (base <= closedHeight + SETTLE_EPSILON) return SHEET_STATE.CLOSED;
|
|
25
25
|
if (height >= maxHeight - SETTLE_EPSILON) return SHEET_STATE.FILL;
|
|
26
26
|
|
|
27
|
-
const count =
|
|
28
|
-
const highest = count > 0 ? (
|
|
27
|
+
const count = snapPoints.length;
|
|
28
|
+
const highest = count > 0 ? (snapPoints[count - 1] as number) : base;
|
|
29
29
|
if (height > highest + SETTLE_EPSILON) return SHEET_STATE.OVER_EXTENDED;
|
|
30
30
|
if (base >= highest - SETTLE_EPSILON) return SHEET_STATE.EXTENDED;
|
|
31
31
|
return SHEET_STATE.OPENED;
|
package/src/gesture/AGENTS.md
CHANGED
|
@@ -12,31 +12,31 @@ The two pans that drive a sheet, and the haptic hooks they fire.
|
|
|
12
12
|
## The handlers
|
|
13
13
|
|
|
14
14
|
`onStart` cancels the animation that owns `base`, records the source (handle
|
|
15
|
-
or content), where the drag began and which
|
|
15
|
+
or content), where the drag began and which snap point was under it.
|
|
16
16
|
|
|
17
17
|
`onChange` turns the finger's travel into a height — `startBase −
|
|
18
18
|
translationY`, less the scrollable's offset for a content pan once BSHEET-4
|
|
19
19
|
wires one — rubber-banded by `resistOverDrag` past the lowest and highest
|
|
20
|
-
|
|
21
|
-
`enablePanDownToClose`, else the first
|
|
22
|
-
|
|
20
|
+
snap point and never above the container. The lowest is the closed height when
|
|
21
|
+
`enablePanDownToClose`, else the first snap point. `crossedSnapPoint` decides the
|
|
22
|
+
snap point haptic; entering the rubber band fires the over-drag haptic once.
|
|
23
23
|
|
|
24
24
|
`onFinalize`, not `onEnd`: a gesture that fails after activating never fires
|
|
25
|
-
`onEnd`, and the sheet would be stranded between
|
|
25
|
+
`onEnd`, and the sheet would be stranded between snap points. `selectSnapHeight`
|
|
26
26
|
projects the release velocity a fifth of a second ahead and picks the nearest
|
|
27
27
|
candidate, closing only when the pan may close, and `animateTo` gets half the
|
|
28
28
|
velocity in height space — upward positive, so `−velocityY`.
|
|
29
29
|
|
|
30
30
|
A content pan over a scrollable is shared with the list. When `listOwnsRelease`
|
|
31
|
-
says the release is the list's — the sheet at its highest
|
|
31
|
+
says the release is the list's — the sheet at its highest snap point, the list
|
|
32
32
|
scrolled — the sheet does not snap, but it **still settles**: the finger moved
|
|
33
33
|
`base` by hand, so no animation ran and nothing else would write
|
|
34
|
-
`currentIndex`. `
|
|
34
|
+
`currentIndex`. `restingSnapPoint` names the snap point under `base` and `settleAt`
|
|
35
35
|
does the bookkeeping of a finished animation without the motion —
|
|
36
36
|
`currentIndex`, then `onSettle`, so the handle's accessibility value and
|
|
37
37
|
`onIndexChange` catch up. A `return` there once left the index at the previous
|
|
38
|
-
|
|
39
|
-
nothing because `close` read the sheet as already closed. Between
|
|
38
|
+
snap point (or at `-1`) under a sheet sitting at 90%, and the next overlay tap did
|
|
39
|
+
nothing because `close` read the sheet as already closed. Between snap points,
|
|
40
40
|
which the ownership test should rule out, the sheet snaps to the nearest with
|
|
41
41
|
no velocity and never closes: the release velocity is the list's momentum.
|
|
42
42
|
|
|
@@ -51,14 +51,14 @@ view. Scrollables (BSHEET-4) wrap their native gesture in
|
|
|
51
51
|
|
|
52
52
|
## Per-gesture memory
|
|
53
53
|
|
|
54
|
-
Where the drag began, the
|
|
54
|
+
Where the drag began, the snap point last under it and whether it is over-dragging
|
|
55
55
|
live in shared values rather than closure variables. Each worklet gets its own
|
|
56
56
|
copy of a captured `let`, so a write in `onStart` would never be seen by
|
|
57
57
|
`onChange`.
|
|
58
58
|
|
|
59
59
|
## Haptics
|
|
60
60
|
|
|
61
|
-
The package imports nothing from a haptics library. `
|
|
61
|
+
The package imports nothing from a haptics library. `onSnapPointHaptic`,
|
|
62
62
|
`onCloseHaptic` and `onOverDragHaptic` are worklets the consumer passes —
|
|
63
63
|
`Presets.System.selection` from pulsar is one already — and they run inside the
|
|
64
64
|
gesture handler, in the frame the crossing happened.
|
|
@@ -8,11 +8,11 @@ import type { GestureType } from "react-native-gesture-handler";
|
|
|
8
8
|
export type HapticWorklet = () => void;
|
|
9
9
|
|
|
10
10
|
export type SheetHaptics = {
|
|
11
|
-
/** A drag crossed a
|
|
12
|
-
|
|
11
|
+
/** A drag crossed a snap point. */
|
|
12
|
+
onSnapPointHaptic?: HapticWorklet;
|
|
13
13
|
/** A drag let go on a close. */
|
|
14
14
|
onCloseHaptic?: HapticWorklet;
|
|
15
|
-
/** A drag left the
|
|
15
|
+
/** A drag left the snap point range and the rubber band took over. */
|
|
16
16
|
onOverDragHaptic?: HapticWorklet;
|
|
17
17
|
};
|
|
18
18
|
|
|
@@ -44,7 +44,7 @@ type ScrollLockProps = Pick<
|
|
|
44
44
|
>;
|
|
45
45
|
|
|
46
46
|
export type ScrollLock = {
|
|
47
|
-
/** `true` while the sheet is below its highest
|
|
47
|
+
/** `true` while the sheet is below its highest snap point: the list is held at `scrollLockedAt`. */
|
|
48
48
|
locked: SharedValue<boolean>;
|
|
49
49
|
/** For the list's `onScroll`. Tracks the offset into `scrollOffsetY` and enforces the lock. */
|
|
50
50
|
scrollHandler: ReturnType<typeof useAnimatedScrollHandler>;
|
|
@@ -55,14 +55,14 @@ export type ScrollLock = {
|
|
|
55
55
|
/**
|
|
56
56
|
* The scroll lock: the UI-thread half of a scrollable body.
|
|
57
57
|
*
|
|
58
|
-
* Below the highest
|
|
58
|
+
* Below the highest snap point a drag on the list must move the sheet, not the
|
|
59
59
|
* rows. The content pan already does the moving — the native scroll and the
|
|
60
60
|
* pan are simultaneous — so this hook's job is to make sure the list does not
|
|
61
61
|
* *also* move: every scroll event while locked is answered with a `scrollTo`
|
|
62
62
|
* back to where the lock engaged, and the offset the pan reads is pinned
|
|
63
63
|
* there too, so the pan sees the list consume nothing.
|
|
64
64
|
*
|
|
65
|
-
* The lock engages when the sheet leaves its highest
|
|
65
|
+
* The lock engages when the sheet leaves its highest snap point by any path — a
|
|
66
66
|
* drag, a `snapToIndex`, a keyboard — and takes the offset of that moment as
|
|
67
67
|
* its target. A list scrolled a hundred pixels, then collapsed by the handle,
|
|
68
68
|
* holds those hundred pixels rather than jumping to the top; when the sheet
|