@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,41 @@
1
+ /** Everything the dynamic detent is a sum of, in height space. */
2
+ export type DynamicDetentInput = {
3
+ /** Measured handle height; `UNMEASURED` counts as zero. */
4
+ handleHeight: number;
5
+ /** Measured content height; `UNMEASURED` counts as zero. */
6
+ contentHeight: number;
7
+ /** Measured footer content height; ignored when `hasFooter` is false. */
8
+ footerContentHeight: number;
9
+ /** The safe-area band under the sheet — zero for a detached sheet. */
10
+ band: number;
11
+ hasFooter: boolean;
12
+ /** The height the sheet may occupy. */
13
+ available: number;
14
+ /** An optional cap on the whole detent, below `available`. */
15
+ maxDynamicContentSize?: number;
16
+ };
17
+
18
+ /**
19
+ * The detent a sheet sized to its content settles at.
20
+ *
21
+ * Handle, content and — unlike the library this replaces — the footer's
22
+ * content are all in, and the safe-area band exactly once: it lives inside the
23
+ * footer when there is one and under the content when there is not. Capped by
24
+ * `maxDynamicContentSize` and always by `available`.
25
+ */
26
+ export function dynamicDetent(input: DynamicDetentInput): number {
27
+ "worklet";
28
+ const handle = input.handleHeight > 0 ? input.handleHeight : 0;
29
+ const content = input.contentHeight > 0 ? input.contentHeight : 0;
30
+ const footer = input.hasFooter && input.footerContentHeight > 0 ? input.footerContentHeight : 0;
31
+ const band = input.band > 0 ? input.band : 0;
32
+ const available = input.available > 0 ? input.available : 0;
33
+
34
+ let cap = available;
35
+ if (input.maxDynamicContentSize !== undefined && input.maxDynamicContentSize < cap) {
36
+ cap = input.maxDynamicContentSize > 0 ? input.maxDynamicContentSize : 0;
37
+ }
38
+
39
+ const sum = handle + content + footer + band;
40
+ return sum < cap ? sum : cap;
41
+ }
@@ -0,0 +1,65 @@
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
+ });
@@ -0,0 +1,44 @@
1
+ import { CLOSED_INDEX } from "../sheet.types";
2
+
3
+ /**
4
+ * The index a height corresponds to: `-1` closed, `0` the lowest detent,
5
+ * fractional in between, clamped at both ends.
6
+ *
7
+ * Piecewise-linear over `[closedHeight, ...detents]` → `[-1, 0, 1, …]`. It is
8
+ * fed `base`, never `height`, so a keyboard lift moves the sheet without
9
+ * moving the index or the backdrop that follows it.
10
+ */
11
+ export function indexForHeight(height: number, detents: readonly number[], closedHeight: number): number {
12
+ "worklet";
13
+ const count = detents.length;
14
+ if (count === 0 || height <= closedHeight) return CLOSED_INDEX;
15
+
16
+ let lower = closedHeight;
17
+ for (let index = 0; index < count; index += 1) {
18
+ const upper = detents[index] as number;
19
+ if (height <= upper) {
20
+ const span = upper - lower;
21
+ if (span <= 0) return index;
22
+ return index - 1 + (height - lower) / span;
23
+ }
24
+ lower = upper;
25
+ }
26
+ return count - 1;
27
+ }
28
+
29
+ /**
30
+ * The inverse: the height at an index, `-1` being the closed height. Integers
31
+ * land on detents, fractions interpolate, and an out-of-range index clamps.
32
+ */
33
+ export function heightForIndex(index: number, detents: readonly number[], closedHeight: number): number {
34
+ "worklet";
35
+ const count = detents.length;
36
+ if (count === 0 || index <= CLOSED_INDEX) return closedHeight;
37
+ if (index >= count - 1) return detents[count - 1] as number;
38
+
39
+ const upperIndex = Math.ceil(index);
40
+ const lower = upperIndex === 0 ? closedHeight : (detents[upperIndex - 1] as number);
41
+ const upper = detents[upperIndex] as number;
42
+ const fraction = index - (upperIndex - 1);
43
+ return lower + (upper - lower) * fraction;
44
+ }
@@ -0,0 +1,72 @@
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
+ });
@@ -0,0 +1,63 @@
1
+ import { err, ok, type Result } from "../result";
2
+ import type { DetentSpec } from "../sheet.types";
3
+
4
+ /** A detent with its unit made explicit. */
5
+ export type ParsedDetent = { kind: "px"; value: number } | { kind: "percent"; value: number };
6
+
7
+ /** Why a detent could not be read. `value` is what the consumer wrote. */
8
+ export type DetentError = { code: "invalid-snap-point"; value: unknown };
9
+
10
+ /**
11
+ * Reads one detent as the consumer wrote it — `300` or `"50%"` — refusing
12
+ * `NaN`, infinities, negatives and any string that is not a percentage.
13
+ *
14
+ * This is the JS-thread validator, for `snapToPosition` and for surfacing a
15
+ * bad `snapPoints` prop by name. The worklet below re-implements the same
16
+ * parse inline, because a module-scope worklet may not call it.
17
+ */
18
+ export function parseDetent(spec: DetentSpec): Result<ParsedDetent, DetentError> {
19
+ if (typeof spec === "number") {
20
+ if (!Number.isFinite(spec) || spec < 0) return err({ code: "invalid-snap-point", value: spec });
21
+ return ok({ kind: "px", value: spec });
22
+ }
23
+ if (typeof spec === "string") {
24
+ const match = /^(\d+(?:\.\d+)?)%$/.exec(spec);
25
+ if (match === null) return err({ code: "invalid-snap-point", value: spec });
26
+ return ok({ kind: "percent", value: Number(match[1]) });
27
+ }
28
+ return err({ code: "invalid-snap-point", value: spec });
29
+ }
30
+
31
+ /**
32
+ * Resolves a `snapPoints` spec into ascending, unique heights, each clamped
33
+ * to `[0, available]`.
34
+ *
35
+ * `available` is the height the sheet may occupy — the container height less
36
+ * the resting bottom for a detached sheet. While it is unmeasured (or zero)
37
+ * there is nothing to snap to, and the result is empty rather than a list of
38
+ * zeros. An invalid entry is skipped, never written into the list: one `NaN`
39
+ * in `detents` freezes a sheet.
40
+ */
41
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the flat-worklet rule forbids extracting helpers; see the doc comment above.
42
+ export function normalizeDetents(spec: readonly DetentSpec[], available: number): number[] {
43
+ "worklet";
44
+ if (!(available > 0)) return [];
45
+
46
+ const heights: number[] = [];
47
+ for (let index = 0; index < spec.length; index += 1) {
48
+ const entry = spec[index];
49
+ let height = Number.NaN;
50
+ if (typeof entry === "number") {
51
+ height = entry;
52
+ } else if (typeof entry === "string" && entry.length > 1 && entry.charCodeAt(entry.length - 1) === 37) {
53
+ height = (Number(entry.slice(0, -1)) / 100) * available;
54
+ }
55
+ if (!Number.isFinite(height)) continue;
56
+
57
+ const clamped = height < 0 ? 0 : height > available ? available : height;
58
+ if (heights.indexOf(clamped) === -1) heights.push(clamped);
59
+ }
60
+
61
+ heights.sort((a, b) => a - b);
62
+ return heights;
63
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resistOverDrag } from "./over-drag";
3
+
4
+ describe("resistOverDrag", () => {
5
+ test("inside the range the height passes through untouched", () => {
6
+ expect(resistOverDrag(300, 0, 800, 2.5)).toBe(300);
7
+ expect(resistOverDrag(0, 0, 800, 2.5)).toBe(0);
8
+ expect(resistOverDrag(800, 0, 800, 2.5)).toBe(800);
9
+ });
10
+
11
+ test("is continuous at the boundary", () => {
12
+ expect(resistOverDrag(800.001, 0, 800, 2.5)).toBeCloseTo(800, 2);
13
+ expect(resistOverDrag(-0.001, 0, 800, 2.5)).toBeCloseTo(0, 2);
14
+ });
15
+
16
+ test("beyond the top it is monotone and sub-linear", () => {
17
+ let previous = 800;
18
+ let previousGain = Number.POSITIVE_INFINITY;
19
+ for (let over = 10; over <= 400; over += 10) {
20
+ const value = resistOverDrag(800 + over, 0, 800, 2.5);
21
+ expect(value).toBeGreaterThan(previous);
22
+ expect(value - 800).toBeLessThan(over);
23
+ const gain = value - previous;
24
+ expect(gain).toBeLessThanOrEqual(previousGain);
25
+ previous = value;
26
+ previousGain = gain;
27
+ }
28
+ });
29
+
30
+ test("beyond the bottom it resists symmetrically", () => {
31
+ expect(resistOverDrag(-100, 0, 800, 2.5)).toBeCloseTo(-(resistOverDrag(900, 0, 800, 2.5) - 800));
32
+ });
33
+
34
+ test("a factor of zero is a hard clamp", () => {
35
+ expect(resistOverDrag(900, 0, 800, 0)).toBe(800);
36
+ expect(resistOverDrag(-100, 0, 800, 0)).toBe(0);
37
+ });
38
+
39
+ test("a larger factor gives more travel for the same over-drag", () => {
40
+ expect(resistOverDrag(900, 0, 800, 4)).toBeGreaterThan(resistOverDrag(900, 0, 800, 2));
41
+ });
42
+
43
+ test("an inverted range collapses to its minimum rather than oscillating", () => {
44
+ expect(resistOverDrag(500, 800, 200, 2.5)).toBe(800);
45
+ });
46
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A dragged height pulled back toward `[min, max]` with rubber-band
3
+ * resistance once it leaves the range.
4
+ *
5
+ * Beyond either end the travel is `(√(1 + 2·over / factor) − 1) · factor`:
6
+ * slope 1 at the boundary so the sheet never jumps or races the finger,
7
+ * then square-root growth — monotone and sub-linear, so the finger is always
8
+ * ahead. A `factor` of `0` is a hard clamp, and a larger factor allows more
9
+ * travel. An inverted range collapses to `min`.
10
+ */
11
+ export function resistOverDrag(height: number, min: number, max: number, factor: number): number {
12
+ "worklet";
13
+ if (max < min) return min;
14
+ if (height >= min && height <= max) return height;
15
+ if (!(factor > 0)) return height < min ? min : max;
16
+
17
+ if (height > max) {
18
+ const over = height - max;
19
+ return max + (Math.sqrt(1 + (2 * over) / factor) - 1) * factor;
20
+ }
21
+ const under = min - height;
22
+ return min - (Math.sqrt(1 + (2 * under) / factor) - 1) * factor;
23
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { selectSnapHeight } from "./select-snap-height";
3
+
4
+ const detents = [200, 400, 800];
5
+ const at = (height: number, velocity = 0, closedHeight: number | null = 0) =>
6
+ selectSnapHeight({ height, velocity, detents, closedHeight, projection: 0.2 });
7
+
8
+ describe("selectSnapHeight", () => {
9
+ test("at rest, the nearest detent wins", () => {
10
+ expect(at(250)).toBe(200);
11
+ expect(at(350)).toBe(400);
12
+ expect(at(700)).toBe(800);
13
+ });
14
+
15
+ test("velocity projects the release point — a flick upward reaches the next detent", () => {
16
+ expect(at(250, 1000)).toBe(400);
17
+ });
18
+
19
+ test("a flick downward from just above a detent falls through it", () => {
20
+ expect(at(420, -1500)).toBe(200);
21
+ });
22
+
23
+ test("closes only when a closed height is offered", () => {
24
+ expect(at(50)).toBe(0);
25
+ expect(at(50, 0, null)).toBe(200);
26
+ });
27
+
28
+ test("a detached closed height is a real candidate, resting line included", () => {
29
+ expect(at(-20, 0, -32)).toBe(-32);
30
+ });
31
+
32
+ test("a hard downward flick closes when it can", () => {
33
+ expect(at(700, -4000)).toBe(0);
34
+ expect(at(700, -4000, null)).toBe(200);
35
+ });
36
+
37
+ test("a projection past the highest detent still lands on it", () => {
38
+ expect(at(700, 5000)).toBe(800);
39
+ });
40
+
41
+ test("exactly between two detents, the lower one wins", () => {
42
+ expect(at(300)).toBe(200);
43
+ });
44
+
45
+ test("with nothing to snap to, the height is left where it is", () => {
46
+ expect(selectSnapHeight({ height: 123, velocity: 0, detents: [], closedHeight: null, projection: 0.2 })).toBe(123);
47
+ });
48
+
49
+ test("a NaN velocity is treated as rest rather than poisoning the target", () => {
50
+ expect(at(350, Number.NaN)).toBe(400);
51
+ });
52
+ });
@@ -0,0 +1,44 @@
1
+ export type SelectSnapHeightInput = {
2
+ /** Where the finger let go, in height space. */
3
+ height: number;
4
+ /** Release velocity in height space — upward positive, so pass `-velocityY`. */
5
+ velocity: number;
6
+ /** Ascending detents. */
7
+ detents: readonly number[];
8
+ /** The closed height when closing is allowed from this gesture, else `null`. */
9
+ closedHeight: number | null;
10
+ /** Seconds of velocity to project — the `0.2` the previous engine used. */
11
+ projection: number;
12
+ };
13
+
14
+ /**
15
+ * The detent a released drag settles at: the candidate nearest to where the
16
+ * finger was heading, `height + projection · velocity`.
17
+ *
18
+ * Closing is a candidate only when `closedHeight` is given; the pan passes
19
+ * `null` unless `enablePanDownToClose`. On a tie the lower candidate wins.
20
+ */
21
+ export function selectSnapHeight(input: SelectSnapHeightInput): number {
22
+ "worklet";
23
+ const velocity = Number.isFinite(input.velocity) ? input.velocity : 0;
24
+ const projected = input.height + input.projection * velocity;
25
+
26
+ let best = input.height;
27
+ let bestDistance = Number.POSITIVE_INFINITY;
28
+
29
+ if (input.closedHeight !== null) {
30
+ best = input.closedHeight;
31
+ bestDistance = Math.abs(projected - input.closedHeight);
32
+ }
33
+
34
+ for (let index = 0; index < input.detents.length; index += 1) {
35
+ const candidate = input.detents[index] as number;
36
+ const distance = Math.abs(projected - candidate);
37
+ if (distance < bestDistance) {
38
+ best = candidate;
39
+ bestDistance = distance;
40
+ }
41
+ }
42
+
43
+ return best;
44
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SHEET_STATE } from "../sheet.types";
3
+ import { sheetState } from "./sheet-state";
4
+
5
+ const 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
+ });
@@ -0,0 +1,32 @@
1
+ import { SHEET_STATE, type SheetState } from "../sheet.types";
2
+
3
+ /** Settle tolerance: a spring at rest is within this of its target. */
4
+ const SETTLE_EPSILON = 0.5;
5
+
6
+ /**
7
+ * Where the sheet is relative to its detents.
8
+ *
9
+ * `base` decides closed and extended — the keyboard-free height is what the
10
+ * gesture and the scroll lock reason about. `height` decides fill and
11
+ * over-extended, because a keyboard lift or an over-drag pushes the visible
12
+ * sheet past the highest detent without moving `base`. With no detents (a
13
+ * dynamic sheet whose content is the only detent, before it measures) any
14
+ * open height is extended, so the list under it is never locked.
15
+ */
16
+ export function sheetState(
17
+ base: number,
18
+ height: number,
19
+ detents: readonly number[],
20
+ closedHeight: number,
21
+ maxHeight: number
22
+ ): SheetState {
23
+ "worklet";
24
+ if (base <= closedHeight + SETTLE_EPSILON) return SHEET_STATE.CLOSED;
25
+ if (height >= maxHeight - SETTLE_EPSILON) return SHEET_STATE.FILL;
26
+
27
+ const count = detents.length;
28
+ const highest = count > 0 ? (detents[count - 1] as number) : base;
29
+ if (height > highest + SETTLE_EPSILON) return SHEET_STATE.OVER_EXTENDED;
30
+ if (base >= highest - SETTLE_EPSILON) return SHEET_STATE.EXTENDED;
31
+ return SHEET_STATE.OPENED;
32
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { bodyClip, bodyInset, scrollContentHeight } from "./body-inset";
3
+
4
+ describe("bodyInset", () => {
5
+ test("with a sticky footer the body reserves the footer, band included", () => {
6
+ expect(bodyInset(true, 90, 34)).toBe(90);
7
+ });
8
+
9
+ test("without one it reserves the band alone", () => {
10
+ expect(bodyInset(false, 0, 34)).toBe(34);
11
+ });
12
+
13
+ test("the band is gone under the keyboard, and so is the reservation", () => {
14
+ expect(bodyInset(false, 0, 0)).toBe(0);
15
+ });
16
+
17
+ test("never negative — an unmeasured footer reserves nothing", () => {
18
+ expect(bodyInset(true, -1, 34)).toBe(0);
19
+ expect(bodyInset(false, 0, -1)).toBe(0);
20
+ });
21
+ });
22
+
23
+ describe("bodyClip", () => {
24
+ const at = (sheetHeight: number) =>
25
+ bodyClip({ contentArea: 486, inset: 90, hasFooter: true, sheetHeight, handleHeight: 24, keyboardHeight: 0 });
26
+
27
+ test("at rest on its detent the body is clipped exactly at the footer's top edge", () => {
28
+ // contentArea 486 = 600 − 24 − 90; the body is laid out 90 deeper, to the
29
+ // sheet's bottom line, and the clip hides that 90 behind the footer.
30
+ expect(at(600)).toBe(486);
31
+ });
32
+
33
+ test("dragged below its detent the clip follows the live height, so nothing shows under the footer", () => {
34
+ expect(at(500)).toBe(386);
35
+ expect(at(200)).toBe(86);
36
+ });
37
+
38
+ test("above its detent — an over-drag — the clip stays at the laid-out body", () => {
39
+ expect(at(700)).toBe(486 + 90);
40
+ });
41
+
42
+ test("nearly closed, the footer sits at the top of the panel and the body is fully hidden", () => {
43
+ expect(at(100)).toBe(0);
44
+ expect(at(0)).toBe(0);
45
+ });
46
+
47
+ test("under the keyboard the footer's top edge is the keyboard's less the footer, and the clip follows", () => {
48
+ expect(
49
+ bodyClip({
50
+ contentArea: 150,
51
+ inset: 90,
52
+ hasFooter: true,
53
+ sheetHeight: 600,
54
+ handleHeight: 24,
55
+ keyboardHeight: 336,
56
+ })
57
+ ).toBe(150);
58
+ expect(
59
+ bodyClip({
60
+ contentArea: 150,
61
+ inset: 90,
62
+ hasFooter: true,
63
+ sheetHeight: 550,
64
+ handleHeight: 24,
65
+ keyboardHeight: 336,
66
+ })
67
+ ).toBe(100);
68
+ });
69
+
70
+ test("without a footer the body is never clipped short of its layout: it slides with the sheet", () => {
71
+ expect(
72
+ bodyClip({ contentArea: 542, inset: 34, hasFooter: false, sheetHeight: 300, handleHeight: 24, keyboardHeight: 0 })
73
+ ).toBe(576);
74
+ });
75
+
76
+ test("unmeasured parts count as zero", () => {
77
+ expect(
78
+ bodyClip({ contentArea: -1, inset: -1, hasFooter: true, sheetHeight: 600, handleHeight: -1, keyboardHeight: 0 })
79
+ ).toBe(0);
80
+ });
81
+ });
82
+
83
+ describe("scrollContentHeight", () => {
84
+ test("the rows are the content size less the trailing spacer", () => {
85
+ expect(scrollContentHeight(1234, 90)).toBe(1144);
86
+ });
87
+
88
+ test("an unmeasured spacer subtracts nothing", () => {
89
+ expect(scrollContentHeight(1234, -1)).toBe(1234);
90
+ });
91
+
92
+ test("never negative", () => {
93
+ expect(scrollContentHeight(30, 90)).toBe(0);
94
+ });
95
+ });