@delacour/react-native-bottom-sheet 0.2.0 → 0.3.0-alpha.20261023075140

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-bottom-sheet",
3
- "version": "0.2.0",
3
+ "version": "0.3.0-alpha.20261023075140",
4
4
  "description": "Headless bottom sheet engine for React Native — no tokens, no className",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -110,6 +110,7 @@ function BottomSheetRoot({
110
110
  overDragResistanceFactor = 2.5,
111
111
  keyboardBehavior = "interactive",
112
112
  keyboardBlurBehavior = "restore",
113
+ keyboardLiftLimit = "container",
113
114
  keyboardScope = "inside",
114
115
  enableBlurKeyboardOnGesture = true,
115
116
  animation,
@@ -168,6 +169,7 @@ function BottomSheetRoot({
168
169
  animateOnMount,
169
170
  keyboardBehavior,
170
171
  keyboardBlurBehavior,
172
+ keyboardLiftLimit,
171
173
  keyboardScope,
172
174
  enableBlurKeyboardOnGesture,
173
175
  }),
@@ -184,6 +186,7 @@ function BottomSheetRoot({
184
186
  animateOnMount,
185
187
  keyboardBehavior,
186
188
  keyboardBlurBehavior,
189
+ keyboardLiftLimit,
187
190
  keyboardScope,
188
191
  enableBlurKeyboardOnGesture,
189
192
  ]
@@ -14,6 +14,7 @@ import type {
14
14
  DetachedProp,
15
15
  KeyboardBehavior,
16
16
  KeyboardBlurBehavior,
17
+ KeyboardLiftLimit,
17
18
  KeyboardScope,
18
19
  ReduceMotionMode,
19
20
  SnapPointSpec,
@@ -75,7 +76,7 @@ export type BottomSheetProps = SheetHaptics & {
75
76
  snapPoints?: readonly SnapPointSpec[];
76
77
  /** Adds the content's own height as a snap point. @default true */
77
78
  dynamicSizing?: boolean;
78
- /** Caps the dynamic snap point. */
79
+ /** Caps the dynamic snap point, and how high a keyboard may lift the sheet. */
79
80
  maxDynamicContentSize?: number;
80
81
  /** Pixels the frame leaves clear at the top. @default 0 */
81
82
  topInset?: number;
@@ -103,6 +104,8 @@ export type BottomSheetProps = SheetHaptics & {
103
104
  keyboardBehavior?: KeyboardBehavior;
104
105
  /** @default "restore" */
105
106
  keyboardBlurBehavior?: KeyboardBlurBehavior;
107
+ /** Whether `maxDynamicContentSize` also stops the keyboard's lift. @default "container" */
108
+ keyboardLiftLimit?: KeyboardLiftLimit;
106
109
  /** @default "inside" */
107
110
  keyboardScope?: KeyboardScope;
108
111
  /** @default true */
@@ -33,7 +33,7 @@ at the moment a view needs it, by `geometry/position.ts`, and nowhere else.
33
33
  | `snap-points/select-snap-height.ts` | W `selectSnapHeight` — nearest candidate to `height + projection · velocity`; closes only when given a closed height |
34
34
  | `snap-points/over-drag.ts` | W `resistOverDrag` — slope 1 at the edge, square-root beyond; factor 0 clamps |
35
35
  | `snap-points/sheet-state.ts` | W `sheetState` — closed / opened / extended / overExtended / fill |
36
- | `keyboard/keyboard-lift.ts` | W `keyboardInContainer`, W `keyboardLift` — the lift, band and gap subtracted in step with progress |
36
+ | `keyboard/keyboard-lift.ts` | W `keyboardInContainer`, W `keyboardLift` — the lift, band and gap subtracted in step with progress, stopped at the ceiling; W `keyboardCeiling` — a capped dynamic sheet is lifted no higher than its cap |
37
37
  | `keyboard/keyboard-owner.ts` | W `isInputInsideSheet` — the `keyboardScope: "inside"` geometry test |
38
38
  | `keyboard/content-area.ts` | W `contentArea` — what the body may fill, never negative |
39
39
  | `keyboard/container-layout-guard.ts` | `acceptContainerLayout` — refuses Android's adjustResize double-count |
@@ -71,11 +71,12 @@ band = detached ? 0 : inset bandNow = band · (
71
71
  footerHeight = hasFooter ? footerContent + bandNow : 0
72
72
  dynamicSnapPoint = min(handle + content + (hasFooter ? footerContent : 0) + band, maxDynamicContentSize, available)
73
73
  snapPoints = sortAsc(unique(normalize(spec, available) ∪ {dynamicSnapPoint if dynamicSizing}))
74
- keyboardLift = behavior ∈ {interactive, extend} ? max(0, kb − band · p − (detached ? restingBottom : 0)) : 0
74
+ ceiling = keyboardLiftLimit = maxDynamicContentSize && dynamicSizing && maxDynamicContentSize ? min(maxDynamicContentSize, maxHeight) : maxHeight
75
+ keyboardLift = behavior ∈ {interactive, extend} ? clamp(kb − band · p − (detached ? restingBottom : 0), 0, ceiling − base) : 0
75
76
  height = clamp(base + keyboardLift, closedHeight, maxHeight)
76
77
  translateY = C − restingBottom − height
77
78
  index = piecewise(base, [closedHeight, …snapPoints] → [−1, 0, 1, …]) // base, never height
78
- contentArea = max(0, min(maxHeight, highest + keyboardLift) − handle − footerHeight − kb − (hasFooter ? 0 : bandNow))
79
+ contentArea = max(0, min(maxHeight, highest + keyboardLift(base = highest)) − handle − footerHeight − kb − (hasFooter ? 0 : bandNow))
79
80
  footerTop = max(0, height − kb − footerContent − bandNow)
80
81
  bodyInset = hasFooter ? footerHeight : bandNow
81
82
  bodyClip = hasFooter ? max(0, min(contentArea + bodyInset, height − kb − handle − bodyInset)) : contentArea + bodyInset
package/src/core/index.ts CHANGED
@@ -31,7 +31,13 @@ export { isLayoutReady, type LayoutReadyInput } from "./intent/layout-ready";
31
31
  export { type IntentResolution, type IntentState, resolveIntent } from "./intent/resolve-intent";
32
32
  export { acceptContainerLayout, type ContainerLayoutInput } from "./keyboard/container-layout-guard";
33
33
  export { type ContentAreaInput, contentArea } from "./keyboard/content-area";
34
- export { type KeyboardLiftInput, keyboardInContainer, keyboardLift } from "./keyboard/keyboard-lift";
34
+ export {
35
+ type KeyboardCeilingInput,
36
+ type KeyboardLiftInput,
37
+ keyboardCeiling,
38
+ keyboardInContainer,
39
+ keyboardLift,
40
+ } from "./keyboard/keyboard-lift";
35
41
  export { type InputInsideSheetInput, isInputInsideSheet } from "./keyboard/keyboard-owner";
36
42
  export { type KeyboardOwnerInput, resolveKeyboardOwner } from "./keyboard/keyboard-ownership";
37
43
  export { type KeyboardAnimationState, shouldResetKeyboardAnimation } from "./keyboard/keyboard-reset-guard";
@@ -75,6 +81,7 @@ export {
75
81
  type GestureSource,
76
82
  type KeyboardBehavior,
77
83
  type KeyboardBlurBehavior,
84
+ type KeyboardLiftLimit,
78
85
  type KeyboardScope,
79
86
  SCROLLABLE_TYPE,
80
87
  type ScrollableType,
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { KeyboardBehavior } from "../sheet.types";
3
- import { keyboardInContainer, keyboardLift } from "./keyboard-lift";
3
+ import { keyboardCeiling, keyboardInContainer, keyboardLift } from "./keyboard-lift";
4
4
 
5
5
  describe("keyboardInContainer", () => {
6
6
  test("keyboard-controller's height is negative; the container's share is positive", () => {
@@ -52,3 +52,48 @@ describe("keyboardLift", () => {
52
52
  expect(keyboardLift({ ...input, keyboardHeight: 0, progress: 0, behavior: "interactive" })).toBe(0);
53
53
  });
54
54
  });
55
+
56
+ describe("keyboardLift under a ceiling", () => {
57
+ const input = { keyboardHeight: 336, progress: 1, band: 0, gapBelow: 0, behavior: "interactive" as const };
58
+
59
+ test("a short sheet lifts until its top reaches the ceiling, and no further", () => {
60
+ expect(keyboardLift({ ...input, base: 600, ceiling: 724 })).toBe(124);
61
+ });
62
+
63
+ test("a sheet already at the ceiling stays where it is; the keyboard covers its bottom", () => {
64
+ expect(keyboardLift({ ...input, base: 724, ceiling: 724 })).toBe(0);
65
+ });
66
+
67
+ test("a snap point above the ceiling is never pulled down — the lift is zero, not negative", () => {
68
+ expect(keyboardLift({ ...input, base: 800, ceiling: 724 })).toBe(0);
69
+ });
70
+
71
+ test("a ceiling the lift never reaches changes nothing", () => {
72
+ expect(keyboardLift({ ...input, base: 200, ceiling: 724 })).toBe(336);
73
+ });
74
+
75
+ test("without a ceiling the lift is the keyboard's", () => {
76
+ expect(keyboardLift({ ...input, base: 724 })).toBe(336);
77
+ });
78
+ });
79
+
80
+ describe("keyboardCeiling", () => {
81
+ const capped = { limit: "maxDynamicContentSize", dynamicSizing: true, maxHeight: 852 } as const;
82
+
83
+ test("a dynamic sheet with a cap is lifted no higher than the cap", () => {
84
+ expect(keyboardCeiling({ ...capped, maxDynamicContentSize: 724 })).toBe(724);
85
+ });
86
+
87
+ test("a cap above the container changes nothing", () => {
88
+ expect(keyboardCeiling({ ...capped, maxDynamicContentSize: 900 })).toBe(852);
89
+ });
90
+
91
+ test("no cap, or explicit snap points, leave the container as the ceiling", () => {
92
+ expect(keyboardCeiling({ ...capped, maxDynamicContentSize: undefined })).toBe(852);
93
+ expect(keyboardCeiling({ ...capped, dynamicSizing: false, maxDynamicContentSize: 724 })).toBe(852);
94
+ });
95
+
96
+ test("the default limit is the container, cap or no cap", () => {
97
+ expect(keyboardCeiling({ ...capped, limit: "container", maxDynamicContentSize: 724 })).toBe(852);
98
+ });
99
+ });
@@ -1,4 +1,4 @@
1
- import type { KeyboardBehavior } from "../sheet.types";
1
+ import type { KeyboardBehavior, KeyboardLiftLimit } from "../sheet.types";
2
2
 
3
3
  /**
4
4
  * How much of the keyboard overlaps the sheet's container.
@@ -24,6 +24,10 @@ export type KeyboardLiftInput = {
24
24
  behavior: KeyboardBehavior;
25
25
  /** A detached sheet's resting bottom — pixels it already floats above the edge. */
26
26
  gapBelow: number;
27
+ /** The keyboard-free height the lift sits on. Read only with `ceiling`. */
28
+ base?: number;
29
+ /** The height the lift may not carry `base` past — see `keyboardCeiling`. */
30
+ ceiling?: number;
27
31
  };
28
32
 
29
33
  /**
@@ -43,5 +47,35 @@ export function keyboardLift(input: KeyboardLiftInput): number {
43
47
  const band = input.band > 0 ? input.band : 0;
44
48
  const gap = input.gapBelow > 0 ? input.gapBelow : 0;
45
49
  const lift = input.keyboardHeight - band * progress - gap;
46
- return lift > 0 ? lift : 0;
50
+ if (!(lift > 0)) return 0;
51
+ if (input.ceiling === undefined) return lift;
52
+ const room = input.ceiling - (input.base ?? 0);
53
+ if (!(room > 0)) return 0;
54
+ return lift < room ? lift : room;
55
+ }
56
+
57
+ export type KeyboardCeilingInput = {
58
+ limit: KeyboardLiftLimit;
59
+ dynamicSizing: boolean;
60
+ maxDynamicContentSize: number | undefined;
61
+ /** The container's available height. */
62
+ maxHeight: number;
63
+ };
64
+
65
+ /**
66
+ * The height a keyboard may lift the sheet to.
67
+ *
68
+ * The container's top, unless `limit` is `maxDynamicContentSize` and the sheet
69
+ * sizes to its content under that cap: then the lift stops at the cap and the
70
+ * keyboard covers the sheet's bottom instead, the body shrinking by it as
71
+ * always. Decided on the UI thread, in the frame the keyboard moves — a caller
72
+ * shrinking the cap from a JS keyboard event lands a few frames late, and the
73
+ * sheet overshoots to the container's top before settling.
74
+ */
75
+ export function keyboardCeiling(input: KeyboardCeilingInput): number {
76
+ "worklet";
77
+ const cap = input.maxDynamicContentSize;
78
+ if (input.limit !== "maxDynamicContentSize" || !input.dynamicSizing || cap === undefined) return input.maxHeight;
79
+ const bounded = cap > 0 ? cap : 0;
80
+ return bounded < input.maxHeight ? bounded : input.maxHeight;
47
81
  }
@@ -22,6 +22,15 @@ export type KeyboardBehavior = "interactive" | "extend" | "fillParent" | "none";
22
22
  /** What the sheet does when the keyboard it lifted for goes away. */
23
23
  export type KeyboardBlurBehavior = "none" | "restore";
24
24
 
25
+ /**
26
+ * How high a keyboard may lift the sheet.
27
+ *
28
+ * `container` — to the container's top. `maxDynamicContentSize` — a sheet
29
+ * sized to its content stops at that cap, and the keyboard covers its bottom
30
+ * instead; for a caller whose cap already keeps the page in view.
31
+ */
32
+ export type KeyboardLiftLimit = "container" | "maxDynamicContentSize";
33
+
25
34
  /**
26
35
  * Which focused inputs count as the sheet's own.
27
36
  *
@@ -5,6 +5,7 @@ import type {
5
5
  GestureSource,
6
6
  KeyboardBehavior,
7
7
  KeyboardBlurBehavior,
8
+ KeyboardLiftLimit,
8
9
  KeyboardScope,
9
10
  ScrollableType,
10
11
  SheetIntent,
@@ -38,6 +39,7 @@ export type SheetWorkletConfig = {
38
39
  animateOnMount: boolean;
39
40
  keyboardBehavior: KeyboardBehavior;
40
41
  keyboardBlurBehavior: KeyboardBlurBehavior;
42
+ keyboardLiftLimit: KeyboardLiftLimit;
41
43
  keyboardScope: KeyboardScope;
42
44
  enableBlurKeyboardOnGesture: boolean;
43
45
  };
@@ -13,6 +13,7 @@ import {
13
13
  footerTop,
14
14
  indexForHeight,
15
15
  isLayoutReady,
16
+ keyboardCeiling,
16
17
  keyboardLift,
17
18
  positionFor,
18
19
  restingBottom,
@@ -56,6 +57,17 @@ export function useSheetGeometry(state: SheetSharedState): SheetGeometry {
56
57
  const keyboardEffective = useDerivedValue(() =>
57
58
  state.keyboardOwned.value && state.config.value.keyboardBehavior !== "none" ? state.keyboardHeight.value : 0
58
59
  );
60
+ // Under `keyboardLiftLimit="maxDynamicContentSize"` a capped dynamic sheet is
61
+ // lifted no higher than its cap — decided here, on the UI thread, so it holds
62
+ // from the keyboard's first frame.
63
+ const ceiling = useDerivedValue(() =>
64
+ keyboardCeiling({
65
+ limit: state.config.value.keyboardLiftLimit,
66
+ dynamicSizing: state.config.value.dynamicSizing,
67
+ maxDynamicContentSize: state.config.value.maxDynamicContentSize,
68
+ maxHeight: maxHeight.value,
69
+ })
70
+ );
59
71
  const lift = useDerivedValue(() =>
60
72
  keyboardLift({
61
73
  keyboardHeight: keyboardEffective.value,
@@ -63,6 +75,8 @@ export function useSheetGeometry(state: SheetSharedState): SheetGeometry {
63
75
  band: band.value,
64
76
  behavior: state.config.value.keyboardBehavior,
65
77
  gapBelow: resting.value,
78
+ base: state.base.value,
79
+ ceiling: ceiling.value,
66
80
  })
67
81
  );
68
82
  const height = useDerivedValue(() => clampHeight(state.base.value + lift.value, closed.value, maxHeight.value));
@@ -92,7 +106,17 @@ export function useSheetGeometry(state: SheetSharedState): SheetGeometry {
92
106
  // never reflows during a snap between snap points — but a `fillParent` snap
93
107
  // takes `base` above every snap point, and the body follows it up.
94
108
  const area = useDerivedValue(() => {
95
- const sheetHeight = Math.min(maxHeight.value, Math.max(highest.value, state.base.value) + lift.value);
109
+ const from = Math.max(highest.value, state.base.value);
110
+ const fromLift = keyboardLift({
111
+ keyboardHeight: keyboardEffective.value,
112
+ progress: state.keyboardProgress.value,
113
+ band: band.value,
114
+ behavior: state.config.value.keyboardBehavior,
115
+ gapBelow: resting.value,
116
+ base: from,
117
+ ceiling: ceiling.value,
118
+ });
119
+ const sheetHeight = Math.min(maxHeight.value, from + fromLift);
96
120
  return contentArea({
97
121
  sheetHeight,
98
122
  handleHeight: state.handleHeight.value,
@@ -64,6 +64,7 @@ export function useSheetState(snapPoints: readonly SnapPointSpec[], config: Shee
64
64
  animateOnMount,
65
65
  keyboardBehavior,
66
66
  keyboardBlurBehavior,
67
+ keyboardLiftLimit,
67
68
  keyboardScope,
68
69
  enableBlurKeyboardOnGesture,
69
70
  } = config;
@@ -83,6 +84,7 @@ export function useSheetState(snapPoints: readonly SnapPointSpec[], config: Shee
83
84
  animateOnMount,
84
85
  keyboardBehavior,
85
86
  keyboardBlurBehavior,
87
+ keyboardLiftLimit,
86
88
  keyboardScope,
87
89
  enableBlurKeyboardOnGesture,
88
90
  };
@@ -100,6 +102,7 @@ export function useSheetState(snapPoints: readonly SnapPointSpec[], config: Shee
100
102
  animateOnMount,
101
103
  keyboardBehavior,
102
104
  keyboardBlurBehavior,
105
+ keyboardLiftLimit,
103
106
  keyboardScope,
104
107
  enableBlurKeyboardOnGesture,
105
108
  ]);