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;