@farcaster/snap 2.4.0 → 2.5.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 (58) 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 +6 -2
  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 +30 -4
  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 +60 -1
  24. package/dist/ui/catalog.js +3 -3
  25. package/dist/ui/cell-grid.d.ts +4 -0
  26. package/dist/ui/cell-grid.js +3 -4
  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 +6 -2
  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 +36 -4
  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/catalog.ts +5 -4
  54. package/src/ui/cell-grid.ts +5 -5
  55. package/src/ui/index.ts +2 -2
  56. package/src/ui/item.ts +35 -5
  57. package/src/ui/stack.ts +3 -1
  58. package/src/validator.ts +29 -1
@@ -0,0 +1,3 @@
1
+ export type ButtonContentOrientation = "horizontal" | "vertical";
2
+ export declare function getButtonContentOrientation(labels: readonly string[]): ButtonContentOrientation;
3
+ export declare function shouldUseHorizontalButtonContent(labels: readonly string[]): boolean;
@@ -0,0 +1,25 @@
1
+ const MAX_HORIZONTAL_TOTAL_LENGTH = {
2
+ 2: 20,
3
+ 3: 15,
4
+ 4: 11,
5
+ 5: 8,
6
+ };
7
+ function displayLength(label) {
8
+ return Array.from(label.trim().replace(/\s+/g, " ")).length;
9
+ }
10
+ export function getButtonContentOrientation(labels) {
11
+ const lengths = labels
12
+ .map((label) => displayLength(label))
13
+ .filter((length) => length > 0);
14
+ const count = lengths.length;
15
+ if (count <= 1)
16
+ return "horizontal";
17
+ const maxTotalLength = MAX_HORIZONTAL_TOTAL_LENGTH[count] ?? 0;
18
+ if (maxTotalLength === 0)
19
+ return "vertical";
20
+ const totalLength = lengths.reduce((sum, length) => sum + length, 0);
21
+ return totalLength <= maxTotalLength ? "horizontal" : "vertical";
22
+ }
23
+ export function shouldUseHorizontalButtonContent(labels) {
24
+ return getButtonContentOrientation(labels) === "horizontal";
25
+ }
@@ -12,6 +12,7 @@ export declare const GRID_MAX_COLS = 32;
12
12
  export declare const GRID_MIN_ROWS = 2;
13
13
  export declare const GRID_MAX_ROWS = 16;
14
14
  export declare const GRID_GAP_VALUES: readonly ["none", "sm", "md", "lg"];
15
+ export declare const GRID_CELL_ASPECT_RATIO_VALUES: readonly ["auto", "square"];
15
16
  export declare const MAX_ELEMENTS = 64;
16
17
  export declare const MAX_ROOT_CHILDREN = 7;
17
18
  export declare const MAX_CHILDREN = 6;
package/dist/constants.js CHANGED
@@ -15,6 +15,7 @@ export const GRID_MAX_COLS = 32;
15
15
  export const GRID_MIN_ROWS = 2;
16
16
  export const GRID_MAX_ROWS = 16;
17
17
  export const GRID_GAP_VALUES = ["none", "sm", "md", "lg"];
18
+ export const GRID_CELL_ASPECT_RATIO_VALUES = ["auto", "square"];
18
19
  // ─── Snap structural limits ───────────────────────────
19
20
  export const MAX_ELEMENTS = 64;
20
21
  export const MAX_ROOT_CHILDREN = 7;
@@ -44,11 +44,11 @@ export function SnapActionButton({ element, emit, }) {
44
44
  };
45
45
  return (
46
46
  /**
47
- * In a horizontal stack, `flex-1` lets the wrapper share row width with peers.
48
- * In a vertical stack, `flex-1` would silently grow the button to fill column
49
- * height (1/N distribution when siblings also flex-grow); stick to `w-full`.
47
+ * In a horizontal stack, `flex-auto` lets the row fill available width while
48
+ * preserving content-proportional button widths. In a vertical stack, flex
49
+ * growth would silently stretch button height; stick to `w-full`.
50
50
  */
51
51
  _jsx("div", { className: inHorizontalStack
52
- ? "w-full min-w-0 flex-1"
53
- : "w-full min-w-0", children: _jsxs(Button, { type: "button", variant: isPrimary ? "default" : "secondary", className: cn("w-full gap-2"), style: style, onClick: () => emit("press"), onPointerEnter: () => setHovered(true), onPointerLeave: () => setHovered(false), children: [Icon && _jsx(Icon, { size: 16 }), label, showExternalIcon && (_jsx(ExternalLink, { size: 14, style: { opacity: 0.6 } }))] }) }));
52
+ ? "min-w-0 flex-auto"
53
+ : "w-full min-w-0", style: inHorizontalStack ? { flex: "1 1 auto" } : undefined, children: _jsxs(Button, { type: "button", variant: isPrimary ? "default" : "secondary", className: cn("w-full gap-2"), style: style, onClick: () => emit("press"), onPointerEnter: () => setHovered(true), onPointerLeave: () => setHovered(false), children: [Icon && _jsx(Icon, { size: 16 }), label, showExternalIcon && (_jsx(ExternalLink, { size: 14, style: { opacity: 0.6 } }))] }) }));
54
54
  }
@@ -19,6 +19,7 @@ export function SnapCellGrid({ element: { props, on }, emit, }) {
19
19
  const gapMap = { none: 0, sm: 1, md: 2, lg: 4 };
20
20
  const gapPx = gapMap[gap] ?? 1;
21
21
  const rowHeight = typeof props.rowHeight === "number" ? props.rowHeight : 28;
22
+ const squareCells = props.cellAspectRatio === "square";
22
23
  const name = props.name ? String(props.name) : POST_GRID_TAP_KEY;
23
24
  const tapPath = `/inputs/${name}`;
24
25
  const tapRaw = get(tapPath);
@@ -63,7 +64,9 @@ export function SnapCellGrid({ element: { props, on }, emit, }) {
63
64
  emit("press");
64
65
  };
65
66
  /** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
66
- const emptyCellBg = colors.mode === "dark" ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.05)";
67
+ const emptyCellBg = colors.mode === "dark"
68
+ ? "rgba(255, 255, 255, 0.05)"
69
+ : "rgba(0, 0, 0, 0.05)";
67
70
  const cellEls = [];
68
71
  for (let r = 0; r < rows; r++) {
69
72
  for (let c = 0; c < cols; c++) {
@@ -80,7 +83,8 @@ export function SnapCellGrid({ element: { props, on }, emit, }) {
80
83
  }
81
84
  }
82
85
  : undefined, className: cn("flex items-center justify-center rounded text-xs font-semibold", interactive ? "cursor-pointer select-none" : "cursor-default"), style: {
83
- height: rowHeight,
86
+ height: squareCells ? undefined : rowHeight,
87
+ aspectRatio: squareCells ? "1 / 1" : undefined,
84
88
  background: bg,
85
89
  color: textColor,
86
90
  boxShadow: selected
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Children, Fragment } from "react";
4
4
  import { cn } from "@neynar/ui/utils";
5
5
  import { useSnapColors } from "../hooks/use-snap-colors.js";
6
+ import { SnapItemGroupBorderProvider } from "./item-layout-context.js";
6
7
  const GAP_MAP = {
7
8
  none: "gap-0",
8
9
  sm: "gap-1",
@@ -15,5 +16,5 @@ export function SnapItemGroup({ element: { props }, children, }) {
15
16
  const gap = GAP_MAP[String(props.gap ?? "sm")] ?? "gap-1";
16
17
  const items = Children.toArray(children);
17
18
  const colors = useSnapColors();
18
- return (_jsx("div", { className: cn("flex flex-col", border && "rounded-lg border", gap), style: border ? { borderColor: colors.border } : undefined, children: items.map((child, i) => (_jsxs(Fragment, { children: [separator && i > 0 && (_jsx("div", { className: "h-px", style: { backgroundColor: colors.border } })), child] }, i))) }));
19
+ return (_jsx(SnapItemGroupBorderProvider, { value: border, children: _jsx("div", { className: cn("flex flex-col", border && "rounded-lg border", gap), style: border ? { borderColor: colors.border } : undefined, children: items.map((child, i) => (_jsxs(Fragment, { children: [separator && i > 0 && (_jsx("div", { className: "h-px", style: { backgroundColor: colors.border } })), child] }, i))) }) }));
19
20
  }
@@ -0,0 +1,2 @@
1
+ export declare const SnapItemGroupBorderProvider: import("react").Provider<boolean>;
2
+ export declare function useSnapItemGroupHasBorder(): boolean;
@@ -0,0 +1,7 @@
1
+ "use client";
2
+ import { createContext, useContext } from "react";
3
+ const SnapItemGroupBorderContext = createContext(false);
4
+ export const SnapItemGroupBorderProvider = SnapItemGroupBorderContext.Provider;
5
+ export function useSnapItemGroupHasBorder() {
6
+ return useContext(SnapItemGroupBorderContext);
7
+ }
@@ -1,15 +1,52 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Item, ItemContent, ItemTitle, ItemDescription, ItemActions, } from "@neynar/ui/item";
3
+ import { Item, ItemContent, ItemTitle, ItemDescription, ItemActions, ItemMedia, } from "@neynar/ui/item";
4
4
  import { cn } from "@neynar/ui/utils";
5
5
  import { useSnapColors } from "../hooks/use-snap-colors.js";
6
6
  import { useSnapStackDirection } from "../stack-direction-context.js";
7
+ import { ICON_MAP } from "./icon.js";
8
+ import { useSnapItemGroupHasBorder } from "./item-layout-context.js";
9
+ function parseItemMedia(value) {
10
+ if (!value || typeof value !== "object")
11
+ return undefined;
12
+ const media = value;
13
+ if (media.variant === "icon" && typeof media.name === "string") {
14
+ return {
15
+ variant: "icon",
16
+ name: media.name,
17
+ color: typeof media.color === "string" ? media.color : undefined,
18
+ };
19
+ }
20
+ if (media.variant === "image" && typeof media.url === "string") {
21
+ return {
22
+ variant: "image",
23
+ url: media.url,
24
+ alt: typeof media.alt === "string" ? media.alt : undefined,
25
+ round: typeof media.round === "boolean" ? media.round : undefined,
26
+ };
27
+ }
28
+ return undefined;
29
+ }
7
30
  export function SnapItem({ element: { props, children: childIds }, children, }) {
8
31
  const title = String(props.title ?? "");
9
32
  const description = props.description ? String(props.description) : undefined;
33
+ const media = parseItemMedia(props.media);
10
34
  const colors = useSnapColors();
35
+ const inBorderedGroup = useSnapItemGroupHasBorder();
11
36
  const inHorizontalStack = useSnapStackDirection() === "horizontal";
12
- return (_jsxs(Item, { className: cn("py-1.5 px-2.5",
37
+ const MediaIcon = media?.variant === "icon" ? ICON_MAP[media.name] : undefined;
38
+ return (_jsxs(Item, { className: cn("gap-2 py-1.5", inBorderedGroup ? "px-2" : "px-0",
13
39
  /** Horizontal: share width with peers. Vertical: don't fill column height. */
14
- inHorizontalStack && "flex-1"), children: [_jsxs(ItemContent, { className: "gap-0.5", children: [_jsx(ItemTitle, { style: { color: colors.text }, children: title }), description && (_jsx(ItemDescription, { className: "mt-0", style: { color: colors.textMuted }, children: description }))] }), childIds && childIds.length > 0 && _jsx(ItemActions, { children: children })] }));
40
+ inHorizontalStack && "flex-1"), style: {
41
+ columnGap: 8,
42
+ paddingInline: inBorderedGroup ? 8 : 0,
43
+ }, children: [media?.variant === "icon" && MediaIcon && (_jsx(ItemMedia, { variant: "icon", className: "self-center translate-y-0", style: { alignSelf: "center", transform: "none" }, children: _jsx(MediaIcon, { size: 20, style: { color: colors.colorHex(media.color) } }) })), media?.variant === "image" && (_jsx(ItemMedia, { variant: "image", className: "self-center translate-y-0", style: {
44
+ alignSelf: "center",
45
+ borderRadius: media.round ? "9999px" : undefined,
46
+ transform: "none",
47
+ }, children: _jsx("img", { src: media.url, alt: media.alt ?? "", className: "size-full object-cover" }) })), _jsxs(ItemContent, { className: "gap-0", children: [_jsx(ItemTitle, { style: { color: colors.text }, children: title }), description && (_jsx(ItemDescription, { className: "mt-0 text-xs leading-snug", style: {
48
+ color: colors.textMuted,
49
+ fontSize: 12,
50
+ lineHeight: "16px",
51
+ }, children: description }))] }), childIds && childIds.length > 0 && (_jsx(ItemActions, { className: "gap-1.5 self-center", style: { alignSelf: "center", columnGap: 6 }, children: children }))] }));
15
52
  }
@@ -1,11 +1,11 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { cn } from "@neynar/ui/utils";
4
- import { countRenderableChildren, defaultHorizontalGapSize, horizontalChildrenAreAllButtons, } from "../../stack-horizontal-utils.js";
4
+ import { childrenShouldUseHorizontalButtonLayout, childrenAreAllButtons, countRenderableChildren, defaultHorizontalGapSize, } from "../../stack-horizontal-utils.js";
5
5
  import { SnapStackDirectionProvider, useSnapStackDirection, } from "../stack-direction-context.js";
6
6
  const VGAP = {
7
7
  none: "gap-0",
8
- sm: "gap-2",
8
+ sm: "gap-1",
9
9
  md: "gap-4",
10
10
  lg: "gap-6",
11
11
  };
@@ -22,52 +22,60 @@ const JUSTIFY_FLEX = {
22
22
  between: "justify-between",
23
23
  around: "justify-around",
24
24
  };
25
- /** Equal columns for explicit `columns` prop and for all-button horizontal rows. */
25
+ /** Equal-width cell count for explicit `equalWidth` / `columns` props. */
26
26
  const COLUMN_GRID_CLASS = {
27
- 1: "grid grid-cols-1 auto-rows-auto items-stretch [&>*]:min-w-0",
28
- 2: "grid grid-cols-2 auto-rows-auto items-stretch [&>*]:min-w-0",
29
- 3: "grid grid-cols-3 auto-rows-auto items-stretch [&>*]:min-w-0",
30
- 4: "grid grid-cols-4 auto-rows-auto items-stretch [&>*]:min-w-0",
31
- 5: "grid grid-cols-5 auto-rows-auto items-stretch [&>*]:min-w-0",
32
- 6: "grid grid-cols-6 auto-rows-auto items-stretch [&>*]:min-w-0",
27
+ 1: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
28
+ 2: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
29
+ 3: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
30
+ 4: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
31
+ 5: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
32
+ 6: "auto-rows-auto items-stretch [&>*]:min-w-0 [&>*]:w-full",
33
33
  };
34
34
  export function SnapStack({ element: { props }, children, }) {
35
35
  const parentDirection = useSnapStackDirection();
36
- const direction = String(props.direction ?? "vertical");
36
+ const buttonContentUsesHorizontal = childrenShouldUseHorizontalButtonLayout(children);
37
+ const direction = buttonContentUsesHorizontal === undefined
38
+ ? String(props.direction ?? "vertical")
39
+ : buttonContentUsesHorizontal
40
+ ? "horizontal"
41
+ : "vertical";
37
42
  const isHorizontal = direction === "horizontal";
38
43
  const justifyKey = props.justify ? String(props.justify) : undefined;
39
44
  const justifyFlex = justifyKey ? JUSTIFY_FLEX[justifyKey] : undefined;
40
- const buttonRowGrid = isHorizontal && horizontalChildrenAreAllButtons(children);
41
- const buttonRowCount = buttonRowGrid
42
- ? countRenderableChildren(children)
43
- : 0;
45
+ const allChildrenAreButtons = childrenAreAllButtons(children);
44
46
  const columnsRaw = props.columns;
47
+ const equalWidth = props.equalWidth === true;
45
48
  const columns = typeof columnsRaw === "number" &&
46
49
  columnsRaw >= 2 &&
47
50
  columnsRaw <= 6 &&
48
51
  Number.isInteger(columnsRaw)
49
52
  ? columnsRaw
50
53
  : undefined;
51
- // Horizontal default depends on column count: 2→lg, 3→md, 4+→sm. Vertical stays md.
52
- // Count comes from explicit `columns`, then button-row inference, else direct children
53
- // count (any horizontal stack is N columns wide regardless of child types).
54
- const horizontalColumnCount = isHorizontal
55
- ? (columns ??
56
- (buttonRowGrid ? buttonRowCount : undefined) ??
57
- countRenderableChildren(children))
54
+ const equalWidthColumnCount = columns ?? (equalWidth ? countRenderableChildren(children) : undefined);
55
+ const explicitEqualWidth = isHorizontal &&
56
+ equalWidthColumnCount !== undefined &&
57
+ equalWidthColumnCount >= 1 &&
58
+ equalWidthColumnCount <= 6;
59
+ // Button-only stacks always default to sm; mixed horizontal stacks scale by child count.
60
+ // Vertical non-button stacks default to md.
61
+ const horizontalChildCount = isHorizontal
62
+ ? (explicitEqualWidth
63
+ ? equalWidthColumnCount
64
+ : countRenderableChildren(children))
58
65
  : undefined;
59
66
  const explicitGap = typeof props.gap === "string" && props.gap in (isHorizontal ? HGAP : VGAP);
60
67
  const gapKey = explicitGap
61
68
  ? String(props.gap)
62
- : isHorizontal
63
- ? defaultHorizontalGapSize(horizontalColumnCount)
64
- : "md";
69
+ : allChildrenAreButtons
70
+ ? "sm"
71
+ : isHorizontal
72
+ ? defaultHorizontalGapSize(horizontalChildCount)
73
+ : "md";
65
74
  const gap = isHorizontal
66
75
  ? (HGAP[gapKey] ?? HGAP.md)
67
76
  : (VGAP[gapKey] ?? VGAP.md);
68
- const explicitColumnGrid = isHorizontal && columns !== undefined && !buttonRowGrid;
69
- const columnGridClass = explicitColumnGrid && columns !== undefined
70
- ? COLUMN_GRID_CLASS[columns]
77
+ const columnGridClass = explicitEqualWidth && equalWidthColumnCount !== undefined
78
+ ? COLUMN_GRID_CLASS[equalWidthColumnCount]
71
79
  : undefined;
72
80
  /**
73
81
  * Row peers under a horizontal stack must shrink and share width (`flex-1` + `min-w-0`).
@@ -79,17 +87,18 @@ export function SnapStack({ element: { props }, children, }) {
79
87
  ? "min-w-0 flex-1 basis-0 max-w-full"
80
88
  : "w-full min-w-0";
81
89
  const justifyBlockGrid = justifyFlex &&
82
- (!isHorizontal || (!buttonRowGrid && !explicitColumnGrid));
90
+ (!isHorizontal || !explicitEqualWidth);
83
91
  /** Single flex row (nowrap): peers stay side-by-side and shrink via min-w-0 / flex-1 on nested stacks. */
84
92
  const horizontalFlexClasses = "flex min-w-0 flex-row flex-nowrap items-stretch [&>*]:min-w-0";
93
+ const equalWidthStyle = explicitEqualWidth && equalWidthColumnCount !== undefined
94
+ ? {
95
+ display: "grid",
96
+ gridTemplateColumns: `repeat(${equalWidthColumnCount}, minmax(0, 1fr))`,
97
+ }
98
+ : undefined;
85
99
  return (_jsx(SnapStackDirectionProvider, { direction: isHorizontal ? "horizontal" : "vertical", children: _jsx("div", { className: cn(rootWidthClass, isHorizontal
86
- ? buttonRowGrid &&
87
- buttonRowCount >= 1 &&
88
- buttonRowCount <= 6 &&
89
- COLUMN_GRID_CLASS[buttonRowCount]
90
- ? cn(COLUMN_GRID_CLASS[buttonRowCount], gap, "[&>*]:w-full")
91
- : explicitColumnGrid && columnGridClass
92
- ? cn(columnGridClass, gap)
93
- : cn(horizontalFlexClasses, gap, justifyBlockGrid ? justifyFlex : undefined)
94
- : cn("flex min-w-0 w-full flex-col", gap, justifyFlex)), children: children }) }));
100
+ ? explicitEqualWidth && columnGridClass
101
+ ? cn(columnGridClass, gap)
102
+ : cn(horizontalFlexClasses, gap, justifyBlockGrid ? justifyFlex : undefined)
103
+ : cn("flex min-w-0 w-full flex-col", gap, justifyFlex)), style: equalWidthStyle, children: children }) }));
95
104
  }
@@ -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.js";
8
9
  export function SnapToggleGroup({ element: { props }, }) {
9
10
  const { get, set } = useStateStore();
@@ -12,7 +13,6 @@ export function SnapToggleGroup({ element: { props }, }) {
12
13
  const path = `/inputs/${name}`;
13
14
  const label = props.label ? String(props.label) : undefined;
14
15
  const isMultiple = Boolean(props.multiple);
15
- const orientation = String(props.orientation ?? "horizontal");
16
16
  const options = Array.isArray(props.options)
17
17
  ? props.options
18
18
  : [];
@@ -43,12 +43,14 @@ export function SnapToggleGroup({ element: { props }, }) {
43
43
  set(path, opt);
44
44
  }
45
45
  };
46
- const isVertical = orientation === "vertical";
46
+ const isVertical = !shouldUseHorizontalButtonContent(options);
47
47
  const [hoveredIdx, setHoveredIdx] = useState(null);
48
48
  return (_jsxs("div", { className: "w-full space-y-1.5", children: [label && _jsx(Label, { style: { color: colors.text }, children: label }), _jsx("div", { className: cn("flex gap-1 rounded-lg p-1", isVertical ? "flex-col" : "flex-row"), style: { backgroundColor: colors.muted }, children: options.map((opt, i) => {
49
49
  const isSelected = selected.includes(opt);
50
50
  const isHovered = hoveredIdx === i && !isSelected;
51
- return (_jsx("button", { type: "button", onClick: () => toggle(opt), onPointerEnter: () => setHoveredIdx(i), onPointerLeave: () => setHoveredIdx(null), className: cn("rounded-md px-3 py-2 text-sm font-medium transition-colors", isVertical ? "w-full" : "flex-1"), style: {
51
+ return (_jsx("button", { type: "button", onClick: () => toggle(opt), onPointerEnter: () => setHoveredIdx(i), onPointerLeave: () => setHoveredIdx(null), className: cn("rounded-md px-3 py-2 text-sm font-medium transition-colors", isVertical
52
+ ? "w-full"
53
+ : "flex-auto whitespace-nowrap"), style: {
52
54
  transition: "background-color 0.15s, color 0.15s",
53
55
  ...(isSelected
54
56
  ? {
@@ -61,6 +63,6 @@ export function SnapToggleGroup({ element: { props }, }) {
61
63
  ? (colors.mode === "dark" ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)")
62
64
  : (colors.mode === "dark" ? "rgba(255,255,255,0.02)" : "rgba(0,0,0,0.02)"),
63
65
  }),
64
- }, children: opt }, opt));
66
+ }, children: opt }, `${opt}-${i}`));
65
67
  }) })] }));
66
68
  }
@@ -0,0 +1,2 @@
1
+ export declare const SnapItemGroupBorderProvider: any;
2
+ export declare function useSnapItemGroupHasBorder(): any;
@@ -0,0 +1,6 @@
1
+ import { createContext, useContext } from "react";
2
+ const SnapItemGroupBorderContext = createContext(false);
3
+ export const SnapItemGroupBorderProvider = SnapItemGroupBorderContext.Provider;
4
+ export function useSnapItemGroupHasBorder() {
5
+ return useContext(SnapItemGroupBorderContext);
6
+ }
@@ -3,6 +3,7 @@ import { Pressable, StyleSheet, Text, View } from "react-native";
3
3
  import { ExternalLink } from "lucide-react-native";
4
4
  import { useSnapPalette } from "../use-snap-palette.js";
5
5
  import { useSnapTheme } from "../theme.js";
6
+ import { useSnapStackDirection } from "../stack-direction-context.js";
6
7
  import { ICON_MAP } from "./snap-icon.js";
7
8
  function isExternalLinkAction(on) {
8
9
  if (!on)
@@ -22,9 +23,10 @@ export function SnapActionButton({ element, emit, }) {
22
23
  const iconName = props.icon ? String(props.icon) : undefined;
23
24
  const textColor = isPrimary ? "#fff" : colors.text;
24
25
  const iconColor = isPrimary ? "#fff" : colors.text;
26
+ const inHorizontalStack = useSnapStackDirection() === "horizontal";
25
27
  const on = element.on;
26
28
  const showExternalIcon = isExternalLinkAction(on);
27
- return (_jsx(View, { style: styles.outer, children: _jsxs(Pressable, { style: ({ pressed }) => [
29
+ return (_jsx(View, { style: inHorizontalStack ? styles.outerHorizontal : styles.outer, children: _jsxs(Pressable, { style: ({ pressed }) => [
28
30
  styles.btn,
29
31
  isPrimary ? styles.btnDefault : styles.btnOther,
30
32
  isPrimary
@@ -50,7 +52,18 @@ export function SnapActionButton({ element, emit, }) {
50
52
  : null, _jsx(Text, { style: { color: textColor, fontSize: 14, lineHeight: 18, fontWeight: "600" }, children: label }), showExternalIcon ? (_jsx(ExternalLink, { size: 14, color: iconColor, style: { opacity: 0.6 } })) : null] }) }));
51
53
  }
52
54
  const styles = StyleSheet.create({
53
- outer: { minWidth: 0 },
55
+ outer: {
56
+ width: "100%",
57
+ minWidth: 0,
58
+ alignSelf: "stretch",
59
+ },
60
+ outerHorizontal: {
61
+ flexGrow: 1,
62
+ flexShrink: 1,
63
+ flexBasis: "auto",
64
+ minWidth: 0,
65
+ alignSelf: "stretch",
66
+ },
54
67
  btn: {
55
68
  paddingHorizontal: 16,
56
69
  borderRadius: 10,
@@ -14,6 +14,7 @@ export function SnapCellGrid({ element, emit, }) {
14
14
  const rows = Number(props.rows ?? 2);
15
15
  const cells = Array.isArray(props.cells) ? props.cells : [];
16
16
  const rowHeight = typeof props.rowHeight === "number" ? props.rowHeight : 28;
17
+ const squareCells = props.cellAspectRatio === "square";
17
18
  const gap = String(props.gap ?? "sm");
18
19
  const gapMap = { none: 0, sm: 1, md: 2, lg: 4 };
19
20
  const gapPx = gapMap[gap] ?? 1;
@@ -80,15 +81,36 @@ export function SnapCellGrid({ element, emit, }) {
80
81
  const textColor = bgHex ? readableTextOnHex(bgHex) : colors.text;
81
82
  const cellContent = cell?.content ? (_jsx(Text, { style: [styles.cellText, { color: textColor }], children: cell.content })) : null;
82
83
  // Two-tone ring: outer View with contrasting border, inner View with inverse border
83
- const cellView = selected ? (_jsx(View, { style: [styles.cell, { height: rowHeight, borderWidth: 1, borderColor: ringOuter, borderRadius: 4 }], children: _jsx(View, { style: [
84
+ const cellView = selected ? (_jsx(View, { style: [
85
+ styles.cell,
86
+ squareCells ? styles.squareCell : { height: rowHeight },
87
+ { borderWidth: 1, borderColor: ringOuter, borderRadius: 4 },
88
+ ], children: _jsx(View, { style: [
84
89
  styles.innerCell,
85
- { backgroundColor: bg, borderWidth: 1, borderColor: ringInner, borderRadius: 3 },
86
- ], children: cellContent }) })) : (_jsx(View, { style: [styles.cell, { height: rowHeight, backgroundColor: bg }], children: cellContent }));
90
+ {
91
+ backgroundColor: bg,
92
+ borderWidth: 1,
93
+ borderColor: ringInner,
94
+ borderRadius: 3,
95
+ },
96
+ ], children: cellContent }) })) : (_jsx(View, { style: [
97
+ styles.cell,
98
+ squareCells ? styles.squareCell : { height: rowHeight },
99
+ { backgroundColor: bg },
100
+ ], children: cellContent }));
87
101
  rowCells.push(interactive ? (_jsx(Pressable, { onPress: () => handleTap(r, c), style: styles.cellWrap, children: cellView }, `${r}-${c}`)) : (_jsx(View, { style: styles.cellWrap, children: cellView }, `${r}-${c}`)));
88
102
  }
89
103
  rowEls.push(_jsx(View, { style: [styles.gridRow, { gap: gapPx }], children: rowCells }, r));
90
104
  }
91
- return (_jsx(View, { style: [styles.wrap, { gap: gapPx, backgroundColor: colors.muted, padding: 4, borderRadius: 8 }], children: rowEls }));
105
+ return (_jsx(View, { style: [
106
+ styles.wrap,
107
+ {
108
+ gap: gapPx,
109
+ backgroundColor: colors.muted,
110
+ padding: 4,
111
+ borderRadius: 8,
112
+ },
113
+ ], children: rowEls }));
92
114
  }
93
115
  const styles = StyleSheet.create({
94
116
  wrap: { width: "100%" },
@@ -99,6 +121,10 @@ const styles = StyleSheet.create({
99
121
  alignItems: "center",
100
122
  justifyContent: "center",
101
123
  },
124
+ squareCell: {
125
+ aspectRatio: 1,
126
+ width: "100%",
127
+ },
102
128
  innerCell: {
103
129
  width: "100%",
104
130
  height: "100%",
@@ -2,18 +2,28 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Children, Fragment } from "react";
3
3
  import { StyleSheet, View } from "react-native";
4
4
  import { useSnapTheme } from "../theme.js";
5
- const GAP_MAP = { none: 0, sm: 4, md: 8, lg: 12 };
5
+ import { SnapItemGroupBorderProvider } from "./item-layout-context.js";
6
+ const GAP_MAP = {
7
+ none: 0,
8
+ sm: 4,
9
+ md: 8,
10
+ lg: 12,
11
+ };
6
12
  export function SnapItemGroup({ element: { props }, children, }) {
7
13
  const { colors } = useSnapTheme();
8
14
  const border = Boolean(props.border);
9
15
  const separator = Boolean(props.separator);
10
16
  const gap = GAP_MAP[String(props.gap ?? "sm")] ?? 4;
11
17
  const items = Children.toArray(children);
12
- return (_jsx(View, { style: [
13
- styles.group,
14
- border && { borderWidth: 1, borderColor: colors.border, borderRadius: 12 },
15
- { gap },
16
- ], children: items.map((child, i) => (_jsxs(Fragment, { children: [separator && i > 0 && (_jsx(View, { style: { height: 1, backgroundColor: colors.border + "80" } })), child] }, i))) }));
18
+ return (_jsx(SnapItemGroupBorderProvider, { value: border, children: _jsx(View, { style: [
19
+ styles.group,
20
+ border && {
21
+ borderWidth: 1,
22
+ borderColor: colors.border,
23
+ borderRadius: 12,
24
+ },
25
+ { gap },
26
+ ], children: items.map((child, i) => (_jsxs(Fragment, { children: [separator && i > 0 && (_jsx(View, { style: { height: 1, backgroundColor: colors.border + "80" } })), child] }, i))) }) }));
17
27
  }
18
28
  const styles = StyleSheet.create({
19
29
  group: {
@@ -1,21 +1,51 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Image } from "expo-image";
2
3
  import { StyleSheet, Text, View } from "react-native";
3
4
  import { useSnapStackDirection } from "../stack-direction-context.js";
4
5
  import { useSnapTheme } from "../theme.js";
6
+ import { useSnapPalette } from "../use-snap-palette.js";
7
+ import { useSnapItemGroupHasBorder } from "./item-layout-context.js";
8
+ import { ICON_MAP } from "./snap-icon.js";
9
+ function parseItemMedia(value) {
10
+ if (!value || typeof value !== "object")
11
+ return undefined;
12
+ const media = value;
13
+ if (media.variant === "icon" && typeof media.name === "string") {
14
+ return {
15
+ variant: "icon",
16
+ name: media.name,
17
+ color: typeof media.color === "string" ? media.color : undefined,
18
+ };
19
+ }
20
+ if (media.variant === "image" && typeof media.url === "string") {
21
+ return {
22
+ variant: "image",
23
+ url: media.url,
24
+ alt: typeof media.alt === "string" ? media.alt : undefined,
25
+ round: typeof media.round === "boolean" ? media.round : undefined,
26
+ };
27
+ }
28
+ return undefined;
29
+ }
5
30
  export function SnapItem({ element: { props }, children, }) {
6
31
  const { colors } = useSnapTheme();
32
+ const { accentHex, hex } = useSnapPalette();
7
33
  const title = String(props.title ?? "");
8
- const description = props.description
9
- ? String(props.description)
10
- : undefined;
34
+ const description = props.description ? String(props.description) : undefined;
35
+ const media = parseItemMedia(props.media);
36
+ const inBorderedGroup = useSnapItemGroupHasBorder();
11
37
  /** Match web `Item className="flex-1"`: row peers must share width or title/description collapse. */
12
38
  const rowPeer = useSnapStackDirection() === "horizontal";
13
- const containerVariant = { paddingVertical: 6, paddingHorizontal: 10 };
14
- return (_jsxs(View, { style: [
15
- styles.container,
16
- containerVariant,
17
- rowPeer && styles.rowPeer,
18
- ], children: [_jsxs(View, { style: styles.content, children: [title ? _jsx(Text, { style: [styles.title, { color: colors.text }], children: title }) : null, description ? (_jsx(Text, { style: [styles.description, { color: colors.textSecondary }], children: description })) : null] }), children ? (_jsx(View, { style: styles.actions, children: _jsx(View, { style: { flex: 0 }, children: children }) })) : null] }));
39
+ const MediaIcon = media?.variant === "icon" ? ICON_MAP[media.name] : undefined;
40
+ const mediaColor = media?.variant === "icon" && media.color && media.color !== "accent"
41
+ ? hex(media.color)
42
+ : accentHex;
43
+ const containerVariant = {
44
+ paddingVertical: 6,
45
+ paddingHorizontal: inBorderedGroup ? 8 : 0,
46
+ columnGap: 8,
47
+ };
48
+ return (_jsxs(View, { style: [styles.container, containerVariant, rowPeer && styles.rowPeer], children: [media?.variant === "icon" && MediaIcon ? (_jsx(View, { style: styles.iconMedia, children: _jsx(MediaIcon, { size: 20, color: mediaColor }) })) : null, media?.variant === "image" ? (_jsx(View, { style: [styles.imageMedia, media.round && styles.roundImage], children: _jsx(Image, { source: { uri: media.url }, style: StyleSheet.absoluteFill, contentFit: "cover", accessibilityLabel: media.alt || undefined }) })) : null, _jsxs(View, { style: styles.content, children: [title ? (_jsx(Text, { style: [styles.title, { color: colors.text }], children: title })) : null, description ? (_jsx(Text, { style: [styles.description, { color: colors.textSecondary }], children: description })) : null] }), children ? (_jsx(View, { style: styles.actions, children: _jsx(View, { style: { flex: 0 }, children: children }) })) : null] }));
19
49
  }
20
50
  const styles = StyleSheet.create({
21
51
  container: {
@@ -29,19 +59,32 @@ const styles = StyleSheet.create({
29
59
  content: {
30
60
  flex: 1,
31
61
  },
62
+ iconMedia: {
63
+ alignItems: "center",
64
+ justifyContent: "center",
65
+ },
66
+ imageMedia: {
67
+ width: 40,
68
+ height: 40,
69
+ borderRadius: 6,
70
+ overflow: "hidden",
71
+ backgroundColor: "#f3f4f6",
72
+ },
73
+ roundImage: {
74
+ borderRadius: 9999,
75
+ },
32
76
  title: {
33
77
  fontSize: 15,
34
78
  lineHeight: 20,
35
79
  fontWeight: "500",
36
80
  },
37
81
  description: {
38
- fontSize: 13,
39
- lineHeight: 18,
40
- marginTop: 1,
82
+ fontSize: 12,
83
+ lineHeight: 16,
41
84
  },
42
85
  actions: {
43
86
  marginLeft: "auto",
44
- paddingLeft: 12,
87
+ paddingLeft: 8,
45
88
  flexDirection: "row",
46
89
  alignItems: "center",
47
90
  flexShrink: 0,