Skip to content

Commit 2bd118f

Browse files
cmcWebCode40claude
andcommitted
feat: add JsonViewer component with collapsible tree and syntax highlighting
Add interactive JSON viewer supporting expand/collapse, syntax highlighting for keys/strings/numbers/booleans, configurable initial expansion depth, and light/dark theme support. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c9d7754 commit 2bd118f

1 file changed

Lines changed: 306 additions & 0 deletions

File tree

src/components/JsonViewer.tsx

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import React, { useState, useCallback, useMemo } from 'react';
2+
import {
3+
StyleSheet,
4+
Text,
5+
TouchableOpacity,
6+
View,
7+
type StyleProp,
8+
type ViewStyle,
9+
} from 'react-native';
10+
import type { ThemeMode } from '../constants/colors';
11+
import { getThemeColors } from '../constants/colors';
12+
13+
interface JsonViewerProps {
14+
data: string | object | null;
15+
theme?: ThemeMode;
16+
initialExpanded?: boolean;
17+
maxInitialDepth?: number;
18+
style?: StyleProp<ViewStyle>;
19+
}
20+
21+
interface JsonNodeProps {
22+
keyName?: string;
23+
value: unknown;
24+
depth: number;
25+
theme: ThemeMode;
26+
maxInitialDepth: number;
27+
isLast: boolean;
28+
}
29+
30+
const syntaxColors = {
31+
light: {
32+
key: '#881391',
33+
string: '#1A1AA6',
34+
number: '#098658',
35+
boolean: '#0000FF',
36+
null: '#808080',
37+
bracket: '#333333',
38+
punctuation: '#666666',
39+
},
40+
dark: {
41+
key: '#9CDCFE',
42+
string: '#CE9178',
43+
number: '#B5CEA8',
44+
boolean: '#569CD6',
45+
null: '#808080',
46+
bracket: '#D4D4D4',
47+
punctuation: '#AAAAAA',
48+
},
49+
};
50+
51+
const JsonNode: React.FC<JsonNodeProps> = ({
52+
keyName,
53+
value,
54+
depth,
55+
theme,
56+
maxInitialDepth,
57+
isLast,
58+
}) => {
59+
const [isExpanded, setIsExpanded] = useState(depth < maxInitialDepth);
60+
const themeColors = getThemeColors(theme);
61+
const syntaxTheme = syntaxColors[theme];
62+
const indent = depth * 16;
63+
64+
const toggleExpand = useCallback(() => {
65+
setIsExpanded((prev) => !prev);
66+
}, []);
67+
68+
const renderValue = useCallback(
69+
(val: unknown): React.ReactNode => {
70+
if (val === null) {
71+
return <Text style={{ color: syntaxTheme.null }}>null</Text>;
72+
}
73+
74+
if (val === undefined) {
75+
return <Text style={{ color: syntaxTheme.null }}>undefined</Text>;
76+
}
77+
78+
switch (typeof val) {
79+
case 'string':
80+
return (
81+
<Text style={{ color: syntaxTheme.string }} numberOfLines={3}>
82+
"{val}"
83+
</Text>
84+
);
85+
case 'number':
86+
return <Text style={{ color: syntaxTheme.number }}>{val}</Text>;
87+
case 'boolean':
88+
return (
89+
<Text style={{ color: syntaxTheme.boolean }}>
90+
{val ? 'true' : 'false'}
91+
</Text>
92+
);
93+
default:
94+
return <Text style={{ color: themeColors.text }}>{String(val)}</Text>;
95+
}
96+
},
97+
[syntaxTheme, themeColors]
98+
);
99+
100+
const comma = isLast ? '' : ',';
101+
102+
if (value === null || value === undefined) {
103+
return (
104+
<View style={[styles.row, { marginLeft: indent }]}>
105+
{keyName !== undefined && (
106+
<>
107+
<Text style={{ color: syntaxTheme.key }}>"{keyName}"</Text>
108+
<Text style={{ color: syntaxTheme.punctuation }}>: </Text>
109+
</>
110+
)}
111+
{renderValue(value)}
112+
<Text style={{ color: syntaxTheme.punctuation }}>{comma}</Text>
113+
</View>
114+
);
115+
}
116+
117+
if (typeof value !== 'object') {
118+
return (
119+
<View style={[styles.row, { marginLeft: indent }]}>
120+
{keyName !== undefined && (
121+
<>
122+
<Text style={{ color: syntaxTheme.key }}>"{keyName}"</Text>
123+
<Text style={{ color: syntaxTheme.punctuation }}>: </Text>
124+
</>
125+
)}
126+
{renderValue(value)}
127+
<Text style={{ color: syntaxTheme.punctuation }}>{comma}</Text>
128+
</View>
129+
);
130+
}
131+
132+
const isArray = Array.isArray(value);
133+
const entries = isArray
134+
? value.map((v, i) => [i, v] as [number, unknown])
135+
: Object.entries(value);
136+
const isEmpty = entries.length === 0;
137+
const openBracket = isArray ? '[' : '{';
138+
const closeBracket = isArray ? ']' : '}';
139+
140+
if (isEmpty) {
141+
return (
142+
<View style={[styles.row, { marginLeft: indent }]}>
143+
{keyName !== undefined && (
144+
<>
145+
<Text style={{ color: syntaxTheme.key }}>"{keyName}"</Text>
146+
<Text style={{ color: syntaxTheme.punctuation }}>: </Text>
147+
</>
148+
)}
149+
<Text style={{ color: syntaxTheme.bracket }}>
150+
{openBracket}
151+
{closeBracket}
152+
</Text>
153+
<Text style={{ color: syntaxTheme.punctuation }}>{comma}</Text>
154+
</View>
155+
);
156+
}
157+
158+
return (
159+
<View>
160+
<TouchableOpacity
161+
style={[styles.row, { marginLeft: indent }]}
162+
onPress={toggleExpand}
163+
activeOpacity={0.7}
164+
>
165+
<Text style={[styles.expandIcon, { color: themeColors.textSecondary }]}>
166+
{isExpanded ? '▼' : '▶'}
167+
</Text>
168+
{keyName !== undefined && (
169+
<>
170+
<Text style={{ color: syntaxTheme.key }}>"{keyName}"</Text>
171+
<Text style={{ color: syntaxTheme.punctuation }}>: </Text>
172+
</>
173+
)}
174+
<Text style={{ color: syntaxTheme.bracket }}>{openBracket}</Text>
175+
{!isExpanded && (
176+
<>
177+
<Text style={{ color: themeColors.textMuted }}>
178+
{' '}
179+
{entries.length} {isArray ? 'items' : 'keys'}{' '}
180+
</Text>
181+
<Text style={{ color: syntaxTheme.bracket }}>{closeBracket}</Text>
182+
<Text style={{ color: syntaxTheme.punctuation }}>{comma}</Text>
183+
</>
184+
)}
185+
</TouchableOpacity>
186+
187+
{isExpanded && (
188+
<>
189+
{entries.map(([key, val], index) => (
190+
<JsonNode
191+
key={String(key)}
192+
keyName={isArray ? undefined : String(key)}
193+
value={val}
194+
depth={depth + 1}
195+
theme={theme}
196+
maxInitialDepth={maxInitialDepth}
197+
isLast={index === entries.length - 1}
198+
/>
199+
))}
200+
<View style={[styles.row, { marginLeft: indent }]}>
201+
<Text style={{ color: syntaxTheme.bracket }}>{closeBracket}</Text>
202+
<Text style={{ color: syntaxTheme.punctuation }}>{comma}</Text>
203+
</View>
204+
</>
205+
)}
206+
</View>
207+
);
208+
};
209+
210+
export const JsonViewer: React.FC<JsonViewerProps> = ({
211+
data,
212+
theme = 'light',
213+
initialExpanded = true,
214+
maxInitialDepth = 2,
215+
style,
216+
}) => {
217+
const themeColors = getThemeColors(theme);
218+
219+
const parsedData = useMemo(() => {
220+
if (data === null || data === undefined) {
221+
return null;
222+
}
223+
if (typeof data === 'object') {
224+
return data;
225+
}
226+
if (typeof data === 'string') {
227+
try {
228+
return JSON.parse(data);
229+
} catch {
230+
return data;
231+
}
232+
}
233+
return data;
234+
}, [data]);
235+
236+
if (parsedData === null || parsedData === undefined) {
237+
return (
238+
<View
239+
style={[
240+
styles.container,
241+
{ backgroundColor: themeColors.codeBackground },
242+
style,
243+
]}
244+
>
245+
<Text style={{ color: themeColors.textMuted }}>No data</Text>
246+
</View>
247+
);
248+
}
249+
250+
if (typeof parsedData === 'string') {
251+
return (
252+
<View
253+
style={[
254+
styles.container,
255+
{ backgroundColor: themeColors.codeBackground },
256+
style,
257+
]}
258+
>
259+
<Text style={[styles.rawText, { color: themeColors.text }]}>
260+
{parsedData}
261+
</Text>
262+
</View>
263+
);
264+
}
265+
266+
return (
267+
<View
268+
style={[
269+
styles.container,
270+
{ backgroundColor: themeColors.codeBackground },
271+
style,
272+
]}
273+
>
274+
<JsonNode
275+
value={parsedData}
276+
depth={0}
277+
theme={theme}
278+
maxInitialDepth={initialExpanded ? maxInitialDepth : 0}
279+
isLast={true}
280+
/>
281+
</View>
282+
);
283+
};
284+
285+
const styles = StyleSheet.create({
286+
container: {
287+
padding: 12,
288+
borderRadius: 8,
289+
},
290+
row: {
291+
flexDirection: 'row',
292+
flexWrap: 'wrap',
293+
alignItems: 'flex-start',
294+
paddingVertical: 2,
295+
},
296+
expandIcon: {
297+
width: 16,
298+
fontSize: 10,
299+
marginRight: 4,
300+
},
301+
rawText: {
302+
fontFamily: 'monospace',
303+
fontSize: 13,
304+
lineHeight: 18,
305+
},
306+
});

0 commit comments

Comments
 (0)