@lotics/ui 20.2.0 → 21.0.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 +1 -1
- package/MIGRATION.md +57 -0
- package/docs/catalog.md +30 -21
- package/docs/data_entry.md +55 -23
- package/examples/tpl_task_board.tsx +11 -2
- package/package.json +1 -1
- package/src/file_grid.tsx +14 -2
- package/src/files_editor.tsx +241 -132
- package/src/locale.tsx +74 -0
- package/src/uploading_thumbnail.tsx +5 -3
package/AGENTS.md
CHANGED
|
@@ -15,7 +15,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
15
15
|
| Doc | Read it for |
|
|
16
16
|
|---|---|
|
|
17
17
|
| [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` for a custom-code app's `vite.config.ts`). Read before building any screen; reuse first. |
|
|
18
|
-
| [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (
|
|
18
|
+
| [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (the `FilesEditor` COMPOUND — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
|
|
19
19
|
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split (modify → review-before-apply; create → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer) |
|
|
20
20
|
| [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude, banded cards, register vs inset rows, master-detail `Drawer`, view controls, color discipline, typography, whitespace. |
|
|
21
21
|
| [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget). |
|
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,63 @@ Breaking changes, newest first — normally per major, plus the rare minor that
|
|
|
4
4
|
anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
|
|
5
5
|
this file exists only to move an app from one release to the next.
|
|
6
6
|
|
|
7
|
+
## v21 from 20.x
|
|
8
|
+
|
|
9
|
+
**`FilesEditor` is a COMPOUND.** It rendered a fixed toolbar — Upload · Select · Download all,
|
|
10
|
+
swapping into a select-mode row whose actions hid behind a generic "Menu" — and that shape cost
|
|
11
|
+
two things in real screens. A HOST verb had nowhere to go, so a surface that needed one (an AI
|
|
12
|
+
read over the picked papers, "send these to the broker") hand-rolled the whole grid and lost the
|
|
13
|
+
gallery, the upload queue and the confirm with it. And the actions that DID exist sat behind a
|
|
14
|
+
label naming a widget rather than an act, five interactions deep for "delete this scan".
|
|
15
|
+
|
|
16
|
+
The root still owns what a host cannot reasonably re-implement — the batch selection, the
|
|
17
|
+
full-screen gallery, the Alert-confirmed remove — and still renders the grid. What you can DO to
|
|
18
|
+
the files is now composed below it.
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
// BEFORE
|
|
22
|
+
<FilesEditor
|
|
23
|
+
files={files} uploads={uploads} onAdd={add} onRemove={remove}
|
|
24
|
+
onShareSelected={share} onDownloadZipSelected={zip} readOnly={readOnly}
|
|
25
|
+
/>
|
|
26
|
+
|
|
27
|
+
// AFTER
|
|
28
|
+
<FilesEditor files={files} uploads={uploads} onAdd={readOnly ? undefined : add} onRemove={readOnly ? undefined : remove}>
|
|
29
|
+
<FilesEditorBar>
|
|
30
|
+
<FilesEditorUpload />
|
|
31
|
+
<FilesEditorSelect />
|
|
32
|
+
<FilesEditorSelectAll />
|
|
33
|
+
<FilesEditorBarSpacer />
|
|
34
|
+
<FilesEditorDownload />
|
|
35
|
+
<FilesEditorRemove />
|
|
36
|
+
</FilesEditorBar>
|
|
37
|
+
</FilesEditor>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
| Gone | Now |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `onShareSelected` | a `Button` in the bar reading `useFilesEditorSelection()` — sharing is a HOST act |
|
|
43
|
+
| `onDownloadZipSelected` | the same |
|
|
44
|
+
| `readOnly` | withhold `onAdd`/`onRemove`; the pieces render nothing without a handler, so the composition IS the read-only shape and there is no second mode to keep in sync |
|
|
45
|
+
| the "Menu" popover | the bar. Actions are visible controls, in the order the surface wants them |
|
|
46
|
+
| `labels.menu` · `labels.share` · `labels.downloadZip` | deleted with the acts they named |
|
|
47
|
+
|
|
48
|
+
**No children means no bar** — a grid that previews and nothing else. That is the one rendering
|
|
49
|
+
path; there is no default toolbar to fall back to.
|
|
50
|
+
|
|
51
|
+
**The remaining labels resolve prop → `LoticsLocale.filesEditor` → nothing**, so an app that
|
|
52
|
+
supplies its pack at the root gets them translated with no per-instance wiring.
|
|
53
|
+
|
|
54
|
+
A host verb reads the selection:
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
function ReadWithAi() {
|
|
58
|
+
const { selected, exit } = useFilesEditorSelection();
|
|
59
|
+
return <Button title="Đọc bằng AI" disabled={selected.length === 0}
|
|
60
|
+
onPress={() => { run(selected); exit(); }} />;
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
7
64
|
## v20 from 19.x
|
|
8
65
|
|
|
9
66
|
**`@lotics/ui/section` is DELETED.** Two modules exported a `Section` — the layout grammar's
|
package/docs/catalog.md
CHANGED
|
@@ -221,15 +221,12 @@ reference), `InfoPopover` (the ⓘ explainer).
|
|
|
221
221
|
|
|
222
222
|
### Files
|
|
223
223
|
|
|
224
|
-
`FilesEditor` (THE
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
surface), `FileGrid` (the upload-aware grid: completed files + a live upload queue in one
|
|
231
|
-
surface — `FilesEditor` is this + the toolbar; reach for `FileGrid` bare when you own the
|
|
232
|
-
chrome), `FileThumbnail` / `FileThumbnailGrid` (square tiles; `onPress`/`onFilePress` makes each
|
|
224
|
+
`FilesEditor` (THE attachment surface: an upload-aware grid whose bar you COMPOSE — the root
|
|
225
|
+
owns selection + gallery + confirmed remove, the bar pieces and any HOST verb go below it via
|
|
226
|
+
`useFilesEditorSelection`),
|
|
227
|
+
`FileGrid` (the upload-aware grid: completed files + a live upload queue in one
|
|
228
|
+
surface — `FilesEditor` is this + selection + the composed bar; reach for `FileGrid` bare when
|
|
229
|
+
you own all three), `FileThumbnail` / `FileThumbnailGrid` (square tiles; `onPress`/`onFilePress` makes each
|
|
233
230
|
tile a pressable door — e.g. tap-to-preview — carrying an accessible button role + the
|
|
234
231
|
filename as its name, overridable per tile with `accessibilityLabel` when what the press
|
|
235
232
|
DOES reads better than a raw filename; `selectedIds` for a selection overlay),
|
|
@@ -1065,26 +1062,38 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1065
1062
|
screenshot needs), `selectPasteSink(entries)` (the pure focus-then-stack routing rule the
|
|
1066
1063
|
`.web` sink applies), and the types `FileIntakeFilter` / `FileTransferLike` / `RegionRef` /
|
|
1067
1064
|
`PasteSinkEntry` / `UsePasteFilesOptions` / `FileDropTargetProps`.
|
|
1068
|
-
- **`files_editor`** —
|
|
1069
|
-
|
|
1070
|
-
(
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
`
|
|
1076
|
-
|
|
1065
|
+
- **`files_editor`** — THE attachment surface, as a **compound**. `FilesEditor` (root) owns
|
|
1066
|
+
what a host cannot reasonably re-implement — the batch selection, the built-in gallery
|
|
1067
|
+
(Download + inline preview; no "open in new tab"), the Alert-confirmed remove — and renders
|
|
1068
|
+
`FileGrid`; the host wires `files` + `onAdd`/`onRemove` (+ optional `uploads`,
|
|
1069
|
+
`selectTileRemove`, `labels`, `galleryLabels`, `gridMaxHeight` — cap the grid height so it
|
|
1070
|
+
scrolls and the bar pins, for a popover/drawer). **What you can DO to the files is composed
|
|
1071
|
+
below it**, from `FilesEditorBar` + `FilesEditorUpload` · `FilesEditorSelect` ·
|
|
1072
|
+
`FilesEditorSelectAll` · `FilesEditorDownload` · `FilesEditorRemove` (+
|
|
1073
|
+
`FilesEditorBarSpacer` to push the rest right). Each renders nothing without the handler it
|
|
1074
|
+
needs, so withholding `onAdd`/`onRemove` IS the read-only shape — there is no `readOnly`
|
|
1075
|
+
mode. **A HOST verb is a plain `Button`** reading **`useFilesEditorSelection()`**
|
|
1076
|
+
(`{selected, selectedIds, files, selectMode, clear, exit}`) — an AI read over the picked
|
|
1077
|
+
papers, "send to the broker", a ZIP: acts the kit has never heard of, which is why there is
|
|
1078
|
+
no props-per-act toolbar and no generic Menu. **No children means no bar** (a grid that only
|
|
1079
|
+
previews). Bar words resolve prop → `LoticsLocale.filesEditor`; in-flight tile words →
|
|
1080
|
+
`LoticsLocale.fileUpload`. **No empty state:** with zero files it is a bare bar, so pair it
|
|
1081
|
+
with a `FileDropzone` for the well a records screen opens on. Use `FileGrid`/`FileRows` bare
|
|
1082
|
+
only when you own the selection and the gallery too.
|
|
1077
1083
|
- **`file_grid`** — `FileGrid` + the `FileUpload`/`PendingUpload` types: the upload-aware
|
|
1078
1084
|
grid — `files` are the saved/completed `DisplayFile`s, `uploads` is the LIVE add-queue; it
|
|
1079
1085
|
interleaves both and renders each in-flight tile itself with a labeled status overlay.
|
|
1080
1086
|
Tiles FILL the container width at a uniform size (≥ `minItemWidth`, default 96); pass
|
|
1081
1087
|
`columns` for a fixed count, `itemSize` for exact tiles, or `singleRow` to fit one row and
|
|
1082
1088
|
collapse the rest into a clickable "+N" overflow tile (`onOverflowPress(hiddenCount)`).
|
|
1083
|
-
CRUD via `onFilePress` / `onDisplayRemove` / `onUploadRemove` / `onRetry` / `onRetryAll
|
|
1084
|
-
|
|
1089
|
+
CRUD via `onFilePress` / `onDisplayRemove` / `onUploadRemove` / `onRetry` / `onRetryAll`.
|
|
1090
|
+
The status words resolve **prop → `LoticsLocale.fileUpload` → nothing**, so they follow the
|
|
1091
|
+
root pack with no per-call-site wiring (that is also how the grid inside `FilesEditor`, which
|
|
1092
|
+
cannot pass `labels`, gets localized); `labels.upload`/`labels.retryAll` override one grid.
|
|
1085
1093
|
- **`uploading_thumbnail`** — `UploadingThumbnail` + `UploadStatus`/`UploadStatusLabels`:
|
|
1086
1094
|
the single in-flight upload tile `FileGrid` renders; reach for it only when hand-rolling a
|
|
1087
|
-
non-grid upload layout
|
|
1095
|
+
non-grid upload layout — it is i18n-free by design, so a bare call site owes it `labels`
|
|
1096
|
+
(`FileGrid` resolves them from `LoticsLocale.fileUpload` on your behalf).
|
|
1088
1097
|
- **`file_thumbnail`** — `FileThumbnail` + `DisplayFile` + `THUMBNAIL_SIZE` /
|
|
1089
1098
|
`COMPACT_THUMBNAIL_SIZE` + `getMediaIcon`: the completed tile — the right surface per
|
|
1090
1099
|
MIME: image thumbnail · a doc tile with the `FileBadge` centered + a single-line filename
|
package/docs/data_entry.md
CHANGED
|
@@ -514,28 +514,59 @@ affordance line under its heading — **`<SectionHeadingTitle description="Drag,
|
|
|
514
514
|
to add files">`** (both templates do this). A `FileDropzone`'s own hint already names the paste
|
|
515
515
|
(its default `fileDropzone.hint` is "or click, or paste (⌘V)").
|
|
516
516
|
|
|
517
|
-
### Default: `FilesEditor`
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
517
|
+
### Default: the `FilesEditor` compound
|
|
518
|
+
|
|
519
|
+
**The root owns what a host cannot reasonably re-implement** — the batch selection, the
|
|
520
|
+
full-screen gallery, the Alert-confirmed remove — and renders the upload-aware grid. **What you
|
|
521
|
+
can DO to the files is composed below it**, so a surface offers exactly its own verbs, in its
|
|
522
|
+
own order, under their own names.
|
|
523
|
+
|
|
524
|
+
```tsx
|
|
525
|
+
<FilesEditor files={files} uploads={queue} onAdd={add} onRemove={remove}>
|
|
526
|
+
<FilesEditorBar>
|
|
527
|
+
<FilesEditorUpload />
|
|
528
|
+
<FilesEditorSelect /> {/* "Select" ⇄ "Done" — one toggle, not two buttons */}
|
|
529
|
+
<FilesEditorSelectAll /> {/* renders only while selecting */}
|
|
530
|
+
<FilesEditorBarSpacer />
|
|
531
|
+
<FilesEditorDownload /> {/* the selection while selecting, everything at rest */}
|
|
532
|
+
<FilesEditorRemove />
|
|
533
|
+
</FilesEditorBar>
|
|
534
|
+
</FilesEditor>
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
**A HOST verb is a plain `Button`** that reads `useFilesEditorSelection()` →
|
|
538
|
+
`{selected, selectedIds, files, selectMode, clear, exit}`. This is the whole reason the bar is
|
|
539
|
+
composed: an act the kit has never heard of — read these with AI, send them to the broker, ZIP
|
|
540
|
+
them — has a home, instead of forcing the screen to hand-roll the grid and lose the gallery and
|
|
541
|
+
the upload queue with it.
|
|
542
|
+
|
|
543
|
+
```tsx
|
|
544
|
+
function ReadWithAi() {
|
|
545
|
+
const { selected, exit } = useFilesEditorSelection();
|
|
546
|
+
return <Button title="Read with AI" disabled={selected.length === 0}
|
|
547
|
+
onPress={() => { run(selected); exit(); }} />;
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
**Each piece renders nothing without the handler it needs**, so composition expresses the
|
|
552
|
+
variants that used to be flags: withhold `onAdd` and there is no Upload; withhold `onRemove` and
|
|
553
|
+
there is no Remove and no per-tile ✕ — that IS read-only, with no second mode to keep in sync.
|
|
554
|
+
**No children means no bar**: a grid that previews and nothing else.
|
|
555
|
+
|
|
556
|
+
The destructive per-tile ✕ shows only in SELECT mode (the default view is a clean preview — no
|
|
557
|
+
stray-tap deletes); `selectTileRemove={false}` drops it there too, so the bar's Remove is the
|
|
558
|
+
only delete path (right for a height-bounded cell editor). The built-in gallery is Download +
|
|
559
|
+
inline preview (no "open in new tab" — redundant once everything previews inline). In a
|
|
560
|
+
height-bounded container (a popover/drawer) pass `gridMaxHeight` so the grid SCROLLS and the bar
|
|
561
|
+
pins below it; omit it in free-flow layouts where the grid grows.
|
|
529
562
|
|
|
530
563
|
The rest of the surface: `uploads` passes the live add-queue through to the grid (with
|
|
531
|
-
`onUploadRemove`/`onRetry`/`onRetryAll`); `onUpload` overrides the Upload
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
pass through to the grid; `credentials` covers auth-gated preview URLs; `labels` /
|
|
538
|
-
`galleryLabels` localize the toolbar and the gallery chrome.
|
|
564
|
+
`onUploadRemove`/`onRetry`/`onRetryAll`); `onUpload` overrides the Upload picker (e.g. a native
|
|
565
|
+
document picker); `onDownload` overrides the default per-file `downloadFileFromUrl`; `accept`
|
|
566
|
+
filters the picker; `itemSize`/`minItemWidth`/`columns` pass through to the grid; `credentials`
|
|
567
|
+
covers auth-gated preview URLs. Bar words resolve **prop → `LoticsLocale.filesEditor`**, gallery
|
|
568
|
+
chrome via `galleryLabels`, in-flight tile words via `LoticsLocale.fileUpload` — so an app that
|
|
569
|
+
supplies its pack at the root needs no per-instance labels at all.
|
|
539
570
|
|
|
540
571
|
Reach for the lower-level pieces below only when you need custom chrome.
|
|
541
572
|
|
|
@@ -551,9 +582,10 @@ more" affordance — only the empty state leads with it).
|
|
|
551
582
|
the LIVE add-queue (`FileUpload[]`) — it interleaves both and renders each in-flight tile itself —
|
|
552
583
|
a LABELED status overlay (uploading spinner · "Retrying" · "Paused" · "Upload failed" + a retry
|
|
553
584
|
button · "Can't upload" for a dead/empty file) — so you never hand-map an upload to a
|
|
554
|
-
`FileThumbnail`.
|
|
555
|
-
|
|
556
|
-
`
|
|
585
|
+
`FileThumbnail`. Those words resolve **prop → `LoticsLocale.fileUpload` → nothing** — set the
|
|
586
|
+
pack once at the root and a stalled or dead upload speaks the app's language everywhere,
|
|
587
|
+
including inside `FilesEditor`, which has no per-instance way to reach them. `labels.upload` /
|
|
588
|
+
`labels.retryAll` override one grid.
|
|
557
589
|
|
|
558
590
|
Make it CRUDable by wiring its callbacks: `onFilePress` → set a `number|null` index that drives
|
|
559
591
|
`<FileGalleryModal files activeIndex onIndexChange>` — a FULL-SCREEN viewer with a toolbar
|
|
@@ -21,7 +21,7 @@ import { ActionMenu } from "@lotics/ui/action_menu";
|
|
|
21
21
|
import { Alert } from "@lotics/ui/alert";
|
|
22
22
|
import { pickFiles } from "@lotics/ui/file_picker";
|
|
23
23
|
import { Popover, PopoverTrigger, PopoverContent } from "@lotics/ui/popover";
|
|
24
|
-
import { FilesEditor } from "@lotics/ui/files_editor";
|
|
24
|
+
import { FilesEditor, FilesEditorBar, FilesEditorBarSpacer, FilesEditorRemove, FilesEditorSelect, FilesEditorUpload } from "@lotics/ui/files_editor";
|
|
25
25
|
import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
26
26
|
import { DataGrid, gridRowStyle, type DataGridColumn, type DataGridGroup } from "@lotics/ui/data_grid";
|
|
27
27
|
import { CONTROL_RADIUS } from "@lotics/ui/control_surface";
|
|
@@ -483,7 +483,16 @@ function FilesCell({ files, onAdd, onRemove }: { files: DisplayFile[]; onAdd: (p
|
|
|
483
483
|
</PopoverTrigger>
|
|
484
484
|
<PopoverContent>
|
|
485
485
|
<View style={{ width: 340, padding: 8 }}>
|
|
486
|
-
|
|
486
|
+
{/* The bar is composed, so this popover offers exactly the three acts
|
|
487
|
+
a task attachment needs and nothing else. */}
|
|
488
|
+
<FilesEditor files={files} itemSize={76} onAdd={onAdd} onRemove={onRemove}>
|
|
489
|
+
<FilesEditorBar>
|
|
490
|
+
<FilesEditorUpload />
|
|
491
|
+
<FilesEditorSelect />
|
|
492
|
+
<FilesEditorBarSpacer />
|
|
493
|
+
<FilesEditorRemove />
|
|
494
|
+
</FilesEditorBar>
|
|
495
|
+
</FilesEditor>
|
|
487
496
|
</View>
|
|
488
497
|
</PopoverContent>
|
|
489
498
|
</Popover>
|
package/package.json
CHANGED
package/src/file_grid.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import { FileThumbnail, type DisplayFile } from "./file_thumbnail";
|
|
|
16
16
|
import { ThumbnailGrid } from "./file_thumbnail_grid";
|
|
17
17
|
import { UploadingThumbnail, type UploadStatus, type UploadStatusLabels } from "./uploading_thumbnail";
|
|
18
18
|
import { Button } from "./button";
|
|
19
|
+
import { useLoticsLocale } from "./locale";
|
|
19
20
|
|
|
20
21
|
/** An in-flight upload item (everything except a completed file). */
|
|
21
22
|
export interface PendingUpload {
|
|
@@ -31,6 +32,9 @@ export interface PendingUpload {
|
|
|
31
32
|
* upload. The host maps its own upload state to this. */
|
|
32
33
|
export type FileUpload = { status: "complete"; id: string; file: DisplayFile } | PendingUpload;
|
|
33
34
|
|
|
35
|
+
/** Per-instance overrides. Both fall back to the active `LoticsLocale`
|
|
36
|
+
* (`fileUpload`), so an app localizes this grid — including the one inside
|
|
37
|
+
* `FilesEditor` — by supplying its pack once at the root, not per call site. */
|
|
34
38
|
export interface FileGridLabels {
|
|
35
39
|
/** "Retry all" footer button (shown when any upload errored). */
|
|
36
40
|
retryAll?: string;
|
|
@@ -126,6 +130,14 @@ export function FileGrid(props: FileGridProps) {
|
|
|
126
130
|
labels,
|
|
127
131
|
} = props;
|
|
128
132
|
|
|
133
|
+
// prop → locale → (nothing) — the locale pack is always complete, so an app
|
|
134
|
+
// that never passes `labels` still reads its own language on an upload that
|
|
135
|
+
// stalls or dies. Hardcoded English here is what made "Upload failed" surface
|
|
136
|
+
// inside a fully-Vietnamese app, since `FilesEditor` — the way most apps reach
|
|
137
|
+
// this grid — has no way to pass `labels` down.
|
|
138
|
+
const loc = useLoticsLocale().fileUpload;
|
|
139
|
+
const upload = { ...loc, ...labels?.upload };
|
|
140
|
+
|
|
129
141
|
const renderItems = buildRenderItems(files, uploads);
|
|
130
142
|
|
|
131
143
|
const handleFilePress = useCallback(
|
|
@@ -142,7 +154,7 @@ export function FileGrid(props: FileGridProps) {
|
|
|
142
154
|
|
|
143
155
|
const retryAll =
|
|
144
156
|
onRetryAll && renderItems.some((item) => item.kind === "upload" && item.upload.status === "error") ? (
|
|
145
|
-
<Button onPress={onRetryAll} color="danger" title={labels?.retryAll ??
|
|
157
|
+
<Button onPress={onRetryAll} color="danger" title={labels?.retryAll ?? loc.retryAll} />
|
|
146
158
|
) : null;
|
|
147
159
|
|
|
148
160
|
return (
|
|
@@ -180,7 +192,7 @@ export function FileGrid(props: FileGridProps) {
|
|
|
180
192
|
size={size}
|
|
181
193
|
onRemove={onUploadRemove ? () => onUploadRemove(item.removeId) : undefined}
|
|
182
194
|
onRetry={onRetry ? () => onRetry(item.removeId) : undefined}
|
|
183
|
-
labels={
|
|
195
|
+
labels={upload}
|
|
184
196
|
/>
|
|
185
197
|
)
|
|
186
198
|
}
|
package/src/files_editor.tsx
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
|
2
2
|
import { View, ScrollView, StyleSheet } from "react-native";
|
|
3
3
|
import { Button } from "./button";
|
|
4
|
-
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
5
|
-
import { MenuButton } from "./menu_button";
|
|
6
4
|
import { Alert } from "./alert";
|
|
7
5
|
import { FileGrid, type FileUpload } from "./file_grid";
|
|
8
6
|
import { FileGalleryModal } from "./file_gallery_modal";
|
|
@@ -10,21 +8,22 @@ import type { GalleryLabels } from "./file_preview_types";
|
|
|
10
8
|
import { type DisplayFile } from "./file_thumbnail";
|
|
11
9
|
import { pickFiles } from "./file_picker";
|
|
12
10
|
import { downloadFileFromUrl } from "./download";
|
|
11
|
+
import { useLoticsLocale } from "./locale";
|
|
13
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The words the shipped bar pieces say. Each resolves **prop → `LoticsLocale`
|
|
15
|
+
* (`filesEditor`) → nothing**, so an app localizes by supplying its pack once at
|
|
16
|
+
* the root. A HOST verb is a plain `Button` and names itself.
|
|
17
|
+
*/
|
|
14
18
|
export interface FilesEditorLabels {
|
|
15
19
|
upload: string;
|
|
16
20
|
select: string;
|
|
17
21
|
selectAll: string;
|
|
18
22
|
deselectAll: string;
|
|
19
23
|
done: string;
|
|
20
|
-
/** The select-mode actions menu trigger. */
|
|
21
|
-
menu: string;
|
|
22
24
|
downloadAll: string;
|
|
23
|
-
/** Download-selected
|
|
25
|
+
/** Download-selected, given the count. */
|
|
24
26
|
downloadSelected: (n: number) => string;
|
|
25
|
-
/** Download-as-ZIP menu item (shown when `onDownloadZipSelected` + 2+ selected). */
|
|
26
|
-
downloadZip: string;
|
|
27
|
-
share: string;
|
|
28
27
|
delete: string;
|
|
29
28
|
removeTitle: string;
|
|
30
29
|
removeMessage: string;
|
|
@@ -32,83 +31,151 @@ export interface FilesEditorLabels {
|
|
|
32
31
|
removeConfirm: string;
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
34
|
+
interface FilesEditorCtx {
|
|
35
|
+
files: DisplayFile[];
|
|
36
|
+
selectMode: boolean;
|
|
37
|
+
setSelectMode: (on: boolean) => void;
|
|
38
|
+
selectedIds: Set<string>;
|
|
39
|
+
selected: DisplayFile[];
|
|
40
|
+
toggle: (id: string) => void;
|
|
41
|
+
setAll: (on: boolean) => void;
|
|
42
|
+
clear: () => void;
|
|
43
|
+
exit: () => void;
|
|
44
|
+
onAdd?: (files: File[]) => void;
|
|
45
|
+
onUpload?: () => void;
|
|
46
|
+
onRemove?: (id: string) => void;
|
|
47
|
+
accept?: string;
|
|
48
|
+
labels: FilesEditorLabels;
|
|
49
|
+
download: (files: DisplayFile[]) => void;
|
|
50
|
+
confirmRemove: (files: DisplayFile[], onConfirm: () => void) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const Ctx = createContext<FilesEditorCtx | null>(null);
|
|
54
|
+
|
|
55
|
+
function useCtx(who: string): FilesEditorCtx {
|
|
56
|
+
const ctx = useContext(Ctx);
|
|
57
|
+
if (!ctx) throw new Error(`<${who}> must be rendered inside <FilesEditor>`);
|
|
58
|
+
return ctx;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What a HOST verb reads: the files the user picked, and how to stand down
|
|
63
|
+
* afterwards. This is the whole point of the compound — an action the kit has
|
|
64
|
+
* never heard of ("read these with AI", "attach to the shipment", "send to the
|
|
65
|
+
* broker") is a plain `Button` in the bar that calls this.
|
|
66
|
+
*
|
|
67
|
+
* ```tsx
|
|
68
|
+
* function ReadWithAi() {
|
|
69
|
+
* const { selected, exit } = useFilesEditorSelection();
|
|
70
|
+
* return <Button title="Đọc bằng AI" disabled={selected.length === 0}
|
|
71
|
+
* onPress={() => { run(selected); exit(); }} />;
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function useFilesEditorSelection(): {
|
|
76
|
+
/** The picked files, in the grid's order. Empty outside select mode. */
|
|
77
|
+
selected: DisplayFile[];
|
|
78
|
+
/** Their ids. */
|
|
79
|
+
selectedIds: string[];
|
|
80
|
+
/** Every file on the surface, picked or not. */
|
|
81
|
+
files: DisplayFile[];
|
|
82
|
+
/** Is the surface in batch-select mode? */
|
|
83
|
+
selectMode: boolean;
|
|
84
|
+
/** Drop the selection, stay in select mode. */
|
|
85
|
+
clear: () => void;
|
|
86
|
+
/** Drop the selection AND leave select mode — what a verb does when it is done. */
|
|
87
|
+
exit: () => void;
|
|
88
|
+
} {
|
|
89
|
+
const ctx = useCtx("useFilesEditorSelection");
|
|
90
|
+
return {
|
|
91
|
+
selected: ctx.selected,
|
|
92
|
+
selectedIds: ctx.selected.map((f) => f.id),
|
|
93
|
+
files: ctx.files,
|
|
94
|
+
selectMode: ctx.selectMode,
|
|
95
|
+
clear: ctx.clear,
|
|
96
|
+
exit: ctx.exit,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
52
99
|
|
|
53
100
|
export interface FilesEditorProps {
|
|
54
101
|
/** Completed/saved files. */
|
|
55
102
|
files: DisplayFile[];
|
|
56
103
|
/** The live add-queue (in-flight uploads) — pass through from your upload mechanism. */
|
|
57
104
|
uploads?: FileUpload[];
|
|
58
|
-
/** Picked files → the host uploads + appends to `files`. Omit to hide
|
|
105
|
+
/** Picked files → the host uploads + appends to `files`. Omit to hide `FilesEditorUpload`. */
|
|
59
106
|
onAdd?: (files: File[]) => void;
|
|
60
107
|
/** Override the Upload action's picker (e.g. a native document picker). When
|
|
61
|
-
* set,
|
|
108
|
+
* set, `FilesEditorUpload` calls this instead of the built-in web `pickFiles`. */
|
|
62
109
|
onUpload?: () => void;
|
|
63
|
-
/**
|
|
64
|
-
onDownloadZipSelected?: (files: DisplayFile[]) => void;
|
|
65
|
-
/** Remove a completed file. Omit (or `readOnly`) for a view-only surface. */
|
|
110
|
+
/** Remove a completed file. Omit to hide `FilesEditorRemove` and the per-tile ✕. */
|
|
66
111
|
onRemove?: (id: string) => void;
|
|
67
112
|
/** Show the per-tile ✕ during SELECT mode (default `true`). The default
|
|
68
113
|
* (non-select) view NEVER shows a ✕ regardless — this governs select mode
|
|
69
|
-
* only. Set `false` when
|
|
70
|
-
*
|
|
71
|
-
* nothing else. */
|
|
114
|
+
* only. Set `false` when a bar action is the sole delete path, so a tile in
|
|
115
|
+
* select mode toggles selection and nothing else. */
|
|
72
116
|
selectTileRemove?: boolean;
|
|
73
117
|
/** Cancel an in-flight upload / retry a failed one (when using `uploads`). */
|
|
74
118
|
onUploadRemove?: (id: string) => void;
|
|
75
119
|
onRetry?: (id: string) => void;
|
|
76
120
|
onRetryAll?: () => void;
|
|
77
|
-
/** Show a Share action in select mode, given the selected files. Omit to hide it. */
|
|
78
|
-
onShareSelected?: (files: DisplayFile[]) => void;
|
|
79
121
|
/** Override the default download (`downloadFileFromUrl`). */
|
|
80
122
|
onDownload?: (file: DisplayFile) => void;
|
|
81
|
-
/** Download + preview only (no Upload / Select / remove). */
|
|
82
|
-
readOnly?: boolean;
|
|
83
123
|
/** Native `accept` filter for the file picker. */
|
|
84
124
|
accept?: string;
|
|
85
125
|
itemSize?: number;
|
|
86
126
|
minItemWidth?: number;
|
|
87
127
|
/** Fixed column count for the grid (passed through to `FileGrid`). */
|
|
88
128
|
columns?: number;
|
|
89
|
-
/** Cap the grid's height (px) so it SCROLLS and the
|
|
129
|
+
/** Cap the grid's height (px) so it SCROLLS and the bar pins below it —
|
|
90
130
|
* for a height-bounded container (a popover, a drawer). Omit in free-flow
|
|
91
131
|
* layouts (a form field) where the grid should grow and the page scrolls. */
|
|
92
132
|
gridMaxHeight?: number;
|
|
93
|
-
/**
|
|
133
|
+
/** Per-instance overrides for the shipped bar pieces. Falls back to the
|
|
134
|
+
* active `LoticsLocale`. */
|
|
94
135
|
labels?: Partial<FilesEditorLabels>;
|
|
95
136
|
/** Credentials mode for the built-in gallery's preview fetches — `"include"`
|
|
96
137
|
* for the host's auth-gated proxy URLs, omitted for an app's presigned URLs.
|
|
97
138
|
* See `FilePreviewProps`. */
|
|
98
139
|
credentials?: RequestCredentials;
|
|
99
140
|
/** Translated labels for the built-in full-screen gallery (its toolbar action
|
|
100
|
-
* buttons + confirm dialog). Defaults to
|
|
141
|
+
* buttons + confirm dialog). Defaults to the active locale. */
|
|
101
142
|
galleryLabels?: Partial<GalleryLabels>;
|
|
143
|
+
/**
|
|
144
|
+
* The bar (and anything else) BELOW the grid. Compose it from the shipped
|
|
145
|
+
* pieces plus your own verbs; omit it entirely for a grid that only previews.
|
|
146
|
+
*/
|
|
147
|
+
children?: ReactNode;
|
|
102
148
|
}
|
|
103
149
|
|
|
104
150
|
/**
|
|
105
|
-
* The
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
151
|
+
* The attachment surface, as a COMPOUND: this root owns the parts a host cannot
|
|
152
|
+
* reasonably re-implement — the batch selection, the full-screen gallery, and the
|
|
153
|
+
* Alert-confirmed remove — and renders the upload-aware grid (`FileGrid`). What
|
|
154
|
+
* you can DO to the files is composed below it, so a surface offers exactly its
|
|
155
|
+
* own verbs in its own order.
|
|
156
|
+
*
|
|
157
|
+
* It was a monolith with a fixed toolbar and a generic "Menu" holding whatever
|
|
158
|
+
* the kit happened to support (download, share, delete). That shape had two
|
|
159
|
+
* costs, and both showed up in real apps: a HOST verb had nowhere to go, so a
|
|
160
|
+
* screen that needed one (an AI read over the picked papers) hand-rolled the
|
|
161
|
+
* whole grid and lost the gallery and the upload queue with it; and the actions
|
|
162
|
+
* that DID exist hid behind a label that names a widget rather than an act, five
|
|
163
|
+
* interactions deep for "delete this scan".
|
|
164
|
+
*
|
|
165
|
+
* ```tsx
|
|
166
|
+
* <FilesEditor files={docs} uploads={queue} onAdd={add} onRemove={remove}>
|
|
167
|
+
* <FilesEditorBar>
|
|
168
|
+
* <FilesEditorUpload />
|
|
169
|
+
* <FilesEditorSelect />
|
|
170
|
+
* <FilesEditorBarSpacer />
|
|
171
|
+
* <FilesEditorDownload />
|
|
172
|
+
* <FilesEditorRemove />
|
|
173
|
+
* </FilesEditorBar>
|
|
174
|
+
* </FilesEditor>
|
|
175
|
+
* ```
|
|
176
|
+
*
|
|
177
|
+
* For a plain row list use `FileRows`; for the bare grid with no selection or
|
|
178
|
+
* gallery, `FileGrid`.
|
|
112
179
|
*/
|
|
113
180
|
export function FilesEditor(props: FilesEditorProps) {
|
|
114
181
|
const {
|
|
@@ -116,15 +183,12 @@ export function FilesEditor(props: FilesEditorProps) {
|
|
|
116
183
|
uploads,
|
|
117
184
|
onAdd,
|
|
118
185
|
onUpload,
|
|
119
|
-
onDownloadZipSelected,
|
|
120
186
|
onRemove,
|
|
121
187
|
selectTileRemove = true,
|
|
122
188
|
onUploadRemove,
|
|
123
189
|
onRetry,
|
|
124
190
|
onRetryAll,
|
|
125
|
-
onShareSelected,
|
|
126
191
|
onDownload,
|
|
127
|
-
readOnly = false,
|
|
128
192
|
accept,
|
|
129
193
|
itemSize,
|
|
130
194
|
minItemWidth,
|
|
@@ -133,31 +197,44 @@ export function FilesEditor(props: FilesEditorProps) {
|
|
|
133
197
|
labels,
|
|
134
198
|
credentials,
|
|
135
199
|
galleryLabels,
|
|
200
|
+
children,
|
|
136
201
|
} = props;
|
|
137
|
-
const
|
|
202
|
+
const loc = useLoticsLocale().filesEditor;
|
|
203
|
+
const l = useMemo(() => ({ ...loc, ...labels }), [loc, labels]);
|
|
138
204
|
|
|
139
|
-
const [selectMode,
|
|
140
|
-
const [
|
|
141
|
-
const [menuOpen, setMenuOpen] = useState(false);
|
|
205
|
+
const [selectMode, setSelectModeState] = useState(false);
|
|
206
|
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
|
142
207
|
const [galleryIndex, setGalleryIndex] = useState<number | null>(null);
|
|
143
208
|
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
const
|
|
209
|
+
const selected = useMemo(() => files.filter((f) => selectedIds.has(f.id)), [files, selectedIds]);
|
|
210
|
+
|
|
211
|
+
const clear = useCallback(() => setSelectedIds(new Set()), []);
|
|
212
|
+
const exit = useCallback(() => { setSelectModeState(false); setSelectedIds(new Set()); }, []);
|
|
213
|
+
const setSelectMode = useCallback((on: boolean) => { if (on) setSelectModeState(true); else exit(); }, [exit]);
|
|
214
|
+
const toggle = useCallback((id: string) => {
|
|
215
|
+
setSelectedIds((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
|
216
|
+
}, []);
|
|
217
|
+
const setAll = useCallback((on: boolean) => {
|
|
218
|
+
setSelectedIds(on ? new Set(files.map((f) => f.id)) : new Set());
|
|
219
|
+
}, [files]);
|
|
220
|
+
|
|
221
|
+
const download = useCallback((fs: DisplayFile[]) => {
|
|
222
|
+
for (const f of fs) {
|
|
223
|
+
if (onDownload) onDownload(f);
|
|
224
|
+
else void downloadFileFromUrl(f.url, f.filename, { credentials });
|
|
225
|
+
}
|
|
226
|
+
}, [onDownload, credentials]);
|
|
147
227
|
|
|
148
|
-
const
|
|
149
|
-
setSelected((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
|
150
|
-
const exitSelect = () => { setSelectMode(false); setSelected(new Set()); };
|
|
151
|
-
const download = (fs: DisplayFile[]) =>
|
|
152
|
-
fs.forEach((f) => (onDownload ? onDownload(f) : void downloadFileFromUrl(f.url, f.filename, { credentials })));
|
|
153
|
-
const confirmRemove = (onConfirm: () => void) =>
|
|
228
|
+
const confirmRemove = useCallback((fs: DisplayFile[], onConfirm: () => void) => {
|
|
154
229
|
Alert.alert(l.removeTitle, l.removeMessage, [
|
|
155
230
|
{ text: l.removeCancel, style: "cancel" },
|
|
156
231
|
{ text: l.removeConfirm, style: "destructive", onPress: onConfirm },
|
|
157
232
|
]);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
233
|
+
}, [l.removeTitle, l.removeMessage, l.removeCancel, l.removeConfirm]);
|
|
234
|
+
|
|
235
|
+
const ctx: FilesEditorCtx = {
|
|
236
|
+
files, selectMode, setSelectMode, selectedIds, selected, toggle, setAll, clear, exit,
|
|
237
|
+
onAdd, onUpload, onRemove, accept, labels: l, download, confirmRemove,
|
|
161
238
|
};
|
|
162
239
|
|
|
163
240
|
const grid = (
|
|
@@ -167,14 +244,16 @@ export function FilesEditor(props: FilesEditorProps) {
|
|
|
167
244
|
itemSize={itemSize}
|
|
168
245
|
minItemWidth={minItemWidth}
|
|
169
246
|
columns={columns}
|
|
170
|
-
selectedIds={selectMode ?
|
|
247
|
+
selectedIds={selectMode ? selectedIds : undefined}
|
|
171
248
|
onToggleSelect={selectMode ? toggle : undefined}
|
|
172
249
|
onFilePress={selectMode ? undefined : (f) => setGalleryIndex(files.findIndex((x) => x.id === f.id))}
|
|
173
250
|
// The per-tile ✕ is a SELECT-mode affordance (opt-out via `selectTileRemove`)
|
|
174
251
|
// — the default view stays a clean preview surface so a stray tap can't drop a
|
|
175
|
-
// file, and a `selectTileRemove={false}` surface deletes only via the
|
|
252
|
+
// file, and a `selectTileRemove={false}` surface deletes only via the bar.
|
|
176
253
|
onDisplayRemove={
|
|
177
|
-
|
|
254
|
+
selectMode && selectTileRemove && onRemove
|
|
255
|
+
? (id) => confirmRemove(files.filter((f) => f.id === id), () => onRemove(id))
|
|
256
|
+
: undefined
|
|
178
257
|
}
|
|
179
258
|
onUploadRemove={onUploadRemove}
|
|
180
259
|
onRetry={onRetry}
|
|
@@ -183,74 +262,104 @@ export function FilesEditor(props: FilesEditorProps) {
|
|
|
183
262
|
);
|
|
184
263
|
|
|
185
264
|
return (
|
|
186
|
-
<
|
|
187
|
-
{
|
|
188
|
-
<ScrollView style={{ maxHeight: gridMaxHeight }}>{grid}</ScrollView>
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
265
|
+
<Ctx.Provider value={ctx}>
|
|
266
|
+
<View style={styles.root}>
|
|
267
|
+
{gridMaxHeight !== undefined ? <ScrollView style={{ maxHeight: gridMaxHeight }}>{grid}</ScrollView> : grid}
|
|
268
|
+
{children}
|
|
269
|
+
<FileGalleryModal
|
|
270
|
+
files={files}
|
|
271
|
+
activeIndex={galleryIndex}
|
|
272
|
+
onIndexChange={setGalleryIndex}
|
|
273
|
+
onRemove={onRemove}
|
|
274
|
+
onDownload={onDownload}
|
|
275
|
+
credentials={credentials}
|
|
276
|
+
labels={galleryLabels}
|
|
277
|
+
/>
|
|
278
|
+
</View>
|
|
279
|
+
</Ctx.Provider>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The action row under the grid. Lay it out left-to-right; a
|
|
284
|
+
* `FilesEditorBarSpacer` pushes what follows to the right edge. */
|
|
285
|
+
export function FilesEditorBar({ children }: { children: ReactNode }) {
|
|
286
|
+
return <View style={styles.bar}>{children}</View>;
|
|
287
|
+
}
|
|
192
288
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
<Button title={l.downloadAll} color="secondary" onPress={() => download(files)} />
|
|
198
|
-
</View>
|
|
199
|
-
) : null
|
|
200
|
-
) : selectMode ? (
|
|
201
|
-
<View style={styles.bar}>
|
|
202
|
-
<Button
|
|
203
|
-
title={allSelected ? l.deselectAll : l.selectAll}
|
|
204
|
-
color="muted"
|
|
205
|
-
onPress={() => setSelected(allSelected ? new Set() : new Set(files.map((f) => f.id)))}
|
|
206
|
-
/>
|
|
207
|
-
<Popover open={menuOpen} onOpenChange={setMenuOpen} align="start">
|
|
208
|
-
<PopoverTrigger>
|
|
209
|
-
<Button title={l.menu} color="secondary" disabled={selected.size === 0} onPress={() => setMenuOpen(true)} />
|
|
210
|
-
</PopoverTrigger>
|
|
211
|
-
<PopoverContent>
|
|
212
|
-
<View style={styles.menu}>
|
|
213
|
-
<MenuButton icon="download" title={l.downloadSelected(selected.size)} onPress={() => { setMenuOpen(false); download(selectedFiles); }} />
|
|
214
|
-
{onDownloadZipSelected && selected.size > 1 ? (
|
|
215
|
-
<MenuButton icon="file-down" title={l.downloadZip} onPress={() => { setMenuOpen(false); onDownloadZipSelected(selectedFiles); }} />
|
|
216
|
-
) : null}
|
|
217
|
-
{onShareSelected ? (
|
|
218
|
-
<MenuButton icon="share" title={l.share} onPress={() => { setMenuOpen(false); onShareSelected(selectedFiles); }} />
|
|
219
|
-
) : null}
|
|
220
|
-
{onRemove ? (
|
|
221
|
-
<MenuButton icon="trash" title={l.delete} danger onPress={() => { setMenuOpen(false); confirmRemove(() => { selectedFiles.forEach((f) => onRemove(f.id)); exitSelect(); }); }} />
|
|
222
|
-
) : null}
|
|
223
|
-
</View>
|
|
224
|
-
</PopoverContent>
|
|
225
|
-
</Popover>
|
|
226
|
-
<View style={styles.spacer} />
|
|
227
|
-
<Button title={l.done} color="secondary" onPress={exitSelect} />
|
|
228
|
-
</View>
|
|
229
|
-
) : (
|
|
230
|
-
<View style={styles.bar}>
|
|
231
|
-
{onAdd || onUpload ? <Button title={l.upload} color="primary" onPress={onUpload ?? upload} /> : null}
|
|
232
|
-
{hasFiles ? <Button title={l.select} color="secondary" onPress={() => setSelectMode(true)} /> : null}
|
|
233
|
-
<View style={styles.spacer} />
|
|
234
|
-
{hasFiles ? <Button title={l.downloadAll} color="secondary" onPress={() => download(files)} /> : null}
|
|
235
|
-
</View>
|
|
236
|
-
)}
|
|
289
|
+
/** Pushes the rest of the bar to the right edge. */
|
|
290
|
+
export function FilesEditorBarSpacer() {
|
|
291
|
+
return <View style={styles.spacer} />;
|
|
292
|
+
}
|
|
237
293
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
294
|
+
/** Add files. Renders nothing when the root was given neither `onAdd` nor
|
|
295
|
+
* `onUpload` — a surface cannot offer an upload it has no handler for. */
|
|
296
|
+
export function FilesEditorUpload({ title }: { title?: string } = {}) {
|
|
297
|
+
const { onAdd, onUpload, accept, labels } = useCtx("FilesEditorUpload");
|
|
298
|
+
if (!onAdd && !onUpload) return null;
|
|
299
|
+
const pick = () => {
|
|
300
|
+
if (onUpload) { onUpload(); return; }
|
|
301
|
+
void pickFiles({ multiple: true, accept }).then((picked) => { if (picked.length > 0) onAdd?.(picked); });
|
|
302
|
+
};
|
|
303
|
+
return <Button title={title ?? labels.upload} color="primary" onPress={pick} />;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Enter batch-select mode, and leave it. One control, because it is one
|
|
307
|
+
* toggle — it reads "Chọn" at rest and "Xong" while selecting. */
|
|
308
|
+
export function FilesEditorSelect() {
|
|
309
|
+
const { files, selectMode, setSelectMode, labels } = useCtx("FilesEditorSelect");
|
|
310
|
+
if (files.length === 0) return null;
|
|
311
|
+
return selectMode ? (
|
|
312
|
+
<Button title={labels.done} color="secondary" onPress={() => setSelectMode(false)} />
|
|
313
|
+
) : (
|
|
314
|
+
<Button title={labels.select} color="secondary" onPress={() => setSelectMode(true)} />
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Select-all / deselect-all. Only meaningful while selecting, so it renders
|
|
319
|
+
* nothing at rest. */
|
|
320
|
+
export function FilesEditorSelectAll() {
|
|
321
|
+
const { files, selectMode, selectedIds, setAll, labels } = useCtx("FilesEditorSelectAll");
|
|
322
|
+
if (!selectMode || files.length === 0) return null;
|
|
323
|
+
const allSelected = selectedIds.size === files.length;
|
|
324
|
+
return <Button title={allSelected ? labels.deselectAll : labels.selectAll} color="muted" onPress={() => setAll(!allSelected)} />;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Download. Acts on the SELECTION while selecting and on everything at rest, so
|
|
329
|
+
* it is one control rather than two that mean the same thing in different modes.
|
|
330
|
+
*/
|
|
331
|
+
export function FilesEditorDownload() {
|
|
332
|
+
const { files, selectMode, selected, download, labels } = useCtx("FilesEditorDownload");
|
|
333
|
+
if (files.length === 0) return null;
|
|
334
|
+
if (!selectMode) return <Button title={labels.downloadAll} color="secondary" onPress={() => download(files)} />;
|
|
335
|
+
return (
|
|
336
|
+
<Button
|
|
337
|
+
title={labels.downloadSelected(selected.length)}
|
|
338
|
+
color="secondary"
|
|
339
|
+
disabled={selected.length === 0}
|
|
340
|
+
onPress={() => download(selected)}
|
|
341
|
+
/>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Remove the picked files, behind the root's Alert confirm. Renders nothing
|
|
346
|
+
* when the root was given no `onRemove`, and nothing at rest — removing is an
|
|
347
|
+
* act on a chosen set, never on "everything". */
|
|
348
|
+
export function FilesEditorRemove() {
|
|
349
|
+
const { selectMode, selected, onRemove, confirmRemove, exit, labels } = useCtx("FilesEditorRemove");
|
|
350
|
+
if (!onRemove || !selectMode) return null;
|
|
351
|
+
return (
|
|
352
|
+
<Button
|
|
353
|
+
title={labels.delete}
|
|
354
|
+
color="danger-secondary"
|
|
355
|
+
disabled={selected.length === 0}
|
|
356
|
+
onPress={() => confirmRemove(selected, () => { for (const f of selected) onRemove(f.id); exit(); })}
|
|
357
|
+
/>
|
|
248
358
|
);
|
|
249
359
|
}
|
|
250
360
|
|
|
251
361
|
const styles = StyleSheet.create({
|
|
252
362
|
root: { gap: 10 },
|
|
253
|
-
bar: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
363
|
+
bar: { flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" },
|
|
254
364
|
spacer: { flex: 1 },
|
|
255
|
-
menu: { minWidth: 200, gap: 2, padding: 4 },
|
|
256
365
|
});
|
package/src/locale.tsx
CHANGED
|
@@ -118,6 +118,36 @@ export interface LoticsLocale {
|
|
|
118
118
|
fileDropzone: { label: string; hint: string; drop: string };
|
|
119
119
|
/** `FileThumbnail`'s `RemoveButton`: the ✕ a11y name. */
|
|
120
120
|
fileThumbnail: { remove: string };
|
|
121
|
+
/** `FilesEditor`'s shipped bar pieces (upload · select · select-all · download
|
|
122
|
+
* · remove) and the remove confirm. A HOST verb is a plain `Button` and names
|
|
123
|
+
* itself, so nothing here is about what an app happens to do with a file. */
|
|
124
|
+
filesEditor: {
|
|
125
|
+
upload: string;
|
|
126
|
+
select: string;
|
|
127
|
+
selectAll: string;
|
|
128
|
+
deselectAll: string;
|
|
129
|
+
done: string;
|
|
130
|
+
downloadAll: string;
|
|
131
|
+
downloadSelected: (n: number) => string;
|
|
132
|
+
delete: string;
|
|
133
|
+
removeTitle: string;
|
|
134
|
+
removeMessage: string;
|
|
135
|
+
removeCancel: string;
|
|
136
|
+
removeConfirm: string;
|
|
137
|
+
};
|
|
138
|
+
/** `FileGrid`'s in-flight tiles (`UploadingThumbnail`) + its retry-all footer:
|
|
139
|
+
* the states an upload can be caught in, and what to press about them. The
|
|
140
|
+
* plain `uploading` state has no entry on purpose — a spinner needs no word in
|
|
141
|
+
* any language, and only a state that wants ATTENTION earns one. */
|
|
142
|
+
fileUpload: {
|
|
143
|
+
paused: string;
|
|
144
|
+
retrying: string;
|
|
145
|
+
failed: string;
|
|
146
|
+
/** Retry is futile on the same input — a dead picker ref, or zero bytes. */
|
|
147
|
+
unavailable: string;
|
|
148
|
+
retry: string;
|
|
149
|
+
retryAll: string;
|
|
150
|
+
};
|
|
121
151
|
/** `ImageGallery`: the empty state, the inline rotate controls, and the
|
|
122
152
|
* press-to-zoom a11y name. */
|
|
123
153
|
imageGallery: { empty: string; rotateLeft: string; rotateRight: string; zoom: string };
|
|
@@ -250,6 +280,28 @@ export const en: LoticsLocale = {
|
|
|
250
280
|
overlay: { close: "Close" },
|
|
251
281
|
fileDropzone: { label: "Drag files here", hint: "or click, or paste (⌘V)", drop: "Drop to upload" },
|
|
252
282
|
fileThumbnail: { remove: "Remove" },
|
|
283
|
+
filesEditor: {
|
|
284
|
+
upload: "Upload",
|
|
285
|
+
select: "Select",
|
|
286
|
+
selectAll: "Select all",
|
|
287
|
+
deselectAll: "Deselect all",
|
|
288
|
+
done: "Done",
|
|
289
|
+
downloadAll: "Download all",
|
|
290
|
+
downloadSelected: (n: number) => `Download (${n})`,
|
|
291
|
+
delete: "Delete",
|
|
292
|
+
removeTitle: "Remove attachment?",
|
|
293
|
+
removeMessage: "This removes the file.",
|
|
294
|
+
removeCancel: "Cancel",
|
|
295
|
+
removeConfirm: "Remove",
|
|
296
|
+
},
|
|
297
|
+
fileUpload: {
|
|
298
|
+
paused: "Paused",
|
|
299
|
+
retrying: "Retrying",
|
|
300
|
+
failed: "Upload failed",
|
|
301
|
+
unavailable: "Can't upload",
|
|
302
|
+
retry: "Retry upload",
|
|
303
|
+
retryAll: "Retry all",
|
|
304
|
+
},
|
|
253
305
|
imageGallery: { empty: "No images.", rotateLeft: "Rotate left", rotateRight: "Rotate right", zoom: "Zoom image" },
|
|
254
306
|
infoPopover: { more: "More information" },
|
|
255
307
|
matrix: { total: "Total", less: "Less", more: "More" },
|
|
@@ -376,6 +428,28 @@ export const vi: LoticsLocale = {
|
|
|
376
428
|
overlay: { close: "Đóng" },
|
|
377
429
|
fileDropzone: { label: "Kéo tệp vào đây", hint: "hoặc bấm chọn, hoặc dán (Ctrl+V)", drop: "Thả để tải lên" },
|
|
378
430
|
fileThumbnail: { remove: "Xóa" },
|
|
431
|
+
filesEditor: {
|
|
432
|
+
upload: "Tải lên",
|
|
433
|
+
select: "Chọn",
|
|
434
|
+
selectAll: "Chọn tất cả",
|
|
435
|
+
deselectAll: "Bỏ chọn",
|
|
436
|
+
done: "Xong",
|
|
437
|
+
downloadAll: "Tải tất cả",
|
|
438
|
+
downloadSelected: (n: number) => `Tải ${n} tệp`,
|
|
439
|
+
delete: "Xóa",
|
|
440
|
+
removeTitle: "Xóa tệp?",
|
|
441
|
+
removeMessage: "Tệp sẽ bị gỡ khỏi bản ghi.",
|
|
442
|
+
removeCancel: "Hủy",
|
|
443
|
+
removeConfirm: "Xóa",
|
|
444
|
+
},
|
|
445
|
+
fileUpload: {
|
|
446
|
+
paused: "Tạm dừng",
|
|
447
|
+
retrying: "Đang thử lại",
|
|
448
|
+
failed: "Không tải lên được",
|
|
449
|
+
unavailable: "Tệp không đọc được",
|
|
450
|
+
retry: "Thử lại",
|
|
451
|
+
retryAll: "Thử lại tất cả",
|
|
452
|
+
},
|
|
379
453
|
imageGallery: { empty: "Chưa có ảnh.", rotateLeft: "Xoay trái", rotateRight: "Xoay phải", zoom: "Phóng to ảnh" },
|
|
380
454
|
infoPopover: { more: "Thông tin thêm" },
|
|
381
455
|
matrix: { total: "Tổng", less: "Ít", more: "Nhiều" },
|
|
@@ -14,9 +14,11 @@ import { isImageMimeType } from "./mime";
|
|
|
14
14
|
/** In-flight upload states (everything except a completed file). */
|
|
15
15
|
export type UploadStatus = "preparing" | "queued" | "uploading" | "paused_offline" | "retrying" | "error";
|
|
16
16
|
|
|
17
|
-
/** Status text for the non-happy-path states. i18n-free
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
/** Status text for the non-happy-path states. This component stays i18n-free —
|
|
18
|
+
* `FileGrid` resolves the words (per-instance labels over the active
|
|
19
|
+
* `LoticsLocale`) and hands them in, so nothing below it reads context. The
|
|
20
|
+
* common `uploading` state stays text-free (a spinner is self-evident) — only
|
|
21
|
+
* states that need attention get a word. */
|
|
20
22
|
export interface UploadStatusLabels {
|
|
21
23
|
/** Upload paused because the device is offline. Default "Paused". */
|
|
22
24
|
paused?: string;
|