@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,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary every layer of the engine shares.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is a plain type or a plain number so it can cross into a
|
|
5
|
+
* worklet unchanged. The enums are numeric on purpose: a shared value holding
|
|
6
|
+
* a string is a serialisation on every write, and `gestureSource` is written on
|
|
7
|
+
* every frame of a pan.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A detent as the consumer writes it — pixels of sheet visible above its
|
|
12
|
+
* resting bottom line, or a percentage of the height available to the sheet.
|
|
13
|
+
*
|
|
14
|
+
* Height space throughout: `0` is closed, detents ascend, and `translateY` is
|
|
15
|
+
* derived from a height only at the moment a view needs it.
|
|
16
|
+
*/
|
|
17
|
+
export type DetentSpec = number | `${number}%`;
|
|
18
|
+
|
|
19
|
+
/** What the sheet does when a keyboard it owns appears. */
|
|
20
|
+
export type KeyboardBehavior = "interactive" | "extend" | "fillParent" | "none";
|
|
21
|
+
|
|
22
|
+
/** What the sheet does when the keyboard it lifted for goes away. */
|
|
23
|
+
export type KeyboardBlurBehavior = "none" | "restore";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Which focused inputs count as the sheet's own.
|
|
27
|
+
*
|
|
28
|
+
* `registered` — only inputs that went through `useBottomSheetTextInput`.
|
|
29
|
+
* `inside` — those, plus any focused input whose frame lies inside the sheet.
|
|
30
|
+
* `always` — every keyboard, whoever asked for it.
|
|
31
|
+
*/
|
|
32
|
+
export type KeyboardScope = "registered" | "inside" | "always";
|
|
33
|
+
|
|
34
|
+
/** Why an animation started — reported through `onChange` and `onAnimate`. */
|
|
35
|
+
export type AnimationSource = "mount" | "gesture" | "user" | "snapPoints" | "keyboard" | "container";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A request from the JS thread, resolved on the UI thread once layout is ready.
|
|
39
|
+
*
|
|
40
|
+
* Intents replace present/dismiss: closing a closed sheet resolves to nothing,
|
|
41
|
+
* an open before the first layout waits for it, and `forceClose` skips the
|
|
42
|
+
* animation. The `id` is what a reaction keys on, so two identical requests in
|
|
43
|
+
* a row both run.
|
|
44
|
+
*/
|
|
45
|
+
export type SheetIntent =
|
|
46
|
+
| { id: number; kind: "open" }
|
|
47
|
+
| { id: number; kind: "close" }
|
|
48
|
+
| { id: number; kind: "forceClose" }
|
|
49
|
+
| { id: number; kind: "snapToIndex"; index: number }
|
|
50
|
+
| { id: number; kind: "snapToPosition"; position: DetentSpec }
|
|
51
|
+
| { id: number; kind: "expand" }
|
|
52
|
+
| { id: number; kind: "collapse" };
|
|
53
|
+
|
|
54
|
+
/** Which pan is driving the sheet this frame. */
|
|
55
|
+
export const GESTURE_SOURCE = {
|
|
56
|
+
NONE: 0,
|
|
57
|
+
HANDLE: 1,
|
|
58
|
+
CONTENT: 2,
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
export type GestureSource = (typeof GESTURE_SOURCE)[keyof typeof GESTURE_SOURCE];
|
|
62
|
+
|
|
63
|
+
/** Where the sheet sits relative to its detents. */
|
|
64
|
+
export const SHEET_STATE = {
|
|
65
|
+
CLOSED: 0,
|
|
66
|
+
OPENED: 1,
|
|
67
|
+
EXTENDED: 2,
|
|
68
|
+
OVER_EXTENDED: 3,
|
|
69
|
+
FILL: 4,
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
export type SheetState = (typeof SHEET_STATE)[keyof typeof SHEET_STATE];
|
|
73
|
+
|
|
74
|
+
/** A measurement that has not happened yet. Every measured shared value starts here. */
|
|
75
|
+
export const UNMEASURED = -1;
|
|
76
|
+
|
|
77
|
+
/** The index the sheet reports while closed. */
|
|
78
|
+
export const CLOSED_INDEX = -1;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What kind of scrollable, if any, is the sheet's body. Written by
|
|
82
|
+
* `createBottomSheetScrollable` on focus, `NONE` for static content — a
|
|
83
|
+
* content pan over static content may over-drag past the top, one over a list
|
|
84
|
+
* lets the list bounce instead.
|
|
85
|
+
*/
|
|
86
|
+
export const SCROLLABLE_TYPE = {
|
|
87
|
+
NONE: 0,
|
|
88
|
+
SCROLL_VIEW: 1,
|
|
89
|
+
FLAT_LIST: 2,
|
|
90
|
+
SECTION_LIST: 3,
|
|
91
|
+
} as const;
|
|
92
|
+
|
|
93
|
+
export type ScrollableType = (typeof SCROLLABLE_TYPE)[keyof typeof SCROLLABLE_TYPE];
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const SRC = import.meta.dirname;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every component in the package must carry a `DelacourBottomSheet.`-prefixed
|
|
9
|
+
* displayName.
|
|
10
|
+
*
|
|
11
|
+
* React DevTools reads `displayName || name`, so without one a tree row, an
|
|
12
|
+
* error stack and a profiler entry all read the private symbol —
|
|
13
|
+
* `BottomSheetHandle` rather than `DelacourBottomSheet.BottomSheet.Handle`.
|
|
14
|
+
* `bun test` cannot render React Native, so the convention is checked against
|
|
15
|
+
* the source text instead: this file reads the `.tsx` tree off disk and imports
|
|
16
|
+
* nothing from it. It is the same test `@delacour/react-native-ui` runs, with
|
|
17
|
+
* the prefix changed, so a skin and its engine never answer to one name.
|
|
18
|
+
*
|
|
19
|
+
* The corpus spans every file at once rather than checking each in isolation,
|
|
20
|
+
* because a root is declared in one file and named by the `Object.assign` in
|
|
21
|
+
* another.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Every `.tsx` under `src/`, as a path → source map. */
|
|
25
|
+
function sources(): Map<string, string> {
|
|
26
|
+
const found = new Map<string, string>();
|
|
27
|
+
const walk = (dir: string): void => {
|
|
28
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
29
|
+
const path = join(dir, entry.name);
|
|
30
|
+
if (entry.isDirectory()) walk(path);
|
|
31
|
+
else if (entry.name.endsWith(".tsx")) found.set(path, readFileSync(path, "utf-8"));
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
walk(SRC);
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SOURCES = sources();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A PascalCase `function` declaration, or a `memo(…)` / `forwardRef(…)` const.
|
|
42
|
+
*
|
|
43
|
+
* Those are the only three shapes a component takes here: the package has no
|
|
44
|
+
* arrow-function components. A PascalCase helper would be a false positive —
|
|
45
|
+
* write helpers in camelCase.
|
|
46
|
+
*/
|
|
47
|
+
const DECLARATIONS = [
|
|
48
|
+
/^(?:export )?function ([A-Z][A-Za-z0-9]*)\s*[<(]/gm,
|
|
49
|
+
/^(?:export )?const ([A-Z][A-Za-z0-9]*) = memo\(/gm,
|
|
50
|
+
/^(?:export )?const ([A-Z][A-Za-z0-9]*) = forwardRef[<(]/gm,
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** The trailing form on a part, and the `Object.assign` form on a root. */
|
|
54
|
+
const BINDINGS = [
|
|
55
|
+
/^([A-Z][A-Za-z0-9]*)\.displayName = "([^"]*)";/gm,
|
|
56
|
+
/Object\.assign\(\s*([A-Z][A-Za-z0-9]*)\s*,\s*\{[\s\S]*?displayName: "([^"]*)"/gm,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** Component name → the file it is declared in. */
|
|
60
|
+
function declared(): Map<string, string> {
|
|
61
|
+
const found = new Map<string, string>();
|
|
62
|
+
for (const [path, source] of SOURCES) {
|
|
63
|
+
for (const pattern of DECLARATIONS) {
|
|
64
|
+
for (const [, name] of source.matchAll(pattern)) found.set(name, path);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Component name → every displayName bound to it, across the whole tree. */
|
|
71
|
+
function bound(): Map<string, string[]> {
|
|
72
|
+
const found = new Map<string, string[]>();
|
|
73
|
+
for (const source of SOURCES.values()) {
|
|
74
|
+
for (const pattern of BINDINGS) {
|
|
75
|
+
for (const [, name, value] of source.matchAll(pattern)) {
|
|
76
|
+
found.set(name, [...(found.get(name) ?? []), value]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return found;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const DECLARED = declared();
|
|
84
|
+
const BOUND = bound();
|
|
85
|
+
|
|
86
|
+
/** `DelacourBottomSheet` plus one segment per step down the public API. */
|
|
87
|
+
const SHAPE = /^DelacourBottomSheet(\.[A-Z][A-Za-z0-9]*)+$/;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The floor under the walker: the eleven parts, the root, the slot and the
|
|
91
|
+
* context file, at the count BSHEET-2 landed. A walker matching nothing cannot
|
|
92
|
+
* keep the suite green on an empty set.
|
|
93
|
+
*/
|
|
94
|
+
const MIN_SOURCES = 13;
|
|
95
|
+
const MIN_DECLARED = 12;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every `*.context.tsx`, as a path → source map.
|
|
99
|
+
*
|
|
100
|
+
* A context is `createContext<T | null>(null)` with a `useX()` that throws
|
|
101
|
+
* outside its provider and a `useOptionalX()` that returns `null`. Both are
|
|
102
|
+
* exported by name from the file that declares the context, so the convention
|
|
103
|
+
* is checked against the same source text.
|
|
104
|
+
*/
|
|
105
|
+
const CONTEXT_SOURCES = [...SOURCES].filter(([path]) => path.endsWith(".context.tsx"));
|
|
106
|
+
|
|
107
|
+
const CONTEXT_DECLARATION = /^export const ([A-Z][A-Za-z0-9]*)Context = createContext</gm;
|
|
108
|
+
|
|
109
|
+
describe("displayName", () => {
|
|
110
|
+
test("finds the component tree", () => {
|
|
111
|
+
expect(SOURCES.size).toBeGreaterThanOrEqual(MIN_SOURCES);
|
|
112
|
+
expect(DECLARED.size).toBeGreaterThanOrEqual(MIN_DECLARED);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("every component has one", () => {
|
|
116
|
+
const missing = [...DECLARED]
|
|
117
|
+
.filter(([name]) => !BOUND.has(name))
|
|
118
|
+
.map(([name, path]) => `${name} (${path.slice(SRC.length + 1)})`);
|
|
119
|
+
expect(missing).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Two bindings on one component is a rename that only landed in one place.
|
|
123
|
+
test("no component has two", () => {
|
|
124
|
+
const doubled = [...BOUND].filter(([, values]) => values.length > 1).map(([name]) => name);
|
|
125
|
+
expect(doubled).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("every name is a DelacourBottomSheet path", () => {
|
|
129
|
+
const wrong = [...BOUND]
|
|
130
|
+
.filter(([, values]) => values.some((value) => !SHAPE.test(value)))
|
|
131
|
+
.map(([name, values]) => `${name} = ${values[0]}`);
|
|
132
|
+
expect(wrong).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Two components answering to one name is a copy-pasted part file.
|
|
136
|
+
test("no two components share a name", () => {
|
|
137
|
+
const seen = new Map<string, string>();
|
|
138
|
+
const clashes: string[] = [];
|
|
139
|
+
for (const [name, values] of BOUND) {
|
|
140
|
+
for (const value of values) {
|
|
141
|
+
const owner = seen.get(value);
|
|
142
|
+
if (owner) clashes.push(`${value}: ${owner} and ${name}`);
|
|
143
|
+
else seen.set(value, name);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
expect(clashes).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// A part's name is its path in the public API, so its root's name is a prefix
|
|
150
|
+
// of it. This is what keeps `BottomSheet.Handle` from drifting to `BottomSheet.Grip`.
|
|
151
|
+
test("every name descends from a component that exists", () => {
|
|
152
|
+
const names = new Set([...BOUND.values()].flat());
|
|
153
|
+
const orphans = [...names].filter((name) => {
|
|
154
|
+
const parent = name.slice(0, name.lastIndexOf("."));
|
|
155
|
+
return parent !== "DelacourBottomSheet" && !names.has(parent);
|
|
156
|
+
});
|
|
157
|
+
expect(orphans).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("contexts", () => {
|
|
162
|
+
test("finds the context files", () => {
|
|
163
|
+
expect(CONTEXT_SOURCES.length).toBeGreaterThanOrEqual(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("every context exports a throwing hook and an optional hook", () => {
|
|
167
|
+
const missing: string[] = [];
|
|
168
|
+
for (const [path, source] of CONTEXT_SOURCES) {
|
|
169
|
+
const contexts = [...source.matchAll(CONTEXT_DECLARATION)].map(([, name]) => name as string);
|
|
170
|
+
if (contexts.length === 0) missing.push(`${path.slice(SRC.length + 1)} declares no context`);
|
|
171
|
+
for (const name of contexts) {
|
|
172
|
+
if (!source.includes(`export function use${name}(`)) missing.push(`use${name}`);
|
|
173
|
+
if (!source.includes(`export function useOptional${name}(`)) missing.push(`useOptional${name}`);
|
|
174
|
+
if (!source.includes(`${name}Context.displayName = "DelacourBottomSheet.`)) {
|
|
175
|
+
missing.push(`${name}Context.displayName`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
expect(missing).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/docs.test.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { PACKAGE_ROOT, SRC, sourceFiles } from "./source-tree.test";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Documentation is part of the change.
|
|
8
|
+
*
|
|
9
|
+
* `@delacour/react-native-ui` grew this test because `Radio` shipped undocumented
|
|
10
|
+
* and nothing caught it for fifteen commits. The same applies here, scoped to
|
|
11
|
+
* the top-level folders rather than every nested one — a doc per directory of
|
|
12
|
+
* four utility modules is noise, a doc per subsystem is not.
|
|
13
|
+
*/
|
|
14
|
+
describe("docs", () => {
|
|
15
|
+
const folders = readdirSync(SRC)
|
|
16
|
+
.map((entry) => join(SRC, entry))
|
|
17
|
+
.filter((path) => statSync(path).isDirectory())
|
|
18
|
+
.filter((path) => sourceFiles(path, { skipTests: true }).length > 0);
|
|
19
|
+
|
|
20
|
+
test("the package documents itself", () => {
|
|
21
|
+
const doc = join(PACKAGE_ROOT, "AGENTS.md");
|
|
22
|
+
expect(existsSync(doc)).toBe(true);
|
|
23
|
+
expect(readFileSync(doc, "utf8").split("\n").length).toBeGreaterThan(20);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("finds the subsystems, so a broken walker cannot pass silently", () => {
|
|
27
|
+
expect(folders.length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("every subsystem has an AGENTS.md that is not a stub", () => {
|
|
31
|
+
const missing: string[] = [];
|
|
32
|
+
for (const folder of folders) {
|
|
33
|
+
const doc = join(folder, "AGENTS.md");
|
|
34
|
+
if (!existsSync(doc)) {
|
|
35
|
+
missing.push(`${folder.slice(SRC.length + 1)} has no AGENTS.md`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const content = readFileSync(doc, "utf8");
|
|
39
|
+
if (!content.startsWith("# ") || content.split("\n").length < 10) {
|
|
40
|
+
missing.push(`${folder.slice(SRC.length + 1)}/AGENTS.md is a stub`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
expect(missing).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { read, relative, SRC, sourceFiles } from "./source-tree.test";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The flat-worklet rule.
|
|
6
|
+
*
|
|
7
|
+
* **A worklet declared at module scope may close over module-scope constants
|
|
8
|
+
* but never over another function, imported or local.** Any helper it needs is
|
|
9
|
+
* declared inside its own body.
|
|
10
|
+
*
|
|
11
|
+
* Module scope is the whole rule. A module-scope worklet that calls another
|
|
12
|
+
* module-scope worklet binds at import time in source order, and the UI thread
|
|
13
|
+
* gets `undefined is not a function` — at the moment a finger touches the
|
|
14
|
+
* handle, on a device, with a stack that names neither function. Four
|
|
15
|
+
* components in `@delacour/react-native-ui` document having learned this.
|
|
16
|
+
*
|
|
17
|
+
* A worklet created *inside* a hook is captured by ordinary closure and may
|
|
18
|
+
* call whatever it likes, including the module-scope worklets here. That is
|
|
19
|
+
* how the pan composes `resistOverDrag` and `selectSnapHeight` without
|
|
20
|
+
* duplicating them.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Members of these are always safe — they exist in every worklet runtime. */
|
|
24
|
+
const GLOBAL_NAMESPACES = ["Math", "Number", "Array", "Object", "String", "JSON", "Boolean", "Date"];
|
|
25
|
+
|
|
26
|
+
/** Control-flow keywords that look like calls to a regex. */
|
|
27
|
+
const KEYWORDS = new Set(["if", "for", "while", "switch", "catch", "return", "typeof", "function", "new"]);
|
|
28
|
+
|
|
29
|
+
/** The body of the block opening at `open`, by brace matching. */
|
|
30
|
+
function blockAt(source: string, open: number): string {
|
|
31
|
+
let depth = 0;
|
|
32
|
+
for (let index = open; index < source.length; index += 1) {
|
|
33
|
+
const char = source[index];
|
|
34
|
+
if (char === "{") depth += 1;
|
|
35
|
+
else if (char === "}") {
|
|
36
|
+
depth -= 1;
|
|
37
|
+
if (depth === 0) return source.slice(open + 1, index);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return source.slice(open + 1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Bodies of every column-zero `function` whose first statement is the directive. */
|
|
44
|
+
function moduleScopeWorklets(source: string): { readonly name: string; readonly body: string }[] {
|
|
45
|
+
const found: { name: string; body: string }[] = [];
|
|
46
|
+
const declaration = /^(?:export\s+)?function\s+(\w+)/gm;
|
|
47
|
+
|
|
48
|
+
for (let match = declaration.exec(source); match !== null; match = declaration.exec(source)) {
|
|
49
|
+
const open = source.indexOf("{", match.index);
|
|
50
|
+
if (open === -1) continue;
|
|
51
|
+
const body = blockAt(source, open);
|
|
52
|
+
if (!body.trimStart().startsWith('"worklet";')) continue;
|
|
53
|
+
found.push({ name: match[1] as string, body });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Value names this module imports — the ones a module-scope worklet must not call. */
|
|
60
|
+
function importedValues(source: string): Set<string> {
|
|
61
|
+
const names = new Set<string>();
|
|
62
|
+
const pattern = /^\s*import\s+([\s\S]*?)\s*from\s*["'][^"']+["']/gm;
|
|
63
|
+
for (let match = pattern.exec(source); match !== null; match = pattern.exec(source)) {
|
|
64
|
+
const clause = (match[1] as string).trim();
|
|
65
|
+
if (clause.startsWith("type ")) continue;
|
|
66
|
+
const named = clause.match(/\{([\s\S]*)\}/);
|
|
67
|
+
if (named) {
|
|
68
|
+
for (const raw of (named[1] as string).split(",")) {
|
|
69
|
+
const specifier = raw.trim();
|
|
70
|
+
if (specifier === "" || specifier.startsWith("type ")) continue;
|
|
71
|
+
names.add((specifier.split(/\s+as\s+/).pop() as string).trim());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const bare = clause
|
|
75
|
+
.replace(/\{[\s\S]*\}/, "")
|
|
76
|
+
.replace(/,/g, "")
|
|
77
|
+
.trim();
|
|
78
|
+
if (bare !== "" && !bare.startsWith("*")) names.add(bare);
|
|
79
|
+
}
|
|
80
|
+
return names;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Functions declared at module scope — also off limits inside a module-scope worklet. */
|
|
84
|
+
function functionNames(source: string): Set<string> {
|
|
85
|
+
const names = new Set<string>();
|
|
86
|
+
const patterns = [
|
|
87
|
+
/(?:^|\n)\s*(?:export\s+)?function\s+(\w+)/g,
|
|
88
|
+
/(?:^|\n)\s*(?:export\s+)?const\s+(\w+)\s*=\s*(?:\([^)]*\)|\w+)\s*(?::[^=]+)?=>/g,
|
|
89
|
+
];
|
|
90
|
+
for (const pattern of patterns) {
|
|
91
|
+
for (let match = pattern.exec(source); match !== null; match = pattern.exec(source)) {
|
|
92
|
+
names.add(match[1] as string);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return names;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("flat worklets", () => {
|
|
99
|
+
const files = sourceFiles(SRC, { skipTests: true });
|
|
100
|
+
|
|
101
|
+
test("finds the module-scope worklets, so a broken walker cannot pass silently", () => {
|
|
102
|
+
const total = files.reduce((count, file) => count + moduleScopeWorklets(read(file)).length, 0);
|
|
103
|
+
expect(total).toBeGreaterThan(20);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("no module-scope worklet calls an imported or module-scope function", () => {
|
|
107
|
+
const offenders: string[] = [];
|
|
108
|
+
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
const source = read(file);
|
|
111
|
+
if (!source.includes('"worklet";')) continue;
|
|
112
|
+
const forbidden = new Set([...importedValues(source), ...functionNames(source)]);
|
|
113
|
+
|
|
114
|
+
for (const worklet of moduleScopeWorklets(source)) {
|
|
115
|
+
const declaredInside = functionNames(worklet.body);
|
|
116
|
+
const calls = /(?:^|[^.\w$])(\w+)\s*\(/g;
|
|
117
|
+
|
|
118
|
+
for (let call = calls.exec(worklet.body); call !== null; call = calls.exec(worklet.body)) {
|
|
119
|
+
const name = call[1] as string;
|
|
120
|
+
if (KEYWORDS.has(name) || GLOBAL_NAMESPACES.includes(name)) continue;
|
|
121
|
+
if (declaredInside.has(name) || name === worklet.name) continue;
|
|
122
|
+
if (!forbidden.has(name)) continue;
|
|
123
|
+
offenders.push(`${relative(file)}: ${worklet.name}() calls ${name}()`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
expect(offenders).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# gesture
|
|
2
|
+
|
|
3
|
+
The two pans that drive a sheet, and the haptic hooks they fire.
|
|
4
|
+
|
|
5
|
+
## Files
|
|
6
|
+
|
|
7
|
+
| Path | What |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `gesture.types.ts` | `SheetPans` (handle and content), `SheetHaptics` (the three worklet props), `SheetPanOptions`, the activation constants |
|
|
10
|
+
| `use-sheet-pan.ts` | `useSheetPan(state, geometry, animateTo, settleAt, options)` — both pans from one set of hook-scope handlers |
|
|
11
|
+
|
|
12
|
+
## The handlers
|
|
13
|
+
|
|
14
|
+
`onStart` cancels the animation that owns `base`, records the source (handle
|
|
15
|
+
or content), where the drag began and which detent was under it.
|
|
16
|
+
|
|
17
|
+
`onChange` turns the finger's travel into a height — `startBase −
|
|
18
|
+
translationY`, less the scrollable's offset for a content pan once BSHEET-4
|
|
19
|
+
wires one — rubber-banded by `resistOverDrag` past the lowest and highest
|
|
20
|
+
detent and never above the container. The lowest is the closed height when
|
|
21
|
+
`enablePanDownToClose`, else the first detent. `crossedDetent` decides the
|
|
22
|
+
detent haptic; entering the rubber band fires the over-drag haptic once.
|
|
23
|
+
|
|
24
|
+
`onFinalize`, not `onEnd`: a gesture that fails after activating never fires
|
|
25
|
+
`onEnd`, and the sheet would be stranded between detents. `selectSnapHeight`
|
|
26
|
+
projects the release velocity a fifth of a second ahead and picks the nearest
|
|
27
|
+
candidate, closing only when the pan may close, and `animateTo` gets half the
|
|
28
|
+
velocity in height space — upward positive, so `−velocityY`.
|
|
29
|
+
|
|
30
|
+
A content pan over a scrollable is shared with the list. When `listOwnsRelease`
|
|
31
|
+
says the release is the list's — the sheet at its highest detent, the list
|
|
32
|
+
scrolled — the sheet does not snap, but it **still settles**: the finger moved
|
|
33
|
+
`base` by hand, so no animation ran and nothing else would write
|
|
34
|
+
`currentIndex`. `restingDetent` names the detent under `base` and `settleAt`
|
|
35
|
+
does the bookkeeping of a finished animation without the motion —
|
|
36
|
+
`currentIndex`, then `onSettle`, so the handle's accessibility value and
|
|
37
|
+
`onIndexChange` catch up. A `return` there once left the index at the previous
|
|
38
|
+
detent (or at `-1`) under a sheet sitting at 90%, and the next overlay tap did
|
|
39
|
+
nothing because `close` read the sheet as already closed. Between detents,
|
|
40
|
+
which the ownership test should rule out, the sheet snaps to the nearest with
|
|
41
|
+
no velocity and never closes: the release velocity is the list's momentum.
|
|
42
|
+
|
|
43
|
+
## Activation
|
|
44
|
+
|
|
45
|
+
The content pan waits six points of vertical travel before it claims a touch
|
|
46
|
+
and gives up after twelve horizontal, so a tap lands on the button or the
|
|
47
|
+
field under it and a horizontal pager inside the sheet keeps its swipe. The
|
|
48
|
+
handle pan claims immediately. Both keep running when the finger leaves the
|
|
49
|
+
view. Scrollables (BSHEET-4) wrap their native gesture in
|
|
50
|
+
`simultaneousWithExternalGesture(pans.content)`.
|
|
51
|
+
|
|
52
|
+
## Per-gesture memory
|
|
53
|
+
|
|
54
|
+
Where the drag began, the detent last under it and whether it is over-dragging
|
|
55
|
+
live in shared values rather than closure variables. Each worklet gets its own
|
|
56
|
+
copy of a captured `let`, so a write in `onStart` would never be seen by
|
|
57
|
+
`onChange`.
|
|
58
|
+
|
|
59
|
+
## Haptics
|
|
60
|
+
|
|
61
|
+
The package imports nothing from a haptics library. `onDetentHaptic`,
|
|
62
|
+
`onCloseHaptic` and `onOverDragHaptic` are worklets the consumer passes —
|
|
63
|
+
`Presets.System.selection` from pulsar is one already — and they run inside the
|
|
64
|
+
gesture handler, in the frame the crossing happened.
|
|
65
|
+
|
|
66
|
+
## Why one shared gesture object per pan
|
|
67
|
+
|
|
68
|
+
A gesture builder mutates in place. `Handle` and `Content` each take their pan
|
|
69
|
+
from context and hand it to a `GestureDetector` untouched; a per-part opt-out
|
|
70
|
+
leaves the detector out rather than calling `.enabled(false)` on the shared
|
|
71
|
+
object, which would switch it off for every other part too.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { GestureType } from "react-native-gesture-handler";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A haptic, as a worklet. The engine imports nothing from a haptics library;
|
|
5
|
+
* the skin passes `Presets.System.selection` and friends, which are worklets
|
|
6
|
+
* already, and they fire on the UI thread in the same frame as the crossing.
|
|
7
|
+
*/
|
|
8
|
+
export type HapticWorklet = () => void;
|
|
9
|
+
|
|
10
|
+
export type SheetHaptics = {
|
|
11
|
+
/** A drag crossed a detent. */
|
|
12
|
+
onDetentHaptic?: HapticWorklet;
|
|
13
|
+
/** A drag let go on a close. */
|
|
14
|
+
onCloseHaptic?: HapticWorklet;
|
|
15
|
+
/** A drag left the detent range and the rubber band took over. */
|
|
16
|
+
onOverDragHaptic?: HapticWorklet;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type SheetPanOptions = SheetHaptics & {
|
|
20
|
+
enableHandlePanningGesture: boolean;
|
|
21
|
+
enableContentPanningGesture: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** The two pans, ready for a `GestureDetector` each. */
|
|
25
|
+
export type SheetPans = {
|
|
26
|
+
handle: GestureType;
|
|
27
|
+
content: GestureType;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Seconds of release velocity the snap projects along — the `0.2` the previous engine used. */
|
|
31
|
+
export const SNAP_PROJECTION = 0.2;
|
|
32
|
+
|
|
33
|
+
/** How far a finger travels vertically before the content pan claims it, in pixels. */
|
|
34
|
+
export const CONTENT_ACTIVE_OFFSET_Y = 6;
|
|
35
|
+
|
|
36
|
+
/** Horizontal travel that hands the touch to a horizontal scrollable or pager instead. */
|
|
37
|
+
export const CONTENT_FAIL_OFFSET_X = 12;
|