@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,92 @@
1
+ export {
2
+ ANDROID_TIMING,
3
+ type EasingName,
4
+ IOS_SPRING,
5
+ type PlatformName,
6
+ type ReduceMotionMode,
7
+ type ResolvedAnimation,
8
+ type SheetAnimationConfig,
9
+ selectAnimation,
10
+ } from "./animation/select-animation";
11
+ export { backdropInteractive, backdropOpacity } from "./backdrop/backdrop-opacity";
12
+ export { type DynamicDetentInput, dynamicDetent } from "./detents/dynamic-detent";
13
+ export { heightForIndex, indexForHeight } from "./detents/index-for-height";
14
+ export { type DetentError, normalizeDetents, type ParsedDetent, parseDetent } from "./detents/normalize-detents";
15
+ export { resistOverDrag } from "./detents/over-drag";
16
+ export { type SelectSnapHeightInput, selectSnapHeight } from "./detents/select-snap-height";
17
+ export { sheetState } from "./detents/sheet-state";
18
+ export { type BodyClipInput, bodyClip, bodyInset, scrollContentHeight } from "./footer/body-inset";
19
+ export { bandNow, bottomBand, footerHeight } from "./footer/bottom-band";
20
+ export { type FooterTopInput, footerTop } from "./footer/footer-top";
21
+ export { resolveSheetBottomInset, resolveSheetScrollEndPadding } from "./footer/sheet-insets";
22
+ export { clampHeight } from "./geometry/clamp-height";
23
+ export { availableHeight, closedHeight, restingBottom } from "./geometry/closed-height";
24
+ export {
25
+ DETACHED_DEFAULTS,
26
+ type DetachedFrame,
27
+ type DetachedFrameInput,
28
+ type DetachedOptions,
29
+ type DetachedProp,
30
+ detachedFrame,
31
+ resolveDetached,
32
+ } from "./geometry/detached-frame";
33
+ export { positionFor } from "./geometry/position";
34
+ export { surfaceHeight } from "./geometry/surface-height";
35
+ export { crossedDetent, detentUnder } from "./haptic/crossed-detent";
36
+ export { isLayoutReady, type LayoutReadyInput } from "./intent/layout-ready";
37
+ export { type IntentResolution, type IntentState, resolveIntent } from "./intent/resolve-intent";
38
+ export { acceptContainerLayout, type ContainerLayoutInput } from "./keyboard/container-layout-guard";
39
+ export { type ContentAreaInput, contentArea } from "./keyboard/content-area";
40
+ export { type KeyboardLiftInput, keyboardInContainer, keyboardLift } from "./keyboard/keyboard-lift";
41
+ export { type InputInsideSheetInput, isInputInsideSheet } from "./keyboard/keyboard-owner";
42
+ export { type KeyboardOwnerInput, resolveKeyboardOwner } from "./keyboard/keyboard-ownership";
43
+ export { type KeyboardAnimationState, shouldResetKeyboardAnimation } from "./keyboard/keyboard-reset-guard";
44
+ export { type KeyboardStep, type KeyboardStepInput, keyboardStep } from "./keyboard/keyboard-step";
45
+ export {
46
+ defineSheetMachine,
47
+ resolveTarget,
48
+ type SheetEvent,
49
+ type SheetMachine,
50
+ type SheetMachineConfig,
51
+ type SheetMachineSnapshot,
52
+ type SheetStateNode,
53
+ type SheetStates,
54
+ type SheetStepDirection,
55
+ type SheetStepFrame,
56
+ type SheetStepOverride,
57
+ type SheetStepRole,
58
+ type SheetStepTransition,
59
+ type SheetTransitionError,
60
+ type SheetTransitionObject,
61
+ type SheetTransitionResult,
62
+ type SheetTransitionTarget,
63
+ stepFrame,
64
+ stepOverride,
65
+ transition,
66
+ } from "./machine";
67
+ export { type ErrorResult, err, ok, type Result, type SuccessResult } from "./result";
68
+ export { type ContentPanInput, contentPanDrivesSheet, shouldLockScroll } from "./scroll/scroll-lock";
69
+ export {
70
+ type ListDragInput,
71
+ type ListOwnsReleaseInput,
72
+ listDragHeight,
73
+ listOwnsRelease,
74
+ restingDetent,
75
+ scrollLockTarget,
76
+ } from "./scroll/scroll-pan";
77
+ export {
78
+ type AnimationSource,
79
+ CLOSED_INDEX,
80
+ type DetentSpec,
81
+ GESTURE_SOURCE,
82
+ type GestureSource,
83
+ type KeyboardBehavior,
84
+ type KeyboardBlurBehavior,
85
+ type KeyboardScope,
86
+ SCROLLABLE_TYPE,
87
+ type ScrollableType,
88
+ SHEET_STATE,
89
+ type SheetIntent,
90
+ type SheetState,
91
+ UNMEASURED,
92
+ } from "./sheet.types";
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isLayoutReady } from "./layout-ready";
3
+
4
+ const measured = {
5
+ containerHeight: 800,
6
+ handleHeight: 24,
7
+ contentHeight: 300,
8
+ footerContentHeight: 56,
9
+ dynamicSizing: true,
10
+ hasFooter: true,
11
+ };
12
+
13
+ describe("isLayoutReady", () => {
14
+ test("ready once every measurement the configuration needs has landed", () => {
15
+ expect(isLayoutReady(measured)).toBe(true);
16
+ });
17
+
18
+ test("never ready without a container", () => {
19
+ expect(isLayoutReady({ ...measured, containerHeight: -1 })).toBe(false);
20
+ expect(isLayoutReady({ ...measured, containerHeight: 0 })).toBe(false);
21
+ });
22
+
23
+ test("waits for the handle — a zero-height handle counts as measured", () => {
24
+ expect(isLayoutReady({ ...measured, handleHeight: -1 })).toBe(false);
25
+ expect(isLayoutReady({ ...measured, handleHeight: 0 })).toBe(true);
26
+ });
27
+
28
+ test("waits for content only when sizing to it", () => {
29
+ expect(isLayoutReady({ ...measured, contentHeight: -1 })).toBe(false);
30
+ expect(isLayoutReady({ ...measured, contentHeight: -1, dynamicSizing: false })).toBe(true);
31
+ });
32
+
33
+ test("waits for the footer only when there is one", () => {
34
+ expect(isLayoutReady({ ...measured, footerContentHeight: -1 })).toBe(false);
35
+ expect(isLayoutReady({ ...measured, footerContentHeight: -1, hasFooter: false })).toBe(true);
36
+ });
37
+ });
@@ -0,0 +1,24 @@
1
+ export type LayoutReadyInput = {
2
+ containerHeight: number;
3
+ handleHeight: number;
4
+ contentHeight: number;
5
+ footerContentHeight: number;
6
+ /** Whether the content's height is a detent, and so has to be known. */
7
+ dynamicSizing: boolean;
8
+ hasFooter: boolean;
9
+ };
10
+
11
+ /**
12
+ * Whether every measurement this configuration needs has landed, which is
13
+ * what an open intent waits on. The container must be real; the handle must
14
+ * have reported (zero is a report); content only matters when the sheet sizes
15
+ * to it, and the footer only when there is one.
16
+ */
17
+ export function isLayoutReady(input: LayoutReadyInput): boolean {
18
+ "worklet";
19
+ if (!(input.containerHeight > 0)) return false;
20
+ if (input.handleHeight < 0) return false;
21
+ if (input.dynamicSizing && input.contentHeight < 0) return false;
22
+ if (input.hasFooter && input.footerContentHeight < 0) return false;
23
+ return true;
24
+ }
@@ -0,0 +1,163 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { SheetIntent } from "../sheet.types";
3
+ import { type IntentState, resolveIntent } from "./resolve-intent";
4
+
5
+ const ready: IntentState = {
6
+ currentIndex: -1,
7
+ base: 0,
8
+ layoutReady: true,
9
+ detents: [200, 400, 800],
10
+ closedHeight: 0,
11
+ maxHeight: 800,
12
+ initialIndex: 0,
13
+ };
14
+ const open: IntentState = { ...ready, currentIndex: 1, base: 400 };
15
+ /** A sheet visibly open whose settled index never caught up — a release the list owned. */
16
+ const staleClosed: IntentState = { ...ready, currentIndex: -1, base: 800 };
17
+ const unmeasured: IntentState = { ...ready, layoutReady: false, detents: [] };
18
+
19
+ const intent = (kind: SheetIntent["kind"], extra: Record<string, unknown> = {}): SheetIntent =>
20
+ ({ id: 1, kind, ...extra }) as SheetIntent;
21
+
22
+ describe("resolveIntent", () => {
23
+ describe("open", () => {
24
+ test("animates a closed sheet to its initial index", () => {
25
+ expect(resolveIntent(ready, intent("open"))).toEqual({ action: "animate", target: 200 });
26
+ expect(resolveIntent({ ...ready, initialIndex: 2 }, intent("open"))).toEqual({ action: "animate", target: 800 });
27
+ });
28
+
29
+ test("an initial index past the last detent lands on the last", () => {
30
+ expect(resolveIntent({ ...ready, initialIndex: 9 }, intent("open"))).toEqual({ action: "animate", target: 800 });
31
+ });
32
+
33
+ test("waits for layout rather than animating from a guess", () => {
34
+ expect(resolveIntent(unmeasured, intent("open"))).toEqual({ action: "wait" });
35
+ });
36
+
37
+ test("is a no-op on a sheet that is already open", () => {
38
+ expect(resolveIntent(open, intent("open"))).toBeNull();
39
+ expect(resolveIntent(staleClosed, intent("open"))).toBeNull();
40
+ });
41
+
42
+ test("with nothing to open to there is nothing to do", () => {
43
+ expect(resolveIntent({ ...ready, detents: [] }, intent("open"))).toBeNull();
44
+ });
45
+ });
46
+
47
+ describe("close", () => {
48
+ test("animates an open sheet to its closed height", () => {
49
+ expect(resolveIntent(open, intent("close"))).toEqual({ action: "animate", target: 0 });
50
+ expect(resolveIntent({ ...open, closedHeight: -50 }, intent("close"))).toEqual({
51
+ action: "animate",
52
+ target: -50,
53
+ });
54
+ });
55
+
56
+ test("a visibly open sheet closes even when its settled index still says closed", () => {
57
+ expect(resolveIntent(staleClosed, intent("close"))).toEqual({ action: "animate", target: 0 });
58
+ expect(resolveIntent(staleClosed, intent("forceClose"))).toEqual({ action: "jump", target: 0 });
59
+ expect(resolveIntent(staleClosed, intent("snapToIndex", { index: -1 }))).toEqual({
60
+ action: "animate",
61
+ target: 0,
62
+ });
63
+ });
64
+
65
+ test("a sheet resting within the settle tolerance of closed is closed", () => {
66
+ expect(resolveIntent({ ...ready, base: 0.4 }, intent("close"))).toBeNull();
67
+ expect(resolveIntent({ ...ready, closedHeight: -50, base: -49.8 }, intent("close"))).toBeNull();
68
+ });
69
+
70
+ test("a never-opened detached sheet (negative closedHeight, base 0) is closed, so open resolves", () => {
71
+ const detached: IntentState = { ...ready, currentIndex: -1, closedHeight: -50, base: 0 };
72
+ expect(resolveIntent(detached, intent("open"))).not.toBeNull();
73
+ expect(resolveIntent(detached, intent("close"))).toBeNull();
74
+ });
75
+
76
+ test("closing a closed sheet is nothing — no deadlock, no phantom onClose", () => {
77
+ expect(resolveIntent(ready, intent("close"))).toBeNull();
78
+ expect(resolveIntent(unmeasured, intent("close"))).toBeNull();
79
+ });
80
+ });
81
+
82
+ describe("forceClose", () => {
83
+ test("jumps an open sheet closed without animating", () => {
84
+ expect(resolveIntent(open, intent("forceClose"))).toEqual({ action: "jump", target: 0 });
85
+ });
86
+
87
+ test("is nothing on a closed sheet", () => {
88
+ expect(resolveIntent(ready, intent("forceClose"))).toBeNull();
89
+ });
90
+ });
91
+
92
+ describe("snapToIndex", () => {
93
+ test("animates to the detent at that index", () => {
94
+ expect(resolveIntent(open, intent("snapToIndex", { index: 2 }))).toEqual({ action: "animate", target: 800 });
95
+ });
96
+
97
+ test("-1 closes", () => {
98
+ expect(resolveIntent(open, intent("snapToIndex", { index: -1 }))).toEqual({ action: "animate", target: 0 });
99
+ });
100
+
101
+ test("an index that does not exist is refused", () => {
102
+ expect(resolveIntent(open, intent("snapToIndex", { index: 3 }))).toBeNull();
103
+ expect(resolveIntent(open, intent("snapToIndex", { index: -2 }))).toBeNull();
104
+ expect(resolveIntent(open, intent("snapToIndex", { index: 1.5 }))).toBeNull();
105
+ });
106
+
107
+ test("waits for layout", () => {
108
+ expect(resolveIntent(unmeasured, intent("snapToIndex", { index: 0 }))).toEqual({ action: "wait" });
109
+ });
110
+ });
111
+
112
+ describe("snapToPosition", () => {
113
+ test("a pixel height animates as written, without becoming a detent", () => {
114
+ expect(resolveIntent(open, intent("snapToPosition", { position: 333 }))).toEqual({
115
+ action: "animate",
116
+ target: 333,
117
+ });
118
+ });
119
+
120
+ test("a percentage resolves against the height available", () => {
121
+ expect(resolveIntent(open, intent("snapToPosition", { position: "50%" }))).toEqual({
122
+ action: "animate",
123
+ target: 400,
124
+ });
125
+ });
126
+
127
+ test("clamps to what the sheet can show", () => {
128
+ expect(resolveIntent(open, intent("snapToPosition", { position: 5000 }))).toEqual({
129
+ action: "animate",
130
+ target: 800,
131
+ });
132
+ expect(resolveIntent({ ...open, closedHeight: -50 }, intent("snapToPosition", { position: -500 }))).toEqual({
133
+ action: "animate",
134
+ target: -50,
135
+ });
136
+ });
137
+
138
+ test("an invalid position is refused", () => {
139
+ expect(resolveIntent(open, intent("snapToPosition", { position: "abc" }))).toBeNull();
140
+ expect(resolveIntent(open, intent("snapToPosition", { position: Number.NaN }))).toBeNull();
141
+ });
142
+
143
+ test("waits for layout", () => {
144
+ expect(resolveIntent(unmeasured, intent("snapToPosition", { position: 100 }))).toEqual({ action: "wait" });
145
+ });
146
+ });
147
+
148
+ describe("expand and collapse", () => {
149
+ test("expand animates to the highest detent, collapse to the lowest", () => {
150
+ expect(resolveIntent(open, intent("expand"))).toEqual({ action: "animate", target: 800 });
151
+ expect(resolveIntent(open, intent("collapse"))).toEqual({ action: "animate", target: 200 });
152
+ });
153
+
154
+ test("both open a closed sheet", () => {
155
+ expect(resolveIntent(ready, intent("expand"))).toEqual({ action: "animate", target: 800 });
156
+ });
157
+
158
+ test("both wait for layout and refuse an empty detent list", () => {
159
+ expect(resolveIntent(unmeasured, intent("expand"))).toEqual({ action: "wait" });
160
+ expect(resolveIntent({ ...ready, detents: [] }, intent("collapse"))).toBeNull();
161
+ });
162
+ });
163
+ });
@@ -0,0 +1,98 @@
1
+ import type { SheetIntent } from "../sheet.types";
2
+
3
+ /** Settle tolerance: a base within this of the closed height is closed. */
4
+ const SETTLE_EPSILON = 0.5;
5
+
6
+ /** What the resolver needs to know about the sheet at the moment an intent lands. */
7
+ export type IntentState = {
8
+ /** The settled index, `-1` closed. */
9
+ currentIndex: number;
10
+ /**
11
+ * The keyboard-free height right now. A sheet is open when either says so:
12
+ * a release the list owned once left `currentIndex` at `-1` under a sheet
13
+ * sitting at its top, and a `close` read it as closed and did nothing.
14
+ */
15
+ base: number;
16
+ layoutReady: boolean;
17
+ /** Ascending, normalised detents. */
18
+ detents: readonly number[];
19
+ closedHeight: number;
20
+ /** The height the sheet may occupy — what a `%` position resolves against. */
21
+ maxHeight: number;
22
+ /** The index `open` targets. */
23
+ initialIndex: number;
24
+ };
25
+
26
+ /**
27
+ * What an intent turns into.
28
+ *
29
+ * `wait` keeps it queued for the next layout; `animate` and `jump` name a
30
+ * target height; `null` is nothing at all — closing a closed sheet, opening
31
+ * an open one, an index that does not exist.
32
+ */
33
+ export type IntentResolution =
34
+ | { action: "wait" }
35
+ | { action: "animate"; target: number }
36
+ | { action: "jump"; target: number };
37
+
38
+ /**
39
+ * Resolves a request from the JS thread into a height to go to, or nothing.
40
+ *
41
+ * This is where present/dismiss's deadlocks go to die: a close before an
42
+ * open is `null`, not a queued dismiss that fires when the sheet finally
43
+ * presents; an open before the first layout waits for it rather than animating
44
+ * from a guess; `forceClose` jumps. `snapToPosition` resolves a `%` against
45
+ * `maxHeight` and clamps, without ever adding a detent.
46
+ *
47
+ * Runs on the UI thread inside the intent reaction, so it is a flat worklet
48
+ * and re-implements the little it needs of `heightForIndex` and `parseDetent`
49
+ * inline.
50
+ */
51
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the flat-worklet rule forbids extracting helpers; see the doc comment above.
52
+ export function resolveIntent(state: IntentState, intent: SheetIntent): IntentResolution | null {
53
+ "worklet";
54
+ const detents = state.detents;
55
+ const count = detents.length;
56
+ // A detached sheet's closed height is negative and `base` starts at 0, so a
57
+ // never-opened card must not read as open: a sheet is visibly open only when
58
+ // its height is positive, hence the floor at 0.
59
+ const isOpen = state.currentIndex >= 0 || state.base > Math.max(state.closedHeight, 0) + SETTLE_EPSILON;
60
+
61
+ if (intent.kind === "close") {
62
+ return isOpen ? { action: "animate", target: state.closedHeight } : null;
63
+ }
64
+ if (intent.kind === "forceClose") {
65
+ return isOpen ? { action: "jump", target: state.closedHeight } : null;
66
+ }
67
+ if (intent.kind === "open" && isOpen) return null;
68
+ if (!state.layoutReady) return { action: "wait" };
69
+
70
+ if (intent.kind === "open") {
71
+ if (count === 0) return null;
72
+ const index =
73
+ state.initialIndex < 0 ? 0 : state.initialIndex > count - 1 ? count - 1 : Math.floor(state.initialIndex);
74
+ return { action: "animate", target: detents[index] as number };
75
+ }
76
+ if (intent.kind === "snapToIndex") {
77
+ const index = intent.index;
78
+ if (index === -1) return isOpen ? { action: "animate", target: state.closedHeight } : null;
79
+ if (!Number.isInteger(index) || index < 0 || index > count - 1) return null;
80
+ return { action: "animate", target: detents[index] as number };
81
+ }
82
+ if (intent.kind === "snapToPosition") {
83
+ const position = intent.position;
84
+ let height = Number.NaN;
85
+ if (typeof position === "number") {
86
+ height = position;
87
+ } else if (typeof position === "string" && position.length > 1 && position.charCodeAt(position.length - 1) === 37) {
88
+ height = (Number(position.slice(0, -1)) / 100) * state.maxHeight;
89
+ }
90
+ if (!Number.isFinite(height)) return null;
91
+ const clamped =
92
+ height < state.closedHeight ? state.closedHeight : height > state.maxHeight ? state.maxHeight : height;
93
+ return { action: "animate", target: clamped };
94
+ }
95
+ if (count === 0) return null;
96
+ if (intent.kind === "expand") return { action: "animate", target: detents[count - 1] as number };
97
+ return { action: "animate", target: detents[0] as number };
98
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { acceptContainerLayout } from "./container-layout-guard";
3
+
4
+ describe("acceptContainerLayout", () => {
5
+ test("the first measurement is always accepted", () => {
6
+ expect(acceptContainerLayout({ prev: -1, next: 800, keyboardHeight: 336, progress: 1 })).toBe(true);
7
+ });
8
+
9
+ test("growth is always accepted", () => {
10
+ expect(acceptContainerLayout({ prev: 800, next: 900, keyboardHeight: 336, progress: 1 })).toBe(true);
11
+ });
12
+
13
+ test("a shrink with no keyboard in play is a real resize", () => {
14
+ expect(acceptContainerLayout({ prev: 800, next: 600, keyboardHeight: 0, progress: 0 })).toBe(true);
15
+ });
16
+
17
+ test("a shrink by the keyboard's height while it is up is adjustResize double-counting, and is refused", () => {
18
+ expect(acceptContainerLayout({ prev: 800, next: 464, keyboardHeight: 336, progress: 1 })).toBe(false);
19
+ });
20
+
21
+ test("a shrink smaller than the keyboard mid-animation is refused too — the frame is chasing the keyboard", () => {
22
+ expect(acceptContainerLayout({ prev: 800, next: 700, keyboardHeight: 336, progress: 0.4 })).toBe(false);
23
+ });
24
+
25
+ test("a shrink larger than the keyboard is a rotation or a split view, keyboard or not", () => {
26
+ expect(acceptContainerLayout({ prev: 800, next: 400, keyboardHeight: 336, progress: 1 })).toBe(true);
27
+ });
28
+
29
+ test("the same value is accepted, so a re-layout to the same size is not a phantom refusal", () => {
30
+ expect(acceptContainerLayout({ prev: 800, next: 800, keyboardHeight: 336, progress: 1 })).toBe(true);
31
+ });
32
+ });
@@ -0,0 +1,31 @@
1
+ export type ContainerLayoutInput = {
2
+ /** The container height on record; `UNMEASURED` before the first layout. */
3
+ prev: number;
4
+ /** The height `onLayout` just reported. */
5
+ next: number;
6
+ /** The keyboard within the container, positive. */
7
+ keyboardHeight: number;
8
+ /** keyboard-controller's `progress`. */
9
+ progress: number;
10
+ };
11
+
12
+ /** Slack for a keyboard height rounded differently by the two sources. */
13
+ const TOLERANCE = 2;
14
+
15
+ /**
16
+ * Whether a container `onLayout` is a real resize or Android's `adjustResize`
17
+ * shrinking the window under a keyboard the sheet is already lifting for.
18
+ *
19
+ * Accepting the second would count the keyboard twice: once as the lift, once
20
+ * as a smaller container with every percent detent re-derived against it.
21
+ * So while the keyboard is in play, a shrink no larger than the keyboard is
22
+ * refused. A shrink larger than that — a rotation, a split view — is real
23
+ * whatever the keyboard is doing, and growth and first measurement always
24
+ * land.
25
+ */
26
+ export function acceptContainerLayout(input: ContainerLayoutInput): boolean {
27
+ if (input.prev < 0 || input.next >= input.prev) return true;
28
+ if (!(input.progress > 0) || !(input.keyboardHeight > 0)) return true;
29
+ const shrink = input.prev - input.next;
30
+ return shrink > input.keyboardHeight + TOLERANCE;
31
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { contentArea } from "./content-area";
3
+
4
+ describe("contentArea", () => {
5
+ test("the body gets what the handle, footer and keyboard leave", () => {
6
+ expect(
7
+ contentArea({ sheetHeight: 600, handleHeight: 24, footerHeight: 90, keyboardHeight: 0, trailingBand: 0 })
8
+ ).toBe(486);
9
+ });
10
+
11
+ test("without a footer the safe-area band trails the content instead", () => {
12
+ expect(
13
+ contentArea({ sheetHeight: 600, handleHeight: 24, footerHeight: 0, keyboardHeight: 0, trailingBand: 34 })
14
+ ).toBe(542);
15
+ });
16
+
17
+ test("a keyboard the sheet owns takes its height off the body", () => {
18
+ expect(
19
+ contentArea({ sheetHeight: 800, handleHeight: 24, footerHeight: 90, keyboardHeight: 336, trailingBand: 0 })
20
+ ).toBe(350);
21
+ });
22
+
23
+ test("never negative — a keyboard taller than the sheet leaves an empty body, not an inverted one", () => {
24
+ expect(
25
+ contentArea({ sheetHeight: 200, handleHeight: 24, footerHeight: 90, keyboardHeight: 336, trailingBand: 0 })
26
+ ).toBe(0);
27
+ });
28
+
29
+ test("unmeasured parts subtract nothing", () => {
30
+ expect(
31
+ contentArea({ sheetHeight: 600, handleHeight: -1, footerHeight: -1, keyboardHeight: 0, trailingBand: 0 })
32
+ ).toBe(600);
33
+ });
34
+ });
@@ -0,0 +1,26 @@
1
+ export type ContentAreaInput = {
2
+ /** The sheet's visible height for sizing the body — `min(maxHeight, highest + keyboardLift)`. */
3
+ sheetHeight: number;
4
+ handleHeight: number;
5
+ /** The sticky footer's full height, band included; zero without one. */
6
+ footerHeight: number;
7
+ /** The keyboard the sheet owns, positive, within the container. */
8
+ keyboardHeight: number;
9
+ /** The safe-area band trailing the content when there is no footer to hold it. */
10
+ trailingBand: number;
11
+ };
12
+
13
+ /**
14
+ * The height the body may fill: whatever the handle, the footer, the keyboard
15
+ * and the trailing band leave of the sheet. Never negative, and an unmeasured
16
+ * part subtracts nothing.
17
+ */
18
+ export function contentArea(input: ContentAreaInput): number {
19
+ "worklet";
20
+ const handle = input.handleHeight > 0 ? input.handleHeight : 0;
21
+ const footer = input.footerHeight > 0 ? input.footerHeight : 0;
22
+ const keyboard = input.keyboardHeight > 0 ? input.keyboardHeight : 0;
23
+ const band = input.trailingBand > 0 ? input.trailingBand : 0;
24
+ const area = input.sheetHeight - handle - footer - keyboard - band;
25
+ return area > 0 ? area : 0;
26
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { KeyboardBehavior } from "../sheet.types";
3
+ import { keyboardInContainer, keyboardLift } from "./keyboard-lift";
4
+
5
+ describe("keyboardInContainer", () => {
6
+ test("keyboard-controller's height is negative; the container's share is positive", () => {
7
+ expect(keyboardInContainer(-336, 0)).toBe(336);
8
+ });
9
+
10
+ test("a container ending above the window bottom is overlapped by less of the keyboard", () => {
11
+ expect(keyboardInContainer(-336, 80)).toBe(256);
12
+ });
13
+
14
+ test("a container the keyboard never reaches sees zero", () => {
15
+ expect(keyboardInContainer(-100, 200)).toBe(0);
16
+ expect(keyboardInContainer(0, 0)).toBe(0);
17
+ });
18
+
19
+ test("an unmeasured offset counts as zero rather than inflating the keyboard", () => {
20
+ expect(keyboardInContainer(-336, -1)).toBe(336);
21
+ });
22
+ });
23
+
24
+ describe("keyboardLift", () => {
25
+ const input = { keyboardHeight: 336, progress: 1, band: 34, gapBelow: 0 };
26
+
27
+ test("interactive and extend lift by the keyboard less the collapsed safe-area band", () => {
28
+ for (const behavior of ["interactive", "extend"] as const satisfies KeyboardBehavior[]) {
29
+ expect(keyboardLift({ ...input, behavior })).toBe(336 - 34);
30
+ }
31
+ });
32
+
33
+ test("the band collapses in step with the keyboard's progress", () => {
34
+ expect(keyboardLift({ ...input, behavior: "interactive", progress: 0.5, keyboardHeight: 168 })).toBe(168 - 17);
35
+ });
36
+
37
+ test("fillParent and none never lift — fill grows the sheet, none ignores the keyboard", () => {
38
+ for (const behavior of ["fillParent", "none"] as const satisfies KeyboardBehavior[]) {
39
+ expect(keyboardLift({ ...input, behavior })).toBe(0);
40
+ }
41
+ });
42
+
43
+ test("a detached sheet already floats above the bottom, so its gap comes off the lift", () => {
44
+ expect(keyboardLift({ ...input, band: 0, gapBelow: 50, behavior: "interactive" })).toBe(336 - 50);
45
+ });
46
+
47
+ test("never negative — a keyboard shorter than the gap leaves the sheet alone", () => {
48
+ expect(keyboardLift({ ...input, keyboardHeight: 20, band: 0, gapBelow: 50, behavior: "interactive" })).toBe(0);
49
+ });
50
+
51
+ test("no keyboard, no lift, whatever the behaviour", () => {
52
+ expect(keyboardLift({ ...input, keyboardHeight: 0, progress: 0, behavior: "interactive" })).toBe(0);
53
+ });
54
+ });
@@ -0,0 +1,47 @@
1
+ import type { KeyboardBehavior } from "../sheet.types";
2
+
3
+ /**
4
+ * How much of the keyboard overlaps the sheet's container.
5
+ *
6
+ * keyboard-controller reports `height` as a **negative** number of pixels from
7
+ * the window's bottom edge. A container whose bottom sits
8
+ * `containerBottomOffset` above that edge is overlapped by that much less.
9
+ */
10
+ export function keyboardInContainer(rawKeyboardHeight: number, containerBottomOffset: number): number {
11
+ "worklet";
12
+ const offset = containerBottomOffset > 0 ? containerBottomOffset : 0;
13
+ const overlap = -rawKeyboardHeight - offset;
14
+ return overlap > 0 ? overlap : 0;
15
+ }
16
+
17
+ export type KeyboardLiftInput = {
18
+ /** The keyboard the sheet owns, positive, already reduced to the container. */
19
+ keyboardHeight: number;
20
+ /** keyboard-controller's `progress`, 0 closed through 1 open. */
21
+ progress: number;
22
+ /** The safe-area band under an attached sheet; zero when detached. */
23
+ band: number;
24
+ behavior: KeyboardBehavior;
25
+ /** A detached sheet's resting bottom — pixels it already floats above the edge. */
26
+ gapBelow: number;
27
+ };
28
+
29
+ /**
30
+ * The extra height a keyboard adds on top of `base`.
31
+ *
32
+ * Only `interactive` and `extend` lift. The sheet already spends `band`
33
+ * pixels on the safe area under the footer, and those pixels are the
34
+ * keyboard's now, so they come off the lift in step with `progress`; a
35
+ * detached sheet's gap comes off the same way. That subtraction is what holds
36
+ * `footerTop` still for the whole keyboard animation — see `footer/footer-top`.
37
+ */
38
+ export function keyboardLift(input: KeyboardLiftInput): number {
39
+ "worklet";
40
+ if (input.behavior !== "interactive" && input.behavior !== "extend") return 0;
41
+
42
+ const progress = input.progress < 0 ? 0 : input.progress > 1 ? 1 : input.progress;
43
+ const band = input.band > 0 ? input.band : 0;
44
+ const gap = input.gapBelow > 0 ? input.gapBelow : 0;
45
+ const lift = input.keyboardHeight - band * progress - gap;
46
+ return lift > 0 ? lift : 0;
47
+ }