@farcaster/snap 2.5.1 → 2.6.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.
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/react/catalog-renderer.js +2 -0
- package/dist/react/components/action-button.js +9 -1
- package/dist/react/components/cell-grid.js +16 -2
- package/dist/react/components/image.js +5 -2
- package/dist/react/components/paginator.d.ts +7 -0
- package/dist/react/components/paginator.js +103 -0
- package/dist/react/components/stack.js +21 -18
- package/dist/react/components/text.js +13 -1
- package/dist/react/snap-version-context.d.ts +3 -0
- package/dist/react/snap-version-context.js +7 -0
- package/dist/react/snap-view-core.d.ts +1 -1
- package/dist/react/snap-view-core.js +27 -4
- package/dist/react-native/catalog-renderer.js +2 -0
- package/dist/react-native/components/snap-action-button.js +8 -2
- package/dist/react-native/components/snap-cell-grid.js +16 -2
- package/dist/react-native/components/snap-image.js +29 -4
- package/dist/react-native/components/snap-paginator.d.ts +5 -0
- package/dist/react-native/components/snap-paginator.js +194 -0
- package/dist/react-native/components/snap-text.js +4 -3
- package/dist/react-native/expand-state.d.ts +19 -0
- package/dist/react-native/expand-state.js +18 -0
- package/dist/react-native/index.d.ts +7 -1
- package/dist/react-native/index.js +3 -3
- package/dist/react-native/snap-version-context.d.ts +3 -0
- package/dist/react-native/snap-version-context.js +6 -0
- package/dist/react-native/snap-view-core.d.ts +1 -1
- package/dist/react-native/snap-view-core.js +27 -4
- package/dist/react-native/v1/snap-view.d.ts +7 -1
- package/dist/react-native/v1/snap-view.js +35 -11
- package/dist/react-native/v2/snap-view.d.ts +7 -1
- package/dist/react-native/v2/snap-view.js +60 -17
- package/dist/ui/catalog.d.ts +45 -0
- package/dist/ui/catalog.js +20 -3
- package/dist/ui/cell-grid.d.ts +5 -0
- package/dist/ui/cell-grid.js +2 -1
- package/dist/ui/image.d.ts +4 -1
- package/dist/ui/image.js +3 -1
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- package/dist/ui/paginator-state.d.ts +18 -0
- package/dist/ui/paginator-state.js +47 -0
- package/dist/ui/paginator.d.ts +17 -0
- package/dist/ui/paginator.js +8 -0
- package/dist/ui/text.d.ts +1 -0
- package/dist/ui/text.js +1 -0
- package/dist/validator.js +16 -3
- package/llms.txt +19 -4
- package/package.json +1 -1
- package/src/constants.ts +1 -0
- package/src/react/catalog-renderer.tsx +2 -0
- package/src/react/components/action-button.tsx +13 -2
- package/src/react/components/cell-grid.tsx +22 -2
- package/src/react/components/image.tsx +17 -0
- package/src/react/components/paginator.tsx +208 -0
- package/src/react/components/stack.tsx +20 -18
- package/src/react/components/text.tsx +13 -1
- package/src/react/snap-version-context.tsx +12 -0
- package/src/react/snap-view-core.tsx +44 -12
- package/src/react-native/catalog-renderer.tsx +2 -0
- package/src/react-native/components/snap-action-button.tsx +10 -2
- package/src/react-native/components/snap-cell-grid.tsx +22 -2
- package/src/react-native/components/snap-image.tsx +40 -1
- package/src/react-native/components/snap-paginator.tsx +283 -0
- package/src/react-native/components/snap-text.tsx +4 -2
- package/src/react-native/expand-state.ts +48 -0
- package/src/react-native/index.tsx +15 -0
- package/src/react-native/snap-version-context.tsx +10 -0
- package/src/react-native/snap-view-core.tsx +47 -12
- package/src/react-native/v1/snap-view.tsx +57 -10
- package/src/react-native/v2/snap-view.tsx +88 -17
- package/src/ui/catalog.ts +25 -3
- package/src/ui/cell-grid.ts +2 -0
- package/src/ui/image.ts +3 -1
- package/src/ui/index.ts +3 -0
- package/src/ui/paginator-state.ts +67 -0
- package/src/ui/paginator.ts +11 -0
- package/src/ui/text.ts +1 -0
- package/src/validator.ts +19 -3
|
@@ -7,8 +7,7 @@ import {
|
|
|
7
7
|
resolveAccentHex,
|
|
8
8
|
} from "../snap-view-core";
|
|
9
9
|
import type { SnapPage, SnapActionHandlers } from "../types";
|
|
10
|
-
|
|
11
|
-
const SNAP_MAX_HEIGHT = 500;
|
|
10
|
+
import { getSnapExpansionState } from "../expand-state";
|
|
12
11
|
|
|
13
12
|
// ─── SnapViewV1 (no validation) ──────────────────────
|
|
14
13
|
|
|
@@ -72,6 +71,9 @@ function SnapCardV1Inner({
|
|
|
72
71
|
appearance,
|
|
73
72
|
plain,
|
|
74
73
|
loadingOverlay,
|
|
74
|
+
forceExpanded,
|
|
75
|
+
expandButtonLabel,
|
|
76
|
+
onExpandPress,
|
|
75
77
|
}: {
|
|
76
78
|
snap: SnapPage;
|
|
77
79
|
handlers: SnapActionHandlers;
|
|
@@ -81,6 +83,9 @@ function SnapCardV1Inner({
|
|
|
81
83
|
appearance: "light" | "dark";
|
|
82
84
|
plain: boolean;
|
|
83
85
|
loadingOverlay?: ReactNode;
|
|
86
|
+
forceExpanded?: boolean;
|
|
87
|
+
expandButtonLabel?: string;
|
|
88
|
+
onExpandPress?: () => void;
|
|
84
89
|
}) {
|
|
85
90
|
const { colors, mode } = useSnapTheme();
|
|
86
91
|
const accentHex = resolveAccentHex(snap.theme?.accent, mode);
|
|
@@ -92,8 +97,14 @@ function SnapCardV1Inner({
|
|
|
92
97
|
setContentHeight(0);
|
|
93
98
|
}, [snap]);
|
|
94
99
|
|
|
95
|
-
const
|
|
96
|
-
|
|
100
|
+
const expansion = getSnapExpansionState({
|
|
101
|
+
contentHeight,
|
|
102
|
+
internalExpanded: isExpanded,
|
|
103
|
+
forceExpanded,
|
|
104
|
+
onExpandPress,
|
|
105
|
+
expandButtonLabel,
|
|
106
|
+
});
|
|
107
|
+
const expandButtonInsideCard = typeof onExpandPress === "function";
|
|
97
108
|
|
|
98
109
|
const isDark = mode === "dark";
|
|
99
110
|
const pillBg = isDark ? "rgba(40,40,40,0.92)" : "rgba(255,255,255,0.92)";
|
|
@@ -112,14 +123,18 @@ function SnapCardV1Inner({
|
|
|
112
123
|
]}
|
|
113
124
|
>
|
|
114
125
|
<View
|
|
115
|
-
style={
|
|
126
|
+
style={
|
|
127
|
+
expansion.clipped
|
|
128
|
+
? { maxHeight: expansion.maxHeight, overflow: "hidden" }
|
|
129
|
+
: undefined
|
|
130
|
+
}
|
|
116
131
|
>
|
|
117
132
|
<View
|
|
118
133
|
collapsable={false}
|
|
119
134
|
onLayout={(event) => {
|
|
120
135
|
const nextHeight = Math.round(event.nativeEvent.layout.height);
|
|
121
136
|
setContentHeight((currentHeight) =>
|
|
122
|
-
|
|
137
|
+
expansion.clipped
|
|
123
138
|
? Math.max(currentHeight, nextHeight)
|
|
124
139
|
: currentHeight === nextHeight
|
|
125
140
|
? currentHeight
|
|
@@ -142,8 +157,15 @@ function SnapCardV1Inner({
|
|
|
142
157
|
: loadingOverlay
|
|
143
158
|
: null}
|
|
144
159
|
</View>
|
|
145
|
-
{
|
|
146
|
-
<View
|
|
160
|
+
{expansion.showButton ? (
|
|
161
|
+
<View
|
|
162
|
+
pointerEvents="box-none"
|
|
163
|
+
style={
|
|
164
|
+
expandButtonInsideCard
|
|
165
|
+
? cardStyles.expandFloatInset
|
|
166
|
+
: cardStyles.expandFloat
|
|
167
|
+
}
|
|
168
|
+
>
|
|
147
169
|
<Pressable
|
|
148
170
|
style={({ pressed }) => [
|
|
149
171
|
cardStyles.expandButton,
|
|
@@ -153,13 +175,17 @@ function SnapCardV1Inner({
|
|
|
153
175
|
},
|
|
154
176
|
]}
|
|
155
177
|
onPress={() => {
|
|
156
|
-
|
|
178
|
+
if (expansion.useInternalToggle) {
|
|
179
|
+
setIsExpanded((value) => !value);
|
|
180
|
+
} else {
|
|
181
|
+
onExpandPress?.();
|
|
182
|
+
}
|
|
157
183
|
}}
|
|
158
184
|
>
|
|
159
185
|
<Text
|
|
160
186
|
style={[cardStyles.expandButtonText, { color: colors.text }]}
|
|
161
187
|
>
|
|
162
|
-
{
|
|
188
|
+
{expansion.buttonLabel}
|
|
163
189
|
</Text>
|
|
164
190
|
</Pressable>
|
|
165
191
|
</View>
|
|
@@ -194,6 +220,9 @@ export function SnapCardV1({
|
|
|
194
220
|
actionError,
|
|
195
221
|
plain = false,
|
|
196
222
|
loadingOverlay,
|
|
223
|
+
forceExpanded,
|
|
224
|
+
expandButtonLabel,
|
|
225
|
+
onExpandPress,
|
|
197
226
|
}: {
|
|
198
227
|
snap: SnapPage;
|
|
199
228
|
handlers: SnapActionHandlers;
|
|
@@ -205,6 +234,12 @@ export function SnapCardV1({
|
|
|
205
234
|
plain?: boolean;
|
|
206
235
|
/** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
|
|
207
236
|
loadingOverlay?: ReactNode;
|
|
237
|
+
/** When true, render full content height without 500px clipping or expand controls. */
|
|
238
|
+
forceExpanded?: boolean;
|
|
239
|
+
/** Custom label for the collapsed expand button. */
|
|
240
|
+
expandButtonLabel?: string;
|
|
241
|
+
/** Called from the collapsed expand button instead of toggling internal state. */
|
|
242
|
+
onExpandPress?: () => void;
|
|
208
243
|
}) {
|
|
209
244
|
return (
|
|
210
245
|
<SnapThemeProvider appearance={appearance} colors={colors}>
|
|
@@ -217,6 +252,9 @@ export function SnapCardV1({
|
|
|
217
252
|
appearance={appearance}
|
|
218
253
|
plain={plain}
|
|
219
254
|
loadingOverlay={loadingOverlay}
|
|
255
|
+
forceExpanded={forceExpanded}
|
|
256
|
+
expandButtonLabel={expandButtonLabel}
|
|
257
|
+
onExpandPress={onExpandPress}
|
|
220
258
|
/>
|
|
221
259
|
</SnapThemeProvider>
|
|
222
260
|
);
|
|
@@ -235,6 +273,15 @@ const cardStyles = StyleSheet.create({
|
|
|
235
273
|
alignItems: "center",
|
|
236
274
|
justifyContent: "center",
|
|
237
275
|
},
|
|
276
|
+
expandFloatInset: {
|
|
277
|
+
position: "absolute",
|
|
278
|
+
left: 0,
|
|
279
|
+
right: 0,
|
|
280
|
+
bottom: 10,
|
|
281
|
+
height: 28,
|
|
282
|
+
alignItems: "center",
|
|
283
|
+
justifyContent: "center",
|
|
284
|
+
},
|
|
238
285
|
expandButton: {
|
|
239
286
|
minWidth: 92,
|
|
240
287
|
alignItems: "center",
|
|
@@ -12,10 +12,10 @@ import {
|
|
|
12
12
|
type ValidationResult,
|
|
13
13
|
} from "@farcaster/snap";
|
|
14
14
|
import type { SnapPage, SnapActionHandlers } from "../types";
|
|
15
|
+
import { getSnapExpansionState, SNAP_MAX_HEIGHT } from "../expand-state";
|
|
15
16
|
|
|
16
17
|
// ─── Constants ───────────────────────────────────────
|
|
17
18
|
|
|
18
|
-
const SNAP_MAX_HEIGHT = 500;
|
|
19
19
|
const SNAP_WARNING_HEIGHT = 700;
|
|
20
20
|
const SHOW_MORE_OVERHANG = 14;
|
|
21
21
|
|
|
@@ -141,6 +141,9 @@ function SnapCardV2Inner({
|
|
|
141
141
|
appearance,
|
|
142
142
|
plain,
|
|
143
143
|
loadingOverlay,
|
|
144
|
+
forceExpanded,
|
|
145
|
+
expandButtonLabel,
|
|
146
|
+
onExpandPress,
|
|
144
147
|
}: {
|
|
145
148
|
snap: SnapPage;
|
|
146
149
|
handlers: SnapActionHandlers;
|
|
@@ -153,6 +156,9 @@ function SnapCardV2Inner({
|
|
|
153
156
|
appearance: "light" | "dark";
|
|
154
157
|
plain: boolean;
|
|
155
158
|
loadingOverlay?: ReactNode;
|
|
159
|
+
forceExpanded?: boolean;
|
|
160
|
+
expandButtonLabel?: string;
|
|
161
|
+
onExpandPress?: () => void;
|
|
156
162
|
}) {
|
|
157
163
|
const { colors, mode } = useSnapTheme();
|
|
158
164
|
const accentHex = resolveAccentHex(snap.theme?.accent, mode);
|
|
@@ -164,8 +170,15 @@ function SnapCardV2Inner({
|
|
|
164
170
|
setContentHeight(0);
|
|
165
171
|
}, [snap]);
|
|
166
172
|
|
|
167
|
-
const
|
|
168
|
-
|
|
173
|
+
const expansion = getSnapExpansionState({
|
|
174
|
+
contentHeight,
|
|
175
|
+
internalExpanded: isExpanded,
|
|
176
|
+
forceExpanded,
|
|
177
|
+
onExpandPress,
|
|
178
|
+
expandButtonLabel,
|
|
179
|
+
showOverflowWarning,
|
|
180
|
+
});
|
|
181
|
+
const expandButtonInsideCard = typeof onExpandPress === "function";
|
|
169
182
|
|
|
170
183
|
const content = (
|
|
171
184
|
<SnapViewV2Inner
|
|
@@ -181,13 +194,19 @@ function SnapCardV2Inner({
|
|
|
181
194
|
if (plain) {
|
|
182
195
|
return (
|
|
183
196
|
<>
|
|
184
|
-
<View
|
|
197
|
+
<View
|
|
198
|
+
style={
|
|
199
|
+
expansion.clipped
|
|
200
|
+
? { maxHeight: expansion.maxHeight, overflow: "hidden" }
|
|
201
|
+
: undefined
|
|
202
|
+
}
|
|
203
|
+
>
|
|
185
204
|
<View
|
|
186
205
|
collapsable={false}
|
|
187
206
|
onLayout={(e) => {
|
|
188
207
|
const nextHeight = Math.round(e.nativeEvent.layout.height);
|
|
189
208
|
setContentHeight((current) =>
|
|
190
|
-
|
|
209
|
+
expansion.clipped
|
|
191
210
|
? Math.max(current, nextHeight)
|
|
192
211
|
: current === nextHeight
|
|
193
212
|
? current
|
|
@@ -203,7 +222,7 @@ function SnapCardV2Inner({
|
|
|
203
222
|
? <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
|
|
204
223
|
: loadingOverlay
|
|
205
224
|
: null}
|
|
206
|
-
{
|
|
225
|
+
{expansion.showButton ? (
|
|
207
226
|
<View style={[cardStyles.expandRow, cardStyles.expandRowPlain]}>
|
|
208
227
|
<Pressable
|
|
209
228
|
style={({ pressed }) => [
|
|
@@ -212,10 +231,16 @@ function SnapCardV2Inner({
|
|
|
212
231
|
backgroundColor: pressed ? colors.mutedHover : colors.muted,
|
|
213
232
|
},
|
|
214
233
|
]}
|
|
215
|
-
onPress={() =>
|
|
234
|
+
onPress={() => {
|
|
235
|
+
if (expansion.useInternalToggle) {
|
|
236
|
+
setIsExpanded((value) => !value);
|
|
237
|
+
} else {
|
|
238
|
+
onExpandPress?.();
|
|
239
|
+
}
|
|
240
|
+
}}
|
|
216
241
|
>
|
|
217
242
|
<Text style={[cardStyles.expandButtonText, { color: colors.text }]}>
|
|
218
|
-
{
|
|
243
|
+
{expansion.buttonLabel}
|
|
219
244
|
</Text>
|
|
220
245
|
</Pressable>
|
|
221
246
|
</View>
|
|
@@ -224,13 +249,20 @@ function SnapCardV2Inner({
|
|
|
224
249
|
);
|
|
225
250
|
}
|
|
226
251
|
|
|
227
|
-
const overflowAmount = showOverflowWarning
|
|
252
|
+
const overflowAmount = expansion.showOverflowWarning
|
|
253
|
+
? contentHeight - SNAP_MAX_HEIGHT
|
|
254
|
+
: 0;
|
|
228
255
|
const isDark = mode === "dark";
|
|
229
256
|
const pillBg = isDark ? "rgba(40,40,40,0.92)" : "rgba(255,255,255,0.92)";
|
|
230
257
|
const pillBgPressed = isDark ? "rgba(60,60,60,0.95)" : "rgba(240,240,240,0.95)";
|
|
231
258
|
|
|
232
259
|
return (
|
|
233
|
-
<View
|
|
260
|
+
<View
|
|
261
|
+
style={{
|
|
262
|
+
paddingBottom:
|
|
263
|
+
expansion.showButton && !expandButtonInsideCard ? SHOW_MORE_OVERHANG : 0,
|
|
264
|
+
}}
|
|
265
|
+
>
|
|
234
266
|
<View style={{ position: "relative" }}>
|
|
235
267
|
<View
|
|
236
268
|
style={{
|
|
@@ -238,7 +270,9 @@ function SnapCardV2Inner({
|
|
|
238
270
|
borderWidth: 1,
|
|
239
271
|
borderColor: colors.border,
|
|
240
272
|
backgroundColor: colors.surface,
|
|
241
|
-
maxHeight: showOverflowWarning
|
|
273
|
+
maxHeight: expansion.showOverflowWarning
|
|
274
|
+
? undefined
|
|
275
|
+
: expansion.maxHeight,
|
|
242
276
|
overflow: "hidden",
|
|
243
277
|
minHeight: 120,
|
|
244
278
|
}}
|
|
@@ -248,7 +282,7 @@ function SnapCardV2Inner({
|
|
|
248
282
|
onLayout={(e) => {
|
|
249
283
|
const nextHeight = Math.round(e.nativeEvent.layout.height);
|
|
250
284
|
setContentHeight((current) =>
|
|
251
|
-
|
|
285
|
+
expansion.clipped
|
|
252
286
|
? Math.max(current, nextHeight)
|
|
253
287
|
: current === nextHeight
|
|
254
288
|
? current
|
|
@@ -259,7 +293,7 @@ function SnapCardV2Inner({
|
|
|
259
293
|
>
|
|
260
294
|
{content}
|
|
261
295
|
</View>
|
|
262
|
-
{showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (
|
|
296
|
+
{expansion.showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (
|
|
263
297
|
<View style={{ position: "absolute", top: SNAP_MAX_HEIGHT, left: 0, right: 0, height: overflowAmount, zIndex: 10, pointerEvents: "none" }}>
|
|
264
298
|
<View style={{ height: 1, borderTopWidth: 1, borderStyle: "dashed", borderColor: "rgba(255,100,100,0.6)" }} />
|
|
265
299
|
<View style={{ position: "absolute", top: -10, right: 4, backgroundColor: "rgba(0,0,0,0.7)", paddingHorizontal: 4, paddingVertical: 1, borderRadius: 3 }}>
|
|
@@ -274,8 +308,15 @@ function SnapCardV2Inner({
|
|
|
274
308
|
: loadingOverlay
|
|
275
309
|
: null}
|
|
276
310
|
</View>
|
|
277
|
-
{
|
|
278
|
-
<View
|
|
311
|
+
{expansion.showButton ? (
|
|
312
|
+
<View
|
|
313
|
+
pointerEvents="box-none"
|
|
314
|
+
style={
|
|
315
|
+
expandButtonInsideCard
|
|
316
|
+
? cardStyles.expandFloatInset
|
|
317
|
+
: cardStyles.expandFloat
|
|
318
|
+
}
|
|
319
|
+
>
|
|
279
320
|
<Pressable
|
|
280
321
|
style={({ pressed }) => [
|
|
281
322
|
cardStyles.expandButton,
|
|
@@ -284,10 +325,16 @@ function SnapCardV2Inner({
|
|
|
284
325
|
borderColor: colors.border,
|
|
285
326
|
},
|
|
286
327
|
]}
|
|
287
|
-
onPress={() =>
|
|
328
|
+
onPress={() => {
|
|
329
|
+
if (expansion.useInternalToggle) {
|
|
330
|
+
setIsExpanded((value) => !value);
|
|
331
|
+
} else {
|
|
332
|
+
onExpandPress?.();
|
|
333
|
+
}
|
|
334
|
+
}}
|
|
288
335
|
>
|
|
289
336
|
<Text style={[cardStyles.expandButtonText, { color: colors.text }]}>
|
|
290
|
-
{
|
|
337
|
+
{expansion.buttonLabel}
|
|
291
338
|
</Text>
|
|
292
339
|
</Pressable>
|
|
293
340
|
</View>
|
|
@@ -325,6 +372,9 @@ export function SnapCardV2({
|
|
|
325
372
|
actionError,
|
|
326
373
|
plain = false,
|
|
327
374
|
loadingOverlay,
|
|
375
|
+
forceExpanded,
|
|
376
|
+
expandButtonLabel,
|
|
377
|
+
onExpandPress,
|
|
328
378
|
}: {
|
|
329
379
|
snap: SnapPage;
|
|
330
380
|
handlers: SnapActionHandlers;
|
|
@@ -339,6 +389,12 @@ export function SnapCardV2({
|
|
|
339
389
|
plain?: boolean;
|
|
340
390
|
/** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
|
|
341
391
|
loadingOverlay?: ReactNode;
|
|
392
|
+
/** When true, render full content height without 500px clipping or expand controls. */
|
|
393
|
+
forceExpanded?: boolean;
|
|
394
|
+
/** Custom label for the collapsed expand button. */
|
|
395
|
+
expandButtonLabel?: string;
|
|
396
|
+
/** Called from the collapsed expand button instead of toggling internal state. */
|
|
397
|
+
onExpandPress?: () => void;
|
|
342
398
|
}) {
|
|
343
399
|
return (
|
|
344
400
|
<SnapThemeProvider appearance={appearance} colors={colors}>
|
|
@@ -354,6 +410,9 @@ export function SnapCardV2({
|
|
|
354
410
|
appearance={appearance}
|
|
355
411
|
plain={plain}
|
|
356
412
|
loadingOverlay={loadingOverlay}
|
|
413
|
+
forceExpanded={forceExpanded}
|
|
414
|
+
expandButtonLabel={expandButtonLabel}
|
|
415
|
+
onExpandPress={onExpandPress}
|
|
357
416
|
/>
|
|
358
417
|
</SnapThemeProvider>
|
|
359
418
|
);
|
|
@@ -373,6 +432,18 @@ const cardStyles = StyleSheet.create({
|
|
|
373
432
|
alignItems: "center",
|
|
374
433
|
justifyContent: "center",
|
|
375
434
|
},
|
|
435
|
+
expandFloatInset: {
|
|
436
|
+
position: "absolute",
|
|
437
|
+
left: 0,
|
|
438
|
+
right: 0,
|
|
439
|
+
bottom: 10,
|
|
440
|
+
height: 28,
|
|
441
|
+
alignItems: "center",
|
|
442
|
+
justifyContent: "center",
|
|
443
|
+
},
|
|
444
|
+
expandRow: {
|
|
445
|
+
alignItems: "center",
|
|
446
|
+
},
|
|
376
447
|
expandRowPlain: {
|
|
377
448
|
paddingTop: 8,
|
|
378
449
|
alignItems: "center",
|
package/src/ui/catalog.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { inputProps } from "./input.js";
|
|
|
10
10
|
import { itemProps } from "./item.js";
|
|
11
11
|
import { itemGroupProps } from "./item-group.js";
|
|
12
12
|
import { imageProps } from "./image.js";
|
|
13
|
+
import { paginatorProps } from "./paginator.js";
|
|
13
14
|
import { progressProps } from "./progress.js";
|
|
14
15
|
import { separatorProps } from "./separator.js";
|
|
15
16
|
import { sliderProps } from "./slider.js";
|
|
@@ -72,7 +73,13 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
|
|
|
72
73
|
},
|
|
73
74
|
image: {
|
|
74
75
|
props: imageProps,
|
|
75
|
-
description:
|
|
76
|
+
description:
|
|
77
|
+
"HTTPS image with fixed aspect ratio. Supports compact 4:1 banners and optional title/subtitle overlay text.",
|
|
78
|
+
},
|
|
79
|
+
paginator: {
|
|
80
|
+
props: paginatorProps,
|
|
81
|
+
description:
|
|
82
|
+
"Client-side paginator. Children are page element ids; the @farcaster/snap React/React Native components render one page at a time with optional built-in previous/next controls and indicators, optional top/bottom controlsPosition, and author-controlled local transition (slide | fade | scale | none). Buttons or cell_grid cells in the same snap can bind paginator_next, paginator_prev, or paginator_go_to for custom local navigation. Only one paginator is supported per rendered snap in this release. Page index is json-render local UI state and is not posted as input.",
|
|
76
83
|
},
|
|
77
84
|
progress: {
|
|
78
85
|
props: progressProps,
|
|
@@ -97,7 +104,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
|
|
|
97
104
|
text: {
|
|
98
105
|
props: textProps,
|
|
99
106
|
description:
|
|
100
|
-
"Text block — size: md (body, default), sm (caption). Optional weight and
|
|
107
|
+
"Text block — size: md (body, default), sm (caption). Optional weight, align, and maxLines. Text does not clamp by default; set maxLines to bound rendered lines.",
|
|
101
108
|
},
|
|
102
109
|
bar_chart: {
|
|
103
110
|
props: barChartProps,
|
|
@@ -107,7 +114,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
|
|
|
107
114
|
cell_grid: {
|
|
108
115
|
props: cellGridProps,
|
|
109
116
|
description:
|
|
110
|
-
"Cell grid — sparse colored cells on a rows×cols grid. Cell color and textColor are palette names or literal #rrggbb hex values (hex ignores page accent); textColor overrides the default auto-contrast text color. Two interaction modes: leave select 'off' and bind on.press to fire an action per cell press (inputs[name] is the pressed 'row,col' before the action runs); or set select 'single'/'multiple' for press-to-select with a visual ring (no auto-fire — pair with a separate submit button). on.press is ignored when select is on.",
|
|
117
|
+
"Cell grid — sparse colored cells on a rows×cols grid. Set maxWidth to sm or md to render compact square boards centered instead of stretching full-width; lg is the default full-width behavior. Cell color and textColor are palette names or literal #rrggbb hex values (hex ignores page accent); textColor overrides the default auto-contrast text color. Two interaction modes: leave select 'off' and bind on.press to fire an action per cell press (inputs[name] is the pressed 'row,col' before the action runs); or set select 'single'/'multiple' for press-to-select with a visual ring (no auto-fire — pair with a separate submit button). on.press is ignored when select is on.",
|
|
111
118
|
},
|
|
112
119
|
},
|
|
113
120
|
actions: {
|
|
@@ -165,5 +172,20 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
|
|
|
165
172
|
buyToken: z.string().optional(),
|
|
166
173
|
}),
|
|
167
174
|
},
|
|
175
|
+
paginator_next: {
|
|
176
|
+
description:
|
|
177
|
+
"Move the snap's paginator to the next page locally. Does not POST and is ignored when no paginator is rendered.",
|
|
178
|
+
params: z.object({ page: z.number().int().min(0).optional() }),
|
|
179
|
+
},
|
|
180
|
+
paginator_prev: {
|
|
181
|
+
description:
|
|
182
|
+
"Move the snap's paginator to the previous page locally. Does not POST and is ignored when no paginator is rendered.",
|
|
183
|
+
params: z.object({ page: z.number().int().min(0).optional() }),
|
|
184
|
+
},
|
|
185
|
+
paginator_go_to: {
|
|
186
|
+
description:
|
|
187
|
+
"Move the snap's paginator to a specific zero-based page index locally. Does not POST and is ignored when no paginator is rendered.",
|
|
188
|
+
params: z.object({ page: z.number().int().min(0) }),
|
|
189
|
+
},
|
|
168
190
|
},
|
|
169
191
|
});
|
package/src/ui/cell-grid.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
GRID_MAX_ROWS,
|
|
8
8
|
GRID_GAP_VALUES,
|
|
9
9
|
GRID_CELL_ASPECT_RATIO_VALUES,
|
|
10
|
+
GRID_MAX_WIDTH_VALUES,
|
|
10
11
|
} from "../constants.js";
|
|
11
12
|
|
|
12
13
|
/** Palette name or `#rrggbb`; input is trimmed so palette and hex rules match runtime resolvers. */
|
|
@@ -38,6 +39,7 @@ export const cellGridProps = z
|
|
|
38
39
|
gap: z.enum(GRID_GAP_VALUES).optional(),
|
|
39
40
|
cellAspectRatio: z.enum(GRID_CELL_ASPECT_RATIO_VALUES).optional(),
|
|
40
41
|
rowHeight: z.number().int().min(8).max(64).optional(),
|
|
42
|
+
maxWidth: z.enum(GRID_MAX_WIDTH_VALUES).optional(),
|
|
41
43
|
select: z.enum(["off", "single", "multiple"]).optional(),
|
|
42
44
|
})
|
|
43
45
|
.superRefine((val, ctx) => {
|
package/src/ui/image.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
export const IMAGE_ASPECTS = ["1:1", "16:9", "4:3", "9:16"] as const;
|
|
3
|
+
export const IMAGE_ASPECTS = ["1:1", "16:9", "4:3", "9:16", "4:1"] as const;
|
|
4
4
|
|
|
5
5
|
export const imageProps = z.object({
|
|
6
6
|
url: z.string(),
|
|
7
7
|
aspect: z.enum(IMAGE_ASPECTS),
|
|
8
8
|
alt: z.string().optional(),
|
|
9
|
+
title: z.string().min(1).max(80).optional(),
|
|
10
|
+
subtitle: z.string().min(1).max(120).optional(),
|
|
9
11
|
});
|
|
10
12
|
|
|
11
13
|
export type ImageProps = z.infer<typeof imageProps>;
|
package/src/ui/index.ts
CHANGED
|
@@ -28,6 +28,9 @@ export type { IconProps } from "./icon.js";
|
|
|
28
28
|
export { imageProps } from "./image.js";
|
|
29
29
|
export type { ImageProps } from "./image.js";
|
|
30
30
|
|
|
31
|
+
export { paginatorProps } from "./paginator.js";
|
|
32
|
+
export type { PaginatorProps } from "./paginator.js";
|
|
33
|
+
|
|
31
34
|
export { progressProps } from "./progress.js";
|
|
32
35
|
export type { ProgressProps } from "./progress.js";
|
|
33
36
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const SNAP_PAGINATOR_PAGE_PATH = "/ui/paginator/page";
|
|
2
|
+
export const SNAP_PAGINATOR_PAGE_COUNT_PATH = "/ui/paginator/pageCount";
|
|
3
|
+
|
|
4
|
+
export type SnapPaginatorAction =
|
|
5
|
+
| { action: "paginator_next" | "paginator_prev" }
|
|
6
|
+
| { action: "paginator_go_to"; page?: number };
|
|
7
|
+
|
|
8
|
+
type StateStoreAccess = {
|
|
9
|
+
get: (path: string) => unknown;
|
|
10
|
+
set: (path: string, value: unknown) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function clampPaginatorPage(value: number, pageCount: number): number {
|
|
14
|
+
return Math.min(Math.max(value, 0), Math.max(pageCount - 1, 0));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function pageFromValue(value: unknown, fallback = 0): number {
|
|
18
|
+
return typeof value === "number" && Number.isInteger(value)
|
|
19
|
+
? value
|
|
20
|
+
: fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function pageCountFromValue(value: unknown): number {
|
|
24
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0
|
|
25
|
+
? value
|
|
26
|
+
: 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getPaginatorAction(
|
|
30
|
+
on: Record<string, unknown> | undefined,
|
|
31
|
+
): SnapPaginatorAction | null {
|
|
32
|
+
const press = on?.press as
|
|
33
|
+
| { action?: string; params?: Record<string, unknown> }
|
|
34
|
+
| undefined;
|
|
35
|
+
if (!press) return null;
|
|
36
|
+
if (press.action === "paginator_next") return { action: "paginator_next" };
|
|
37
|
+
if (press.action === "paginator_prev") return { action: "paginator_prev" };
|
|
38
|
+
if (press.action === "paginator_go_to") {
|
|
39
|
+
const page = press.params?.page;
|
|
40
|
+
return {
|
|
41
|
+
action: "paginator_go_to",
|
|
42
|
+
page: typeof page === "number" && Number.isInteger(page) ? page : 0,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function runPaginatorAction(
|
|
49
|
+
store: StateStoreAccess,
|
|
50
|
+
action: SnapPaginatorAction | null,
|
|
51
|
+
): boolean {
|
|
52
|
+
if (!action) return false;
|
|
53
|
+
const pageCount = pageCountFromValue(store.get(SNAP_PAGINATOR_PAGE_COUNT_PATH));
|
|
54
|
+
if (pageCount === 0) return false;
|
|
55
|
+
const currentPage = clampPaginatorPage(
|
|
56
|
+
pageFromValue(store.get(SNAP_PAGINATOR_PAGE_PATH), 0),
|
|
57
|
+
pageCount,
|
|
58
|
+
);
|
|
59
|
+
const nextPage =
|
|
60
|
+
action.action === "paginator_next"
|
|
61
|
+
? currentPage + 1
|
|
62
|
+
: action.action === "paginator_prev"
|
|
63
|
+
? currentPage - 1
|
|
64
|
+
: (action as { action: "paginator_go_to"; page?: number }).page ?? 0;
|
|
65
|
+
store.set(SNAP_PAGINATOR_PAGE_PATH, clampPaginatorPage(nextPage, pageCount));
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const paginatorProps = z.object({
|
|
4
|
+
initialPage: z.number().int().min(0).optional(),
|
|
5
|
+
showIndicators: z.boolean().optional(),
|
|
6
|
+
showControls: z.boolean().optional(),
|
|
7
|
+
controlsPosition: z.enum(["top", "bottom"]).optional(),
|
|
8
|
+
transition: z.enum(["slide", "fade", "scale", "none"]).optional(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export type PaginatorProps = z.infer<typeof paginatorProps>;
|
package/src/ui/text.ts
CHANGED
|
@@ -10,6 +10,7 @@ export const textProps = z.object({
|
|
|
10
10
|
size: z.enum(TEXT_SIZES).optional(),
|
|
11
11
|
weight: z.enum(TEXT_WEIGHTS).optional(),
|
|
12
12
|
align: z.enum(TEXT_ALIGNS).optional(),
|
|
13
|
+
maxLines: z.number().int().min(1).max(6).optional(),
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
export type TextProps = z.infer<typeof textProps>;
|
package/src/validator.ts
CHANGED
|
@@ -119,9 +119,14 @@ function validateStructure(ui: {
|
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// Root element has a stricter children limit
|
|
122
|
+
// Root element has a stricter children limit. Paginator pages are intentionally
|
|
123
|
+
// allowed to exceed per-container child caps while preserving global limits.
|
|
123
124
|
const rootEl = elements[ui.root];
|
|
124
|
-
if (
|
|
125
|
+
if (
|
|
126
|
+
rootEl?.type !== "paginator" &&
|
|
127
|
+
rootEl?.children &&
|
|
128
|
+
rootEl.children.length > MAX_ROOT_CHILDREN
|
|
129
|
+
) {
|
|
125
130
|
issues.push({
|
|
126
131
|
code: "custom",
|
|
127
132
|
message: `Root element "${ui.root}" exceeds maximum of ${MAX_ROOT_CHILDREN} children (found ${rootEl.children.length})`,
|
|
@@ -131,7 +136,7 @@ function validateStructure(ui: {
|
|
|
131
136
|
|
|
132
137
|
for (const [id, el] of Object.entries(elements)) {
|
|
133
138
|
if (id === ui.root) continue; // already checked above
|
|
134
|
-
if (el.children && el.children.length > MAX_CHILDREN) {
|
|
139
|
+
if (el.type !== "paginator" && el.children && el.children.length > MAX_CHILDREN) {
|
|
135
140
|
issues.push({
|
|
136
141
|
code: "custom",
|
|
137
142
|
message: `Element "${id}" exceeds maximum of ${MAX_CHILDREN} children (found ${el.children.length})`,
|
|
@@ -140,6 +145,17 @@ function validateStructure(ui: {
|
|
|
140
145
|
}
|
|
141
146
|
}
|
|
142
147
|
|
|
148
|
+
const paginatorIds = Object.entries(elements)
|
|
149
|
+
.filter(([, el]) => el.type === "paginator")
|
|
150
|
+
.map(([id]) => id);
|
|
151
|
+
if (paginatorIds.length > 1) {
|
|
152
|
+
issues.push({
|
|
153
|
+
code: "custom",
|
|
154
|
+
message: `Snap supports at most one paginator (found ${paginatorIds.length}: ${paginatorIds.join(", ")})`,
|
|
155
|
+
path: ["ui", "elements"],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
143
159
|
const depth = measureDepth(
|
|
144
160
|
elements as Record<string, { children?: string[] }>,
|
|
145
161
|
ui.root,
|