Skip to content

Commit d029f4b

Browse files
authored
feat: improve grab selection labels and menu title (#8)
Improves how grabbed elements are labeled in the selection menu and in the description copied from Grab.
1 parent ba7c5d4 commit d029f4b

4 files changed

Lines changed: 162 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-native-grab": patch
3+
---
4+
5+
Grab labels now prefer meaningful component names from the owner stack (skipping generic `View` / `Text` wrappers), so the menu shows titles like **Text (in YourScreen)** and the copied description preview matches. The selection menu title also scales down when the label is long so it stays readable.

src/react-native/__tests__/grab-context-description.test.ts

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
1-
import { describe, expect, it, vi } from "vitest";
2-
import { getDescription } from "../description";
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
getDescription,
4+
getGrabSelectionTitle,
5+
GRAB_HOST_LIKE_COMPONENT_NAMES,
6+
isHostLikeComponentName,
7+
} from "../description";
38
import { composeGrabContextValue, ReactNativeGrabInternalContext } from "../grab-context";
9+
import type { RenderedByFrame } from "../get-rendered-by";
410
import type { ReactNativeFiberNode } from "../types";
511

612
vi.mock("../get-rendered-by", () => ({
713
getRenderedBy: vi.fn(async () => []),
814
}));
915

16+
import { getRenderedBy } from "../get-rendered-by";
17+
18+
const mockedGetRenderedBy = vi.mocked(getRenderedBy);
19+
1020
const createHostFiber = (
1121
props: Record<string, unknown>,
1222
parent: ReactNativeFiberNode | null = null,
@@ -31,6 +41,88 @@ const createContextProviderFiber = (
3141
_debugOwner: null,
3242
});
3343

44+
const frame = (name: string): RenderedByFrame => ({
45+
name,
46+
file: null,
47+
line: null,
48+
column: null,
49+
collapse: false,
50+
});
51+
52+
describe("isHostLikeComponentName", () => {
53+
it("treats View and Text as host-like", () => {
54+
for (const name of GRAB_HOST_LIKE_COMPONENT_NAMES) {
55+
expect(isHostLikeComponentName(name)).toBe(true);
56+
}
57+
expect(isHostLikeComponentName("InstallTabs")).toBe(false);
58+
});
59+
60+
it("trims names before matching", () => {
61+
expect(isHostLikeComponentName(" Text ")).toBe(true);
62+
});
63+
});
64+
65+
describe("getGrabSelectionTitle", () => {
66+
it("skips host-like owners to show Text (in InstallTabs)", () => {
67+
const fiber = createHostFiber({ children: "x" });
68+
expect(getGrabSelectionTitle(fiber, [frame("Text"), frame("InstallTabs")])).toBe(
69+
"Text (in InstallTabs)",
70+
);
71+
});
72+
73+
it("skips multiple host-like owners", () => {
74+
const viewFiber: ReactNativeFiberNode = {
75+
type: "View",
76+
memoizedProps: {},
77+
return: null,
78+
stateNode: null,
79+
_debugStack: new Error(),
80+
_debugOwner: null,
81+
};
82+
expect(getGrabSelectionTitle(viewFiber, [frame("View"), frame("Text"), frame("Screen")])).toBe(
83+
"View (in Screen)",
84+
);
85+
});
86+
87+
it("returns host only when every owner is host-like", () => {
88+
const viewFiber: ReactNativeFiberNode = {
89+
type: "View",
90+
memoizedProps: {},
91+
return: null,
92+
stateNode: null,
93+
_debugStack: new Error(),
94+
_debugOwner: null,
95+
};
96+
expect(getGrabSelectionTitle(viewFiber, [frame("View"), frame("Text")])).toBe("View");
97+
});
98+
99+
it("returns Selected element when host is unknown", () => {
100+
const fiber = {
101+
type: () => null,
102+
memoizedProps: {},
103+
return: null,
104+
stateNode: null,
105+
_debugStack: new Error(),
106+
_debugOwner: null,
107+
} as ReactNativeFiberNode;
108+
expect(getGrabSelectionTitle(fiber, [])).toBe("Selected element");
109+
});
110+
111+
it("uses host-like name from renderedBy when the fiber has no string host", () => {
112+
const fiber = {
113+
type: () => null,
114+
memoizedProps: {},
115+
return: null,
116+
stateNode: null,
117+
_debugStack: new Error(),
118+
_debugOwner: null,
119+
} as ReactNativeFiberNode;
120+
expect(getGrabSelectionTitle(fiber, [frame("Text"), frame("InstallTabs")])).toBe(
121+
"Text (in InstallTabs)",
122+
);
123+
});
124+
});
125+
34126
describe("composeGrabContextValue", () => {
35127
it("returns shallow copy when parent context does not exist", () => {
36128
const result = composeGrabContextValue(null, { screen: "home", attempt: 1 });
@@ -54,6 +146,11 @@ describe("composeGrabContextValue", () => {
54146
});
55147

56148
describe("getDescription with grab context", () => {
149+
beforeEach(() => {
150+
mockedGetRenderedBy.mockReset();
151+
mockedGetRenderedBy.mockResolvedValue([]);
152+
});
153+
57154
it("keeps current output format when no context provider is in ancestors", async () => {
58155
const selectedFiber = createHostFiber({ children: "Hello" });
59156

@@ -64,6 +161,16 @@ describe("getDescription with grab context", () => {
64161
expect(description).not.toContain("Context:");
65162
});
66163

164+
it("uses first non-host-like renderedBy name for the preview tag", async () => {
165+
mockedGetRenderedBy.mockResolvedValue([frame("Text"), frame("InstallTabs")]);
166+
const selectedFiber = createHostFiber({ children: "Hello" });
167+
168+
const description = await getDescription(selectedFiber);
169+
170+
expect(description.startsWith("<InstallTabs")).toBe(true);
171+
expect(description).toContain("Hello");
172+
});
173+
67174
it("appends Context block from nearest provider value", async () => {
68175
const parentProvider = createContextProviderFiber({ screen: "home", locale: "en" });
69176
const childProvider = createContextProviderFiber(

src/react-native/description.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ const PRIORITY_ATTRS = [
1818
"accessibilityValue",
1919
] as const;
2020

21+
/** Owner names treated as host-like when resolving `Text (in Owner)` for the grab menu. */
22+
export const GRAB_HOST_LIKE_COMPONENT_NAMES = ["View", "Text"] as const;
23+
24+
const HOST_LIKE_NAME_SET = new Set<string>(GRAB_HOST_LIKE_COMPONENT_NAMES);
25+
26+
export const isHostLikeComponentName = (name: string): boolean =>
27+
HOST_LIKE_NAME_SET.has(name.trim());
28+
29+
const firstHostLikeRenderedByName = (renderedBy: RenderedByFrame[]): string | null => {
30+
const frame = renderedBy.find((f) => isHostLikeComponentName(f.name));
31+
const trimmed = frame?.name?.trim();
32+
return trimmed && trimmed.length > 0 ? trimmed : null;
33+
};
34+
35+
const firstNonHostRenderedByName = (renderedBy: RenderedByFrame[]): string | null => {
36+
const frame = renderedBy.find((f) => !isHostLikeComponentName(f.name));
37+
const trimmed = frame?.name?.trim();
38+
return trimmed && trimmed.length > 0 ? trimmed : null;
39+
};
40+
2141
const truncate = (value: string, maxLength: number): string => {
2242
if (value.length <= maxLength) return value;
2343
return `${value.slice(0, maxLength - 3)}...`;
@@ -120,8 +140,8 @@ const getPreviewComponentName = (
120140
node: ReactNativeFiberNode,
121141
renderedBy: RenderedByFrame[],
122142
): string => {
123-
const firstRenderedBy = renderedBy[0]?.name?.trim();
124-
if (firstRenderedBy) return firstRenderedBy;
143+
const fromRenderedBy = firstNonHostRenderedByName(renderedBy);
144+
if (fromRenderedBy) return fromRenderedBy;
125145

126146
const hostFiber = getHostFiber(node);
127147
return getHostComponentName(hostFiber);
@@ -208,6 +228,22 @@ const buildContextBlock = (contextValue: ReactNativeGrabContextValue | null): st
208228
return `\n\nContext:\n${JSON.stringify(contextValue, null, 2)}`;
209229
};
210230

231+
export const getGrabSelectionTitle = (
232+
node: ReactNativeFiberNode,
233+
renderedBy: RenderedByFrame[],
234+
): string => {
235+
const fromFiber = getHostComponentName(getHostFiber(node));
236+
const rawHostLabel =
237+
firstHostLikeRenderedByName(renderedBy) ?? (fromFiber !== "(unknown)" ? fromFiber : null);
238+
const hostUnknown = rawHostLabel == null;
239+
const hostLabel = hostUnknown ? "Selected element" : rawHostLabel;
240+
const ownerName = firstNonHostRenderedByName(renderedBy);
241+
if (ownerName && ownerName !== hostLabel) {
242+
return `${hostLabel} (in ${ownerName})`;
243+
}
244+
return hostLabel;
245+
};
246+
211247
export const getDescription = async (node: ReactNativeFiberNode): Promise<string> => {
212248
let renderedBy = await getRenderedBy(node);
213249

src/react-native/grab-overlay.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
showGrabSelectionMenu,
2121
unregisterLocalGrabSelectionController,
2222
} from "./grab-controller";
23-
import { getDescription } from "./description";
23+
import { getDescription, getGrabSelectionTitle } from "./description";
2424
import { getRenderedBy, type RenderedByFrame } from "./get-rendered-by";
2525
import { findNodeAtPoint, measureInWindow } from "./measure";
2626
import { openStackFrameInEditor } from "./open";
@@ -178,8 +178,7 @@ export const ReactNativeGrabOverlay = ({
178178
]);
179179

180180
const firstFrame = renderedBy.find((frame) => Boolean(frame.file)) ?? null;
181-
const elementNameMatch = description.match(/<([A-Za-z0-9_$.:-]+)/);
182-
const elementName = elementNameMatch?.[1] ?? firstFrame?.name ?? "Selected element";
181+
const elementName = getGrabSelectionTitle(result.fiberNode, renderedBy);
183182

184183
setState((prev) => ({
185184
...prev,
@@ -369,7 +368,12 @@ export const ReactNativeGrabOverlay = ({
369368
visible={state.selectedElement !== null}
370369
>
371370
<View style={styles.selectionMenuHeader}>
372-
<Text numberOfLines={1} style={styles.selectionMenuTitle}>
371+
<Text
372+
adjustsFontSizeToFit
373+
minimumFontScale={0.65}
374+
numberOfLines={1}
375+
style={styles.selectionMenuTitle}
376+
>
373377
{state.selectedElement?.elementName}
374378
</Text>
375379
</View>
@@ -437,10 +441,12 @@ const styles = StyleSheet.create({
437441
borderColor: GRAB_PRIMARY,
438442
},
439443
selectionMenuHeader: {
444+
alignSelf: "stretch",
440445
paddingHorizontal: 14,
441446
paddingVertical: 12,
442447
},
443448
selectionMenuTitle: {
449+
width: "100%",
444450
color: "#111111",
445451
fontSize: 14,
446452
fontWeight: "600",

0 commit comments

Comments
 (0)