@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,65 @@
1
+ /**
2
+ * What trails the body, in pixels: the sticky footer — content and band — when
3
+ * there is one, and the safe-area band alone when there is not. Both are the
4
+ * current values, so the reservation collapses with the band as the keyboard
5
+ * rises. Never negative; an unmeasured footer reserves nothing yet.
6
+ *
7
+ * A body is laid out to the sheet's bottom line and reserves this much at its
8
+ * end — a spacer inside a static `Content`, a trailing spacer inside a
9
+ * scrollable's content — so a list's rows run under the footer and the band
10
+ * and its last row can still be scrolled fully clear of them.
11
+ */
12
+ export function bodyInset(hasFooter: boolean, footerHeight: number, band: number): number {
13
+ "worklet";
14
+ const value = hasFooter ? footerHeight : band;
15
+ return value > 0 ? value : 0;
16
+ }
17
+
18
+ export type BodyClipInput = {
19
+ /** The geometry's `contentArea` — the body sized against its detent. */
20
+ contentArea: number;
21
+ /** `bodyInset` — what the body reserves at its end. */
22
+ inset: number;
23
+ hasFooter: boolean;
24
+ /** The sheet's live height — `base + keyboardLift`, clamped. */
25
+ sheetHeight: number;
26
+ handleHeight: number;
27
+ /** The keyboard the sheet owns, positive, within the container. */
28
+ keyboardHeight: number;
29
+ };
30
+
31
+ /**
32
+ * How tall the body's clipping box is right now.
33
+ *
34
+ * The body is laid out `contentArea + inset` tall — to the sheet's bottom line
35
+ * — and without a footer that is the clip too: the sheet slides as one body
36
+ * and nothing needs hiding. Under a sticky footer the clip also follows the
37
+ * footer's top edge, `sheetHeight − keyboard − handle − inset`, which is where
38
+ * `footerTop` puts it. On the detent the two agree and the clip is exactly
39
+ * `contentArea`; below it — a drag to close, the close animation — the footer
40
+ * stays on the screen's bottom edge while the panel slides down under it, and
41
+ * the clip shrinks in step so no line of the body is ever drawn below the
42
+ * footer's top, whatever the footer's background. Never negative.
43
+ */
44
+ export function bodyClip(input: BodyClipInput): number {
45
+ "worklet";
46
+ const inset = input.inset > 0 ? input.inset : 0;
47
+ const area = (input.contentArea > 0 ? input.contentArea : 0) + inset;
48
+ if (!input.hasFooter) return area;
49
+ const handle = input.handleHeight > 0 ? input.handleHeight : 0;
50
+ const keyboard = input.keyboardHeight > 0 ? input.keyboardHeight : 0;
51
+ const visible = input.sheetHeight - keyboard - handle - inset;
52
+ const clip = visible < area ? visible : area;
53
+ return clip > 0 ? clip : 0;
54
+ }
55
+
56
+ /**
57
+ * The rows' own height: a scrollable's reported content size less the
58
+ * trailing spacer it renders inside that content. This is what the dynamic
59
+ * detent counts — the spacer is the footer and the band, which the detent
60
+ * already adds once. An unmeasured spacer subtracts nothing; never negative.
61
+ */
62
+ export function scrollContentHeight(contentSize: number, spacer: number): number {
63
+ const value = contentSize - (spacer > 0 ? spacer : 0);
64
+ return value > 0 ? value : 0;
65
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { bandNow, bottomBand, footerHeight } from "./bottom-band";
3
+
4
+ describe("bottomBand", () => {
5
+ test("an attached sheet reserves the safe-area inset", () => {
6
+ expect(bottomBand(false, 34)).toBe(34);
7
+ });
8
+
9
+ test("a detached sheet floats above the inset, so it has no band", () => {
10
+ expect(bottomBand(true, 34)).toBe(0);
11
+ });
12
+
13
+ test("a negative inset is treated as none", () => {
14
+ expect(bottomBand(false, -1)).toBe(0);
15
+ });
16
+ });
17
+
18
+ describe("bandNow", () => {
19
+ test("the band is whole with the keyboard down and gone with it up", () => {
20
+ expect(bandNow(34, 0)).toBe(34);
21
+ expect(bandNow(34, 1)).toBe(0);
22
+ });
23
+
24
+ test("collapses linearly with progress", () => {
25
+ expect(bandNow(34, 0.5)).toBe(17);
26
+ });
27
+
28
+ test("clamps a progress outside [0, 1]", () => {
29
+ expect(bandNow(34, -0.2)).toBe(34);
30
+ expect(bandNow(34, 1.3)).toBe(0);
31
+ });
32
+ });
33
+
34
+ describe("footerHeight", () => {
35
+ test("a footer is its content plus the band under it right now", () => {
36
+ expect(footerHeight(true, 56, 34)).toBe(90);
37
+ });
38
+
39
+ test("no footer, no height, whatever was measured", () => {
40
+ expect(footerHeight(false, 56, 34)).toBe(0);
41
+ });
42
+
43
+ test("an unmeasured footer contributes only its band", () => {
44
+ expect(footerHeight(true, -1, 34)).toBe(34);
45
+ });
46
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The safe-area band an attached sheet reserves under its content or footer.
3
+ * A detached sheet already floats above the inset and reserves nothing.
4
+ */
5
+ export function bottomBand(detached: boolean, bottomInset: number): number {
6
+ "worklet";
7
+ if (detached) return 0;
8
+ return bottomInset > 0 ? bottomInset : 0;
9
+ }
10
+
11
+ /**
12
+ * The band as it stands mid-keyboard: whole at `progress` 0, gone at 1. The
13
+ * keyboard covers the safe area, so the pixels reserved for it are the
14
+ * keyboard's now — and `keyboardLift` subtracts the same amount, which is
15
+ * what keeps the footer still.
16
+ */
17
+ export function bandNow(band: number, progress: number): number {
18
+ "worklet";
19
+ const p = progress < 0 ? 0 : progress > 1 ? 1 : progress;
20
+ const value = (band > 0 ? band : 0) * (1 - p);
21
+ return value;
22
+ }
23
+
24
+ /** The sticky footer's full height right now — content plus the band under it, or nothing without a footer. */
25
+ export function footerHeight(hasFooter: boolean, footerContentHeight: number, band: number): number {
26
+ "worklet";
27
+ if (!hasFooter) return 0;
28
+ const content = footerContentHeight > 0 ? footerContentHeight : 0;
29
+ return content + (band > 0 ? band : 0);
30
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { keyboardLift } from "../keyboard/keyboard-lift";
3
+ import { bandNow } from "./bottom-band";
4
+ import { footerTop } from "./footer-top";
5
+
6
+ describe("footerTop", () => {
7
+ test("with no keyboard the footer sits above its band at the bottom of the sheet", () => {
8
+ expect(footerTop({ sheetHeight: 500, keyboardHeight: 0, footerContentHeight: 56, band: 34 })).toBe(410);
9
+ });
10
+
11
+ test("a keyboard pushes the footer up by its full height", () => {
12
+ expect(footerTop({ sheetHeight: 500, keyboardHeight: 300, footerContentHeight: 56, band: 0 })).toBe(144);
13
+ });
14
+
15
+ test("never negative — a footer taller than the sheet pins to the top", () => {
16
+ expect(footerTop({ sheetHeight: 40, keyboardHeight: 0, footerContentHeight: 56, band: 34 })).toBe(0);
17
+ });
18
+
19
+ test("an unmeasured footer subtracts nothing", () => {
20
+ expect(footerTop({ sheetHeight: 500, keyboardHeight: 0, footerContentHeight: -1, band: 34 })).toBe(466);
21
+ });
22
+
23
+ /**
24
+ * The proof the whole footer/keyboard/inset model rests on.
25
+ *
26
+ * The sheet's height is `base + keyboardLift`, the lift is
27
+ * `kb − band · p`, and the band under the footer is `band · (1 − p)`. Then
28
+ * `footerTop = base + kb − band·p − kb − footer − band + band·p`
29
+ * = `base − footer − band`,
30
+ * independent of `p`: the footer does not move a pixel while the keyboard
31
+ * animates, only the safe-area band under it collapses, and at `p = 1` the
32
+ * footer's bottom edge sits on the keyboard's top edge.
33
+ */
34
+ test("holds still for the whole keyboard animation", () => {
35
+ const base = 24 + 300 + 56 + 34;
36
+ const band = 34;
37
+ const footerContentHeight = 56;
38
+ const fullKeyboard = 336;
39
+ const expected = base - footerContentHeight - band;
40
+
41
+ for (let step = 0; step <= 20; step += 1) {
42
+ const progress = step / 20;
43
+ const keyboardHeight = fullKeyboard * progress;
44
+ const lift = keyboardLift({ keyboardHeight, progress, band, behavior: "interactive", gapBelow: 0 });
45
+ const sheetHeight = base + lift;
46
+ const top = footerTop({ sheetHeight, keyboardHeight, footerContentHeight, band: bandNow(band, progress) });
47
+ expect(top).toBeCloseTo(expected, 6);
48
+ }
49
+ });
50
+
51
+ test("at full keyboard the footer's bottom edge sits on the keyboard's top edge", () => {
52
+ const base = 414;
53
+ const band = 34;
54
+ const footerContentHeight = 56;
55
+ const keyboardHeight = 336;
56
+ const lift = keyboardLift({ keyboardHeight, progress: 1, band, behavior: "interactive", gapBelow: 0 });
57
+ const sheetHeight = base + lift;
58
+ const top = footerTop({ sheetHeight, keyboardHeight, footerContentHeight, band: bandNow(band, 1) });
59
+ expect(top + footerContentHeight).toBe(sheetHeight - keyboardHeight);
60
+ });
61
+ });
@@ -0,0 +1,27 @@
1
+ export type FooterTopInput = {
2
+ /** The sheet's visible height — `base + keyboardLift`. */
3
+ sheetHeight: number;
4
+ /** The keyboard the sheet owns, positive, within the container. */
5
+ keyboardHeight: number;
6
+ /** The footer's measured content, band excluded; `UNMEASURED` counts as zero. */
7
+ footerContentHeight: number;
8
+ /** The band under the footer right now — `bandNow(band, progress)`. */
9
+ band: number;
10
+ };
11
+
12
+ /**
13
+ * The sticky footer's `translateY` inside the body: the sheet's height less
14
+ * the keyboard, the footer's content and the band under it, never negative.
15
+ *
16
+ * Fed the current band rather than the resting one, this is constant across a
17
+ * keyboard animation — `footer-top.test.ts` sweeps the proof. The footer is
18
+ * the one thing that does not move when the keyboard does.
19
+ */
20
+ export function footerTop(input: FooterTopInput): number {
21
+ "worklet";
22
+ const keyboard = input.keyboardHeight > 0 ? input.keyboardHeight : 0;
23
+ const footer = input.footerContentHeight > 0 ? input.footerContentHeight : 0;
24
+ const band = input.band > 0 ? input.band : 0;
25
+ const top = input.sheetHeight - keyboard - footer - band;
26
+ return top > 0 ? top : 0;
27
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveSheetBottomInset, resolveSheetScrollEndPadding } from "./sheet-insets";
3
+
4
+ describe("resolveSheetBottomInset", () => {
5
+ test("without a sticky footer the content's trailing spacer holds the safe-area band", () => {
6
+ expect(resolveSheetBottomInset({ bottom: 34, hasStickyFooter: false })).toBe(34);
7
+ });
8
+
9
+ test("with a sticky footer the footer holds the band, so the content reserves none", () => {
10
+ expect(resolveSheetBottomInset({ bottom: 34, hasStickyFooter: true })).toBe(0);
11
+ });
12
+
13
+ test("no inset, no band either way", () => {
14
+ expect(resolveSheetBottomInset({ bottom: 0, hasStickyFooter: false })).toBe(0);
15
+ });
16
+ });
17
+
18
+ describe("resolveSheetScrollEndPadding", () => {
19
+ test("without a sticky footer a skin adds nothing — the engine's spacer already ends the content at the band", () => {
20
+ expect(resolveSheetScrollEndPadding({ bottom: 34, hasStickyFooter: false, footerGap: 16 })).toBe(0);
21
+ });
22
+
23
+ test("with a sticky footer the scroll content ends a gap above the footer's hairline", () => {
24
+ expect(resolveSheetScrollEndPadding({ bottom: 34, hasStickyFooter: true, footerGap: 16 })).toBe(16);
25
+ expect(resolveSheetScrollEndPadding({ bottom: 0, hasStickyFooter: true, footerGap: 16 })).toBe(16);
26
+ });
27
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The safe-area band the engine reserves under a sheet's static content.
3
+ *
4
+ * The band belongs to whatever is bottom-most in the sheet: with a sticky
5
+ * footer that is the footer, whose styled box pads through the band, so the
6
+ * content reserves none of it. With no footer the content is bottom-most and
7
+ * `Content`'s trailing spacer holds it. This is what the engine reserves, not
8
+ * something a skin adds — a skin that padded the band as well would count it
9
+ * twice and leave a gap the height of the home indicator.
10
+ *
11
+ * Lives in the engine so the skin can re-export it without mounting a sheet.
12
+ */
13
+ export function resolveSheetBottomInset(input: { hasStickyFooter: boolean; bottom: number }): number {
14
+ return input.hasStickyFooter ? 0 : input.bottom;
15
+ }
16
+
17
+ /**
18
+ * The padding a skin adds at the end of a sheet's scrollable content: a
19
+ * `footerGap` above a sticky footer's hairline, so the last row does not butt
20
+ * against it, and otherwise nothing — the engine's scrollables already end
21
+ * their content with a spacer the height of the footer or the band, so the
22
+ * rows scroll under either and the last one can be brought fully clear.
23
+ */
24
+ export function resolveSheetScrollEndPadding(input: {
25
+ hasStickyFooter: boolean;
26
+ bottom: number;
27
+ footerGap: number;
28
+ }): number {
29
+ return input.hasStickyFooter ? input.footerGap : 0;
30
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { clampHeight } from "./clamp-height";
3
+
4
+ describe("clampHeight", () => {
5
+ test("inside the range the height passes through", () => {
6
+ expect(clampHeight(300, 0, 800)).toBe(300);
7
+ });
8
+
9
+ test("clamps to the closed height below and the maximum above", () => {
10
+ expect(clampHeight(-40, 0, 800)).toBe(0);
11
+ expect(clampHeight(900, 0, 800)).toBe(800);
12
+ });
13
+
14
+ test("a detached closed height is negative and still the floor", () => {
15
+ expect(clampHeight(-100, -50, 750)).toBe(-50);
16
+ });
17
+
18
+ test("NaN collapses to the closed height rather than freezing the sheet", () => {
19
+ expect(clampHeight(Number.NaN, 0, 800)).toBe(0);
20
+ });
21
+
22
+ test("an inverted range collapses to the closed height", () => {
23
+ expect(clampHeight(300, 0, -1)).toBe(0);
24
+ });
25
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * A height held within `[closedHeight, maxHeight]`. `NaN` and an inverted
3
+ * range both collapse to the closed height: one bad frame written into `base`
4
+ * would otherwise freeze the sheet with nothing on screen to say why.
5
+ */
6
+ export function clampHeight(height: number, closedHeight: number, maxHeight: number): number {
7
+ "worklet";
8
+ if (Number.isNaN(height) || maxHeight < closedHeight) return closedHeight;
9
+ if (height < closedHeight) return closedHeight;
10
+ if (height > maxHeight) return maxHeight;
11
+ return height;
12
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { availableHeight, closedHeight, restingBottom } from "./closed-height";
3
+
4
+ describe("restingBottom", () => {
5
+ test("an attached sheet rests on the container's bottom edge", () => {
6
+ expect(restingBottom(false, 16, 34)).toBe(0);
7
+ });
8
+
9
+ test("a detached sheet rests its offset above the safe-area inset", () => {
10
+ expect(restingBottom(true, 16, 34)).toBe(50);
11
+ });
12
+
13
+ test("an unmeasured inset counts as zero", () => {
14
+ expect(restingBottom(true, 16, -1)).toBe(16);
15
+ });
16
+ });
17
+
18
+ describe("closedHeight", () => {
19
+ test("an attached sheet closes at zero", () => {
20
+ expect(closedHeight(0)).toBe(0);
21
+ });
22
+
23
+ test("a detached sheet closes below its resting line, so its top clears the container edge", () => {
24
+ expect(closedHeight(50)).toBe(-50);
25
+ });
26
+ });
27
+
28
+ describe("availableHeight", () => {
29
+ test("the container less the resting bottom", () => {
30
+ expect(availableHeight(800, 0)).toBe(800);
31
+ expect(availableHeight(800, 50)).toBe(750);
32
+ });
33
+
34
+ test("an unmeasured container has nothing available", () => {
35
+ expect(availableHeight(-1, 0)).toBe(0);
36
+ });
37
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * How far above the container's bottom edge the sheet rests: nothing when
3
+ * attached, the detached `bottomOffset` plus the safe-area inset when
4
+ * floating. Every height in the engine is measured up from here.
5
+ */
6
+ export function restingBottom(detached: boolean, bottomOffset: number, bottomInset: number): number {
7
+ "worklet";
8
+ if (!detached) return 0;
9
+ const offset = bottomOffset > 0 ? bottomOffset : 0;
10
+ const inset = bottomInset > 0 ? bottomInset : 0;
11
+ return offset + inset;
12
+ }
13
+
14
+ /**
15
+ * The height at which the sheet is closed: `−restingBottom`, so a detached
16
+ * sheet's top edge clears the container's bottom edge and a
17
+ * `positionFor(C, restingBottom, closedHeight)` is exactly `C`.
18
+ */
19
+ export function closedHeight(restingBottomValue: number): number {
20
+ "worklet";
21
+ return 0 - restingBottomValue;
22
+ }
23
+
24
+ /** The height the sheet may occupy — the container less the resting bottom, never negative. */
25
+ export function availableHeight(containerHeight: number, restingBottomValue: number): number {
26
+ "worklet";
27
+ const available = containerHeight - restingBottomValue;
28
+ return available > 0 ? available : 0;
29
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { closedHeight } from "./closed-height";
3
+ import { DETACHED_DEFAULTS, detachedFrame, resolveDetached } from "./detached-frame";
4
+ import { positionFor } from "./position";
5
+
6
+ describe("resolveDetached", () => {
7
+ test("undefined and false are attached", () => {
8
+ expect(resolveDetached(undefined)).toBeNull();
9
+ expect(resolveDetached(false)).toBeNull();
10
+ });
11
+
12
+ test("true takes the defaults", () => {
13
+ expect(resolveDetached(true)).toEqual(DETACHED_DEFAULTS);
14
+ expect(DETACHED_DEFAULTS).toEqual({ horizontalMargin: 16, bottomOffset: 16 });
15
+ });
16
+
17
+ test("an object fills in whichever default it leaves out", () => {
18
+ expect(resolveDetached({ horizontalMargin: 24 })).toEqual({ horizontalMargin: 24, bottomOffset: 16 });
19
+ expect(resolveDetached({ bottomOffset: 0 })).toEqual({ horizontalMargin: 16, bottomOffset: 0 });
20
+ });
21
+ });
22
+
23
+ describe("detachedFrame", () => {
24
+ test("the card is inset by the margin on both sides", () => {
25
+ expect(detachedFrame({ containerWidth: 390, horizontalMargin: 16, bottomOffset: 16, bottomInset: 34 })).toEqual({
26
+ left: 16,
27
+ width: 358,
28
+ restingBottom: 50,
29
+ });
30
+ });
31
+
32
+ test("a margin wider than the container collapses the width to zero rather than going negative", () => {
33
+ expect(detachedFrame({ containerWidth: 20, horizontalMargin: 16, bottomOffset: 16, bottomInset: 0 }).width).toBe(0);
34
+ });
35
+
36
+ test("an unmeasured width yields an empty frame", () => {
37
+ expect(detachedFrame({ containerWidth: -1, horizontalMargin: 16, bottomOffset: 16, bottomInset: 0 }).width).toBe(0);
38
+ });
39
+
40
+ test("closed, a detached sheet's translateY is the full container height — entirely off-screen", () => {
41
+ const frame = detachedFrame({ containerWidth: 390, horizontalMargin: 16, bottomOffset: 16, bottomInset: 34 });
42
+ expect(positionFor(800, frame.restingBottom, closedHeight(frame.restingBottom))).toBe(800);
43
+ });
44
+
45
+ test("open, a detached sheet's bottom edge floats restingBottom above the container edge", () => {
46
+ const frame = detachedFrame({ containerWidth: 390, horizontalMargin: 16, bottomOffset: 16, bottomInset: 34 });
47
+ const top = positionFor(800, frame.restingBottom, 300);
48
+ expect(800 - (top + 300)).toBe(50);
49
+ });
50
+ });
@@ -0,0 +1,49 @@
1
+ /** The `detached` prop as the consumer writes it. */
2
+ export type DetachedProp = boolean | { horizontalMargin?: number; bottomOffset?: number } | undefined;
3
+
4
+ /** A detached sheet's geometry, every field filled in. */
5
+ export type DetachedOptions = { horizontalMargin: number; bottomOffset: number };
6
+
7
+ /** What `detached: true` means. */
8
+ export const DETACHED_DEFAULTS: DetachedOptions = { horizontalMargin: 16, bottomOffset: 16 };
9
+
10
+ /** The `detached` prop resolved: `null` when attached, full options when floating. */
11
+ export function resolveDetached(detached: DetachedProp): DetachedOptions | null {
12
+ if (detached === undefined || detached === false) return null;
13
+ if (detached === true) return DETACHED_DEFAULTS;
14
+ return {
15
+ horizontalMargin: detached.horizontalMargin ?? DETACHED_DEFAULTS.horizontalMargin,
16
+ bottomOffset: detached.bottomOffset ?? DETACHED_DEFAULTS.bottomOffset,
17
+ };
18
+ }
19
+
20
+ export type DetachedFrameInput = {
21
+ containerWidth: number;
22
+ horizontalMargin: number;
23
+ bottomOffset: number;
24
+ /** The safe-area inset the card floats above. */
25
+ bottomInset: number;
26
+ };
27
+
28
+ export type DetachedFrame = {
29
+ left: number;
30
+ width: number;
31
+ /** How far above the container's bottom edge the card rests. */
32
+ restingBottom: number;
33
+ };
34
+
35
+ /**
36
+ * Where a detached card sits: inset by the margin on both sides, resting
37
+ * `bottomOffset` above the safe-area inset. Closed is then
38
+ * `translateY = containerHeight` — the card's top edge exactly at the
39
+ * container's bottom, resting line included — because `closedHeight` is
40
+ * `−restingBottom`.
41
+ */
42
+ export function detachedFrame(input: DetachedFrameInput): DetachedFrame {
43
+ "worklet";
44
+ const margin = input.horizontalMargin > 0 ? input.horizontalMargin : 0;
45
+ const width = input.containerWidth - margin * 2;
46
+ const offset = input.bottomOffset > 0 ? input.bottomOffset : 0;
47
+ const inset = input.bottomInset > 0 ? input.bottomInset : 0;
48
+ return { left: margin, width: width > 0 ? width : 0, restingBottom: offset + inset };
49
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { positionFor } from "./position";
3
+
4
+ describe("positionFor", () => {
5
+ test("a closed attached sheet sits at the container's bottom edge", () => {
6
+ expect(positionFor(800, 0, 0)).toBe(800);
7
+ });
8
+
9
+ test("height is measured up from the resting bottom line", () => {
10
+ expect(positionFor(800, 0, 300)).toBe(500);
11
+ expect(positionFor(800, 32, 300)).toBe(468);
12
+ });
13
+
14
+ test("a detached sheet closed is fully off-screen, resting line included", () => {
15
+ expect(positionFor(800, 32, -32)).toBe(800);
16
+ });
17
+
18
+ test("filling the available height puts the top at zero", () => {
19
+ expect(positionFor(800, 32, 768)).toBe(0);
20
+ });
21
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The `translateY` that puts `height` pixels of sheet above its resting bottom
3
+ * line, inside a container `containerHeight` tall.
4
+ *
5
+ * Height space is the engine's coordinate system; this is the one place it is
6
+ * converted to a transform. A detached sheet rests `restingBottom` above the
7
+ * container's bottom edge, so its closed position is the full container
8
+ * height and every detent sits that much higher.
9
+ */
10
+ export function positionFor(containerHeight: number, restingBottom: number, height: number): number {
11
+ "worklet";
12
+ return containerHeight - restingBottom - height;
13
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { surfaceHeight } from "./surface-height";
3
+
4
+ describe("surfaceHeight", () => {
5
+ test("between the detents the surface follows the height", () => {
6
+ expect(surfaceHeight(300, 200, 400)).toBe(300);
7
+ expect(surfaceHeight(200, 200, 400)).toBe(200);
8
+ expect(surfaceHeight(400, 200, 400)).toBe(400);
9
+ });
10
+
11
+ test("below the first detent the surface keeps the detent's height, so a close slides the card rigidly", () => {
12
+ expect(surfaceHeight(120, 200, 400)).toBe(200);
13
+ expect(surfaceHeight(0, 200, 400)).toBe(200);
14
+ expect(surfaceHeight(-50, 200, 400)).toBe(200);
15
+ });
16
+
17
+ test("above the last detent the surface keeps the detent's height, so an over-drag lifts the card rigidly", () => {
18
+ expect(surfaceHeight(450, 200, 400)).toBe(400);
19
+ });
20
+
21
+ test("one detent is a rigid card at every height", () => {
22
+ expect(surfaceHeight(10, 320, 320)).toBe(320);
23
+ expect(surfaceHeight(500, 320, 320)).toBe(320);
24
+ });
25
+
26
+ test("no detents yet: never negative, never above zero", () => {
27
+ expect(surfaceHeight(-16, 0, 0)).toBe(0);
28
+ expect(surfaceHeight(-16, -16, -16)).toBe(0);
29
+ });
30
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * How tall a detached card's surface is right now.
3
+ *
4
+ * Between its detents the card resizes: its bottom edge stays on the resting
5
+ * line and the top follows `height`. Below the first detent — a drag down to
6
+ * close, the close animation, a rubber-band under the lowest detent — and
7
+ * above the last — an over-drag, a keyboard lift — the card is a rigid body:
8
+ * its surface keeps the detent's height and `translateY` alone moves it, so
9
+ * the bottom corners slide through the gap and off-screen intact rather than
10
+ * collapsing onto the resting line.
11
+ *
12
+ * An attached sheet never needs this: its panel is as tall as the frame and
13
+ * its bottom edge is always off-screen.
14
+ */
15
+ export function surfaceHeight(height: number, lowestDetent: number, highestDetent: number): number {
16
+ "worklet";
17
+ const low = lowestDetent > 0 ? lowestDetent : 0;
18
+ const high = highestDetent > low ? highestDetent : low;
19
+ if (height < low) return low;
20
+ if (height > high) return high;
21
+ return height;
22
+ }
@@ -0,0 +1,43 @@
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
+ });
@@ -0,0 +1,33 @@
1
+ /** Settle tolerance: a spring at rest is within this of its target. */
2
+ const SETTLE_EPSILON = 0.5;
3
+
4
+ /**
5
+ * The index of the highest 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
+ }