@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.
Files changed (143) hide show
  1. package/README.md +132 -0
  2. package/package.json +57 -0
  3. package/src/animation/AGENTS.md +58 -0
  4. package/src/animation/animation.types.ts +35 -0
  5. package/src/animation/resolve-animation.ts +75 -0
  6. package/src/animation/use-animate-to.ts +107 -0
  7. package/src/animation/use-settle-callbacks.ts +60 -0
  8. package/src/components/AGENTS.md +80 -0
  9. package/src/components/bottom-sheet-background.tsx +34 -0
  10. package/src/components/bottom-sheet-close.tsx +45 -0
  11. package/src/components/bottom-sheet-container.tsx +79 -0
  12. package/src/components/bottom-sheet-content.tsx +102 -0
  13. package/src/components/bottom-sheet-description.tsx +33 -0
  14. package/src/components/bottom-sheet-footer.tsx +97 -0
  15. package/src/components/bottom-sheet-handle.tsx +72 -0
  16. package/src/components/bottom-sheet-overlay.tsx +91 -0
  17. package/src/components/bottom-sheet-portal.tsx +96 -0
  18. package/src/components/bottom-sheet-text-input.tsx +36 -0
  19. package/src/components/bottom-sheet-title.tsx +30 -0
  20. package/src/components/bottom-sheet-trigger.tsx +51 -0
  21. package/src/components/bottom-sheet.context.tsx +134 -0
  22. package/src/components/bottom-sheet.tsx +475 -0
  23. package/src/components/bottom-sheet.types.ts +235 -0
  24. package/src/core/AGENTS.md +160 -0
  25. package/src/core/animation/select-animation.test.ts +57 -0
  26. package/src/core/animation/select-animation.ts +104 -0
  27. package/src/core/backdrop/backdrop-opacity.test.ts +49 -0
  28. package/src/core/backdrop/backdrop-opacity.ts +21 -0
  29. package/src/core/detents/dynamic-detent.test.ts +40 -0
  30. package/src/core/detents/dynamic-detent.ts +41 -0
  31. package/src/core/detents/index-for-height.test.ts +65 -0
  32. package/src/core/detents/index-for-height.ts +44 -0
  33. package/src/core/detents/normalize-detents.test.ts +72 -0
  34. package/src/core/detents/normalize-detents.ts +63 -0
  35. package/src/core/detents/over-drag.test.ts +46 -0
  36. package/src/core/detents/over-drag.ts +23 -0
  37. package/src/core/detents/select-snap-height.test.ts +52 -0
  38. package/src/core/detents/select-snap-height.ts +44 -0
  39. package/src/core/detents/sheet-state.test.ts +42 -0
  40. package/src/core/detents/sheet-state.ts +32 -0
  41. package/src/core/footer/body-inset.test.ts +95 -0
  42. package/src/core/footer/body-inset.ts +65 -0
  43. package/src/core/footer/bottom-band.test.ts +46 -0
  44. package/src/core/footer/bottom-band.ts +30 -0
  45. package/src/core/footer/footer-top.test.ts +61 -0
  46. package/src/core/footer/footer-top.ts +27 -0
  47. package/src/core/footer/sheet-insets.test.ts +27 -0
  48. package/src/core/footer/sheet-insets.ts +30 -0
  49. package/src/core/geometry/clamp-height.test.ts +25 -0
  50. package/src/core/geometry/clamp-height.ts +12 -0
  51. package/src/core/geometry/closed-height.test.ts +37 -0
  52. package/src/core/geometry/closed-height.ts +29 -0
  53. package/src/core/geometry/detached-frame.test.ts +50 -0
  54. package/src/core/geometry/detached-frame.ts +49 -0
  55. package/src/core/geometry/position.test.ts +21 -0
  56. package/src/core/geometry/position.ts +13 -0
  57. package/src/core/geometry/surface-height.test.ts +30 -0
  58. package/src/core/geometry/surface-height.ts +22 -0
  59. package/src/core/haptic/crossed-detent.test.ts +43 -0
  60. package/src/core/haptic/crossed-detent.ts +33 -0
  61. package/src/core/index.ts +92 -0
  62. package/src/core/intent/layout-ready.test.ts +37 -0
  63. package/src/core/intent/layout-ready.ts +24 -0
  64. package/src/core/intent/resolve-intent.test.ts +163 -0
  65. package/src/core/intent/resolve-intent.ts +98 -0
  66. package/src/core/keyboard/container-layout-guard.test.ts +32 -0
  67. package/src/core/keyboard/container-layout-guard.ts +31 -0
  68. package/src/core/keyboard/content-area.test.ts +34 -0
  69. package/src/core/keyboard/content-area.ts +26 -0
  70. package/src/core/keyboard/keyboard-lift.test.ts +54 -0
  71. package/src/core/keyboard/keyboard-lift.ts +47 -0
  72. package/src/core/keyboard/keyboard-owner.test.ts +35 -0
  73. package/src/core/keyboard/keyboard-owner.ts +25 -0
  74. package/src/core/keyboard/keyboard-ownership.test.ts +69 -0
  75. package/src/core/keyboard/keyboard-ownership.ts +39 -0
  76. package/src/core/keyboard/keyboard-reset-guard.test.ts +33 -0
  77. package/src/core/keyboard/keyboard-reset-guard.ts +33 -0
  78. package/src/core/keyboard/keyboard-step.test.ts +66 -0
  79. package/src/core/keyboard/keyboard-step.ts +53 -0
  80. package/src/core/machine/define-sheet-machine.test.ts +187 -0
  81. package/src/core/machine/define-sheet-machine.ts +35 -0
  82. package/src/core/machine/index.ts +23 -0
  83. package/src/core/machine/machine.types.ts +80 -0
  84. package/src/core/machine/step-transition.test.ts +46 -0
  85. package/src/core/machine/step-transition.ts +59 -0
  86. package/src/core/machine/transition.test.ts +51 -0
  87. package/src/core/machine/transition.ts +58 -0
  88. package/src/core/result.test.ts +15 -0
  89. package/src/core/result.ts +20 -0
  90. package/src/core/scroll/scroll-lock.test.ts +40 -0
  91. package/src/core/scroll/scroll-lock.ts +35 -0
  92. package/src/core/scroll/scroll-pan.test.ts +99 -0
  93. package/src/core/scroll/scroll-pan.ts +96 -0
  94. package/src/core/sheet.types.ts +93 -0
  95. package/src/display-name.test.ts +181 -0
  96. package/src/docs.test.ts +45 -0
  97. package/src/flat-worklet.test.ts +130 -0
  98. package/src/gesture/AGENTS.md +71 -0
  99. package/src/gesture/gesture.types.ts +37 -0
  100. package/src/gesture/use-scroll-lock.ts +162 -0
  101. package/src/gesture/use-sheet-pan.ts +250 -0
  102. package/src/index.ts +127 -0
  103. package/src/keyboard/AGENTS.md +84 -0
  104. package/src/keyboard/use-bottom-sheet-text-input.ts +79 -0
  105. package/src/keyboard/use-keyboard-animation-guard.ts +59 -0
  106. package/src/keyboard/use-sheet-keyboard.ts +163 -0
  107. package/src/layout/AGENTS.md +52 -0
  108. package/src/layout/use-container-layout.ts +63 -0
  109. package/src/layout/use-measure-height.ts +40 -0
  110. package/src/lib/AGENTS.md +35 -0
  111. package/src/lib/compose-refs.ts +37 -0
  112. package/src/lib/merge-props.ts +42 -0
  113. package/src/lib/slot.tsx +44 -0
  114. package/src/lib/use-controllable-state.ts +45 -0
  115. package/src/no-classname.test.ts +27 -0
  116. package/src/portal/AGENTS.md +101 -0
  117. package/src/portal/bottom-sheet-host.tsx +67 -0
  118. package/src/portal/bottom-sheet-provider.tsx +33 -0
  119. package/src/portal/host.context.tsx +25 -0
  120. package/src/portal/sheet-registry.context.tsx +124 -0
  121. package/src/portal/sheet-registry.test.ts +87 -0
  122. package/src/portal/sheet-registry.ts +78 -0
  123. package/src/purity.test.ts +90 -0
  124. package/src/scrollable/AGENTS.md +123 -0
  125. package/src/scrollable/bottom-sheet-flat-list.tsx +15 -0
  126. package/src/scrollable/bottom-sheet-scroll-view.tsx +15 -0
  127. package/src/scrollable/bottom-sheet-section-list.tsx +22 -0
  128. package/src/scrollable/create-bottom-sheet-scrollable.tsx +248 -0
  129. package/src/scrollable/scrollable.types.ts +76 -0
  130. package/src/source-tree.test.ts +33 -0
  131. package/src/state/AGENTS.md +63 -0
  132. package/src/state/state.types.ts +134 -0
  133. package/src/state/use-detents.ts +43 -0
  134. package/src/state/use-sheet-geometry.ts +178 -0
  135. package/src/state/use-sheet-intents.ts +144 -0
  136. package/src/state/use-sheet-state.ts +157 -0
  137. package/src/steps/AGENTS.md +100 -0
  138. package/src/steps/bottom-sheet-step.tsx +61 -0
  139. package/src/steps/bottom-sheet-steps.tsx +215 -0
  140. package/src/steps/steps.context.tsx +73 -0
  141. package/src/steps/steps.types.ts +61 -0
  142. package/src/steps/use-sheet-machine.ts +70 -0
  143. 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
+ });