@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 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 (incl. 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`). |
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 all-in-one attachment field: an upload-aware grid + a toolbar below it
225
- that swaps into a batch SELECT mode, full-screen preview, download/share, and
226
- Alert-confirmed remove — the host only owns `files` + wires `onAdd`/`onRemove`. Reach for
227
- this first for "manage a record's attachments"), `FileDropzone`, `FileRows`
228
- (batteries-included file LIST: tap a row built-in full-screen gallery, with a per-row
229
- trailing menu = Download · Open-external · Remove; the default "here are some files"
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`** — `FilesEditor` THE all-in-one attachment field: `FileGrid` + a
1069
- toolbar (Upload primary · Select · Download all) that swaps into a batch SELECT mode
1070
- (Select all · a Menu of Download/Share/Delete · Done; the per-tile is select-mode-only,
1071
- never in the default view; `selectTileRemove={false}` drops even that so delete is
1072
- menu-only) + built-in gallery (Download + inline preview; no "open in new tab") +
1073
- Alert-confirmed remove; host wires `files` + `onAdd`/`onRemove` (+ optional `uploads`,
1074
- `onShareSelected`, `readOnly`, `selectTileRemove`, `labels`, `galleryLabels`,
1075
- `gridMaxHeight` cap the grid height so it scrolls and the toolbar pins, for a
1076
- popover/drawer). Use `FileGrid`/`FileRows` bare only when you own the chrome.
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
- localize with `labels.upload` (an `UploadStatusLabels`).
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
@@ -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
- **`<FilesEditor files onAdd onRemove>`** bundles the upload-aware grid + a toolbar below it
520
- (Upload · Select · Download all) + a batch SELECT mode + the full-screen gallery +
521
- Alert-confirmed remove; the host only owns `files` and wires `onAdd` (picked its upload) /
522
- `onRemove`. The destructive per-tile shows only in SELECT mode (the default view is a clean
523
- preview — no stray-tap deletes); pass `selectTileRemove={false}` to drop that ✕ entirely so
524
- select mode deletes ONLY via Select → Menu → Delete (the batch gating flow — right for a
525
- height-bounded cell editor). The built-in gallery is Download + inline preview (no "open in new
526
- tab" — it's redundant once everything previews inline). In a height-bounded container (a
527
- popover/drawer) pass `gridMaxHeight` so the grid SCROLLS and the toolbar pins below it; omit it
528
- in free-flow layouts (a form field) where the grid grows.
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 action's built-in web
532
- picker (e.g. a native document picker); `readOnly` (or omitting `onRemove`) makes it a view-only
533
- download+preview surface; omitting `onAdd` hides Upload. Select mode gains a Share action via
534
- `onShareSelected(files)` and a "Download as ZIP" item via `onDownloadZipSelected(files)` (shown at
535
- 2+ selected) both host-provided, hidden when omitted. `onDownload` overrides the default
536
- per-file `downloadFileFromUrl`; `accept` filters the picker; `itemSize`/`minItemWidth`/`columns`
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`. Localize the labels with `labels.upload` (an `UploadStatusLabels`) and
555
- `labels.retryAll`. **Limitation:** the upload-status labels are NOT wired to
556
- `LoticsLocaleProvider` they default to English; pass `labels` per instance to localize.
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
- <FilesEditor files={files} itemSize={76} onAdd={onAdd} onRemove={onRemove} />
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "20.2.0",
3
+ "version": "21.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
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 ?? "Retry all"} />
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={labels?.upload}
195
+ labels={upload}
184
196
  />
185
197
  )
186
198
  }
@@ -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 menu item, given the count. */
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
- const DEFAULT_LABELS: FilesEditorLabels = {
36
- upload: "Upload",
37
- select: "Select",
38
- selectAll: "Select all",
39
- deselectAll: "Deselect all",
40
- done: "Done",
41
- menu: "Menu",
42
- downloadAll: "Download all",
43
- downloadSelected: (n) => `Download (${n})`,
44
- downloadZip: "Download as ZIP",
45
- share: "Share",
46
- delete: "Delete",
47
- removeTitle: "Remove attachment?",
48
- removeMessage: "This removes the file.",
49
- removeCancel: "Cancel",
50
- removeConfirm: "Remove",
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 Upload. */
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, the Upload button calls this instead of the built-in web `pickFiles`. */
108
+ * set, `FilesEditorUpload` calls this instead of the built-in web `pickFiles`. */
62
109
  onUpload?: () => void;
63
- /** Show a "Download as ZIP" action in select mode (2+ selected), given the files. */
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 batch Select Menu → Delete is the sole delete
70
- * path (e.g. a cell popover), so a tile in select mode toggles selection and
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 toolbar pins below it —
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
- /** Translated labels. Defaults to English. */
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 English. */
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 all-in-one attachment surface: an upload-aware thumbnail grid (`FileGrid`)
106
- * with a toolbar BELOW it, a batch SELECT mode, full-screen preview
107
- * (`FileGalleryModal`), and download / share / Alert-confirmed remove. Agnostic —
108
- * the host wires `onAdd` (picked files its upload) and `onRemove`; select mode,
109
- * the gallery, download and the remove confirm are all internal. Mirrors the
110
- * frontend `files_editor`. For a plain row list use `FileRows`; for the bare
111
- * upload grid use `FileGrid`.
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 l = { ...DEFAULT_LABELS, ...labels };
202
+ const loc = useLoticsLocale().filesEditor;
203
+ const l = useMemo(() => ({ ...loc, ...labels }), [loc, labels]);
138
204
 
139
- const [selectMode, setSelectMode] = useState(false);
140
- const [selected, setSelected] = useState<Set<string>>(() => new Set());
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 hasFiles = files.length > 0;
145
- const allSelected = hasFiles && selected.size === files.length;
146
- const selectedFiles = files.filter((f) => selected.has(f.id));
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 toggle = (id: string) =>
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
- const upload = () => {
159
- if (!onAdd) return;
160
- void pickFiles({ multiple: true, accept }).then((picked) => { if (picked.length > 0) onAdd(picked); });
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 ? selected : undefined}
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 menu.
252
+ // file, and a `selectTileRemove={false}` surface deletes only via the bar.
176
253
  onDisplayRemove={
177
- !readOnly && selectMode && selectTileRemove && onRemove ? (id) => confirmRemove(() => onRemove(id)) : undefined
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
- <View style={styles.root}>
187
- {gridMaxHeight !== undefined ? (
188
- <ScrollView style={{ maxHeight: gridMaxHeight }}>{grid}</ScrollView>
189
- ) : (
190
- grid
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
- {readOnly ? (
194
- hasFiles ? (
195
- <View style={styles.bar}>
196
- <View style={styles.spacer} />
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
- <FileGalleryModal
239
- files={files}
240
- activeIndex={galleryIndex}
241
- onIndexChange={setGalleryIndex}
242
- onRemove={!readOnly && onRemove ? onRemove : undefined}
243
- onDownload={onDownload}
244
- credentials={credentials}
245
- labels={galleryLabels}
246
- />
247
- </View>
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 defaults (English); the
18
- * Lotics host injects translations. The common `uploading` state stays text-free
19
- * (a spinner is self-evident) only states that need attention get a word. */
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;