react-sharesheet 1.9.0 → 1.10.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.
Files changed (44) hide show
  1. package/README.md +48 -13
  2. package/dist/content.d.mts +3 -3
  3. package/dist/content.d.ts +3 -3
  4. package/dist/content.js +12 -5
  5. package/dist/content.js.map +1 -1
  6. package/dist/content.mjs +11 -5
  7. package/dist/content.mjs.map +1 -1
  8. package/dist/drawer.d.mts +3 -3
  9. package/dist/drawer.d.ts +3 -3
  10. package/dist/drawer.js +40 -17
  11. package/dist/drawer.js.map +1 -1
  12. package/dist/drawer.mjs +29 -17
  13. package/dist/drawer.mjs.map +1 -1
  14. package/dist/{headless-lxSw27f0.d.ts → headless-CEUr7QJE.d.ts} +5 -14
  15. package/dist/{headless-x5iUF86E.d.mts → headless-JYVQPj9Q.d.mts} +5 -14
  16. package/dist/headless.d.mts +2 -2
  17. package/dist/headless.d.ts +2 -2
  18. package/dist/headless.js +6 -2
  19. package/dist/headless.js.map +1 -1
  20. package/dist/headless.mjs +7 -3
  21. package/dist/headless.mjs.map +1 -1
  22. package/dist/index.d.mts +2 -2
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.js +36 -15
  25. package/dist/index.js.map +1 -1
  26. package/dist/index.mjs +27 -16
  27. package/dist/index.mjs.map +1 -1
  28. package/dist/{platforms-BtmTZmgl.d.mts → platforms-B1VXTDkO.d.mts} +20 -2
  29. package/dist/{platforms-BtmTZmgl.d.ts → platforms-B1VXTDkO.d.ts} +20 -2
  30. package/package.json +2 -2
  31. package/src/ShareSheetContent.tsx +4 -2
  32. package/src/ShareSheetDrawer.tsx +32 -9
  33. package/src/__tests__/drawer-trigger.test.tsx +50 -0
  34. package/src/__tests__/hooks.test.ts +3 -3
  35. package/src/__tests__/og-fetcher.test.ts +9 -9
  36. package/src/__tests__/preview-fetch.test.tsx +82 -0
  37. package/src/__tests__/setup.ts +1 -1
  38. package/src/content.ts +1 -0
  39. package/src/drawer.ts +1 -0
  40. package/src/headless.ts +1 -1
  41. package/src/hooks.ts +12 -5
  42. package/src/index.ts +1 -1
  43. package/src/og-fetcher.ts +6 -1
  44. package/src/types.ts +8 -1
@@ -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);
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import { render, renderHook, waitFor } from "@testing-library/react";
3
+
4
+ import { ShareSheetContent } from "../ShareSheetContent";
5
+ import { ShareSheetDrawer } from "../ShareSheetDrawer";
6
+ import { useOGData } from "../hooks";
7
+ import { clearOGCache, type OGFetcher } from "../og-fetcher";
8
+
9
+ const card = { title: "A page", image: "https://example.com/card.png", url: "https://example.com/p" };
10
+
11
+ describe("link preview lookup", () => {
12
+ beforeEach(() => {
13
+ vi.clearAllMocks();
14
+ clearOGCache();
15
+ });
16
+
17
+ it("sends the link nowhere by default: no request, a link placeholder", async () => {
18
+ const { container } = render(<ShareSheetContent shareUrl="https://example.com/private?seed=42" shareText="hi" />);
19
+
20
+ // Give a stray effect the chance to fire before looking
21
+ await new Promise((resolve) => setTimeout(resolve, 20));
22
+ expect(globalThis.fetch).not.toHaveBeenCalled();
23
+ expect(container.querySelector("img")).toBeNull();
24
+ });
25
+
26
+ it("looks the link up with the fetcher the caller passes, and shows its image", async () => {
27
+ const fetchPreview = vi.fn<OGFetcher>().mockResolvedValue(card);
28
+ const { container } = render(
29
+ <ShareSheetContent shareUrl="https://example.com/p" shareText="hi" fetchPreview={fetchPreview} />,
30
+ );
31
+
32
+ await waitFor(() => expect(container.querySelector("img")).not.toBeNull());
33
+ expect(fetchPreview).toHaveBeenCalledTimes(1);
34
+ expect(fetchPreview).toHaveBeenCalledWith("https://example.com/p");
35
+ expect(container.querySelector("img")).toHaveAttribute("src", card.image);
36
+ expect(globalThis.fetch).not.toHaveBeenCalled();
37
+ });
38
+
39
+ it("never looks up a link that already has a preview image", async () => {
40
+ const fetchPreview = vi.fn<OGFetcher>().mockResolvedValue(card);
41
+ render(
42
+ <ShareSheetContent
43
+ shareUrl="https://example.com/p"
44
+ shareText="hi"
45
+ previewImage="data:image/png;base64,AAAA"
46
+ fetchPreview={fetchPreview}
47
+ />,
48
+ );
49
+
50
+ await new Promise((resolve) => setTimeout(resolve, 20));
51
+ expect(fetchPreview).not.toHaveBeenCalled();
52
+ expect(globalThis.fetch).not.toHaveBeenCalled();
53
+ });
54
+
55
+ it("the drawer hands the fetcher to its sheet", async () => {
56
+ const fetchPreview = vi.fn<OGFetcher>().mockResolvedValue(card);
57
+ render(
58
+ <ShareSheetDrawer shareUrl="https://example.com/p" shareText="hi" fetchPreview={fetchPreview} open onOpenChange={() => {}}>
59
+ <button type="button">Share</button>
60
+ </ShareSheetDrawer>,
61
+ );
62
+
63
+ await waitFor(() => expect(fetchPreview).toHaveBeenCalledWith("https://example.com/p"));
64
+ });
65
+
66
+ it("useOGData does not refetch when the caller passes a new inline fetcher each render", async () => {
67
+ const calls: string[] = [];
68
+ const { result, rerender } = renderHook(() =>
69
+ useOGData("https://example.com/p", async (url) => {
70
+ calls.push(url);
71
+ return card;
72
+ }),
73
+ );
74
+
75
+ await waitFor(() => expect(result.current.ogData).toEqual(card));
76
+ rerender();
77
+ rerender();
78
+ await new Promise((resolve) => setTimeout(resolve, 20));
79
+ expect(calls).toEqual(["https://example.com/p"]);
80
+ expect(globalThis.fetch).not.toHaveBeenCalled();
81
+ });
82
+ });
@@ -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/content.ts CHANGED
@@ -2,3 +2,4 @@ export { ShareSheetContent, ShareMenuContent } from "./ShareSheetContent";
2
2
  export type { ShareSheetContentProps, ShareSheetContentClassNames, ShareMenuContentProps, ShareMenuContentClassNames, ShareOption } from "./types";
3
3
  export { CSS_VARS_UI, CSS_VAR_UI_DEFAULTS, CSS_VARS, CSS_VAR_DEFAULTS } from "./types";
4
4
  export { PLATFORM_COLORS, PLATFORM_CSS_VARS } from "./platforms";
5
+ export { fetchOGData, type OGData, type OGFetcher } from "./og-fetcher";
package/src/drawer.ts CHANGED
@@ -2,3 +2,4 @@ export { ShareSheetDrawer, ShareMenuDrawer } from "./ShareSheetDrawer";
2
2
  export type { ShareSheetDrawerProps, ShareSheetDrawerClassNames, ShareMenuDrawerProps, ShareMenuDrawerClassNames, ShareOption } from "./types";
3
3
  export { CSS_VARS_UI, CSS_VAR_UI_DEFAULTS, CSS_VARS, CSS_VAR_DEFAULTS } from "./types";
4
4
  export { PLATFORM_COLORS, PLATFORM_CSS_VARS } from "./platforms";
5
+ export { fetchOGData, type OGData, type OGFetcher } from "./og-fetcher";
package/src/headless.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  export { useShareSheet, useShareMenu, useOGData, type UseShareSheetOptions, type UseShareMenuOptions } from "./hooks";
4
4
 
5
5
  // OG Data fetcher
6
- export { fetchOGData, clearOGCache, type OGData } from "./og-fetcher";
6
+ export { fetchOGData, clearOGCache, type OGData, type OGFetcher } from "./og-fetcher";
7
7
 
8
8
  export {
9
9
  shareToWhatsApp,
package/src/hooks.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useMemo, useState, useCallback, useEffect } from "react";
3
+ import { useMemo, useState, useCallback, useEffect, useRef } from "react";
4
4
  import { getSafeUrl, isMobileDevice, getAllPlatformAvailability } from "./utils";
5
- import { fetchOGData, type OGData } from "./og-fetcher";
5
+ import { fetchOGData, type OGData, type OGFetcher } from "./og-fetcher";
6
6
  import {
7
7
  shareToWhatsApp,
8
8
  shareToTelegram,
@@ -279,9 +279,10 @@ export function useShareSheet({
279
279
 
280
280
  /**
281
281
  * Hook to fetch OG (Open Graph) data from a URL.
282
- * Automatically fetches and caches OG metadata for link previews.
282
+ * Fetches with `fetcher`, the Microlink lookup (`fetchOGData`, cached) unless another is given:
283
+ * calling this hook is the opt-in, since the lookup sends `url` to a third party.
283
284
  */
284
- export function useOGData(url: string | undefined): {
285
+ export function useOGData(url: string | undefined, fetcher: OGFetcher = fetchOGData): {
285
286
  ogData: OGData | null;
286
287
  loading: boolean;
287
288
  error: string | null;
@@ -289,6 +290,12 @@ export function useOGData(url: string | undefined): {
289
290
  const [ogData, setOgData] = useState<OGData | null>(null);
290
291
  const [loading, setLoading] = useState(false);
291
292
  const [error, setError] = useState<string | null>(null);
293
+ // Read at fetch time, so an inline fetcher does not refetch on every render
294
+ // (this effect runs before the one below in the same commit)
295
+ const fetcherRef = useRef(fetcher);
296
+ useEffect(() => {
297
+ fetcherRef.current = fetcher;
298
+ });
292
299
 
293
300
  useEffect(() => {
294
301
  if (!url) {
@@ -302,7 +309,7 @@ export function useOGData(url: string | undefined): {
302
309
  setLoading(true);
303
310
  setError(null);
304
311
 
305
- fetchOGData(url)
312
+ fetcherRef.current(url)
306
313
  .then((data) => {
307
314
  if (!cancelled) {
308
315
  setOgData(data);
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ export { ShareSheetDrawer, ShareMenuDrawer } from "./ShareSheetDrawer";
6
6
  export { useShareSheet, useShareMenu, useOGData, type UseShareSheetOptions, type UseShareMenuOptions } from "./hooks";
7
7
 
8
8
  // OG Data fetcher
9
- export { fetchOGData, clearOGCache, type OGData } from "./og-fetcher";
9
+ export { fetchOGData, clearOGCache, type OGData, type OGFetcher } from "./og-fetcher";
10
10
 
11
11
  // Types
12
12
  export type {
package/src/og-fetcher.ts CHANGED
@@ -1,4 +1,6 @@
1
- // OG Data fetcher using Microlink API (free, no API key required)
1
+ // OG data fetcher using the Microlink API (free, no API key required).
2
+ // The sheet never calls it on its own: a caller opts in with `fetchPreview={fetchOGData}`,
3
+ // because the lookup sends the shared link to a third party.
2
4
 
3
5
  export interface OGData {
4
6
  title?: string;
@@ -8,6 +10,9 @@ export interface OGData {
8
10
  siteName?: string;
9
11
  }
10
12
 
13
+ /** Looks up a link's card. Resolves to null when there is none. */
14
+ export type OGFetcher = (url: string) => Promise<OGData | null>;
15
+
11
16
  export interface OGFetchResult {
12
17
  data: OGData | null;
13
18
  loading: boolean;
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import type { OGFetcher } from "./og-fetcher";
2
3
 
3
4
  /** CSS variable names for UI elements (non-platform specific) */
4
5
  export const CSS_VARS_UI = {
@@ -100,7 +101,7 @@ export interface ShareSheetDrawerClassNames extends ShareSheetContentClassNames
100
101
  export interface ShareSheetContentProps {
101
102
  /** Title displayed at the top of the sheet */
102
103
  title?: string;
103
- /** URL to share (OG preview will be fetched automatically) */
104
+ /** URL to share */
104
105
  shareUrl: string;
105
106
  /** Text to share alongside the URL */
106
107
  shareText: string;
@@ -110,6 +111,12 @@ export interface ShareSheetContentProps {
110
111
  downloadFilename?: string;
111
112
  /** Custom preview image URL (skips OG fetching if provided) */
112
113
  previewImage?: string | null;
114
+ /**
115
+ * Looks up the link's card when there is no `previewImage`. Unset (the default), no request
116
+ * leaves the page and the sheet shows a link placeholder. Pass `fetchOGData` for the Microlink
117
+ * lookup the sheet made by itself before 1.10.0: it sends `shareUrl` to api.microlink.io.
118
+ */
119
+ fetchPreview?: OGFetcher;
113
120
  /** File to share via native share (data URL or Blob) - takes priority over URL sharing */
114
121
  shareFile?: string | Blob | null;
115
122
  /** Filename for the shared file */