@farcaster/snap 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/react-native/catalog-renderer.d.ts +5 -0
  2. package/dist/react-native/catalog-renderer.js +36 -0
  3. package/dist/react-native/components/snap-action-button.d.ts +2 -0
  4. package/dist/react-native/components/snap-action-button.js +68 -0
  5. package/dist/react-native/components/snap-badge.d.ts +2 -0
  6. package/dist/react-native/components/snap-badge.js +38 -0
  7. package/dist/react-native/components/snap-icon.d.ts +5 -0
  8. package/dist/react-native/components/snap-icon.js +56 -0
  9. package/dist/react-native/components/snap-image.d.ts +2 -0
  10. package/dist/react-native/components/snap-image.js +24 -0
  11. package/dist/react-native/components/snap-input.d.ts +2 -0
  12. package/dist/react-native/components/snap-input.js +36 -0
  13. package/dist/react-native/components/snap-item-group.d.ts +5 -0
  14. package/dist/react-native/components/snap-item-group.js +23 -0
  15. package/dist/react-native/components/snap-item.d.ts +5 -0
  16. package/dist/react-native/components/snap-item.js +45 -0
  17. package/dist/react-native/components/snap-progress.d.ts +2 -0
  18. package/dist/react-native/components/snap-progress.js +26 -0
  19. package/dist/react-native/components/snap-separator.d.ts +2 -0
  20. package/dist/react-native/components/snap-separator.js +23 -0
  21. package/dist/react-native/components/snap-slider.d.ts +2 -0
  22. package/dist/react-native/components/snap-slider.js +42 -0
  23. package/dist/react-native/components/snap-stack.d.ts +5 -0
  24. package/dist/react-native/components/snap-stack.js +49 -0
  25. package/dist/react-native/components/snap-switch.d.ts +2 -0
  26. package/dist/react-native/components/snap-switch.js +30 -0
  27. package/dist/react-native/components/snap-text.d.ts +2 -0
  28. package/dist/react-native/components/snap-text.js +37 -0
  29. package/dist/react-native/components/snap-toggle-group.d.ts +2 -0
  30. package/dist/react-native/components/snap-toggle-group.js +100 -0
  31. package/dist/react-native/index.d.ts +52 -0
  32. package/dist/react-native/index.js +155 -0
  33. package/dist/react-native/theme.d.ts +21 -0
  34. package/dist/react-native/theme.js +37 -0
  35. package/dist/react-native/use-snap-palette.d.ts +13 -0
  36. package/dist/react-native/use-snap-palette.js +48 -0
  37. package/dist/ui/badge.d.ts +2 -2
  38. package/dist/ui/button.d.ts +2 -2
  39. package/dist/ui/catalog.d.ts +7 -7
  40. package/dist/ui/icon.d.ts +2 -2
  41. package/dist/ui/schema.d.ts +1 -1
  42. package/package.json +7 -2
  43. package/src/react-native/catalog-renderer.tsx +37 -0
  44. package/src/react-native/components/snap-action-button.tsx +92 -0
  45. package/src/react-native/components/snap-badge.tsx +57 -0
  46. package/src/react-native/components/snap-icon.tsx +102 -0
  47. package/src/react-native/components/snap-image.tsx +38 -0
  48. package/src/react-native/components/snap-input.tsx +57 -0
  49. package/src/react-native/components/snap-item-group.tsx +43 -0
  50. package/src/react-native/components/snap-item.tsx +70 -0
  51. package/src/react-native/components/snap-progress.tsx +40 -0
  52. package/src/react-native/components/snap-separator.tsx +32 -0
  53. package/src/react-native/components/snap-slider.tsx +82 -0
  54. package/src/react-native/components/snap-stack.tsx +66 -0
  55. package/src/react-native/components/snap-switch.tsx +45 -0
  56. package/src/react-native/components/snap-text.tsx +53 -0
  57. package/src/react-native/components/snap-toggle-group.tsx +128 -0
  58. package/src/react-native/index.tsx +267 -0
  59. package/src/react-native/theme.tsx +73 -0
  60. package/src/react-native/use-snap-palette.ts +64 -0
@@ -0,0 +1,92 @@
1
+ declare const __DEV__: boolean;
2
+
3
+ import type { ComponentRenderProps } from "@json-render/react-native";
4
+ import { Pressable, StyleSheet, Text, View } from "react-native";
5
+ import { useSnapPalette } from "../use-snap-palette";
6
+ import { useSnapTheme } from "../theme";
7
+ import { ICON_MAP } from "./snap-icon";
8
+
9
+ const VARIANT_MAP: Record<string, "default" | "secondary" | "outline" | "ghost"> = {
10
+ default: "default",
11
+ secondary: "secondary",
12
+ outline: "outline",
13
+ ghost: "ghost",
14
+ };
15
+
16
+ export function SnapActionButton({
17
+ element: { props },
18
+ emit,
19
+ }: ComponentRenderProps<Record<string, unknown>>) {
20
+ const { accentHex } = useSnapPalette();
21
+ const { colors } = useSnapTheme();
22
+ const label = String(props.label ?? "Action");
23
+ const variant = VARIANT_MAP[String(props.variant ?? "default")] ?? "default";
24
+ const iconName = props.icon ? String(props.icon) : undefined;
25
+
26
+ const variantStyle = (() => {
27
+ switch (variant) {
28
+ case "default":
29
+ return { backgroundColor: accentHex };
30
+ case "secondary":
31
+ return { backgroundColor: "transparent", borderWidth: 1.5, borderColor: accentHex };
32
+ case "outline":
33
+ return { backgroundColor: "rgba(255,255,255,0.04)", borderWidth: 1, borderColor: colors.border };
34
+ case "ghost":
35
+ return { backgroundColor: "transparent" };
36
+ }
37
+ })();
38
+
39
+ const textColor = variant === "default" ? "#fff" : variant === "secondary" ? accentHex : colors.text;
40
+ const iconColor = variant === "default" ? "#fff" : variant === "secondary" ? accentHex : colors.text;
41
+
42
+ return (
43
+ <View style={styles.outer}>
44
+ <Pressable
45
+ style={({ pressed }) => [
46
+ styles.btn,
47
+ variant === "default" ? styles.btnDefault : styles.btnOther,
48
+ variantStyle,
49
+ pressed && styles.pressed,
50
+ ]}
51
+ onPress={() => {
52
+ void (async () => {
53
+ try {
54
+ await emit("press");
55
+ } catch (err: unknown) {
56
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
57
+ // eslint-disable-next-line no-console
58
+ console.error("[snap] action failed", err);
59
+ }
60
+ }
61
+ })();
62
+ }}
63
+ >
64
+ {iconName && ICON_MAP[iconName] ? (
65
+ (() => { const I = ICON_MAP[iconName]!; return <I size={16} color={iconColor} />; })()
66
+ ) : null}
67
+ <Text style={{ color: textColor, fontSize: 14, fontWeight: "600" }}>
68
+ {label}
69
+ </Text>
70
+ </Pressable>
71
+ </View>
72
+ );
73
+ }
74
+
75
+ const styles = StyleSheet.create({
76
+ outer: { flex: 1, minWidth: 0 },
77
+ btn: {
78
+ paddingHorizontal: 16,
79
+ borderRadius: 10,
80
+ alignItems: "center",
81
+ justifyContent: "center",
82
+ flexDirection: "row",
83
+ gap: 8,
84
+ },
85
+ btnDefault: {
86
+ paddingVertical: 10,
87
+ },
88
+ btnOther: {
89
+ paddingVertical: 8,
90
+ },
91
+ pressed: { opacity: 0.88 },
92
+ });
@@ -0,0 +1,57 @@
1
+ import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import { StyleSheet, Text, View } from "react-native";
3
+ import { useSnapPalette } from "../use-snap-palette";
4
+ import { ICON_MAP } from "./snap-icon";
5
+
6
+ export function SnapBadge({
7
+ element: { props },
8
+ }: ComponentRenderProps<Record<string, unknown>>) {
9
+ const { accentHex, hex } = useSnapPalette();
10
+ const label = String(props.label ?? "");
11
+ const color = props.color ? String(props.color) : undefined;
12
+ const iconName = props.icon ? String(props.icon) : undefined;
13
+ const isAccent = !color || color === "accent";
14
+ const resolvedColor = isAccent ? accentHex : hex(color);
15
+
16
+ const Icon = iconName ? ICON_MAP[iconName] : undefined;
17
+
18
+ return (
19
+ <View
20
+ style={[
21
+ styles.badge,
22
+ isAccent
23
+ ? { backgroundColor: resolvedColor, borderColor: resolvedColor }
24
+ : { borderColor: resolvedColor },
25
+ ]}
26
+ >
27
+ {Icon && (
28
+ <Icon size={12} color={isAccent ? "#fff" : resolvedColor} />
29
+ )}
30
+ <Text
31
+ style={[
32
+ styles.label,
33
+ { color: isAccent ? "#fff" : resolvedColor },
34
+ ]}
35
+ >
36
+ {label}
37
+ </Text>
38
+ </View>
39
+ );
40
+ }
41
+
42
+ const styles = StyleSheet.create({
43
+ badge: {
44
+ alignSelf: "flex-start",
45
+ flexDirection: "row",
46
+ alignItems: "center",
47
+ gap: 4,
48
+ paddingHorizontal: 8,
49
+ paddingVertical: 2,
50
+ borderRadius: 9999,
51
+ borderWidth: 1,
52
+ },
53
+ label: {
54
+ fontSize: 12,
55
+ fontWeight: "500",
56
+ },
57
+ });
@@ -0,0 +1,102 @@
1
+ import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import { View } from "react-native";
3
+ import { useSnapPalette } from "../use-snap-palette";
4
+ import {
5
+ ArrowRight,
6
+ ArrowLeft,
7
+ ExternalLink,
8
+ ChevronRight,
9
+ Check,
10
+ X,
11
+ AlertTriangle,
12
+ Info,
13
+ Clock,
14
+ Heart,
15
+ MessageCircle,
16
+ Repeat,
17
+ Share,
18
+ User,
19
+ Users,
20
+ Star,
21
+ Trophy,
22
+ Zap,
23
+ Flame,
24
+ Gift,
25
+ ImageIcon,
26
+ Play,
27
+ Pause,
28
+ Wallet,
29
+ Coins,
30
+ Plus,
31
+ Minus,
32
+ RefreshCw,
33
+ Bookmark,
34
+ ThumbsUp,
35
+ ThumbsDown,
36
+ TrendingUp,
37
+ TrendingDown,
38
+ type LucideIcon,
39
+ } from "lucide-react-native";
40
+
41
+ const ICON_MAP: Record<string, LucideIcon> = {
42
+ "arrow-right": ArrowRight,
43
+ "arrow-left": ArrowLeft,
44
+ "external-link": ExternalLink,
45
+ "chevron-right": ChevronRight,
46
+ check: Check,
47
+ x: X,
48
+ "alert-triangle": AlertTriangle,
49
+ info: Info,
50
+ clock: Clock,
51
+ heart: Heart,
52
+ "message-circle": MessageCircle,
53
+ repeat: Repeat,
54
+ share: Share,
55
+ user: User,
56
+ users: Users,
57
+ star: Star,
58
+ trophy: Trophy,
59
+ zap: Zap,
60
+ flame: Flame,
61
+ gift: Gift,
62
+ image: ImageIcon,
63
+ play: Play,
64
+ pause: Pause,
65
+ wallet: Wallet,
66
+ coins: Coins,
67
+ plus: Plus,
68
+ minus: Minus,
69
+ "refresh-cw": RefreshCw,
70
+ bookmark: Bookmark,
71
+ "thumbs-up": ThumbsUp,
72
+ "thumbs-down": ThumbsDown,
73
+ "trending-up": TrendingUp,
74
+ "trending-down": TrendingDown,
75
+ };
76
+
77
+ const SIZE_PX: Record<string, number> = {
78
+ sm: 16,
79
+ md: 20,
80
+ };
81
+
82
+ export function SnapIcon({
83
+ element: { props },
84
+ }: ComponentRenderProps<Record<string, unknown>>) {
85
+ const { accentHex, hex } = useSnapPalette();
86
+ const name = String(props.name ?? "info");
87
+ const size = SIZE_PX[String(props.size ?? "md")] ?? 20;
88
+ const color = props.color ? String(props.color) : undefined;
89
+ const isAccent = !color || color === "accent";
90
+ const resolvedColor = isAccent ? accentHex : hex(color);
91
+
92
+ const Icon = ICON_MAP[name];
93
+ if (!Icon) return null;
94
+
95
+ return (
96
+ <View style={{ alignItems: "center", justifyContent: "center" }}>
97
+ <Icon size={size} color={resolvedColor} />
98
+ </View>
99
+ );
100
+ }
101
+
102
+ export { ICON_MAP };
@@ -0,0 +1,38 @@
1
+ import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import { Image } from "expo-image";
3
+ import { StyleSheet, View } from "react-native";
4
+
5
+ function aspectToRatio(aspect: string): number {
6
+ const [w, h] = aspect.split(":").map(Number);
7
+ if (!w || !h) return 1;
8
+ return w / h;
9
+ }
10
+
11
+ export function SnapImage({
12
+ element: { props },
13
+ }: ComponentRenderProps<Record<string, unknown>>) {
14
+ const url = String(props.url ?? "");
15
+ const alt = String(props.alt ?? "");
16
+ const ratio = aspectToRatio(String(props.aspect ?? "1:1"));
17
+
18
+ return (
19
+ <View style={[styles.frame, { aspectRatio: ratio }]}>
20
+ <Image
21
+ source={{ uri: url }}
22
+ style={StyleSheet.absoluteFill}
23
+ contentFit="cover"
24
+ accessibilityLabel={alt || undefined}
25
+ />
26
+ </View>
27
+ );
28
+ }
29
+
30
+ const styles = StyleSheet.create({
31
+ frame: {
32
+ flex: 1,
33
+ width: "100%",
34
+ borderRadius: 8,
35
+ overflow: "hidden",
36
+ backgroundColor: "#f3f4f6",
37
+ },
38
+ });
@@ -0,0 +1,57 @@
1
+ import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import { useStateStore } from "@json-render/react-native";
3
+ import { StyleSheet, Text, TextInput, View } from "react-native";
4
+ import { useSnapTheme } from "../theme";
5
+
6
+ export function SnapInput({
7
+ element: { props },
8
+ }: ComponentRenderProps<Record<string, unknown>>) {
9
+ const { get, set } = useStateStore();
10
+ const { colors } = useSnapTheme();
11
+ const name = String(props.name ?? "input");
12
+ const path = `/inputs/${name}`;
13
+ const label = props.label ? String(props.label) : undefined;
14
+ const placeholder = props.placeholder ? String(props.placeholder) : undefined;
15
+ const type = String(props.type ?? "text");
16
+ const maxLength =
17
+ typeof props.maxLength === "number" ? props.maxLength : undefined;
18
+ const defaultValue = props.defaultValue != null ? String(props.defaultValue) : "";
19
+ const raw = get(path);
20
+ const value = raw !== undefined && raw !== null ? String(raw) : defaultValue;
21
+
22
+ return (
23
+ <View style={styles.wrap}>
24
+ {label ? <Text style={[styles.label, { color: colors.text }]}>{label}</Text> : null}
25
+ <TextInput
26
+ style={[
27
+ styles.input,
28
+ {
29
+ borderColor: colors.border,
30
+ backgroundColor: colors.inputBg,
31
+ color: colors.text,
32
+ },
33
+ ]}
34
+ value={value}
35
+ onChangeText={(text) => set(path, type === "number" ? Number(text) || 0 : text)}
36
+ placeholder={placeholder}
37
+ placeholderTextColor={colors.textSecondary}
38
+ maxLength={maxLength}
39
+ autoCapitalize="none"
40
+ autoCorrect={false}
41
+ keyboardType={type === "number" ? "numeric" : "default"}
42
+ />
43
+ </View>
44
+ );
45
+ }
46
+
47
+ const styles = StyleSheet.create({
48
+ wrap: { width: "100%", gap: 4 },
49
+ label: { fontSize: 13, fontWeight: "500" },
50
+ input: {
51
+ borderWidth: 1,
52
+ borderRadius: 8,
53
+ paddingHorizontal: 12,
54
+ paddingVertical: 10,
55
+ fontSize: 14,
56
+ },
57
+ });
@@ -0,0 +1,43 @@
1
+ import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import { Children, Fragment, type ReactNode } from "react";
3
+ import { StyleSheet, View } from "react-native";
4
+ import { useSnapTheme } from "../theme";
5
+
6
+ const GAP_MAP: Record<string, number> = { none: 0, sm: 4, md: 8, lg: 12 };
7
+
8
+ export function SnapItemGroup({
9
+ element: { props },
10
+ children,
11
+ }: ComponentRenderProps<Record<string, unknown>> & { children?: ReactNode }) {
12
+ const { colors } = useSnapTheme();
13
+ const border = Boolean(props.border);
14
+ const separator = Boolean(props.separator);
15
+ const gap = GAP_MAP[String(props.gap ?? "sm")] ?? 4;
16
+ const items = Children.toArray(children);
17
+
18
+ return (
19
+ <View
20
+ style={[
21
+ styles.group,
22
+ border && { borderWidth: 1, borderColor: colors.border, borderRadius: 12 },
23
+ { gap },
24
+ ]}
25
+ >
26
+ {items.map((child, i) => (
27
+ <Fragment key={i}>
28
+ {separator && i > 0 && (
29
+ <View style={{ height: 1, backgroundColor: colors.border + "80" }} />
30
+ )}
31
+ {child}
32
+ </Fragment>
33
+ ))}
34
+ </View>
35
+ );
36
+ }
37
+
38
+ const styles = StyleSheet.create({
39
+ group: {
40
+ width: "100%",
41
+ overflow: "hidden",
42
+ },
43
+ });
@@ -0,0 +1,70 @@
1
+ import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import type { ReactNode } from "react";
3
+ import { StyleSheet, Text, View } from "react-native";
4
+ import { useSnapTheme } from "../theme";
5
+
6
+ export function SnapItem({
7
+ element: { props },
8
+ children,
9
+ }: ComponentRenderProps<Record<string, unknown>> & { children?: ReactNode }) {
10
+ const { colors } = useSnapTheme();
11
+ const title = String(props.title ?? "");
12
+ const description = props.description
13
+ ? String(props.description)
14
+ : undefined;
15
+ const variant = String(props.variant ?? "default");
16
+
17
+ const containerVariant =
18
+ variant === "outline"
19
+ ? { borderWidth: 1, borderColor: colors.border + "80", borderRadius: 8, padding: 10 }
20
+ : variant === "muted"
21
+ ? { backgroundColor: "rgba(255,255,255,0.04)", borderRadius: 8, padding: 10 }
22
+ : { paddingVertical: 8, paddingHorizontal: 10 };
23
+
24
+ return (
25
+ <View style={[styles.container, containerVariant]}>
26
+ <View style={styles.content}>
27
+ <Text style={[styles.title, { color: colors.text }]}>{title}</Text>
28
+ {description ? (
29
+ <Text style={[styles.description, { color: colors.textSecondary }]}>
30
+ {description}
31
+ </Text>
32
+ ) : null}
33
+ </View>
34
+ {children ? (
35
+ <View style={styles.actions}>
36
+ <View style={{ flex: 0 }}>{children}</View>
37
+ </View>
38
+ ) : null}
39
+ </View>
40
+ );
41
+ }
42
+
43
+ const styles = StyleSheet.create({
44
+ container: {
45
+ flex: 1,
46
+ flexDirection: "row",
47
+ alignItems: "center",
48
+ },
49
+ content: {
50
+ flex: 1,
51
+ },
52
+ title: {
53
+ fontSize: 15,
54
+ fontWeight: "500",
55
+ },
56
+ description: {
57
+ fontSize: 13,
58
+ marginTop: 1,
59
+ },
60
+ actions: {
61
+ marginLeft: "auto",
62
+ paddingLeft: 12,
63
+ flexDirection: "row",
64
+ alignItems: "center",
65
+ flexShrink: 0,
66
+ flexGrow: 0,
67
+ flexBasis: "auto",
68
+ gap: 4,
69
+ },
70
+ });
@@ -0,0 +1,40 @@
1
+ import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import { StyleSheet, Text, View } from "react-native";
3
+ import { useSnapPalette } from "../use-snap-palette";
4
+ import { useSnapTheme } from "../theme";
5
+
6
+ export function SnapProgress({
7
+ element: { props },
8
+ }: ComponentRenderProps<Record<string, unknown>>) {
9
+ const { accentHex } = useSnapPalette();
10
+ const { colors } = useSnapTheme();
11
+ const value = Number(props.value ?? 0);
12
+ const max = Math.max(1, Number(props.max ?? 100));
13
+ const percent = Math.min(100, Math.max(0, (value / max) * 100));
14
+ const label = props.label != null ? String(props.label) : null;
15
+
16
+ return (
17
+ <View style={styles.wrap}>
18
+ {label ? (
19
+ <Text style={[styles.label, { color: colors.textSecondary }]}>{label}</Text>
20
+ ) : null}
21
+ <View style={[styles.track, { backgroundColor: colors.muted }]}>
22
+ <View style={[styles.fill, { width: `${percent}%`, backgroundColor: accentHex }]} />
23
+ </View>
24
+ </View>
25
+ );
26
+ }
27
+
28
+ const styles = StyleSheet.create({
29
+ wrap: { flex: 1, width: "100%", gap: 4 },
30
+ label: { fontSize: 13 },
31
+ track: {
32
+ height: 10,
33
+ borderRadius: 9999,
34
+ overflow: "hidden",
35
+ },
36
+ fill: {
37
+ height: "100%",
38
+ borderRadius: 9999,
39
+ },
40
+ });
@@ -0,0 +1,32 @@
1
+ import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { useSnapTheme } from "../theme";
4
+
5
+ export function SnapSeparator({
6
+ element: { props },
7
+ }: ComponentRenderProps<Record<string, unknown>>) {
8
+ const { colors } = useSnapTheme();
9
+ const orientation = String(props.orientation ?? "horizontal");
10
+ const isVertical = orientation === "vertical";
11
+
12
+ return (
13
+ <View
14
+ style={[
15
+ isVertical ? styles.vertical : styles.horizontal,
16
+ { backgroundColor: colors.border + "80" },
17
+ ]}
18
+ />
19
+ );
20
+ }
21
+
22
+ const styles = StyleSheet.create({
23
+ horizontal: {
24
+ width: "100%",
25
+ height: 1,
26
+ },
27
+ vertical: {
28
+ height: "100%",
29
+ width: 1,
30
+ alignSelf: "stretch",
31
+ },
32
+ });
@@ -0,0 +1,82 @@
1
+ import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import { useStateStore } from "@json-render/react-native";
3
+ import Slider from "@react-native-community/slider";
4
+ import { StyleSheet, Text, View } from "react-native";
5
+ import { useSnapPalette } from "../use-snap-palette";
6
+ import { useSnapTheme } from "../theme";
7
+
8
+ export function SnapSlider({
9
+ element: { props },
10
+ }: ComponentRenderProps<Record<string, unknown>>) {
11
+ const { get, set } = useStateStore();
12
+ const { accentHex } = useSnapPalette();
13
+ const { colors } = useSnapTheme();
14
+ const name = String(props.name ?? "slider");
15
+ const path = `/inputs/${name}`;
16
+ const min = Number(props.min ?? 0);
17
+ const max = Number(props.max ?? 100);
18
+ const step = props.step != null ? Number(props.step) : 1;
19
+ const fallback =
20
+ props.defaultValue != null ? Number(props.defaultValue) : (min + max) / 2;
21
+ const raw = get(path);
22
+ const value =
23
+ raw === undefined || raw === null ? fallback : Number(raw);
24
+ const clamped = Number.isFinite(value)
25
+ ? Math.min(max, Math.max(min, value))
26
+ : fallback;
27
+
28
+ const label = props.label != null ? String(props.label) : null;
29
+ const minLabel = props.minLabel != null ? String(props.minLabel) : null;
30
+ const maxLabel = props.maxLabel != null ? String(props.maxLabel) : null;
31
+
32
+ return (
33
+ <View style={styles.wrap}>
34
+ {label ? (
35
+ <View style={styles.labelRow}>
36
+ <Text style={[styles.label, { color: colors.text }]}>{label}</Text>
37
+ <Text style={[styles.valueText, { color: colors.textSecondary }]}>
38
+ {String(Math.round(clamped))}
39
+ </Text>
40
+ </View>
41
+ ) : null}
42
+ <Slider
43
+ style={styles.slider}
44
+ minimumValue={min}
45
+ maximumValue={max}
46
+ step={step > 0 ? step : 1}
47
+ value={clamped}
48
+ onValueChange={(v) => set(path, v)}
49
+ minimumTrackTintColor={accentHex}
50
+ maximumTrackTintColor={colors.muted}
51
+ thumbTintColor={accentHex}
52
+ />
53
+ {minLabel != null || maxLabel != null ? (
54
+ <View style={styles.minMaxRow}>
55
+ <Text style={[styles.minMax, { color: colors.textSecondary }]}>
56
+ {minLabel ?? String(min)}
57
+ </Text>
58
+ <Text style={[styles.minMax, { color: colors.textSecondary }]}>
59
+ {maxLabel ?? String(max)}
60
+ </Text>
61
+ </View>
62
+ ) : null}
63
+ </View>
64
+ );
65
+ }
66
+
67
+ const styles = StyleSheet.create({
68
+ wrap: { width: "100%", gap: 6 },
69
+ labelRow: {
70
+ flexDirection: "row",
71
+ justifyContent: "space-between",
72
+ alignItems: "center",
73
+ },
74
+ label: { fontSize: 13, fontWeight: "500", flex: 1 },
75
+ valueText: { fontSize: 13 },
76
+ slider: { width: "100%", height: 40 },
77
+ minMaxRow: {
78
+ flexDirection: "row",
79
+ justifyContent: "space-between",
80
+ },
81
+ minMax: { fontSize: 12 },
82
+ });
@@ -0,0 +1,66 @@
1
+ import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import type { ReactNode } from "react";
3
+ import { StyleSheet, View } from "react-native";
4
+
5
+ const VGAP: Record<string, number> = {
6
+ none: 0,
7
+ sm: 8,
8
+ md: 16,
9
+ lg: 24,
10
+ };
11
+
12
+ const HGAP: Record<string, number> = {
13
+ none: 0,
14
+ sm: 4,
15
+ md: 8,
16
+ lg: 12,
17
+ };
18
+
19
+ const JUSTIFY: Record<string, "flex-start" | "center" | "flex-end" | "space-between" | "space-around"> = {
20
+ start: "flex-start",
21
+ center: "center",
22
+ end: "flex-end",
23
+ between: "space-between",
24
+ around: "space-around",
25
+ };
26
+
27
+ export function SnapStack({
28
+ element: { props },
29
+ children,
30
+ }: ComponentRenderProps<Record<string, unknown>> & { children?: ReactNode }) {
31
+ const direction = String(props.direction ?? "vertical");
32
+ const rawGap = props.gap;
33
+ const isHorizontal = direction === "horizontal";
34
+ const gapMap = isHorizontal ? HGAP : VGAP;
35
+ const gap =
36
+ typeof rawGap === "number"
37
+ ? rawGap
38
+ : typeof rawGap === "string" && rawGap in gapMap
39
+ ? gapMap[rawGap]!
40
+ : isHorizontal ? HGAP.md! : VGAP.md!;
41
+ const justify = props.justify ? JUSTIFY[String(props.justify)] : undefined;
42
+
43
+ return (
44
+ <View
45
+ style={[
46
+ styles.stack,
47
+ isHorizontal ? styles.horizontal : undefined,
48
+ { gap },
49
+ justify ? { justifyContent: justify } : undefined,
50
+ ]}
51
+ >
52
+ {children}
53
+ </View>
54
+ );
55
+ }
56
+
57
+ const styles = StyleSheet.create({
58
+ stack: {
59
+ width: "100%",
60
+ },
61
+ horizontal: {
62
+ flexDirection: "row",
63
+ alignItems: "center",
64
+ flexWrap: "wrap",
65
+ },
66
+ });