@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.
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
@@ -19,7 +19,7 @@ export type SheetIntentDispatch = (request: IntentRequest) => void;
19
19
  * A JS write to a shared value is queued to the UI thread, so reading
20
20
  * `state.intent.value` straight after a `dispatch` still returns what was
21
21
  * there before — the root once read `null` that way, queued an `open` on top
22
- * of a ref's `expand`, and the sheet landed on the wrong detent. `clear`
22
+ * of a ref's `expand`, and the sheet landed on the wrong snap point. `clear`
23
23
  * writes `null` in order after whatever was queued, which is what a controlled
24
24
  * `isOpen: false` needs to cancel an open still waiting on layout.
25
25
  */
@@ -43,7 +43,7 @@ export type SheetIntents = {
43
43
  * jump rather than an animation when `animateOnMount` is off. `mountPending`
44
44
  * is the flag, and it clears on that first resolution whatever kind it was.
45
45
  *
46
- * A second reaction handles the detents moving under a settled sheet — dynamic
46
+ * A second reaction handles the snap points moving under a settled sheet — dynamic
47
47
  * content growing, a `snapPoints` change — by animating to the same index in
48
48
  * the new list, and a container resize by jumping there instead. Neither runs
49
49
  * while a gesture or an animation owns `base`.
@@ -84,14 +84,14 @@ export function useSheetIntents(
84
84
  // a close during the open animation has to see an open sheet.
85
85
  const effectiveIndex =
86
86
  state.animStatus.value === ANIM_STATUS.RUNNING
87
- ? Math.round(indexForHeight(state.animTarget.value, geometry.detents.value, geometry.closedHeight.value))
87
+ ? Math.round(indexForHeight(state.animTarget.value, geometry.snapPoints.value, geometry.closedHeight.value))
88
88
  : state.currentIndex.value;
89
89
  const resolution = resolveIntent(
90
90
  {
91
91
  currentIndex: effectiveIndex,
92
92
  base: state.base.value,
93
93
  layoutReady: current.ready,
94
- detents: geometry.detents.value,
94
+ snapPoints: geometry.snapPoints.value,
95
95
  closedHeight: geometry.closedHeight.value,
96
96
  maxHeight: geometry.maxHeight.value,
97
97
  initialIndex: state.config.value.initialIndex,
@@ -117,18 +117,21 @@ export function useSheetIntents(
117
117
  );
118
118
 
119
119
  useAnimatedReaction(
120
- () => ({ detents: geometry.detents.value.join(","), container: state.containerHeight.value }),
120
+ () => ({ snapPoints: geometry.snapPoints.value.join(","), container: state.containerHeight.value }),
121
121
  (current, previous) => {
122
- if (previous === null || (current.detents === previous.detents && current.container === previous.container)) {
122
+ if (
123
+ previous === null ||
124
+ (current.snapPoints === previous.snapPoints && current.container === previous.container)
125
+ ) {
123
126
  return;
124
127
  }
125
128
  const busy = state.animStatus.value !== ANIM_STATUS.IDLE || state.gestureSource.value !== GESTURE_SOURCE.NONE;
126
129
  const index = state.currentIndex.value;
127
130
  if (busy || index < 0) return;
128
131
 
129
- const detents = geometry.detents.value;
130
- if (detents.length === 0) return;
131
- const target = heightForIndex(Math.min(index, detents.length - 1), detents, geometry.closedHeight.value);
132
+ const snapPoints = geometry.snapPoints.value;
133
+ if (snapPoints.length === 0) return;
134
+ const target = heightForIndex(Math.min(index, snapPoints.length - 1), snapPoints, geometry.closedHeight.value);
132
135
  if (Math.abs(target - state.base.value) < 0.5) return;
133
136
 
134
137
  if (current.container !== previous.container) {
@@ -3,25 +3,25 @@ import { useSharedValue } from "react-native-reanimated";
3
3
  import {
4
4
  type AnimationSource,
5
5
  CLOSED_INDEX,
6
- type DetentSpec,
7
6
  GESTURE_SOURCE,
8
7
  SCROLLABLE_TYPE,
9
8
  type ScrollableType,
10
9
  type SheetIntent,
10
+ type SnapPointSpec,
11
11
  UNMEASURED,
12
12
  } from "../core";
13
13
  import { ANIM_STATUS, type AnimStatus, type SheetSharedState, type SheetWorkletConfig } from "./state.types";
14
14
 
15
15
  /**
16
- * Allocates the raw shared values, once per sheet, and keeps `detentSpec` and
16
+ * Allocates the raw shared values, once per sheet, and keeps `snapPointSpec` and
17
17
  * `config` in step with the props.
18
18
  *
19
- * `detentSpec` is written when its **serialisation** changes, not its identity,
19
+ * `snapPointSpec` is written when its **serialisation** changes, not its identity,
20
20
  * so a `snapPoints={["40%", "85%"]}` literal in a render body costs nothing —
21
21
  * the array identity churn that made the library this replaces re-derive on
22
22
  * every render is simply not observed.
23
23
  */
24
- export function useSheetState(snapPoints: readonly DetentSpec[], config: SheetWorkletConfig): SheetSharedState {
24
+ export function useSheetState(snapPoints: readonly SnapPointSpec[], config: SheetWorkletConfig): SheetSharedState {
25
25
  const containerHeight = useSharedValue(UNMEASURED);
26
26
  const containerWidth = useSharedValue(UNMEASURED);
27
27
  const containerBottomOffset = useSharedValue(0);
@@ -42,14 +42,14 @@ export function useSheetState(snapPoints: readonly DetentSpec[], config: SheetWo
42
42
  const keyboardHeight = useSharedValue(0);
43
43
  const intent = useSharedValue<SheetIntent | null>(null);
44
44
  const mountPending = useSharedValue(true);
45
- const detentSpec = useSharedValue<readonly DetentSpec[]>(snapPoints);
45
+ const snapPointSpec = useSharedValue<readonly SnapPointSpec[]>(snapPoints);
46
46
  const configValue = useSharedValue<SheetWorkletConfig>(config);
47
47
 
48
48
  const specKey = snapPoints.join("|");
49
49
  // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the serialisation on purpose; the array is only read when it changes
50
50
  useEffect(() => {
51
- detentSpec.value = snapPoints;
52
- }, [specKey, detentSpec]);
51
+ snapPointSpec.value = snapPoints;
52
+ }, [specKey, snapPointSpec]);
53
53
 
54
54
  const {
55
55
  dynamicSizing,
@@ -126,7 +126,7 @@ export function useSheetState(snapPoints: readonly DetentSpec[], config: SheetWo
126
126
  keyboardHeight,
127
127
  intent,
128
128
  mountPending,
129
- detentSpec,
129
+ snapPointSpec,
130
130
  config: configValue,
131
131
  }),
132
132
  [
@@ -150,7 +150,7 @@ export function useSheetState(snapPoints: readonly DetentSpec[], config: SheetWo
150
150
  keyboardHeight,
151
151
  intent,
152
152
  mountPending,
153
- detentSpec,
153
+ snapPointSpec,
154
154
  configValue,
155
155
  ]
156
156
  );
@@ -1,17 +1,17 @@
1
1
  import { type SharedValue, useDerivedValue } from "react-native-reanimated";
2
- import { dynamicDetent, normalizeDetents } from "../core";
2
+ import { dynamicSnapPoint, normalizeSnapPoints } from "../core";
3
3
  import type { SheetSharedState } from "./state.types";
4
4
 
5
5
  /**
6
- * The ascending, unique detents, recomputed on the UI thread whenever the spec,
6
+ * The ascending, unique snap points, recomputed on the UI thread whenever the spec,
7
7
  * the container or a measurement changes.
8
8
  *
9
- * The dynamic detent joins the list only once the handle and the content have
10
- * both reported — before that a sheet sized to its content has no detent at
9
+ * The dynamic snap point joins the list only once the handle and the content have
10
+ * both reported — before that a sheet sized to its content has no snap point at
11
11
  * all, and `layoutReady` keeps an open intent waiting rather than animating to
12
- * a guess. An explicit detent equal to the dynamic one is one detent, not two.
12
+ * a guess. An explicit snap point equal to the dynamic one is one snap point, not two.
13
13
  */
14
- export function useDetents(
14
+ export function useSnapPoints(
15
15
  state: SheetSharedState,
16
16
  maxHeight: SharedValue<number>,
17
17
  band: SharedValue<number>
@@ -19,13 +19,13 @@ export function useDetents(
19
19
  return useDerivedValue<readonly number[]>(() => {
20
20
  const available = maxHeight.value;
21
21
  const config = state.config.value;
22
- const detents = normalizeDetents(state.detentSpec.value, available);
22
+ const snapPoints = normalizeSnapPoints(state.snapPointSpec.value, available);
23
23
 
24
- if (!config.dynamicSizing || available <= 0) return detents;
25
- if (state.handleHeight.value < 0 || state.contentHeight.value < 0) return detents;
26
- if (config.hasFooter && state.footerContentHeight.value < 0) return detents;
24
+ if (!config.dynamicSizing || available <= 0) return snapPoints;
25
+ if (state.handleHeight.value < 0 || state.contentHeight.value < 0) return snapPoints;
26
+ if (config.hasFooter && state.footerContentHeight.value < 0) return snapPoints;
27
27
 
28
- const dynamic = dynamicDetent({
28
+ const dynamic = dynamicSnapPoint({
29
29
  handleHeight: state.handleHeight.value,
30
30
  contentHeight: state.contentHeight.value,
31
31
  footerContentHeight: state.footerContentHeight.value,
@@ -34,9 +34,9 @@ export function useDetents(
34
34
  available,
35
35
  maxDynamicContentSize: config.maxDynamicContentSize,
36
36
  });
37
- if (detents.indexOf(dynamic) !== -1) return detents;
37
+ if (snapPoints.indexOf(dynamic) !== -1) return snapPoints;
38
38
 
39
- const merged = [...detents, dynamic];
39
+ const merged = [...snapPoints, dynamic];
40
40
  merged.sort((a, b) => a - b);
41
41
  return merged;
42
42
  });
@@ -60,23 +60,23 @@ function Actions() {
60
60
  height is a shared value. Only the current step and, during a change, the
61
61
  one leaving are rendered; each is absolutely positioned across the top of
62
62
  the stack, measured by its `onLayout` while current.
63
- - **The height glides because the detent moves once.** When the current step
63
+ - **The height glides because the snap point moves once.** When the current step
64
64
  measures, `Steps` writes the number to the sheet's `contentHeight` straight
65
- away and springs the stack's height to it. The dynamic detent re-derives on
66
- the UI thread, the root's detent-change reaction (`state/use-sheet-intents.ts`)
65
+ away and springs the stack's height to it. The dynamic snap point re-derives on
66
+ the UI thread, the root's snap-point-change reaction (`state/use-sheet-intents.ts`)
67
67
  animates `base` to the same index in the new list with the sheet's own
68
68
  animation, and — because the stack's spring is resolved from the same
69
69
  config — the panel's top edge and the body's bottom edge move together. A
70
- sheet resting on an explicit detent does not move at all. If the sheet was
71
- busy when the detent moved (a step change mid-open), the stack animation's
72
- completion nudges `base` onto its detent.
70
+ sheet resting on an explicit snap point does not move at all. If the sheet was
71
+ busy when the snap point moved (a step change mid-open), the stack animation's
72
+ completion nudges `base` onto its snap point.
73
73
  - **`Content` stops measuring.** The internal context's
74
74
  `contentHeightSource` reads `steps` while a `Steps` body is mounted, and
75
75
  `Content`'s `onLayout` returns early; its layout events would otherwise be
76
76
  the stack mid-animation, a frame stale. The unmount reset to `UNMEASURED`
77
77
  still runs, so the next open waits for the first step to measure again.
78
78
  - **A step's `snapPoints` replace the root's, and turn dynamic sizing off,
79
- while it is current.** A step that names its detents is sized by them.
79
+ while it is current.** A step that names its snap points is sized by them.
80
80
  `dismissible: false` switches off pan-down-to-close and the overlay's press
81
81
  for that step — `Close` and the ref still work. Both go through
82
82
  `setStepOverride` on the root (`components/bottom-sheet.tsx`), because the
@@ -34,20 +34,20 @@ function animateWith(animation: ReanimatedAnimation, value: number, complete: (f
34
34
  * across the top of the stack and only the current step and, during a change,
35
35
  * the one leaving are rendered. The stack's height is a shared value animated
36
36
  * to whatever the current step measures, and that same measurement is written
37
- * to the sheet's `contentHeight` the moment it lands, so the dynamic detent
38
- * moves once and the root's detent-change reaction animates `base` there with
37
+ * to the sheet's `contentHeight` the moment it lands, so the dynamic snap point
38
+ * moves once and the root's snap-point-change reaction animates `base` there with
39
39
  * the same spring the body is using: the panel's top edge and the body's
40
- * bottom edge glide together, and a sheet resting on an explicit detent does
41
- * not move at all. Should the sheet have been busy when the detent moved — a
40
+ * bottom edge glide together, and a sheet resting on an explicit snap point does
41
+ * not move at all. Should the sheet have been busy when the snap point moved — a
42
42
  * step change mid-open — the height animation's completion nudges it onto its
43
- * detent.
43
+ * snap point.
44
44
  *
45
45
  * `Content`'s own measurement is switched off while this is the body, or its
46
46
  * layout events would race the animated height with stale numbers.
47
47
  *
48
48
  * The step the machine is on decides two things about the sheet: its
49
49
  * `snapPoints` replace the root's while it is current (and dynamic sizing is
50
- * off for it — a step that names its detents is sized by them), and
50
+ * off for it — a step that names its snap points is sized by them), and
51
51
  * `dismissible: false` disables pan-down-to-close and the overlay's press.
52
52
  * Both go through the root, since the overlay is not a descendant of the body.
53
53
  *
@@ -159,9 +159,9 @@ export function BottomSheetSteps<S extends string, C, E extends SheetEvent>({
159
159
  if (finished !== true) return;
160
160
  const busy = state.animStatus.value !== ANIM_STATUS.IDLE || state.gestureSource.value !== GESTURE_SOURCE.NONE;
161
161
  const index = state.currentIndex.value;
162
- const detents = geometry.detents.value;
163
- if (busy || index < 0 || detents.length === 0) return;
164
- const target = heightForIndex(Math.min(index, detents.length - 1), detents, geometry.closedHeight.value);
162
+ const snapPoints = geometry.snapPoints.value;
163
+ if (busy || index < 0 || snapPoints.length === 0) return;
164
+ const target = heightForIndex(Math.min(index, snapPoints.length - 1), snapPoints, geometry.closedHeight.value);
165
165
  if (Math.abs(target - state.base.value) > 0.5) animateTo(target, "snapPoints", 0);
166
166
  };
167
167
  stepHeight.value = animateWith(resolved, height, settle);
@@ -1,40 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { dynamicDetent } from "./dynamic-detent";
3
-
4
- const measured = { handleHeight: 24, contentHeight: 300, footerContentHeight: 56, band: 34, available: 800 };
5
-
6
- describe("dynamicDetent", () => {
7
- test("without a footer the sheet is handle, content and the safe-area band", () => {
8
- expect(dynamicDetent({ ...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(dynamicDetent({ ...measured, hasFooter: true })).toBe(24 + 300 + 56 + 34);
13
- });
14
-
15
- test("a detached sheet has no band, so it contributes nothing", () => {
16
- expect(dynamicDetent({ ...measured, band: 0, hasFooter: true })).toBe(24 + 300 + 56);
17
- });
18
-
19
- test("never exceeds the available height", () => {
20
- expect(dynamicDetent({ ...measured, contentHeight: 2000, hasFooter: true })).toBe(800);
21
- });
22
-
23
- test("maxDynamicContentSize caps below the available height", () => {
24
- expect(dynamicDetent({ ...measured, contentHeight: 2000, hasFooter: false, maxDynamicContentSize: 500 })).toBe(500);
25
- });
26
-
27
- test("maxDynamicContentSize above the available height changes nothing", () => {
28
- expect(dynamicDetent({ ...measured, contentHeight: 2000, hasFooter: false, maxDynamicContentSize: 5000 })).toBe(
29
- 800
30
- );
31
- });
32
-
33
- test("unmeasured parts count as zero rather than pulling the sum negative", () => {
34
- expect(dynamicDetent({ ...measured, handleHeight: -1, footerContentHeight: -1, hasFooter: true })).toBe(300 + 34);
35
- });
36
-
37
- test("an unmeasured container yields zero, never a negative height", () => {
38
- expect(dynamicDetent({ ...measured, available: -1, hasFooter: false })).toBe(0);
39
- });
40
- });
@@ -1,65 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { heightForIndex, indexForHeight } from "./index-for-height";
3
-
4
- const detents = [200, 400, 800];
5
-
6
- describe("indexForHeight", () => {
7
- test("each detent maps to its own integer index", () => {
8
- expect(indexForHeight(200, detents, 0)).toBe(0);
9
- expect(indexForHeight(400, detents, 0)).toBe(1);
10
- expect(indexForHeight(800, detents, 0)).toBe(2);
11
- });
12
-
13
- test("the closed height is -1", () => {
14
- expect(indexForHeight(0, detents, 0)).toBe(-1);
15
- expect(indexForHeight(-32, detents, -32)).toBe(-1);
16
- });
17
-
18
- test("between detents the index is fractional, so the backdrop can follow a drag", () => {
19
- expect(indexForHeight(100, detents, 0)).toBeCloseTo(-0.5);
20
- expect(indexForHeight(300, detents, 0)).toBeCloseTo(0.5);
21
- expect(indexForHeight(600, detents, 0)).toBeCloseTo(1.5);
22
- });
23
-
24
- test("clamps beyond either end — an over-drag never reports a detent that does not exist", () => {
25
- expect(indexForHeight(-100, detents, 0)).toBe(-1);
26
- expect(indexForHeight(900, detents, 0)).toBe(2);
27
- });
28
-
29
- test("with no detents every height is closed", () => {
30
- expect(indexForHeight(300, [], 0)).toBe(-1);
31
- });
32
-
33
- test("a detent 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 detent", () => {
41
- for (const [index, height] of detents.entries()) {
42
- expect(heightForIndex(index, detents, 0)).toBe(height);
43
- expect(indexForHeight(heightForIndex(index, detents, 0), detents, 0)).toBe(index);
44
- }
45
- });
46
-
47
- test("-1 is the closed height, resting line included", () => {
48
- expect(heightForIndex(-1, detents, 0)).toBe(0);
49
- expect(heightForIndex(-1, detents, -32)).toBe(-32);
50
- });
51
-
52
- test("clamps an out-of-range index to the nearest end", () => {
53
- expect(heightForIndex(5, detents, 0)).toBe(800);
54
- expect(heightForIndex(-3, detents, 0)).toBe(0);
55
- });
56
-
57
- test("a fractional index interpolates, mirroring indexForHeight", () => {
58
- expect(heightForIndex(0.5, detents, 0)).toBe(300);
59
- expect(heightForIndex(-0.5, detents, 0)).toBe(100);
60
- });
61
-
62
- test("with no detents every index is closed", () => {
63
- expect(heightForIndex(0, [], -16)).toBe(-16);
64
- });
65
- });
@@ -1,72 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { DetentSpec } from "../sheet.types";
3
- import { normalizeDetents, parseDetent } from "./normalize-detents";
4
-
5
- describe("parseDetent", () => {
6
- test("a finite non-negative number is pixels", () => {
7
- expect(parseDetent(300)).toEqual({ success: true, data: { kind: "px", value: 300 } });
8
- expect(parseDetent(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(parseDetent("50%")).toEqual({ success: true, data: { kind: "percent", value: 50 } });
13
- expect(parseDetent("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(parseDetent(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(parseDetent(bad as DetentSpec)).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(parseDetent(undefined as unknown as DetentSpec)).toEqual({
33
- success: false,
34
- error: { code: "invalid-snap-point", value: undefined },
35
- });
36
- });
37
- });
38
-
39
- describe("normalizeDetents", () => {
40
- test("pixels pass through", () => {
41
- expect(normalizeDetents([200, 400], 800)).toEqual([200, 400]);
42
- });
43
-
44
- test("percentages resolve against the available height", () => {
45
- expect(normalizeDetents(["25%", "50%", "100%"], 800)).toEqual([200, 400, 800]);
46
- });
47
-
48
- test("mixed specs sort ascending regardless of authoring order", () => {
49
- expect(normalizeDetents(["100%", 100, "50%"], 800)).toEqual([100, 400, 800]);
50
- });
51
-
52
- test("duplicates collapse, including a pixel and a percentage that agree", () => {
53
- expect(normalizeDetents([400, "50%", 400], 800)).toEqual([400]);
54
- });
55
-
56
- test("clamps to the available height and to zero", () => {
57
- expect(normalizeDetents([1000, "150%"], 800)).toEqual([800]);
58
- });
59
-
60
- test("skips an invalid spec rather than poisoning the list", () => {
61
- expect(normalizeDetents([Number.NaN, "x%" as DetentSpec, 300], 800)).toEqual([300]);
62
- });
63
-
64
- test("an unmeasured or zero available height yields nothing to snap to", () => {
65
- expect(normalizeDetents(["50%"], -1)).toEqual([]);
66
- expect(normalizeDetents([300], 0)).toEqual([]);
67
- });
68
-
69
- test("an empty spec is an empty list", () => {
70
- expect(normalizeDetents([], 800)).toEqual([]);
71
- });
72
- });
@@ -1,42 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { SHEET_STATE } from "../sheet.types";
3
- import { sheetState } from "./sheet-state";
4
-
5
- const detents = [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, detents, 0, maxHeight)).toBe(SHEET_STATE.CLOSED);
11
- expect(sheetState(-10, -10, detents, 0, maxHeight)).toBe(SHEET_STATE.CLOSED);
12
- expect(sheetState(-32, -32, detents, -32, maxHeight)).toBe(SHEET_STATE.CLOSED);
13
- });
14
-
15
- test("between closed and the highest detent it is opened", () => {
16
- expect(sheetState(200, 200, detents, 0, maxHeight)).toBe(SHEET_STATE.OPENED);
17
- expect(sheetState(300, 300, detents, 0, maxHeight)).toBe(SHEET_STATE.OPENED);
18
- });
19
-
20
- test("at the highest detent it is extended", () => {
21
- expect(sheetState(400, 400, detents, 0, maxHeight)).toBe(SHEET_STATE.EXTENDED);
22
- });
23
-
24
- test("a hair below the highest detent still counts as extended, so a settled spring unlocks the list", () => {
25
- expect(sheetState(399.7, 399.7, detents, 0, maxHeight)).toBe(SHEET_STATE.EXTENDED);
26
- });
27
-
28
- test("above the highest detent — over-drag or a keyboard lift — it is over-extended", () => {
29
- expect(sheetState(450, 450, detents, 0, maxHeight)).toBe(SHEET_STATE.OVER_EXTENDED);
30
- expect(sheetState(400, 600, detents, 0, maxHeight)).toBe(SHEET_STATE.OVER_EXTENDED);
31
- });
32
-
33
- test("filling the available height is fill, whatever the detents say", () => {
34
- expect(sheetState(800, 800, detents, 0, maxHeight)).toBe(SHEET_STATE.FILL);
35
- expect(sheetState(400, 800, detents, 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 detents 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
- });
@@ -1,43 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { crossedDetent, detentUnder } from "./crossed-detent";
3
-
4
- const detents = [200, 400, 800];
5
-
6
- describe("detentUnder", () => {
7
- test("the index of the highest detent at or below the height, -1 below the first", () => {
8
- expect(detentUnder(100, detents)).toBe(-1);
9
- expect(detentUnder(200, detents)).toBe(0);
10
- expect(detentUnder(399, detents)).toBe(0);
11
- expect(detentUnder(400, detents)).toBe(1);
12
- expect(detentUnder(900, detents)).toBe(2);
13
- });
14
-
15
- test("a hair below a detent counts as on it, so a settled spring is not a phantom crossing", () => {
16
- expect(detentUnder(399.8, detents)).toBe(1);
17
- });
18
-
19
- test("with no detents nothing is ever under the sheet", () => {
20
- expect(detentUnder(300, [])).toBe(-1);
21
- });
22
- });
23
-
24
- describe("crossedDetent", () => {
25
- test("true when the drag crosses a detent in either direction", () => {
26
- expect(crossedDetent(0, 401, detents)).toBe(true);
27
- expect(crossedDetent(1, 399, detents)).toBe(true);
28
- });
29
-
30
- test("false while the drag stays between the same detents", () => {
31
- expect(crossedDetent(0, 250, detents)).toBe(false);
32
- expect(crossedDetent(0, 399, detents)).toBe(false);
33
- });
34
-
35
- test("crossing the lowest detent downward counts, so a haptic fires on the way to closed too", () => {
36
- expect(crossedDetent(0, 150, detents)).toBe(true);
37
- });
38
-
39
- test("a frame that skips a detent is one crossing, and detentUnder says where it landed", () => {
40
- expect(crossedDetent(-1, 850, detents)).toBe(true);
41
- expect(detentUnder(850, detents)).toBe(2);
42
- });
43
- });
@@ -1,33 +0,0 @@
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 detent at or below `height`, `-1` below the
6
- * first. A hair under a detent counts as on it, so a spring settling a
7
- * fraction short is not a crossing.
8
- */
9
- export function detentUnder(height: number, detents: readonly number[]): number {
10
- "worklet";
11
- let under = -1;
12
- for (let index = 0; index < detents.length; index += 1) {
13
- if (height + SETTLE_EPSILON >= (detents[index] as number)) under = index;
14
- else break;
15
- }
16
- return under;
17
- }
18
-
19
- /**
20
- * Whether a drag crossed a detent this frame: the detent 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 `detentUnder(height, detents)`. A frame
23
- * that leaps two detents is one crossing, not a burst.
24
- */
25
- export function crossedDetent(lastIndex: number, height: number, detents: readonly number[]): boolean {
26
- "worklet";
27
- let under = -1;
28
- for (let index = 0; index < detents.length; index += 1) {
29
- if (height + SETTLE_EPSILON >= (detents[index] as number)) under = index;
30
- else break;
31
- }
32
- return under !== lastIndex;
33
- }
File without changes