@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,27 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { read, relative, SRC, sourceFiles } from "./source-tree.test";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* This package emits no `className`.
|
|
6
|
+
*
|
|
7
|
+
* That is the promise the engine/skin split rests on: radii, colours and
|
|
8
|
+
* spacing arrive as `style`, and `@delacour/react-native-ui` is the only place
|
|
9
|
+
* tokens exist. It also has a concrete consequence in the playground — Tailwind's
|
|
10
|
+
* scanner needs no `@source` line pointing here, because there is nothing to
|
|
11
|
+
* scan. A stray `className` would make that missing line a silent bug where a
|
|
12
|
+
* class compiles in development and vanishes from a release build.
|
|
13
|
+
*
|
|
14
|
+
* So the promise is checked rather than hoped for.
|
|
15
|
+
*/
|
|
16
|
+
describe("no className", () => {
|
|
17
|
+
const files = sourceFiles(SRC, { skipTests: true });
|
|
18
|
+
|
|
19
|
+
test("finds the source, so a broken walker cannot pass silently", () => {
|
|
20
|
+
expect(files.length).toBeGreaterThan(20);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("no module names className", () => {
|
|
24
|
+
const offenders = files.filter((file) => read(file).includes("className")).map((file) => relative(file));
|
|
25
|
+
expect(offenders).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# portal
|
|
2
|
+
|
|
3
|
+
Where a sheet draws, and who is on top: the provider, the host, the nearest-host
|
|
4
|
+
context and the registry that `Portal` reads.
|
|
5
|
+
|
|
6
|
+
## Files
|
|
7
|
+
|
|
8
|
+
| Path | What |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| `bottom-sheet-provider.tsx` | `BottomSheetProvider` — teleport's `PortalProvider`, the registry provider and the host-name context set to `"root"`. The app mounts it once |
|
|
11
|
+
| `bottom-sheet-host.tsx` | `BottomSheet.Host` — a teleport `PortalHost` (absolute fill, `box-none`) that provides its own name as the nearest one. The recipe for a native modal |
|
|
12
|
+
| `host.context.tsx` | `BottomSheetHostNameContext` — the nearest host's name, `null` with no provider; `useBottomSheetHostName` / `useOptionalBottomSheetHostName` |
|
|
13
|
+
| `sheet-registry.ts` | The pure reducer: `reduceRegistry`, `topOf`, `zIndexOf`, `isTop`, `INITIAL_REGISTRY`. Tested without a renderer |
|
|
14
|
+
| `sheet-registry.test.ts` | Increasing z, re-open brings forward, `replace` closes the same host only, unknown close is the same state, top per host |
|
|
15
|
+
| `sheet-registry.context.tsx` | `SheetRegistryProvider` (reducer state in React, the id → methods map) and `useBottomSheetRegistry() → { dismissAll, dismiss }` |
|
|
16
|
+
|
|
17
|
+
`sheet-registry.ts` and `sheet-registry.context.tsx` are two files on purpose:
|
|
18
|
+
`./sheet-registry` must resolve to the maths, and a `.ts` and a `.tsx` of one
|
|
19
|
+
name would leave that to the bundler.
|
|
20
|
+
|
|
21
|
+
## One implementation
|
|
22
|
+
|
|
23
|
+
`BottomSheet.Portal` is one component with two destinations. With a
|
|
24
|
+
`BottomSheetProvider` above it and no `inline`, the frame is written inside a
|
|
25
|
+
teleport `Portal` bound to the nearest host — `"root"` under a bare provider,
|
|
26
|
+
or the host a native modal screen wrote last, or the `hostName` prop. Teleport
|
|
27
|
+
moves the **native** view and leaves the React tree where it was, so every
|
|
28
|
+
context provided around the trigger — a theme, a form, a query client — still
|
|
29
|
+
reaches the sheet's parts with nothing re-provided. Without a provider, or
|
|
30
|
+
with `inline`, the frame renders where it is written.
|
|
31
|
+
|
|
32
|
+
There is no `BottomSheetModal`, and no minimise/restore stack. A modal sheet is
|
|
33
|
+
a sheet whose portal teleports, and that is the default.
|
|
34
|
+
|
|
35
|
+
The teleport `Portal` wrapper is an absolute fill of the host with the
|
|
36
|
+
registry's `zIndex`; the frame inside it is the same absolute fill, `box-none`,
|
|
37
|
+
so a closed sheet takes no touch and the app under it stays interactive.
|
|
38
|
+
Teleport's native `PortalView` hit-tests only its subviews, so the wrapper is
|
|
39
|
+
transparent to touches too.
|
|
40
|
+
|
|
41
|
+
## The registry
|
|
42
|
+
|
|
43
|
+
A sheet is in the registry while it is **presented** — from the open that
|
|
44
|
+
mounted its children to the settle at `-1` that unmounts them — under the host
|
|
45
|
+
its portal chose. An inline sheet registers under a host of its own
|
|
46
|
+
(`inline:<id>`), so nothing replaces it and it is always its own top.
|
|
47
|
+
|
|
48
|
+
`reduceRegistry` stamps each open with the next `z`; the frame and the panel
|
|
49
|
+
carry it as `zIndex`, and sheets in one host are siblings, so the later open
|
|
50
|
+
draws on top. Opening a sheet already open re-stamps it, which brings it
|
|
51
|
+
forward. `stackBehavior: "replace"` drops every other sheet in the same host
|
|
52
|
+
and reports them; the provider calls each one's `close()`, they animate out,
|
|
53
|
+
and their own settle finds nothing to remove.
|
|
54
|
+
|
|
55
|
+
`useBottomSheetRegistry()` returns `dismissAll` and `dismiss(id)`; the root
|
|
56
|
+
registers `{ close, forceClose }` under its `useId` on mount. Both throw
|
|
57
|
+
outside the provider — there is nothing to ask without one — and the
|
|
58
|
+
`useOptional` form returns `null`.
|
|
59
|
+
|
|
60
|
+
`closeOnBack` subscribes to `BackHandler` only while the sheet is presented
|
|
61
|
+
**and** `isTop` in its host, so a back press closes the top sheet and the one
|
|
62
|
+
under it survives. Without a registry every sheet is its own top. iOS has no
|
|
63
|
+
back button; the unit test on `isTop` is what stands in for the device check.
|
|
64
|
+
|
|
65
|
+
## Hosts
|
|
66
|
+
|
|
67
|
+
`BottomSheet.Host` is for the one place the root host cannot reach: a native
|
|
68
|
+
modal. A `Modal`, or a screen a navigator presents as one, is its own window,
|
|
69
|
+
and a sheet teleported to the root draws behind it. The host is written
|
|
70
|
+
**last** in that screen so it is the topmost sibling, and it provides its own
|
|
71
|
+
name as the nearest one, so every sheet written in the screen lands there
|
|
72
|
+
without a `hostName`.
|
|
73
|
+
|
|
74
|
+
A host needs the provider above it — teleport's `PortalHost` reads the
|
|
75
|
+
provider's manager — and it must not be written inside a sheet: a sheet
|
|
76
|
+
teleported into another sheet's panel is clipped by it and moves with it. Both
|
|
77
|
+
warn in development.
|
|
78
|
+
|
|
79
|
+
## Detached
|
|
80
|
+
|
|
81
|
+
`detached` is resolved once by the root (`resolveDetached`) and handed to the
|
|
82
|
+
parts through the internal context, next to the shared-value copy the geometry
|
|
83
|
+
reads. `Container` derives `left` and `width` from `detachedFrame` on the UI
|
|
84
|
+
thread, turns `overflow` visible so a card's shadow is not clipped, and is
|
|
85
|
+
`box-none`, so a touch in the gap under the card falls through to the
|
|
86
|
+
overlay. The frame is `overflow: visible` too. The overlay is unchanged — it
|
|
87
|
+
fills the frame, margins and gap included, so a tap there closes. The rest is
|
|
88
|
+
`core/`: `closedHeight` is `−restingBottom`, so closed is `translateY =
|
|
89
|
+
containerHeight` and the card is fully off-screen; `%` detents resolve against
|
|
90
|
+
the height above the resting line; `keyboardLift` subtracts the gap. The
|
|
91
|
+
corners are the consumer's `Background` style.
|
|
92
|
+
|
|
93
|
+
**The card moves as one rigid body.** The panel is as tall as the frame, so a
|
|
94
|
+
detached `Background` is sized by the geometry's `surfaceHeight`
|
|
95
|
+
(`core/geometry/surface-height.ts`): the sheet's `height` between its detents,
|
|
96
|
+
the first detent's height below them and the last's above. A drag down to
|
|
97
|
+
close, the close animation, a rubber-band under the lowest detent and an
|
|
98
|
+
over-drag past the highest therefore leave the surface, the body and the
|
|
99
|
+
footer exactly as they were and let `translateY` alone move the card, bottom
|
|
100
|
+
corners included, through the gap and off the screen. `Content` clamps its
|
|
101
|
+
body to `surfaceHeight − handleHeight` for the same reason.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode, useEffect } from "react";
|
|
2
|
+
import { StyleSheet, type ViewProps } from "react-native";
|
|
3
|
+
import { PortalHost } from "react-native-teleport";
|
|
4
|
+
import { useOptionalBottomSheetInternal } from "../components/bottom-sheet.context";
|
|
5
|
+
import { BottomSheetHostNameContext, useOptionalBottomSheetHostName } from "./host.context";
|
|
6
|
+
|
|
7
|
+
export type BottomSheetHostProps = {
|
|
8
|
+
/** What a `Portal`'s `hostName` names. Unique across the mounted hosts. */
|
|
9
|
+
name: string;
|
|
10
|
+
style?: ViewProps["style"];
|
|
11
|
+
/**
|
|
12
|
+
* The screen the host serves. A sheet written inside targets this host
|
|
13
|
+
* without a `hostName`, and teleport draws every portal over the children,
|
|
14
|
+
* so the host is the screen's outermost view. With no children it is an
|
|
15
|
+
* absolute fill instead, written last in the screen, and a sheet elsewhere
|
|
16
|
+
* in that screen names it with `hostName`.
|
|
17
|
+
*/
|
|
18
|
+
children?: ReactNode;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A place for sheets to teleport to, other than the app root.
|
|
23
|
+
*
|
|
24
|
+
* The recipe for a native modal: a `Modal`, or a navigator screen presented
|
|
25
|
+
* as one, is its own window, and a sheet teleported to the root host draws
|
|
26
|
+
* behind it. Write `<BottomSheet.Host name="modal" />` last in that screen
|
|
27
|
+
* and every sheet written in the screen lands there — the host provides its
|
|
28
|
+
* own name as the nearest one, so no `Portal` needs telling.
|
|
29
|
+
*
|
|
30
|
+
* An absolute fill that takes no touch of its own, so the screen under it
|
|
31
|
+
* stays interactive until a sheet opens over it.
|
|
32
|
+
*
|
|
33
|
+
* A host inside a sheet is a mistake this warns about in development: a
|
|
34
|
+
* sheet teleported into another sheet's panel is clipped by that panel and
|
|
35
|
+
* moves with it.
|
|
36
|
+
*/
|
|
37
|
+
export function BottomSheetHost({ name, style, children }: BottomSheetHostProps): ReactElement {
|
|
38
|
+
const insideSheet = useOptionalBottomSheetInternal() !== null;
|
|
39
|
+
const provided = useOptionalBottomSheetHostName() !== null;
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!__DEV__) return;
|
|
43
|
+
if (insideSheet) {
|
|
44
|
+
console.warn(
|
|
45
|
+
`[@delacour/react-native-bottom-sheet] <BottomSheet.Host name="${name}"> is rendered inside a sheet. A host belongs in a screen, written last; a sheet teleported into another sheet's panel is clipped by it.`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (!provided) {
|
|
49
|
+
console.warn(
|
|
50
|
+
`[@delacour/react-native-bottom-sheet] <BottomSheet.Host name="${name}"> has no <BottomSheetProvider> above it. Mount the provider at the app root.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}, [insideSheet, provided, name]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<BottomSheetHostNameContext.Provider value={name}>
|
|
57
|
+
<PortalHost name={name} style={[children === undefined ? StyleSheet.absoluteFill : styles.fill, style]}>
|
|
58
|
+
{children}
|
|
59
|
+
</PortalHost>
|
|
60
|
+
</BottomSheetHostNameContext.Provider>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
BottomSheetHost.displayName = "DelacourBottomSheet.BottomSheet.Host";
|
|
64
|
+
|
|
65
|
+
const styles = StyleSheet.create({
|
|
66
|
+
fill: { flex: 1 },
|
|
67
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import { PortalProvider } from "react-native-teleport";
|
|
3
|
+
import { BottomSheetHostNameContext } from "./host.context";
|
|
4
|
+
import { SheetRegistryProvider } from "./sheet-registry.context";
|
|
5
|
+
|
|
6
|
+
export type BottomSheetProviderProps = {
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The one provider this package renders, mounted once by the app — inside its
|
|
12
|
+
* gesture root and keyboard provider, around its navigator.
|
|
13
|
+
*
|
|
14
|
+
* Three things, in one: teleport's `PortalProvider`, which renders its
|
|
15
|
+
* children and then a `root` host that fills them, so a teleported sheet
|
|
16
|
+
* draws over the navigator and the tab bar; the sheet registry, which gives
|
|
17
|
+
* every presented sheet its place in the z-order and lets the app close them
|
|
18
|
+
* without a ref; and the host-name context, set to `"root"`, which is what
|
|
19
|
+
* makes a `Portal` written anywhere below teleport there without being told.
|
|
20
|
+
*
|
|
21
|
+
* Nothing else. `GestureHandlerRootView`, `SafeAreaProvider` and
|
|
22
|
+
* `KeyboardProvider` are the app's — see the package `AGENTS.md`.
|
|
23
|
+
*/
|
|
24
|
+
export function BottomSheetProvider({ children }: BottomSheetProviderProps): ReactElement {
|
|
25
|
+
return (
|
|
26
|
+
<PortalProvider>
|
|
27
|
+
<SheetRegistryProvider>
|
|
28
|
+
<BottomSheetHostNameContext.Provider value="root">{children}</BottomSheetHostNameContext.Provider>
|
|
29
|
+
</SheetRegistryProvider>
|
|
30
|
+
</PortalProvider>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
BottomSheetProvider.displayName = "DelacourBottomSheet.BottomSheet.Provider";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The name of the nearest `BottomSheet.Host` above a sheet — `"root"` under a
|
|
5
|
+
* bare `BottomSheetProvider`, the host's own name under one written inside a
|
|
6
|
+
* native modal. A `Portal` with no `hostName` of its own teleports here.
|
|
7
|
+
*
|
|
8
|
+
* `null` means no provider at all, and a portal renders in place.
|
|
9
|
+
*/
|
|
10
|
+
export const BottomSheetHostNameContext = createContext<string | null>(null);
|
|
11
|
+
BottomSheetHostNameContext.displayName = "DelacourBottomSheet.HostNameContext";
|
|
12
|
+
|
|
13
|
+
export function useBottomSheetHostName(): string {
|
|
14
|
+
const value = useContext(BottomSheetHostNameContext);
|
|
15
|
+
if (value === null) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
"[@delacour/react-native-bottom-sheet] useBottomSheetHostName outside <BottomSheetProvider>. Mount the provider at the app root."
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useOptionalBottomSheetHostName(): string | null {
|
|
24
|
+
return useContext(BottomSheetHostNameContext);
|
|
25
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactElement,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { INITIAL_REGISTRY, reduceRegistry, type SheetRegistryState, type StackBehavior } from "./sheet-registry";
|
|
12
|
+
|
|
13
|
+
/** What a sheet hands the registry so it can be closed from outside. */
|
|
14
|
+
export type SheetRegistryMethods = {
|
|
15
|
+
close: () => void;
|
|
16
|
+
forceClose: () => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type SheetRegistryValue = {
|
|
20
|
+
/** The open set and its z-order. React state: a change re-renders every presented sheet's frame. */
|
|
21
|
+
state: SheetRegistryState;
|
|
22
|
+
/** A root registers its methods on mount; the cleanup unregisters. */
|
|
23
|
+
register: (id: string, methods: SheetRegistryMethods) => () => void;
|
|
24
|
+
/** A portal reports that its sheet is presented, in which host, and how it stacks. */
|
|
25
|
+
present: (id: string, host: string, behavior: StackBehavior) => void;
|
|
26
|
+
/** The sheet's close settled, or its portal unmounted. */
|
|
27
|
+
dismissed: (id: string) => void;
|
|
28
|
+
dismiss: (id: string) => void;
|
|
29
|
+
dismissAll: () => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const SheetRegistryContext = createContext<SheetRegistryValue | null>(null);
|
|
33
|
+
SheetRegistryContext.displayName = "DelacourBottomSheet.RegistryContext";
|
|
34
|
+
|
|
35
|
+
export function useSheetRegistry(): SheetRegistryValue {
|
|
36
|
+
const value = useContext(SheetRegistryContext);
|
|
37
|
+
if (value === null) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"[@delacour/react-native-bottom-sheet] useSheetRegistry outside <BottomSheetProvider>. Mount the provider at the app root."
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useOptionalSheetRegistry(): SheetRegistryValue | null {
|
|
46
|
+
return useContext(SheetRegistryContext);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What `useBottomSheetRegistry()` returns — the two things an app does to sheets it does not hold a ref to. */
|
|
50
|
+
export type BottomSheetRegistryValue = {
|
|
51
|
+
/** Closes every presented sheet, in every host. */
|
|
52
|
+
dismissAll: () => void;
|
|
53
|
+
/** Closes one sheet by the id its root registered. Unknown ids are nothing. */
|
|
54
|
+
dismiss: (id: string) => void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The app's handle on every open sheet. Throws outside `BottomSheetProvider`:
|
|
59
|
+
* there is no registry to ask without one.
|
|
60
|
+
*/
|
|
61
|
+
export function useBottomSheetRegistry(): BottomSheetRegistryValue {
|
|
62
|
+
const { dismissAll, dismiss } = useSheetRegistry();
|
|
63
|
+
return useMemo(() => ({ dismissAll, dismiss }), [dismissAll, dismiss]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useOptionalBottomSheetRegistry(): BottomSheetRegistryValue | null {
|
|
67
|
+
const registry = useOptionalSheetRegistry();
|
|
68
|
+
return useMemo(
|
|
69
|
+
() => (registry === null ? null : { dismissAll: registry.dismissAll, dismiss: registry.dismiss }),
|
|
70
|
+
[registry]
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Holds the registry: the pure reducer's state, mirrored into React state for
|
|
76
|
+
* the frames that read a `zIndex` from it, and a map from sheet id to the
|
|
77
|
+
* methods a root registered.
|
|
78
|
+
*
|
|
79
|
+
* The reducer runs against a ref and its result is then set, rather than
|
|
80
|
+
* inside a `setState` updater, because a `replace` open has a side effect —
|
|
81
|
+
* telling the displaced sheets to close — and an updater may run twice.
|
|
82
|
+
*/
|
|
83
|
+
export function SheetRegistryProvider({ children }: { children?: ReactNode }): ReactElement {
|
|
84
|
+
const current = useRef<SheetRegistryState>(INITIAL_REGISTRY);
|
|
85
|
+
const [state, setState] = useState<SheetRegistryState>(INITIAL_REGISTRY);
|
|
86
|
+
const methods = useRef(new Map<string, SheetRegistryMethods>());
|
|
87
|
+
|
|
88
|
+
const register = useCallback((id: string, sheet: SheetRegistryMethods) => {
|
|
89
|
+
methods.current.set(id, sheet);
|
|
90
|
+
return () => {
|
|
91
|
+
if (methods.current.get(id) === sheet) methods.current.delete(id);
|
|
92
|
+
};
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
const present = useCallback((id: string, host: string, behavior: StackBehavior) => {
|
|
96
|
+
const result = reduceRegistry(current.current, { type: "open", id, host, behavior });
|
|
97
|
+
current.current = result.state;
|
|
98
|
+
setState(result.state);
|
|
99
|
+
for (const victim of result.closed) methods.current.get(victim)?.close();
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
const dismissed = useCallback((id: string) => {
|
|
103
|
+
const result = reduceRegistry(current.current, { type: "close", id });
|
|
104
|
+
if (result.state === current.current) return;
|
|
105
|
+
current.current = result.state;
|
|
106
|
+
setState(result.state);
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
const dismiss = useCallback((id: string) => {
|
|
110
|
+
methods.current.get(id)?.close();
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
const dismissAll = useCallback(() => {
|
|
114
|
+
for (const entry of current.current.open) methods.current.get(entry.id)?.close();
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
const value = useMemo<SheetRegistryValue>(
|
|
118
|
+
() => ({ state, register, present, dismissed, dismiss, dismissAll }),
|
|
119
|
+
[state, register, present, dismissed, dismiss, dismissAll]
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return <SheetRegistryContext.Provider value={value}>{children}</SheetRegistryContext.Provider>;
|
|
123
|
+
}
|
|
124
|
+
SheetRegistryProvider.displayName = "DelacourBottomSheet.RegistryProvider";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { INITIAL_REGISTRY, isTop, reduceRegistry, type SheetRegistryState, topOf, zIndexOf } from "./sheet-registry";
|
|
3
|
+
|
|
4
|
+
function open(state: SheetRegistryState, id: string, host = "root", behavior: "push" | "replace" = "push") {
|
|
5
|
+
return reduceRegistry(state, { type: "open", id, host, behavior });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("sheet registry", () => {
|
|
9
|
+
test("opening assigns increasing z, later on top", () => {
|
|
10
|
+
const a = open(INITIAL_REGISTRY, "a");
|
|
11
|
+
const b = open(a.state, "b");
|
|
12
|
+
expect(zIndexOf(b.state, "a")).toBeGreaterThan(0);
|
|
13
|
+
expect(zIndexOf(b.state, "b")).toBeGreaterThan(zIndexOf(b.state, "a"));
|
|
14
|
+
expect(topOf(b.state, "root")).toBe("b");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("reopening a sheet already open brings it to the top", () => {
|
|
18
|
+
const a = open(INITIAL_REGISTRY, "a");
|
|
19
|
+
const b = open(a.state, "b");
|
|
20
|
+
const again = open(b.state, "a");
|
|
21
|
+
expect(topOf(again.state, "root")).toBe("a");
|
|
22
|
+
expect(again.state.open.filter((entry) => entry.id === "a")).toHaveLength(1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("push closes nothing", () => {
|
|
26
|
+
const a = open(INITIAL_REGISTRY, "a");
|
|
27
|
+
const b = open(a.state, "b");
|
|
28
|
+
expect(b.closed).toEqual([]);
|
|
29
|
+
expect(zIndexOf(b.state, "a")).toBeGreaterThan(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("replace closes the other open sheets in the same host and reports them", () => {
|
|
33
|
+
const a = open(INITIAL_REGISTRY, "a");
|
|
34
|
+
const b = open(a.state, "b");
|
|
35
|
+
const c = open(b.state, "c", "root", "replace");
|
|
36
|
+
expect([...c.closed].sort()).toEqual(["a", "b"]);
|
|
37
|
+
expect(zIndexOf(c.state, "a")).toBe(0);
|
|
38
|
+
expect(zIndexOf(c.state, "b")).toBe(0);
|
|
39
|
+
expect(topOf(c.state, "root")).toBe("c");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("replace leaves sheets in another host alone", () => {
|
|
43
|
+
const a = open(INITIAL_REGISTRY, "a", "modal");
|
|
44
|
+
const b = open(a.state, "b", "root", "replace");
|
|
45
|
+
expect(b.closed).toEqual([]);
|
|
46
|
+
expect(topOf(b.state, "modal")).toBe("a");
|
|
47
|
+
expect(topOf(b.state, "root")).toBe("b");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("closing removes the entry and the next one down is top", () => {
|
|
51
|
+
const a = open(INITIAL_REGISTRY, "a");
|
|
52
|
+
const b = open(a.state, "b");
|
|
53
|
+
const closed = reduceRegistry(b.state, { type: "close", id: "b" });
|
|
54
|
+
expect(topOf(closed.state, "root")).toBe("a");
|
|
55
|
+
expect(zIndexOf(closed.state, "b")).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("closing an unknown sheet returns the same state", () => {
|
|
59
|
+
const a = open(INITIAL_REGISTRY, "a");
|
|
60
|
+
const closed = reduceRegistry(a.state, { type: "close", id: "nope" });
|
|
61
|
+
expect(closed.state).toBe(a.state);
|
|
62
|
+
expect(closed.closed).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("top of an empty host is null; z of a closed sheet is zero", () => {
|
|
66
|
+
expect(topOf(INITIAL_REGISTRY, "root")).toBeNull();
|
|
67
|
+
expect(zIndexOf(INITIAL_REGISTRY, "a")).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("z never reuses a number after a close", () => {
|
|
71
|
+
const a = open(INITIAL_REGISTRY, "a");
|
|
72
|
+
const zA = zIndexOf(a.state, "a");
|
|
73
|
+
const closed = reduceRegistry(a.state, { type: "close", id: "a" });
|
|
74
|
+
const b = open(closed.state, "b");
|
|
75
|
+
expect(zIndexOf(b.state, "b")).toBeGreaterThan(zA);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("isTop is true only for the topmost sheet of its own host", () => {
|
|
79
|
+
const a = open(INITIAL_REGISTRY, "a");
|
|
80
|
+
const b = open(a.state, "b");
|
|
81
|
+
const m = open(b.state, "m", "modal");
|
|
82
|
+
expect(isTop(m.state, "a")).toBe(false);
|
|
83
|
+
expect(isTop(m.state, "b")).toBe(true);
|
|
84
|
+
expect(isTop(m.state, "m")).toBe(true);
|
|
85
|
+
expect(isTop(m.state, "nope")).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** One sheet that is currently presented — open, or on its way closed. */
|
|
2
|
+
export type SheetRegistryEntry = {
|
|
3
|
+
id: string;
|
|
4
|
+
/** The host the sheet's frame lives in; `replace` and `top` are scoped to it. */
|
|
5
|
+
host: string;
|
|
6
|
+
/** Later opens get larger numbers, so the sibling order in a host is the open order. */
|
|
7
|
+
z: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type SheetRegistryState = {
|
|
11
|
+
nextZ: number;
|
|
12
|
+
open: readonly SheetRegistryEntry[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const INITIAL_REGISTRY: SheetRegistryState = { nextZ: 1, open: [] };
|
|
16
|
+
|
|
17
|
+
export type StackBehavior = "push" | "replace";
|
|
18
|
+
|
|
19
|
+
export type SheetRegistryAction =
|
|
20
|
+
| { type: "open"; id: string; host: string; behavior: StackBehavior }
|
|
21
|
+
| { type: "close"; id: string };
|
|
22
|
+
|
|
23
|
+
export type SheetRegistryResult = {
|
|
24
|
+
state: SheetRegistryState;
|
|
25
|
+
/** Sheets the action displaced: a `replace` open's victims. The provider tells each to close. */
|
|
26
|
+
closed: readonly string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The registry as a pure reducer over plain data, so the z-order, `replace` and
|
|
31
|
+
* `dismissAll` are testable without a renderer.
|
|
32
|
+
*
|
|
33
|
+
* An `open` stamps the next `z` and puts the sheet on top of its host; a sheet
|
|
34
|
+
* already open is re-stamped rather than duplicated, so re-presenting brings
|
|
35
|
+
* it forward. With `replace`, every other sheet in the same host is dropped
|
|
36
|
+
* and reported in `closed` — the provider calls their `close()`, and their
|
|
37
|
+
* own settle at `-1` is then a no-op here. A `close` of a sheet that is not
|
|
38
|
+
* open returns the same state object, so a settle that races an unmount
|
|
39
|
+
* causes no render.
|
|
40
|
+
*/
|
|
41
|
+
export function reduceRegistry(state: SheetRegistryState, action: SheetRegistryAction): SheetRegistryResult {
|
|
42
|
+
if (action.type === "close") {
|
|
43
|
+
if (!state.open.some((entry) => entry.id === action.id)) return { state, closed: [] };
|
|
44
|
+
return { state: { ...state, open: state.open.filter((entry) => entry.id !== action.id) }, closed: [] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const others = state.open.filter((entry) => entry.id !== action.id);
|
|
48
|
+
const displaced = action.behavior === "replace" ? others.filter((entry) => entry.host === action.host) : [];
|
|
49
|
+
const kept = displaced.length === 0 ? others : others.filter((entry) => entry.host !== action.host);
|
|
50
|
+
const entry: SheetRegistryEntry = { id: action.id, host: action.host, z: state.nextZ };
|
|
51
|
+
return {
|
|
52
|
+
state: { nextZ: state.nextZ + 1, open: [...kept, entry] },
|
|
53
|
+
closed: displaced.map((victim) => victim.id),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The sheet drawn on top in `host`, or `null` when nothing is presented there. */
|
|
58
|
+
export function topOf(state: SheetRegistryState, host: string): string | null {
|
|
59
|
+
let top: SheetRegistryEntry | null = null;
|
|
60
|
+
for (const entry of state.open) {
|
|
61
|
+
if (entry.host !== host) continue;
|
|
62
|
+
if (top === null || entry.z > top.z) top = entry;
|
|
63
|
+
}
|
|
64
|
+
return top === null ? null : top.id;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The sheet's `zIndex` while presented, `0` otherwise. */
|
|
68
|
+
export function zIndexOf(state: SheetRegistryState, id: string): number {
|
|
69
|
+
const entry = state.open.find((candidate) => candidate.id === id);
|
|
70
|
+
return entry === undefined ? 0 : entry.z;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether `id` is the sheet on top of its own host — the one a back press should close. */
|
|
74
|
+
export function isTop(state: SheetRegistryState, id: string): boolean {
|
|
75
|
+
const entry = state.open.find((candidate) => candidate.id === id);
|
|
76
|
+
if (entry === undefined) return false;
|
|
77
|
+
return topOf(state, entry.host) === id;
|
|
78
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { read, relative, SRC, sourceFiles } from "./source-tree.test";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Modules `src/core` may not import a runtime value from.
|
|
7
|
+
*
|
|
8
|
+
* React Native ships Flow-typed source that Bun's transpiler cannot parse, so
|
|
9
|
+
* one of these anywhere under `core/` takes the *entire* suite down — and the
|
|
10
|
+
* error names whichever file happened to import the offender first, not the
|
|
11
|
+
* file that broke the rule. Hence this test: it fails first, and it fails by
|
|
12
|
+
* name.
|
|
13
|
+
*
|
|
14
|
+
* Type-only imports are fine. Types are erased before Bun sees them.
|
|
15
|
+
*/
|
|
16
|
+
const FORBIDDEN = [
|
|
17
|
+
"react",
|
|
18
|
+
"react-native",
|
|
19
|
+
"react-dom",
|
|
20
|
+
"react-native-reanimated",
|
|
21
|
+
"react-native-gesture-handler",
|
|
22
|
+
"react-native-worklets",
|
|
23
|
+
"react-native-keyboard-controller",
|
|
24
|
+
"react-native-safe-area-context",
|
|
25
|
+
"react-native-teleport",
|
|
26
|
+
"react-native-pulsar",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const IMPORT = /^\s*import\s+([\s\S]*?)\s*from\s*["']([^"']+)["']/gm;
|
|
30
|
+
|
|
31
|
+
/** Whether an import clause brings in nothing but types. */
|
|
32
|
+
function isTypeOnly(clause: string): boolean {
|
|
33
|
+
const trimmed = clause.trim();
|
|
34
|
+
if (trimmed.startsWith("type ")) return true;
|
|
35
|
+
|
|
36
|
+
const named = trimmed.match(/^\{([\s\S]*)\}$/);
|
|
37
|
+
if (!named) return false;
|
|
38
|
+
|
|
39
|
+
return (named[1] as string)
|
|
40
|
+
.split(",")
|
|
41
|
+
.map((specifier) => specifier.trim())
|
|
42
|
+
.filter((specifier) => specifier !== "")
|
|
43
|
+
.every((specifier) => specifier.startsWith("type "));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("core purity", () => {
|
|
47
|
+
const files = sourceFiles(join(SRC, "core"));
|
|
48
|
+
|
|
49
|
+
test("finds the core modules, so a broken walker cannot pass silently", () => {
|
|
50
|
+
expect(files.length).toBeGreaterThan(20);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("no module under src/core imports a React Native runtime value", () => {
|
|
54
|
+
const offenders: string[] = [];
|
|
55
|
+
|
|
56
|
+
for (const file of files) {
|
|
57
|
+
const source = read(file);
|
|
58
|
+
IMPORT.lastIndex = 0;
|
|
59
|
+
for (let match = IMPORT.exec(source); match !== null; match = IMPORT.exec(source)) {
|
|
60
|
+
const clause = match[1] as string;
|
|
61
|
+
const specifier = match[2] as string;
|
|
62
|
+
const root = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
|
|
63
|
+
if (!FORBIDDEN.includes(root as string)) continue;
|
|
64
|
+
if (isTypeOnly(clause)) continue;
|
|
65
|
+
offenders.push(`${relative(file)} imports ${specifier}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
expect(offenders).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// `core/` is the bottom of the package. A relative import that resolves
|
|
73
|
+
// outside it would pull a hook, a component or a native module into the
|
|
74
|
+
// maths — and into `bun test`.
|
|
75
|
+
test("no module under src/core reaches sideways or upward out of core", () => {
|
|
76
|
+
const core = join(SRC, "core");
|
|
77
|
+
const offenders: string[] = [];
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const source = read(file);
|
|
80
|
+
IMPORT.lastIndex = 0;
|
|
81
|
+
for (let match = IMPORT.exec(source); match !== null; match = IMPORT.exec(source)) {
|
|
82
|
+
const specifier = match[2] as string;
|
|
83
|
+
if (!specifier.startsWith(".")) continue;
|
|
84
|
+
const target = resolve(dirname(file), specifier);
|
|
85
|
+
if (!target.startsWith(`${core}/`)) offenders.push(`${relative(file)} imports ${specifier}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
expect(offenders).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
});
|