@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.
- package/dist/button-orientation-utils.d.ts +3 -0
- package/dist/button-orientation-utils.js +25 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/react/components/action-button.js +5 -5
- package/dist/react/components/cell-grid.js +6 -2
- package/dist/react/components/item-group.js +2 -1
- package/dist/react/components/item-layout-context.d.ts +2 -0
- package/dist/react/components/item-layout-context.js +7 -0
- package/dist/react/components/item.js +40 -3
- package/dist/react/components/stack.js +46 -37
- package/dist/react/components/toggle-group.js +6 -4
- package/dist/react-native/components/item-layout-context.d.ts +2 -0
- package/dist/react-native/components/item-layout-context.js +6 -0
- package/dist/react-native/components/snap-action-button.js +15 -2
- package/dist/react-native/components/snap-cell-grid.js +30 -4
- package/dist/react-native/components/snap-item-group.js +16 -6
- package/dist/react-native/components/snap-item.js +56 -13
- package/dist/react-native/components/snap-stack.js +32 -33
- package/dist/react-native/components/snap-toggle-group.js +5 -4
- package/dist/stack-horizontal-utils.d.ts +7 -5
- package/dist/stack-horizontal-utils.js +24 -14
- package/dist/ui/catalog.d.ts +60 -1
- package/dist/ui/catalog.js +3 -3
- package/dist/ui/cell-grid.d.ts +4 -0
- package/dist/ui/cell-grid.js +3 -4
- package/dist/ui/index.d.ts +2 -2
- package/dist/ui/index.js +1 -1
- package/dist/ui/item.d.ts +112 -1
- package/dist/ui/item.js +28 -2
- package/dist/ui/stack.d.ts +1 -0
- package/dist/ui/stack.js +3 -1
- package/dist/validator.js +19 -1
- package/llms.txt +3 -1
- package/package.json +1 -1
- package/src/button-orientation-utils.ts +36 -0
- package/src/constants.ts +1 -0
- package/src/react/components/action-button.tsx +5 -4
- package/src/react/components/cell-grid.tsx +6 -2
- package/src/react/components/item-group.tsx +19 -17
- package/src/react/components/item-layout-context.tsx +12 -0
- package/src/react/components/item.tsx +97 -4
- package/src/react/components/stack.tsx +51 -40
- package/src/react/components/toggle-group.tsx +6 -4
- package/src/react-native/components/item-layout-context.tsx +10 -0
- package/src/react-native/components/snap-action-button.tsx +15 -2
- package/src/react-native/components/snap-cell-grid.tsx +36 -4
- package/src/react-native/components/snap-item-group.tsx +31 -17
- package/src/react-native/components/snap-item.tsx +92 -14
- package/src/react-native/components/snap-stack.tsx +37 -36
- package/src/react-native/components/snap-toggle-group.tsx +5 -4
- package/src/stack-horizontal-utils.ts +32 -13
- package/src/ui/catalog.ts +5 -4
- package/src/ui/cell-grid.ts +5 -5
- package/src/ui/index.ts +2 -2
- package/src/ui/item.ts +35 -5
- package/src/ui/stack.ts +3 -1
- package/src/validator.ts +29 -1
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
|
|
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
|
@@ -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-
|
|
64
|
-
* In a vertical stack,
|
|
65
|
-
*
|
|
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
|
-
? "
|
|
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}`;
|
|
@@ -78,7 +79,9 @@ export function SnapCellGrid({
|
|
|
78
79
|
|
|
79
80
|
/** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
|
|
80
81
|
const emptyCellBg =
|
|
81
|
-
colors.mode === "dark"
|
|
82
|
+
colors.mode === "dark"
|
|
83
|
+
? "rgba(255, 255, 255, 0.05)"
|
|
84
|
+
: "rgba(0, 0, 0, 0.05)";
|
|
82
85
|
|
|
83
86
|
const cellEls: ReactNode[] = [];
|
|
84
87
|
for (let r = 0; r < rows; r++) {
|
|
@@ -110,7 +113,8 @@ export function SnapCellGrid({
|
|
|
110
113
|
interactive ? "cursor-pointer select-none" : "cursor-default",
|
|
111
114
|
)}
|
|
112
115
|
style={{
|
|
113
|
-
height: rowHeight,
|
|
116
|
+
height: squareCells ? undefined : rowHeight,
|
|
117
|
+
aspectRatio: squareCells ? "1 / 1" : undefined,
|
|
114
118
|
background: bg,
|
|
115
119
|
color: textColor,
|
|
116
120
|
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
|
-
<
|
|
29
|
-
|
|
30
|
-
"flex flex-col",
|
|
31
|
-
border
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
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-
|
|
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
|
|
38
|
+
/** Equal-width cell count for explicit `equalWidth` / `columns` props. */
|
|
38
39
|
const COLUMN_GRID_CLASS: Record<number, string> = {
|
|
39
|
-
1: "
|
|
40
|
-
2: "
|
|
41
|
-
3: "
|
|
42
|
-
4: "
|
|
43
|
-
5: "
|
|
44
|
-
6: "
|
|
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
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
:
|
|
87
|
-
?
|
|
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
|
-
|
|
96
|
-
? COLUMN_GRID_CLASS[
|
|
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 ||
|
|
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
|
-
?
|
|
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 =
|
|
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
|
|
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: {
|
|
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,
|
|
@@ -18,6 +18,7 @@ export function SnapCellGrid({
|
|
|
18
18
|
const rows = Number(props.rows ?? 2);
|
|
19
19
|
const cells = Array.isArray(props.cells) ? props.cells : [];
|
|
20
20
|
const rowHeight = typeof props.rowHeight === "number" ? props.rowHeight : 28;
|
|
21
|
+
const squareCells = props.cellAspectRatio === "square";
|
|
21
22
|
const gap = String(props.gap ?? "sm");
|
|
22
23
|
const gapMap: Record<string, number> = { none: 0, sm: 1, md: 2, lg: 4 };
|
|
23
24
|
const gapPx = gapMap[gap] ?? 1;
|
|
@@ -101,18 +102,35 @@ export function SnapCellGrid({
|
|
|
101
102
|
|
|
102
103
|
// Two-tone ring: outer View with contrasting border, inner View with inverse border
|
|
103
104
|
const cellView = selected ? (
|
|
104
|
-
<View
|
|
105
|
+
<View
|
|
106
|
+
style={[
|
|
107
|
+
styles.cell,
|
|
108
|
+
squareCells ? styles.squareCell : { height: rowHeight },
|
|
109
|
+
{ borderWidth: 1, borderColor: ringOuter, borderRadius: 4 },
|
|
110
|
+
]}
|
|
111
|
+
>
|
|
105
112
|
<View
|
|
106
113
|
style={[
|
|
107
114
|
styles.innerCell,
|
|
108
|
-
{
|
|
115
|
+
{
|
|
116
|
+
backgroundColor: bg,
|
|
117
|
+
borderWidth: 1,
|
|
118
|
+
borderColor: ringInner,
|
|
119
|
+
borderRadius: 3,
|
|
120
|
+
},
|
|
109
121
|
]}
|
|
110
122
|
>
|
|
111
123
|
{cellContent}
|
|
112
124
|
</View>
|
|
113
125
|
</View>
|
|
114
126
|
) : (
|
|
115
|
-
<View
|
|
127
|
+
<View
|
|
128
|
+
style={[
|
|
129
|
+
styles.cell,
|
|
130
|
+
squareCells ? styles.squareCell : { height: rowHeight },
|
|
131
|
+
{ backgroundColor: bg },
|
|
132
|
+
]}
|
|
133
|
+
>
|
|
116
134
|
{cellContent}
|
|
117
135
|
</View>
|
|
118
136
|
);
|
|
@@ -141,7 +159,17 @@ export function SnapCellGrid({
|
|
|
141
159
|
}
|
|
142
160
|
|
|
143
161
|
return (
|
|
144
|
-
<View
|
|
162
|
+
<View
|
|
163
|
+
style={[
|
|
164
|
+
styles.wrap,
|
|
165
|
+
{
|
|
166
|
+
gap: gapPx,
|
|
167
|
+
backgroundColor: colors.muted,
|
|
168
|
+
padding: 4,
|
|
169
|
+
borderRadius: 8,
|
|
170
|
+
},
|
|
171
|
+
]}
|
|
172
|
+
>
|
|
145
173
|
{rowEls}
|
|
146
174
|
</View>
|
|
147
175
|
);
|
|
@@ -156,6 +184,10 @@ const styles = StyleSheet.create({
|
|
|
156
184
|
alignItems: "center",
|
|
157
185
|
justifyContent: "center",
|
|
158
186
|
},
|
|
187
|
+
squareCell: {
|
|
188
|
+
aspectRatio: 1,
|
|
189
|
+
width: "100%",
|
|
190
|
+
},
|
|
159
191
|
innerCell: {
|
|
160
192
|
width: "100%",
|
|
161
193
|
height: "100%",
|