@delacour/react-native-ui 0.1.0-alpha.20261005222904 → 0.1.0-alpha.20261006134908

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20261005222904",
3
+ "version": "0.1.0-alpha.20261006134908",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,6 +53,7 @@
53
53
  "./label": "./src/components/label/index.ts",
54
54
  "./list-group": "./src/components/list-group/index.ts",
55
55
  "./meter": "./src/components/meter/index.ts",
56
+ "./overlay": "./src/components/overlay/index.ts",
56
57
  "./pressable": "./src/components/pressable/index.ts",
57
58
  "./progress": "./src/components/progress/index.ts",
58
59
  "./provider": "./src/components/provider/index.ts",
@@ -100,8 +101,8 @@
100
101
  },
101
102
  "peerDependencies": {
102
103
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
103
- "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261005222904",
104
- "@delacour/react-native-charts": "0.1.0-alpha.20261005222904",
104
+ "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261006134908",
105
+ "@delacour/react-native-charts": "0.1.0-alpha.20261006134908",
105
106
  "@legendapp/list": ">=3.3",
106
107
  "expo-linear-gradient": ">=15",
107
108
  "expo-router": ">=57",
@@ -44,11 +44,11 @@ Compound root plus `Avatar.Badge` and `Avatar.Group`.
44
44
  the clipped circle. An `Avatar.Badge` hangs over the circle's edge, and a
45
45
  single clipped box would cut it in half. A test asserts `overflow-hidden` is on
46
46
  `face` and never on `root`.
47
- - **Initials are the first letter of the first and last word.** `Mary Jane
48
- Watson` is `MW`, the way a person signs, and `Cher` is `C`.
47
+ - **Initials are the first letter of the first and last word.** `Anahera Rose
48
+ Tui` is `AT`, the way a person signs, and `Mihi` is `M`.
49
49
  `resolveAvatarInitials` walks code points rather than UTF-16 units, so a letter
50
50
  outside the basic plane is never split into a replacement box, and skips
51
- leading punctuation so `"(Kate)"` still gives `K`. `fallback` overrides the
51
+ leading punctuation so `"(Aria)"` still gives `A`. `fallback` overrides the
52
52
  drawn text only; `name` is still what a screen reader reads.
53
53
  - **Two axes paint the fallback, on `Badge`'s six colours.** `variant` is `soft`
54
54
  or `solid`, `color` is `default` … `info`, and the twelve cells live in
@@ -77,7 +77,7 @@ Compound root plus `Avatar.Badge` and `Avatar.Group`.
77
77
  - **The avatar is one element to assistive technology.** The root is
78
78
  `accessible`, so an `Avatar.Badge` inside it is never focused on its own. A
79
79
  presence dot has no words; put the status into the avatar's
80
- `accessibilityLabel` (`"Kate Austen, online"`).
80
+ `accessibilityLabel` (`"Aria Whitlock, online"`).
81
81
  - **`Avatar.Badge` is a pin, and a dot only when empty.** Given children — a
82
82
  `Badge` with a count, an `Icon` — it positions them and adds nothing else, so a
83
83
  count keeps the look it has everywhere else. Empty, it draws a dot sized to
@@ -165,7 +165,7 @@ function AvatarRoot({
165
165
  * is tried again. With no `fallback` and no `name` the fallback is a person glyph.
166
166
  *
167
167
  * `name` is what a screen reader reads, and its initials — first and last word,
168
- * `Mary Jane Watson` → `MW` — are the default fallback. `fallback` overrides the
168
+ * `Anahera Rose Tui` → `AT` — are the default fallback. `fallback` overrides the
169
169
  * drawn text only.
170
170
  *
171
171
  * `variant` and `color` paint the fallback surface, on the same six colours a
@@ -180,10 +180,10 @@ function AvatarRoot({
180
180
  * corner, outside the clipped circle so it is never cut in half.
181
181
  *
182
182
  * @example
183
- * <Avatar name="Kate Austen" source={{ uri: user.avatarUrl }} />
183
+ * <Avatar name="Aria Whitlock" source={{ uri: user.avatarUrl }} />
184
184
  *
185
185
  * @example
186
- * <Avatar accessibilityLabel="Kate Austen, online" name="Kate Austen">
186
+ * <Avatar accessibilityLabel="Aria Whitlock, online" name="Aria Whitlock">
187
187
  * <Avatar.Badge placement="bottom-right" />
188
188
  * </Avatar>
189
189
  *
@@ -10,17 +10,17 @@ import {
10
10
 
11
11
  describe("resolveAvatarInitials", () => {
12
12
  test("takes the first letter of the first and last words", () => {
13
- expect(resolveAvatarInitials("Kate Austen")).toBe("KA");
14
- expect(resolveAvatarInitials("Mary Jane Watson")).toBe("MW");
13
+ expect(resolveAvatarInitials("Aria Whitlock")).toBe("AW");
14
+ expect(resolveAvatarInitials("Anahera Rose Tui")).toBe("AT");
15
15
  });
16
16
 
17
17
  test("a single word gives a single letter", () => {
18
- expect(resolveAvatarInitials("Cher")).toBe("C");
18
+ expect(resolveAvatarInitials("Mihi")).toBe("M");
19
19
  });
20
20
 
21
21
  test("uppercases, and ignores surrounding and repeated whitespace", () => {
22
- expect(resolveAvatarInitials(" oliver lee ")).toBe("OL");
23
- expect(resolveAvatarInitials("ada\tlovelace")).toBe("AL");
22
+ expect(resolveAvatarInitials(" rawiri kemp ")).toBe("RK");
23
+ expect(resolveAvatarInitials("lena\tvarga")).toBe("LV");
24
24
  });
25
25
 
26
26
  test("is empty for an empty, blank or missing name", () => {
@@ -31,15 +31,15 @@ describe("resolveAvatarInitials", () => {
31
31
 
32
32
  test("never splits a letter outside the basic plane", () => {
33
33
  // A naive `word[0]` would take half of a surrogate pair and draw a box.
34
- expect(resolveAvatarInitials("𝒜da 𝒵ed")).toBe("𝒜𝒵");
34
+ expect(resolveAvatarInitials("𝒜ria 𝒲hitlock")).toBe("𝒜𝒲");
35
35
  });
36
36
 
37
37
  test("keeps letters in scripts with no case", () => {
38
- expect(resolveAvatarInitials("李 小龍")).toBe("李小");
38
+ expect(resolveAvatarInitials("森山 健二")).toBe("森健");
39
39
  });
40
40
 
41
41
  test("skips a leading punctuation mark in a word", () => {
42
- expect(resolveAvatarInitials("(Kate) 'Austen'")).toBe("KA");
42
+ expect(resolveAvatarInitials("(Aria) 'Whitlock'")).toBe("AW");
43
43
  });
44
44
  });
45
45
 
@@ -156,11 +156,11 @@ describe("resolveAvatarOverflowLabel", () => {
156
156
 
157
157
  describe("resolveAvatarAccessibilityLabel", () => {
158
158
  test("prefers the name", () => {
159
- expect(resolveAvatarAccessibilityLabel({ name: "Kate Austen", fallback: "KA" })).toBe("Kate Austen");
159
+ expect(resolveAvatarAccessibilityLabel({ name: "Aria Whitlock", fallback: "AW" })).toBe("Aria Whitlock");
160
160
  });
161
161
 
162
162
  test("falls back to the fallback text", () => {
163
- expect(resolveAvatarAccessibilityLabel({ fallback: "KA" })).toBe("KA");
163
+ expect(resolveAvatarAccessibilityLabel({ fallback: "AW" })).toBe("AW");
164
164
  });
165
165
 
166
166
  test("is undefined when there is nothing to read", () => {
@@ -169,6 +169,6 @@ describe("resolveAvatarAccessibilityLabel", () => {
169
169
  });
170
170
 
171
171
  test("an explicit label wins over both", () => {
172
- expect(resolveAvatarAccessibilityLabel({ accessibilityLabel: "You", name: "Kate Austen" })).toBe("You");
172
+ expect(resolveAvatarAccessibilityLabel({ accessibilityLabel: "You", name: "Aria Whitlock" })).toBe("You");
173
173
  });
174
174
  });
@@ -23,7 +23,7 @@ function initialOf(word: string): string {
23
23
 
24
24
  /**
25
25
  * Up to two initials from a name: the first letter of the first word and of the
26
- * last. `Mary Jane Watson` reads `MW`, which is how a person signs rather than
26
+ * last. `Anahera Rose Tui` reads `AT`, which is how a person signs rather than
27
27
  * how a form abbreviates them. A single word gives a single letter.
28
28
  *
29
29
  * Walks code points rather than UTF-16 units, so a letter outside the basic
@@ -0,0 +1,42 @@
1
+ import {
2
+ BottomSheetProvider as HeadlessProvider,
3
+ type BottomSheetProviderProps as HeadlessProviderProps,
4
+ } from "@delacour/react-native-bottom-sheet";
5
+ import type { ReactElement } from "react";
6
+ import { TeleportProvidedContext, useIsTeleportProvided } from "../overlay/overlay.context";
7
+
8
+ export type BottomSheetProviderProps = Omit<HeadlessProviderProps, "hasPortalProvider">;
9
+
10
+ /**
11
+ * The engine's provider, mounted once by the app — and aware of
12
+ * `OverlayProvider`.
13
+ *
14
+ * Both providers need teleport's `PortalProvider`, and there must be exactly
15
+ * one: teleport registers hosts natively by name, so two would mean two hosts
16
+ * called `"root"`. Whichever of the two mounts outermost mounts it and says so
17
+ * through `TeleportProvidedContext`; the inner one skips its own. Sheets and
18
+ * overlays then share one `"root"` host, ordered by `zIndex` — every overlay's
19
+ * band starts above every sheet's.
20
+ *
21
+ * The overlay folder's context file is a leaf (rule 3): it imports nothing but
22
+ * React, so this cross-folder import cannot close a cycle.
23
+ *
24
+ * @example
25
+ * <DelacourProvider>
26
+ * <OverlayProvider>
27
+ * <BottomSheetProvider>
28
+ * <Stack />
29
+ * </BottomSheetProvider>
30
+ * </OverlayProvider>
31
+ * </DelacourProvider>
32
+ */
33
+ export function BottomSheetProvider({ children }: BottomSheetProviderProps): ReactElement {
34
+ const isTeleportProvided = useIsTeleportProvided();
35
+
36
+ return (
37
+ <HeadlessProvider hasPortalProvider={isTeleportProvided}>
38
+ <TeleportProvidedContext.Provider value>{children}</TeleportProvidedContext.Provider>
39
+ </HeadlessProvider>
40
+ );
41
+ }
42
+ BottomSheetProvider.displayName = "DelacourUI.BottomSheet.Provider";
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  BottomSheetHost,
3
- BottomSheetProvider,
4
3
  BottomSheet as Headless,
5
4
  type BottomSheetProps as HeadlessBottomSheetProps,
6
5
  } from "@delacour/react-native-bottom-sheet";
@@ -18,6 +17,7 @@ import { BottomSheetFooter } from "./bottom-sheet-footer";
18
17
  import { BottomSheetHandle } from "./bottom-sheet-handle";
19
18
  import { BottomSheetLegendList } from "./bottom-sheet-legend-list";
20
19
  import { BottomSheetOverlay } from "./bottom-sheet-overlay";
20
+ import { BottomSheetProvider } from "./bottom-sheet-provider";
21
21
  import { BottomSheetScrollView } from "./bottom-sheet-scroll-view";
22
22
  import { BottomSheetSectionList } from "./bottom-sheet-section-list";
23
23
  import { BottomSheetStep, BottomSheetSteps } from "./bottom-sheet-steps";
@@ -172,7 +172,7 @@ export const BottomSheet = Object.assign(BottomSheetRoot, {
172
172
  Step: BottomSheetStep,
173
173
  /** A place for sheets to teleport to other than the root — the recipe for a native modal. */
174
174
  Host: BottomSheetHost,
175
- /** The engine's provider. Mount it once, inside `DelacourProvider`. */
175
+ /** The engine's provider, sharing `OverlayProvider`'s teleport host. Mount it once, inside `DelacourProvider`. */
176
176
  Provider: BottomSheetProvider,
177
177
  displayName: "DelacourUI.BottomSheet",
178
178
  });
@@ -3,8 +3,6 @@ export {
3
3
  type BottomSheetContextValue,
4
4
  type BottomSheetHostProps,
5
5
  type BottomSheetPortalProps,
6
- BottomSheetProvider,
7
- type BottomSheetProviderProps,
8
6
  type BottomSheetRef,
9
7
  type BottomSheetRegistryValue,
10
8
  type BottomSheetTextInputHandlers,
@@ -46,6 +44,7 @@ export type { BottomSheetFooterProps } from "./bottom-sheet-footer";
46
44
  export type { BottomSheetHandleProps } from "./bottom-sheet-handle";
47
45
  export type { BottomSheetLegendListProps } from "./bottom-sheet-legend-list";
48
46
  export type { BottomSheetOverlayProps } from "./bottom-sheet-overlay";
47
+ export { BottomSheetProvider, type BottomSheetProviderProps } from "./bottom-sheet-provider";
49
48
  export type { BottomSheetScrollViewProps } from "./bottom-sheet-scroll-view";
50
49
  export type { BottomSheetSectionListProps } from "./bottom-sheet-section-list";
51
50
  export type { BottomSheetStepProps, BottomSheetStepsProps } from "./bottom-sheet-steps";
@@ -0,0 +1,89 @@
1
+ # Overlay
2
+
3
+ The layer under every overlay in this library — Dialog, Drawer, Popover, Tooltip, Toast,
4
+ Feedback. Not a component anyone renders on its own: the provider an app mounts once, and the
5
+ parts and hooks an overlay is drawn with.
6
+
7
+ `import { Overlay, OverlayProvider, useOverlayBackHandler, useOverlayPresence } from "@delacour/react-native-ui/overlay";`
8
+
9
+ `react-native-teleport` is an **optional peer**, as it is for `BottomSheet`: an app that never
10
+ imports an overlay never resolves it. The consequence is the same one thing to do — **mount
11
+ `OverlayProvider` once**, inside `DelacourProvider`, around `BottomSheetProvider` and the navigator.
12
+
13
+ ## Files
14
+
15
+ | File | What it holds |
16
+ | --- | --- |
17
+ | `index.ts` | → `@delacour/react-native-ui/overlay` |
18
+ | `overlay.tsx` | `Overlay` — the root is the provider: teleport's `PortalProvider` (skipped when one is already above), the registry and the touch-start fan-out; the `Object.assign` names `Portal` and `Scrim`. `OverlayProvider` is the same component, under the name a root layout reads best with |
19
+ | `overlay-portal.tsx` | `Overlay.Portal` — registers while mounted, teleports to `"root"` in a `box-none` absolute fill carrying the registry's `zIndex`; inline without a provider |
20
+ | `overlay-scrim.tsx` | `Overlay.Scrim` — `bg-overlay`, opacity follows presence, hidden from assistive technology |
21
+ | `overlay.context.tsx` | **Leaf.** `OverlayContext`, `TeleportProvidedContext` and their hooks. `bottom-sheet/` imports it |
22
+ | `overlay-registry.ts` | **Pure.** The z-order reducer, `zIndexOfOverlay`, `topOverlay`, `isTopOverlay`, `OVERLAY_LAYER_BASE` |
23
+ | `overlay-presence.ts` | **Pure.** `reducePresence` — `closed → entering → open → exiting → closed` |
24
+ | `overlay.variants.ts` | `overlayVariants` (`scrim`), `OVERLAY_MOTION`, `OVERLAY_SCRIM_TOKEN` |
25
+ | `use-overlay-presence.ts` | `useOverlayPresence` — keeps an overlay mounted through its exit, drives `progress` 0 → 1 |
26
+ | `use-overlay-back-handler.ts` | `useOverlayBackHandler` — Android back, only while the overlay is the top |
27
+ | `*.test.ts` | The registry, the presence machine, the scrim token and the motion constants |
28
+
29
+ ## Design
30
+
31
+ - **There is exactly one teleport `PortalProvider` in an app, and this is why the folder
32
+ exists.** Teleport registers its hosts natively by name, and the bottom-sheet engine's
33
+ provider already mounts one with a `"root"` host. Six overlays each mounting their own would
34
+ mean seven hosts called `"root"`. So `OverlayProvider` and this library's `BottomSheetProvider`
35
+ each mount teleport's provider only when `TeleportProvidedContext` says none is above, and set
36
+ it when they do — either mount order works, and the engine's provider learned
37
+ `hasPortalProvider` to make the skip possible. Everything draws into one `"root"` host.
38
+ - **One host means one z-order, so the layers are bands.** Sheets are stamped from 1 by the
39
+ engine's registry; overlays take `modal` 1000+, `anchored` 2000+, `toast` 3000+. Every overlay
40
+ draws over every sheet, a popover over the dialog that opened it, a toast over both. Within a
41
+ band the later open is on top, and a re-open brings an overlay forward. The number is base plus
42
+ **rank** in the band, never the raw open counter, so a long session cannot walk a dialog into
43
+ the popover band — a test opens five thousand to prove it. The cost: a sheet opened *from* a
44
+ dialog draws under it. Do not open a sheet from a dialog.
45
+ - **The registry entry lives exactly as long as `Overlay.Portal` is mounted.** Mount the portal
46
+ only while `useOverlayPresence().isPresent`, and the registry, the z-order and the back button
47
+ all follow presence with nothing else to keep in sync.
48
+ - **Back closes only the top overlay.** `useOverlayBackHandler` takes the portal's `id` and
49
+ subscribes only while that id is `topOverlay` among `modal` and `anchored` — a toast never takes
50
+ the back button. React Native calls the most recent listener first, and an overlay over a sheet
51
+ subscribed after it, so the overlay answers first and returns `true`.
52
+ - **Presence is a reducer so an interrupted animation is boring.** A re-open mid-exit goes back
53
+ to `entering` from wherever `progress` is; a close mid-entrance goes to `exiting`. The finish
54
+ callback reaches React only when the timing ran to the end, and a stale finish (`entered` while
55
+ `exiting`) is a no-op in the reducer, so the hook keeps no generation counter.
56
+ - **Overlay motion is behaviour, not decoration.** `isMotionCalm` does not still it — an overlay
57
+ that did not appear would change what the screen says — but every animation is finite, so an E2E
58
+ runner's settle wait ends anyway. Under reduce motion the fade still runs (opacity is not
59
+ motion) and `isReduced` tells the component to drop its transforms. Exit (160 ms) is quicker
60
+ than entrance (220 ms): a dismissed overlay should get out of the way.
61
+ - **The scrim is `bg-overlay` at opacity 1**, the token carrying its own alpha, for
62
+ `BottomSheet.Overlay`'s reason. It is React Native's `Pressable`, not this library's: it wants
63
+ no feedback or haptic, and a Gesture Handler tap there would race a drawer's pan for the touch.
64
+ It always takes the touch, even with no `onDismiss`, because the app under a non-dismissible
65
+ dialog must not be pressable. It is hidden from assistive technology; a screen reader user
66
+ dismisses through the panel's `onAccessibilityEscape` or the back button.
67
+ - **The portal's wrapper is `box-none`.** An overlay with no scrim — a toast, a tooltip — leaves
68
+ the app under it interactive.
69
+ - **The provider hears every touch that starts beneath it, and claims none.** Its children sit in a
70
+ `flex: 1` `View` whose `onTouchStart` fans out to `subscribeTouchStart` listeners. `onTouchStart`
71
+ is a bubbling touch event, not a responder negotiation, so it never stops the touch reaching its
72
+ target or a Gesture Handler gesture; and React Native bubbles it through the React tree rather
73
+ than the native one, so a touch inside a teleported overlay arrives too. It exists for Tooltip,
74
+ whose outside tap must close it *and* still press what it landed on — a full-screen catcher
75
+ under the panel, Popover's way, would swallow that tap, and nothing but a common ancestor of the
76
+ whole app can hear a touch on a view the tooltip knows nothing about.
77
+ - **No provider, no teleport.** Without `OverlayProvider` a portal renders inline, in an absolute
78
+ fill of the nearest positioned ancestor, and warns once in development; it may be clipped. That
79
+ is a working fallback for a `delacour add` copy in an app that has not mounted the provider yet,
80
+ not a supported layout.
81
+ - **`DelacourProvider` cannot mount this** — the peer decision in
82
+ [DelacourProvider](../provider/AGENTS.md): teleport is optional. `apps/playground/src/app/_layout.tsx`
83
+ is the reference mount.
84
+
85
+ ## Testing
86
+
87
+ `bun test` reaches the registry, the presence machine, the scrim token and the motion constants.
88
+ The provider, the portal and the hooks are verified on a simulator through every overlay's
89
+ gallery — the foundation has no gallery of its own, the way `DelacourProvider` has none.
@@ -0,0 +1,27 @@
1
+ export { Overlay, OverlayProvider, type OverlayProviderProps } from "./overlay";
2
+ export {
3
+ OverlayContext,
4
+ type OverlayContextValue,
5
+ TeleportProvidedContext,
6
+ useIsTeleportProvided,
7
+ useOptionalOverlay,
8
+ } from "./overlay.context";
9
+ export { OVERLAY_MOTION, OVERLAY_SCRIM_TOKEN, type OverlayVariantProps, overlayVariants } from "./overlay.variants";
10
+ export type { OverlayPortalProps } from "./overlay-portal";
11
+ export { isPresent, type PresenceEvent, type PresencePhase, presenceTarget, reducePresence } from "./overlay-presence";
12
+ export {
13
+ INITIAL_OVERLAY_REGISTRY,
14
+ isTopOverlay,
15
+ OVERLAY_LAYER_BASE,
16
+ OVERLAY_LAYERS,
17
+ type OverlayLayer,
18
+ type OverlayRegistryAction,
19
+ type OverlayRegistryEntry,
20
+ type OverlayRegistryState,
21
+ reduceOverlayRegistry,
22
+ topOverlay,
23
+ zIndexOfOverlay,
24
+ } from "./overlay-registry";
25
+ export type { OverlayScrimProps } from "./overlay-scrim";
26
+ export { type UseOverlayBackHandlerOptions, useOverlayBackHandler } from "./use-overlay-back-handler";
27
+ export { type OverlayPresence, type UseOverlayPresenceOptions, useOverlayPresence } from "./use-overlay-presence";
@@ -0,0 +1,84 @@
1
+ import { type ReactElement, type ReactNode, useEffect, useId } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { Portal } from "react-native-teleport";
4
+ import { useOptionalOverlay } from "./overlay.context";
5
+ import { type OverlayLayer, zIndexOfOverlay } from "./overlay-registry";
6
+
7
+ export type OverlayPortalProps = {
8
+ /** Which band of the z-order this overlay draws in. */
9
+ layer: OverlayLayer;
10
+ children?: ReactNode;
11
+ /**
12
+ * The overlay's registry id. Pass the same id to `useOverlayBackHandler` so
13
+ * the back button knows whether this overlay is the top. Defaults to a
14
+ * `useId()` of the portal's own.
15
+ */
16
+ id?: string;
17
+ /** Render where it is written instead of teleporting. Default false. */
18
+ isInline?: boolean;
19
+ /** The teleport host. Default `"root"` — the one `OverlayProvider`'s teleport provider fills the app with. */
20
+ hostName?: string;
21
+ };
22
+
23
+ let hasWarnedMissingProvider = false;
24
+
25
+ /**
26
+ * Draws its children over the whole app, in its layer of the z-order.
27
+ *
28
+ * Teleport moves the native view and leaves the React tree where it was, so
29
+ * every context provided around the overlay's trigger — a theme, a form, a
30
+ * query client — still reaches what the portal draws.
31
+ *
32
+ * The overlay is in the registry for exactly as long as this is mounted, so
33
+ * mount it only while the overlay is present (`useOverlayPresence`). Its
34
+ * wrapper is an absolute fill and `box-none`: it takes no touch of its own, so
35
+ * an overlay with no scrim — a toast, a tooltip — leaves the app under it
36
+ * interactive.
37
+ *
38
+ * Without an `OverlayProvider` above, or with `isInline`, the children render
39
+ * where they are written, in an absolute fill of the nearest positioned
40
+ * ancestor — and the missing provider warns once in development.
41
+ */
42
+ export function OverlayPortal({
43
+ layer,
44
+ children,
45
+ id,
46
+ isInline = false,
47
+ hostName = "root",
48
+ }: OverlayPortalProps): ReactElement {
49
+ const ownId = useId();
50
+ const overlayId = id ?? ownId;
51
+ const registry = useOptionalOverlay();
52
+
53
+ const present = registry?.present;
54
+ const dismissed = registry?.dismissed;
55
+ useEffect(() => {
56
+ if (present === undefined || dismissed === undefined) return;
57
+ present(overlayId, layer);
58
+ return () => dismissed(overlayId);
59
+ }, [present, dismissed, overlayId, layer]);
60
+
61
+ useEffect(() => {
62
+ if (!__DEV__ || registry !== null || isInline || hasWarnedMissingProvider) return;
63
+ hasWarnedMissingProvider = true;
64
+ console.warn(
65
+ "Overlay.Portal: an overlay rendered with no <OverlayProvider> above it, so it draws inline and may be clipped. Mount OverlayProvider once at the app root."
66
+ );
67
+ }, [registry, isInline]);
68
+
69
+ const zIndex = registry === null ? 0 : zIndexOfOverlay(registry.state, overlayId);
70
+ const frame = (
71
+ <View pointerEvents="box-none" style={[StyleSheet.absoluteFill, { zIndex }]}>
72
+ {children}
73
+ </View>
74
+ );
75
+
76
+ if (isInline || registry === null) return frame;
77
+
78
+ return (
79
+ <Portal hostName={hostName} style={[StyleSheet.absoluteFill, { zIndex }]}>
80
+ {frame}
81
+ </Portal>
82
+ );
83
+ }
84
+ OverlayPortal.displayName = "DelacourUI.Overlay.Portal";
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isPresent, type PresencePhase, presenceTarget, reducePresence } from "./overlay-presence";
3
+
4
+ describe("reducePresence", () => {
5
+ const cases: [PresencePhase, Parameters<typeof reducePresence>[1]["type"], PresencePhase][] = [
6
+ ["closed", "open", "entering"],
7
+ ["closed", "close", "closed"],
8
+ ["closed", "entered", "closed"],
9
+ ["closed", "exited", "closed"],
10
+ ["entering", "entered", "open"],
11
+ ["entering", "close", "exiting"],
12
+ ["entering", "open", "entering"],
13
+ ["entering", "exited", "entering"],
14
+ ["open", "close", "exiting"],
15
+ ["open", "open", "open"],
16
+ ["open", "entered", "open"],
17
+ ["exiting", "exited", "closed"],
18
+ ["exiting", "open", "entering"],
19
+ ["exiting", "close", "exiting"],
20
+ ["exiting", "entered", "exiting"],
21
+ ];
22
+
23
+ for (const [from, event, to] of cases) {
24
+ test(`${from} + ${event} → ${to}`, () => {
25
+ expect(reducePresence(from, { type: event })).toBe(to);
26
+ });
27
+ }
28
+ });
29
+
30
+ describe("isPresent", () => {
31
+ test("is false only when closed", () => {
32
+ expect(isPresent("closed")).toBe(false);
33
+ expect(isPresent("entering")).toBe(true);
34
+ expect(isPresent("open")).toBe(true);
35
+ expect(isPresent("exiting")).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe("presenceTarget", () => {
40
+ test("animates toward 1 while entering or open, 0 otherwise", () => {
41
+ expect(presenceTarget("entering")).toBe(1);
42
+ expect(presenceTarget("open")).toBe(1);
43
+ expect(presenceTarget("exiting")).toBe(0);
44
+ expect(presenceTarget("closed")).toBe(0);
45
+ });
46
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Where an overlay is in its life: absent, arriving, shown, or leaving.
3
+ *
4
+ * `exiting` is the whole reason this exists — an overlay that unmounted the
5
+ * moment it closed would have no exit animation to play.
6
+ */
7
+ export type PresencePhase = "closed" | "entering" | "open" | "exiting";
8
+
9
+ export type PresenceEvent =
10
+ /** The owner asked for it to show. */
11
+ | { type: "open" }
12
+ /** The owner asked for it to hide. */
13
+ | { type: "close" }
14
+ /** The entrance animation finished. */
15
+ | { type: "entered" }
16
+ /** The exit animation finished. */
17
+ | { type: "exited" };
18
+
19
+ /**
20
+ * The presence machine as a pure reducer.
21
+ *
22
+ * A re-open during the exit reverses into `entering` from wherever the
23
+ * animation is, and a close during the entrance reverses into `exiting`. A
24
+ * finish event from an animation that was interrupted — `entered` while
25
+ * already `exiting` — is stale and changes nothing, so the hook needs no
26
+ * generation counter to drop it.
27
+ */
28
+ export function reducePresence(phase: PresencePhase, event: PresenceEvent): PresencePhase {
29
+ switch (event.type) {
30
+ case "open":
31
+ return phase === "closed" || phase === "exiting" ? "entering" : phase;
32
+ case "close":
33
+ return phase === "entering" || phase === "open" ? "exiting" : phase;
34
+ case "entered":
35
+ return phase === "entering" ? "open" : phase;
36
+ case "exited":
37
+ return phase === "exiting" ? "closed" : phase;
38
+ }
39
+ }
40
+
41
+ /** Whether the overlay is mounted: every phase but `closed`. */
42
+ export function isPresent(phase: PresencePhase): boolean {
43
+ return phase !== "closed";
44
+ }
45
+
46
+ /** The progress value the phase animates toward — 1 shown, 0 hidden. */
47
+ export function presenceTarget(phase: PresencePhase): 0 | 1 {
48
+ return phase === "entering" || phase === "open" ? 1 : 0;
49
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ INITIAL_OVERLAY_REGISTRY,
4
+ isTopOverlay,
5
+ OVERLAY_LAYER_BASE,
6
+ type OverlayRegistryState,
7
+ reduceOverlayRegistry,
8
+ topOverlay,
9
+ zIndexOfOverlay,
10
+ } from "./overlay-registry";
11
+
12
+ function apply(...actions: Parameters<typeof reduceOverlayRegistry>[1][]): OverlayRegistryState {
13
+ return actions.reduce(reduceOverlayRegistry, INITIAL_OVERLAY_REGISTRY);
14
+ }
15
+
16
+ describe("OVERLAY_LAYER_BASE", () => {
17
+ test("orders modal under anchored under toast, all above the sheet engine's 1-up stamps", () => {
18
+ expect(OVERLAY_LAYER_BASE.modal).toBeGreaterThanOrEqual(1000);
19
+ expect(OVERLAY_LAYER_BASE.anchored).toBeGreaterThan(OVERLAY_LAYER_BASE.modal);
20
+ expect(OVERLAY_LAYER_BASE.toast).toBeGreaterThan(OVERLAY_LAYER_BASE.anchored);
21
+ });
22
+ });
23
+
24
+ describe("reduceOverlayRegistry", () => {
25
+ test("an open overlay draws above its layer's base", () => {
26
+ const state = apply({ type: "open", id: "a", layer: "modal" });
27
+ expect(zIndexOfOverlay(state, "a")).toBe(OVERLAY_LAYER_BASE.modal + 1);
28
+ });
29
+
30
+ test("a later open in the same layer draws above the earlier one", () => {
31
+ const state = apply({ type: "open", id: "a", layer: "modal" }, { type: "open", id: "b", layer: "modal" });
32
+ expect(zIndexOfOverlay(state, "b")).toBeGreaterThan(zIndexOfOverlay(state, "a"));
33
+ });
34
+
35
+ test("the layer wins over open order", () => {
36
+ const state = apply({ type: "open", id: "toast", layer: "toast" }, { type: "open", id: "dialog", layer: "modal" });
37
+ expect(zIndexOfOverlay(state, "toast")).toBeGreaterThan(zIndexOfOverlay(state, "dialog"));
38
+ });
39
+
40
+ test("re-opening brings an overlay forward instead of duplicating it", () => {
41
+ const state = apply(
42
+ { type: "open", id: "a", layer: "modal" },
43
+ { type: "open", id: "b", layer: "modal" },
44
+ { type: "open", id: "a", layer: "modal" }
45
+ );
46
+ expect(state.open.filter((entry) => entry.id === "a")).toHaveLength(1);
47
+ expect(zIndexOfOverlay(state, "a")).toBeGreaterThan(zIndexOfOverlay(state, "b"));
48
+ });
49
+
50
+ test("a z never leaves its layer's band, however many opens came before", () => {
51
+ let state = INITIAL_OVERLAY_REGISTRY;
52
+ for (let i = 0; i < 5000; i++) {
53
+ state = reduceOverlayRegistry(state, { type: "open", id: `m${i}`, layer: "modal" });
54
+ state = reduceOverlayRegistry(state, { type: "close", id: `m${i}` });
55
+ }
56
+ state = reduceOverlayRegistry(state, { type: "open", id: "last", layer: "modal" });
57
+ expect(zIndexOfOverlay(state, "last")).toBeLessThan(OVERLAY_LAYER_BASE.anchored);
58
+ });
59
+
60
+ test("closing an overlay that is not open returns the same state", () => {
61
+ const state = apply({ type: "open", id: "a", layer: "modal" });
62
+ expect(reduceOverlayRegistry(state, { type: "close", id: "nope" })).toBe(state);
63
+ });
64
+
65
+ test("a closed overlay has no z", () => {
66
+ const state = apply({ type: "open", id: "a", layer: "modal" }, { type: "close", id: "a" });
67
+ expect(zIndexOfOverlay(state, "a")).toBe(0);
68
+ });
69
+ });
70
+
71
+ describe("topOverlay / isTopOverlay", () => {
72
+ test("is null with nothing open", () => {
73
+ expect(topOverlay(INITIAL_OVERLAY_REGISTRY)).toBeNull();
74
+ });
75
+
76
+ test("an anchored overlay over a dialog is the top", () => {
77
+ const state = apply({ type: "open", id: "dialog", layer: "modal" }, { type: "open", id: "pop", layer: "anchored" });
78
+ expect(topOverlay(state)).toBe("pop");
79
+ expect(isTopOverlay(state, "dialog")).toBe(false);
80
+ });
81
+
82
+ test("a toast never captures the back button", () => {
83
+ const state = apply({ type: "open", id: "dialog", layer: "modal" }, { type: "open", id: "toast", layer: "toast" });
84
+ expect(topOverlay(state)).toBe("dialog");
85
+ expect(isTopOverlay(state, "toast")).toBe(false);
86
+ });
87
+
88
+ test("closing the top hands it to the one beneath", () => {
89
+ const state = apply(
90
+ { type: "open", id: "a", layer: "modal" },
91
+ { type: "open", id: "b", layer: "modal" },
92
+ { type: "close", id: "b" }
93
+ );
94
+ expect(isTopOverlay(state, "a")).toBe(true);
95
+ });
96
+ });
@@ -0,0 +1,99 @@
1
+ /** The band of the z-order an overlay draws in. */
2
+ export type OverlayLayer = "modal" | "anchored" | "toast";
3
+
4
+ export const OVERLAY_LAYERS = ["modal", "anchored", "toast"] as const satisfies readonly OverlayLayer[];
5
+
6
+ /**
7
+ * Where each layer's band starts.
8
+ *
9
+ * Every overlay and every bottom sheet teleports into the same `"root"` host, so
10
+ * they are siblings ordered by `zIndex`. The sheet engine stamps its sheets from
11
+ * 1 upward; starting the first band at 1000 puts every overlay above every sheet.
12
+ * A dialog sits under a popover opened from it, and a toast sits above both.
13
+ */
14
+ export const OVERLAY_LAYER_BASE = {
15
+ modal: 1000,
16
+ anchored: 2000,
17
+ toast: 3000,
18
+ } as const satisfies Record<OverlayLayer, number>;
19
+
20
+ /** The layers whose top overlay answers Android's back button. A toast never does. */
21
+ const BACK_CAPTURING_LAYERS: ReadonlySet<OverlayLayer> = new Set(["modal", "anchored"]);
22
+
23
+ /** One overlay that is presented — open, or animating closed. */
24
+ export type OverlayRegistryEntry = {
25
+ id: string;
26
+ layer: OverlayLayer;
27
+ /** Open order across every layer; later is larger. */
28
+ seq: number;
29
+ };
30
+
31
+ export type OverlayRegistryState = {
32
+ nextSeq: number;
33
+ open: readonly OverlayRegistryEntry[];
34
+ };
35
+
36
+ export const INITIAL_OVERLAY_REGISTRY: OverlayRegistryState = { nextSeq: 1, open: [] };
37
+
38
+ export type OverlayRegistryAction = { type: "open"; id: string; layer: OverlayLayer } | { type: "close"; id: string };
39
+
40
+ /**
41
+ * The registry as a pure reducer, so the z-order is testable without a renderer.
42
+ *
43
+ * An `open` stamps the next sequence number; an overlay already open is
44
+ * re-stamped rather than duplicated, which brings it forward. A `close` of an
45
+ * overlay that is not open returns the same state object, so an unmount racing
46
+ * an exit causes no render.
47
+ */
48
+ export function reduceOverlayRegistry(
49
+ state: OverlayRegistryState,
50
+ action: OverlayRegistryAction
51
+ ): OverlayRegistryState {
52
+ if (action.type === "close") {
53
+ if (!state.open.some((entry) => entry.id === action.id)) return state;
54
+ return { ...state, open: state.open.filter((entry) => entry.id !== action.id) };
55
+ }
56
+
57
+ const others = state.open.filter((entry) => entry.id !== action.id);
58
+ return {
59
+ nextSeq: state.nextSeq + 1,
60
+ open: [...others, { id: action.id, layer: action.layer, seq: state.nextSeq }],
61
+ };
62
+ }
63
+
64
+ /**
65
+ * The overlay's `zIndex` while presented, `0` otherwise.
66
+ *
67
+ * Its layer's base plus its rank among the open overlays of that layer — a rank,
68
+ * not the raw sequence number, so a long session's thousandth dialog still sits
69
+ * below the first popover.
70
+ */
71
+ export function zIndexOfOverlay(state: OverlayRegistryState, id: string): number {
72
+ const entry = state.open.find((candidate) => candidate.id === id);
73
+ if (entry === undefined) return 0;
74
+ let rank = 1;
75
+ for (const other of state.open) {
76
+ if (other.layer === entry.layer && other.seq < entry.seq) rank++;
77
+ }
78
+ return OVERLAY_LAYER_BASE[entry.layer] + rank;
79
+ }
80
+
81
+ /** The back-capturing overlay drawn on top, or `null` when none is presented. */
82
+ export function topOverlay(state: OverlayRegistryState): string | null {
83
+ let top: string | null = null;
84
+ let topZ = 0;
85
+ for (const entry of state.open) {
86
+ if (!BACK_CAPTURING_LAYERS.has(entry.layer)) continue;
87
+ const z = zIndexOfOverlay(state, entry.id);
88
+ if (z > topZ) {
89
+ top = entry.id;
90
+ topZ = z;
91
+ }
92
+ }
93
+ return top;
94
+ }
95
+
96
+ /** Whether `id` is the overlay a back press should close. */
97
+ export function isTopOverlay(state: OverlayRegistryState, id: string): boolean {
98
+ return topOverlay(state) === id;
99
+ }
@@ -0,0 +1,49 @@
1
+ import type { ReactElement } from "react";
2
+ import { Pressable, type PressableProps } from "react-native";
3
+ import Animated, { type SharedValue, useAnimatedStyle } from "react-native-reanimated";
4
+ import { overlayVariants } from "./overlay.variants";
5
+
6
+ const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
7
+
8
+ export type OverlayScrimProps = Omit<PressableProps, "style" | "children" | "onPress"> & {
9
+ /** The 0 → 1 presence progress from `useOverlayPresence`; the scrim's opacity follows it. */
10
+ progress: SharedValue<number>;
11
+ /**
12
+ * Called on a tap. Omit it and the scrim still takes the touch — the app
13
+ * under a non-dismissible overlay must not be pressable — but does nothing.
14
+ */
15
+ onDismiss?: () => void;
16
+ className?: string;
17
+ };
18
+
19
+ /**
20
+ * The dim layer between the app and a modal overlay.
21
+ *
22
+ * `bg-overlay` at opacity 1 — the token carries its own alpha — faded in and
23
+ * out by presence. Written before the panel in the same portal, so view order
24
+ * gives a touch on the panel to the panel.
25
+ *
26
+ * Invisible to assistive technology. A scrim is not a control a screen reader
27
+ * user can find; they dismiss through the panel's `onAccessibilityEscape` and
28
+ * the back button instead.
29
+ *
30
+ * React Native's own `Pressable`, not this library's: it needs no feedback, no
31
+ * haptic and no gesture handler — a gesture-handler tap here would race the
32
+ * panel's own pans for the touch.
33
+ */
34
+ export function OverlayScrim({ progress, onDismiss, className, ...props }: OverlayScrimProps): ReactElement {
35
+ const fade = useAnimatedStyle(() => ({ opacity: progress.value }));
36
+
37
+ return (
38
+ <AnimatedPressable
39
+ accessibilityElementsHidden
40
+ accessible={false}
41
+ className={overlayVariants().scrim({ className })}
42
+ importantForAccessibility="no-hide-descendants"
43
+ onPress={onDismiss}
44
+ style={fade}
45
+ {...props}
46
+ />
47
+ );
48
+ }
49
+ OverlayScrim.displayName = "DelacourUI.Overlay.Scrim";
@@ -0,0 +1,48 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { OverlayLayer, OverlayRegistryState } from "./overlay-registry";
3
+
4
+ /** What `OverlayProvider` shares with every overlay beneath it. */
5
+ export type OverlayContextValue = {
6
+ /** The open set and its z-order. A change re-renders every presented portal. */
7
+ state: OverlayRegistryState;
8
+ /** A portal reports that its overlay is presented, and in which layer. */
9
+ present: (id: string, layer: OverlayLayer) => void;
10
+ /** The overlay's portal unmounted. */
11
+ dismissed: (id: string) => void;
12
+ /**
13
+ * Hear every touch that starts anywhere under the provider — the app, a
14
+ * sheet, another overlay — without taking it. A non-modal overlay closes on
15
+ * an outside tap this way and the tap still reaches what was under it.
16
+ * Returns the unsubscribe.
17
+ */
18
+ subscribeTouchStart: (listener: () => void) => () => void;
19
+ };
20
+
21
+ export const OverlayContext = createContext<OverlayContextValue | null>(null);
22
+ OverlayContext.displayName = "DelacourUI.Overlay.Context";
23
+
24
+ /** The overlay registry, or `null` outside an `OverlayProvider`. */
25
+ export function useOptionalOverlay(): OverlayContextValue | null {
26
+ return useContext(OverlayContext);
27
+ }
28
+
29
+ /**
30
+ * Whether a teleport `PortalProvider` is already mounted above.
31
+ *
32
+ * Teleport registers its hosts natively by name, so two providers would mean
33
+ * two hosts called `"root"`. `OverlayProvider` and this library's
34
+ * `BottomSheetProvider` both set this when they mount one, and both skip
35
+ * their own when it is already set — which is what makes either mount order
36
+ * work.
37
+ *
38
+ * This file is a leaf on purpose: `bottom-sheet/` imports it across folders,
39
+ * and it imports nothing but React and types, so the import can never close a
40
+ * cycle (package rule 3).
41
+ */
42
+ export const TeleportProvidedContext = createContext(false);
43
+ TeleportProvidedContext.displayName = "DelacourUI.Overlay.TeleportProvided";
44
+
45
+ /** True beneath a provider that mounted teleport's `PortalProvider`. */
46
+ export function useIsTeleportProvided(): boolean {
47
+ return useContext(TeleportProvidedContext);
48
+ }
@@ -0,0 +1,118 @@
1
+ import { type ReactElement, type ReactNode, useCallback, useMemo, useRef, useState } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { PortalProvider } from "react-native-teleport";
4
+ import {
5
+ OverlayContext,
6
+ type OverlayContextValue,
7
+ TeleportProvidedContext,
8
+ useIsTeleportProvided,
9
+ } from "./overlay.context";
10
+ import { OverlayPortal } from "./overlay-portal";
11
+ import {
12
+ INITIAL_OVERLAY_REGISTRY,
13
+ type OverlayLayer,
14
+ type OverlayRegistryState,
15
+ reduceOverlayRegistry,
16
+ } from "./overlay-registry";
17
+ import { OverlayScrim } from "./overlay-scrim";
18
+
19
+ export type OverlayProviderProps = {
20
+ children?: ReactNode;
21
+ };
22
+
23
+ function OverlayRoot({ children }: OverlayProviderProps): ReactElement {
24
+ const isTeleportProvided = useIsTeleportProvided();
25
+ const current = useRef<OverlayRegistryState>(INITIAL_OVERLAY_REGISTRY);
26
+ const [state, setState] = useState<OverlayRegistryState>(INITIAL_OVERLAY_REGISTRY);
27
+
28
+ const present = useCallback((id: string, layer: OverlayLayer) => {
29
+ current.current = reduceOverlayRegistry(current.current, { type: "open", id, layer });
30
+ setState(current.current);
31
+ }, []);
32
+
33
+ const dismissed = useCallback((id: string) => {
34
+ const next = reduceOverlayRegistry(current.current, { type: "close", id });
35
+ if (next === current.current) return;
36
+ current.current = next;
37
+ setState(next);
38
+ }, []);
39
+
40
+ const touchListeners = useRef(new Set<() => void>());
41
+ const subscribeTouchStart = useCallback((listener: () => void) => {
42
+ touchListeners.current.add(listener);
43
+ return () => {
44
+ touchListeners.current.delete(listener);
45
+ };
46
+ }, []);
47
+ const onTouchStart = useCallback(() => {
48
+ for (const listener of [...touchListeners.current]) listener();
49
+ }, []);
50
+
51
+ const value = useMemo<OverlayContextValue>(
52
+ () => ({ state, present, dismissed, subscribeTouchStart }),
53
+ [state, present, dismissed, subscribeTouchStart]
54
+ );
55
+
56
+ // `onTouchStart` is a bubbling touch event, not a responder claim: it fires
57
+ // for a touch on any descendant in the React tree — teleported overlays
58
+ // included, since teleport leaves the fiber where it was — and never stops
59
+ // the touch reaching its target or a gesture handler.
60
+ const registry = (
61
+ <OverlayContext.Provider value={value}>
62
+ <View onTouchStart={onTouchStart} style={styles.fill}>
63
+ {children}
64
+ </View>
65
+ </OverlayContext.Provider>
66
+ );
67
+ if (isTeleportProvided) return registry;
68
+
69
+ return (
70
+ <PortalProvider>
71
+ <TeleportProvidedContext.Provider value>{registry}</TeleportProvidedContext.Provider>
72
+ </PortalProvider>
73
+ );
74
+ }
75
+
76
+ const styles = StyleSheet.create({ fill: { flex: 1 } });
77
+
78
+ /**
79
+ * The layer every overlay in this library draws into. Mount it once, inside
80
+ * `DelacourProvider` and around the navigator — and around
81
+ * `BottomSheetProvider` when the app has sheets.
82
+ *
83
+ * Two things. Teleport's `PortalProvider`, whose `"root"` host fills the app
84
+ * and is where every dialog, drawer, popover, tooltip, toast and sheet draws —
85
+ * skipped when a provider above already mounted one, because two would mean
86
+ * two native hosts with one name. And the overlay registry, which gives every
87
+ * presented overlay a `zIndex` in its layer's band and tells each one whether it
88
+ * is the top, the one Android's back button closes. It also hears every touch
89
+ * that starts beneath it, without claiming one, so a tooltip can close on an
90
+ * outside tap that still lands where it was aimed.
91
+ *
92
+ * `DelacourProvider` cannot mount this, for the reason it cannot mount
93
+ * `BottomSheetProvider`: `react-native-teleport` is an optional peer, and an
94
+ * import there would make every app resolve it.
95
+ *
96
+ * `OverlayProvider` is the same component under the name a root layout reads
97
+ * best with; `Overlay.Portal` and `Overlay.Scrim` are the parts every overlay
98
+ * is drawn with.
99
+ *
100
+ * @example
101
+ * <DelacourProvider>
102
+ * <OverlayProvider>
103
+ * <BottomSheetProvider>
104
+ * <Stack />
105
+ * </BottomSheetProvider>
106
+ * </OverlayProvider>
107
+ * </DelacourProvider>
108
+ */
109
+ export const Overlay = Object.assign(OverlayRoot, {
110
+ /** Teleports its children over the app, in a layer of the z-order. */
111
+ Portal: OverlayPortal,
112
+ /** The dim layer under a modal overlay, faded by presence. */
113
+ Scrim: OverlayScrim,
114
+ displayName: "DelacourUI.Overlay",
115
+ });
116
+
117
+ /** `Overlay` under the name a root layout reads best with. */
118
+ export const OverlayProvider = Overlay;
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { declaredTokens } from "../../styles/theme-tokens.test";
3
+ import { OVERLAY_MOTION, OVERLAY_SCRIM_TOKEN, overlayVariants } from "./overlay.variants";
4
+
5
+ const LIGHT = declaredTokens("light");
6
+ const DARK = declaredTokens("dark");
7
+
8
+ describe("overlayVariants", () => {
9
+ test("the scrim fills its parent in the overlay token", () => {
10
+ const scrim = overlayVariants().scrim();
11
+ expect(scrim).toContain("absolute");
12
+ expect(scrim).toContain("inset-0");
13
+ expect(scrim).toContain(`bg-${OVERLAY_SCRIM_TOKEN}`);
14
+ });
15
+
16
+ test("a caller's className reaches the scrim and wins", () => {
17
+ expect(overlayVariants().scrim({ className: "bg-black/30" })).toContain("bg-black/30");
18
+ expect(overlayVariants().scrim({ className: "bg-black/30" })).not.toContain(`bg-${OVERLAY_SCRIM_TOKEN}`);
19
+ });
20
+
21
+ test("the scrim token exists in both themes", () => {
22
+ expect(LIGHT.has(OVERLAY_SCRIM_TOKEN)).toBe(true);
23
+ expect(DARK.has(OVERLAY_SCRIM_TOKEN)).toBe(true);
24
+ });
25
+ });
26
+
27
+ describe("OVERLAY_MOTION", () => {
28
+ test("is finite, so an E2E runner's settle wait always ends", () => {
29
+ expect(Number.isFinite(OVERLAY_MOTION.enterMs)).toBe(true);
30
+ expect(Number.isFinite(OVERLAY_MOTION.exitMs)).toBe(true);
31
+ });
32
+
33
+ test("leaves faster than it arrives", () => {
34
+ expect(OVERLAY_MOTION.exitMs).toBeLessThan(OVERLAY_MOTION.enterMs);
35
+ expect(OVERLAY_MOTION.exitMs).toBeGreaterThan(0);
36
+ });
37
+ });
@@ -0,0 +1,33 @@
1
+ import type { VariantProps } from "tailwind-variants";
2
+ import { tv } from "../../lib/tv";
3
+
4
+ /**
5
+ * The theme token every overlay's scrim paints with.
6
+ *
7
+ * `--overlay` carries its own alpha, and the two themes carry different ones,
8
+ * so a scrim draws it at opacity 1 and fades only through presence — the same
9
+ * rule `BottomSheet.Overlay` follows.
10
+ */
11
+ export const OVERLAY_SCRIM_TOKEN = "overlay";
12
+
13
+ /**
14
+ * How long an overlay takes to arrive and to leave, in milliseconds.
15
+ *
16
+ * Leaving is quicker than arriving: an overlay the user dismissed should get
17
+ * out of the way. Both are finite on purpose — overlay motion is behaviour, not
18
+ * decoration, so `isMotionCalm` does not still it, and a finite animation is
19
+ * what lets an E2E runner's settle wait end anyway.
20
+ */
21
+ export const OVERLAY_MOTION = {
22
+ enterMs: 220,
23
+ exitMs: 160,
24
+ } as const;
25
+
26
+ /** Styling for the foundation's own parts. */
27
+ export const overlayVariants = tv({
28
+ slots: {
29
+ scrim: "absolute inset-0 bg-overlay",
30
+ },
31
+ });
32
+
33
+ export type OverlayVariantProps = VariantProps<typeof overlayVariants>;
@@ -0,0 +1,34 @@
1
+ import { useEffect } from "react";
2
+ import { BackHandler } from "react-native";
3
+ import { useOptionalOverlay } from "./overlay.context";
4
+ import { isTopOverlay } from "./overlay-registry";
5
+
6
+ export type UseOverlayBackHandlerOptions = {
7
+ /** The id the overlay's `Overlay.Portal` was given. */
8
+ id: string;
9
+ /** Listen only while true — typically `isPresent && isDismissible`. */
10
+ isEnabled: boolean;
11
+ onBack: () => void;
12
+ };
13
+
14
+ /**
15
+ * Closes the overlay on Android's back button, but only while it is the top
16
+ * one: a popover over a dialog closes, the dialog under it stays.
17
+ *
18
+ * Without an `OverlayProvider` every overlay is its own top. React Native calls
19
+ * the most recently added listener first, and an overlay drawn over a bottom
20
+ * sheet subscribed after it, so the overlay answers before the sheet does.
21
+ */
22
+ export function useOverlayBackHandler({ id, isEnabled, onBack }: UseOverlayBackHandlerOptions): void {
23
+ const registry = useOptionalOverlay();
24
+ const isTop = registry === null ? true : isTopOverlay(registry.state, id);
25
+
26
+ useEffect(() => {
27
+ if (!isEnabled || !isTop) return;
28
+ const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
29
+ onBack();
30
+ return true;
31
+ });
32
+ return () => subscription.remove();
33
+ }, [isEnabled, isTop, onBack]);
34
+ }
@@ -0,0 +1,87 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { Easing, type SharedValue, useReducedMotion, useSharedValue, withTiming } from "react-native-reanimated";
3
+ import { scheduleOnRN } from "react-native-worklets";
4
+ import { OVERLAY_MOTION } from "./overlay.variants";
5
+ import { isPresent, type PresenceEvent, type PresencePhase, presenceTarget, reducePresence } from "./overlay-presence";
6
+
7
+ export type UseOverlayPresenceOptions = {
8
+ isOpen: boolean;
9
+ /** Entrance duration, ms. Default `OVERLAY_MOTION.enterMs`. */
10
+ enterMs?: number;
11
+ /** Exit duration, ms. Default `OVERLAY_MOTION.exitMs`. */
12
+ exitMs?: number;
13
+ /** The entrance finished — the moment to move accessibility focus in. */
14
+ onEntered?: () => void;
15
+ /** The exit finished and the overlay is about to unmount. */
16
+ onExited?: () => void;
17
+ };
18
+
19
+ export type OverlayPresence = {
20
+ /** Keep the overlay mounted while true: from the open until the exit animation ends. */
21
+ isPresent: boolean;
22
+ /** 0 hidden → 1 shown, animated on the UI thread. Drive opacity and transforms from it. */
23
+ progress: SharedValue<number>;
24
+ /** Reduce motion is on: drop scales and translates, keep the fade. */
25
+ isReduced: boolean;
26
+ phase: PresencePhase;
27
+ };
28
+
29
+ const ENTER_EASING = Easing.out(Easing.cubic);
30
+ const EXIT_EASING = Easing.in(Easing.cubic);
31
+
32
+ /**
33
+ * Keeps an overlay mounted through its exit animation and drives its
34
+ * `progress` from 0 to 1 and back.
35
+ *
36
+ * `isOpen` is the owner's state; this follows it through `reducePresence`,
37
+ * which is what lets a re-open during the exit reverse smoothly from wherever
38
+ * the animation is. The finish of each animation reaches React through
39
+ * `scheduleOnRN`, and only when the animation ran to the end — an interrupted
40
+ * one reports nothing, and the reducer drops a stale finish anyway.
41
+ *
42
+ * Under reduce motion the fade still runs — an opacity change is not motion —
43
+ * and `isReduced` tells the component to drop its transforms.
44
+ */
45
+ export function useOverlayPresence({
46
+ isOpen,
47
+ enterMs = OVERLAY_MOTION.enterMs,
48
+ exitMs = OVERLAY_MOTION.exitMs,
49
+ onEntered,
50
+ onExited,
51
+ }: UseOverlayPresenceOptions): OverlayPresence {
52
+ const [phase, setPhase] = useState<PresencePhase>(isOpen ? "entering" : "closed");
53
+ const progress = useSharedValue(0);
54
+ const isReduced = useReducedMotion();
55
+
56
+ const send = useCallback((event: PresenceEvent) => {
57
+ setPhase((current) => reducePresence(current, event));
58
+ }, []);
59
+
60
+ const finishEnter = useCallback(() => {
61
+ send({ type: "entered" });
62
+ onEntered?.();
63
+ }, [send, onEntered]);
64
+
65
+ const finishExit = useCallback(() => {
66
+ send({ type: "exited" });
67
+ onExited?.();
68
+ }, [send, onExited]);
69
+
70
+ useEffect(() => {
71
+ send({ type: isOpen ? "open" : "close" });
72
+ }, [isOpen, send]);
73
+
74
+ useEffect(() => {
75
+ if (phase === "entering") {
76
+ progress.value = withTiming(presenceTarget(phase), { duration: enterMs, easing: ENTER_EASING }, (finished) => {
77
+ if (finished) scheduleOnRN(finishEnter);
78
+ });
79
+ } else if (phase === "exiting") {
80
+ progress.value = withTiming(presenceTarget(phase), { duration: exitMs, easing: EXIT_EASING }, (finished) => {
81
+ if (finished) scheduleOnRN(finishExit);
82
+ });
83
+ }
84
+ }, [phase, progress, enterMs, exitMs, finishEnter, finishExit]);
85
+
86
+ return { isPresent: isPresent(phase), progress, isReduced, phase };
87
+ }