@lotics/ui 20.1.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.
@@ -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;