@farcaster/snap 2.1.1 → 2.1.2

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 (41) hide show
  1. package/dist/constants.d.ts +2 -1
  2. package/dist/constants.js +2 -1
  3. package/dist/react/components/cell-grid.js +13 -14
  4. package/dist/react/components/image.js +5 -1
  5. package/dist/react/components/stack.js +53 -3
  6. package/dist/react/components/text.js +7 -1
  7. package/dist/react/stack-direction-context.d.ts +7 -0
  8. package/dist/react/stack-direction-context.js +10 -0
  9. package/dist/react-native/components/snap-cell-grid.js +5 -7
  10. package/dist/react-native/components/snap-image.js +15 -2
  11. package/dist/react-native/components/snap-item.js +12 -2
  12. package/dist/react-native/components/snap-progress.js +8 -2
  13. package/dist/react-native/components/snap-stack.d.ts +1 -1
  14. package/dist/react-native/components/snap-stack.js +85 -10
  15. package/dist/react-native/components/snap-text.js +7 -2
  16. package/dist/react-native/stack-direction-context.d.ts +7 -0
  17. package/dist/react-native/stack-direction-context.js +9 -0
  18. package/dist/stack-horizontal-utils.d.ts +4 -0
  19. package/dist/stack-horizontal-utils.js +29 -0
  20. package/dist/ui/catalog.d.ts +1 -0
  21. package/dist/ui/catalog.js +1 -1
  22. package/dist/ui/stack.d.ts +1 -0
  23. package/dist/ui/stack.js +8 -0
  24. package/llms.txt +2 -1
  25. package/package.json +1 -1
  26. package/src/constants.ts +2 -1
  27. package/src/react/components/cell-grid.tsx +17 -24
  28. package/src/react/components/image.tsx +8 -1
  29. package/src/react/components/stack.tsx +84 -11
  30. package/src/react/components/text.tsx +8 -1
  31. package/src/react/stack-direction-context.tsx +27 -0
  32. package/src/react-native/components/snap-cell-grid.tsx +5 -11
  33. package/src/react-native/components/snap-image.tsx +17 -2
  34. package/src/react-native/components/snap-item.tsx +14 -2
  35. package/src/react-native/components/snap-progress.tsx +8 -2
  36. package/src/react-native/components/snap-stack.tsx +116 -14
  37. package/src/react-native/components/snap-text.tsx +7 -2
  38. package/src/react-native/stack-direction-context.tsx +25 -0
  39. package/src/stack-horizontal-utils.ts +27 -0
  40. package/src/ui/catalog.ts +1 -1
  41. package/src/ui/stack.ts +8 -0
package/src/constants.ts CHANGED
@@ -20,7 +20,8 @@ export const GRID_GAP_VALUES = ["none", "sm", "md", "lg"] as const;
20
20
  export const MAX_ELEMENTS = 64;
21
21
  export const MAX_ROOT_CHILDREN = 7;
22
22
  export const MAX_CHILDREN = 6;
23
- export const MAX_DEPTH = 4;
23
+ /** Enough depth for side-by-side columns that contain labeled horizontal icon rows (pair → column → row → icon). */
24
+ export const MAX_DEPTH = 5;
24
25
 
25
26
  // ─── Bar chart ─────────────────────────────────────────
26
27
  export const BAR_CHART_MAX_BARS = 6;
@@ -64,12 +64,16 @@ export function SnapCellGrid({
64
64
  });
65
65
  }
66
66
 
67
+ /** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
68
+ const emptyCellBg =
69
+ colors.mode === "dark" ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.05)";
70
+
67
71
  const cellEls: ReactNode[] = [];
68
72
  for (let r = 0; r < rows; r++) {
69
73
  for (let c = 0; c < cols; c++) {
70
74
  const cell = cellMap.get(`${r},${c}`);
71
75
  const selected = interactive && isSelected(r, c);
72
- const bg = cell?.color ? colors.colorHex(cell.color) : "transparent";
76
+ const bg = cell?.color ? colors.colorHex(cell.color) : emptyCellBg;
73
77
 
74
78
  cellEls.push(
75
79
  <div
@@ -105,30 +109,19 @@ export function SnapCellGrid({
105
109
  }
106
110
  }
107
111
 
108
- const selectionLabel = isSelectable && selectedSet.size > 0
109
- ? `inputs.${name}: ${[...selectedSet].join(isMultiple ? " | " : "")}`
110
- : null;
111
-
112
112
  return (
113
- <div>
114
- <div
115
- style={{
116
- display: "grid",
117
- width: "100%",
118
- gridTemplateColumns: `repeat(${cols}, 1fr)`,
119
- gap: gapPx,
120
- padding: 4,
121
- borderRadius: 8,
122
- backgroundColor: colors.muted,
123
- }}
124
- >
125
- {cellEls}
126
- </div>
127
- {selectionLabel && (
128
- <div className="mt-1.5 truncate text-xs font-mono" style={{ color: colors.textMuted }}>
129
- {selectionLabel}
130
- </div>
131
- )}
113
+ <div
114
+ style={{
115
+ display: "grid",
116
+ width: "100%",
117
+ gridTemplateColumns: `repeat(${cols}, 1fr)`,
118
+ gap: gapPx,
119
+ padding: 4,
120
+ borderRadius: 8,
121
+ backgroundColor: colors.muted,
122
+ }}
123
+ >
124
+ {cellEls}
132
125
  </div>
133
126
  );
134
127
  }
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import { AspectRatio } from "@neynar/ui/aspect-ratio";
4
+ import { cn } from "@neynar/ui/utils";
5
+ import { useSnapStackDirection } from "../stack-direction-context";
4
6
 
5
7
  function aspectToRatio(aspect: string): number {
6
8
  const [w, h] = aspect.split(":").map(Number);
@@ -16,11 +18,16 @@ export function SnapImage({
16
18
  const url = String(props.url ?? "");
17
19
  const alt = String(props.alt ?? "");
18
20
  const ratio = aspectToRatio(String(props.aspect ?? "1:1"));
21
+ const stackDir = useSnapStackDirection();
22
+ const inHorizontalStack = stackDir === "horizontal";
19
23
 
20
24
  return (
21
25
  <AspectRatio
22
26
  ratio={ratio}
23
- className="relative w-full flex-1 overflow-hidden rounded-lg"
27
+ className={cn(
28
+ "relative overflow-hidden rounded-lg",
29
+ inHorizontalStack ? "min-w-0 flex-1 basis-0" : "w-full",
30
+ )}
24
31
  >
25
32
  {/* eslint-disable-next-line @next/next/no-img-element */}
26
33
  <img
@@ -2,6 +2,14 @@
2
2
 
3
3
  import type { ReactNode } from "react";
4
4
  import { cn } from "@neynar/ui/utils";
5
+ import {
6
+ countRenderableChildren,
7
+ horizontalChildrenAreAllButtons,
8
+ } from "../../stack-horizontal-utils.js";
9
+ import {
10
+ SnapStackDirectionProvider,
11
+ useSnapStackDirection,
12
+ } from "../stack-direction-context";
5
13
 
6
14
  const VGAP: Record<string, string> = {
7
15
  none: "gap-0",
@@ -17,7 +25,7 @@ const HGAP: Record<string, string> = {
17
25
  lg: "gap-3",
18
26
  };
19
27
 
20
- const JUSTIFY: Record<string, string> = {
28
+ const JUSTIFY_FLEX: Record<string, string> = {
21
29
  start: "justify-start",
22
30
  center: "justify-center",
23
31
  end: "justify-end",
@@ -25,6 +33,16 @@ const JUSTIFY: Record<string, string> = {
25
33
  around: "justify-around",
26
34
  };
27
35
 
36
+ /** Equal columns for explicit `columns` prop and for all-button horizontal rows. */
37
+ const COLUMN_GRID_CLASS: Record<number, string> = {
38
+ 1: "grid grid-cols-1 auto-rows-auto items-stretch [&>*]:min-w-0",
39
+ 2: "grid grid-cols-2 auto-rows-auto items-stretch [&>*]:min-w-0",
40
+ 3: "grid grid-cols-3 auto-rows-auto items-stretch [&>*]:min-w-0",
41
+ 4: "grid grid-cols-4 auto-rows-auto items-stretch [&>*]:min-w-0",
42
+ 5: "grid grid-cols-5 auto-rows-auto items-stretch [&>*]:min-w-0",
43
+ 6: "grid grid-cols-6 auto-rows-auto items-stretch [&>*]:min-w-0",
44
+ };
45
+
28
46
  export function SnapStack({
29
47
  element: { props },
30
48
  children,
@@ -32,24 +50,79 @@ export function SnapStack({
32
50
  element: { props: Record<string, unknown> };
33
51
  children?: ReactNode;
34
52
  }) {
53
+ const parentDirection = useSnapStackDirection();
35
54
  const direction = String(props.direction ?? "vertical");
36
55
  const gapKey = String(props.gap ?? "md");
37
56
  const isHorizontal = direction === "horizontal";
38
57
  const gap = isHorizontal
39
58
  ? (HGAP[gapKey] ?? "gap-2")
40
59
  : (VGAP[gapKey] ?? "gap-4");
41
- const justify = props.justify ? JUSTIFY[String(props.justify)] : undefined;
60
+ const justifyKey = props.justify ? String(props.justify) : undefined;
61
+ const justifyFlex = justifyKey ? JUSTIFY_FLEX[justifyKey] : undefined;
62
+ const buttonRowGrid =
63
+ isHorizontal && horizontalChildrenAreAllButtons(children);
64
+ const buttonRowCount = buttonRowGrid
65
+ ? countRenderableChildren(children)
66
+ : 0;
67
+
68
+ const columnsRaw = props.columns;
69
+ const columns =
70
+ typeof columnsRaw === "number" &&
71
+ columnsRaw >= 2 &&
72
+ columnsRaw <= 6 &&
73
+ Number.isInteger(columnsRaw)
74
+ ? columnsRaw
75
+ : undefined;
76
+ const explicitColumnGrid =
77
+ isHorizontal && columns !== undefined && !buttonRowGrid;
78
+ const columnGridClass =
79
+ explicitColumnGrid && columns !== undefined
80
+ ? COLUMN_GRID_CLASS[columns]
81
+ : undefined;
82
+
83
+ /**
84
+ * Row peers under a horizontal stack must shrink and share width (`flex-1` + `min-w-0`).
85
+ * Avoid `w-full` here: it resolves to 100% of the flex/grid container and fights peer sizing,
86
+ * so each column stacks on its own wrapped row instead of sitting side-by-side.
87
+ */
88
+ const isRowChild = parentDirection === "horizontal";
89
+ const rootWidthClass = isRowChild
90
+ ? "min-w-0 flex-1 basis-0 max-w-full"
91
+ : "w-full min-w-0";
92
+
93
+ const justifyBlockGrid =
94
+ justifyFlex &&
95
+ (!isHorizontal || (!buttonRowGrid && !explicitColumnGrid));
96
+
97
+ /** Single flex row (nowrap): peers stay side-by-side and shrink via min-w-0 / flex-1 on nested stacks. */
98
+ const horizontalFlexClasses =
99
+ "flex min-w-0 flex-row flex-nowrap items-stretch [&>*]:min-w-0";
42
100
 
43
101
  return (
44
- <div
45
- className={cn(
46
- "flex w-full",
47
- isHorizontal ? "flex-row items-center flex-wrap" : "flex-col",
48
- gap,
49
- justify,
50
- )}
102
+ <SnapStackDirectionProvider
103
+ direction={isHorizontal ? "horizontal" : "vertical"}
51
104
  >
52
- {children}
53
- </div>
105
+ <div
106
+ className={cn(
107
+ rootWidthClass,
108
+ isHorizontal
109
+ ? buttonRowGrid &&
110
+ buttonRowCount >= 1 &&
111
+ buttonRowCount <= 6 &&
112
+ COLUMN_GRID_CLASS[buttonRowCount]
113
+ ? cn(
114
+ COLUMN_GRID_CLASS[buttonRowCount]!,
115
+ gap,
116
+ "[&>*]:w-full",
117
+ )
118
+ : explicitColumnGrid && columnGridClass
119
+ ? cn(columnGridClass, gap)
120
+ : cn(horizontalFlexClasses, gap, justifyBlockGrid ? justifyFlex : undefined)
121
+ : cn("flex min-w-0 w-full flex-col", gap, justifyFlex),
122
+ )}
123
+ >
124
+ {children}
125
+ </div>
126
+ </SnapStackDirectionProvider>
54
127
  );
55
128
  }
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { Text } from "@neynar/ui/typography";
4
+ import { cn } from "@neynar/ui/utils";
4
5
  import { useSnapColors } from "../hooks/use-snap-colors";
6
+ import { useSnapStackDirection } from "../stack-direction-context";
5
7
 
6
8
  const SIZE_MAP = {
7
9
  md: { textSize: "base" as const },
@@ -19,13 +21,18 @@ export function SnapText({
19
21
  const align = (props.align as "left" | "center" | "right") ?? undefined;
20
22
  const config = SIZE_MAP[size] ?? SIZE_MAP.md;
21
23
  const colors = useSnapColors();
24
+ const stackDir = useSnapStackDirection();
25
+ const inHorizontalStack = stackDir === "horizontal";
22
26
 
23
27
  return (
24
28
  <Text
25
29
  size={config.textSize}
26
30
  weight={weight}
27
31
  align={align}
28
- className="flex-1"
32
+ className={cn(
33
+ /** Row peers hug content like RN `wrapRow`; avoid `flex-1` stretching peers across the row. */
34
+ inHorizontalStack ? "min-w-0 shrink" : "flex-1",
35
+ )}
29
36
  style={{ color: colors.text }}
30
37
  >
31
38
  {content}
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, type ReactNode } from "react";
4
+
5
+ export type SnapStackDirection = "vertical" | "horizontal";
6
+
7
+ const SnapStackDirectionContext = createContext<SnapStackDirection | undefined>(
8
+ undefined,
9
+ );
10
+
11
+ export function SnapStackDirectionProvider({
12
+ direction,
13
+ children,
14
+ }: {
15
+ direction: SnapStackDirection;
16
+ children: ReactNode;
17
+ }) {
18
+ return (
19
+ <SnapStackDirectionContext.Provider value={direction}>
20
+ {children}
21
+ </SnapStackDirectionContext.Provider>
22
+ );
23
+ }
24
+
25
+ export function useSnapStackDirection(): SnapStackDirection | undefined {
26
+ return useContext(SnapStackDirectionContext);
27
+ }
@@ -66,13 +66,17 @@ export function SnapCellGrid({
66
66
  const ringOuter = appearance === "dark" ? "#fff" : "#000";
67
67
  const ringInner = appearance === "dark" ? "#000" : "#fff";
68
68
 
69
+ /** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
70
+ const emptyCellBg =
71
+ appearance === "dark" ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.05)";
72
+
69
73
  const rowEls = [];
70
74
  for (let r = 0; r < rows; r++) {
71
75
  const rowCells = [];
72
76
  for (let c = 0; c < cols; c++) {
73
77
  const cell = cellMap.get(`${r},${c}`);
74
78
  const selected = interactive && isSelected(r, c);
75
- const bg = cell?.color ? hex(cell.color) : "transparent";
79
+ const bg = cell?.color ? hex(cell.color) : emptyCellBg;
76
80
 
77
81
  const cellContent = cell?.content ? (
78
82
  <Text style={[styles.cellText, { color: colors.textPrimary }]}>
@@ -121,18 +125,9 @@ export function SnapCellGrid({
121
125
  );
122
126
  }
123
127
 
124
- const selectionLabel = isSelectable && selectedSet.size > 0
125
- ? `inputs.${name}: ${[...selectedSet].join(isMultiple ? " | " : "")}`
126
- : null;
127
-
128
128
  return (
129
129
  <View style={[styles.wrap, { gap: gapPx, backgroundColor: colors.muted, padding: 4, borderRadius: 8 }]}>
130
130
  {rowEls}
131
- {selectionLabel ? (
132
- <Text style={[styles.selectionText, { color: colors.textSecondary }]}>
133
- {selectionLabel}
134
- </Text>
135
- ) : null}
136
131
  </View>
137
132
  );
138
133
  }
@@ -153,5 +148,4 @@ const styles = StyleSheet.create({
153
148
  justifyContent: "center",
154
149
  },
155
150
  cellText: { fontSize: 12, lineHeight: 16, fontWeight: "600" },
156
- selectionText: { fontSize: 11, fontFamily: "monospace", marginTop: 6 },
157
151
  });
@@ -1,6 +1,7 @@
1
1
  import type { ComponentRenderProps } from "@json-render/react-native";
2
2
  import { Image } from "expo-image";
3
3
  import { StyleSheet, View } from "react-native";
4
+ import { useSnapStackDirection } from "../stack-direction-context";
4
5
 
5
6
  function aspectToRatio(aspect: string): number {
6
7
  const [w, h] = aspect.split(":").map(Number);
@@ -14,9 +15,17 @@ export function SnapImage({
14
15
  const url = String(props.url ?? "");
15
16
  const alt = String(props.alt ?? "");
16
17
  const ratio = aspectToRatio(String(props.aspect ?? "1:1"));
18
+ const stackDir = useSnapStackDirection();
19
+ const inHorizontalStack = stackDir === "horizontal";
17
20
 
18
21
  return (
19
- <View style={[styles.frame, { aspectRatio: ratio }]}>
22
+ <View
23
+ style={[
24
+ styles.frame,
25
+ inHorizontalStack ? styles.frameInHorizontalRow : styles.frameFullWidth,
26
+ { aspectRatio: ratio },
27
+ ]}
28
+ >
20
29
  <Image
21
30
  source={{ uri: url }}
22
31
  style={StyleSheet.absoluteFill}
@@ -29,9 +38,15 @@ export function SnapImage({
29
38
 
30
39
  const styles = StyleSheet.create({
31
40
  frame: {
32
- width: "100%",
33
41
  borderRadius: 8,
34
42
  overflow: "hidden",
35
43
  backgroundColor: "#f3f4f6",
36
44
  },
45
+ frameFullWidth: {
46
+ width: "100%",
47
+ },
48
+ frameInHorizontalRow: {
49
+ flex: 1,
50
+ minWidth: 0,
51
+ },
37
52
  });
@@ -1,6 +1,7 @@
1
1
  import type { ComponentRenderProps } from "@json-render/react-native";
2
2
  import type { ReactNode } from "react";
3
3
  import { StyleSheet, Text, View } from "react-native";
4
+ import { useSnapStackDirection } from "../stack-direction-context";
4
5
  import { useSnapTheme } from "../theme";
5
6
 
6
7
  export function SnapItem({
@@ -12,12 +13,19 @@ export function SnapItem({
12
13
  const description = props.description
13
14
  ? String(props.description)
14
15
  : undefined;
15
- const variant = String(props.variant ?? "default");
16
+ /** Match web `Item className="flex-1"`: row peers must share width or title/description collapse. */
17
+ const rowPeer = useSnapStackDirection() === "horizontal";
16
18
 
17
19
  const containerVariant = { paddingVertical: 6, paddingHorizontal: 10 };
18
20
 
19
21
  return (
20
- <View style={[styles.container, containerVariant]}>
22
+ <View
23
+ style={[
24
+ styles.container,
25
+ containerVariant,
26
+ rowPeer && styles.rowPeer,
27
+ ]}
28
+ >
21
29
  <View style={styles.content}>
22
30
  {title ? <Text style={[styles.title, { color: colors.text }]}>{title}</Text> : null}
23
31
  {description ? (
@@ -40,6 +48,10 @@ const styles = StyleSheet.create({
40
48
  flexDirection: "row",
41
49
  alignItems: "center",
42
50
  },
51
+ rowPeer: {
52
+ flex: 1,
53
+ minWidth: 0,
54
+ },
43
55
  content: {
44
56
  flex: 1,
45
57
  },
@@ -1,5 +1,6 @@
1
1
  import type { ComponentRenderProps } from "@json-render/react-native";
2
2
  import { StyleSheet, Text, View } from "react-native";
3
+ import { useSnapStackDirection } from "../stack-direction-context";
3
4
  import { useSnapPalette } from "../use-snap-palette";
4
5
  import { useSnapTheme } from "../theme";
5
6
 
@@ -12,9 +13,10 @@ export function SnapProgress({
12
13
  const max = Math.max(1, Number(props.max ?? 100));
13
14
  const percent = Math.min(100, Math.max(0, (value / max) * 100));
14
15
  const label = props.label != null ? String(props.label) : null;
16
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
15
17
 
16
18
  return (
17
- <View style={styles.wrap}>
19
+ <View style={[styles.wrap, inHorizontalStack ? styles.wrapRowPeer : styles.wrapCol]}>
18
20
  {label ? (
19
21
  <Text style={[styles.label, { color: colors.textSecondary }]}>{label}</Text>
20
22
  ) : null}
@@ -26,7 +28,11 @@ export function SnapProgress({
26
28
  }
27
29
 
28
30
  const styles = StyleSheet.create({
29
- wrap: { width: "100%", gap: 4 },
31
+ wrap: { gap: 4 },
32
+ /** Vertical stacks: span card width (matches web `w-full`). */
33
+ wrapCol: { width: "100%" },
34
+ /** Horizontal row peers: share space; `width: 100%` each overflows the row. */
35
+ wrapRowPeer: { flex: 1, minWidth: 0 },
30
36
  label: { fontSize: 13, lineHeight: 18 },
31
37
  track: {
32
38
  height: 10,
@@ -1,6 +1,14 @@
1
1
  import type { ComponentRenderProps } from "@json-render/react-native";
2
- import type { ReactNode } from "react";
2
+ import { Children, type ReactNode } from "react";
3
3
  import { StyleSheet, View } from "react-native";
4
+ import {
5
+ countRenderableChildren,
6
+ horizontalChildrenAreAllButtons,
7
+ } from "../../stack-horizontal-utils.js";
8
+ import {
9
+ SnapStackDirectionProvider,
10
+ useSnapStackDirection,
11
+ } from "../stack-direction-context";
4
12
 
5
13
  const VGAP: Record<string, number> = {
6
14
  none: 0,
@@ -24,10 +32,23 @@ const JUSTIFY: Record<string, "flex-start" | "center" | "flex-end" | "space-betw
24
32
  around: "space-around",
25
33
  };
26
34
 
35
+ /** Equal-width cells for explicit `columns` and all-button horizontal rows. */
36
+ function wrapEqualColumnCells(children: ReactNode): ReactNode {
37
+ const cells = Children.toArray(children).filter(
38
+ (c) => c != null && c !== false,
39
+ );
40
+ return cells.map((child, i) => (
41
+ <View key={i} style={styles.equalColumnCell}>
42
+ {child}
43
+ </View>
44
+ ));
45
+ }
46
+
27
47
  export function SnapStack({
28
48
  element: { props },
29
49
  children,
30
50
  }: ComponentRenderProps<Record<string, unknown>> & { children?: ReactNode }) {
51
+ const parentDirection = useSnapStackDirection();
31
52
  const direction = String(props.direction ?? "vertical");
32
53
  const rawGap = props.gap;
33
54
  const isHorizontal = direction === "horizontal";
@@ -38,29 +59,110 @@ export function SnapStack({
38
59
  : typeof rawGap === "string" && rawGap in gapMap
39
60
  ? gapMap[rawGap]!
40
61
  : isHorizontal ? HGAP.md! : VGAP.md!;
41
- const justify = props.justify ? JUSTIFY[String(props.justify)] : undefined;
62
+ const buttonRowGrid =
63
+ isHorizontal && horizontalChildrenAreAllButtons(children);
64
+ const buttonRowCount = buttonRowGrid
65
+ ? countRenderableChildren(children)
66
+ : 0;
67
+
68
+ const columnsRaw = props.columns;
69
+ const columns =
70
+ typeof columnsRaw === "number" &&
71
+ columnsRaw >= 2 &&
72
+ columnsRaw <= 6 &&
73
+ Number.isInteger(columnsRaw)
74
+ ? columnsRaw
75
+ : undefined;
76
+ const explicitColumnGrid =
77
+ isHorizontal && columns !== undefined && !buttonRowGrid;
78
+
79
+ const justify =
80
+ props.justify &&
81
+ (!isHorizontal || (!buttonRowGrid && !explicitColumnGrid))
82
+ ? JUSTIFY[String(props.justify)]
83
+ : undefined;
84
+
85
+ const isRowChild = parentDirection === "horizontal";
86
+
87
+ const packedHorizontal =
88
+ isHorizontal &&
89
+ ((buttonRowGrid &&
90
+ buttonRowCount >= 1 &&
91
+ buttonRowCount <= 6) ||
92
+ explicitColumnGrid);
93
+
94
+ let horizontalBody: ReactNode = children;
95
+ if (
96
+ isHorizontal &&
97
+ buttonRowGrid &&
98
+ buttonRowCount >= 1 &&
99
+ buttonRowCount <= 6
100
+ ) {
101
+ horizontalBody = wrapEqualColumnCells(children);
102
+ } else if (isHorizontal && explicitColumnGrid && columns !== undefined) {
103
+ horizontalBody = wrapEqualColumnCells(children);
104
+ }
42
105
 
43
106
  return (
44
- <View
45
- style={[
46
- styles.stack,
47
- isHorizontal ? styles.horizontal : undefined,
48
- { gap },
49
- justify ? { justifyContent: justify } : undefined,
50
- ]}
107
+ <SnapStackDirectionProvider
108
+ direction={isHorizontal ? "horizontal" : "vertical"}
51
109
  >
52
- {children}
53
- </View>
110
+ <View
111
+ style={[
112
+ isRowChild ? styles.stackRowChild : styles.stack,
113
+ isHorizontal
114
+ ? packedHorizontal
115
+ ? styles.horizontalPacked
116
+ : styles.horizontalDefault
117
+ : styles.verticalStack,
118
+ { gap },
119
+ justify ? { justifyContent: justify } : undefined,
120
+ ]}
121
+ >
122
+ {horizontalBody}
123
+ </View>
124
+ </SnapStackDirectionProvider>
54
125
  );
55
126
  }
56
127
 
57
128
  const styles = StyleSheet.create({
58
129
  stack: {
59
130
  width: "100%",
131
+ minWidth: 0,
132
+ },
133
+ verticalStack: {
134
+ width: "100%",
135
+ minWidth: 0,
60
136
  },
61
- horizontal: {
137
+ /** Nested stack inside a horizontal row — share width with siblings (matches web flex peers). */
138
+ stackRowChild: {
139
+ flexGrow: 1,
140
+ flexShrink: 1,
141
+ flexBasis: 0,
142
+ minWidth: 0,
143
+ maxWidth: "100%",
144
+ alignSelf: "stretch",
145
+ },
146
+ /** Default horizontal row: single line, equal-height peers. */
147
+ horizontalDefault: {
62
148
  flexDirection: "row",
63
- alignItems: "center",
64
- flexWrap: "wrap",
149
+ alignItems: "stretch",
150
+ flexWrap: "nowrap",
151
+ width: "100%",
152
+ minWidth: 0,
153
+ },
154
+ /** Single row for packed equal-width cells (button grids & explicit columns). */
155
+ horizontalPacked: {
156
+ flexDirection: "row",
157
+ flexWrap: "nowrap",
158
+ alignItems: "stretch",
159
+ width: "100%",
160
+ minWidth: 0,
161
+ },
162
+ equalColumnCell: {
163
+ flexGrow: 1,
164
+ flexShrink: 1,
165
+ flexBasis: 0,
166
+ minWidth: 0,
65
167
  },
66
168
  });
@@ -1,5 +1,6 @@
1
1
  import type { ComponentRenderProps } from "@json-render/react-native";
2
2
  import { StyleSheet, Text, View } from "react-native";
3
+ import { useSnapStackDirection } from "../stack-direction-context";
3
4
  import { useSnapTheme } from "../theme";
4
5
 
5
6
  const SIZE_STYLES: Record<string, { fontSize: number; lineHeight?: number; fontWeight?: "400" | "500" | "600" | "700" }> = {
@@ -24,9 +25,10 @@ export function SnapText({
24
25
  const sizeStyle = SIZE_STYLES[size] ?? SIZE_STYLES.md;
25
26
  const resolvedWeight = weight ? WEIGHT_MAP[weight] : sizeStyle?.fontWeight;
26
27
  const textAlign = align === "center" ? "center" : align === "right" ? "right" : "left";
28
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
27
29
 
28
30
  return (
29
- <View style={styles.wrap}>
31
+ <View style={inHorizontalStack ? styles.wrapRow : styles.wrapCol}>
30
32
  <Text
31
33
  style={[
32
34
  styles.base,
@@ -46,6 +48,9 @@ export function SnapText({
46
48
  }
47
49
 
48
50
  const styles = StyleSheet.create({
49
- wrap: { width: "100%" },
51
+ /** Full width for vertical stacks (alignment / wrapping). */
52
+ wrapCol: { width: "100%" },
53
+ /** Row peers: hug content; avoid width 100% fighting nowrap horizontal rows. */
54
+ wrapRow: { flexShrink: 1, minWidth: 0 },
50
55
  base: {},
51
56
  });
@@ -0,0 +1,25 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+
3
+ export type SnapStackDirection = "vertical" | "horizontal";
4
+
5
+ const SnapStackDirectionContext = createContext<SnapStackDirection | undefined>(
6
+ undefined,
7
+ );
8
+
9
+ export function SnapStackDirectionProvider({
10
+ direction,
11
+ children,
12
+ }: {
13
+ direction: SnapStackDirection;
14
+ children: ReactNode;
15
+ }) {
16
+ return (
17
+ <SnapStackDirectionContext.Provider value={direction}>
18
+ {children}
19
+ </SnapStackDirectionContext.Provider>
20
+ );
21
+ }
22
+
23
+ export function useSnapStackDirection(): SnapStackDirection | undefined {
24
+ return useContext(SnapStackDirectionContext);
25
+ }