@farcaster/snap 2.0.2 → 2.1.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/react/components/cell-grid.d.ts +3 -1
- package/dist/react/components/cell-grid.js +8 -4
- package/dist/react/index.d.ts +3 -1
- package/dist/react/index.js +3 -3
- package/dist/react/snap-view-core.d.ts +12 -1
- package/dist/react/snap-view-core.js +10 -5
- package/dist/react/v1/snap-view.d.ts +7 -2
- package/dist/react/v1/snap-view.js +48 -40
- package/dist/react/v2/snap-view.d.ts +6 -2
- package/dist/react/v2/snap-view.js +98 -33
- package/dist/react-native/components/snap-cell-grid.d.ts +1 -1
- package/dist/react-native/components/snap-cell-grid.js +10 -4
- package/dist/react-native/confetti-overlay.js +33 -36
- package/dist/react-native/index.d.ts +3 -1
- package/dist/react-native/index.js +3 -3
- package/dist/react-native/snap-view-core.d.ts +11 -1
- package/dist/react-native/snap-view-core.js +25 -9
- package/dist/react-native/v1/snap-view.d.ts +9 -3
- package/dist/react-native/v1/snap-view.js +51 -52
- package/dist/react-native/v2/snap-view.d.ts +8 -3
- package/dist/react-native/v2/snap-view.js +92 -21
- package/dist/ui/catalog.js +2 -2
- package/dist/validator.js +8 -33
- package/llms.txt +26 -3
- package/package.json +1 -1
- package/src/react/components/cell-grid.tsx +11 -5
- package/src/react/index.tsx +5 -0
- package/src/react/snap-view-core.tsx +23 -8
- package/src/react/v1/snap-view.tsx +84 -55
- package/src/react/v2/snap-view.tsx +165 -52
- package/src/react-native/components/snap-cell-grid.tsx +11 -4
- package/src/react-native/confetti-overlay.tsx +40 -37
- package/src/react-native/index.tsx +5 -0
- package/src/react-native/snap-view-core.tsx +56 -14
- package/src/react-native/v1/snap-view.tsx +71 -47
- package/src/react-native/v2/snap-view.tsx +166 -28
- package/src/ui/catalog.ts +2 -2
- package/src/validator.ts +22 -46
package/dist/validator.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { snapResponseSchema } from "./schemas.js";
|
|
2
|
-
import { MAX_CHILDREN, MAX_DEPTH, MAX_ELEMENTS, MAX_ROOT_CHILDREN, SPEC_VERSION_1 } from "./constants.js";
|
|
2
|
+
import { MAX_CHILDREN, MAX_DEPTH, MAX_ELEMENTS, MAX_ROOT_CHILDREN, SPEC_VERSION_1, } from "./constants.js";
|
|
3
3
|
import { snapJsonRenderCatalog } from "./ui/catalog.js";
|
|
4
4
|
// ─── Helpers ──────────────────────────────────────────
|
|
5
5
|
/** Actions whose `params.target` must be a valid URL. */
|
|
@@ -9,8 +9,6 @@ const URL_TARGET_ACTIONS = new Set([
|
|
|
9
9
|
"open_snap",
|
|
10
10
|
"open_mini_app",
|
|
11
11
|
]);
|
|
12
|
-
/** Image file extensions allowed in image URLs. */
|
|
13
|
-
const ALLOWED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
|
|
14
12
|
/**
|
|
15
13
|
* Returns true if the URL is a loopback address (localhost dev exception).
|
|
16
14
|
*/
|
|
@@ -38,31 +36,6 @@ function validateUrl(raw) {
|
|
|
38
36
|
return `javascript: URIs are not allowed`;
|
|
39
37
|
return `URL must use HTTPS (got ${url.protocol.replace(":", "")}): "${raw}"`;
|
|
40
38
|
}
|
|
41
|
-
/**
|
|
42
|
-
* Validate an image URL: must pass URL validation + have an allowed extension.
|
|
43
|
-
*/
|
|
44
|
-
function validateImageUrl(raw) {
|
|
45
|
-
const urlError = validateUrl(raw);
|
|
46
|
-
if (urlError)
|
|
47
|
-
return urlError;
|
|
48
|
-
let url;
|
|
49
|
-
try {
|
|
50
|
-
url = new URL(raw);
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
53
|
-
return null; // already caught above
|
|
54
|
-
}
|
|
55
|
-
const pathname = url.pathname;
|
|
56
|
-
const lastDot = pathname.lastIndexOf(".");
|
|
57
|
-
if (lastDot === -1) {
|
|
58
|
-
return `Image URL must end with a supported extension (${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}): "${raw}"`;
|
|
59
|
-
}
|
|
60
|
-
const ext = pathname.slice(lastDot + 1).toLowerCase();
|
|
61
|
-
if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
|
|
62
|
-
return `Image URL has unsupported extension ".${ext}" (allowed: ${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}): "${raw}"`;
|
|
63
|
-
}
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
39
|
// ─── Depth measurement ────────────────────────────────
|
|
67
40
|
/**
|
|
68
41
|
* Walk the element tree from `root` and return the max depth reached.
|
|
@@ -133,8 +106,8 @@ function validateStructure(ui) {
|
|
|
133
106
|
// ─── URL validation ───────────────────────────────────
|
|
134
107
|
/**
|
|
135
108
|
* Validate all URLs in the snap:
|
|
136
|
-
* - image.url: must
|
|
137
|
-
* - action target URLs (submit, open_url, open_snap, open_mini_app): must
|
|
109
|
+
* - image.url: must use HTTPS (or HTTP on loopback for dev)
|
|
110
|
+
* - action target URLs (submit, open_url, open_snap, open_mini_app): must use HTTPS (or HTTP on loopback for dev)
|
|
138
111
|
*/
|
|
139
112
|
function validateUrls(elements) {
|
|
140
113
|
const issues = [];
|
|
@@ -142,7 +115,7 @@ function validateUrls(elements) {
|
|
|
142
115
|
for (const [id, el] of Object.entries(els)) {
|
|
143
116
|
// Validate image URLs
|
|
144
117
|
if (el.type === "image" && typeof el.props?.url === "string") {
|
|
145
|
-
const error =
|
|
118
|
+
const error = validateUrl(el.props.url);
|
|
146
119
|
if (error) {
|
|
147
120
|
issues.push({
|
|
148
121
|
code: "custom",
|
|
@@ -191,11 +164,13 @@ export function validateSnapResponse(json) {
|
|
|
191
164
|
if (!(ui.root in ui.elements)) {
|
|
192
165
|
return {
|
|
193
166
|
valid: false,
|
|
194
|
-
issues: [
|
|
167
|
+
issues: [
|
|
168
|
+
{
|
|
195
169
|
code: "custom",
|
|
196
170
|
message: `ui.root "${ui.root}" does not exist in ui.elements`,
|
|
197
171
|
path: ["ui", "root"],
|
|
198
|
-
}
|
|
172
|
+
},
|
|
173
|
+
],
|
|
199
174
|
};
|
|
200
175
|
}
|
|
201
176
|
// Structural limits and URL validation only apply to v2+ snaps
|
package/llms.txt
CHANGED
|
@@ -68,7 +68,7 @@ Top-level fields: `version` (required, `"1.0"` or `"2.0"`), `theme` (optional, `
|
|
|
68
68
|
- `title` (string, required, max 100)
|
|
69
69
|
- `description` (string, optional, max 160)
|
|
70
70
|
- `variant` (optional): `"default"`. Default: `"default"`
|
|
71
|
-
- Children render in the actions slot (right side)
|
|
71
|
+
- 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
72
|
|
|
73
73
|
**progress** — Horizontal progress bar.
|
|
74
74
|
- `value` (number, required, 0 to max)
|
|
@@ -98,7 +98,8 @@ Top-level fields: `version` (required, `"1.0"` or `"2.0"`), `theme` (optional, `
|
|
|
98
98
|
- `cells` (array, required): sparse list of `{ row, col, color?: PaletteColor, content?: string }`
|
|
99
99
|
- `gap` (optional): `"none"` (0px) | `"sm"` (1px) | `"md"` (2px) | `"lg"` (4px). Default: `"sm"`
|
|
100
100
|
- `rowHeight` (number, optional, 8–64): pixel height per row. Default: 28. Grid height = rows × rowHeight
|
|
101
|
-
- `select` (optional): `"off"` | `"single"` | `"multiple"`. Default: `"off"`.
|
|
101
|
+
- `select` (optional): `"off"` | `"single"` | `"multiple"`. Default: `"off"`. With `select: "off"`, bind `on.press` for press-to-act (each press writes `"row,col"` to `inputs[name]` and fires the action). With `"single"` / `"multiple"`, presses accumulate selection state and pair with a separate submit `button`; `on.press` is ignored.
|
|
102
|
+
- Events: `press` — fires on cell press, only when `select: "off"`; `inputs[name]` is set to `"row,col"` before the bound action runs
|
|
102
103
|
|
|
103
104
|
### Container Components
|
|
104
105
|
|
|
@@ -165,7 +166,7 @@ Bound to buttons via `on.press`:
|
|
|
165
166
|
| `open_mini_app` | `target` (URL) | Open as Farcaster mini app |
|
|
166
167
|
| `view_cast` | `hash` (string) | Navigate to a cast |
|
|
167
168
|
| `view_profile` | `fid` (number) | Navigate to a profile |
|
|
168
|
-
| `compose_cast` | `text?`, `channelKey?`, `embeds?` | Open cast composer |
|
|
169
|
+
| `compose_cast` | `text?`, `channelKey?`, `embeds?` | Open cast composer. Put URLs in `embeds`, not `text` |
|
|
169
170
|
| `view_token` | `token` (CAIP-19) | View token in wallet |
|
|
170
171
|
| `send_token` | `token`, `amount?`, `recipientFid?`, `recipientAddress?` | Send token flow |
|
|
171
172
|
| `swap_token` | `sellToken?`, `buyToken?` | Swap token flow |
|
|
@@ -174,6 +175,8 @@ Bound to buttons via `on.press`:
|
|
|
174
175
|
|
|
175
176
|
`arrow-right`, `arrow-left`, `external-link`, `chevron-right`, `check`, `x`, `alert-triangle`, `info`, `clock`, `heart`, `message-circle`, `repeat`, `share`, `user`, `users`, `star`, `trophy`, `zap`, `flame`, `gift`, `image`, `play`, `pause`, `wallet`, `coins`, `plus`, `minus`, `refresh-cw`, `bookmark`, `thumbs-up`, `thumbs-down`, `trending-up`, `trending-down`
|
|
176
177
|
|
|
178
|
+
`chevron-right`, `arrow-right`, and `external-link` are navigation/disclosure affordances — only use them when the surrounding element actually navigates (e.g. a button bound to `open_url` or `open_snap`). Never place them inside an `item`'s actions slot; `item` is not interactive.
|
|
179
|
+
|
|
177
180
|
## Color Palette
|
|
178
181
|
|
|
179
182
|
`gray`, `blue`, `red`, `amber`, `green`, `teal`, `purple`, `pink`
|
|
@@ -194,6 +197,26 @@ import { withTursoServerless, createInMemoryDataStore } from "@farcaster/snap-tu
|
|
|
194
197
|
- `@farcaster/snap-hono` — Hono adapter (`registerSnapHandler`)
|
|
195
198
|
- `@farcaster/snap-turso` — `withTursoServerless`, `DataStore` / `DataStoreValue`, in-memory and Turso helpers
|
|
196
199
|
|
|
200
|
+
## Template Project Setup
|
|
201
|
+
|
|
202
|
+
The `template/` directory is the starting point for a snap. It's an ESM project
|
|
203
|
+
(`"type": "module"`) with `moduleResolution: "NodeNext"`.
|
|
204
|
+
|
|
205
|
+
**ESM import rule (CRITICAL)**: all local relative imports must include the `.js`
|
|
206
|
+
extension, even though the source files are `.ts`:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
// ✅ correct
|
|
210
|
+
import { foo } from "./foo.js";
|
|
211
|
+
|
|
212
|
+
// ❌ wrong — fails `pnpm build`; on deploy every route returns 500 FUNCTION_INVOCATION_FAILED
|
|
213
|
+
import { foo } from "./foo";
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`tsx` dev accepts bare imports, so the bug only surfaces on deploy. Always run
|
|
217
|
+
`pnpm build` (`tsc --noEmit`) before deploying — NodeNext makes this a build-time error.
|
|
218
|
+
Bare package imports (`hono`, `@farcaster/snap`, etc.) do not need an extension.
|
|
219
|
+
|
|
197
220
|
## Full Documentation
|
|
198
221
|
|
|
199
222
|
https://docs.farcaster.xyz/snap
|
package/package.json
CHANGED
|
@@ -7,17 +7,21 @@ import { POST_GRID_TAP_KEY } from "@farcaster/snap";
|
|
|
7
7
|
import { useSnapColors } from "../hooks/use-snap-colors";
|
|
8
8
|
|
|
9
9
|
export function SnapCellGrid({
|
|
10
|
-
element: { props },
|
|
10
|
+
element: { props, on },
|
|
11
|
+
emit,
|
|
11
12
|
}: {
|
|
12
|
-
element: { props: Record<string, unknown> };
|
|
13
|
+
element: { props: Record<string, unknown>; on?: Record<string, unknown> };
|
|
14
|
+
emit: (name: string) => void;
|
|
13
15
|
}) {
|
|
14
16
|
const { get, set } = useStateStore();
|
|
15
17
|
const colors = useSnapColors();
|
|
16
18
|
const cols = Number(props.cols ?? 2);
|
|
17
19
|
const rows = Number(props.rows ?? 2);
|
|
18
20
|
const select = String(props.select ?? "off");
|
|
19
|
-
const interactive = select !== "off";
|
|
20
21
|
const isMultiple = select === "multiple";
|
|
22
|
+
const isSelectable = select !== "off";
|
|
23
|
+
const hasPressAction = Boolean(on?.press);
|
|
24
|
+
const interactive = isSelectable || hasPressAction;
|
|
21
25
|
const cells = Array.isArray(props.cells) ? props.cells : [];
|
|
22
26
|
const gap = String(props.gap ?? "sm");
|
|
23
27
|
const gapMap: Record<string, number> = { none: 0, sm: 1, md: 2, lg: 4 };
|
|
@@ -36,7 +40,8 @@ export function SnapCellGrid({
|
|
|
36
40
|
}
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
const isSelected = (r: number, c: number) =>
|
|
43
|
+
const isSelected = (r: number, c: number) =>
|
|
44
|
+
isSelectable && selectedSet.has(`${r},${c}`);
|
|
40
45
|
|
|
41
46
|
const handleTap = (r: number, c: number) => {
|
|
42
47
|
const key = `${r},${c}`;
|
|
@@ -48,6 +53,7 @@ export function SnapCellGrid({
|
|
|
48
53
|
} else {
|
|
49
54
|
set(tapPath, key);
|
|
50
55
|
}
|
|
56
|
+
if (hasPressAction) emit("press");
|
|
51
57
|
};
|
|
52
58
|
|
|
53
59
|
const cellMap = new Map<string, { color?: string; content?: string }>();
|
|
@@ -99,7 +105,7 @@ export function SnapCellGrid({
|
|
|
99
105
|
}
|
|
100
106
|
}
|
|
101
107
|
|
|
102
|
-
const selectionLabel =
|
|
108
|
+
const selectionLabel = isSelectable && selectedSet.size > 0
|
|
103
109
|
? `inputs.${name}: ${[...selectedSet].join(isMultiple ? " | " : "")}`
|
|
104
110
|
: null;
|
|
105
111
|
|
package/src/react/index.tsx
CHANGED
|
@@ -59,6 +59,7 @@ export function SnapCard({
|
|
|
59
59
|
validationErrorFallback,
|
|
60
60
|
actionError,
|
|
61
61
|
plain = false,
|
|
62
|
+
loadingOverlay,
|
|
62
63
|
}: {
|
|
63
64
|
snap: SnapPage;
|
|
64
65
|
handlers: SnapActionHandlers;
|
|
@@ -73,6 +74,8 @@ export function SnapCard({
|
|
|
73
74
|
actionError?: string | null;
|
|
74
75
|
/** When true, renders without card frame (no border, background, or padding). */
|
|
75
76
|
plain?: boolean;
|
|
77
|
+
/** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
|
|
78
|
+
loadingOverlay?: ReactNode;
|
|
76
79
|
}) {
|
|
77
80
|
if (snap.version === SPEC_VERSION_2) {
|
|
78
81
|
return (
|
|
@@ -87,6 +90,7 @@ export function SnapCard({
|
|
|
87
90
|
validationErrorFallback={validationErrorFallback}
|
|
88
91
|
actionError={actionError}
|
|
89
92
|
plain={plain}
|
|
93
|
+
loadingOverlay={loadingOverlay}
|
|
90
94
|
/>
|
|
91
95
|
);
|
|
92
96
|
}
|
|
@@ -100,6 +104,7 @@ export function SnapCard({
|
|
|
100
104
|
maxWidth={maxWidth}
|
|
101
105
|
actionError={actionError}
|
|
102
106
|
plain={plain}
|
|
107
|
+
loadingOverlay={loadingOverlay}
|
|
103
108
|
/>
|
|
104
109
|
);
|
|
105
110
|
}
|
|
@@ -8,6 +8,7 @@ import { resolveSnapPaletteHex } from "./lib/resolve-palette-hex";
|
|
|
8
8
|
import { snapPreviewPrimaryCssProperties } from "./lib/preview-primary-css";
|
|
9
9
|
import {
|
|
10
10
|
type CSSProperties,
|
|
11
|
+
type ReactNode,
|
|
11
12
|
useCallback,
|
|
12
13
|
useEffect,
|
|
13
14
|
useMemo,
|
|
@@ -111,7 +112,7 @@ function ConfettiOverlay() {
|
|
|
111
112
|
);
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
function SnapLoadingOverlay({
|
|
115
|
+
export function SnapLoadingOverlay({
|
|
115
116
|
appearance,
|
|
116
117
|
accentHex,
|
|
117
118
|
active,
|
|
@@ -197,11 +198,17 @@ export function SnapViewCore({
|
|
|
197
198
|
handlers,
|
|
198
199
|
loading = false,
|
|
199
200
|
appearance = "dark",
|
|
201
|
+
loadingOverlay,
|
|
200
202
|
}: {
|
|
201
203
|
snap: SnapPage;
|
|
202
204
|
handlers: SnapActionHandlers;
|
|
203
205
|
loading?: boolean;
|
|
204
206
|
appearance?: "light" | "dark";
|
|
207
|
+
/**
|
|
208
|
+
* Custom content rendered while `loading` is true. When `undefined` (default)
|
|
209
|
+
* the built-in spinner + backdrop is used. Pass `null` to render nothing.
|
|
210
|
+
*/
|
|
211
|
+
loadingOverlay?: ReactNode;
|
|
205
212
|
}) {
|
|
206
213
|
const spec = snap.ui;
|
|
207
214
|
const initialState = useMemo(() => spec.state ?? { inputs: {} }, [spec]);
|
|
@@ -232,7 +239,11 @@ export function SnapViewCore({
|
|
|
232
239
|
setPageKey((k) => k + 1);
|
|
233
240
|
}, [spec]);
|
|
234
241
|
|
|
235
|
-
const showConfetti = snap.effects?.includes("confetti");
|
|
242
|
+
const showConfetti = snap.effects?.includes("confetti") ?? false;
|
|
243
|
+
const [confettiKey, setConfettiKey] = useState(0);
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (showConfetti) setConfettiKey((k) => k + 1);
|
|
246
|
+
}, [showConfetti, snap]);
|
|
236
247
|
|
|
237
248
|
const accentName = snap.theme?.accent ?? "purple";
|
|
238
249
|
|
|
@@ -314,12 +325,16 @@ export function SnapViewCore({
|
|
|
314
325
|
|
|
315
326
|
return (
|
|
316
327
|
<div style={{ position: "relative", width: "100%" }}>
|
|
317
|
-
{showConfetti && <ConfettiOverlay />}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
328
|
+
{showConfetti && <ConfettiOverlay key={confettiKey} />}
|
|
329
|
+
{loadingOverlay === undefined ? (
|
|
330
|
+
<SnapLoadingOverlay
|
|
331
|
+
appearance={appearance}
|
|
332
|
+
accentHex={accentHex}
|
|
333
|
+
active={loading}
|
|
334
|
+
/>
|
|
335
|
+
) : loading ? (
|
|
336
|
+
<>{loadingOverlay}</>
|
|
337
|
+
) : null}
|
|
323
338
|
|
|
324
339
|
<div style={previewSurfaceStyle}>
|
|
325
340
|
<SnapPreviewAccentProvider
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, useState } from "react";
|
|
4
|
-
import { SnapViewCore } from "../snap-view-core";
|
|
3
|
+
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { SnapViewCore, SnapLoadingOverlay } from "../snap-view-core";
|
|
5
|
+
import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex";
|
|
5
6
|
import type { SnapPage, SnapActionHandlers } from "../index";
|
|
6
7
|
|
|
7
8
|
const SNAP_MAX_HEIGHT = 500;
|
|
@@ -11,11 +12,14 @@ export function SnapViewV1({
|
|
|
11
12
|
handlers,
|
|
12
13
|
loading = false,
|
|
13
14
|
appearance = "dark",
|
|
15
|
+
loadingOverlay,
|
|
14
16
|
}: {
|
|
15
17
|
snap: SnapPage;
|
|
16
18
|
handlers: SnapActionHandlers;
|
|
17
19
|
loading?: boolean;
|
|
18
20
|
appearance?: "light" | "dark";
|
|
21
|
+
/** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
|
|
22
|
+
loadingOverlay?: ReactNode;
|
|
19
23
|
}) {
|
|
20
24
|
return (
|
|
21
25
|
<SnapViewCore
|
|
@@ -23,6 +27,7 @@ export function SnapViewV1({
|
|
|
23
27
|
handlers={handlers}
|
|
24
28
|
loading={loading}
|
|
25
29
|
appearance={appearance}
|
|
30
|
+
loadingOverlay={loadingOverlay}
|
|
26
31
|
/>
|
|
27
32
|
);
|
|
28
33
|
}
|
|
@@ -35,6 +40,7 @@ export function SnapCardV1({
|
|
|
35
40
|
maxWidth = 480,
|
|
36
41
|
actionError,
|
|
37
42
|
plain = false,
|
|
43
|
+
loadingOverlay,
|
|
38
44
|
}: {
|
|
39
45
|
snap: SnapPage;
|
|
40
46
|
handlers: SnapActionHandlers;
|
|
@@ -43,6 +49,8 @@ export function SnapCardV1({
|
|
|
43
49
|
maxWidth?: number;
|
|
44
50
|
actionError?: string | null;
|
|
45
51
|
plain?: boolean;
|
|
52
|
+
/** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
|
|
53
|
+
loadingOverlay?: ReactNode;
|
|
46
54
|
}) {
|
|
47
55
|
const isDark = appearance === "dark";
|
|
48
56
|
const borderColor = isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
|
|
@@ -86,76 +94,97 @@ export function SnapCardV1({
|
|
|
86
94
|
|
|
87
95
|
const isClipped = isExpandable && !isExpanded;
|
|
88
96
|
|
|
97
|
+
const accentHex = useMemo(
|
|
98
|
+
() => resolveSnapPaletteHex(snap.theme?.accent ?? "purple", appearance),
|
|
99
|
+
[snap.theme?.accent, appearance],
|
|
100
|
+
);
|
|
101
|
+
|
|
89
102
|
return (
|
|
90
103
|
<div
|
|
91
104
|
style={{
|
|
92
105
|
position: "relative",
|
|
93
106
|
width: "100%",
|
|
94
107
|
maxWidth,
|
|
95
|
-
overflow: "hidden",
|
|
96
|
-
...(plain ? {} : {
|
|
97
|
-
borderRadius: 16,
|
|
98
|
-
border: `1px solid ${borderColor}`,
|
|
99
|
-
backgroundColor: surfaceBg,
|
|
100
|
-
}),
|
|
101
108
|
}}
|
|
102
109
|
>
|
|
103
110
|
<div
|
|
104
|
-
style={
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
:
|
|
111
|
-
|
|
111
|
+
style={{
|
|
112
|
+
position: "relative",
|
|
113
|
+
overflow: "hidden",
|
|
114
|
+
...(plain ? {} : {
|
|
115
|
+
borderRadius: 16,
|
|
116
|
+
border: `1px solid ${borderColor}`,
|
|
117
|
+
backgroundColor: surfaceBg,
|
|
118
|
+
}),
|
|
119
|
+
}}
|
|
112
120
|
>
|
|
113
|
-
<div
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
121
|
+
<div
|
|
122
|
+
style={
|
|
123
|
+
isClipped
|
|
124
|
+
? {
|
|
125
|
+
maxHeight: SNAP_MAX_HEIGHT,
|
|
126
|
+
overflow: "hidden",
|
|
127
|
+
}
|
|
128
|
+
: undefined
|
|
129
|
+
}
|
|
130
|
+
>
|
|
131
|
+
<div ref={contentRef} style={plain ? undefined : { padding: 16 }}>
|
|
132
|
+
<SnapViewV1
|
|
133
|
+
snap={snap}
|
|
134
|
+
handlers={handlers}
|
|
135
|
+
loading={loading}
|
|
136
|
+
appearance={appearance}
|
|
137
|
+
loadingOverlay={null}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
{loadingOverlay === undefined ? (
|
|
142
|
+
<SnapLoadingOverlay
|
|
118
143
|
appearance={appearance}
|
|
144
|
+
accentHex={accentHex}
|
|
145
|
+
active={loading}
|
|
119
146
|
/>
|
|
120
|
-
|
|
147
|
+
) : loading ? (
|
|
148
|
+
<>{loadingOverlay}</>
|
|
149
|
+
) : null}
|
|
121
150
|
</div>
|
|
122
151
|
{isExpandable ? (
|
|
123
|
-
<
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
aria-expanded={isExpanded}
|
|
155
|
+
onClick={() => setIsExpanded((value) => !value)}
|
|
124
156
|
style={{
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
157
|
+
position: "absolute",
|
|
158
|
+
bottom: 0,
|
|
159
|
+
left: "50%",
|
|
160
|
+
transform: "translate(-50%, 50%)",
|
|
161
|
+
appearance: "none",
|
|
162
|
+
border: `1px solid ${borderColor}`,
|
|
163
|
+
borderRadius: 9999,
|
|
164
|
+
backgroundColor: isDark ? "rgba(30,30,30,0.6)" : "rgba(255,255,255,0.6)",
|
|
165
|
+
backdropFilter: "blur(12px) saturate(180%)",
|
|
166
|
+
WebkitBackdropFilter: "blur(12px) saturate(180%)",
|
|
167
|
+
color: toggleText,
|
|
168
|
+
padding: "2px 10px",
|
|
169
|
+
fontSize: 12,
|
|
170
|
+
lineHeight: "16px",
|
|
171
|
+
fontWeight: 600,
|
|
172
|
+
cursor: "pointer",
|
|
173
|
+
zIndex: 11,
|
|
174
|
+
}}
|
|
175
|
+
onMouseEnter={(event) => {
|
|
176
|
+
event.currentTarget.style.backgroundColor = isDark
|
|
177
|
+
? "rgba(50,50,50,0.7)"
|
|
178
|
+
: "rgba(245,245,245,0.75)";
|
|
179
|
+
}}
|
|
180
|
+
onMouseLeave={(event) => {
|
|
181
|
+
event.currentTarget.style.backgroundColor = isDark
|
|
182
|
+
? "rgba(30,30,30,0.6)"
|
|
183
|
+
: "rgba(255,255,255,0.6)";
|
|
131
184
|
}}
|
|
132
185
|
>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
aria-expanded={isExpanded}
|
|
136
|
-
onClick={() => setIsExpanded((value) => !value)}
|
|
137
|
-
style={{
|
|
138
|
-
appearance: "none",
|
|
139
|
-
border: "none",
|
|
140
|
-
borderRadius: 9999,
|
|
141
|
-
backgroundColor: toggleBg,
|
|
142
|
-
color: toggleText,
|
|
143
|
-
padding: "6px 10px",
|
|
144
|
-
fontSize: 13,
|
|
145
|
-
lineHeight: "18px",
|
|
146
|
-
fontWeight: 600,
|
|
147
|
-
cursor: "pointer",
|
|
148
|
-
}}
|
|
149
|
-
onMouseEnter={(event) => {
|
|
150
|
-
event.currentTarget.style.backgroundColor = toggleBgHover;
|
|
151
|
-
}}
|
|
152
|
-
onMouseLeave={(event) => {
|
|
153
|
-
event.currentTarget.style.backgroundColor = toggleBg;
|
|
154
|
-
}}
|
|
155
|
-
>
|
|
156
|
-
{isExpanded ? "Show less" : "Show more"}
|
|
157
|
-
</button>
|
|
158
|
-
</div>
|
|
186
|
+
{isExpanded ? "Show less" : "Show more"}
|
|
187
|
+
</button>
|
|
159
188
|
) : null}
|
|
160
189
|
{actionError && (
|
|
161
190
|
<div
|