@lotics/ui 5.5.0 → 5.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/AGENTS.md CHANGED
@@ -222,10 +222,15 @@ never removes a file. Three reusable primitives back it (the logic is shared; th
222
222
  copy stay local):
223
223
  - **`useSelectionMode()`** (`@lotics/ui/use_selection_mode`) — `{ active, selected, enter, exit, toggle,
224
224
  toggleAll }`, an agnostic multi-select state machine (string ids; pairs with the grid's `selectedIds`).
225
- - **`shareOrDownloadFiles(files, { title })`** (`@lotics/ui/share_or_download`) — `navigator.share({ files })`
225
+ - **`shareOrDownloadFiles(files, { title, credentials })`** (`@lotics/ui/share_or_download`) — `navigator.share({ files })`
226
226
  (mobile → Save to gallery / send to an app), else individual `downloadFileFromUrl` — **never a ZIP**.
227
- It shares the BYTES (fetches each presigned URL → `File` at the tap), so URL expiry afterward is moot;
228
- the share path needs the host iframe to grant `allow="web-share"` (else it falls back to download).
227
+ It shares the BYTES (fetches each URL → `File`), so URL expiry afterward is moot; the share path needs the
228
+ host iframe to grant `allow="web-share"` (else it falls back to download). Best for FAST urls (presigned
229
+ R2). **For SLOW urls (auth-gated proxy with a server round-trip), split it:** `prepareShareFiles(files,
230
+ {credentials})` fetches the `File[]` BEFORE the gesture (on menu-open), then `shareFiles(File[])` runs
231
+ `navigator.share` synchronously on the tap — **iOS Safari rejects `share()` if a slow fetch burns the
232
+ tap's transient activation**, which silently lands in the download fallback. `prepareShareFiles` throws on
233
+ a fetch failure (log it, don't swallow); `shareFiles` returns `"shared"|"cancelled"|"unsupported"`.
229
234
  - **`rotateImageToBlob(url, degrees)`** (`@lotics/ui/rotate_image`) — canvas-bake a 90° rotation into a
230
235
  NEW blob for re-upload (`useImageRotation` is view-only; this is how a rotation is persisted). Pair with
231
236
  `FileGalleryModal`'s `onPersistRotation`/`persisting` (the ✓ shown on a rotated image).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "5.5.0",
3
+ "version": "5.6.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -178,6 +178,9 @@ const styles = StyleSheet.create({
178
178
  track: {
179
179
  flexDirection: "row",
180
180
  alignSelf: "flex-start",
181
+ // Match the 40px system control height so it aligns with inputs/buttons in a
182
+ // toolbar band; segments stretch (alignItems defaults to "stretch") to fill it.
183
+ height: 40,
181
184
  gap: 2,
182
185
  padding: 3,
183
186
  borderRadius: 10,
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, afterEach } from "vitest";
2
- import { shareOrDownloadFiles } from "./share_or_download";
2
+ import { shareOrDownloadFiles, prepareShareFiles, shareFiles } from "./share_or_download";
3
3
 
4
4
  afterEach(() => {
5
5
  vi.restoreAllMocks();
@@ -48,3 +48,45 @@ describe("shareOrDownloadFiles", () => {
48
48
  expect(await shareOrDownloadFiles([{ url: "u", filename: "a.png" }])).toEqual({ delivered: "share", count: 0 });
49
49
  });
50
50
  });
51
+
52
+ describe("prepareShareFiles + shareFiles (iOS: fetch ahead of the gesture, share synchronously)", () => {
53
+ it("prepareShareFiles fetches the bytes into File[] with the given credentials", async () => {
54
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(new Blob(["img"], { type: "image/png" })));
55
+ vi.stubGlobal("fetch", fetchMock);
56
+ vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn() });
57
+
58
+ const files = await prepareShareFiles([{ url: "https://api/v1/files/k", filename: "a.png" }], { credentials: "include" });
59
+
60
+ expect(files).not.toBeNull();
61
+ expect(files?.[0]).toBeInstanceOf(File);
62
+ expect(files?.[0].name).toBe("a.png");
63
+ expect(fetchMock.mock.calls[0][1]).toMatchObject({ credentials: "include" });
64
+ });
65
+
66
+ it("prepareShareFiles returns null when Web Share (files) is unsupported", async () => {
67
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"]))));
68
+ vi.stubGlobal("navigator", {}); // no canShare / share
69
+ expect(await prepareShareFiles([{ url: "u", filename: "a.png" }])).toBeNull();
70
+ });
71
+
72
+ it("prepareShareFiles THROWS on a fetch failure (caller logs it, not a silent download)", async () => {
73
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 401 })));
74
+ vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn() });
75
+ await expect(prepareShareFiles([{ url: "u", filename: "a.png" }])).rejects.toThrow(/401/);
76
+ });
77
+
78
+ it("shareFiles shares the SAME pre-fetched File (no re-fetch) and maps cancel/unsupported", async () => {
79
+ const share = vi.fn(async (_d: { files: File[] }) => undefined);
80
+ vi.stubGlobal("navigator", { canShare: () => true, share });
81
+ const file = new File(["x"], "a.png", { type: "image/png" });
82
+
83
+ expect(await shareFiles([file], { title: "T" })).toBe("shared");
84
+ expect(share.mock.calls[0][0].files[0]).toBe(file); // the exact pre-fetched File crosses the boundary
85
+
86
+ vi.stubGlobal("navigator", { share: vi.fn(async () => { throw new DOMException("x", "AbortError"); }) });
87
+ expect(await shareFiles([file])).toBe("cancelled");
88
+
89
+ vi.stubGlobal("navigator", {}); // no share
90
+ expect(await shareFiles([file])).toBe("unsupported");
91
+ });
92
+ });
@@ -5,8 +5,15 @@
5
5
  //
6
6
  // Web Share inside an iframe requires the host to grant `allow="web-share"` on
7
7
  // the iframe element; without it `navigator.canShare({ files })` is false and we
8
- // fall through to downloads. Must be invoked from a user gesture (a click) — the
9
- // blob fetches happen inside that gesture's task so the share keeps activation.
8
+ // fall through to downloads.
9
+ //
10
+ // iOS Safari quirk: `navigator.share` MUST be called within the tap's *transient
11
+ // activation*. Awaiting a SLOW blob fetch before it (e.g. an auth-gated proxy URL
12
+ // with a server-side R2 round-trip) burns the activation → `share()` rejects →
13
+ // the caller falls back to a download. So for slow URLs, fetch the File[] BEFORE
14
+ // the gesture with `prepareShareFiles`, then call `shareFiles` synchronously on
15
+ // the tap. `shareOrDownloadFiles` is the all-in-one convenience for FAST URLs
16
+ // (presigned R2) where the fetch fits inside the activation window.
10
17
  //
11
18
  // Pure web, no React Native / @lotics/shared imports — consumable by both the
12
19
  // host frontend and sandboxed custom-code apps via the per-file export.
@@ -23,6 +30,10 @@ export type ShareOrDownloadResult =
23
30
  | { delivered: "share"; count: number }
24
31
  | { delivered: "download"; count: number };
25
32
 
33
+ /** "shared" = the sheet completed · "cancelled" = the user dismissed it ·
34
+ * "unsupported" = Web Share (files) unavailable. A genuine share failure THROWS. */
35
+ export type ShareFilesResult = "shared" | "cancelled" | "unsupported";
36
+
26
37
  async function toFile(f: ShareableFile, credentials: RequestCredentials): Promise<File> {
27
38
  const res = await fetch(f.url, { cache: "no-store", credentials });
28
39
  if (!res.ok) throw new Error(`fetch failed: ${res.status} ${res.statusText}`);
@@ -30,6 +41,49 @@ async function toFile(f: ShareableFile, credentials: RequestCredentials): Promis
30
41
  return new File([blob], f.filename, { type: f.mimeType || blob.type || "application/octet-stream" });
31
42
  }
32
43
 
44
+ /**
45
+ * Fetch `files` into File objects ready for {@link shareFiles}, to be done BEFORE
46
+ * the share gesture so the share call itself stays synchronous (the iOS note
47
+ * above). Returns the File[] when Web Share (files) is supported and the set is
48
+ * shareable; returns `null` when Web Share is unavailable or the set isn't
49
+ * shareable (the caller should download instead). THROWS if a fetch fails, so the
50
+ * caller can log the real reason rather than silently downloading.
51
+ */
52
+ export async function prepareShareFiles(
53
+ files: ShareableFile[],
54
+ opts?: { credentials?: RequestCredentials },
55
+ ): Promise<File[] | null> {
56
+ const valid = files.filter((f) => f.url);
57
+ if (valid.length === 0) return null;
58
+ const nav = typeof navigator !== "undefined" ? navigator : undefined;
59
+ if (!nav || typeof nav.canShare !== "function" || typeof nav.share !== "function") return null;
60
+ const fileObjs = await Promise.all(valid.map((f) => toFile(f, opts?.credentials ?? "same-origin")));
61
+ if (fileObjs.length === 0 || !nav.canShare({ files: fileObjs })) return null;
62
+ return fileObjs;
63
+ }
64
+
65
+ /**
66
+ * Share already-fetched File objects via the OS sheet. Call SYNCHRONOUSLY from a
67
+ * user gesture (no awaited fetch before it) so iOS keeps the transient activation.
68
+ * "cancelled" = user dismissed · "unsupported" = no Web Share; a real failure THROWS.
69
+ */
70
+ export async function shareFiles(fileObjs: File[], opts?: { title?: string; text?: string }): Promise<ShareFilesResult> {
71
+ const nav = typeof navigator !== "undefined" ? navigator : undefined;
72
+ if (!nav || typeof nav.share !== "function" || fileObjs.length === 0) return "unsupported";
73
+ try {
74
+ await nav.share({ files: fileObjs, title: opts?.title, text: opts?.text });
75
+ return "shared";
76
+ } catch (err) {
77
+ if (err instanceof DOMException && err.name === "AbortError") return "cancelled";
78
+ throw err;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * All-in-one: fetch + share, else download each file (never a ZIP). Best for FAST
84
+ * URLs (presigned R2) where the fetch fits inside the iOS activation window; for
85
+ * slow URLs, prepare ahead with {@link prepareShareFiles} + {@link shareFiles}.
86
+ */
33
87
  export async function shareOrDownloadFiles(
34
88
  files: ShareableFile[],
35
89
  opts?: { title?: string; text?: string; credentials?: RequestCredentials },