@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.
- package/dist/constants.d.ts +2 -1
- package/dist/constants.js +2 -1
- package/dist/react/components/cell-grid.js +13 -14
- package/dist/react/components/image.js +5 -1
- package/dist/react/components/stack.js +53 -3
- package/dist/react/components/text.js +7 -1
- package/dist/react/stack-direction-context.d.ts +7 -0
- package/dist/react/stack-direction-context.js +10 -0
- package/dist/react-native/components/snap-cell-grid.js +5 -7
- package/dist/react-native/components/snap-image.js +15 -2
- package/dist/react-native/components/snap-item.js +12 -2
- package/dist/react-native/components/snap-progress.js +8 -2
- package/dist/react-native/components/snap-stack.d.ts +1 -1
- package/dist/react-native/components/snap-stack.js +85 -10
- package/dist/react-native/components/snap-text.js +7 -2
- package/dist/react-native/stack-direction-context.d.ts +7 -0
- package/dist/react-native/stack-direction-context.js +9 -0
- package/dist/stack-horizontal-utils.d.ts +4 -0
- package/dist/stack-horizontal-utils.js +29 -0
- package/dist/ui/catalog.d.ts +1 -0
- package/dist/ui/catalog.js +1 -1
- package/dist/ui/stack.d.ts +1 -0
- package/dist/ui/stack.js +8 -0
- package/llms.txt +2 -1
- package/package.json +1 -1
- package/src/constants.ts +2 -1
- package/src/react/components/cell-grid.tsx +17 -24
- package/src/react/components/image.tsx +8 -1
- package/src/react/components/stack.tsx +84 -11
- package/src/react/components/text.tsx +8 -1
- package/src/react/stack-direction-context.tsx +27 -0
- package/src/react-native/components/snap-cell-grid.tsx +5 -11
- package/src/react-native/components/snap-image.tsx +17 -2
- package/src/react-native/components/snap-item.tsx +14 -2
- package/src/react-native/components/snap-progress.tsx +8 -2
- package/src/react-native/components/snap-stack.tsx +116 -14
- package/src/react-native/components/snap-text.tsx +7 -2
- package/src/react-native/stack-direction-context.tsx +25 -0
- package/src/stack-horizontal-utils.ts +27 -0
- package/src/ui/catalog.ts +1 -1
- 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
|
-
|
|
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) :
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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=
|
|
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
|
|
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
|
|
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
|
-
<
|
|
45
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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=
|
|
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) :
|
|
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
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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
|
|
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
|
|
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
|
-
<
|
|
45
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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: "
|
|
64
|
-
flexWrap: "
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|