@delacour/react-native-bottom-sheet 0.1.0-alpha.0 → 0.1.0-alpha.20261006140348
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 +2 -2
- package/package.json +1 -1
- package/src/animation/AGENTS.md +1 -1
- package/src/animation/animation.types.ts +1 -1
- package/src/animation/use-animate-to.ts +4 -4
- package/src/animation/use-settle-callbacks.ts +3 -3
- package/src/components/AGENTS.md +2 -2
- package/src/components/bottom-sheet-background.tsx +1 -1
- package/src/components/bottom-sheet-content.tsx +3 -3
- package/src/components/bottom-sheet-footer.tsx +3 -3
- package/src/components/bottom-sheet-handle.tsx +5 -5
- package/src/components/bottom-sheet-overlay.tsx +1 -1
- package/src/components/bottom-sheet.context.tsx +1 -1
- package/src/components/bottom-sheet.tsx +13 -11
- package/src/components/bottom-sheet.types.ts +15 -15
- package/src/core/AGENTS.md +17 -17
- package/src/core/backdrop/backdrop-opacity.test.ts +2 -2
- package/src/core/backdrop/backdrop-opacity.ts +1 -1
- package/src/core/footer/body-inset.test.ts +3 -3
- package/src/core/footer/body-inset.ts +3 -3
- package/src/core/geometry/position.ts +1 -1
- package/src/core/geometry/surface-height.test.ts +5 -5
- package/src/core/geometry/surface-height.ts +7 -7
- package/src/core/haptic/crossed-snap-point.test.ts +43 -0
- package/src/core/haptic/crossed-snap-point.ts +33 -0
- package/src/core/index.ts +14 -9
- package/src/core/intent/layout-ready.ts +1 -1
- package/src/core/intent/resolve-intent.test.ts +9 -9
- package/src/core/intent/resolve-intent.ts +10 -10
- package/src/core/keyboard/container-layout-guard.ts +1 -1
- package/src/core/keyboard/keyboard-step.ts +2 -2
- package/src/core/machine/machine.types.ts +2 -2
- package/src/core/machine/step-transition.ts +2 -2
- package/src/core/scroll/scroll-lock.test.ts +2 -2
- package/src/core/scroll/scroll-lock.ts +2 -2
- package/src/core/scroll/scroll-pan.test.ts +14 -14
- package/src/core/scroll/scroll-pan.ts +14 -14
- package/src/core/sheet.types.ts +5 -5
- package/src/core/snap-points/dynamic-snap-point.test.ts +44 -0
- package/src/core/{detents/dynamic-detent.ts → snap-points/dynamic-snap-point.ts} +5 -5
- package/src/core/snap-points/index-for-height.test.ts +65 -0
- package/src/core/{detents → snap-points}/index-for-height.ts +11 -11
- package/src/core/snap-points/normalize-snap-points.test.ts +72 -0
- package/src/core/{detents/normalize-detents.ts → snap-points/normalize-snap-points.ts} +9 -9
- package/src/core/{detents → snap-points}/select-snap-height.test.ts +10 -8
- package/src/core/{detents → snap-points}/select-snap-height.ts +5 -5
- package/src/core/snap-points/sheet-state.test.ts +42 -0
- package/src/core/{detents → snap-points}/sheet-state.ts +6 -6
- package/src/gesture/AGENTS.md +11 -11
- package/src/gesture/gesture.types.ts +3 -3
- package/src/gesture/use-scroll-lock.ts +3 -3
- package/src/gesture/use-sheet-pan.ts +43 -32
- package/src/keyboard/AGENTS.md +2 -2
- package/src/keyboard/use-sheet-keyboard.ts +6 -6
- package/src/layout/AGENTS.md +5 -5
- package/src/portal/AGENTS.md +14 -5
- package/src/portal/bottom-sheet-provider.tsx +19 -7
- package/src/scrollable/AGENTS.md +5 -5
- package/src/scrollable/bottom-sheet-scroll-view.tsx +1 -1
- package/src/scrollable/create-bottom-sheet-scrollable.tsx +3 -3
- package/src/state/AGENTS.md +4 -4
- package/src/state/state.types.ts +11 -11
- package/src/state/use-sheet-geometry.ts +11 -11
- package/src/state/use-sheet-intents.ts +12 -9
- package/src/state/use-sheet-state.ts +9 -9
- package/src/state/{use-detents.ts → use-snap-points.ts} +13 -13
- package/src/steps/AGENTS.md +7 -7
- package/src/steps/bottom-sheet-steps.tsx +9 -9
- package/src/core/detents/dynamic-detent.test.ts +0 -40
- package/src/core/detents/index-for-height.test.ts +0 -65
- package/src/core/detents/normalize-detents.test.ts +0 -72
- package/src/core/detents/sheet-state.test.ts +0 -42
- package/src/core/haptic/crossed-detent.test.ts +0 -43
- package/src/core/haptic/crossed-detent.ts +0 -33
- /package/src/core/{detents → snap-points}/over-drag.test.ts +0 -0
- /package/src/core/{detents → snap-points}/over-drag.ts +0 -0
|
@@ -19,7 +19,7 @@ export type SheetIntentDispatch = (request: IntentRequest) => void;
|
|
|
19
19
|
* A JS write to a shared value is queued to the UI thread, so reading
|
|
20
20
|
* `state.intent.value` straight after a `dispatch` still returns what was
|
|
21
21
|
* there before — the root once read `null` that way, queued an `open` on top
|
|
22
|
-
* of a ref's `expand`, and the sheet landed on the wrong
|
|
22
|
+
* of a ref's `expand`, and the sheet landed on the wrong snap point. `clear`
|
|
23
23
|
* writes `null` in order after whatever was queued, which is what a controlled
|
|
24
24
|
* `isOpen: false` needs to cancel an open still waiting on layout.
|
|
25
25
|
*/
|
|
@@ -43,7 +43,7 @@ export type SheetIntents = {
|
|
|
43
43
|
* jump rather than an animation when `animateOnMount` is off. `mountPending`
|
|
44
44
|
* is the flag, and it clears on that first resolution whatever kind it was.
|
|
45
45
|
*
|
|
46
|
-
* A second reaction handles the
|
|
46
|
+
* A second reaction handles the snap points moving under a settled sheet — dynamic
|
|
47
47
|
* content growing, a `snapPoints` change — by animating to the same index in
|
|
48
48
|
* the new list, and a container resize by jumping there instead. Neither runs
|
|
49
49
|
* while a gesture or an animation owns `base`.
|
|
@@ -84,14 +84,14 @@ export function useSheetIntents(
|
|
|
84
84
|
// a close during the open animation has to see an open sheet.
|
|
85
85
|
const effectiveIndex =
|
|
86
86
|
state.animStatus.value === ANIM_STATUS.RUNNING
|
|
87
|
-
? Math.round(indexForHeight(state.animTarget.value, geometry.
|
|
87
|
+
? Math.round(indexForHeight(state.animTarget.value, geometry.snapPoints.value, geometry.closedHeight.value))
|
|
88
88
|
: state.currentIndex.value;
|
|
89
89
|
const resolution = resolveIntent(
|
|
90
90
|
{
|
|
91
91
|
currentIndex: effectiveIndex,
|
|
92
92
|
base: state.base.value,
|
|
93
93
|
layoutReady: current.ready,
|
|
94
|
-
|
|
94
|
+
snapPoints: geometry.snapPoints.value,
|
|
95
95
|
closedHeight: geometry.closedHeight.value,
|
|
96
96
|
maxHeight: geometry.maxHeight.value,
|
|
97
97
|
initialIndex: state.config.value.initialIndex,
|
|
@@ -117,18 +117,21 @@ export function useSheetIntents(
|
|
|
117
117
|
);
|
|
118
118
|
|
|
119
119
|
useAnimatedReaction(
|
|
120
|
-
() => ({
|
|
120
|
+
() => ({ snapPoints: geometry.snapPoints.value.join(","), container: state.containerHeight.value }),
|
|
121
121
|
(current, previous) => {
|
|
122
|
-
if (
|
|
122
|
+
if (
|
|
123
|
+
previous === null ||
|
|
124
|
+
(current.snapPoints === previous.snapPoints && current.container === previous.container)
|
|
125
|
+
) {
|
|
123
126
|
return;
|
|
124
127
|
}
|
|
125
128
|
const busy = state.animStatus.value !== ANIM_STATUS.IDLE || state.gestureSource.value !== GESTURE_SOURCE.NONE;
|
|
126
129
|
const index = state.currentIndex.value;
|
|
127
130
|
if (busy || index < 0) return;
|
|
128
131
|
|
|
129
|
-
const
|
|
130
|
-
if (
|
|
131
|
-
const target = heightForIndex(Math.min(index,
|
|
132
|
+
const snapPoints = geometry.snapPoints.value;
|
|
133
|
+
if (snapPoints.length === 0) return;
|
|
134
|
+
const target = heightForIndex(Math.min(index, snapPoints.length - 1), snapPoints, geometry.closedHeight.value);
|
|
132
135
|
if (Math.abs(target - state.base.value) < 0.5) return;
|
|
133
136
|
|
|
134
137
|
if (current.container !== previous.container) {
|
|
@@ -3,25 +3,25 @@ import { useSharedValue } from "react-native-reanimated";
|
|
|
3
3
|
import {
|
|
4
4
|
type AnimationSource,
|
|
5
5
|
CLOSED_INDEX,
|
|
6
|
-
type DetentSpec,
|
|
7
6
|
GESTURE_SOURCE,
|
|
8
7
|
SCROLLABLE_TYPE,
|
|
9
8
|
type ScrollableType,
|
|
10
9
|
type SheetIntent,
|
|
10
|
+
type SnapPointSpec,
|
|
11
11
|
UNMEASURED,
|
|
12
12
|
} from "../core";
|
|
13
13
|
import { ANIM_STATUS, type AnimStatus, type SheetSharedState, type SheetWorkletConfig } from "./state.types";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Allocates the raw shared values, once per sheet, and keeps `
|
|
16
|
+
* Allocates the raw shared values, once per sheet, and keeps `snapPointSpec` and
|
|
17
17
|
* `config` in step with the props.
|
|
18
18
|
*
|
|
19
|
-
* `
|
|
19
|
+
* `snapPointSpec` is written when its **serialisation** changes, not its identity,
|
|
20
20
|
* so a `snapPoints={["40%", "85%"]}` literal in a render body costs nothing —
|
|
21
21
|
* the array identity churn that made the library this replaces re-derive on
|
|
22
22
|
* every render is simply not observed.
|
|
23
23
|
*/
|
|
24
|
-
export function useSheetState(snapPoints: readonly
|
|
24
|
+
export function useSheetState(snapPoints: readonly SnapPointSpec[], config: SheetWorkletConfig): SheetSharedState {
|
|
25
25
|
const containerHeight = useSharedValue(UNMEASURED);
|
|
26
26
|
const containerWidth = useSharedValue(UNMEASURED);
|
|
27
27
|
const containerBottomOffset = useSharedValue(0);
|
|
@@ -42,14 +42,14 @@ export function useSheetState(snapPoints: readonly DetentSpec[], config: SheetWo
|
|
|
42
42
|
const keyboardHeight = useSharedValue(0);
|
|
43
43
|
const intent = useSharedValue<SheetIntent | null>(null);
|
|
44
44
|
const mountPending = useSharedValue(true);
|
|
45
|
-
const
|
|
45
|
+
const snapPointSpec = useSharedValue<readonly SnapPointSpec[]>(snapPoints);
|
|
46
46
|
const configValue = useSharedValue<SheetWorkletConfig>(config);
|
|
47
47
|
|
|
48
48
|
const specKey = snapPoints.join("|");
|
|
49
49
|
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the serialisation on purpose; the array is only read when it changes
|
|
50
50
|
useEffect(() => {
|
|
51
|
-
|
|
52
|
-
}, [specKey,
|
|
51
|
+
snapPointSpec.value = snapPoints;
|
|
52
|
+
}, [specKey, snapPointSpec]);
|
|
53
53
|
|
|
54
54
|
const {
|
|
55
55
|
dynamicSizing,
|
|
@@ -126,7 +126,7 @@ export function useSheetState(snapPoints: readonly DetentSpec[], config: SheetWo
|
|
|
126
126
|
keyboardHeight,
|
|
127
127
|
intent,
|
|
128
128
|
mountPending,
|
|
129
|
-
|
|
129
|
+
snapPointSpec,
|
|
130
130
|
config: configValue,
|
|
131
131
|
}),
|
|
132
132
|
[
|
|
@@ -150,7 +150,7 @@ export function useSheetState(snapPoints: readonly DetentSpec[], config: SheetWo
|
|
|
150
150
|
keyboardHeight,
|
|
151
151
|
intent,
|
|
152
152
|
mountPending,
|
|
153
|
-
|
|
153
|
+
snapPointSpec,
|
|
154
154
|
configValue,
|
|
155
155
|
]
|
|
156
156
|
);
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { type SharedValue, useDerivedValue } from "react-native-reanimated";
|
|
2
|
-
import {
|
|
2
|
+
import { dynamicSnapPoint, normalizeSnapPoints } from "../core";
|
|
3
3
|
import type { SheetSharedState } from "./state.types";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* The ascending, unique
|
|
6
|
+
* The ascending, unique snap points, recomputed on the UI thread whenever the spec,
|
|
7
7
|
* the container or a measurement changes.
|
|
8
8
|
*
|
|
9
|
-
* The dynamic
|
|
10
|
-
* both reported — before that a sheet sized to its content has no
|
|
9
|
+
* The dynamic snap point joins the list only once the handle and the content have
|
|
10
|
+
* both reported — before that a sheet sized to its content has no snap point at
|
|
11
11
|
* all, and `layoutReady` keeps an open intent waiting rather than animating to
|
|
12
|
-
* a guess. An explicit
|
|
12
|
+
* a guess. An explicit snap point equal to the dynamic one is one snap point, not two.
|
|
13
13
|
*/
|
|
14
|
-
export function
|
|
14
|
+
export function useSnapPoints(
|
|
15
15
|
state: SheetSharedState,
|
|
16
16
|
maxHeight: SharedValue<number>,
|
|
17
17
|
band: SharedValue<number>
|
|
@@ -19,13 +19,13 @@ export function useDetents(
|
|
|
19
19
|
return useDerivedValue<readonly number[]>(() => {
|
|
20
20
|
const available = maxHeight.value;
|
|
21
21
|
const config = state.config.value;
|
|
22
|
-
const
|
|
22
|
+
const snapPoints = normalizeSnapPoints(state.snapPointSpec.value, available);
|
|
23
23
|
|
|
24
|
-
if (!config.dynamicSizing || available <= 0) return
|
|
25
|
-
if (state.handleHeight.value < 0 || state.contentHeight.value < 0) return
|
|
26
|
-
if (config.hasFooter && state.footerContentHeight.value < 0) return
|
|
24
|
+
if (!config.dynamicSizing || available <= 0) return snapPoints;
|
|
25
|
+
if (state.handleHeight.value < 0 || state.contentHeight.value < 0) return snapPoints;
|
|
26
|
+
if (config.hasFooter && state.footerContentHeight.value < 0) return snapPoints;
|
|
27
27
|
|
|
28
|
-
const dynamic =
|
|
28
|
+
const dynamic = dynamicSnapPoint({
|
|
29
29
|
handleHeight: state.handleHeight.value,
|
|
30
30
|
contentHeight: state.contentHeight.value,
|
|
31
31
|
footerContentHeight: state.footerContentHeight.value,
|
|
@@ -34,9 +34,9 @@ export function useDetents(
|
|
|
34
34
|
available,
|
|
35
35
|
maxDynamicContentSize: config.maxDynamicContentSize,
|
|
36
36
|
});
|
|
37
|
-
if (
|
|
37
|
+
if (snapPoints.indexOf(dynamic) !== -1) return snapPoints;
|
|
38
38
|
|
|
39
|
-
const merged = [...
|
|
39
|
+
const merged = [...snapPoints, dynamic];
|
|
40
40
|
merged.sort((a, b) => a - b);
|
|
41
41
|
return merged;
|
|
42
42
|
});
|
package/src/steps/AGENTS.md
CHANGED
|
@@ -60,23 +60,23 @@ function Actions() {
|
|
|
60
60
|
height is a shared value. Only the current step and, during a change, the
|
|
61
61
|
one leaving are rendered; each is absolutely positioned across the top of
|
|
62
62
|
the stack, measured by its `onLayout` while current.
|
|
63
|
-
- **The height glides because the
|
|
63
|
+
- **The height glides because the snap point moves once.** When the current step
|
|
64
64
|
measures, `Steps` writes the number to the sheet's `contentHeight` straight
|
|
65
|
-
away and springs the stack's height to it. The dynamic
|
|
66
|
-
the UI thread, the root's
|
|
65
|
+
away and springs the stack's height to it. The dynamic snap point re-derives on
|
|
66
|
+
the UI thread, the root's snap-point-change reaction (`state/use-sheet-intents.ts`)
|
|
67
67
|
animates `base` to the same index in the new list with the sheet's own
|
|
68
68
|
animation, and — because the stack's spring is resolved from the same
|
|
69
69
|
config — the panel's top edge and the body's bottom edge move together. A
|
|
70
|
-
sheet resting on an explicit
|
|
71
|
-
busy when the
|
|
72
|
-
completion nudges `base` onto its
|
|
70
|
+
sheet resting on an explicit snap point does not move at all. If the sheet was
|
|
71
|
+
busy when the snap point moved (a step change mid-open), the stack animation's
|
|
72
|
+
completion nudges `base` onto its snap point.
|
|
73
73
|
- **`Content` stops measuring.** The internal context's
|
|
74
74
|
`contentHeightSource` reads `steps` while a `Steps` body is mounted, and
|
|
75
75
|
`Content`'s `onLayout` returns early; its layout events would otherwise be
|
|
76
76
|
the stack mid-animation, a frame stale. The unmount reset to `UNMEASURED`
|
|
77
77
|
still runs, so the next open waits for the first step to measure again.
|
|
78
78
|
- **A step's `snapPoints` replace the root's, and turn dynamic sizing off,
|
|
79
|
-
while it is current.** A step that names its
|
|
79
|
+
while it is current.** A step that names its snap points is sized by them.
|
|
80
80
|
`dismissible: false` switches off pan-down-to-close and the overlay's press
|
|
81
81
|
for that step — `Close` and the ref still work. Both go through
|
|
82
82
|
`setStepOverride` on the root (`components/bottom-sheet.tsx`), because the
|
|
@@ -34,20 +34,20 @@ function animateWith(animation: ReanimatedAnimation, value: number, complete: (f
|
|
|
34
34
|
* across the top of the stack and only the current step and, during a change,
|
|
35
35
|
* the one leaving are rendered. The stack's height is a shared value animated
|
|
36
36
|
* to whatever the current step measures, and that same measurement is written
|
|
37
|
-
* to the sheet's `contentHeight` the moment it lands, so the dynamic
|
|
38
|
-
* moves once and the root's
|
|
37
|
+
* to the sheet's `contentHeight` the moment it lands, so the dynamic snap point
|
|
38
|
+
* moves once and the root's snap-point-change reaction animates `base` there with
|
|
39
39
|
* the same spring the body is using: the panel's top edge and the body's
|
|
40
|
-
* bottom edge glide together, and a sheet resting on an explicit
|
|
41
|
-
* not move at all. Should the sheet have been busy when the
|
|
40
|
+
* bottom edge glide together, and a sheet resting on an explicit snap point does
|
|
41
|
+
* not move at all. Should the sheet have been busy when the snap point moved — a
|
|
42
42
|
* step change mid-open — the height animation's completion nudges it onto its
|
|
43
|
-
*
|
|
43
|
+
* snap point.
|
|
44
44
|
*
|
|
45
45
|
* `Content`'s own measurement is switched off while this is the body, or its
|
|
46
46
|
* layout events would race the animated height with stale numbers.
|
|
47
47
|
*
|
|
48
48
|
* The step the machine is on decides two things about the sheet: its
|
|
49
49
|
* `snapPoints` replace the root's while it is current (and dynamic sizing is
|
|
50
|
-
* off for it — a step that names its
|
|
50
|
+
* off for it — a step that names its snap points is sized by them), and
|
|
51
51
|
* `dismissible: false` disables pan-down-to-close and the overlay's press.
|
|
52
52
|
* Both go through the root, since the overlay is not a descendant of the body.
|
|
53
53
|
*
|
|
@@ -159,9 +159,9 @@ export function BottomSheetSteps<S extends string, C, E extends SheetEvent>({
|
|
|
159
159
|
if (finished !== true) return;
|
|
160
160
|
const busy = state.animStatus.value !== ANIM_STATUS.IDLE || state.gestureSource.value !== GESTURE_SOURCE.NONE;
|
|
161
161
|
const index = state.currentIndex.value;
|
|
162
|
-
const
|
|
163
|
-
if (busy || index < 0 ||
|
|
164
|
-
const target = heightForIndex(Math.min(index,
|
|
162
|
+
const snapPoints = geometry.snapPoints.value;
|
|
163
|
+
if (busy || index < 0 || snapPoints.length === 0) return;
|
|
164
|
+
const target = heightForIndex(Math.min(index, snapPoints.length - 1), snapPoints, geometry.closedHeight.value);
|
|
165
165
|
if (Math.abs(target - state.base.value) > 0.5) animateTo(target, "snapPoints", 0);
|
|
166
166
|
};
|
|
167
167
|
stepHeight.value = animateWith(resolved, height, settle);
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { dynamicDetent } from "./dynamic-detent";
|
|
3
|
-
|
|
4
|
-
const measured = { handleHeight: 24, contentHeight: 300, footerContentHeight: 56, band: 34, available: 800 };
|
|
5
|
-
|
|
6
|
-
describe("dynamicDetent", () => {
|
|
7
|
-
test("without a footer the sheet is handle, content and the safe-area band", () => {
|
|
8
|
-
expect(dynamicDetent({ ...measured, hasFooter: false })).toBe(24 + 300 + 34);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
test("with a footer the footer content is included, and the band is counted once", () => {
|
|
12
|
-
expect(dynamicDetent({ ...measured, hasFooter: true })).toBe(24 + 300 + 56 + 34);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
test("a detached sheet has no band, so it contributes nothing", () => {
|
|
16
|
-
expect(dynamicDetent({ ...measured, band: 0, hasFooter: true })).toBe(24 + 300 + 56);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test("never exceeds the available height", () => {
|
|
20
|
-
expect(dynamicDetent({ ...measured, contentHeight: 2000, hasFooter: true })).toBe(800);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("maxDynamicContentSize caps below the available height", () => {
|
|
24
|
-
expect(dynamicDetent({ ...measured, contentHeight: 2000, hasFooter: false, maxDynamicContentSize: 500 })).toBe(500);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test("maxDynamicContentSize above the available height changes nothing", () => {
|
|
28
|
-
expect(dynamicDetent({ ...measured, contentHeight: 2000, hasFooter: false, maxDynamicContentSize: 5000 })).toBe(
|
|
29
|
-
800
|
|
30
|
-
);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("unmeasured parts count as zero rather than pulling the sum negative", () => {
|
|
34
|
-
expect(dynamicDetent({ ...measured, handleHeight: -1, footerContentHeight: -1, hasFooter: true })).toBe(300 + 34);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("an unmeasured container yields zero, never a negative height", () => {
|
|
38
|
-
expect(dynamicDetent({ ...measured, available: -1, hasFooter: false })).toBe(0);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { heightForIndex, indexForHeight } from "./index-for-height";
|
|
3
|
-
|
|
4
|
-
const detents = [200, 400, 800];
|
|
5
|
-
|
|
6
|
-
describe("indexForHeight", () => {
|
|
7
|
-
test("each detent maps to its own integer index", () => {
|
|
8
|
-
expect(indexForHeight(200, detents, 0)).toBe(0);
|
|
9
|
-
expect(indexForHeight(400, detents, 0)).toBe(1);
|
|
10
|
-
expect(indexForHeight(800, detents, 0)).toBe(2);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
test("the closed height is -1", () => {
|
|
14
|
-
expect(indexForHeight(0, detents, 0)).toBe(-1);
|
|
15
|
-
expect(indexForHeight(-32, detents, -32)).toBe(-1);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
test("between detents the index is fractional, so the backdrop can follow a drag", () => {
|
|
19
|
-
expect(indexForHeight(100, detents, 0)).toBeCloseTo(-0.5);
|
|
20
|
-
expect(indexForHeight(300, detents, 0)).toBeCloseTo(0.5);
|
|
21
|
-
expect(indexForHeight(600, detents, 0)).toBeCloseTo(1.5);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test("clamps beyond either end — an over-drag never reports a detent that does not exist", () => {
|
|
25
|
-
expect(indexForHeight(-100, detents, 0)).toBe(-1);
|
|
26
|
-
expect(indexForHeight(900, detents, 0)).toBe(2);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test("with no detents every height is closed", () => {
|
|
30
|
-
expect(indexForHeight(300, [], 0)).toBe(-1);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("a detent coinciding with the closed height does not divide by zero", () => {
|
|
34
|
-
expect(Number.isFinite(indexForHeight(0, [0, 400], 0))).toBe(true);
|
|
35
|
-
expect(indexForHeight(400, [0, 400], 0)).toBe(1);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
describe("heightForIndex", () => {
|
|
40
|
-
test("round-trips every detent", () => {
|
|
41
|
-
for (const [index, height] of detents.entries()) {
|
|
42
|
-
expect(heightForIndex(index, detents, 0)).toBe(height);
|
|
43
|
-
expect(indexForHeight(heightForIndex(index, detents, 0), detents, 0)).toBe(index);
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("-1 is the closed height, resting line included", () => {
|
|
48
|
-
expect(heightForIndex(-1, detents, 0)).toBe(0);
|
|
49
|
-
expect(heightForIndex(-1, detents, -32)).toBe(-32);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("clamps an out-of-range index to the nearest end", () => {
|
|
53
|
-
expect(heightForIndex(5, detents, 0)).toBe(800);
|
|
54
|
-
expect(heightForIndex(-3, detents, 0)).toBe(0);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("a fractional index interpolates, mirroring indexForHeight", () => {
|
|
58
|
-
expect(heightForIndex(0.5, detents, 0)).toBe(300);
|
|
59
|
-
expect(heightForIndex(-0.5, detents, 0)).toBe(100);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("with no detents every index is closed", () => {
|
|
63
|
-
expect(heightForIndex(0, [], -16)).toBe(-16);
|
|
64
|
-
});
|
|
65
|
-
});
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type { DetentSpec } from "../sheet.types";
|
|
3
|
-
import { normalizeDetents, parseDetent } from "./normalize-detents";
|
|
4
|
-
|
|
5
|
-
describe("parseDetent", () => {
|
|
6
|
-
test("a finite non-negative number is pixels", () => {
|
|
7
|
-
expect(parseDetent(300)).toEqual({ success: true, data: { kind: "px", value: 300 } });
|
|
8
|
-
expect(parseDetent(0)).toEqual({ success: true, data: { kind: "px", value: 0 } });
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
test("a percentage string is a fraction of the available height", () => {
|
|
12
|
-
expect(parseDetent("50%")).toEqual({ success: true, data: { kind: "percent", value: 50 } });
|
|
13
|
-
expect(parseDetent("12.5%")).toEqual({ success: true, data: { kind: "percent", value: 12.5 } });
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test("refuses NaN, infinities and negatives by name", () => {
|
|
17
|
-
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, -1] as const) {
|
|
18
|
-
expect(parseDetent(bad)).toEqual({ success: false, error: { code: "invalid-snap-point", value: bad } });
|
|
19
|
-
}
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("refuses a string that is not a percentage", () => {
|
|
23
|
-
for (const bad of ["50", "abc%", "%", "-10%", "50px"]) {
|
|
24
|
-
expect(parseDetent(bad as DetentSpec)).toEqual({
|
|
25
|
-
success: false,
|
|
26
|
-
error: { code: "invalid-snap-point", value: bad },
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("refuses anything that is not a number or a string", () => {
|
|
32
|
-
expect(parseDetent(undefined as unknown as DetentSpec)).toEqual({
|
|
33
|
-
success: false,
|
|
34
|
-
error: { code: "invalid-snap-point", value: undefined },
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
describe("normalizeDetents", () => {
|
|
40
|
-
test("pixels pass through", () => {
|
|
41
|
-
expect(normalizeDetents([200, 400], 800)).toEqual([200, 400]);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("percentages resolve against the available height", () => {
|
|
45
|
-
expect(normalizeDetents(["25%", "50%", "100%"], 800)).toEqual([200, 400, 800]);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("mixed specs sort ascending regardless of authoring order", () => {
|
|
49
|
-
expect(normalizeDetents(["100%", 100, "50%"], 800)).toEqual([100, 400, 800]);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("duplicates collapse, including a pixel and a percentage that agree", () => {
|
|
53
|
-
expect(normalizeDetents([400, "50%", 400], 800)).toEqual([400]);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("clamps to the available height and to zero", () => {
|
|
57
|
-
expect(normalizeDetents([1000, "150%"], 800)).toEqual([800]);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("skips an invalid spec rather than poisoning the list", () => {
|
|
61
|
-
expect(normalizeDetents([Number.NaN, "x%" as DetentSpec, 300], 800)).toEqual([300]);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("an unmeasured or zero available height yields nothing to snap to", () => {
|
|
65
|
-
expect(normalizeDetents(["50%"], -1)).toEqual([]);
|
|
66
|
-
expect(normalizeDetents([300], 0)).toEqual([]);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("an empty spec is an empty list", () => {
|
|
70
|
-
expect(normalizeDetents([], 800)).toEqual([]);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { SHEET_STATE } from "../sheet.types";
|
|
3
|
-
import { sheetState } from "./sheet-state";
|
|
4
|
-
|
|
5
|
-
const detents = [200, 400];
|
|
6
|
-
const maxHeight = 800;
|
|
7
|
-
|
|
8
|
-
describe("sheetState", () => {
|
|
9
|
-
test("at or below the closed height the sheet is closed", () => {
|
|
10
|
-
expect(sheetState(0, 0, detents, 0, maxHeight)).toBe(SHEET_STATE.CLOSED);
|
|
11
|
-
expect(sheetState(-10, -10, detents, 0, maxHeight)).toBe(SHEET_STATE.CLOSED);
|
|
12
|
-
expect(sheetState(-32, -32, detents, -32, maxHeight)).toBe(SHEET_STATE.CLOSED);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
test("between closed and the highest detent it is opened", () => {
|
|
16
|
-
expect(sheetState(200, 200, detents, 0, maxHeight)).toBe(SHEET_STATE.OPENED);
|
|
17
|
-
expect(sheetState(300, 300, detents, 0, maxHeight)).toBe(SHEET_STATE.OPENED);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test("at the highest detent it is extended", () => {
|
|
21
|
-
expect(sheetState(400, 400, detents, 0, maxHeight)).toBe(SHEET_STATE.EXTENDED);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test("a hair below the highest detent still counts as extended, so a settled spring unlocks the list", () => {
|
|
25
|
-
expect(sheetState(399.7, 399.7, detents, 0, maxHeight)).toBe(SHEET_STATE.EXTENDED);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test("above the highest detent — over-drag or a keyboard lift — it is over-extended", () => {
|
|
29
|
-
expect(sheetState(450, 450, detents, 0, maxHeight)).toBe(SHEET_STATE.OVER_EXTENDED);
|
|
30
|
-
expect(sheetState(400, 600, detents, 0, maxHeight)).toBe(SHEET_STATE.OVER_EXTENDED);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("filling the available height is fill, whatever the detents say", () => {
|
|
34
|
-
expect(sheetState(800, 800, detents, 0, maxHeight)).toBe(SHEET_STATE.FILL);
|
|
35
|
-
expect(sheetState(400, 800, detents, 0, maxHeight)).toBe(SHEET_STATE.FILL);
|
|
36
|
-
expect(sheetState(800, 800, [200, 800], 0, maxHeight)).toBe(SHEET_STATE.FILL);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("with no detents anything open is extended, so a dynamic sheet still unlocks its list", () => {
|
|
40
|
-
expect(sheetState(300, 300, [], 0, maxHeight)).toBe(SHEET_STATE.EXTENDED);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
@@ -1,43 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,33 +0,0 @@
|
|
|
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
|
-
}
|
|
File without changes
|
|
File without changes
|