diff --git a/.changeset/olive-pugs-repeat.md b/.changeset/olive-pugs-repeat.md
new file mode 100644
index 0000000..a5ea414
--- /dev/null
+++ b/.changeset/olive-pugs-repeat.md
@@ -0,0 +1,7 @@
+---
+"react-native-grab": patch
+---
+
+Resolve grab points against the selection owner instead of the window, so elements are selected under the finger when the owner is not at the window origin (a screen under a native header, or a natively presented surface).
+
+Let `ReactNativeGrabSurface` be sized by its host: it no longer forces a fill, and takes `flex: 1` through `style` when it should fill a presented container. Surface activation now deactivates on unmount, a failed owner registration is reported instead of failing silently, and the web entry points render their children rather than dropping the subtree.
diff --git a/.changeset/quiet-pandas-grab.md b/.changeset/quiet-pandas-grab.md
new file mode 100644
index 0000000..f7f9bcc
--- /dev/null
+++ b/.changeset/quiet-pandas-grab.md
@@ -0,0 +1,5 @@
+---
+"react-native-grab": patch
+---
+
+Add `ReactNativeGrabSurface` for selecting React Native content hosted in separately presented native surfaces.
diff --git a/README.md b/README.md
index a1510af..47cc347 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,8 @@ npm install react-native-grab
1. Add React Native Grab middleware to Metro.
2. Wrap your app root with `ReactNativeGrabRoot`.
3. **If your app uses native navigators** (e.g. native stack, native tabs), **wrap each screen** with `ReactNativeGrabScreen`.
-4. Open Dev Menu and choose `React Native Grab` to start selecting elements.
+4. Wrap content hosted in a separately presented native sheet or modal with `ReactNativeGrabSurface` and set `active` only while it is presented.
+5. Open Dev Menu and choose `React Native Grab` to start selecting elements.
## Quick Configuration Example
@@ -72,6 +73,7 @@ export default function AppLayout() {
- `ReactNativeGrabRoot`: Root-level provider for grab functionality.
- `ReactNativeGrabScreen`: When using native navigators (native stack, native tabs), wrap **each screen** with this component for accurate selection.
+- `ReactNativeGrabSurface`: Wraps content hosted in a separately presented native surface. An active surface takes selection priority over the focused screen; when it becomes inactive, selection falls back to the focused screen or root. Unlike `ReactNativeGrabRoot` and `ReactNativeGrabScreen`, it does not stretch to fill its parent, so that sheets using content-measured detents can still measure their content. This component is a no-op in production builds.
- `ReactNativeGrabContextProvider`: Adds custom metadata to grabbed elements. Nested providers are shallow-merged and child keys override parent keys. This provider is a no-op in production builds.
- `enableGrabbing()`: Programmatically enables grabbing flow.
- `setFocusEffect(impl)`: Overrides the hook used by `ReactNativeGrabScreen` to detect when a screen is focused. By default the library auto-detects `useFocusEffect` from `expo-router` or `@react-navigation/native`. Call `setFocusEffect` once at app startup when neither package is present (e.g. a custom router) or when you want explicit control over which implementation is used.
@@ -83,6 +85,38 @@ import { useFocusEffect } from "my-custom-router";
setFocusEffect(useFocusEffect);
```
+For a native sheet or modal, place `ReactNativeGrabSurface` inside the presented content and keep `active` synchronized with its presentation lifecycle:
+
+```tsx
+import { Modal, StyleSheet } from "react-native";
+import { ReactNativeGrabSurface } from "react-native-grab";
+
+function DetailsModal({ visible }: { visible: boolean }) {
+ return (
+
+
+ {/* modal or sheet content */}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ surface: {
+ flex: 1,
+ },
+});
+```
+
+`ReactNativeGrabSurface` renders a `View` around your content and sizes to that content by
+default, which is what sheets with content-measured detents need. Pass `flex: 1` through `style`
+when the surface should fill the presented container instead, as above.
+
+Keep `active` synchronized with the full presentation lifecycle, including drag-to-dismiss — drive
+it from the presentation callbacks (`onDidDismiss` and friends) rather than from the handler that
+opened the surface. A surface left `active` after dismissal keeps selection priority and blocks
+grabbing on the screen behind it.
+
When grab context is available for a selected element, copied output includes an additional `Context:` JSON block appended after the existing element preview and stack trace lines.
## Documentation
diff --git a/example/package-lock.json b/example/package-lock.json
index 9eba180..80702a6 100644
--- a/example/package-lock.json
+++ b/example/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^15.0.2",
+ "@lodev09/react-native-true-sheet": "^3.11.12",
"@react-navigation/bottom-tabs": "^7.7.3",
"@react-navigation/elements": "^2.8.1",
"@react-navigation/native": "^7.1.28",
@@ -42,21 +43,41 @@
}
},
"..": {
- "version": "0.0.2",
+ "version": "1.1.1",
"license": "MIT",
"dependencies": {
"metro-config-transformers": "latest"
},
"devDependencies": {
+ "@changesets/cli": "^2.29.8",
"@types/node": "latest",
"@types/react": "latest",
+ "husky": "^9.1.7",
"oxfmt": "latest",
- "typescript": "latest"
+ "typescript": "latest",
+ "vitest": "^3.2.4"
},
"peerDependencies": {
+ "@react-navigation/native": "*",
+ "expo-dev-menu": "*",
+ "expo-router": "*",
"react": ">=19",
- "react-native": ">=0.82",
+ "react-native": ">=0.80",
"react-native-screens": "*"
+ },
+ "peerDependenciesMeta": {
+ "@react-navigation/native": {
+ "optional": true
+ },
+ "expo-dev-menu": {
+ "optional": true
+ },
+ "expo-router": {
+ "optional": true
+ },
+ "react-native-screens": {
+ "optional": true
+ }
}
},
"node_modules/@babel/code-frame": {
@@ -2156,6 +2177,44 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@lodev09/react-native-true-sheet": {
+ "version": "3.11.12",
+ "resolved": "https://registry.npmjs.org/@lodev09/react-native-true-sheet/-/react-native-true-sheet-3.11.12.tgz",
+ "integrity": "sha512-eL7mfUw0p3dxqXKqck3k7H2Ja6UHGbPSqbVDRKeANuxZN4xctxF1iFSPYHj0LULVtheqxKBhWBiR4lJ25El57Q==",
+ "license": "MIT",
+ "workspaces": [
+ "example/bare",
+ "example/expo",
+ "example/shared",
+ "docs"
+ ],
+ "peerDependencies": {
+ "@radix-ui/react-dialog": ">=1",
+ "@radix-ui/react-presence": ">=1",
+ "@react-navigation/core": ">=7",
+ "react": "*",
+ "react-native": "*",
+ "react-native-reanimated": ">=4",
+ "react-native-worklets": "*"
+ },
+ "peerDependenciesMeta": {
+ "@radix-ui/react-dialog": {
+ "optional": true
+ },
+ "@radix-ui/react-presence": {
+ "optional": true
+ },
+ "@react-navigation/core": {
+ "optional": true
+ },
+ "react-native-reanimated": {
+ "optional": true
+ },
+ "react-native-worklets": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
diff --git a/example/package.json b/example/package.json
index 864ce61..d9efc5c 100644
--- a/example/package.json
+++ b/example/package.json
@@ -13,6 +13,7 @@
},
"dependencies": {
"@expo/vector-icons": "^15.0.2",
+ "@lodev09/react-native-true-sheet": "^3.11.12",
"@react-navigation/bottom-tabs": "^7.7.3",
"@react-navigation/elements": "^2.8.1",
"@react-navigation/native": "^7.1.28",
diff --git a/example/src/app/(tabs)/explore.tsx b/example/src/app/(tabs)/explore.tsx
index a548ce4..626086a 100644
--- a/example/src/app/(tabs)/explore.tsx
+++ b/example/src/app/(tabs)/explore.tsx
@@ -105,6 +105,31 @@ export default function TabTwoScreen() {
+
+
+ Content presented by a native sheet lives outside the screen subtree. Open the sheet
+ playground and try selecting elements inside a presented sheet.
+
+
+ [styles.modalTrigger, pressed && styles.pressed]}
+ >
+
+ Open sheet playground
+
+
+
+
+
+
Open a dedicated modal with nested grab context providers. Each nested element
diff --git a/example/src/app/_layout.tsx b/example/src/app/_layout.tsx
index 48f6447..5ead18e 100644
--- a/example/src/app/_layout.tsx
+++ b/example/src/app/_layout.tsx
@@ -22,6 +22,7 @@ export default function MainLayout() {
name="context-playground"
options={{ presentation: "modal", title: "Context Playground" }}
/>
+
diff --git a/example/src/app/sheet-playground.tsx b/example/src/app/sheet-playground.tsx
new file mode 100644
index 0000000..edccc1a
--- /dev/null
+++ b/example/src/app/sheet-playground.tsx
@@ -0,0 +1,177 @@
+import { TrueSheet } from "@lodev09/react-native-true-sheet";
+import { useRef, useState } from "react";
+import { Modal, Pressable, StyleSheet, Text, View } from "react-native";
+
+import { ThemedText } from "@/components/themed-text";
+import { ThemedView } from "@/components/themed-view";
+import { Spacing } from "@/constants/theme";
+import { useTheme } from "@/hooks/use-theme";
+
+import { ReactNativeGrabScreen, ReactNativeGrabSurface, enableGrabbing } from "react-native-grab";
+
+/**
+ * Named wrappers so the grab selection menu title identifies which surface the
+ * element came from: the menu renders `Text (in ScreenTarget)`, `Text (in AutoSheetTarget)`,
+ * and so on. The e2e flows assert on those exact strings.
+ *
+ * These render `Text` directly instead of `ThemedText`: the menu title names the
+ * closest non-host owner, so a shared wrapper component would make every target
+ * report the same `Text (in ThemedText)`.
+ */
+function ScreenTarget() {
+ const theme = useTheme();
+ return Screen target;
+}
+
+function AutoSheetTarget() {
+ const theme = useTheme();
+ return Auto sheet target;
+}
+
+function StackedSheetTarget() {
+ const theme = useTheme();
+ return Stacked sheet target;
+}
+
+function ModalTarget() {
+ const theme = useTheme();
+ return Modal target;
+}
+
+type ActionProps = {
+ label: string;
+ onPress: () => void;
+};
+
+function Action({ label, onPress }: ActionProps) {
+ return (
+ [pressed && styles.pressed]}>
+
+ {label}
+
+
+ );
+}
+
+export default function SheetPlaygroundScreen() {
+ const theme = useTheme();
+ const autoSheet = useRef(null);
+ const stackedSheet = useRef(null);
+
+ // Driven by the sheet's own presentation lifecycle rather than by the press
+ // handlers, so drag-to-dismiss also deactivates the surface.
+ const [isAutoSheetPresented, setIsAutoSheetPresented] = useState(false);
+ const [isStackedSheetPresented, setIsStackedSheetPresented] = useState(false);
+ const [isModalVisible, setIsModalVisible] = useState(false);
+
+ return (
+
+
+ Native sheets
+
+ TrueSheet presents its content in a native container outside the screen subtree. Each
+ sheet wraps its content in ReactNativeGrabSurface so grabbing resolves to the presented
+ sheet instead of the screen behind it.
+
+
+
+
+
+
+ void autoSheet.current?.present()} />
+ setIsModalVisible(true)} />
+
+
+
+ {/* Content-measured sheet: the surface wrapper sizes to its content, which is
+ what the `auto` detent needs to measure. */}
+ setIsAutoSheetPresented(true)}
+ onDidDismiss={() => setIsAutoSheetPresented(false)}
+ >
+
+ Auto sheet
+
+
+ void stackedSheet.current?.present()} />
+ void autoSheet.current?.dismiss()} />
+
+
+
+ {/* Second sheet, presented on top of the first one: the most recently
+ activated surface has to win, and dismissing it has to hand selection
+ back to the sheet underneath rather than to the screen. */}
+ setIsStackedSheetPresented(true)}
+ onDidDismiss={() => setIsStackedSheetPresented(false)}
+ >
+
+ Stacked sheet
+
+
+ void stackedSheet.current?.dismiss()}
+ />
+
+
+
+ {/* A plain RN modal presents a full-screen container, so here the surface
+ has to be told to fill it. */}
+ setIsModalVisible(false)}>
+
+ Modal
+
+
+ setIsModalVisible(false)} />
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ gap: Spacing.three,
+ padding: Spacing.four,
+ },
+ fill: {
+ flex: 1,
+ },
+ target: {
+ fontSize: 16,
+ fontWeight: "500",
+ lineHeight: 24,
+ },
+ sheetContent: {
+ gap: Spacing.three,
+ padding: Spacing.four,
+ paddingBottom: Spacing.six,
+ },
+ actions: {
+ gap: Spacing.two,
+ },
+ action: {
+ alignItems: "center",
+ borderRadius: Spacing.three,
+ paddingHorizontal: Spacing.four,
+ paddingVertical: Spacing.two,
+ },
+ pressed: {
+ opacity: 0.7,
+ },
+});
diff --git a/src/react-native/__tests__/selection-owners.test.ts b/src/react-native/__tests__/selection-owners.test.ts
new file mode 100644
index 0000000..07a3a9b
--- /dev/null
+++ b/src/react-native/__tests__/selection-owners.test.ts
@@ -0,0 +1,92 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { ReactNativeElement } from "react-native";
+
+vi.mock("react-native", () => ({
+ findNodeHandle: (ref: { nativeTag: number }) => ref.nativeTag,
+}));
+
+vi.mock("../fabric", () => ({
+ getFabricUIManager: () => ({
+ findShadowNodeByTag_DEPRECATED: (nativeTag: number) => ({ nativeTag }),
+ }),
+}));
+
+import {
+ clearGrabSelectionOwnerFocus,
+ getResolvedGrabSelectionOwnerId,
+ registerGrabSelectionOwner,
+ setGrabSelectionOwnerActive,
+ setGrabSelectionOwnerFocused,
+ unregisterGrabSelectionOwner,
+} from "../containers";
+
+const registeredOwnerIds: string[] = [];
+
+const registerOwner = (id: string, kind: "root" | "screen" | "surface", nativeTag: number) => {
+ registeredOwnerIds.push(id);
+ registerGrabSelectionOwner(id, kind, { nativeTag } as unknown as ReactNativeElement);
+};
+
+afterEach(() => {
+ for (const id of registeredOwnerIds.splice(0)) {
+ unregisterGrabSelectionOwner(id);
+ }
+});
+
+describe("grab selection owner resolution", () => {
+ it("prefers the most recently activated surface and restores previous owners", () => {
+ registerOwner("root", "root", 1);
+ registerOwner("screen", "screen", 2);
+ setGrabSelectionOwnerFocused("screen", true);
+ registerOwner("first-sheet", "surface", 3);
+ registerOwner("second-sheet", "surface", 4);
+ expect(getResolvedGrabSelectionOwnerId()).toBe("screen");
+
+ setGrabSelectionOwnerActive("first-sheet", true);
+ expect(getResolvedGrabSelectionOwnerId()).toBe("first-sheet");
+
+ setGrabSelectionOwnerActive("second-sheet", true);
+ expect(getResolvedGrabSelectionOwnerId()).toBe("second-sheet");
+
+ setGrabSelectionOwnerActive("second-sheet", false);
+ expect(getResolvedGrabSelectionOwnerId()).toBe("first-sheet");
+
+ setGrabSelectionOwnerActive("second-sheet", true);
+ expect(getResolvedGrabSelectionOwnerId()).toBe("second-sheet");
+
+ unregisterGrabSelectionOwner("second-sheet");
+ expect(getResolvedGrabSelectionOwnerId()).toBe("first-sheet");
+
+ setGrabSelectionOwnerActive("first-sheet", false);
+ expect(getResolvedGrabSelectionOwnerId()).toBe("screen");
+
+ clearGrabSelectionOwnerFocus("screen");
+ expect(getResolvedGrabSelectionOwnerId()).toBe("root");
+ });
+
+ it("ignores activation for owners that are not surfaces", () => {
+ registerOwner("root", "root", 1);
+ registerOwner("screen", "screen", 2);
+
+ setGrabSelectionOwnerActive("screen", true);
+ setGrabSelectionOwnerActive("root", true);
+
+ expect(getResolvedGrabSelectionOwnerId()).toBe("root");
+ });
+
+ it("falls back to the focused screen when the only active surface unmounts", () => {
+ registerOwner("root", "root", 1);
+ registerOwner("screen", "screen", 2);
+ setGrabSelectionOwnerFocused("screen", true);
+ registerOwner("sheet", "surface", 3);
+ setGrabSelectionOwnerActive("sheet", true);
+ expect(getResolvedGrabSelectionOwnerId()).toBe("sheet");
+
+ unregisterGrabSelectionOwner("sheet");
+ expect(getResolvedGrabSelectionOwnerId()).toBe("screen");
+
+ // A stale activation for the unmounted surface must not resurrect it.
+ setGrabSelectionOwnerActive("sheet", true);
+ expect(getResolvedGrabSelectionOwnerId()).toBe("screen");
+ });
+});
diff --git a/src/react-native/containers.ts b/src/react-native/containers.ts
index a2e1b64..418b81e 100644
--- a/src/react-native/containers.ts
+++ b/src/react-native/containers.ts
@@ -3,22 +3,19 @@ import { findNodeHandle, type ReactNativeElement } from "react-native";
import type { ReactNativeShadowNode } from "./types";
import { getFabricUIManager } from "./fabric";
-export type GrabSelectionOwnerKind = "root" | "screen";
+export type GrabSelectionOwnerKind = "root" | "screen" | "surface";
export type GrabSelectionOwner = {
id: string;
kind: GrabSelectionOwnerKind;
shadowNode: ReactNativeShadowNode;
registrationOrder: number;
-};
-
-type SelectionOwnersStoreSnapshot = {
- owners: Map;
- focusedScreenOwnerId: string | null;
+ activationOrder: number | null;
};
let ownerIdCounter = 0;
let registrationOrder = 0;
+let activationOrder = 0;
let focusedScreenOwnerId: string | null = null;
const owners = new Map();
const listeners = new Set<() => void>();
@@ -36,11 +33,6 @@ const subscribe = (listener: () => void) => {
};
};
-const getSnapshot = (): SelectionOwnersStoreSnapshot => ({
- owners: new Map(owners),
- focusedScreenOwnerId,
-});
-
const getOwnerShadowNode = (ref: ReactNativeElement, errorMessage: string) => {
// @ts-expect-error - findNodeHandle is not typed correctly
const nativeTag = findNodeHandle(ref);
@@ -52,12 +44,33 @@ const getOwnerShadowNode = (ref: ReactNativeElement, errorMessage: string) => {
return getFabricUIManager().findShadowNodeByTag_DEPRECATED(nativeTag);
};
+const ownerNativeTagErrorMessages: Record = {
+ root: "Failed to find native tag for app root",
+ screen: "Failed to find native tag for screen",
+ surface: "Failed to find native tag for native surface",
+};
+
const getFallbackRootOwner = () => {
const rootOwners = Array.from(owners.values()).filter((owner) => owner.kind === "root");
rootOwners.sort((left, right) => right.registrationOrder - left.registrationOrder);
return rootOwners[0] ?? null;
};
+const getActiveSurfaceOwner = () => {
+ let activeSurfaceOwner: GrabSelectionOwner | null = null;
+ let highestActivationOrder = -1;
+
+ for (const owner of owners.values()) {
+ const order = owner.kind === "surface" ? owner.activationOrder : null;
+ if (order !== null && order > highestActivationOrder) {
+ activeSurfaceOwner = owner;
+ highestActivationOrder = order;
+ }
+ }
+
+ return activeSurfaceOwner;
+};
+
export const createGrabSelectionOwnerId = (kind: GrabSelectionOwnerKind) => {
ownerIdCounter += 1;
return `react-native-grab-${kind}-${ownerIdCounter}`;
@@ -68,12 +81,7 @@ export const registerGrabSelectionOwner = (
kind: GrabSelectionOwnerKind,
ref: ReactNativeElement,
) => {
- const shadowNode = getOwnerShadowNode(
- ref,
- kind === "root"
- ? "Failed to find native tag for app root"
- : "Failed to find native tag for screen",
- );
+ const shadowNode = getOwnerShadowNode(ref, ownerNativeTagErrorMessages[kind]);
registrationOrder += 1;
owners.set(id, {
@@ -81,6 +89,7 @@ export const registerGrabSelectionOwner = (
kind,
shadowNode,
registrationOrder,
+ activationOrder: null,
});
notify();
};
@@ -124,11 +133,36 @@ export const clearGrabSelectionOwnerFocus = (id: string) => {
notify();
};
+export const setGrabSelectionOwnerActive = (id: string, isActive: boolean) => {
+ const owner = owners.get(id);
+ // Re-activating an already active surface deliberately keeps its original
+ // activation order, so a redundant render cannot promote a surface above one
+ // that was presented on top of it.
+ if (!owner || owner.kind !== "surface" || (owner.activationOrder !== null) === isActive) {
+ return;
+ }
+
+ if (isActive) {
+ activationOrder += 1;
+ }
+
+ owners.set(id, {
+ ...owner,
+ activationOrder: isActive ? activationOrder : null,
+ });
+ notify();
+};
+
export const getGrabSelectionOwner = (id: string): GrabSelectionOwner | null => {
return owners.get(id) ?? null;
};
export const getResolvedGrabSelectionOwner = (): GrabSelectionOwner | null => {
+ const activeSurfaceOwner = getActiveSurfaceOwner();
+ if (activeSurfaceOwner) {
+ return activeSurfaceOwner;
+ }
+
if (focusedScreenOwnerId) {
const focusedOwner = owners.get(focusedScreenOwnerId);
if (focusedOwner) {
@@ -143,14 +177,6 @@ export const getResolvedGrabSelectionOwnerId = (): string | null => {
return getResolvedGrabSelectionOwner()?.id ?? null;
};
-export const useResolvedGrabSelectionOwnerId = () => {
- return useSyncExternalStore(
- subscribe,
- () => getResolvedGrabSelectionOwnerId(),
- () => null,
- );
-};
-
export const useIsResolvedGrabSelectionOwner = (id: string) => {
return useSyncExternalStore(
subscribe,
@@ -158,7 +184,3 @@ export const useIsResolvedGrabSelectionOwner = (id: string) => {
() => false,
);
};
-
-export const useSelectionOwnersStore = () => {
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
-};
diff --git a/src/react-native/grab-overlay.tsx b/src/react-native/grab-overlay.tsx
index 24e205d..ff66c25 100644
--- a/src/react-native/grab-overlay.tsx
+++ b/src/react-native/grab-overlay.tsx
@@ -144,14 +144,22 @@ export const ReactNativeGrabOverlay = ({
return null;
}
- const internalNode = findNodeAtPoint(owner.shadowNode, pageX, pageY);
+ // findNodeAtPoint resolves the point against the owner, not the window, so
+ // page coordinates have to be rebased onto the owner's origin. They only
+ // coincide for an owner sitting at the window origin, which is why a screen
+ // under a native header, or a natively presented surface, missed its target.
+ const ownerRect = measureInWindow(owner.shadowNode);
+ const internalNode = findNodeAtPoint(
+ owner.shadowNode,
+ pageX - ownerRect[0],
+ pageY - ownerRect[1],
+ );
const shadowNode = internalNode?.stateNode?.node;
if (!shadowNode) {
return null;
}
- const ownerRect = measureInWindow(owner.shadowNode);
const rect = nativeFabricUIManager.getBoundingClientRect(shadowNode, true);
return {
fiberNode: internalNode,
diff --git a/src/react-native/grab-root.tsx b/src/react-native/grab-root.tsx
index 2342a58..3d0e51a 100644
--- a/src/react-native/grab-root.tsx
+++ b/src/react-native/grab-root.tsx
@@ -1,43 +1,26 @@
-import { useEffect, useRef, useState } from "react";
-import { View, ViewProps, type GestureResponderHandlers } from "react-native";
-import {
- createGrabSelectionOwnerId,
- registerGrabSelectionOwner,
- unregisterGrabSelectionOwner,
-} from "./containers";
-import { ReactNativeGrabOverlay } from "./grab-overlay";
+import type { ViewProps } from "react-native";
import { ReactNativeGrabRootControls } from "./grab-root-controls";
+import {
+ grabSelectionOwnerFillStyle,
+ GrabSelectionOwnerView,
+ useGrabSelectionOwner,
+} from "./grab-selection-owner";
export type ReactNativeGrabRootProps = ViewProps;
export const ReactNativeGrabRoot = ({ children, style, ...props }: ReactNativeGrabRootProps) => {
- const rootRef = useRef(null);
- const ownerIdRef = useRef(createGrabSelectionOwnerId("root"));
- const [panHandlers, setPanHandlers] = useState(null);
-
- useEffect(() => {
- if (!rootRef.current) {
- return;
- }
-
- registerGrabSelectionOwner(ownerIdRef.current, "root", rootRef.current);
- return () => {
- unregisterGrabSelectionOwner(ownerIdRef.current);
- };
- }, []);
+ const { ownerId, ownerRef } = useGrabSelectionOwner("root");
return (
<>
-
{children}
-
-
+
>
diff --git a/src/react-native/grab-root.web.tsx b/src/react-native/grab-root.web.tsx
index f353582..6855770 100644
--- a/src/react-native/grab-root.web.tsx
+++ b/src/react-native/grab-root.web.tsx
@@ -1,4 +1,6 @@
-// React Native Grab doesn't support web yet!
-export const ReactNativeGrabRoot = () => {
- return null;
+import { View, type ViewProps } from "react-native";
+
+// React Native Grab doesn't support web yet, so the root only preserves layout.
+export const ReactNativeGrabRoot = ({ style, ...props }: ViewProps) => {
+ return ;
};
diff --git a/src/react-native/grab-screen.tsx b/src/react-native/grab-screen.tsx
index 574cb1f..07d12cc 100644
--- a/src/react-native/grab-screen.tsx
+++ b/src/react-native/grab-screen.tsx
@@ -1,14 +1,12 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { View, type GestureResponderHandlers, type ViewProps } from "react-native";
-import {
- clearGrabSelectionOwnerFocus,
- createGrabSelectionOwnerId,
- registerGrabSelectionOwner,
- setGrabSelectionOwnerFocused,
- unregisterGrabSelectionOwner,
-} from "./containers";
+import { useCallback } from "react";
+import { type ViewProps } from "react-native";
+import { clearGrabSelectionOwnerFocus, setGrabSelectionOwnerFocused } from "./containers";
import { getFocusEffect } from "./focus-effect";
-import { ReactNativeGrabOverlay } from "./grab-overlay";
+import {
+ grabSelectionOwnerFillStyle,
+ GrabSelectionOwnerView,
+ useGrabSelectionOwner,
+} from "./grab-selection-owner";
const useFocusEffect = getFocusEffect();
@@ -22,44 +20,25 @@ export const ReactNativeGrabScreen = ({
id,
...props
}: ReactNativeGrabScreenProps) => {
- const screenRef = useRef(null);
- const ownerIdRef = useRef(id ?? createGrabSelectionOwnerId("screen"));
- const [panHandlers, setPanHandlers] = useState(null);
-
- useEffect(() => {
- if (!screenRef.current) {
- return;
- }
-
- registerGrabSelectionOwner(ownerIdRef.current, "screen", screenRef.current);
- return () => {
- unregisterGrabSelectionOwner(ownerIdRef.current);
- };
- }, []);
+ const { ownerId, ownerRef } = useGrabSelectionOwner("screen", id);
useFocusEffect(
useCallback(() => {
- if (!screenRef.current) {
- return;
- }
-
- setGrabSelectionOwnerFocused(ownerIdRef.current, true);
+ setGrabSelectionOwnerFocused(ownerId, true);
return () => {
- clearGrabSelectionOwnerFocus(ownerIdRef.current);
+ clearGrabSelectionOwnerFocus(ownerId);
};
- }, []),
+ }, [ownerId]),
);
return (
-
{children}
-
-
+
);
};
diff --git a/src/react-native/grab-screen.web.tsx b/src/react-native/grab-screen.web.tsx
index 7ca6ccb..776ed5b 100644
--- a/src/react-native/grab-screen.web.tsx
+++ b/src/react-native/grab-screen.web.tsx
@@ -1,4 +1,7 @@
-// React Native Grab doesn't support web yet!
-export const ReactNativeGrabScreen = () => {
- return null;
+import { View, type ViewProps } from "react-native";
+
+// React Native Grab doesn't support web yet, so the screen only preserves layout.
+// `id` identifies a selection owner, not a DOM node, so it is not forwarded.
+export const ReactNativeGrabScreen = ({ id, style, ...props }: ViewProps & { id?: string }) => {
+ return ;
};
diff --git a/src/react-native/grab-selection-owner.tsx b/src/react-native/grab-selection-owner.tsx
new file mode 100644
index 0000000..36c7c2b
--- /dev/null
+++ b/src/react-native/grab-selection-owner.tsx
@@ -0,0 +1,56 @@
+import { useEffect, useRef, useState, type RefObject } from "react";
+import { View, type GestureResponderHandlers, type ViewProps } from "react-native";
+import {
+ createGrabSelectionOwnerId,
+ registerGrabSelectionOwner,
+ unregisterGrabSelectionOwner,
+ type GrabSelectionOwnerKind,
+} from "./containers";
+import { ReactNativeGrabOverlay } from "./grab-overlay";
+
+/** Root and screen owners always fill their parent; surfaces are sized by their host. */
+export const grabSelectionOwnerFillStyle = { flex: 1 } as const;
+
+export const useGrabSelectionOwner = (kind: GrabSelectionOwnerKind, id?: string) => {
+ const ownerRef = useRef(null);
+ const ownerIdRef = useRef(id ?? createGrabSelectionOwnerId(kind));
+
+ useEffect(() => {
+ const ownerId = ownerIdRef.current;
+
+ if (!ownerRef.current) {
+ console.error(
+ `[react-native-grab] Failed to register ${kind} selection owner: the view ref was never attached. Elements inside it cannot be grabbed.`,
+ );
+ return;
+ }
+
+ registerGrabSelectionOwner(ownerId, kind, ownerRef.current);
+ return () => {
+ unregisterGrabSelectionOwner(ownerId);
+ };
+ }, [kind]);
+
+ return { ownerId: ownerIdRef.current, ownerRef };
+};
+
+type GrabSelectionOwnerViewProps = ViewProps & {
+ ownerId: string;
+ ownerRef: RefObject;
+};
+
+export const GrabSelectionOwnerView = ({
+ children,
+ ownerId,
+ ownerRef,
+ ...props
+}: GrabSelectionOwnerViewProps) => {
+ const [panHandlers, setPanHandlers] = useState(null);
+
+ return (
+
+ {children}
+
+
+ );
+};
diff --git a/src/react-native/grab-surface.tsx b/src/react-native/grab-surface.tsx
new file mode 100644
index 0000000..07201be
--- /dev/null
+++ b/src/react-native/grab-surface.tsx
@@ -0,0 +1,34 @@
+import { useEffect } from "react";
+import type { ViewProps } from "react-native";
+import { setGrabSelectionOwnerActive } from "./containers";
+import { GrabSelectionOwnerView, useGrabSelectionOwner } from "./grab-selection-owner";
+
+export type ReactNativeGrabSurfaceProps = ViewProps & {
+ active: boolean;
+};
+
+export const ReactNativeGrabSurface = ({
+ active,
+ children,
+ style,
+ ...props
+}: ReactNativeGrabSurfaceProps) => {
+ const { ownerId, ownerRef } = useGrabSelectionOwner("surface");
+
+ useEffect(() => {
+ if (!active) {
+ return;
+ }
+
+ setGrabSelectionOwnerActive(ownerId, true);
+ return () => {
+ setGrabSelectionOwnerActive(ownerId, false);
+ };
+ }, [active, ownerId]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/react-native/grab-surface.web.tsx b/src/react-native/grab-surface.web.tsx
new file mode 100644
index 0000000..beef152
--- /dev/null
+++ b/src/react-native/grab-surface.web.tsx
@@ -0,0 +1,6 @@
+import { View, type ViewProps } from "react-native";
+
+// React Native Grab doesn't support web yet, so the surface only preserves layout.
+export const ReactNativeGrabSurface = ({ active, ...props }: ViewProps & { active: boolean }) => {
+ return ;
+};
diff --git a/src/react-native/index.ts b/src/react-native/index.ts
index c59a453..1d6663f 100644
--- a/src/react-native/index.ts
+++ b/src/react-native/index.ts
@@ -1,10 +1,12 @@
import type { ReactNativeGrabRootProps } from "./grab-root";
import type { ReactNativeGrabScreenProps } from "./grab-screen";
+import type { ReactNativeGrabSurfaceProps } from "./grab-surface";
import type { ReactNativeGrabContextProviderProps } from "./grab-context";
import type { ReactNode } from "react";
export type { ReactNativeGrabRootProps } from "./grab-root";
export type { ReactNativeGrabScreenProps } from "./grab-screen";
+export type { ReactNativeGrabSurfaceProps } from "./grab-surface";
export type {
ReactNativeGrabContextProviderProps,
ReactNativeGrabContextValue,
@@ -21,6 +23,10 @@ export const ReactNativeGrabScreen: React.ComponentType = __DEV__
+ ? require("./grab-surface").ReactNativeGrabSurface
+ : Passthrough;
+
export const ReactNativeGrabContextProvider: React.ComponentType =
__DEV__ ? require("./grab-context").ReactNativeGrabContextProvider : Passthrough;