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

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 (143) hide show
  1. package/README.md +132 -0
  2. package/package.json +57 -0
  3. package/src/animation/AGENTS.md +58 -0
  4. package/src/animation/animation.types.ts +35 -0
  5. package/src/animation/resolve-animation.ts +75 -0
  6. package/src/animation/use-animate-to.ts +107 -0
  7. package/src/animation/use-settle-callbacks.ts +60 -0
  8. package/src/components/AGENTS.md +80 -0
  9. package/src/components/bottom-sheet-background.tsx +34 -0
  10. package/src/components/bottom-sheet-close.tsx +45 -0
  11. package/src/components/bottom-sheet-container.tsx +79 -0
  12. package/src/components/bottom-sheet-content.tsx +102 -0
  13. package/src/components/bottom-sheet-description.tsx +33 -0
  14. package/src/components/bottom-sheet-footer.tsx +97 -0
  15. package/src/components/bottom-sheet-handle.tsx +72 -0
  16. package/src/components/bottom-sheet-overlay.tsx +91 -0
  17. package/src/components/bottom-sheet-portal.tsx +96 -0
  18. package/src/components/bottom-sheet-text-input.tsx +36 -0
  19. package/src/components/bottom-sheet-title.tsx +30 -0
  20. package/src/components/bottom-sheet-trigger.tsx +51 -0
  21. package/src/components/bottom-sheet.context.tsx +134 -0
  22. package/src/components/bottom-sheet.tsx +475 -0
  23. package/src/components/bottom-sheet.types.ts +235 -0
  24. package/src/core/AGENTS.md +160 -0
  25. package/src/core/animation/select-animation.test.ts +57 -0
  26. package/src/core/animation/select-animation.ts +104 -0
  27. package/src/core/backdrop/backdrop-opacity.test.ts +49 -0
  28. package/src/core/backdrop/backdrop-opacity.ts +21 -0
  29. package/src/core/detents/dynamic-detent.test.ts +40 -0
  30. package/src/core/detents/dynamic-detent.ts +41 -0
  31. package/src/core/detents/index-for-height.test.ts +65 -0
  32. package/src/core/detents/index-for-height.ts +44 -0
  33. package/src/core/detents/normalize-detents.test.ts +72 -0
  34. package/src/core/detents/normalize-detents.ts +63 -0
  35. package/src/core/detents/over-drag.test.ts +46 -0
  36. package/src/core/detents/over-drag.ts +23 -0
  37. package/src/core/detents/select-snap-height.test.ts +52 -0
  38. package/src/core/detents/select-snap-height.ts +44 -0
  39. package/src/core/detents/sheet-state.test.ts +42 -0
  40. package/src/core/detents/sheet-state.ts +32 -0
  41. package/src/core/footer/body-inset.test.ts +95 -0
  42. package/src/core/footer/body-inset.ts +65 -0
  43. package/src/core/footer/bottom-band.test.ts +46 -0
  44. package/src/core/footer/bottom-band.ts +30 -0
  45. package/src/core/footer/footer-top.test.ts +61 -0
  46. package/src/core/footer/footer-top.ts +27 -0
  47. package/src/core/footer/sheet-insets.test.ts +27 -0
  48. package/src/core/footer/sheet-insets.ts +30 -0
  49. package/src/core/geometry/clamp-height.test.ts +25 -0
  50. package/src/core/geometry/clamp-height.ts +12 -0
  51. package/src/core/geometry/closed-height.test.ts +37 -0
  52. package/src/core/geometry/closed-height.ts +29 -0
  53. package/src/core/geometry/detached-frame.test.ts +50 -0
  54. package/src/core/geometry/detached-frame.ts +49 -0
  55. package/src/core/geometry/position.test.ts +21 -0
  56. package/src/core/geometry/position.ts +13 -0
  57. package/src/core/geometry/surface-height.test.ts +30 -0
  58. package/src/core/geometry/surface-height.ts +22 -0
  59. package/src/core/haptic/crossed-detent.test.ts +43 -0
  60. package/src/core/haptic/crossed-detent.ts +33 -0
  61. package/src/core/index.ts +92 -0
  62. package/src/core/intent/layout-ready.test.ts +37 -0
  63. package/src/core/intent/layout-ready.ts +24 -0
  64. package/src/core/intent/resolve-intent.test.ts +163 -0
  65. package/src/core/intent/resolve-intent.ts +98 -0
  66. package/src/core/keyboard/container-layout-guard.test.ts +32 -0
  67. package/src/core/keyboard/container-layout-guard.ts +31 -0
  68. package/src/core/keyboard/content-area.test.ts +34 -0
  69. package/src/core/keyboard/content-area.ts +26 -0
  70. package/src/core/keyboard/keyboard-lift.test.ts +54 -0
  71. package/src/core/keyboard/keyboard-lift.ts +47 -0
  72. package/src/core/keyboard/keyboard-owner.test.ts +35 -0
  73. package/src/core/keyboard/keyboard-owner.ts +25 -0
  74. package/src/core/keyboard/keyboard-ownership.test.ts +69 -0
  75. package/src/core/keyboard/keyboard-ownership.ts +39 -0
  76. package/src/core/keyboard/keyboard-reset-guard.test.ts +33 -0
  77. package/src/core/keyboard/keyboard-reset-guard.ts +33 -0
  78. package/src/core/keyboard/keyboard-step.test.ts +66 -0
  79. package/src/core/keyboard/keyboard-step.ts +53 -0
  80. package/src/core/machine/define-sheet-machine.test.ts +187 -0
  81. package/src/core/machine/define-sheet-machine.ts +35 -0
  82. package/src/core/machine/index.ts +23 -0
  83. package/src/core/machine/machine.types.ts +80 -0
  84. package/src/core/machine/step-transition.test.ts +46 -0
  85. package/src/core/machine/step-transition.ts +59 -0
  86. package/src/core/machine/transition.test.ts +51 -0
  87. package/src/core/machine/transition.ts +58 -0
  88. package/src/core/result.test.ts +15 -0
  89. package/src/core/result.ts +20 -0
  90. package/src/core/scroll/scroll-lock.test.ts +40 -0
  91. package/src/core/scroll/scroll-lock.ts +35 -0
  92. package/src/core/scroll/scroll-pan.test.ts +99 -0
  93. package/src/core/scroll/scroll-pan.ts +96 -0
  94. package/src/core/sheet.types.ts +93 -0
  95. package/src/display-name.test.ts +181 -0
  96. package/src/docs.test.ts +45 -0
  97. package/src/flat-worklet.test.ts +130 -0
  98. package/src/gesture/AGENTS.md +71 -0
  99. package/src/gesture/gesture.types.ts +37 -0
  100. package/src/gesture/use-scroll-lock.ts +162 -0
  101. package/src/gesture/use-sheet-pan.ts +250 -0
  102. package/src/index.ts +127 -0
  103. package/src/keyboard/AGENTS.md +84 -0
  104. package/src/keyboard/use-bottom-sheet-text-input.ts +79 -0
  105. package/src/keyboard/use-keyboard-animation-guard.ts +59 -0
  106. package/src/keyboard/use-sheet-keyboard.ts +163 -0
  107. package/src/layout/AGENTS.md +52 -0
  108. package/src/layout/use-container-layout.ts +63 -0
  109. package/src/layout/use-measure-height.ts +40 -0
  110. package/src/lib/AGENTS.md +35 -0
  111. package/src/lib/compose-refs.ts +37 -0
  112. package/src/lib/merge-props.ts +42 -0
  113. package/src/lib/slot.tsx +44 -0
  114. package/src/lib/use-controllable-state.ts +45 -0
  115. package/src/no-classname.test.ts +27 -0
  116. package/src/portal/AGENTS.md +101 -0
  117. package/src/portal/bottom-sheet-host.tsx +67 -0
  118. package/src/portal/bottom-sheet-provider.tsx +33 -0
  119. package/src/portal/host.context.tsx +25 -0
  120. package/src/portal/sheet-registry.context.tsx +124 -0
  121. package/src/portal/sheet-registry.test.ts +87 -0
  122. package/src/portal/sheet-registry.ts +78 -0
  123. package/src/purity.test.ts +90 -0
  124. package/src/scrollable/AGENTS.md +123 -0
  125. package/src/scrollable/bottom-sheet-flat-list.tsx +15 -0
  126. package/src/scrollable/bottom-sheet-scroll-view.tsx +15 -0
  127. package/src/scrollable/bottom-sheet-section-list.tsx +22 -0
  128. package/src/scrollable/create-bottom-sheet-scrollable.tsx +248 -0
  129. package/src/scrollable/scrollable.types.ts +76 -0
  130. package/src/source-tree.test.ts +33 -0
  131. package/src/state/AGENTS.md +63 -0
  132. package/src/state/state.types.ts +134 -0
  133. package/src/state/use-detents.ts +43 -0
  134. package/src/state/use-sheet-geometry.ts +178 -0
  135. package/src/state/use-sheet-intents.ts +144 -0
  136. package/src/state/use-sheet-state.ts +157 -0
  137. package/src/steps/AGENTS.md +100 -0
  138. package/src/steps/bottom-sheet-step.tsx +61 -0
  139. package/src/steps/bottom-sheet-steps.tsx +215 -0
  140. package/src/steps/steps.context.tsx +73 -0
  141. package/src/steps/steps.types.ts +61 -0
  142. package/src/steps/use-sheet-machine.ts +70 -0
  143. package/src/steps/use-sheet-step.ts +29 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The vocabulary of the sheet state machine.
3
+ *
4
+ * A machine is a plain description — states, the events each handles, and
5
+ * where they lead — and a snapshot is a plain value. Nothing here holds state;
6
+ * `transition` takes a snapshot and returns the next one, which is what lets
7
+ * `useSheetMachine` be a `useReducer` and an XState user substitute their own
8
+ * controller.
9
+ */
10
+ import type { Result } from "../result";
11
+ import type { DetentSpec } from "../sheet.types";
12
+
13
+ /** The least an event can be. Discriminate on `type`; carry anything else. */
14
+ export type SheetEvent = { type: string };
15
+
16
+ /** Which way a step change reads, for the transition the body animates. */
17
+ export type SheetStepDirection = "forward" | "back";
18
+
19
+ /**
20
+ * A transition spelled out: where it goes, whether it may, and what it does to
21
+ * context on the way. `assign` returns a new context and never mutates the one
22
+ * it was given.
23
+ */
24
+ export type SheetTransitionObject<S extends string, C, E extends SheetEvent> = {
25
+ target: S;
26
+ guard?: (context: C, event: E) => boolean;
27
+ assign?: (context: C, event: E) => C;
28
+ };
29
+
30
+ /** A transition as written — a bare target, or the object above. */
31
+ export type SheetTransitionTarget<S extends string, C, E extends SheetEvent> = S | SheetTransitionObject<S, C, E>;
32
+
33
+ /**
34
+ * One step. `on` maps an event type to its transition, and the handler for a
35
+ * given `type` sees only that member of the event union. The sheet-facing
36
+ * fields override the root configuration while the step is current.
37
+ */
38
+ export type SheetStateNode<S extends string, C, E extends SheetEvent> = {
39
+ on?: { [T in E["type"]]?: SheetTransitionTarget<S, C, Extract<E, { type: T }>> };
40
+ snapPoints?: readonly DetentSpec[];
41
+ dismissible?: boolean;
42
+ direction?: SheetStepDirection;
43
+ };
44
+
45
+ /** Every step, keyed by name. Declaration order is step order. */
46
+ export type SheetStates<S extends string, C, E extends SheetEvent> = { [K in S]: SheetStateNode<S, C, E> };
47
+
48
+ export type SheetMachineConfig<S extends string, C, E extends SheetEvent> = {
49
+ id?: string;
50
+ initial: S;
51
+ context: C;
52
+ states: SheetStates<S, C, E>;
53
+ };
54
+
55
+ /** Where a machine is: the current step, its context and the steps that led here. */
56
+ export type SheetMachineSnapshot<S extends string, C> = {
57
+ value: S;
58
+ context: C;
59
+ history: readonly S[];
60
+ };
61
+
62
+ export type SheetTransitionError<S extends string> =
63
+ | { code: "no-transition"; from: S; event: string }
64
+ | { code: "guard-rejected"; from: S; to: S; event: string };
65
+
66
+ export type SheetTransitionResult<S extends string, C> = Result<SheetMachineSnapshot<S, C>, SheetTransitionError<S>>;
67
+
68
+ export type SheetMachine<S extends string, C, E extends SheetEvent> = {
69
+ config: SheetMachineConfig<S, C, E>;
70
+ initial: SheetMachineSnapshot<S, C>;
71
+ /** The next snapshot, or why there is none. Never throws, never mutates. */
72
+ transition: (snapshot: SheetMachineSnapshot<S, C>, event: E) => SheetTransitionResult<S, C>;
73
+ /** Whether `transition` would succeed — for disabling a Next button. */
74
+ can: (snapshot: SheetMachineSnapshot<S, C>, event: E) => boolean;
75
+ /** The steps in declaration order. */
76
+ steps: readonly S[];
77
+ /** The target's explicit `direction`, else declaration order. */
78
+ directionOf: (from: S, to: S) => SheetStepDirection;
79
+ nodeOf: (step: S) => SheetStateNode<S, C, E>;
80
+ };
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { stepFrame, stepOverride } from "./step-transition";
3
+
4
+ describe("stepFrame", () => {
5
+ test("crossfade fades the incoming in and the outgoing out", () => {
6
+ expect(stepFrame("crossfade", "incoming", "forward", 0.25, 390)).toEqual({ opacity: 0.25, translateX: 0 });
7
+ expect(stepFrame("crossfade", "outgoing", "forward", 0.25, 390)).toEqual({ opacity: 0.75, translateX: 0 });
8
+ });
9
+
10
+ test("slide moves the incoming in from the right and the outgoing out to the left when going forward", () => {
11
+ expect(stepFrame("slide", "incoming", "forward", 0, 390)).toEqual({ opacity: 1, translateX: 390 });
12
+ expect(stepFrame("slide", "incoming", "forward", 1, 390)).toEqual({ opacity: 1, translateX: 0 });
13
+ expect(stepFrame("slide", "outgoing", "forward", 0.5, 390)).toEqual({ opacity: 1, translateX: -195 });
14
+ });
15
+
16
+ test("slide mirrors when going back", () => {
17
+ expect(stepFrame("slide", "incoming", "back", 0, 390)).toEqual({ opacity: 1, translateX: -390 });
18
+ expect(stepFrame("slide", "outgoing", "back", 1, 390)).toEqual({ opacity: 1, translateX: 390 });
19
+ });
20
+
21
+ test("none shows the incoming and hides the outgoing at once", () => {
22
+ expect(stepFrame("none", "incoming", "forward", 0, 390)).toEqual({ opacity: 1, translateX: 0 });
23
+ expect(stepFrame("none", "outgoing", "forward", 0, 390)).toEqual({ opacity: 0, translateX: 0 });
24
+ });
25
+
26
+ test("an unmeasured width slides nothing rather than off to minus one", () => {
27
+ expect(stepFrame("slide", "incoming", "forward", 0, -1)).toEqual({ opacity: 1, translateX: 0 });
28
+ });
29
+ });
30
+
31
+ describe("stepOverride", () => {
32
+ test("a step with nothing declared overrides nothing", () => {
33
+ expect(stepOverride({})).toEqual({ snapPoints: undefined, dismissible: undefined });
34
+ });
35
+
36
+ test("a step's snapPoints and dismissible are surfaced as the override", () => {
37
+ expect(stepOverride({ snapPoints: ["35%"], dismissible: false })).toEqual({
38
+ snapPoints: ["35%"],
39
+ dismissible: false,
40
+ });
41
+ });
42
+
43
+ test("an explicit dismissible: true is kept, not collapsed to undefined", () => {
44
+ expect(stepOverride({ dismissible: true })).toEqual({ snapPoints: undefined, dismissible: true });
45
+ });
46
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * How one step gives way to the next, as a number per frame.
3
+ *
4
+ * `BottomSheet.Steps` drives a single `progress` from `0` to `1` when the
5
+ * machine changes step, and both the outgoing and the incoming step read their
6
+ * frame from it here. Kept in core so the three transitions are a table a
7
+ * test can read rather than three `useAnimatedStyle`s a device has to show.
8
+ */
9
+ import type { DetentSpec } from "../sheet.types";
10
+ import type { SheetEvent, SheetStateNode, SheetStepDirection } from "./machine.types";
11
+
12
+ /** The `transition` prop of `BottomSheet.Steps`. */
13
+ export type SheetStepTransition = "crossfade" | "slide" | "none";
14
+
15
+ /** Which side of a step change a view is on. */
16
+ export type SheetStepRole = "incoming" | "outgoing";
17
+
18
+ export type SheetStepFrame = { opacity: number; translateX: number };
19
+
20
+ /**
21
+ * The opacity and horizontal offset of a step at `progress` through a change.
22
+ *
23
+ * `crossfade` fades the two across each other; `slide` moves the incoming step
24
+ * in from the side the direction points away from and the outgoing one out the
25
+ * other way, a whole `width` each; `none` swaps them on the first frame. An
26
+ * unmeasured width (`< 0`) slides nothing.
27
+ */
28
+ export function stepFrame(
29
+ transition: SheetStepTransition,
30
+ role: SheetStepRole,
31
+ direction: SheetStepDirection,
32
+ progress: number,
33
+ width: number
34
+ ): SheetStepFrame {
35
+ "worklet";
36
+ if (transition === "crossfade") {
37
+ return { opacity: role === "incoming" ? progress : 1 - progress, translateX: 0 };
38
+ }
39
+ if (transition === "slide") {
40
+ const span = width > 0 ? width : 0;
41
+ const sign = direction === "forward" ? 1 : -1;
42
+ const translateX = role === "incoming" ? (1 - progress) * span * sign : -progress * span * sign;
43
+ return { opacity: 1, translateX };
44
+ }
45
+ return { opacity: role === "incoming" ? 1 : 0, translateX: 0 };
46
+ }
47
+
48
+ /** What a step asks the root to change while it is current. */
49
+ export type SheetStepOverride = {
50
+ snapPoints: readonly DetentSpec[] | undefined;
51
+ dismissible: boolean | undefined;
52
+ };
53
+
54
+ /** The override a step node declares — nothing more than its two sheet-facing fields, read once per step change. */
55
+ export function stepOverride<S extends string, C, E extends SheetEvent>(
56
+ node: SheetStateNode<S, C, E>
57
+ ): SheetStepOverride {
58
+ return { snapPoints: node.snapPoints, dismissible: node.dismissible };
59
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { SheetStates } from "./machine.types";
3
+ import { resolveTarget, transition } from "./transition";
4
+
5
+ type Event = { type: "GO" } | { type: "SET"; n: number };
6
+ type Context = { n: number };
7
+ const states: SheetStates<"one" | "two", Context, Event> = {
8
+ one: {
9
+ on: {
10
+ GO: "two",
11
+ SET: { target: "one", assign: (_context, event) => ({ n: event.n }) },
12
+ },
13
+ },
14
+ two: { on: { GO: { target: "one", guard: (context) => context.n > 0 } } },
15
+ };
16
+
17
+ describe("transition", () => {
18
+ test("resolveTarget widens a bare state name to a transition object", () => {
19
+ expect(resolveTarget("two")).toEqual({ target: "two" });
20
+ const object = { target: "two" as const, guard: () => true };
21
+ expect(resolveTarget(object)).toBe(object);
22
+ });
23
+
24
+ test("moves between states and records the origin", () => {
25
+ expect(transition(states, { value: "one", context: { n: 0 }, history: [] }, { type: "GO" })).toEqual({
26
+ success: true,
27
+ data: { value: "two", context: { n: 0 }, history: ["one"] },
28
+ });
29
+ });
30
+
31
+ test("applies assign with the narrowed event", () => {
32
+ expect(transition(states, { value: "one", context: { n: 0 }, history: [] }, { type: "SET", n: 4 })).toEqual({
33
+ success: true,
34
+ data: { value: "one", context: { n: 4 }, history: [] },
35
+ });
36
+ });
37
+
38
+ test("reports guard-rejected with both ends of the edge", () => {
39
+ expect(transition(states, { value: "two", context: { n: 0 }, history: ["one"] }, { type: "GO" })).toEqual({
40
+ success: false,
41
+ error: { code: "guard-rejected", from: "two", to: "one", event: "GO" },
42
+ });
43
+ });
44
+
45
+ test("reports no-transition for an unhandled event", () => {
46
+ expect(transition(states, { value: "two", context: { n: 1 }, history: [] }, { type: "SET", n: 1 })).toEqual({
47
+ success: false,
48
+ error: { code: "no-transition", from: "two", event: "SET" },
49
+ });
50
+ });
51
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The one step of a sheet machine: a snapshot and an event in, the next
3
+ * snapshot or a typed refusal out.
4
+ *
5
+ * A refusal is data, not an exception. A `NEXT` the guard rejects is the
6
+ * normal case for a form with an empty field, and the caller wants to know
7
+ * which edge said no rather than catch something.
8
+ */
9
+ import { err, ok } from "../result";
10
+ import type {
11
+ SheetEvent,
12
+ SheetMachineSnapshot,
13
+ SheetStates,
14
+ SheetTransitionObject,
15
+ SheetTransitionResult,
16
+ SheetTransitionTarget,
17
+ } from "./machine.types";
18
+
19
+ /** A bare state name widened to the object form; an object returned as is. */
20
+ export function resolveTarget<S extends string, C, E extends SheetEvent>(
21
+ target: SheetTransitionTarget<S, C, E>
22
+ ): SheetTransitionObject<S, C, E> {
23
+ return typeof target === "string" ? { target } : target;
24
+ }
25
+
26
+ /**
27
+ * The transition `states[value].on[event.type]`, seen with the whole event
28
+ * union. Per-type narrowing is the author's contract with `on`; at the moment
29
+ * of dispatch the event's `type` has already selected the handler that was
30
+ * written for it, so widening back to `E` here loses nothing.
31
+ */
32
+ function transitionFor<S extends string, C, E extends SheetEvent>(
33
+ states: SheetStates<S, C, E>,
34
+ value: S,
35
+ type: E["type"]
36
+ ): SheetTransitionTarget<S, C, E> | undefined {
37
+ const on = states[value].on as { [T in E["type"]]?: SheetTransitionTarget<S, C, E> } | undefined;
38
+ return on?.[type];
39
+ }
40
+
41
+ export function transition<S extends string, C, E extends SheetEvent>(
42
+ states: SheetStates<S, C, E>,
43
+ snapshot: SheetMachineSnapshot<S, C>,
44
+ event: E
45
+ ): SheetTransitionResult<S, C> {
46
+ const from = snapshot.value;
47
+ const found = transitionFor(states, from, event.type);
48
+ if (found === undefined) return err({ code: "no-transition", from, event: event.type });
49
+
50
+ const { target, guard, assign } = resolveTarget(found);
51
+ if (guard !== undefined && !guard(snapshot.context, event)) {
52
+ return err({ code: "guard-rejected", from, to: target, event: event.type });
53
+ }
54
+
55
+ const context = assign === undefined ? snapshot.context : assign(snapshot.context, event);
56
+ const history = target === from ? snapshot.history : [...snapshot.history, from];
57
+ return ok({ value: target, context, history });
58
+ }
@@ -0,0 +1,15 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { err, ok } from "./result";
3
+
4
+ describe("Result", () => {
5
+ test("ok wraps a value", () => {
6
+ expect(ok(3)).toEqual({ success: true, data: 3 });
7
+ });
8
+
9
+ test("err keeps a typed error rather than flattening it to a string", () => {
10
+ expect(err({ code: "invalid-snap-point" as const })).toEqual({
11
+ success: false,
12
+ error: { code: "invalid-snap-point" },
13
+ });
14
+ });
15
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The house `Result` — the shape `@delacour/types` defines, copied rather than
3
+ * imported.
4
+ *
5
+ * `@delacour/types` is a private workspace package and this one publishes. A
6
+ * `workspace:*` dependency on something npm has never seen would resolve for
7
+ * every workspace consumer and for nobody else; the twelve lines below are
8
+ * cheaper than making the types package public for a single union.
9
+ */
10
+ export type SuccessResult<Success> = { success: true; data: Success };
11
+ export type ErrorResult<Error = string> = { success: false; error: Error };
12
+ export type Result<Success, Error = string> = SuccessResult<Success> | ErrorResult<Error>;
13
+
14
+ export function ok<T>(data: T): SuccessResult<T> {
15
+ return { success: true, data };
16
+ }
17
+
18
+ export function err<E>(error: E): ErrorResult<E> {
19
+ return { success: false, error };
20
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SHEET_STATE } from "../sheet.types";
3
+ import { contentPanDrivesSheet, shouldLockScroll } from "./scroll-lock";
4
+
5
+ describe("shouldLockScroll", () => {
6
+ test("locked below the highest detent — a drag on the list moves the sheet, not the rows", () => {
7
+ expect(shouldLockScroll(SHEET_STATE.CLOSED, true)).toBe(true);
8
+ expect(shouldLockScroll(SHEET_STATE.OPENED, true)).toBe(true);
9
+ });
10
+
11
+ test("unlocked at the highest detent and beyond", () => {
12
+ expect(shouldLockScroll(SHEET_STATE.EXTENDED, true)).toBe(false);
13
+ expect(shouldLockScroll(SHEET_STATE.OVER_EXTENDED, true)).toBe(false);
14
+ expect(shouldLockScroll(SHEET_STATE.FILL, true)).toBe(false);
15
+ });
16
+
17
+ test("never locked when the content pan is off — the list is the only thing that can move", () => {
18
+ expect(shouldLockScroll(SHEET_STATE.OPENED, false)).toBe(false);
19
+ });
20
+ });
21
+
22
+ describe("contentPanDrivesSheet", () => {
23
+ test("while locked the pan always drives the sheet", () => {
24
+ expect(contentPanDrivesSheet({ locked: true, contentOffsetY: 120, translationY: -10 })).toBe(true);
25
+ });
26
+
27
+ test("unlocked and scrolled, the list owns the drag", () => {
28
+ expect(contentPanDrivesSheet({ locked: false, contentOffsetY: 120, translationY: 10 })).toBe(false);
29
+ expect(contentPanDrivesSheet({ locked: false, contentOffsetY: 120, translationY: -10 })).toBe(false);
30
+ });
31
+
32
+ test("unlocked at the top, a downward drag pulls the sheet and an upward one scrolls the list", () => {
33
+ expect(contentPanDrivesSheet({ locked: false, contentOffsetY: 0, translationY: 10 })).toBe(true);
34
+ expect(contentPanDrivesSheet({ locked: false, contentOffsetY: 0, translationY: -10 })).toBe(false);
35
+ });
36
+
37
+ test("a bounced-past-the-top offset counts as the top", () => {
38
+ expect(contentPanDrivesSheet({ locked: false, contentOffsetY: -30, translationY: 10 })).toBe(true);
39
+ });
40
+ });
@@ -0,0 +1,35 @@
1
+ import { SHEET_STATE, type SheetState } from "../sheet.types";
2
+
3
+ /**
4
+ * Whether a scrollable inside the sheet is pinned in place.
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
8
+ * and beyond it scrolls. With the content pan disabled there is nothing else
9
+ * the drag could do, so the list is never locked.
10
+ */
11
+ export function shouldLockScroll(state: SheetState, enableContentPan: boolean): boolean {
12
+ "worklet";
13
+ if (!enableContentPan) return false;
14
+ return state !== SHEET_STATE.EXTENDED && state !== SHEET_STATE.OVER_EXTENDED && state !== SHEET_STATE.FILL;
15
+ }
16
+
17
+ export type ContentPanInput = {
18
+ /** `shouldLockScroll` right now. */
19
+ locked: boolean;
20
+ /** The scrollable's offset; negative when bounced past the top. */
21
+ contentOffsetY: number;
22
+ /** The pan's translation this gesture; positive is downward. */
23
+ translationY: number;
24
+ };
25
+
26
+ /**
27
+ * Whether the content pan should move the sheet rather than let the list
28
+ * scroll: always while locked, and at the top of an unlocked list only when
29
+ * the finger is pulling down. Everything else is the list's.
30
+ */
31
+ export function contentPanDrivesSheet(input: ContentPanInput): boolean {
32
+ "worklet";
33
+ if (input.locked) return true;
34
+ return input.contentOffsetY <= 0 && input.translationY > 0;
35
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SCROLLABLE_TYPE } from "../sheet.types";
3
+ import { listDragHeight, listOwnsRelease, restingDetent, scrollLockTarget } from "./scroll-pan";
4
+
5
+ describe("listDragHeight", () => {
6
+ const highest = 600;
7
+
8
+ test("at the top with the list scrolled, the finger spends the offset before the sheet moves", () => {
9
+ const at = (translationY: number) =>
10
+ listDragHeight({ startBase: 600, translationY, startOffset: 204, held: false, highest });
11
+ expect(at(100)).toBe(600);
12
+ expect(at(204)).toBe(600);
13
+ expect(at(254)).toBe(550);
14
+ });
15
+
16
+ test("at the top with the list at its top, a pull-down moves the sheet at once", () => {
17
+ expect(listDragHeight({ startBase: 600, translationY: 30, startOffset: 0, held: false, highest })).toBe(570);
18
+ });
19
+
20
+ test("an upward drag at the top never over-drags: the list scrolls and bounces instead", () => {
21
+ expect(listDragHeight({ startBase: 600, translationY: -100, startOffset: 0, held: false, highest })).toBe(600);
22
+ expect(listDragHeight({ startBase: 600, translationY: -100, startOffset: 204, held: false, highest })).toBe(600);
23
+ });
24
+
25
+ test("a list held by the lock has no budget: the drag moves the sheet whatever it holds", () => {
26
+ expect(listDragHeight({ startBase: 300, translationY: -100, startOffset: 204, held: true, highest })).toBe(400);
27
+ expect(listDragHeight({ startBase: 300, translationY: 50, startOffset: 204, held: true, highest })).toBe(250);
28
+ });
29
+
30
+ test("once a held list's sheet reaches the top the budget applies, and the clamp absorbs it", () => {
31
+ expect(listDragHeight({ startBase: 300, translationY: -300, startOffset: 204, held: false, highest })).toBe(600);
32
+ expect(listDragHeight({ startBase: 300, translationY: -96, startOffset: 204, held: false, highest })).toBe(600);
33
+ expect(listDragHeight({ startBase: 300, translationY: -90, startOffset: 204, held: false, highest })).toBe(594);
34
+ });
35
+
36
+ test("a bounce past the top when the gesture began counts as no budget", () => {
37
+ expect(listDragHeight({ startBase: 600, translationY: 30, startOffset: -20, held: false, highest })).toBe(570);
38
+ });
39
+ });
40
+
41
+ describe("listOwnsRelease", () => {
42
+ const highest = 600;
43
+
44
+ test("a content pan released with the list scrolled and the sheet at the highest detent is the list's", () => {
45
+ expect(listOwnsRelease({ scrollable: true, offset: 120, base: 600, highest })).toBe(true);
46
+ expect(listOwnsRelease({ scrollable: true, offset: 120, base: 599.7, highest })).toBe(true);
47
+ });
48
+
49
+ test("at the top of the list the sheet takes it", () => {
50
+ expect(listOwnsRelease({ scrollable: true, offset: 0, base: 600, highest })).toBe(false);
51
+ expect(listOwnsRelease({ scrollable: true, offset: -12, base: 600, highest })).toBe(false);
52
+ });
53
+
54
+ test("below the highest detent the sheet takes it whatever the offset", () => {
55
+ expect(listOwnsRelease({ scrollable: true, offset: 120, base: 300, highest })).toBe(false);
56
+ });
57
+
58
+ test("static content never hands a release to a list", () => {
59
+ expect(listOwnsRelease({ scrollable: false, offset: 120, base: 600, highest })).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe("restingDetent", () => {
64
+ const detents = [200, 400, 600];
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);
70
+ });
71
+
72
+ test("a base between detents rests on none", () => {
73
+ expect(restingDetent(500, detents)).toBeNull();
74
+ expect(restingDetent(598, detents)).toBeNull();
75
+ });
76
+
77
+ test("with no detents nothing can be rested on", () => {
78
+ expect(restingDetent(0, [])).toBeNull();
79
+ });
80
+ });
81
+
82
+ describe("scrollLockTarget", () => {
83
+ test("a handle drag or a snap holds the list where it is, never above the top", () => {
84
+ expect(scrollLockTarget(140, false)).toBe(140);
85
+ expect(scrollLockTarget(0, false)).toBe(0);
86
+ expect(scrollLockTarget(-40, false)).toBe(0);
87
+ });
88
+
89
+ test("a content pan leaves the top only once the list is at its top, so it locks at zero", () => {
90
+ expect(scrollLockTarget(9, true)).toBe(0);
91
+ });
92
+ });
93
+
94
+ describe("SCROLLABLE_TYPE", () => {
95
+ test("static content is zero, so a fresh shared value means no scrollable", () => {
96
+ expect(SCROLLABLE_TYPE.NONE).toBe(0);
97
+ expect(new Set(Object.values(SCROLLABLE_TYPE)).size).toBe(4);
98
+ });
99
+ });
@@ -0,0 +1,96 @@
1
+ export type ListDragInput = {
2
+ /** `base` when the gesture began. */
3
+ startBase: number;
4
+ /** The pan's translation this gesture; positive is downward. */
5
+ translationY: number;
6
+ /** The list's offset when the gesture began. */
7
+ startOffset: number;
8
+ /**
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
11
+ * the top yet this gesture. A held list scrolls nothing, so it has no
12
+ * budget to spend.
13
+ */
14
+ held: boolean;
15
+ highest: number;
16
+ };
17
+
18
+ /**
19
+ * The height a content pan over a scrollable drags to.
20
+ *
21
+ * The pan and the native scroll are simultaneous, and the finger's travel is
22
+ * shared: what the list scrolls, the sheet must not also move by. Rather than
23
+ * subtract the list's live offset — which races the pan by a frame, so the
24
+ * sheet dips, the lock engages and the list freezes — the offset the list
25
+ * *began* with is a budget the finger has to spend downward before the sheet
26
+ * moves. The list scrolls one-for-one under the same finger, so it reaches its
27
+ * top exactly as the budget runs out, and the sheet takes over from there.
28
+ * Upward, the budget only grows the clamp's slack: a list scrolls up freely
29
+ * and bounces past the top of the sheet rather than over-dragging it, which is
30
+ * what the `highest` clamp does.
31
+ */
32
+ export function listDragHeight(input: ListDragInput): number {
33
+ "worklet";
34
+ const budget = input.held ? 0 : Math.max(0, input.startOffset);
35
+ return Math.min(input.startBase - input.translationY + budget, input.highest);
36
+ }
37
+
38
+ export type ListOwnsReleaseInput = {
39
+ /** Whether a scrollable is registered with the sheet at all. */
40
+ scrollable: boolean;
41
+ /** The list's offset at release. */
42
+ offset: number;
43
+ /** The keyboard-free height at release. */
44
+ base: number;
45
+ highest: number;
46
+ };
47
+
48
+ /** Settle tolerance: a sheet within this of a detent is on it. */
49
+ const AT_DETENT = 0.5;
50
+
51
+ /**
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
54
+ * was scrolling rows and the release velocity is the list's momentum, not a
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.
57
+ */
58
+ export function listOwnsRelease(input: ListOwnsReleaseInput): boolean {
59
+ "worklet";
60
+ if (!input.scrollable) return false;
61
+ if (input.offset <= 0) return false;
62
+ return input.base >= input.highest - AT_DETENT;
63
+ }
64
+
65
+ /**
66
+ * The detent a base is resting on — the one within the settle tolerance of
67
+ * it — or `null` when it sits between two.
68
+ *
69
+ * A release the list owns still has to settle the sheet's bookkeeping: the
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.
73
+ */
74
+ export function restingDetent(base: number, detents: readonly number[]): number | null {
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;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Where a list is held while the sheet is below its highest detent.
85
+ *
86
+ * Wherever it was when the lock engaged, never above the top — a handle drag
87
+ * or a `snapToIndex` on a scrolled list keeps its rows. A content pan is the
88
+ * exception: it only leaves the top once the finger has spent the list's
89
+ * whole offset, so the list *is* at the top, whatever the last scroll event
90
+ * to land before the lock reported.
91
+ */
92
+ export function scrollLockTarget(offset: number, byContentPan: boolean): number {
93
+ "worklet";
94
+ if (byContentPan) return 0;
95
+ return Math.max(0, offset);
96
+ }