Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/olive-pugs-repeat.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/quiet-pandas-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-native-grab": patch
---

Add `ReactNativeGrabSurface` for selecting React Native content hosted in separately presented native surfaces.
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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 visible={visible}>
<ReactNativeGrabSurface active={visible} style={styles.surface}>
{/* modal or sheet content */}
</ReactNativeGrabSurface>
</Modal>
);
}

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
Expand Down
65 changes: 62 additions & 3 deletions example/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions example/src/app/(tabs)/explore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,31 @@ export default function TabTwoScreen() {
</Link>
</Collapsible>

<Collapsible title="Native sheet">
<ThemedText type="small">
Content presented by a native sheet lives outside the screen subtree. Open the sheet
playground and try selecting elements inside a presented sheet.
</ThemedText>
<Link href="/sheet-playground" asChild>
<Pressable
style={({ pressed }) => [styles.modalTrigger, pressed && styles.pressed]}
>
<ThemedView type="backgroundElement" style={styles.modalTriggerInner}>
<ThemedText type="link">Open sheet playground</ThemedText>
<SymbolView
tintColor={theme.text}
name={{
ios: "rectangle.bottomhalf.filled",
android: "layers",
web: "layers",
}}
size={14}
/>
</ThemedView>
</Pressable>
</Link>
</Collapsible>

<Collapsible title="Grab context playground">
<ThemedText type="small">
Open a dedicated modal with nested grab context providers. Each nested element
Expand Down
1 change: 1 addition & 0 deletions example/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default function MainLayout() {
name="context-playground"
options={{ presentation: "modal", title: "Context Playground" }}
/>
<Stack.Screen name="sheet-playground" options={{ title: "Sheet Playground" }} />
</Stack>
</View>
</ThemeProvider>
Expand Down
177 changes: 177 additions & 0 deletions example/src/app/sheet-playground.tsx
Original file line number Diff line number Diff line change
@@ -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 <Text style={[styles.target, { color: theme.text }]}>Screen target</Text>;
}

function AutoSheetTarget() {
const theme = useTheme();
return <Text style={[styles.target, { color: theme.text }]}>Auto sheet target</Text>;
}

function StackedSheetTarget() {
const theme = useTheme();
return <Text style={[styles.target, { color: theme.text }]}>Stacked sheet target</Text>;
}

function ModalTarget() {
const theme = useTheme();
return <Text style={[styles.target, { color: theme.text }]}>Modal target</Text>;
}

type ActionProps = {
label: string;
onPress: () => void;
};

function Action({ label, onPress }: ActionProps) {
return (
<Pressable onPress={onPress} style={({ pressed }) => [pressed && styles.pressed]}>
<ThemedView type="backgroundElement" style={styles.action}>
<ThemedText type="link">{label}</ThemedText>
</ThemedView>
</Pressable>
);
}

export default function SheetPlaygroundScreen() {
const theme = useTheme();
const autoSheet = useRef<TrueSheet>(null);
const stackedSheet = useRef<TrueSheet>(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 (
<ReactNativeGrabScreen>
<ThemedView style={styles.container}>
<ThemedText type="subtitle">Native sheets</ThemedText>
<ThemedText type="small" themeColor="textSecondary">
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.
</ThemedText>

<ScreenTarget />

<View style={styles.actions}>
<Action label="Start grabbing on screen" onPress={enableGrabbing} />
<Action label="Open auto sheet" onPress={() => void autoSheet.current?.present()} />
<Action label="Open modal" onPress={() => setIsModalVisible(true)} />
</View>
</ThemedView>

{/* Content-measured sheet: the surface wrapper sizes to its content, which is
what the `auto` detent needs to measure. */}
<TrueSheet
ref={autoSheet}
name="auto-sheet"
detents={["auto"]}
cornerRadius={24}
backgroundColor={theme.background}
onDidPresent={() => setIsAutoSheetPresented(true)}
onDidDismiss={() => setIsAutoSheetPresented(false)}
>
<ReactNativeGrabSurface active={isAutoSheetPresented} style={styles.sheetContent}>
<ThemedText type="subtitle">Auto sheet</ThemedText>
<AutoSheetTarget />
<Action label="Start grabbing in auto sheet" onPress={enableGrabbing} />
<Action label="Open stacked sheet" onPress={() => void stackedSheet.current?.present()} />
<Action label="Dismiss auto sheet" onPress={() => void autoSheet.current?.dismiss()} />
</ReactNativeGrabSurface>
</TrueSheet>

{/* 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. */}
<TrueSheet
ref={stackedSheet}
name="stacked-sheet"
detents={["auto"]}
cornerRadius={24}
backgroundColor={theme.background}
onDidPresent={() => setIsStackedSheetPresented(true)}
onDidDismiss={() => setIsStackedSheetPresented(false)}
>
<ReactNativeGrabSurface active={isStackedSheetPresented} style={styles.sheetContent}>
<ThemedText type="subtitle">Stacked sheet</ThemedText>
<StackedSheetTarget />
<Action label="Start grabbing in stacked sheet" onPress={enableGrabbing} />
<Action
label="Dismiss stacked sheet"
onPress={() => void stackedSheet.current?.dismiss()}
/>
</ReactNativeGrabSurface>
</TrueSheet>

{/* A plain RN modal presents a full-screen container, so here the surface
has to be told to fill it. */}
<Modal visible={isModalVisible} onRequestClose={() => setIsModalVisible(false)}>
<ReactNativeGrabSurface
active={isModalVisible}
style={[styles.sheetContent, styles.fill, { backgroundColor: theme.background }]}
>
<ThemedText type="subtitle">Modal</ThemedText>
<ModalTarget />
<Action label="Start grabbing in modal" onPress={enableGrabbing} />
<Action label="Close modal" onPress={() => setIsModalVisible(false)} />
</ReactNativeGrabSurface>
</Modal>
</ReactNativeGrabScreen>
);
}

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,
},
});
Loading
Loading