react-sharesheet 1.8.0 → 1.9.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.
@@ -28,9 +28,9 @@ const defaultClasses = {
28
28
  header: "text-center mb-2",
29
29
  title: "text-2xl font-black",
30
30
  subtitle: "mt-1 text-sm",
31
- preview: "flex justify-center mb-4 px-4",
31
+ preview: "flex justify-center my-4 px-4",
32
32
  previewSkeleton: "rounded-xl overflow-hidden",
33
- previewImage: "border border-white/10",
33
+ previewImage: "border border-white/10 rounded-xl",
34
34
  previewMeta: "",
35
35
  grid: "px-2 py-6 flex flex-row items-start gap-4 gap-y-6 flex-wrap justify-center",
36
36
  button: "flex flex-col items-center gap-0 text-xs w-[60px] outline-none cursor-pointer group",
@@ -64,12 +64,16 @@ export function ShareSheetContent({
64
64
  shareText,
65
65
  downloadUrl,
66
66
  downloadFilename,
67
+ previewImage,
68
+ shareFile,
69
+ shareFilename,
67
70
  className,
68
71
  classNames = {},
69
72
  buttonSize = DEFAULT_BUTTON_SIZE,
70
73
  iconSize = DEFAULT_ICON_SIZE,
71
74
  onNativeShare,
72
75
  onCopy,
76
+ onShare,
73
77
  onDownload,
74
78
  hide = [],
75
79
  show,
@@ -79,8 +83,8 @@ export function ShareSheetContent({
79
83
  const [imageLoaded, setImageLoaded] = useState(false);
80
84
  const [imageError, setImageError] = useState(false);
81
85
 
82
- // Fetch OG data automatically from shareUrl
83
- const { ogData, loading: ogLoading } = useOGData(shareUrl);
86
+ // Only fetch OG data if previewImage is not provided
87
+ const { ogData, loading: ogLoading } = useOGData(previewImage ? undefined : shareUrl);
84
88
 
85
89
  const handleImageLoad = useCallback(() => {
86
90
  setImageLoaded(true);
@@ -96,6 +100,8 @@ export function ShareSheetContent({
96
100
  downloadUrl,
97
101
  downloadFilename,
98
102
  emailSubject: shareText,
103
+ shareFile,
104
+ shareFilename,
99
105
  onNativeShare,
100
106
  onCopy,
101
107
  onDownload,
@@ -151,11 +157,14 @@ export function ShareSheetContent({
151
157
  // Use CSS var with fallback to platform color
152
158
  bgColor: cssVar(PLATFORM_CSS_VARS[id], PLATFORM_COLORS[id].bg),
153
159
  textColor: PLATFORM_COLORS[id].text,
154
- onClick: shareActions[id],
160
+ onClick: () => {
161
+ onShare?.(id);
162
+ shareActions[id]();
163
+ },
155
164
  condition,
156
165
  };
157
166
  });
158
- }, [iconSize, labels, icons, dynamicLabels, shareActions, shareSheet.canNativeShare, shareSheet.platformAvailability, downloadUrl]);
167
+ }, [iconSize, labels, icons, dynamicLabels, shareActions, onShare, shareSheet.canNativeShare, shareSheet.platformAvailability, downloadUrl]);
159
168
 
160
169
  const visibleButtons = useMemo(() => {
161
170
  // If show is provided, use its order
@@ -184,11 +193,13 @@ export function ShareSheetContent({
184
193
 
185
194
  // Render OG preview
186
195
  const renderPreview = () => {
187
- const ogImage = ogData?.image;
196
+ // Use previewImage if provided, otherwise use OG data
197
+ const ogImage = previewImage || ogData?.image;
188
198
  const hasImage = ogImage && !imageError;
199
+ const isLoading = !previewImage && ogLoading;
189
200
 
190
- // Loading state
191
- if (ogLoading) {
201
+ // Loading state (only when fetching OG data, not when previewImage is provided)
202
+ if (isLoading) {
192
203
  return (
193
204
  <div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: "100%" }}>
194
205
  <div
@@ -221,8 +232,8 @@ export function ShareSheetContent({
221
232
  );
222
233
  }
223
234
 
224
- // No OG data or no image - show link placeholder
225
- if (!ogData || !hasImage) {
235
+ // No image available - show link placeholder (skip if previewImage was provided)
236
+ if (!hasImage) {
226
237
  return (
227
238
  <div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: "100%" }}>
228
239
  <div
@@ -274,7 +285,27 @@ export function ShareSheetContent({
274
285
  );
275
286
  }
276
287
 
277
- // Image loading state
288
+ // If previewImage is a data URL, show it directly (no preloading needed)
289
+ const isDataUrl = typeof ogImage === "string" && ogImage.startsWith("data:");
290
+ if (isDataUrl) {
291
+ return (
292
+ <div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: "100%" }}>
293
+ {/* eslint-disable-next-line @next/next/no-img-element */}
294
+ <img
295
+ src={ogImage}
296
+ alt="Preview"
297
+ className={cn(defaultClasses.previewImage, classNames.previewImage)}
298
+ style={{
299
+ width: "100%",
300
+ maxWidth: "320px",
301
+ height: "auto",
302
+ }}
303
+ />
304
+ </div>
305
+ );
306
+ }
307
+
308
+ // Image loading state (for remote URLs)
278
309
  if (!imageLoaded) {
279
310
  return (
280
311
  <div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: "100%" }}>
@@ -308,7 +339,7 @@ export function ShareSheetContent({
308
339
  {/* eslint-disable-next-line @next/next/no-img-element */}
309
340
  <img
310
341
  src={ogImage}
311
- alt={ogData.title || "Preview"}
342
+ alt={ogData?.title || "Preview"}
312
343
  onLoad={handleImageLoad}
313
344
  onError={handleImageError}
314
345
  style={{ display: "none" }}
@@ -323,13 +354,12 @@ export function ShareSheetContent({
323
354
  {/* eslint-disable-next-line @next/next/no-img-element */}
324
355
  <img
325
356
  src={ogImage}
326
- alt={ogData.title || "Preview"}
357
+ alt={ogData?.title || "Preview"}
327
358
  className={cn(defaultClasses.previewImage, classNames.previewImage, "sharesheet-fadein")}
328
359
  style={{
329
360
  width: "100%",
330
361
  maxWidth: "320px",
331
362
  height: "auto",
332
- borderRadius: "12px",
333
363
  }}
334
364
  />
335
365
  </div>
@@ -16,6 +16,31 @@ const defaultDrawerClasses = {
16
16
  trigger: "",
17
17
  };
18
18
 
19
+ /**
20
+ * Vaul passes the trigger's button semantics down to whatever element it receives:
21
+ * `type="button"`, `aria-haspopup="dialog"` and `aria-expanded`. Handing it a plain
22
+ * `div` puts button attributes on a non-button, which axe reports as a critical
23
+ * `aria-allowed-attr` violation and which screen readers cannot announce.
24
+ *
25
+ * So the trigger element is the one the caller already provided, usually their own
26
+ * button, with the trigger classes merged in. Only a bare string or a fragment needs a
27
+ * host element, and that host is a real button.
28
+ */
29
+ function renderTrigger(children: React.ReactNode, className: string): React.ReactElement {
30
+ if (React.isValidElement(children)) {
31
+ if (!className) return children;
32
+ const childClassName = (children.props as { className?: string }).className;
33
+ return React.cloneElement(children as React.ReactElement<{ className?: string }>, {
34
+ className: cn(childClassName, className),
35
+ });
36
+ }
37
+ return (
38
+ <button type="button" className={className}>
39
+ {children}
40
+ </button>
41
+ );
42
+ }
43
+
19
44
  // Helper to create var() with fallback
20
45
  function cssVar(name: string, fallback: string): string {
21
46
  return `var(${name}, ${fallback})`;
@@ -40,6 +65,9 @@ export function ShareSheetDrawer({
40
65
  shareText,
41
66
  downloadUrl,
42
67
  downloadFilename,
68
+ previewImage,
69
+ shareFile,
70
+ shareFilename,
43
71
  disabled,
44
72
  children,
45
73
  open: controlledOpen,
@@ -67,15 +95,11 @@ export function ShareSheetDrawer({
67
95
  return (
68
96
  <Drawer.Root open={open} onOpenChange={setOpen} shouldScaleBackground>
69
97
  <Drawer.Trigger asChild>
70
- <div
71
- className={cn(
72
- defaultDrawerClasses.trigger,
73
- classNames.trigger,
74
- disabled ? "pointer-events-none opacity-50" : ""
75
- )}
76
- >
77
- {children}
78
- </div>
98
+ {renderTrigger(children, cn(
99
+ defaultDrawerClasses.trigger,
100
+ classNames.trigger,
101
+ disabled ? "pointer-events-none opacity-50" : ""
102
+ ))}
79
103
  </Drawer.Trigger>
80
104
  <Drawer.Portal>
81
105
  <Drawer.Overlay
@@ -111,6 +135,9 @@ export function ShareSheetDrawer({
111
135
  shareText={shareText}
112
136
  downloadUrl={downloadUrl}
113
137
  downloadFilename={downloadFilename}
138
+ previewImage={previewImage}
139
+ shareFile={shareFile}
140
+ shareFilename={shareFilename}
114
141
  className={className}
115
142
  classNames={classNames}
116
143
  buttonSize={buttonSize}
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+
4
+ import { ShareSheetDrawer } from "../ShareSheetDrawer";
5
+
6
+ describe("ShareSheetDrawer trigger", () => {
7
+ it("puts the button semantics on the caller's own button, not on a wrapper div", () => {
8
+ render(
9
+ <ShareSheetDrawer shareUrl="https://example.com" shareText="hello">
10
+ <button type="button" data-testid="opener">Share</button>
11
+ </ShareSheetDrawer>,
12
+ );
13
+
14
+ const opener = screen.getByTestId("opener");
15
+ expect(opener.tagName).toBe("BUTTON");
16
+ expect(opener).toHaveAttribute("aria-haspopup", "dialog");
17
+ // Le défaut corrigé : ces attributs atterrissaient sur un div, ce qu'axe signale en
18
+ // critique et qu'aucun lecteur d'écran ne sait annoncer.
19
+ expect(document.querySelector("div[aria-haspopup]")).toBeNull();
20
+ expect(document.querySelector("div[aria-expanded]")).toBeNull();
21
+ });
22
+
23
+ it("still gives a bare string a real button to live in", () => {
24
+ render(
25
+ <ShareSheetDrawer shareUrl="https://example.com" shareText="hello">
26
+ Share this
27
+ </ShareSheetDrawer>,
28
+ );
29
+
30
+ const opener = screen.getByText("Share this");
31
+ expect(opener.tagName).toBe("BUTTON");
32
+ expect(opener).toHaveAttribute("type", "button");
33
+ });
34
+
35
+ it("merges the trigger class into the caller's element instead of wrapping it", () => {
36
+ render(
37
+ <ShareSheetDrawer
38
+ shareUrl="https://example.com"
39
+ shareText="hello"
40
+ classNames={{ trigger: "trigger-class" }}
41
+ >
42
+ <button type="button" className="mine" data-testid="opener">Share</button>
43
+ </ShareSheetDrawer>,
44
+ );
45
+
46
+ const opener = screen.getByTestId("opener");
47
+ expect(opener.className).toContain("mine");
48
+ expect(opener.className).toContain("trigger-class");
49
+ });
50
+ });
@@ -115,7 +115,7 @@ describe("useOGData", () => {
115
115
  });
116
116
 
117
117
  it("should return initial loading state", () => {
118
- vi.mocked(global.fetch).mockImplementation(
118
+ vi.mocked(globalThis.fetch).mockImplementation(
119
119
  () => new Promise(() => {}) // Never resolves
120
120
  );
121
121
 
@@ -137,7 +137,7 @@ describe("useOGData", () => {
137
137
  },
138
138
  };
139
139
 
140
- vi.mocked(global.fetch).mockResolvedValueOnce({
140
+ vi.mocked(globalThis.fetch).mockResolvedValueOnce({
141
141
  ok: true,
142
142
  json: () => Promise.resolve(mockResponse),
143
143
  } as Response);
@@ -175,7 +175,7 @@ describe("useOGData", () => {
175
175
  data: { title: "Page 2", url: "https://example2.com" },
176
176
  };
177
177
 
178
- vi.mocked(global.fetch)
178
+ vi.mocked(globalThis.fetch)
179
179
  .mockResolvedValueOnce({
180
180
  ok: true,
181
181
  json: () => Promise.resolve(mockResponse1),
@@ -19,7 +19,7 @@ describe("og-fetcher", () => {
19
19
  },
20
20
  };
21
21
 
22
- vi.mocked(global.fetch).mockResolvedValueOnce({
22
+ vi.mocked(globalThis.fetch).mockResolvedValueOnce({
23
23
  ok: true,
24
24
  json: () => Promise.resolve(mockResponse),
25
25
  } as Response);
@@ -36,7 +36,7 @@ describe("og-fetcher", () => {
36
36
  });
37
37
 
38
38
  it("should return null on failed fetch", async () => {
39
- vi.mocked(global.fetch).mockResolvedValueOnce({
39
+ vi.mocked(globalThis.fetch).mockResolvedValueOnce({
40
40
  ok: false,
41
41
  status: 404,
42
42
  } as Response);
@@ -52,7 +52,7 @@ describe("og-fetcher", () => {
52
52
  data: null,
53
53
  };
54
54
 
55
- vi.mocked(global.fetch).mockResolvedValueOnce({
55
+ vi.mocked(globalThis.fetch).mockResolvedValueOnce({
56
56
  ok: true,
57
57
  json: () => Promise.resolve(mockResponse),
58
58
  } as Response);
@@ -73,7 +73,7 @@ describe("og-fetcher", () => {
73
73
  },
74
74
  };
75
75
 
76
- vi.mocked(global.fetch).mockResolvedValueOnce({
76
+ vi.mocked(globalThis.fetch).mockResolvedValueOnce({
77
77
  ok: true,
78
78
  json: () => Promise.resolve(mockResponse),
79
79
  } as Response);
@@ -84,7 +84,7 @@ describe("og-fetcher", () => {
84
84
  const result2 = await fetchOGData("https://example.com/cached");
85
85
 
86
86
  expect(result1).toEqual(result2);
87
- expect(global.fetch).toHaveBeenCalledTimes(1);
87
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
88
88
  });
89
89
 
90
90
  it("should clear cache", async () => {
@@ -96,7 +96,7 @@ describe("og-fetcher", () => {
96
96
  },
97
97
  };
98
98
 
99
- vi.mocked(global.fetch).mockResolvedValue({
99
+ vi.mocked(globalThis.fetch).mockResolvedValue({
100
100
  ok: true,
101
101
  json: () => Promise.resolve(mockResponse),
102
102
  } as Response);
@@ -105,11 +105,11 @@ describe("og-fetcher", () => {
105
105
  clearOGCache();
106
106
  await fetchOGData("https://example.com");
107
107
 
108
- expect(global.fetch).toHaveBeenCalledTimes(2);
108
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
109
109
  });
110
110
 
111
111
  it("should handle network errors gracefully", async () => {
112
- vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Network error"));
112
+ vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error("Network error"));
113
113
 
114
114
  const result = await fetchOGData("https://example.com");
115
115
 
@@ -126,7 +126,7 @@ describe("og-fetcher", () => {
126
126
  },
127
127
  };
128
128
 
129
- vi.mocked(global.fetch).mockResolvedValueOnce({
129
+ vi.mocked(globalThis.fetch).mockResolvedValueOnce({
130
130
  ok: true,
131
131
  json: () => Promise.resolve(mockResponse),
132
132
  } as Response);
@@ -19,4 +19,4 @@ Object.defineProperty(navigator, "clipboard", {
19
19
  vi.stubGlobal("open", vi.fn());
20
20
 
21
21
  // Mock fetch for OG data tests
22
- global.fetch = vi.fn();
22
+ globalThis.fetch = vi.fn();
package/src/hooks.ts CHANGED
@@ -50,6 +50,10 @@ export interface UseShareSheetOptions {
50
50
  downloadFilename?: string;
51
51
  /** Email subject (optional) */
52
52
  emailSubject?: string;
53
+ /** File to share via native share (data URL or Blob) - takes priority over URL sharing */
54
+ shareFile?: string | Blob | null;
55
+ /** Filename for the shared file */
56
+ shareFilename?: string;
53
57
  /** Callback after native share */
54
58
  onNativeShare?: () => void;
55
59
  /** Callback after copy */
@@ -68,6 +72,8 @@ export function useShareSheet({
68
72
  downloadUrl,
69
73
  downloadFilename,
70
74
  emailSubject = "Share",
75
+ shareFile,
76
+ shareFilename = "share.png",
71
77
  onNativeShare,
72
78
  onCopy,
73
79
  onDownload,
@@ -105,12 +111,64 @@ export function useShareSheet({
105
111
  }, [safeUrl, onCopy]);
106
112
 
107
113
  const nativeShare = useCallback(async () => {
108
- if (!safeUrl) return;
109
114
  const nav = navigator as Navigator & {
110
115
  share?: (data: ShareData) => Promise<void>;
116
+ canShare?: (data: ShareData) => boolean;
111
117
  };
112
118
  if (!("share" in nav) || typeof nav.share !== "function") return;
119
+
113
120
  try {
121
+ // If shareFile is provided, try to share as a file
122
+ if (shareFile) {
123
+ let blob: Blob;
124
+
125
+ // Convert data URL to Blob if needed
126
+ if (typeof shareFile === "string" && shareFile.startsWith("data:")) {
127
+ const response = await fetch(shareFile);
128
+ blob = await response.blob();
129
+ } else if (shareFile instanceof Blob) {
130
+ blob = shareFile;
131
+ } else {
132
+ // Invalid shareFile, fall back to URL sharing
133
+ if (!safeUrl) return;
134
+ await nav.share({
135
+ title: shareText,
136
+ text: shareText,
137
+ url: safeUrl,
138
+ });
139
+ onNativeShare?.();
140
+ return;
141
+ }
142
+
143
+ // Create a File from the Blob
144
+ const file = new File([blob], shareFilename, { type: blob.type || "image/png" });
145
+
146
+ // Check if file sharing is supported
147
+ const shareData: ShareData = {
148
+ files: [file],
149
+ title: shareText,
150
+ text: shareText,
151
+ };
152
+
153
+ if (nav.canShare && !nav.canShare(shareData)) {
154
+ // File sharing not supported, fall back to URL sharing
155
+ if (!safeUrl) return;
156
+ await nav.share({
157
+ title: shareText,
158
+ text: shareText,
159
+ url: safeUrl,
160
+ });
161
+ onNativeShare?.();
162
+ return;
163
+ }
164
+
165
+ await nav.share(shareData);
166
+ onNativeShare?.();
167
+ return;
168
+ }
169
+
170
+ // Fall back to URL sharing
171
+ if (!safeUrl) return;
114
172
  await nav.share({
115
173
  title: shareText,
116
174
  text: shareText,
@@ -120,7 +178,7 @@ export function useShareSheet({
120
178
  } catch {
121
179
  // user canceled or share failed -> ignore
122
180
  }
123
- }, [safeUrl, shareText, onNativeShare]);
181
+ }, [safeUrl, shareText, shareFile, shareFilename, onNativeShare]);
124
182
 
125
183
  const downloadFile = useCallback(async () => {
126
184
  const url = (downloadUrl ?? "").trim();
package/src/types.ts CHANGED
@@ -21,7 +21,7 @@ export const CSS_VAR_UI_DEFAULTS = {
21
21
  [CSS_VARS_UI.overlayBg]: "rgba(0, 0, 0, 0.7)",
22
22
  [CSS_VARS_UI.drawerBg]: "#09090b",
23
23
  [CSS_VARS_UI.drawerBorder]: "#27272a",
24
- [CSS_VARS_UI.handleBg]: "#27272a",
24
+ [CSS_VARS_UI.handleBg]: "rgba(255, 255, 255, 0.2)",
25
25
  [CSS_VARS_UI.titleColor]: "#ffffff",
26
26
  [CSS_VARS_UI.subtitleColor]: "#a1a1aa",
27
27
  [CSS_VARS_UI.buttonLabelColor]: "#ffffff",
@@ -108,6 +108,12 @@ export interface ShareSheetContentProps {
108
108
  downloadUrl?: string | null;
109
109
  /** Filename for downloaded file */
110
110
  downloadFilename?: string;
111
+ /** Custom preview image URL (skips OG fetching if provided) */
112
+ previewImage?: string | null;
113
+ /** File to share via native share (data URL or Blob) - takes priority over URL sharing */
114
+ shareFile?: string | Blob | null;
115
+ /** Filename for the shared file */
116
+ shareFilename?: string;
111
117
  /** Custom class name for the container (shorthand for classNames.root) */
112
118
  className?: string;
113
119
  /** Override class names for sub-components */
@@ -122,6 +128,8 @@ export interface ShareSheetContentProps {
122
128
  onCopy?: () => void;
123
129
  /** Called when download starts */
124
130
  onDownload?: () => void;
131
+ /** Called on every share option click, with the option id (analytics-friendly) */
132
+ onShare?: (option: ShareOption) => void;
125
133
  /** Hide specific share options */
126
134
  hide?: ShareOption[];
127
135
  /** Show only these platforms, in this exact order */