@delacour/react-native-bottom-sheet 0.1.0-alpha.0 → 0.1.0-alpha.20261006140500

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.
Files changed (76) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/animation/AGENTS.md +1 -1
  4. package/src/animation/animation.types.ts +1 -1
  5. package/src/animation/use-animate-to.ts +4 -4
  6. package/src/animation/use-settle-callbacks.ts +3 -3
  7. package/src/components/AGENTS.md +2 -2
  8. package/src/components/bottom-sheet-background.tsx +1 -1
  9. package/src/components/bottom-sheet-content.tsx +3 -3
  10. package/src/components/bottom-sheet-footer.tsx +3 -3
  11. package/src/components/bottom-sheet-handle.tsx +5 -5
  12. package/src/components/bottom-sheet-overlay.tsx +1 -1
  13. package/src/components/bottom-sheet.context.tsx +1 -1
  14. package/src/components/bottom-sheet.tsx +13 -11
  15. package/src/components/bottom-sheet.types.ts +15 -15
  16. package/src/core/AGENTS.md +17 -17
  17. package/src/core/backdrop/backdrop-opacity.test.ts +2 -2
  18. package/src/core/backdrop/backdrop-opacity.ts +1 -1
  19. package/src/core/footer/body-inset.test.ts +3 -3
  20. package/src/core/footer/body-inset.ts +3 -3
  21. package/src/core/geometry/position.ts +1 -1
  22. package/src/core/geometry/surface-height.test.ts +5 -5
  23. package/src/core/geometry/surface-height.ts +7 -7
  24. package/src/core/haptic/crossed-snap-point.test.ts +43 -0
  25. package/src/core/haptic/crossed-snap-point.ts +33 -0
  26. package/src/core/index.ts +14 -9
  27. package/src/core/intent/layout-ready.ts +1 -1
  28. package/src/core/intent/resolve-intent.test.ts +9 -9
  29. package/src/core/intent/resolve-intent.ts +10 -10
  30. package/src/core/keyboard/container-layout-guard.ts +1 -1
  31. package/src/core/keyboard/keyboard-step.ts +2 -2
  32. package/src/core/machine/machine.types.ts +2 -2
  33. package/src/core/machine/step-transition.ts +2 -2
  34. package/src/core/scroll/scroll-lock.test.ts +2 -2
  35. package/src/core/scroll/scroll-lock.ts +2 -2
  36. package/src/core/scroll/scroll-pan.test.ts +14 -14
  37. package/src/core/scroll/scroll-pan.ts +14 -14
  38. package/src/core/sheet.types.ts +5 -5
  39. package/src/core/snap-points/dynamic-snap-point.test.ts +44 -0
  40. package/src/core/{detents/dynamic-detent.ts → snap-points/dynamic-snap-point.ts} +5 -5
  41. package/src/core/snap-points/index-for-height.test.ts +65 -0
  42. package/src/core/{detents → snap-points}/index-for-height.ts +11 -11
  43. package/src/core/snap-points/normalize-snap-points.test.ts +72 -0
  44. package/src/core/{detents/normalize-detents.ts → snap-points/normalize-snap-points.ts} +9 -9
  45. package/src/core/{detents → snap-points}/select-snap-height.test.ts +10 -8
  46. package/src/core/{detents → snap-points}/select-snap-height.ts +5 -5
  47. package/src/core/snap-points/sheet-state.test.ts +42 -0
  48. package/src/core/{detents → snap-points}/sheet-state.ts +6 -6
  49. package/src/gesture/AGENTS.md +11 -11
  50. package/src/gesture/gesture.types.ts +3 -3
  51. package/src/gesture/use-scroll-lock.ts +3 -3
  52. package/src/gesture/use-sheet-pan.ts +43 -32
  53. package/src/keyboard/AGENTS.md +2 -2
  54. package/src/keyboard/use-sheet-keyboard.ts +6 -6
  55. package/src/layout/AGENTS.md +5 -5
  56. package/src/portal/AGENTS.md +14 -5
  57. package/src/portal/bottom-sheet-provider.tsx +19 -7
  58. package/src/scrollable/AGENTS.md +5 -5
  59. package/src/scrollable/bottom-sheet-scroll-view.tsx +1 -1
  60. package/src/scrollable/create-bottom-sheet-scrollable.tsx +3 -3
  61. package/src/state/AGENTS.md +4 -4
  62. package/src/state/state.types.ts +11 -11
  63. package/src/state/use-sheet-geometry.ts +11 -11
  64. package/src/state/use-sheet-intents.ts +12 -9
  65. package/src/state/use-sheet-state.ts +9 -9
  66. package/src/state/{use-detents.ts → use-snap-points.ts} +13 -13
  67. package/src/steps/AGENTS.md +7 -7
  68. package/src/steps/bottom-sheet-steps.tsx +9 -9
  69. package/src/core/detents/dynamic-detent.test.ts +0 -40
  70. package/src/core/detents/index-for-height.test.ts +0 -65
  71. package/src/core/detents/normalize-detents.test.ts +0 -72
  72. package/src/core/detents/sheet-state.test.ts +0 -42
  73. package/src/core/haptic/crossed-detent.test.ts +0 -43
  74. package/src/core/haptic/crossed-detent.ts +0 -33
  75. /package/src/core/{detents → snap-points}/over-drag.test.ts +0 -0
  76. /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 detents the card resizes: its bottom edge stays on the resting
5
- * line and the top follows `height`. Below the first detent — a drag down to
6
- * close, the close animation, a rubber-band under the lowest detent — and
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 detent's height and `translateY` alone moves it, so
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, lowestDetent: number, highestDetent: number): number {
15
+ export function surfaceHeight(height: number, lowestSnapPoint: number, highestSnapPoint: number): number {
16
16
  "worklet";
17
- const low = lowestDetent > 0 ? lowestDetent : 0;
18
- const high = highestDetent > low ? highestDetent : low;
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 { crossedDetent, detentUnder } from "./haptic/crossed-detent";
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
- restingDetent,
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 detent, and so has to be known. */
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
- detents: [200, 400, 800],
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, detents: [] };
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 detent lands on 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, detents: [] }, intent("open"))).toBeNull();
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 detent at that index", () => {
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 detent", () => {
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 detent, collapse to the lowest", () => {
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 detent list", () => {
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, detents: [] }, intent("collapse"))).toBeNull();
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 detents. */
18
- detents: readonly number[];
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 detent.
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 `parseDetent`
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 detents = state.detents;
55
- const count = detents.length;
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: detents[index] as number };
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: detents[index] as number };
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: detents[count - 1] as number };
97
- return { action: "animate", target: detents[0] as number };
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 detent re-derived against it.
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 detent,
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 detent it held before. `release` — the same moment under
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 { DetentSpec } from "../sheet.types";
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 DetentSpec[];
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 { DetentSpec } from "../sheet.types";
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 DetentSpec[] | undefined;
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 detent — a drag on the list moves the sheet, not the rows", () => {
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 detent and beyond", () => {
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 detent 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 detent
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, restingDetent, scrollLockTarget } from "./scroll-pan";
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 detent is the list's", () => {
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 detent the sheet takes it whatever the offset", () => {
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("restingDetent", () => {
64
- const detents = [200, 400, 600];
63
+ describe("restingSnapPoint", () => {
64
+ const snapPoints = [200, 400, 600];
65
65
 
66
- test("a base on a detent, or within the settle tolerance of one, is resting on it", () => {
67
- expect(restingDetent(600, detents)).toBe(600);
68
- expect(restingDetent(599.7, detents)).toBe(600);
69
- expect(restingDetent(400.4, detents)).toBe(400);
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 detents rests on none", () => {
73
- expect(restingDetent(500, detents)).toBeNull();
74
- expect(restingDetent(598, detents)).toBeNull();
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 detents nothing can be rested on", () => {
78
- expect(restingDetent(0, [])).toBeNull();
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 detent — and the sheet has not reached
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 detent is on it. */
49
- const AT_DETENT = 0.5;
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 detent and the list is scrolled, so the finger
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 detent under a series of interrupted scrolls.
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 - AT_DETENT;
62
+ return input.base >= input.highest - AT_SNAP_POINT;
63
63
  }
64
64
 
65
65
  /**
66
- * The detent a base is resting on — the one within the settle tolerance of
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 detent `base` is on and
72
- * settles there without motion; between detents it snaps instead.
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 restingDetent(base: number, detents: readonly number[]): number | null {
74
+ export function restingSnapPoint(base: number, snapPoints: readonly number[]): number | null {
75
75
  "worklet";
76
- for (let index = 0; index < detents.length; index += 1) {
77
- const detent = detents[index] as number;
78
- if (Math.abs(base - detent) <= AT_DETENT) return detent;
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 detent.
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
@@ -8,13 +8,13 @@
8
8
  */
9
9
 
10
10
  /**
11
- * A detent as the consumer writes it — pixels of sheet visible above its
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, detents ascend, and `translateY` is
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 DetentSpec = number | `${number}%`;
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: DetentSpec }
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 detents. */
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 detent is a sum of, in height space. */
2
- export type DynamicDetentInput = {
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 detent, below `available`. */
14
+ /** An optional cap on the whole snap point, below `available`. */
15
15
  maxDynamicContentSize?: number;
16
16
  };
17
17
 
18
18
  /**
19
- * The detent a sheet sized to its content settles at.
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 dynamicDetent(input: DynamicDetentInput): number {
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;