@farcaster/snap 2.4.0 → 2.5.1

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 (59) hide show
  1. package/dist/button-orientation-utils.d.ts +3 -0
  2. package/dist/button-orientation-utils.js +25 -0
  3. package/dist/constants.d.ts +1 -0
  4. package/dist/constants.js +1 -0
  5. package/dist/react/components/action-button.js +5 -5
  6. package/dist/react/components/cell-grid.js +12 -3
  7. package/dist/react/components/item-group.js +2 -1
  8. package/dist/react/components/item-layout-context.d.ts +2 -0
  9. package/dist/react/components/item-layout-context.js +7 -0
  10. package/dist/react/components/item.js +40 -3
  11. package/dist/react/components/stack.js +46 -37
  12. package/dist/react/components/toggle-group.js +6 -4
  13. package/dist/react-native/components/item-layout-context.d.ts +2 -0
  14. package/dist/react-native/components/item-layout-context.js +6 -0
  15. package/dist/react-native/components/snap-action-button.js +15 -2
  16. package/dist/react-native/components/snap-cell-grid.js +36 -5
  17. package/dist/react-native/components/snap-item-group.js +16 -6
  18. package/dist/react-native/components/snap-item.js +56 -13
  19. package/dist/react-native/components/snap-stack.js +32 -33
  20. package/dist/react-native/components/snap-toggle-group.js +5 -4
  21. package/dist/stack-horizontal-utils.d.ts +7 -5
  22. package/dist/stack-horizontal-utils.js +24 -14
  23. package/dist/ui/catalog.d.ts +70 -1
  24. package/dist/ui/catalog.js +4 -4
  25. package/dist/ui/cell-grid.d.ts +14 -0
  26. package/dist/ui/cell-grid.js +7 -7
  27. package/dist/ui/index.d.ts +2 -2
  28. package/dist/ui/index.js +1 -1
  29. package/dist/ui/item.d.ts +112 -1
  30. package/dist/ui/item.js +28 -2
  31. package/dist/ui/stack.d.ts +1 -0
  32. package/dist/ui/stack.js +3 -1
  33. package/dist/validator.js +19 -1
  34. package/llms.txt +3 -1
  35. package/package.json +1 -1
  36. package/src/button-orientation-utils.ts +36 -0
  37. package/src/constants.ts +1 -0
  38. package/src/react/components/action-button.tsx +5 -4
  39. package/src/react/components/cell-grid.tsx +13 -4
  40. package/src/react/components/item-group.tsx +19 -17
  41. package/src/react/components/item-layout-context.tsx +12 -0
  42. package/src/react/components/item.tsx +97 -4
  43. package/src/react/components/stack.tsx +51 -40
  44. package/src/react/components/toggle-group.tsx +6 -4
  45. package/src/react-native/components/item-layout-context.tsx +10 -0
  46. package/src/react-native/components/snap-action-button.tsx +15 -2
  47. package/src/react-native/components/snap-cell-grid.tsx +43 -6
  48. package/src/react-native/components/snap-item-group.tsx +31 -17
  49. package/src/react-native/components/snap-item.tsx +92 -14
  50. package/src/react-native/components/snap-stack.tsx +37 -36
  51. package/src/react-native/components/snap-toggle-group.tsx +5 -4
  52. package/src/stack-horizontal-utils.ts +32 -13
  53. package/src/ui/README.md +1 -1
  54. package/src/ui/catalog.ts +6 -5
  55. package/src/ui/cell-grid.ts +8 -7
  56. package/src/ui/index.ts +2 -2
  57. package/src/ui/item.ts +35 -5
  58. package/src/ui/stack.ts +3 -1
  59. package/src/validator.ts +29 -1
package/dist/validator.js CHANGED
@@ -14,7 +14,10 @@ const URL_TARGET_ACTIONS = new Set([
14
14
  */
15
15
  function isLoopback(url) {
16
16
  const host = url.hostname;
17
- return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
17
+ return (host === "localhost" ||
18
+ host === "127.0.0.1" ||
19
+ host === "::1" ||
20
+ host === "[::1]");
18
21
  }
19
22
  /**
20
23
  * Validate a URL string: must be HTTPS (or HTTP on loopback for dev).
@@ -124,6 +127,21 @@ function validateUrls(elements) {
124
127
  });
125
128
  }
126
129
  }
130
+ if (el.type === "item" &&
131
+ el.props?.media &&
132
+ typeof el.props.media === "object") {
133
+ const media = el.props.media;
134
+ if (media.variant === "image" && typeof media.url === "string") {
135
+ const error = validateUrl(media.url);
136
+ if (error) {
137
+ issues.push({
138
+ code: "custom",
139
+ message: error,
140
+ path: ["ui", "elements", id, "props", "media", "url"],
141
+ });
142
+ }
143
+ }
144
+ }
127
145
  // Validate action target URLs
128
146
  if (el.on) {
129
147
  for (const [event, binding] of Object.entries(el.on)) {
package/llms.txt CHANGED
@@ -64,10 +64,11 @@ Top-level fields: `version` (required, `"1.0"` or `"2.0"`), `theme` (optional, `
64
64
  - `aspect` (required): `"1:1"` | `"16:9"` | `"4:3"` | `"9:16"`
65
65
  - `alt` (string, optional)
66
66
 
67
- **item** — Content row with title and right-side actions slot.
67
+ **item** — Content row matching shadcn Item: optional left media, content, and right-side actions slot.
68
68
  - `title` (string, required, max 100)
69
69
  - `description` (string, optional, max 160)
70
70
  - `variant` (optional): `"default"`. Default: `"default"`
71
+ - `media` (optional): left-side media. Icon media: `{ "variant": "icon", "name": IconName, "color"?: PaletteColor }`. Image media: `{ "variant": "image", "url": string, "alt"?: string, "round"?: boolean }`.
71
72
  - Children render in the actions slot (right side). Badges, buttons, and icons are all valid — but the item itself is not interactive, so avoid navigation-style icons (`chevron-right`, `arrow-right`, `external-link`) that imply the row navigates.
72
73
 
73
74
  **progress** — Horizontal progress bar.
@@ -97,6 +98,7 @@ Top-level fields: `version` (required, `"1.0"` or `"2.0"`), `theme` (optional, `
97
98
  - `rows` (number, required, 2–16)
98
99
  - `cells` (array, required): sparse list of `{ row, col, color?: PaletteColor | #rrggbb, content?: string, value?: string (1–30 chars) }`. When `value` is set on a cell, that string is what's written to `inputs[name]` on press/select; otherwise `"row,col"` is used. Use `value` for grids with meaningful labels (calendar days, alphabet letters, region codes) so handlers don't have to reverse-lookup. Use the row/col fallback for true coordinate grids (minesweeper, tic-tac-toe, pixel art).
99
100
  - `gap` (optional): `"none"` (0px) | `"sm"` (1px) | `"md"` (2px) | `"lg"` (4px). Default: `"sm"`
101
+ - `cellAspectRatio` (optional): `"auto"` | `"square"`. Default: `"auto"`. Use `"square"` for game boards whose cells must stay square as snap width changes.
100
102
  - `rowHeight` (number, optional, 8–64): pixel height per row. Default: 28. Grid height = rows × rowHeight
101
103
  - `select` (optional): `"off"` | `"single"` | `"multiple"`. Default: `"off"`. With `select: "off"`, bind `on.press` for press-to-act (each press writes the cell's `value` or `"row,col"` to `inputs[name]` and fires the action). With `"single"` / `"multiple"`, presses accumulate selection state and pair with a separate submit `button` (multi-select joins values with `|`); `on.press` is ignored.
102
104
  - Events: `press` — fires on cell press, only when `select: "off"`; `inputs[name]` is set to the pressed cell's `value` (or `"row,col"` fallback) before the bound action runs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "description": "Farcaster Snaps 🫰",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,36 @@
1
+ export type ButtonContentOrientation = "horizontal" | "vertical";
2
+
3
+ const MAX_HORIZONTAL_TOTAL_LENGTH: Record<number, number> = {
4
+ 2: 20,
5
+ 3: 15,
6
+ 4: 11,
7
+ 5: 8,
8
+ };
9
+
10
+ function displayLength(label: string): number {
11
+ return Array.from(label.trim().replace(/\s+/g, " ")).length;
12
+ }
13
+
14
+ export function getButtonContentOrientation(
15
+ labels: readonly string[],
16
+ ): ButtonContentOrientation {
17
+ const lengths = labels
18
+ .map((label) => displayLength(label))
19
+ .filter((length) => length > 0);
20
+ const count = lengths.length;
21
+
22
+ if (count <= 1) return "horizontal";
23
+
24
+ const maxTotalLength = MAX_HORIZONTAL_TOTAL_LENGTH[count] ?? 0;
25
+
26
+ if (maxTotalLength === 0) return "vertical";
27
+
28
+ const totalLength = lengths.reduce((sum, length) => sum + length, 0);
29
+ return totalLength <= maxTotalLength ? "horizontal" : "vertical";
30
+ }
31
+
32
+ export function shouldUseHorizontalButtonContent(
33
+ labels: readonly string[],
34
+ ): boolean {
35
+ return getButtonContentOrientation(labels) === "horizontal";
36
+ }
package/src/constants.ts CHANGED
@@ -20,6 +20,7 @@ export const GRID_MAX_COLS = 32;
20
20
  export const GRID_MIN_ROWS = 2;
21
21
  export const GRID_MAX_ROWS = 16;
22
22
  export const GRID_GAP_VALUES = ["none", "sm", "md", "lg"] as const;
23
+ export const GRID_CELL_ASPECT_RATIO_VALUES = ["auto", "square"] as const;
23
24
 
24
25
  // ─── Snap structural limits ───────────────────────────
25
26
  export const MAX_ELEMENTS = 64;
@@ -60,16 +60,17 @@ export function SnapActionButton({
60
60
 
61
61
  return (
62
62
  /**
63
- * In a horizontal stack, `flex-1` lets the wrapper share row width with peers.
64
- * In a vertical stack, `flex-1` would silently grow the button to fill column
65
- * height (1/N distribution when siblings also flex-grow); stick to `w-full`.
63
+ * In a horizontal stack, `flex-auto` lets the row fill available width while
64
+ * preserving content-proportional button widths. In a vertical stack, flex
65
+ * growth would silently stretch button height; stick to `w-full`.
66
66
  */
67
67
  <div
68
68
  className={
69
69
  inHorizontalStack
70
- ? "w-full min-w-0 flex-1"
70
+ ? "min-w-0 flex-auto"
71
71
  : "w-full min-w-0"
72
72
  }
73
+ style={inHorizontalStack ? { flex: "1 1 auto" } : undefined}
73
74
  >
74
75
  <Button
75
76
  type="button"
@@ -27,6 +27,7 @@ export function SnapCellGrid({
27
27
  const gapMap: Record<string, number> = { none: 0, sm: 1, md: 2, lg: 4 };
28
28
  const gapPx = gapMap[gap] ?? 1;
29
29
  const rowHeight = typeof props.rowHeight === "number" ? props.rowHeight : 28;
30
+ const squareCells = props.cellAspectRatio === "square";
30
31
 
31
32
  const name = props.name ? String(props.name) : POST_GRID_TAP_KEY;
32
33
  const tapPath = `/inputs/${name}`;
@@ -34,11 +35,12 @@ export function SnapCellGrid({
34
35
 
35
36
  const cellMap = new Map<
36
37
  string,
37
- { color?: string; content?: string; value?: string }
38
+ { color?: string; textColor?: string; content?: string; value?: string }
38
39
  >();
39
40
  for (const c of cells) {
40
41
  cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
41
42
  color: c.color as string | undefined,
43
+ textColor: c.textColor as string | undefined,
42
44
  content: c.content != null ? String(c.content) : undefined,
43
45
  value: typeof c.value === "string" ? c.value : undefined,
44
46
  });
@@ -78,7 +80,9 @@ export function SnapCellGrid({
78
80
 
79
81
  /** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
80
82
  const emptyCellBg =
81
- colors.mode === "dark" ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.05)";
83
+ colors.mode === "dark"
84
+ ? "rgba(255, 255, 255, 0.05)"
85
+ : "rgba(0, 0, 0, 0.05)";
82
86
 
83
87
  const cellEls: ReactNode[] = [];
84
88
  for (let r = 0; r < rows; r++) {
@@ -87,7 +91,11 @@ export function SnapCellGrid({
87
91
  const selected = interactive && isSelected(r, c);
88
92
  const bgHex = cell?.color ? colors.colorHex(cell.color) : null;
89
93
  const bg = bgHex ?? emptyCellBg;
90
- const textColor = bgHex ? readableTextOnHex(bgHex) : colors.text;
94
+ const textColor = cell?.textColor
95
+ ? colors.colorHex(cell.textColor)
96
+ : bgHex
97
+ ? readableTextOnHex(bgHex)
98
+ : colors.text;
91
99
 
92
100
  cellEls.push(
93
101
  <div
@@ -110,7 +118,8 @@ export function SnapCellGrid({
110
118
  interactive ? "cursor-pointer select-none" : "cursor-default",
111
119
  )}
112
120
  style={{
113
- height: rowHeight,
121
+ height: squareCells ? undefined : rowHeight,
122
+ aspectRatio: squareCells ? "1 / 1" : undefined,
114
123
  background: bg,
115
124
  color: textColor,
116
125
  boxShadow: selected
@@ -3,6 +3,7 @@
3
3
  import { Children, type ReactNode, Fragment } from "react";
4
4
  import { cn } from "@neynar/ui/utils";
5
5
  import { useSnapColors } from "../hooks/use-snap-colors";
6
+ import { SnapItemGroupBorderProvider } from "./item-layout-context";
6
7
 
7
8
  const GAP_MAP: Record<string, string> = {
8
9
  none: "gap-0",
@@ -25,22 +26,23 @@ export function SnapItemGroup({
25
26
  const colors = useSnapColors();
26
27
 
27
28
  return (
28
- <div
29
- className={cn(
30
- "flex flex-col",
31
- border && "rounded-lg border",
32
- gap,
33
- )}
34
- style={border ? { borderColor: colors.border } : undefined}
35
- >
36
- {items.map((child, i) => (
37
- <Fragment key={i}>
38
- {separator && i > 0 && (
39
- <div className="h-px" style={{ backgroundColor: colors.border }} />
40
- )}
41
- {child}
42
- </Fragment>
43
- ))}
44
- </div>
29
+ <SnapItemGroupBorderProvider value={border}>
30
+ <div
31
+ className={cn("flex flex-col", border && "rounded-lg border", gap)}
32
+ style={border ? { borderColor: colors.border } : undefined}
33
+ >
34
+ {items.map((child, i) => (
35
+ <Fragment key={i}>
36
+ {separator && i > 0 && (
37
+ <div
38
+ className="h-px"
39
+ style={{ backgroundColor: colors.border }}
40
+ />
41
+ )}
42
+ {child}
43
+ </Fragment>
44
+ ))}
45
+ </div>
46
+ </SnapItemGroupBorderProvider>
45
47
  );
46
48
  }
@@ -0,0 +1,12 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+
5
+ const SnapItemGroupBorderContext = createContext(false);
6
+
7
+ export const SnapItemGroupBorderProvider =
8
+ SnapItemGroupBorderContext.Provider;
9
+
10
+ export function useSnapItemGroupHasBorder() {
11
+ return useContext(SnapItemGroupBorderContext);
12
+ }
@@ -6,10 +6,50 @@ import {
6
6
  ItemTitle,
7
7
  ItemDescription,
8
8
  ItemActions,
9
+ ItemMedia,
9
10
  } from "@neynar/ui/item";
10
11
  import { cn } from "@neynar/ui/utils";
11
12
  import { useSnapColors } from "../hooks/use-snap-colors";
12
13
  import { useSnapStackDirection } from "../stack-direction-context";
14
+ import { ICON_MAP } from "./icon";
15
+ import { useSnapItemGroupHasBorder } from "./item-layout-context";
16
+
17
+ type ItemMediaConfig =
18
+ | {
19
+ variant: "icon";
20
+ name: string;
21
+ color?: string;
22
+ }
23
+ | {
24
+ variant: "image";
25
+ url: string;
26
+ alt?: string;
27
+ round?: boolean;
28
+ };
29
+
30
+ function parseItemMedia(value: unknown): ItemMediaConfig | undefined {
31
+ if (!value || typeof value !== "object") return undefined;
32
+
33
+ const media = value as Record<string, unknown>;
34
+ if (media.variant === "icon" && typeof media.name === "string") {
35
+ return {
36
+ variant: "icon",
37
+ name: media.name,
38
+ color: typeof media.color === "string" ? media.color : undefined,
39
+ };
40
+ }
41
+
42
+ if (media.variant === "image" && typeof media.url === "string") {
43
+ return {
44
+ variant: "image",
45
+ url: media.url,
46
+ alt: typeof media.alt === "string" ? media.alt : undefined,
47
+ round: typeof media.round === "boolean" ? media.round : undefined,
48
+ };
49
+ }
50
+
51
+ return undefined;
52
+ }
13
53
 
14
54
  export function SnapItem({
15
55
  element: { props, children: childIds },
@@ -20,26 +60,79 @@ export function SnapItem({
20
60
  }) {
21
61
  const title = String(props.title ?? "");
22
62
  const description = props.description ? String(props.description) : undefined;
63
+ const media = parseItemMedia(props.media);
23
64
  const colors = useSnapColors();
65
+ const inBorderedGroup = useSnapItemGroupHasBorder();
24
66
  const inHorizontalStack = useSnapStackDirection() === "horizontal";
67
+ const MediaIcon =
68
+ media?.variant === "icon" ? ICON_MAP[media.name] : undefined;
25
69
 
26
70
  return (
27
71
  <Item
28
72
  className={cn(
29
- "py-1.5 px-2.5",
73
+ "gap-2 py-1.5",
74
+ inBorderedGroup ? "px-2" : "px-0",
30
75
  /** Horizontal: share width with peers. Vertical: don't fill column height. */
31
76
  inHorizontalStack && "flex-1",
32
77
  )}
78
+ style={{
79
+ columnGap: 8,
80
+ paddingInline: inBorderedGroup ? 8 : 0,
81
+ }}
33
82
  >
34
- <ItemContent className="gap-0.5">
83
+ {media?.variant === "icon" && MediaIcon && (
84
+ <ItemMedia
85
+ variant="icon"
86
+ className="self-center translate-y-0"
87
+ style={{ alignSelf: "center", transform: "none" }}
88
+ >
89
+ <MediaIcon
90
+ size={20}
91
+ style={{ color: colors.colorHex(media.color) }}
92
+ />
93
+ </ItemMedia>
94
+ )}
95
+ {media?.variant === "image" && (
96
+ <ItemMedia
97
+ variant="image"
98
+ className="self-center translate-y-0"
99
+ style={{
100
+ alignSelf: "center",
101
+ borderRadius: media.round ? "9999px" : undefined,
102
+ transform: "none",
103
+ }}
104
+ >
105
+ {/* eslint-disable-next-line @next/next/no-img-element */}
106
+ <img
107
+ src={media.url}
108
+ alt={media.alt ?? ""}
109
+ className="size-full object-cover"
110
+ />
111
+ </ItemMedia>
112
+ )}
113
+ <ItemContent className="gap-0">
35
114
  <ItemTitle style={{ color: colors.text }}>{title}</ItemTitle>
36
115
  {description && (
37
- <ItemDescription className="mt-0" style={{ color: colors.textMuted }}>
116
+ <ItemDescription
117
+ className="mt-0 text-xs leading-snug"
118
+ style={{
119
+ color: colors.textMuted,
120
+ fontSize: 12,
121
+ lineHeight: "16px",
122
+ }}
123
+ >
38
124
  {description}
39
125
  </ItemDescription>
40
126
  )}
41
127
  </ItemContent>
42
- {childIds && childIds.length > 0 && <ItemActions>{children}</ItemActions>}
128
+ {childIds && childIds.length > 0 && (
129
+ <ItemActions
130
+ className="gap-1.5 self-center"
131
+ style={{ alignSelf: "center", columnGap: 6 }}
132
+ >
133
+ {children}
134
+ </ItemActions>
135
+ )}
43
136
  </Item>
44
137
  );
45
138
  }
@@ -1,11 +1,12 @@
1
1
  "use client";
2
2
 
3
- import type { ReactNode } from "react";
3
+ import type { CSSProperties, ReactNode } from "react";
4
4
  import { cn } from "@neynar/ui/utils";
5
5
  import {
6
+ childrenShouldUseHorizontalButtonLayout,
7
+ childrenAreAllButtons,
6
8
  countRenderableChildren,
7
9
  defaultHorizontalGapSize,
8
- horizontalChildrenAreAllButtons,
9
10
  } from "../../stack-horizontal-utils.js";
10
11
  import {
11
12
  SnapStackDirectionProvider,
@@ -14,7 +15,7 @@ import {
14
15
 
15
16
  const VGAP: Record<string, string> = {
16
17
  none: "gap-0",
17
- sm: "gap-2",
18
+ sm: "gap-1",
18
19
  md: "gap-4",
19
20
  lg: "gap-6",
20
21
  };
@@ -34,14 +35,14 @@ const JUSTIFY_FLEX: Record<string, string> = {
34
35
  around: "justify-around",
35
36
  };
36
37
 
37
- /** Equal columns for explicit `columns` prop and for all-button horizontal rows. */
38
+ /** Equal-width cell count for explicit `equalWidth` / `columns` props. */
38
39
  const COLUMN_GRID_CLASS: Record<number, string> = {
39
- 1: "grid grid-cols-1 auto-rows-auto items-stretch [&>*]:min-w-0",
40
- 2: "grid grid-cols-2 auto-rows-auto items-stretch [&>*]:min-w-0",
41
- 3: "grid grid-cols-3 auto-rows-auto items-stretch [&>*]:min-w-0",
42
- 4: "grid grid-cols-4 auto-rows-auto items-stretch [&>*]:min-w-0",
43
- 5: "grid grid-cols-5 auto-rows-auto items-stretch [&>*]:min-w-0",
44
- 6: "grid grid-cols-6 auto-rows-auto items-stretch [&>*]:min-w-0",
40
+ 1: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
41
+ 2: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
42
+ 3: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
43
+ 4: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
44
+ 5: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
45
+ 6: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
45
46
  };
46
47
 
47
48
  export function SnapStack({
@@ -52,17 +53,21 @@ export function SnapStack({
52
53
  children?: ReactNode;
53
54
  }) {
54
55
  const parentDirection = useSnapStackDirection();
55
- const direction = String(props.direction ?? "vertical");
56
+ const buttonContentUsesHorizontal =
57
+ childrenShouldUseHorizontalButtonLayout(children);
58
+ const direction =
59
+ buttonContentUsesHorizontal === undefined
60
+ ? String(props.direction ?? "vertical")
61
+ : buttonContentUsesHorizontal
62
+ ? "horizontal"
63
+ : "vertical";
56
64
  const isHorizontal = direction === "horizontal";
57
65
  const justifyKey = props.justify ? String(props.justify) : undefined;
58
66
  const justifyFlex = justifyKey ? JUSTIFY_FLEX[justifyKey] : undefined;
59
- const buttonRowGrid =
60
- isHorizontal && horizontalChildrenAreAllButtons(children);
61
- const buttonRowCount = buttonRowGrid
62
- ? countRenderableChildren(children)
63
- : 0;
67
+ const allChildrenAreButtons = childrenAreAllButtons(children);
64
68
 
65
69
  const columnsRaw = props.columns;
70
+ const equalWidth = props.equalWidth === true;
66
71
  const columns =
67
72
  typeof columnsRaw === "number" &&
68
73
  columnsRaw >= 2 &&
@@ -71,29 +76,36 @@ export function SnapStack({
71
76
  ? columnsRaw
72
77
  : undefined;
73
78
 
74
- // Horizontal default depends on column count: 2→lg, 3→md, 4+→sm. Vertical stays md.
75
- // Count comes from explicit `columns`, then button-row inference, else direct children
76
- // count (any horizontal stack is N columns wide regardless of child types).
77
- const horizontalColumnCount = isHorizontal
78
- ? (columns ??
79
- (buttonRowGrid ? buttonRowCount : undefined) ??
80
- countRenderableChildren(children))
79
+ const equalWidthColumnCount =
80
+ columns ?? (equalWidth ? countRenderableChildren(children) : undefined);
81
+ const explicitEqualWidth =
82
+ isHorizontal &&
83
+ equalWidthColumnCount !== undefined &&
84
+ equalWidthColumnCount >= 1 &&
85
+ equalWidthColumnCount <= 6;
86
+
87
+ // Button-only stacks always default to sm; mixed horizontal stacks scale by child count.
88
+ // Vertical non-button stacks default to md.
89
+ const horizontalChildCount = isHorizontal
90
+ ? (explicitEqualWidth
91
+ ? equalWidthColumnCount
92
+ : countRenderableChildren(children))
81
93
  : undefined;
82
94
  const explicitGap =
83
95
  typeof props.gap === "string" && props.gap in (isHorizontal ? HGAP : VGAP);
84
96
  const gapKey = explicitGap
85
97
  ? String(props.gap)
86
- : isHorizontal
87
- ? defaultHorizontalGapSize(horizontalColumnCount)
98
+ : allChildrenAreButtons
99
+ ? "sm"
100
+ : isHorizontal
101
+ ? defaultHorizontalGapSize(horizontalChildCount)
88
102
  : "md";
89
103
  const gap = isHorizontal
90
104
  ? (HGAP[gapKey] ?? HGAP.md!)
91
105
  : (VGAP[gapKey] ?? VGAP.md!);
92
- const explicitColumnGrid =
93
- isHorizontal && columns !== undefined && !buttonRowGrid;
94
106
  const columnGridClass =
95
- explicitColumnGrid && columns !== undefined
96
- ? COLUMN_GRID_CLASS[columns]
107
+ explicitEqualWidth && equalWidthColumnCount !== undefined
108
+ ? COLUMN_GRID_CLASS[equalWidthColumnCount]
97
109
  : undefined;
98
110
 
99
111
  /**
@@ -108,11 +120,18 @@ export function SnapStack({
108
120
 
109
121
  const justifyBlockGrid =
110
122
  justifyFlex &&
111
- (!isHorizontal || (!buttonRowGrid && !explicitColumnGrid));
123
+ (!isHorizontal || !explicitEqualWidth);
112
124
 
113
125
  /** Single flex row (nowrap): peers stay side-by-side and shrink via min-w-0 / flex-1 on nested stacks. */
114
126
  const horizontalFlexClasses =
115
127
  "flex min-w-0 flex-row flex-nowrap items-stretch [&>*]:min-w-0";
128
+ const equalWidthStyle: CSSProperties | undefined =
129
+ explicitEqualWidth && equalWidthColumnCount !== undefined
130
+ ? {
131
+ display: "grid",
132
+ gridTemplateColumns: `repeat(${equalWidthColumnCount}, minmax(0, 1fr))`,
133
+ }
134
+ : undefined;
116
135
 
117
136
  return (
118
137
  <SnapStackDirectionProvider
@@ -122,20 +141,12 @@ export function SnapStack({
122
141
  className={cn(
123
142
  rootWidthClass,
124
143
  isHorizontal
125
- ? buttonRowGrid &&
126
- buttonRowCount >= 1 &&
127
- buttonRowCount <= 6 &&
128
- COLUMN_GRID_CLASS[buttonRowCount]
129
- ? cn(
130
- COLUMN_GRID_CLASS[buttonRowCount]!,
131
- gap,
132
- "[&>*]:w-full",
133
- )
134
- : explicitColumnGrid && columnGridClass
144
+ ? explicitEqualWidth && columnGridClass
135
145
  ? cn(columnGridClass, gap)
136
146
  : cn(horizontalFlexClasses, gap, justifyBlockGrid ? justifyFlex : undefined)
137
147
  : cn("flex min-w-0 w-full flex-col", gap, justifyFlex),
138
148
  )}
149
+ style={equalWidthStyle}
139
150
  >
140
151
  {children}
141
152
  </div>
@@ -4,6 +4,7 @@ import { useState } from "react";
4
4
  import { useStateStore } from "@json-render/react";
5
5
  import { Label } from "@neynar/ui/label";
6
6
  import { cn } from "@neynar/ui/utils";
7
+ import { shouldUseHorizontalButtonContent } from "../../button-orientation-utils.js";
7
8
  import { useSnapColors } from "../hooks/use-snap-colors";
8
9
 
9
10
  export function SnapToggleGroup({
@@ -17,7 +18,6 @@ export function SnapToggleGroup({
17
18
  const path = `/inputs/${name}`;
18
19
  const label = props.label ? String(props.label) : undefined;
19
20
  const isMultiple = Boolean(props.multiple);
20
- const orientation = String(props.orientation ?? "horizontal");
21
21
  const options = Array.isArray(props.options)
22
22
  ? (props.options as string[])
23
23
  : [];
@@ -50,7 +50,7 @@ export function SnapToggleGroup({
50
50
  }
51
51
  };
52
52
 
53
- const isVertical = orientation === "vertical";
53
+ const isVertical = !shouldUseHorizontalButtonContent(options);
54
54
  const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
55
55
 
56
56
  return (
@@ -68,14 +68,16 @@ export function SnapToggleGroup({
68
68
  const isHovered = hoveredIdx === i && !isSelected;
69
69
  return (
70
70
  <button
71
- key={opt}
71
+ key={`${opt}-${i}`}
72
72
  type="button"
73
73
  onClick={() => toggle(opt)}
74
74
  onPointerEnter={() => setHoveredIdx(i)}
75
75
  onPointerLeave={() => setHoveredIdx(null)}
76
76
  className={cn(
77
77
  "rounded-md px-3 py-2 text-sm font-medium transition-colors",
78
- isVertical ? "w-full" : "flex-1",
78
+ isVertical
79
+ ? "w-full"
80
+ : "flex-auto whitespace-nowrap",
79
81
  )}
80
82
  style={{
81
83
  transition: "background-color 0.15s, color 0.15s",
@@ -0,0 +1,10 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ const SnapItemGroupBorderContext = createContext(false);
4
+
5
+ export const SnapItemGroupBorderProvider =
6
+ SnapItemGroupBorderContext.Provider;
7
+
8
+ export function useSnapItemGroupHasBorder() {
9
+ return useContext(SnapItemGroupBorderContext);
10
+ }
@@ -5,6 +5,7 @@ import { Pressable, StyleSheet, Text, View } from "react-native";
5
5
  import { ExternalLink } from "lucide-react-native";
6
6
  import { useSnapPalette } from "../use-snap-palette";
7
7
  import { useSnapTheme } from "../theme";
8
+ import { useSnapStackDirection } from "../stack-direction-context";
8
9
  import { ICON_MAP } from "./snap-icon";
9
10
 
10
11
  function isExternalLinkAction(
@@ -32,12 +33,13 @@ export function SnapActionButton({
32
33
 
33
34
  const textColor = isPrimary ? "#fff" : colors.text;
34
35
  const iconColor = isPrimary ? "#fff" : colors.text;
36
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
35
37
 
36
38
  const on = (element as unknown as { on?: Record<string, unknown> }).on;
37
39
  const showExternalIcon = isExternalLinkAction(on);
38
40
 
39
41
  return (
40
- <View style={styles.outer}>
42
+ <View style={inHorizontalStack ? styles.outerHorizontal : styles.outer}>
41
43
  <Pressable
42
44
  style={({ pressed }) => [
43
45
  styles.btn,
@@ -77,7 +79,18 @@ export function SnapActionButton({
77
79
  }
78
80
 
79
81
  const styles = StyleSheet.create({
80
- outer: { minWidth: 0 },
82
+ outer: {
83
+ width: "100%",
84
+ minWidth: 0,
85
+ alignSelf: "stretch",
86
+ },
87
+ outerHorizontal: {
88
+ flexGrow: 1,
89
+ flexShrink: 1,
90
+ flexBasis: "auto",
91
+ minWidth: 0,
92
+ alignSelf: "stretch",
93
+ },
81
94
  btn: {
82
95
  paddingHorizontal: 16,
83
96
  borderRadius: 10,