@lotics/ui 5.1.1 → 5.3.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 +24 -4
- package/package.json +4 -1
- package/src/file_gallery_modal.tsx +27 -1
- package/src/remainder_meter.tsx +29 -3
- package/src/rotate_image.ts +39 -0
- package/src/share_or_download.test.ts +39 -0
- package/src/share_or_download.ts +69 -0
- package/src/use_selection_mode.ts +47 -0
package/AGENTS.md
CHANGED
|
@@ -13,8 +13,8 @@ typeahead, async search, virtualization, and a11y the primitive already ships.
|
|
|
13
13
|
- RN-Web only: `View`/`ScrollView` from `react-native`, the `Text` primitive (no raw
|
|
14
14
|
`div`/`span`, no raw `fontSize`/`fontWeight`); styles are RN objects.
|
|
15
15
|
- i18n: most components are string-free; the few that emit their own user-facing strings take
|
|
16
|
-
a `labels` prop (`DateRangeFilterField`, `Pagination`, `SortHeader`/`Table.sortLabels
|
|
17
|
-
localize there.
|
|
16
|
+
a `labels` prop (`DateRangeFilterField`, `Pagination`, `SortHeader`/`Table.sortLabels`,
|
|
17
|
+
`RemainderMeter`) — localize there.
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "5.3.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,
|
package/src/remainder_meter.tsx
CHANGED
|
@@ -2,6 +2,26 @@ import { StyleSheet, View, type DimensionValue } from "react-native";
|
|
|
2
2
|
import { colors, solid } from "./colors";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Override the meter's captions for localization. Each key is optional and
|
|
7
|
+
* falls back to English (mirrors `PaginationLabels` / `DateRangeFilterField`).
|
|
8
|
+
*/
|
|
9
|
+
export interface RemainderMeterLabels {
|
|
10
|
+
/** The left caption. Default: `{allocated} of {total} applied`. */
|
|
11
|
+
applied?: (allocated: string, total: string) => string;
|
|
12
|
+
/** UNDER state (a remainder still to place). Default: `{remainder} unapplied`. */
|
|
13
|
+
remaining?: (remainder: string) => string;
|
|
14
|
+
/** OVER state (over-allocated). Default: `Over by {amount}`. */
|
|
15
|
+
over?: (amount: string) => string;
|
|
16
|
+
/** EXACT state. Default: `Fully applied`. */
|
|
17
|
+
exact?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const defaultApplied = (allocated: string, total: string) => `${allocated} of ${total} applied`;
|
|
21
|
+
const defaultRemaining = (remainder: string) => `${remainder} unapplied`;
|
|
22
|
+
const defaultOver = (amount: string) => `Over by ${amount}`;
|
|
23
|
+
const DEFAULT_EXACT = "Fully applied";
|
|
24
|
+
|
|
5
25
|
export interface RemainderMeterProps {
|
|
6
26
|
/** The source amount being distributed — the payment, the available stock. */
|
|
7
27
|
total: number;
|
|
@@ -9,6 +29,8 @@ export interface RemainderMeterProps {
|
|
|
9
29
|
allocated: number;
|
|
10
30
|
/** Format amounts (money / units). Defaults to a plain locale string. */
|
|
11
31
|
format?: (n: number) => string;
|
|
32
|
+
/** Localize the captions; each key falls back to English. */
|
|
33
|
+
labels?: RemainderMeterLabels;
|
|
12
34
|
}
|
|
13
35
|
|
|
14
36
|
/**
|
|
@@ -20,17 +42,21 @@ export interface RemainderMeterProps {
|
|
|
20
42
|
* distribution, budgeting.
|
|
21
43
|
*/
|
|
22
44
|
export function RemainderMeter(props: RemainderMeterProps) {
|
|
23
|
-
const { total, allocated, format = (n) => n.toLocaleString() } = props;
|
|
45
|
+
const { total, allocated, format = (n) => n.toLocaleString(), labels } = props;
|
|
24
46
|
const remainder = total - allocated;
|
|
25
47
|
const state = remainder > 0 ? "under" : remainder < 0 ? "over" : "exact";
|
|
26
48
|
const pct: DimensionValue = `${total <= 0 ? 0 : Math.min(100, (allocated / total) * 100)}%`;
|
|
27
49
|
const barColor = state === "over" ? solid("red") : state === "exact" ? solid("emerald") : solid("blue");
|
|
28
50
|
const right =
|
|
29
|
-
state === "exact"
|
|
51
|
+
state === "exact"
|
|
52
|
+
? (labels?.exact ?? DEFAULT_EXACT)
|
|
53
|
+
: state === "over"
|
|
54
|
+
? (labels?.over ?? defaultOver)(format(-remainder))
|
|
55
|
+
: (labels?.remaining ?? defaultRemaining)(format(remainder));
|
|
30
56
|
return (
|
|
31
57
|
<View style={{ gap: 8 }}>
|
|
32
58
|
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
|
|
33
|
-
<Text size="sm" color="muted" style={{ flex: 1 }}>{
|
|
59
|
+
<Text size="sm" color="muted" style={{ flex: 1 }}>{(labels?.applied ?? defaultApplied)(format(allocated), format(total))}</Text>
|
|
34
60
|
<Text size="sm" weight="medium" tabular color={state === "over" ? "danger" : state === "exact" ? "success" : "default"}>
|
|
35
61
|
{right}
|
|
36
62
|
</Text>
|
|
@@ -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
|
+
}
|