@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.
- package/README.md +132 -0
- package/package.json +57 -0
- package/src/animation/AGENTS.md +58 -0
- package/src/animation/animation.types.ts +35 -0
- package/src/animation/resolve-animation.ts +75 -0
- package/src/animation/use-animate-to.ts +107 -0
- package/src/animation/use-settle-callbacks.ts +60 -0
- package/src/components/AGENTS.md +80 -0
- package/src/components/bottom-sheet-background.tsx +34 -0
- package/src/components/bottom-sheet-close.tsx +45 -0
- package/src/components/bottom-sheet-container.tsx +79 -0
- package/src/components/bottom-sheet-content.tsx +102 -0
- package/src/components/bottom-sheet-description.tsx +33 -0
- package/src/components/bottom-sheet-footer.tsx +97 -0
- package/src/components/bottom-sheet-handle.tsx +72 -0
- package/src/components/bottom-sheet-overlay.tsx +91 -0
- package/src/components/bottom-sheet-portal.tsx +96 -0
- package/src/components/bottom-sheet-text-input.tsx +36 -0
- package/src/components/bottom-sheet-title.tsx +30 -0
- package/src/components/bottom-sheet-trigger.tsx +51 -0
- package/src/components/bottom-sheet.context.tsx +134 -0
- package/src/components/bottom-sheet.tsx +475 -0
- package/src/components/bottom-sheet.types.ts +235 -0
- package/src/core/AGENTS.md +160 -0
- package/src/core/animation/select-animation.test.ts +57 -0
- package/src/core/animation/select-animation.ts +104 -0
- package/src/core/backdrop/backdrop-opacity.test.ts +49 -0
- package/src/core/backdrop/backdrop-opacity.ts +21 -0
- package/src/core/detents/dynamic-detent.test.ts +40 -0
- package/src/core/detents/dynamic-detent.ts +41 -0
- package/src/core/detents/index-for-height.test.ts +65 -0
- package/src/core/detents/index-for-height.ts +44 -0
- package/src/core/detents/normalize-detents.test.ts +72 -0
- package/src/core/detents/normalize-detents.ts +63 -0
- package/src/core/detents/over-drag.test.ts +46 -0
- package/src/core/detents/over-drag.ts +23 -0
- package/src/core/detents/select-snap-height.test.ts +52 -0
- package/src/core/detents/select-snap-height.ts +44 -0
- package/src/core/detents/sheet-state.test.ts +42 -0
- package/src/core/detents/sheet-state.ts +32 -0
- package/src/core/footer/body-inset.test.ts +95 -0
- package/src/core/footer/body-inset.ts +65 -0
- package/src/core/footer/bottom-band.test.ts +46 -0
- package/src/core/footer/bottom-band.ts +30 -0
- package/src/core/footer/footer-top.test.ts +61 -0
- package/src/core/footer/footer-top.ts +27 -0
- package/src/core/footer/sheet-insets.test.ts +27 -0
- package/src/core/footer/sheet-insets.ts +30 -0
- package/src/core/geometry/clamp-height.test.ts +25 -0
- package/src/core/geometry/clamp-height.ts +12 -0
- package/src/core/geometry/closed-height.test.ts +37 -0
- package/src/core/geometry/closed-height.ts +29 -0
- package/src/core/geometry/detached-frame.test.ts +50 -0
- package/src/core/geometry/detached-frame.ts +49 -0
- package/src/core/geometry/position.test.ts +21 -0
- package/src/core/geometry/position.ts +13 -0
- package/src/core/geometry/surface-height.test.ts +30 -0
- package/src/core/geometry/surface-height.ts +22 -0
- package/src/core/haptic/crossed-detent.test.ts +43 -0
- package/src/core/haptic/crossed-detent.ts +33 -0
- package/src/core/index.ts +92 -0
- package/src/core/intent/layout-ready.test.ts +37 -0
- package/src/core/intent/layout-ready.ts +24 -0
- package/src/core/intent/resolve-intent.test.ts +163 -0
- package/src/core/intent/resolve-intent.ts +98 -0
- package/src/core/keyboard/container-layout-guard.test.ts +32 -0
- package/src/core/keyboard/container-layout-guard.ts +31 -0
- package/src/core/keyboard/content-area.test.ts +34 -0
- package/src/core/keyboard/content-area.ts +26 -0
- package/src/core/keyboard/keyboard-lift.test.ts +54 -0
- package/src/core/keyboard/keyboard-lift.ts +47 -0
- package/src/core/keyboard/keyboard-owner.test.ts +35 -0
- package/src/core/keyboard/keyboard-owner.ts +25 -0
- package/src/core/keyboard/keyboard-ownership.test.ts +69 -0
- package/src/core/keyboard/keyboard-ownership.ts +39 -0
- package/src/core/keyboard/keyboard-reset-guard.test.ts +33 -0
- package/src/core/keyboard/keyboard-reset-guard.ts +33 -0
- package/src/core/keyboard/keyboard-step.test.ts +66 -0
- package/src/core/keyboard/keyboard-step.ts +53 -0
- package/src/core/machine/define-sheet-machine.test.ts +187 -0
- package/src/core/machine/define-sheet-machine.ts +35 -0
- package/src/core/machine/index.ts +23 -0
- package/src/core/machine/machine.types.ts +80 -0
- package/src/core/machine/step-transition.test.ts +46 -0
- package/src/core/machine/step-transition.ts +59 -0
- package/src/core/machine/transition.test.ts +51 -0
- package/src/core/machine/transition.ts +58 -0
- package/src/core/result.test.ts +15 -0
- package/src/core/result.ts +20 -0
- package/src/core/scroll/scroll-lock.test.ts +40 -0
- package/src/core/scroll/scroll-lock.ts +35 -0
- package/src/core/scroll/scroll-pan.test.ts +99 -0
- package/src/core/scroll/scroll-pan.ts +96 -0
- package/src/core/sheet.types.ts +93 -0
- package/src/display-name.test.ts +181 -0
- package/src/docs.test.ts +45 -0
- package/src/flat-worklet.test.ts +130 -0
- package/src/gesture/AGENTS.md +71 -0
- package/src/gesture/gesture.types.ts +37 -0
- package/src/gesture/use-scroll-lock.ts +162 -0
- package/src/gesture/use-sheet-pan.ts +250 -0
- package/src/index.ts +127 -0
- package/src/keyboard/AGENTS.md +84 -0
- package/src/keyboard/use-bottom-sheet-text-input.ts +79 -0
- package/src/keyboard/use-keyboard-animation-guard.ts +59 -0
- package/src/keyboard/use-sheet-keyboard.ts +163 -0
- package/src/layout/AGENTS.md +52 -0
- package/src/layout/use-container-layout.ts +63 -0
- package/src/layout/use-measure-height.ts +40 -0
- package/src/lib/AGENTS.md +35 -0
- package/src/lib/compose-refs.ts +37 -0
- package/src/lib/merge-props.ts +42 -0
- package/src/lib/slot.tsx +44 -0
- package/src/lib/use-controllable-state.ts +45 -0
- package/src/no-classname.test.ts +27 -0
- package/src/portal/AGENTS.md +101 -0
- package/src/portal/bottom-sheet-host.tsx +67 -0
- package/src/portal/bottom-sheet-provider.tsx +33 -0
- package/src/portal/host.context.tsx +25 -0
- package/src/portal/sheet-registry.context.tsx +124 -0
- package/src/portal/sheet-registry.test.ts +87 -0
- package/src/portal/sheet-registry.ts +78 -0
- package/src/purity.test.ts +90 -0
- package/src/scrollable/AGENTS.md +123 -0
- package/src/scrollable/bottom-sheet-flat-list.tsx +15 -0
- package/src/scrollable/bottom-sheet-scroll-view.tsx +15 -0
- package/src/scrollable/bottom-sheet-section-list.tsx +22 -0
- package/src/scrollable/create-bottom-sheet-scrollable.tsx +248 -0
- package/src/scrollable/scrollable.types.ts +76 -0
- package/src/source-tree.test.ts +33 -0
- package/src/state/AGENTS.md +63 -0
- package/src/state/state.types.ts +134 -0
- package/src/state/use-detents.ts +43 -0
- package/src/state/use-sheet-geometry.ts +178 -0
- package/src/state/use-sheet-intents.ts +144 -0
- package/src/state/use-sheet-state.ts +157 -0
- package/src/steps/AGENTS.md +100 -0
- package/src/steps/bottom-sheet-step.tsx +61 -0
- package/src/steps/bottom-sheet-steps.tsx +215 -0
- package/src/steps/steps.context.tsx +73 -0
- package/src/steps/steps.types.ts +61 -0
- package/src/steps/use-sheet-machine.ts +70 -0
- package/src/steps/use-sheet-step.ts +29 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isInputInsideSheet } from "./keyboard-owner";
|
|
3
|
+
|
|
4
|
+
const sheet = { sheetTop: 400, containerBottom: 800 };
|
|
5
|
+
|
|
6
|
+
describe("isInputInsideSheet", () => {
|
|
7
|
+
test("an input wholly within the sheet's frame is the sheet's", () => {
|
|
8
|
+
expect(isInputInsideSheet({ ...sheet, inputY: 500, inputHeight: 44 })).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("an input above the sheet belongs to the screen behind it", () => {
|
|
12
|
+
expect(isInputInsideSheet({ ...sheet, inputY: 100, inputHeight: 44 })).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("an input ending exactly at the sheet's top edge is not inside", () => {
|
|
16
|
+
expect(isInputInsideSheet({ ...sheet, inputY: 356, inputHeight: 44 })).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("an input straddling the sheet's top edge counts — it was scrolled half out, not moved out", () => {
|
|
20
|
+
expect(isInputInsideSheet({ ...sheet, inputY: 380, inputHeight: 44 })).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("an input below the container is not inside", () => {
|
|
24
|
+
expect(isInputInsideSheet({ ...sheet, inputY: 900, inputHeight: 44 })).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a zero-height or unmeasured input never claims a keyboard", () => {
|
|
28
|
+
expect(isInputInsideSheet({ ...sheet, inputY: 500, inputHeight: 0 })).toBe(false);
|
|
29
|
+
expect(isInputInsideSheet({ ...sheet, inputY: -1, inputHeight: -1 })).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("before the sheet has measured nothing is inside it", () => {
|
|
33
|
+
expect(isInputInsideSheet({ sheetTop: -1, containerBottom: -1, inputY: 500, inputHeight: 44 })).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type InputInsideSheetInput = {
|
|
2
|
+
/** The focused input's top, in window coordinates. */
|
|
3
|
+
inputY: number;
|
|
4
|
+
inputHeight: number;
|
|
5
|
+
/** The sheet's top edge, in window coordinates — `containerTop + position`. */
|
|
6
|
+
sheetTop: number;
|
|
7
|
+
/** The bottom edge of the sheet's container, in window coordinates. */
|
|
8
|
+
containerBottom: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether a focused input lies inside the sheet, which is the geometry
|
|
13
|
+
* fallback for keyboard ownership under `keyboardScope: "inside"`.
|
|
14
|
+
*
|
|
15
|
+
* Any vertical overlap counts: an input the list scrolled half under the
|
|
16
|
+
* sheet's top edge is still the sheet's input. An unmeasured frame on either
|
|
17
|
+
* side claims nothing.
|
|
18
|
+
*/
|
|
19
|
+
export function isInputInsideSheet(input: InputInsideSheetInput): boolean {
|
|
20
|
+
"worklet";
|
|
21
|
+
if (!(input.inputHeight > 0) || input.inputY < 0) return false;
|
|
22
|
+
if (input.sheetTop < 0 || input.containerBottom <= 0) return false;
|
|
23
|
+
const inputBottom = input.inputY + input.inputHeight;
|
|
24
|
+
return inputBottom > input.sheetTop && input.inputY < input.containerBottom;
|
|
25
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveKeyboardOwner } from "./keyboard-ownership";
|
|
3
|
+
|
|
4
|
+
const idle = {
|
|
5
|
+
registeredFocused: false,
|
|
6
|
+
inputInside: false,
|
|
7
|
+
inputFocused: false,
|
|
8
|
+
progress: 0,
|
|
9
|
+
previousOwned: false,
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
describe("resolveKeyboardOwner", () => {
|
|
13
|
+
test("registered scope counts only a registered input", () => {
|
|
14
|
+
expect(resolveKeyboardOwner({ ...idle, scope: "registered", registeredFocused: true })).toBe(true);
|
|
15
|
+
expect(resolveKeyboardOwner({ ...idle, scope: "registered", inputInside: true })).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("inside scope counts a registered input or one whose frame lies in the sheet", () => {
|
|
19
|
+
expect(resolveKeyboardOwner({ ...idle, scope: "inside", registeredFocused: true })).toBe(true);
|
|
20
|
+
expect(resolveKeyboardOwner({ ...idle, scope: "inside", inputInside: true })).toBe(true);
|
|
21
|
+
expect(resolveKeyboardOwner({ ...idle, scope: "inside" })).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("always scope claims every keyboard, focused input or not", () => {
|
|
25
|
+
expect(resolveKeyboardOwner({ ...idle, scope: "always" })).toBe(true);
|
|
26
|
+
expect(resolveKeyboardOwner({ ...idle, scope: "always", progress: 1 })).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Risk 2: the focused-input layout can lag `progress` by a frame, and it
|
|
30
|
+
// goes null before a hide finishes. Once owned, the keyboard stays owned
|
|
31
|
+
// until it is fully down.
|
|
32
|
+
test("an owned keyboard stays owned for the rest of its transition after the candidate goes away", () => {
|
|
33
|
+
for (const progress of [1, 0.6, 0.01]) {
|
|
34
|
+
expect(resolveKeyboardOwner({ ...idle, scope: "inside", progress, previousOwned: true })).toBe(true);
|
|
35
|
+
expect(resolveKeyboardOwner({ ...idle, scope: "registered", progress, previousOwned: true })).toBe(true);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("ownership is released once the keyboard is down and nothing claims it", () => {
|
|
40
|
+
expect(resolveKeyboardOwner({ ...idle, scope: "inside", progress: 0, previousOwned: true })).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a candidate claims a keyboard that is already up — focus moved from the screen into the sheet", () => {
|
|
44
|
+
expect(resolveKeyboardOwner({ ...idle, scope: "inside", progress: 1, inputInside: true })).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("focus moving to a field outside the sheet releases the keyboard while it is still up", () => {
|
|
48
|
+
expect(
|
|
49
|
+
resolveKeyboardOwner({ ...idle, scope: "inside", progress: 1, previousOwned: true, inputFocused: true })
|
|
50
|
+
).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("under registered scope an unregistered field inside the sheet is someone else's too", () => {
|
|
54
|
+
expect(
|
|
55
|
+
resolveKeyboardOwner({
|
|
56
|
+
...idle,
|
|
57
|
+
scope: "registered",
|
|
58
|
+
progress: 1,
|
|
59
|
+
previousOwned: true,
|
|
60
|
+
inputFocused: true,
|
|
61
|
+
inputInside: true,
|
|
62
|
+
})
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a keyboard never owned is not claimed mid-transition by stickiness alone", () => {
|
|
67
|
+
expect(resolveKeyboardOwner({ ...idle, scope: "inside", progress: 0.5 })).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { KeyboardScope } from "../sheet.types";
|
|
2
|
+
|
|
3
|
+
export type KeyboardOwnerInput = {
|
|
4
|
+
scope: KeyboardScope;
|
|
5
|
+
/** A `useBottomSheetTextInput` field holds focus. */
|
|
6
|
+
registeredFocused: boolean;
|
|
7
|
+
/** The focused input's frame lies inside the sheet — `isInputInsideSheet`. */
|
|
8
|
+
inputInside: boolean;
|
|
9
|
+
/** keyboard-controller reports a focused input at all; `false` between a blur and the next focus. */
|
|
10
|
+
inputFocused: boolean;
|
|
11
|
+
/** keyboard-controller's `progress`, 0 closed through 1 open. */
|
|
12
|
+
progress: number;
|
|
13
|
+
/** What this resolved to last frame. */
|
|
14
|
+
previousOwned: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether the keyboard on screen is the sheet's to lift for.
|
|
19
|
+
*
|
|
20
|
+
* A candidate — a registered field, or under `inside` any focused input whose
|
|
21
|
+
* frame overlaps the sheet — claims it outright, up or down. A focused input
|
|
22
|
+
* that is not a candidate is someone else's, and the sheet lets go at once.
|
|
23
|
+
* With no focused input at all the answer is sticky: a keyboard the sheet
|
|
24
|
+
* owned stays owned until `progress` is back at zero. That covers the two
|
|
25
|
+
* ways the signal lies mid-transition — the focused-input layout can land a
|
|
26
|
+
* frame after `progress` starts moving, and it goes null the moment a field
|
|
27
|
+
* blurs, while the keyboard takes another quarter second to leave. Letting go
|
|
28
|
+
* early would drop the sheet by the lift and then have it chase the keyboard
|
|
29
|
+
* down.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveKeyboardOwner(input: KeyboardOwnerInput): boolean {
|
|
32
|
+
"worklet";
|
|
33
|
+
if (input.scope === "always") return true;
|
|
34
|
+
const candidate =
|
|
35
|
+
input.scope === "registered" ? input.registeredFocused : input.registeredFocused || input.inputInside;
|
|
36
|
+
if (candidate) return true;
|
|
37
|
+
if (input.inputFocused) return false;
|
|
38
|
+
return input.previousOwned && input.progress > 0;
|
|
39
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { shouldResetKeyboardAnimation } from "./keyboard-reset-guard";
|
|
3
|
+
|
|
4
|
+
describe("shouldResetKeyboardAnimation", () => {
|
|
5
|
+
test("leaves a genuinely focused keyboard alone", () => {
|
|
6
|
+
// Every open-looking state is legitimate while an input holds focus,
|
|
7
|
+
// including a mid-animation one.
|
|
8
|
+
for (const [progress, height] of [
|
|
9
|
+
[0, 0],
|
|
10
|
+
[0.5, -160],
|
|
11
|
+
[1, -336],
|
|
12
|
+
] as const) {
|
|
13
|
+
expect(shouldResetKeyboardAnimation({ hasFocusedInput: true, height, progress })).toBe(false);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("repairs an open state with nothing focused", () => {
|
|
18
|
+
expect(shouldResetKeyboardAnimation({ hasFocusedInput: false, height: -336, progress: 1 })).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("repairs a half-open state, which is what an interrupted dismiss leaves behind", () => {
|
|
22
|
+
expect(shouldResetKeyboardAnimation({ hasFocusedInput: false, height: -160, progress: 0.5 })).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("repairs either value on its own — the two are written separately", () => {
|
|
26
|
+
expect(shouldResetKeyboardAnimation({ hasFocusedInput: false, height: 0, progress: 1 })).toBe(true);
|
|
27
|
+
expect(shouldResetKeyboardAnimation({ hasFocusedInput: false, height: -336, progress: 0 })).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("is a no-op on an already-closed state, so mounting never schedules a pointless write", () => {
|
|
31
|
+
expect(shouldResetKeyboardAnimation({ hasFocusedInput: false, height: 0, progress: 0 })).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type KeyboardAnimationState = {
|
|
2
|
+
/** `KeyboardProvider`'s shared progress, 0 closed through 1 open. */
|
|
3
|
+
progress: number;
|
|
4
|
+
/** `KeyboardProvider`'s shared height, negative while open. */
|
|
5
|
+
height: number;
|
|
6
|
+
/** Whether React Native's focus registry holds a focused `TextInput`. */
|
|
7
|
+
hasFocusedInput: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether `KeyboardProvider`'s shared animation values are provably stale.
|
|
12
|
+
*
|
|
13
|
+
* A port of `@delacour/react-native-ui`'s `shouldResetKeyboardAnimation`, so
|
|
14
|
+
* the engine's `useKeyboardAnimationGuard` and the wrapper's `Screen.Footer`
|
|
15
|
+
* guard agree on what stale means. keyboard-controller can be left holding an
|
|
16
|
+
* open keyboard after a dismiss its notifications never reported — a modal
|
|
17
|
+
* closing over a focused input is the classic — and the sheet would then lift
|
|
18
|
+
* for a keyboard that is not there.
|
|
19
|
+
*
|
|
20
|
+
* `hasFocusedInput` MUST come from React Native's own focus registry
|
|
21
|
+
* (`TextInput.State.currentlyFocusedInput()`), never from
|
|
22
|
+
* `KeyboardController.isVisible()`. The latter is fed by the very
|
|
23
|
+
* `keyboardWillShow` / `keyboardDidHide` notifications that go missing in the
|
|
24
|
+
* failure this guard exists for, so in the broken case it reads a stale `true`
|
|
25
|
+
* and would veto the repair. RN's registry is driven by the input's own native
|
|
26
|
+
* focus and blur — a genuinely independent signal.
|
|
27
|
+
*/
|
|
28
|
+
export function shouldResetKeyboardAnimation(state: KeyboardAnimationState): boolean {
|
|
29
|
+
"worklet";
|
|
30
|
+
// Never fight a real, focused keyboard.
|
|
31
|
+
if (state.hasFocusedInput) return false;
|
|
32
|
+
return state.progress !== 0 || state.height !== 0;
|
|
33
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { keyboardStep } from "./keyboard-step";
|
|
3
|
+
|
|
4
|
+
const base = {
|
|
5
|
+
behavior: "extend",
|
|
6
|
+
blurBehavior: "restore",
|
|
7
|
+
progress: 0,
|
|
8
|
+
previousProgress: 0,
|
|
9
|
+
owned: true,
|
|
10
|
+
previousOwned: true,
|
|
11
|
+
applied: false,
|
|
12
|
+
sheetOpen: true,
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
describe("keyboardStep", () => {
|
|
16
|
+
test("applies on the first frame the keyboard rises for an owned input", () => {
|
|
17
|
+
expect(keyboardStep({ ...base, progress: 0.1 })).toBe("apply");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("applies once — a rising keyboard already applied for is left alone", () => {
|
|
21
|
+
expect(keyboardStep({ ...base, progress: 0.5, previousProgress: 0.1, applied: true })).toBe(null);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("applies when ownership arrives a frame late, mid-rise", () => {
|
|
25
|
+
expect(keyboardStep({ ...base, progress: 0.4, previousProgress: 0.4, previousOwned: false })).toBe("apply");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("applies when focus moves into the sheet under a keyboard that is already up", () => {
|
|
29
|
+
expect(keyboardStep({ ...base, progress: 1, previousProgress: 1, previousOwned: false })).toBe("apply");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("restores when the keyboard starts down", () => {
|
|
33
|
+
expect(keyboardStep({ ...base, progress: 0.9, previousProgress: 1, applied: true })).toBe("restore");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("restores when focus leaves the sheet under a keyboard that stays up", () => {
|
|
37
|
+
expect(keyboardStep({ ...base, progress: 1, previousProgress: 1, owned: false, applied: true })).toBe("restore");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("an interactive dismiss that turns back re-applies", () => {
|
|
41
|
+
expect(keyboardStep({ ...base, progress: 0.6, previousProgress: 0.4, applied: false })).toBe("apply");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("blur behaviour none never restores, and clears the record so the next rise applies again", () => {
|
|
45
|
+
expect(keyboardStep({ ...base, blurBehavior: "none", progress: 0.9, previousProgress: 1, applied: true })).toBe(
|
|
46
|
+
"release"
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("interactive and none never apply — the lift is a derivation, not a snap", () => {
|
|
51
|
+
expect(keyboardStep({ ...base, behavior: "interactive", progress: 0.1 })).toBe(null);
|
|
52
|
+
expect(keyboardStep({ ...base, behavior: "none", progress: 0.1 })).toBe(null);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a keyboard nobody owns does nothing", () => {
|
|
56
|
+
expect(keyboardStep({ ...base, progress: 0.1, owned: false, previousOwned: false })).toBe(null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a closed sheet never snaps for a keyboard", () => {
|
|
60
|
+
expect(keyboardStep({ ...base, progress: 0.1, sheetOpen: false })).toBe(null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a keyboard that falls without anything applied is nothing to restore", () => {
|
|
64
|
+
expect(keyboardStep({ ...base, progress: 0.5, previousProgress: 0.6 })).toBe(null);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { KeyboardBehavior, KeyboardBlurBehavior } from "../sheet.types";
|
|
2
|
+
|
|
3
|
+
export type KeyboardStepInput = {
|
|
4
|
+
behavior: KeyboardBehavior;
|
|
5
|
+
blurBehavior: KeyboardBlurBehavior;
|
|
6
|
+
/** keyboard-controller's `progress` this frame and last. */
|
|
7
|
+
progress: number;
|
|
8
|
+
previousProgress: number;
|
|
9
|
+
/** `resolveKeyboardOwner` this frame and last. */
|
|
10
|
+
owned: boolean;
|
|
11
|
+
previousOwned: boolean;
|
|
12
|
+
/** Whether the behaviour's snap has run for the keyboard currently up. */
|
|
13
|
+
applied: boolean;
|
|
14
|
+
/** The sheet is above its closed height, settled or on the way. */
|
|
15
|
+
sheetOpen: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What the behaviour reaction does this frame.
|
|
20
|
+
*
|
|
21
|
+
* `apply` — snap for the keyboard: `extend` to the highest detent,
|
|
22
|
+
* `fillParent` to the container. `restore` — the keyboard is leaving or focus
|
|
23
|
+
* left the sheet, and `keyboardBlurBehavior: "restore"` sends the sheet back
|
|
24
|
+
* to the detent it held before. `release` — the same moment under
|
|
25
|
+
* `blurBehavior: "none"`: nothing moves, but the record clears so the next
|
|
26
|
+
* rise applies again. `null` — nothing.
|
|
27
|
+
*/
|
|
28
|
+
export type KeyboardStep = "apply" | "restore" | "release" | null;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The show/hide decision, as a pure function of two consecutive samples.
|
|
32
|
+
*
|
|
33
|
+
* `interactive` and `none` never snap — one lifts by derivation, the other
|
|
34
|
+
* does nothing — so they always return `null`. For the two that do, a rise
|
|
35
|
+
* (`progress` climbing, or ownership arriving under a keyboard already up)
|
|
36
|
+
* applies once, and a fall (`progress` dropping, or ownership leaving)
|
|
37
|
+
* restores once; an interactive dismiss that turns back mid-way applies
|
|
38
|
+
* again, because the fall already restored.
|
|
39
|
+
*/
|
|
40
|
+
export function keyboardStep(input: KeyboardStepInput): KeyboardStep {
|
|
41
|
+
"worklet";
|
|
42
|
+
if (input.behavior !== "extend" && input.behavior !== "fillParent") return null;
|
|
43
|
+
|
|
44
|
+
const rising = input.progress > input.previousProgress || (input.owned && !input.previousOwned);
|
|
45
|
+
const falling = input.progress < input.previousProgress || (!input.owned && input.previousOwned);
|
|
46
|
+
|
|
47
|
+
if (input.applied) {
|
|
48
|
+
if (!falling) return null;
|
|
49
|
+
return input.blurBehavior === "restore" ? "restore" : "release";
|
|
50
|
+
}
|
|
51
|
+
if (!input.owned || !input.sheetOpen || input.progress <= 0) return null;
|
|
52
|
+
return rising ? "apply" : null;
|
|
53
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defineSheetMachine } from "./define-sheet-machine";
|
|
3
|
+
import type { SheetMachineSnapshot } from "./machine.types";
|
|
4
|
+
|
|
5
|
+
type Step = "details" | "confirm" | "success";
|
|
6
|
+
type Context = { name: string; agreed: boolean };
|
|
7
|
+
type Event =
|
|
8
|
+
| { type: "NEXT" }
|
|
9
|
+
| { type: "BACK" }
|
|
10
|
+
| { type: "EDIT"; field: keyof Context; value: Context[keyof Context] }
|
|
11
|
+
| { type: "RESET" };
|
|
12
|
+
|
|
13
|
+
const machine = defineSheetMachine<Step, Context, Event>({
|
|
14
|
+
id: "checkout",
|
|
15
|
+
initial: "details",
|
|
16
|
+
context: { name: "", agreed: false },
|
|
17
|
+
states: {
|
|
18
|
+
details: {
|
|
19
|
+
snapPoints: ["50%"],
|
|
20
|
+
on: {
|
|
21
|
+
NEXT: { target: "confirm", guard: (context) => context.name.length > 0 },
|
|
22
|
+
EDIT: {
|
|
23
|
+
target: "details",
|
|
24
|
+
assign: (context, event) => ({ ...context, [event.field]: event.value }),
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
confirm: {
|
|
29
|
+
on: {
|
|
30
|
+
NEXT: { target: "success", guard: (context) => context.agreed },
|
|
31
|
+
BACK: "details",
|
|
32
|
+
EDIT: {
|
|
33
|
+
target: "confirm",
|
|
34
|
+
assign: (context, event) => ({ ...context, [event.field]: event.value }),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
success: {
|
|
39
|
+
snapPoints: [240],
|
|
40
|
+
dismissible: false,
|
|
41
|
+
on: { RESET: { target: "details", assign: () => ({ name: "", agreed: false }) } },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const filled: SheetMachineSnapshot<Step, Context> = {
|
|
47
|
+
value: "details",
|
|
48
|
+
context: { name: "Ada", agreed: false },
|
|
49
|
+
history: [],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
describe("defineSheetMachine", () => {
|
|
53
|
+
test("initial snapshot is the configured initial state, context and an empty history", () => {
|
|
54
|
+
expect(machine.initial).toEqual({ value: "details", context: { name: "", agreed: false }, history: [] });
|
|
55
|
+
expect(machine.config.id).toBe("checkout");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("steps are the states in declaration order", () => {
|
|
59
|
+
expect(machine.steps).toEqual(["details", "confirm", "success"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("nodeOf exposes per-step snapPoints and dismissible", () => {
|
|
63
|
+
expect(machine.nodeOf("details").snapPoints).toEqual(["50%"]);
|
|
64
|
+
expect(machine.nodeOf("details").dismissible).toBeUndefined();
|
|
65
|
+
expect(machine.nodeOf("success").snapPoints).toEqual([240]);
|
|
66
|
+
expect(machine.nodeOf("success").dismissible).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("transition", () => {
|
|
70
|
+
test("succeeds on a bare string target and appends the origin to history", () => {
|
|
71
|
+
const result = machine.transition({ ...filled, value: "confirm", history: ["details"] }, { type: "BACK" });
|
|
72
|
+
expect(result).toEqual({
|
|
73
|
+
success: true,
|
|
74
|
+
data: { value: "details", context: filled.context, history: ["details", "confirm"] },
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("fails with no-transition when the state does not handle the event", () => {
|
|
79
|
+
expect(machine.transition(machine.initial, { type: "BACK" })).toEqual({
|
|
80
|
+
success: false,
|
|
81
|
+
error: { code: "no-transition", from: "details", event: "BACK" },
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("fails with guard-rejected when the guard says no", () => {
|
|
86
|
+
expect(machine.transition(machine.initial, { type: "NEXT" })).toEqual({
|
|
87
|
+
success: false,
|
|
88
|
+
error: { code: "guard-rejected", from: "details", to: "confirm", event: "NEXT" },
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("passes a guard once context allows it", () => {
|
|
93
|
+
const result = machine.transition(filled, { type: "NEXT" });
|
|
94
|
+
expect(result.success).toBe(true);
|
|
95
|
+
if (result.success) expect(result.data.value).toBe("confirm");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a self-target with assign keeps value, updates context and does not push history", () => {
|
|
99
|
+
const result = machine.transition(machine.initial, { type: "EDIT", field: "name", value: "Ada" });
|
|
100
|
+
expect(result).toEqual({
|
|
101
|
+
success: true,
|
|
102
|
+
data: { value: "details", context: { name: "Ada", agreed: false }, history: [] },
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("assign never mutates the incoming snapshot", () => {
|
|
107
|
+
const before = { value: "details" as const, context: { name: "", agreed: false }, history: [] as Step[] };
|
|
108
|
+
const original = structuredClone(before);
|
|
109
|
+
machine.transition(before, { type: "EDIT", field: "agreed", value: true });
|
|
110
|
+
expect(before).toEqual(original);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("history is not shared with the incoming snapshot", () => {
|
|
114
|
+
const result = machine.transition(filled, { type: "NEXT" });
|
|
115
|
+
if (!result.success) throw new Error("expected success");
|
|
116
|
+
expect(result.data.history).not.toBe(filled.history);
|
|
117
|
+
expect(filled.history).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("assign runs before the target is entered so a reset lands with fresh context", () => {
|
|
121
|
+
const result = machine.transition(
|
|
122
|
+
{ value: "success", context: { name: "Ada", agreed: true }, history: ["details", "confirm"] },
|
|
123
|
+
{ type: "RESET" }
|
|
124
|
+
);
|
|
125
|
+
expect(result).toEqual({
|
|
126
|
+
success: true,
|
|
127
|
+
data: { value: "details", context: { name: "", agreed: false }, history: ["details", "confirm", "success"] },
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("can", () => {
|
|
133
|
+
test("is true only when transition would succeed", () => {
|
|
134
|
+
expect(machine.can(machine.initial, { type: "NEXT" })).toBe(false);
|
|
135
|
+
expect(machine.can(machine.initial, { type: "BACK" })).toBe(false);
|
|
136
|
+
expect(machine.can(filled, { type: "NEXT" })).toBe(true);
|
|
137
|
+
expect(machine.can(machine.initial, { type: "EDIT", field: "name", value: "x" })).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("directionOf", () => {
|
|
142
|
+
test("follows declaration order when the target declares no direction", () => {
|
|
143
|
+
expect(machine.directionOf("details", "confirm")).toBe("forward");
|
|
144
|
+
expect(machine.directionOf("confirm", "details")).toBe("back");
|
|
145
|
+
expect(machine.directionOf("details", "success")).toBe("forward");
|
|
146
|
+
expect(machine.directionOf("details", "details")).toBe("forward");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("an explicit direction on the target node wins over index order", () => {
|
|
150
|
+
const explicit = defineSheetMachine<"a" | "b" | "c", Record<string, never>, { type: "GO" }>({
|
|
151
|
+
initial: "a",
|
|
152
|
+
context: {},
|
|
153
|
+
states: {
|
|
154
|
+
a: { direction: "back", on: { GO: "b" } },
|
|
155
|
+
b: { on: { GO: "c" } },
|
|
156
|
+
c: { direction: "back", on: { GO: "a" } },
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
expect(explicit.directionOf("b", "c")).toBe("back");
|
|
160
|
+
expect(explicit.directionOf("c", "a")).toBe("back");
|
|
161
|
+
expect(explicit.directionOf("a", "b")).toBe("forward");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("a full details → confirm → success run", () => {
|
|
166
|
+
let snapshot = machine.initial;
|
|
167
|
+
const send = (event: Event): void => {
|
|
168
|
+
const result = machine.transition(snapshot, event);
|
|
169
|
+
if (!result.success) throw new Error(`${result.error.code} on ${event.type} from ${result.error.from}`);
|
|
170
|
+
snapshot = result.data;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
expect(machine.can(snapshot, { type: "NEXT" })).toBe(false);
|
|
174
|
+
send({ type: "EDIT", field: "name", value: "Ada" });
|
|
175
|
+
send({ type: "NEXT" });
|
|
176
|
+
expect(snapshot.value).toBe("confirm");
|
|
177
|
+
expect(machine.can(snapshot, { type: "NEXT" })).toBe(false);
|
|
178
|
+
send({ type: "EDIT", field: "agreed", value: true });
|
|
179
|
+
send({ type: "NEXT" });
|
|
180
|
+
expect(snapshot).toEqual({
|
|
181
|
+
value: "success",
|
|
182
|
+
context: { name: "Ada", agreed: true },
|
|
183
|
+
history: ["details", "confirm"],
|
|
184
|
+
});
|
|
185
|
+
expect(machine.nodeOf(snapshot.value).dismissible).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A typed multi-step machine for a sheet body, with no XState in the graph.
|
|
3
|
+
*
|
|
4
|
+
* `defineSheetMachine` reads a config once and hands back the functions
|
|
5
|
+
* `BottomSheet.Steps` and `useSheetMachine` need: `transition` and `can` for
|
|
6
|
+
* the reducer, `steps` and `directionOf` for the animation, `nodeOf` for the
|
|
7
|
+
* per-step `snapPoints` and `dismissible` overrides. Any object with the same
|
|
8
|
+
* shape — an XState adapter, say — can stand in for it.
|
|
9
|
+
*/
|
|
10
|
+
import type { SheetEvent, SheetMachine, SheetMachineConfig, SheetStepDirection } from "./machine.types";
|
|
11
|
+
import { transition } from "./transition";
|
|
12
|
+
|
|
13
|
+
export function defineSheetMachine<S extends string, C, E extends SheetEvent>(
|
|
14
|
+
config: SheetMachineConfig<S, C, E>
|
|
15
|
+
): SheetMachine<S, C, E> {
|
|
16
|
+
const { states } = config;
|
|
17
|
+
const steps = Object.keys(states) as S[];
|
|
18
|
+
const indexOf = new Map(steps.map((step, index) => [step, index]));
|
|
19
|
+
|
|
20
|
+
const directionOf = (from: S, to: S): SheetStepDirection => {
|
|
21
|
+
const explicit = states[to].direction;
|
|
22
|
+
if (explicit !== undefined) return explicit;
|
|
23
|
+
return (indexOf.get(to) ?? 0) < (indexOf.get(from) ?? 0) ? "back" : "forward";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
config,
|
|
28
|
+
initial: { value: config.initial, context: config.context, history: [] },
|
|
29
|
+
transition: (snapshot, event) => transition(states, snapshot, event),
|
|
30
|
+
can: (snapshot, event) => transition(states, snapshot, event).success,
|
|
31
|
+
steps,
|
|
32
|
+
directionOf,
|
|
33
|
+
nodeOf: (step) => states[step],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { defineSheetMachine } from "./define-sheet-machine";
|
|
2
|
+
export type {
|
|
3
|
+
SheetEvent,
|
|
4
|
+
SheetMachine,
|
|
5
|
+
SheetMachineConfig,
|
|
6
|
+
SheetMachineSnapshot,
|
|
7
|
+
SheetStateNode,
|
|
8
|
+
SheetStates,
|
|
9
|
+
SheetStepDirection,
|
|
10
|
+
SheetTransitionError,
|
|
11
|
+
SheetTransitionObject,
|
|
12
|
+
SheetTransitionResult,
|
|
13
|
+
SheetTransitionTarget,
|
|
14
|
+
} from "./machine.types";
|
|
15
|
+
export {
|
|
16
|
+
type SheetStepFrame,
|
|
17
|
+
type SheetStepOverride,
|
|
18
|
+
type SheetStepRole,
|
|
19
|
+
type SheetStepTransition,
|
|
20
|
+
stepFrame,
|
|
21
|
+
stepOverride,
|
|
22
|
+
} from "./step-transition";
|
|
23
|
+
export { resolveTarget, transition } from "./transition";
|