@lotics/ui 5.1.1 → 5.2.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/AGENTS.md CHANGED
@@ -73,7 +73,8 @@ Pick by capability, not by name. (→ the source file for the API.)
73
73
  - **Status / feedback** — `Badge` / `StatusBadge`, `Callout` (inline status), `EmptyState`,
74
74
  `CompletionState`, `ActivityIndicator` / `Loading`, `Skeleton`.
75
75
  - **Files** — `FileDropzone`, `FileThumbnail` / `FileThumbnailGrid`, `FilePreview` /
76
- `FileGalleryModal`, `ImageGallery` (see §Data entry Attachments).
76
+ `FileGalleryModal`, `ImageGallery`; for gated CRUD compose locally with `useSelectionMode` +
77
+ `shareOrDownloadFiles` + `rotateImageToBlob` (see §Data entry → Attachments).
77
78
  - **Specialized work surfaces** — `ScanField` (scan/verify), `StepList` (guided run),
78
79
  `RemainderMeter` + `AllocationRow` (allocation), `Timeline`, `Calendar`, `Gantt`,
79
80
  `comments_thread`.
@@ -208,6 +209,25 @@ instant-preview lifecycle (local object-URL now, `file_id` on complete); map eac
208
209
  `DisplayFile` (snake → camel: `preview_url`→`url`, `mime_type`→`mimeType`) for
209
210
  `<FileThumbnail file={…} uploading={f.status === "uploading"} />`.
210
211
 
212
+ **Gated file CRUD is a PATTERN, composed locally — not a sealed kit component.** When a file field must
213
+ GUARD deletion (operator-facing, accidental delete is a real risk), compose it in the app from the kit
214
+ pieces; don't reach for a one-size widget (the action set + layout vary per app — the host frontend's
215
+ `files_editor` and each app compose their own). The recipe: `FileDropzone` (add) + `FileThumbnailGrid
216
+ files selectedIds onFilePress` (pass `selectedIds` ONLY in select mode) + `FileGalleryModal` (view +
217
+ rotate + persist) + a `Dialog` confirm, all driven by **`useSelectionMode()`** — the reusable LOGIC. The
218
+ gating rule: **NO per-thumbnail ✕** — delete is **Select → ⋯ menu → Delete → confirm**, so a stray tap
219
+ never removes a file. Three reusable primitives back it (the logic is shared; the action bar + layout +
220
+ copy stay local):
221
+ - **`useSelectionMode()`** (`@lotics/ui/use_selection_mode`) — `{ active, selected, enter, exit, toggle,
222
+ toggleAll }`, an agnostic multi-select state machine (string ids; pairs with the grid's `selectedIds`).
223
+ - **`shareOrDownloadFiles(files, { title })`** (`@lotics/ui/share_or_download`) — `navigator.share({ files })`
224
+ (mobile → Save to gallery / send to an app), else individual `downloadFileFromUrl` — **never a ZIP**.
225
+ It shares the BYTES (fetches each presigned URL → `File` at the tap), so URL expiry afterward is moot;
226
+ the share path needs the host iframe to grant `allow="web-share"` (else it falls back to download).
227
+ - **`rotateImageToBlob(url, degrees)`** (`@lotics/ui/rotate_image`) — canvas-bake a 90° rotation into a
228
+ NEW blob for re-upload (`useImageRotation` is view-only; this is how a rotation is persisted). Pair with
229
+ `FileGalleryModal`'s `onPersistRotation`/`persisting` (the ✓ shown on a rotated image).
230
+
211
231
  ### Quick capture — the SPEED surface
212
232
  For fast repeat entry (logging activity, expenses), one compact capture row (a `SegmentedControl`
213
233
  mode, a `Combobox`-as-select, a `Counter`, an Enter-to-submit `TextInputField`); each entry commits
@@ -565,7 +585,7 @@ CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip
565
585
  bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
566
586
  status_grid (StatusGrid + StatusLegend) · heatmap · legend_item · remainder_meter · allocation_row ·
567
587
  scan_field · file_dropzone · file_thumbnail · file_thumbnail_grid · file_preview ·
568
- file_gallery_modal · image_gallery · avatar · skeleton · activity_indicator · loading · divider ·
588
+ file_gallery_modal · image_gallery · use_selection_mode · share_or_download · rotate_image · avatar · skeleton · activity_indicator · loading · divider ·
569
589
  spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
570
590
  comments_thread · agent_run (live streaming work feed) · agent_progress (its compact floating
571
591
  expandable form — a composer's working state) · composer (Composer — the adaptive command/chat
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "5.1.1",
3
+ "version": "5.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -11,6 +11,8 @@
11
11
  "./inline_member_select": "./src/inline_member_select.tsx",
12
12
  "./mime": "./src/mime.ts",
13
13
  "./download": "./src/download.ts",
14
+ "./share_or_download": "./src/share_or_download.ts",
15
+ "./rotate_image": "./src/rotate_image.ts",
14
16
  "./file_picker": "./src/file_picker.ts",
15
17
  "./comments_thread": "./src/comments_thread.tsx",
16
18
  "./file_badge": "./src/file_badge.tsx",
@@ -25,6 +27,7 @@
25
27
  "./image_gallery": "./src/image_gallery.tsx",
26
28
  "./rotatable_image": "./src/rotatable_image.tsx",
27
29
  "./use_image_rotation": "./src/use_image_rotation.ts",
30
+ "./use_selection_mode": "./src/use_selection_mode.ts",
28
31
  "./file_thumbnail_grid": "./src/file_thumbnail_grid.tsx",
29
32
  "./pagination": "./src/pagination.tsx",
30
33
  "./bar_chart": "./src/bar_chart.tsx",
@@ -42,6 +42,15 @@ export interface FileGalleryModalProps {
42
42
  rotation?: ImageRotation;
43
43
  /** Show 90° rotate controls for image files. Default true. */
44
44
  rotatable?: boolean;
45
+ /**
46
+ * Persist the active image's current view-rotation as a new stored file. When
47
+ * provided and the active image is rotated, a confirm (✓) control appears next
48
+ * to the rotate buttons. The host bakes the rotation + swaps the file, then
49
+ * typically closes the modal. View-rotation is reported in degrees (90/180/270).
50
+ */
51
+ onPersistRotation?: (file: DisplayFile, degrees: number) => void;
52
+ /** Disables the persist control while a rotation save is in flight. */
53
+ persisting?: boolean;
45
54
  }
46
55
 
47
56
  /**
@@ -49,7 +58,7 @@ export interface FileGalleryModalProps {
49
58
  * via FilePreview; unknown types show a download placeholder.
50
59
  */
51
60
  export function FileGalleryModal(props: FileGalleryModalProps) {
52
- const { files, activeIndex, onIndexChange, captionHint, labels, onError, rotation, rotatable = true } = props;
61
+ const { files, activeIndex, onIndexChange, captionHint, labels, onError, rotation, rotatable = true, onPersistRotation, persisting } = props;
53
62
  const visible = activeIndex !== null;
54
63
  const overlayRef = useRef<View | null>(null);
55
64
  // Own the rotation when the host doesn't share one (standalone consumers).
@@ -132,6 +141,17 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
132
141
  >
133
142
  <Icon name="rotate-cw" size={20} color={colors.white} />
134
143
  </Pressable>
144
+ {onPersistRotation && rot.rotationFor(file.id) !== 0 ? (
145
+ <Pressable
146
+ onPress={() => { if (!persisting) onPersistRotation(file, rot.rotationFor(file.id)); }}
147
+ accessibilityRole="button"
148
+ accessibilityLabel="Save rotation"
149
+ disabled={persisting}
150
+ style={[styles.controlButton, styles.controlButtonPrimary, persisting && styles.controlButtonBusy]}
151
+ >
152
+ <Icon name="check" size={20} color={colors.white} />
153
+ </Pressable>
154
+ ) : null}
135
155
  </View>
136
156
  ) : null}
137
157
 
@@ -213,6 +233,12 @@ const styles = StyleSheet.create({
213
233
  justifyContent: "center",
214
234
  alignItems: "center",
215
235
  },
236
+ controlButtonPrimary: {
237
+ backgroundColor: colors.blue["600"],
238
+ },
239
+ controlButtonBusy: {
240
+ opacity: 0.6,
241
+ },
216
242
  captionWrap: {
217
243
  position: "absolute",
218
244
  bottom: 24,
@@ -0,0 +1,39 @@
1
+ // Bake a quarter-turn rotation into a NEW image blob via canvas — the persisting
2
+ // half of image rotation (useImageRotation is view-only). A consumer fetches the
3
+ // stored image, rotates the pixels here, and re-uploads the result as a fresh
4
+ // file. Pure web (canvas / createImageBitmap), no React Native imports.
5
+
6
+ export async function rotateImageToBlob(
7
+ url: string,
8
+ degrees: number,
9
+ opts?: { credentials?: RequestCredentials; type?: string; quality?: number },
10
+ ): Promise<Blob> {
11
+ const norm = (((degrees % 360) + 360) % 360);
12
+ const res = await fetch(url, { cache: "no-store", credentials: opts?.credentials ?? "same-origin" });
13
+ if (!res.ok) throw new Error(`rotateImageToBlob: fetch failed ${res.status} ${res.statusText}`);
14
+ const srcBlob = await res.blob();
15
+ if (norm === 0) return srcBlob; // no-op rotation — return the bytes untouched
16
+
17
+ const bitmap = await createImageBitmap(srcBlob);
18
+ try {
19
+ const swap = norm === 90 || norm === 270;
20
+ const canvas = document.createElement("canvas");
21
+ canvas.width = swap ? bitmap.height : bitmap.width;
22
+ canvas.height = swap ? bitmap.width : bitmap.height;
23
+ const ctx = canvas.getContext("2d");
24
+ if (!ctx) throw new Error("rotateImageToBlob: no 2d canvas context");
25
+ ctx.translate(canvas.width / 2, canvas.height / 2);
26
+ ctx.rotate((norm * Math.PI) / 180);
27
+ ctx.drawImage(bitmap, -bitmap.width / 2, -bitmap.height / 2);
28
+ const type = opts?.type || (srcBlob.type.startsWith("image/") ? srcBlob.type : "image/jpeg");
29
+ return await new Promise<Blob>((resolve, reject) =>
30
+ canvas.toBlob(
31
+ (b) => (b ? resolve(b) : reject(new Error("rotateImageToBlob: toBlob produced no blob"))),
32
+ type,
33
+ opts?.quality ?? 0.92,
34
+ ),
35
+ );
36
+ } finally {
37
+ bitmap.close();
38
+ }
39
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { shareOrDownloadFiles } from "./share_or_download";
3
+
4
+ afterEach(() => {
5
+ vi.restoreAllMocks();
6
+ vi.unstubAllGlobals();
7
+ });
8
+
9
+ describe("shareOrDownloadFiles", () => {
10
+ it("is a no-op (download/0) when there are no usable files", async () => {
11
+ expect(await shareOrDownloadFiles([])).toEqual({ delivered: "download", count: 0 });
12
+ expect(await shareOrDownloadFiles([{ url: "", filename: "x.png" }])).toEqual({ delivered: "download", count: 0 });
13
+ });
14
+
15
+ it("hands the BYTES (not the URL) to the OS share sheet when files can be shared", async () => {
16
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"], { type: "image/png" }))));
17
+ // navigator.share is a true external boundary — assert WHAT crosses it. Typing
18
+ // the mock's param makes mock.calls[0][0] the shared payload.
19
+ const share = vi.fn(async (_data: { files: File[]; title?: string }) => undefined);
20
+ vi.stubGlobal("navigator", { canShare: () => true, share });
21
+
22
+ const res = await shareOrDownloadFiles([{ url: "https://r2/presigned", filename: "a.png", mimeType: "image/png" }], { title: "Ảnh" });
23
+
24
+ expect(res).toEqual({ delivered: "share", count: 1 });
25
+ const shared = share.mock.calls[0][0];
26
+ expect(shared.files).toHaveLength(1);
27
+ expect(shared.files[0]).toBeInstanceOf(File); // bytes, not a link → no expiry problem
28
+ expect(shared.files[0].name).toBe("a.png");
29
+ expect(shared.title).toBe("Ảnh");
30
+ });
31
+
32
+ it("treats a cancelled share sheet (AbortError) as done — never falls back to downloading", async () => {
33
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"], { type: "image/png" }))));
34
+ const share = vi.fn(async () => { throw new DOMException("cancelled", "AbortError"); });
35
+ vi.stubGlobal("navigator", { canShare: () => true, share });
36
+
37
+ expect(await shareOrDownloadFiles([{ url: "u", filename: "a.png" }])).toEqual({ delivered: "share", count: 0 });
38
+ });
39
+ });
@@ -0,0 +1,69 @@
1
+ // Share a set of files via the OS share sheet, or fall back to downloading them
2
+ // individually — never a ZIP. On mobile the share sheet is the path to "Save to
3
+ // Photos / gallery" and "send to <app>" (Web Share API Level 2, files variant);
4
+ // on desktop or where file-share is unsupported, each file downloads on its own.
5
+ //
6
+ // Web Share inside an iframe requires the host to grant `allow="web-share"` on
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.
10
+ //
11
+ // Pure web, no React Native / @lotics/shared imports — consumable by both the
12
+ // host frontend and sandboxed custom-code apps via the per-file export.
13
+
14
+ import { downloadFileFromUrl } from "./download";
15
+
16
+ export interface ShareableFile {
17
+ url: string;
18
+ filename: string;
19
+ mimeType?: string;
20
+ }
21
+
22
+ export type ShareOrDownloadResult =
23
+ | { delivered: "share"; count: number }
24
+ | { delivered: "download"; count: number };
25
+
26
+ async function toFile(f: ShareableFile, credentials: RequestCredentials): Promise<File> {
27
+ const res = await fetch(f.url, { cache: "no-store", credentials });
28
+ if (!res.ok) throw new Error(`fetch failed: ${res.status} ${res.statusText}`);
29
+ const blob = await res.blob();
30
+ return new File([blob], f.filename, { type: f.mimeType || blob.type || "application/octet-stream" });
31
+ }
32
+
33
+ export async function shareOrDownloadFiles(
34
+ files: ShareableFile[],
35
+ opts?: { title?: string; text?: string; credentials?: RequestCredentials },
36
+ ): Promise<ShareOrDownloadResult> {
37
+ const credentials = opts?.credentials ?? "same-origin";
38
+ const valid = files.filter((f) => f.url);
39
+ if (valid.length === 0) return { delivered: "download", count: 0 };
40
+
41
+ // Prefer the share sheet (mobile gallery / send). Feature-detect with canShare
42
+ // over real File objects so an iframe without `allow="web-share"` falls through.
43
+ const nav = typeof navigator !== "undefined" ? navigator : undefined;
44
+ if (nav && typeof nav.canShare === "function" && typeof nav.share === "function") {
45
+ let fileObjs: File[];
46
+ try {
47
+ fileObjs = await Promise.all(valid.map((f) => toFile(f, credentials)));
48
+ } catch {
49
+ fileObjs = [];
50
+ }
51
+ if (fileObjs.length > 0 && nav.canShare({ files: fileObjs })) {
52
+ try {
53
+ await nav.share({ files: fileObjs, title: opts?.title, text: opts?.text });
54
+ return { delivered: "share", count: fileObjs.length };
55
+ } catch (err) {
56
+ // User dismissed the sheet — done, not an error, do NOT also download.
57
+ if (err instanceof DOMException && err.name === "AbortError") {
58
+ return { delivered: "share", count: 0 };
59
+ }
60
+ // Anything else (share genuinely failed) → fall through to downloads.
61
+ }
62
+ }
63
+ }
64
+
65
+ for (const f of valid) {
66
+ await downloadFileFromUrl(f.url, f.filename, { credentials });
67
+ }
68
+ return { delivered: "download", count: valid.length };
69
+ }
@@ -0,0 +1,47 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ /**
4
+ * A "selection mode" state machine for a multi-select list (files, table rows, …):
5
+ * enter a mode, toggle items, select-or-clear all, exit. Agnostic — items are
6
+ * string ids, no domain coupling. Pairs with any list that renders a selection
7
+ * overlay from `selected` (e.g. `<FileThumbnailGrid selectedIds>`).
8
+ *
9
+ * It is the reusable LOGIC behind a gated-delete file field; the action bar, the
10
+ * layout, and the confirm Dialog are presentation the consumer composes locally.
11
+ */
12
+ export interface SelectionMode {
13
+ /** Whether selection mode is active. */
14
+ active: boolean;
15
+ /** The currently-selected ids. */
16
+ selected: ReadonlySet<string>;
17
+ enter: () => void;
18
+ /** Leave selection mode AND clear the selection. */
19
+ exit: () => void;
20
+ toggle: (id: string) => void;
21
+ /** Select all `ids` when they aren't all already selected, else clear (stay in mode). */
22
+ toggleAll: (ids: readonly string[]) => void;
23
+ }
24
+
25
+ export function useSelectionMode(): SelectionMode {
26
+ const [active, setActive] = useState(false);
27
+ const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
28
+
29
+ const enter = useCallback(() => setActive(true), []);
30
+ const exit = useCallback(() => {
31
+ setActive(false);
32
+ setSelected(new Set());
33
+ }, []);
34
+ const toggle = useCallback((id: string) => {
35
+ setSelected((prev) => {
36
+ const next = new Set(prev);
37
+ if (next.has(id)) next.delete(id);
38
+ else next.add(id);
39
+ return next;
40
+ });
41
+ }, []);
42
+ const toggleAll = useCallback((ids: readonly string[]) => {
43
+ setSelected((prev) => (ids.length > 0 && prev.size === ids.length ? new Set() : new Set(ids)));
44
+ }, []);
45
+
46
+ return { active, selected, enter, exit, toggle, toggleAll };
47
+ }