@unofficialbox/box-open-elements 0.16.0 → 0.17.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/dist/components/files/drop-zone.d.ts +34 -0
- package/dist/components/files/drop-zone.js +75 -13
- package/dist/foundations/files/directory-entries.d.ts +69 -0
- package/dist/foundations/files/directory-entries.js +149 -0
- package/dist/foundations/files/index.d.ts +1 -0
- package/dist/foundations/files/index.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/patterns/content-uploader/content-uploader.d.ts +43 -0
- package/dist/patterns/content-uploader/content-uploader.js +85 -6
- package/dist/patterns/content-uploader/controller.d.ts +37 -0
- package/dist/patterns/content-uploader/controller.js +111 -2
- package/dist/patterns/content-uploader/types.d.ts +51 -1
- package/package.json +1 -1
|
@@ -13,7 +13,41 @@ export declare class DropZone extends BaseElement {
|
|
|
13
13
|
set message(value: string);
|
|
14
14
|
get description(): string;
|
|
15
15
|
set description(value: string);
|
|
16
|
+
/**
|
|
17
|
+
* Make the browse dialog pick a folder rather than files.
|
|
18
|
+
*
|
|
19
|
+
* Drag-and-drop reads folders regardless — a dropped directory is always
|
|
20
|
+
* traversed. This only governs the click-to-browse path, where the platform
|
|
21
|
+
* makes it either/or: an input is a file picker or a folder picker, never
|
|
22
|
+
* both.
|
|
23
|
+
*/
|
|
24
|
+
get directories(): boolean;
|
|
25
|
+
set directories(value: boolean);
|
|
26
|
+
/**
|
|
27
|
+
* What the browse dialog offers, in the `<input accept>` syntax —
|
|
28
|
+
* `".pdf,.docx"` or `"image/*"`.
|
|
29
|
+
*
|
|
30
|
+
* A hint, never a guarantee: it greys out other files in the picker, but a
|
|
31
|
+
* person can still choose "All files", and it has no effect at all on a drop.
|
|
32
|
+
* Whatever accepts the selection afterwards stays the real check.
|
|
33
|
+
*/
|
|
34
|
+
get accept(): string;
|
|
35
|
+
set accept(value: string);
|
|
16
36
|
protected renderTemplate(): void;
|
|
17
37
|
protected setupListeners(): void;
|
|
38
|
+
/**
|
|
39
|
+
* Report a selection.
|
|
40
|
+
*
|
|
41
|
+
* `files` stays a flat list so existing hosts keep working unchanged;
|
|
42
|
+
* `entries` adds the directory each file came from, which is the only way a
|
|
43
|
+
* host can recreate a dropped folder. `skipped` names anything the browser
|
|
44
|
+
* refused to read, so a lost file is reported rather than simply absent.
|
|
45
|
+
*
|
|
46
|
+
* Nothing is dispatched when there is nothing to say — a drag that lands on
|
|
47
|
+
* the zone carrying no files is not an upload of nothing. A drop where
|
|
48
|
+
* everything was skipped still reports, because that is a failure, not
|
|
49
|
+
* silence.
|
|
50
|
+
*/
|
|
51
|
+
private emitSelection;
|
|
18
52
|
protected update(): void;
|
|
19
53
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BaseElement } from "../../core/index.js";
|
|
2
|
+
import { captureDropEntries, collectEntries, entriesFromFileList, } from "../../foundations/files/index.js";
|
|
2
3
|
import { boeFocusRingShadow, boeFocusVisibleStyles, } from "../../foundations/tokens/index.js";
|
|
3
4
|
import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
|
|
4
5
|
import { boeRadius } from "../../foundations/geometry/index.js";
|
|
@@ -73,7 +74,7 @@ const dropZoneStyles = `
|
|
|
73
74
|
export class DropZone extends BaseElement {
|
|
74
75
|
static tagName = DEFAULT_TAG_NAME;
|
|
75
76
|
static get observedAttributes() {
|
|
76
|
-
return ["description", "label", "message"];
|
|
77
|
+
return ["accept", "description", "directories", "label", "message"];
|
|
77
78
|
}
|
|
78
79
|
dragging = false;
|
|
79
80
|
zoneEl;
|
|
@@ -98,6 +99,38 @@ export class DropZone extends BaseElement {
|
|
|
98
99
|
set description(value) {
|
|
99
100
|
this.setAttribute("description", value);
|
|
100
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Make the browse dialog pick a folder rather than files.
|
|
104
|
+
*
|
|
105
|
+
* Drag-and-drop reads folders regardless — a dropped directory is always
|
|
106
|
+
* traversed. This only governs the click-to-browse path, where the platform
|
|
107
|
+
* makes it either/or: an input is a file picker or a folder picker, never
|
|
108
|
+
* both.
|
|
109
|
+
*/
|
|
110
|
+
get directories() {
|
|
111
|
+
return this.hasAttribute("directories");
|
|
112
|
+
}
|
|
113
|
+
set directories(value) {
|
|
114
|
+
this.toggleAttribute("directories", value);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* What the browse dialog offers, in the `<input accept>` syntax —
|
|
118
|
+
* `".pdf,.docx"` or `"image/*"`.
|
|
119
|
+
*
|
|
120
|
+
* A hint, never a guarantee: it greys out other files in the picker, but a
|
|
121
|
+
* person can still choose "All files", and it has no effect at all on a drop.
|
|
122
|
+
* Whatever accepts the selection afterwards stays the real check.
|
|
123
|
+
*/
|
|
124
|
+
get accept() {
|
|
125
|
+
return this.getAttribute("accept") ?? "";
|
|
126
|
+
}
|
|
127
|
+
set accept(value) {
|
|
128
|
+
if (value) {
|
|
129
|
+
this.setAttribute("accept", value);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.removeAttribute("accept");
|
|
133
|
+
}
|
|
101
134
|
renderTemplate() {
|
|
102
135
|
if (!this.shadowRoot) {
|
|
103
136
|
return;
|
|
@@ -134,23 +167,45 @@ export class DropZone extends BaseElement {
|
|
|
134
167
|
this.zoneEl.addEventListener("drop", event => {
|
|
135
168
|
event.preventDefault();
|
|
136
169
|
this.dragging = false;
|
|
137
|
-
const files = Array.from(event.dataTransfer?.files ?? []);
|
|
138
|
-
this.dispatchEvent(new CustomEvent("files-selected", {
|
|
139
|
-
bubbles: true,
|
|
140
|
-
composed: true,
|
|
141
|
-
detail: { files },
|
|
142
|
-
}));
|
|
143
170
|
this.update();
|
|
171
|
+
// Captured synchronously: the DataTransferItemList is emptied the moment
|
|
172
|
+
// this handler returns, so awaiting anything before reading it loses the
|
|
173
|
+
// drop entirely. Traversal happens afterwards, off the captured entries.
|
|
174
|
+
const captured = captureDropEntries(event.dataTransfer);
|
|
175
|
+
const skipped = [];
|
|
176
|
+
void collectEntries(captured, name => skipped.push(name)).then(entries => {
|
|
177
|
+
this.emitSelection(entries, skipped);
|
|
178
|
+
});
|
|
144
179
|
});
|
|
145
180
|
this.inputEl.addEventListener("change", () => {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
composed: true,
|
|
150
|
-
detail: { files },
|
|
151
|
-
}));
|
|
181
|
+
// `webkitdirectory` reports the tree through each file's
|
|
182
|
+
// webkitRelativePath rather than through entries.
|
|
183
|
+
this.emitSelection(entriesFromFileList(Array.from(this.inputEl.files ?? [])));
|
|
152
184
|
});
|
|
153
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Report a selection.
|
|
188
|
+
*
|
|
189
|
+
* `files` stays a flat list so existing hosts keep working unchanged;
|
|
190
|
+
* `entries` adds the directory each file came from, which is the only way a
|
|
191
|
+
* host can recreate a dropped folder. `skipped` names anything the browser
|
|
192
|
+
* refused to read, so a lost file is reported rather than simply absent.
|
|
193
|
+
*
|
|
194
|
+
* Nothing is dispatched when there is nothing to say — a drag that lands on
|
|
195
|
+
* the zone carrying no files is not an upload of nothing. A drop where
|
|
196
|
+
* everything was skipped still reports, because that is a failure, not
|
|
197
|
+
* silence.
|
|
198
|
+
*/
|
|
199
|
+
emitSelection(entries, skipped = []) {
|
|
200
|
+
if (!entries.length && !skipped.length) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
this.dispatchEvent(new CustomEvent("files-selected", {
|
|
204
|
+
bubbles: true,
|
|
205
|
+
composed: true,
|
|
206
|
+
detail: { entries, files: entries.map(entry => entry.file), skipped },
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
154
209
|
update() {
|
|
155
210
|
if (!this.zoneEl) {
|
|
156
211
|
return;
|
|
@@ -158,6 +213,13 @@ export class DropZone extends BaseElement {
|
|
|
158
213
|
this.zoneEl.dataset.dragging = String(this.dragging);
|
|
159
214
|
this.labelEl.textContent = this.label;
|
|
160
215
|
this.messageEl.textContent = this.message;
|
|
216
|
+
// webkitdirectory turns the browse dialog into a folder picker. It is a
|
|
217
|
+
// property as well as an attribute, and only the property is honoured
|
|
218
|
+
// reliably once the input exists.
|
|
219
|
+
const directories = this.directories;
|
|
220
|
+
this.inputEl.toggleAttribute("webkitdirectory", directories);
|
|
221
|
+
this.inputEl.webkitdirectory = directories;
|
|
222
|
+
this.inputEl.accept = this.accept;
|
|
161
223
|
}
|
|
162
224
|
}
|
|
163
225
|
DropZone.register();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading dropped folders, using only what the platform provides.
|
|
3
|
+
*
|
|
4
|
+
* A drop carries two very different things. `DataTransfer.files` is a flat
|
|
5
|
+
* `FileList` that **cannot represent a directory** — drop a folder and you get
|
|
6
|
+
* nothing useful, silently. `DataTransfer.items` carries `FileSystemEntry`
|
|
7
|
+
* objects via `webkitGetAsEntry()`, which is the only way to see that a folder
|
|
8
|
+
* was dropped at all, let alone read what is inside it.
|
|
9
|
+
*
|
|
10
|
+
* Two rules here are load-bearing and easy to get wrong:
|
|
11
|
+
*
|
|
12
|
+
* 1. **`webkitGetAsEntry()` must be called synchronously** while the `drop`
|
|
13
|
+
* event is being handled. The `DataTransferItemList` is emptied as soon as
|
|
14
|
+
* the handler returns, so an `await` before the call leaves you with
|
|
15
|
+
* nothing. Capture first (`captureDropEntries`), traverse afterwards
|
|
16
|
+
* (`collectEntries`).
|
|
17
|
+
* 2. **`readEntries()` is batched.** It resolves with at most ~100 entries per
|
|
18
|
+
* call and must be called repeatedly until it yields an empty array. Read it
|
|
19
|
+
* once and a folder of 500 files quietly becomes a folder of 100.
|
|
20
|
+
*/
|
|
21
|
+
/** A file plus the directory path it was found under, relative to the drop. */
|
|
22
|
+
export interface UploadEntry {
|
|
23
|
+
file: File;
|
|
24
|
+
/**
|
|
25
|
+
* Directory path relative to the drop root, e.g. `"docs/2026"`. Empty for a
|
|
26
|
+
* file dropped at the top level.
|
|
27
|
+
*/
|
|
28
|
+
path: string;
|
|
29
|
+
}
|
|
30
|
+
/** Minimal shape of the entry objects, which TypeScript's DOM lib types loosely. */
|
|
31
|
+
interface FileSystemEntryLike {
|
|
32
|
+
isDirectory: boolean;
|
|
33
|
+
isFile: boolean;
|
|
34
|
+
name: string;
|
|
35
|
+
createReader?: () => FileSystemDirectoryReaderLike;
|
|
36
|
+
file?: (onSuccess: (file: File) => void, onError?: (error: unknown) => void) => void;
|
|
37
|
+
}
|
|
38
|
+
interface FileSystemDirectoryReaderLike {
|
|
39
|
+
readEntries: (onSuccess: (entries: FileSystemEntryLike[]) => void, onError?: (error: unknown) => void) => void;
|
|
40
|
+
}
|
|
41
|
+
/** What a synchronous capture yields: an entry, or a plain file when there is none. */
|
|
42
|
+
export interface CapturedDropItem {
|
|
43
|
+
entry: FileSystemEntryLike | null;
|
|
44
|
+
file: File | null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Read the entries out of a drop **synchronously**, before the event handler
|
|
48
|
+
* returns and the item list is emptied.
|
|
49
|
+
*/
|
|
50
|
+
export declare const captureDropEntries: (dataTransfer: DataTransfer | null) => CapturedDropItem[];
|
|
51
|
+
/**
|
|
52
|
+
* Resolve captured drop items into files, each tagged with the directory path
|
|
53
|
+
* it came from. Files dropped at the top level get an empty path.
|
|
54
|
+
*
|
|
55
|
+
* Never rejects. A file the browser refuses to hand over, or a directory it
|
|
56
|
+
* cannot finish reading, is reported through `onSkip` and everything else still
|
|
57
|
+
* comes back — losing one file must not lose the drop, but nor should it pass
|
|
58
|
+
* unmentioned, which is the failure mode this whole module exists to remove.
|
|
59
|
+
*/
|
|
60
|
+
export declare const collectEntries: (captured: CapturedDropItem[], onSkip?: (name: string) => void) => Promise<UploadEntry[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Derive the same shape from an `<input webkitdirectory>` selection, where the
|
|
63
|
+
* browser reports the tree through each file's `webkitRelativePath` rather than
|
|
64
|
+
* through entries.
|
|
65
|
+
*/
|
|
66
|
+
export declare const entriesFromFileList: (files: readonly File[]) => UploadEntry[];
|
|
67
|
+
/** Whether any entry sits inside a folder, i.e. whether folder support is needed. */
|
|
68
|
+
export declare const hasFolderEntries: (entries: readonly UploadEntry[]) => boolean;
|
|
69
|
+
export {};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading dropped folders, using only what the platform provides.
|
|
3
|
+
*
|
|
4
|
+
* A drop carries two very different things. `DataTransfer.files` is a flat
|
|
5
|
+
* `FileList` that **cannot represent a directory** — drop a folder and you get
|
|
6
|
+
* nothing useful, silently. `DataTransfer.items` carries `FileSystemEntry`
|
|
7
|
+
* objects via `webkitGetAsEntry()`, which is the only way to see that a folder
|
|
8
|
+
* was dropped at all, let alone read what is inside it.
|
|
9
|
+
*
|
|
10
|
+
* Two rules here are load-bearing and easy to get wrong:
|
|
11
|
+
*
|
|
12
|
+
* 1. **`webkitGetAsEntry()` must be called synchronously** while the `drop`
|
|
13
|
+
* event is being handled. The `DataTransferItemList` is emptied as soon as
|
|
14
|
+
* the handler returns, so an `await` before the call leaves you with
|
|
15
|
+
* nothing. Capture first (`captureDropEntries`), traverse afterwards
|
|
16
|
+
* (`collectEntries`).
|
|
17
|
+
* 2. **`readEntries()` is batched.** It resolves with at most ~100 entries per
|
|
18
|
+
* call and must be called repeatedly until it yields an empty array. Read it
|
|
19
|
+
* once and a folder of 500 files quietly becomes a folder of 100.
|
|
20
|
+
*/
|
|
21
|
+
const getAsEntry = (item) => {
|
|
22
|
+
const candidate = item;
|
|
23
|
+
const method = candidate.webkitGetAsEntry ?? candidate.mozGetAsEntry ?? candidate.getAsEntry;
|
|
24
|
+
return typeof method === "function" ? method.call(item) ?? null : null;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* A macOS bundle (`.app`, `.rtfd`, a Keynote file) arrives as a *directory*
|
|
28
|
+
* that the OS also describes as a zip. Descending into one would upload its
|
|
29
|
+
* internals as loose files and destroy the thing; it is uploaded whole.
|
|
30
|
+
*/
|
|
31
|
+
const isPackage = (item, entry) => entry !== null && entry.isDirectory && item.kind === "file" && item.type === "application/zip";
|
|
32
|
+
/**
|
|
33
|
+
* Read the entries out of a drop **synchronously**, before the event handler
|
|
34
|
+
* returns and the item list is emptied.
|
|
35
|
+
*/
|
|
36
|
+
export const captureDropEntries = (dataTransfer) => {
|
|
37
|
+
if (!dataTransfer) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const items = Array.from(dataTransfer.items ?? []).filter(item => item.kind === "file");
|
|
41
|
+
if (items.length) {
|
|
42
|
+
return items.map(item => {
|
|
43
|
+
const entry = getAsEntry(item);
|
|
44
|
+
// A package is captured as a file so traversal never descends into it,
|
|
45
|
+
// and getAsFile() is also only valid during the event.
|
|
46
|
+
return isPackage(item, entry)
|
|
47
|
+
? { entry: null, file: item.getAsFile() }
|
|
48
|
+
: { entry, file: entry ? null : item.getAsFile() };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
// No item list (older browsers, synthetic events): the flat file list is all
|
|
52
|
+
// there is, and it cannot describe folders.
|
|
53
|
+
return Array.from(dataTransfer.files ?? []).map(file => ({ entry: null, file }));
|
|
54
|
+
};
|
|
55
|
+
const readAllEntries = async (reader) => {
|
|
56
|
+
const all = [];
|
|
57
|
+
// readEntries resolves a batch at a time and signals the end with an empty
|
|
58
|
+
// array. Calling it once truncates any directory past the batch size.
|
|
59
|
+
for (;;) {
|
|
60
|
+
// A directory that cannot be read yields what was read before it failed,
|
|
61
|
+
// rather than throwing: one unreadable subfolder must not discard the
|
|
62
|
+
// hundreds of files that were read successfully around it.
|
|
63
|
+
const batch = await new Promise(resolve => {
|
|
64
|
+
try {
|
|
65
|
+
reader.readEntries(resolve, () => resolve(null));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
resolve(null);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
if (batch === null) {
|
|
72
|
+
return { entries: all, failed: true };
|
|
73
|
+
}
|
|
74
|
+
if (!batch.length) {
|
|
75
|
+
return { entries: all, failed: false };
|
|
76
|
+
}
|
|
77
|
+
all.push(...batch);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const readFile = async (entry) => {
|
|
81
|
+
if (typeof entry.file !== "function") {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return new Promise(resolve => {
|
|
85
|
+
entry.file(file => resolve(file),
|
|
86
|
+
// An unreadable file inside an otherwise good folder should not abort the
|
|
87
|
+
// whole drop; it is skipped and the rest still uploads.
|
|
88
|
+
() => resolve(null));
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
const walk = async (entry, path, out, onSkip) => {
|
|
92
|
+
if (entry.isFile) {
|
|
93
|
+
const file = await readFile(entry);
|
|
94
|
+
if (file) {
|
|
95
|
+
out.push({ file, path });
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
onSkip(path ? `${path}/${entry.name}` : entry.name);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!entry.isDirectory || typeof entry.createReader !== "function") {
|
|
103
|
+
onSkip(path ? `${path}/${entry.name}` : entry.name);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const childPath = path ? `${path}/${entry.name}` : entry.name;
|
|
107
|
+
const { entries: children, failed } = await readAllEntries(entry.createReader());
|
|
108
|
+
if (failed) {
|
|
109
|
+
onSkip(childPath);
|
|
110
|
+
}
|
|
111
|
+
for (const child of children) {
|
|
112
|
+
await walk(child, childPath, out, onSkip);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Resolve captured drop items into files, each tagged with the directory path
|
|
117
|
+
* it came from. Files dropped at the top level get an empty path.
|
|
118
|
+
*
|
|
119
|
+
* Never rejects. A file the browser refuses to hand over, or a directory it
|
|
120
|
+
* cannot finish reading, is reported through `onSkip` and everything else still
|
|
121
|
+
* comes back — losing one file must not lose the drop, but nor should it pass
|
|
122
|
+
* unmentioned, which is the failure mode this whole module exists to remove.
|
|
123
|
+
*/
|
|
124
|
+
export const collectEntries = async (captured, onSkip = () => { }) => {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const item of captured) {
|
|
127
|
+
if (item.entry) {
|
|
128
|
+
await walk(item.entry, "", out, onSkip);
|
|
129
|
+
}
|
|
130
|
+
else if (item.file) {
|
|
131
|
+
out.push({ file: item.file, path: "" });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Derive the same shape from an `<input webkitdirectory>` selection, where the
|
|
138
|
+
* browser reports the tree through each file's `webkitRelativePath` rather than
|
|
139
|
+
* through entries.
|
|
140
|
+
*/
|
|
141
|
+
export const entriesFromFileList = (files) => files.map(file => {
|
|
142
|
+
const relativePath = file.webkitRelativePath ?? "";
|
|
143
|
+
// webkitRelativePath includes the file name; the directory is everything
|
|
144
|
+
// before the last separator.
|
|
145
|
+
const separator = relativePath.lastIndexOf("/");
|
|
146
|
+
return { file, path: separator > 0 ? relativePath.slice(0, separator) : "" };
|
|
147
|
+
});
|
|
148
|
+
/** Whether any entry sits inside a folder, i.e. whether folder support is needed. */
|
|
149
|
+
export const hasFolderEntries = (entries) => entries.some(entry => entry.path !== "");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./directory-entries.js";
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type { ApplyDesignProfileOptions, DesignProfileChangeDetail, DesignProfil
|
|
|
10
10
|
export { boxIconography, boxIconographyAliases } from "./foundations/icons/index.js";
|
|
11
11
|
export * from "./foundations/layout/index.js";
|
|
12
12
|
export * from "./foundations/format/index.js";
|
|
13
|
+
export * from "./foundations/files/index.js";
|
|
13
14
|
export { anchorFloating, parsePlacement, resolvePosition, trackAnchor, } from "./foundations/overlay/index.js";
|
|
14
15
|
export type { OverlayAlign, OverlayPlacement, OverlaySide, PositionOptions, PositionResult, } from "./foundations/overlay/index.js";
|
|
15
16
|
export * from "./components/actions/button.js";
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,9 @@ export { boxIconography, boxIconographyAliases } from "./foundations/icons/index
|
|
|
9
9
|
export * from "./foundations/layout/index.js";
|
|
10
10
|
// Foundations: locale-aware typed-value formatting (shared by box-formatted-*)
|
|
11
11
|
export * from "./foundations/format/index.js";
|
|
12
|
+
// Foundations: reading dropped files and folders (shared by box-drop-zone and
|
|
13
|
+
// the content-uploader pattern)
|
|
14
|
+
export * from "./foundations/files/index.js";
|
|
12
15
|
// Foundations: viewport-aware overlay positioning (for building custom overlays)
|
|
13
16
|
export { anchorFloating, parsePlacement, resolvePosition, trackAnchor, } from "./foundations/overlay/index.js";
|
|
14
17
|
// Components: actions
|
|
@@ -13,6 +13,7 @@ export declare class ContentUploader extends BaseElement {
|
|
|
13
13
|
private footerEl;
|
|
14
14
|
private summaryEl;
|
|
15
15
|
private queueSignature;
|
|
16
|
+
private updateScheduled;
|
|
16
17
|
get folderId(): string | null;
|
|
17
18
|
set folderId(value: string | null);
|
|
18
19
|
get token(): string | null;
|
|
@@ -30,6 +31,25 @@ export declare class ContentUploader extends BaseElement {
|
|
|
30
31
|
/** Maximum accepted file size in bytes. */
|
|
31
32
|
get maxFileSize(): number | undefined;
|
|
32
33
|
set maxFileSize(value: number | undefined);
|
|
34
|
+
/**
|
|
35
|
+
* Most files the queue will hold, default 100 — the same ceiling
|
|
36
|
+
* box-ui-elements uses. Further files are rejected with
|
|
37
|
+
* `file-limit-reached` rather than enqueued.
|
|
38
|
+
*
|
|
39
|
+
* A default matters here in a way it does not for the other constraints: a
|
|
40
|
+
* dropped folder can carry thousands of files, and an unbounded queue has no
|
|
41
|
+
* back pressure at all.
|
|
42
|
+
*/
|
|
43
|
+
get fileLimit(): number;
|
|
44
|
+
set fileLimit(value: number);
|
|
45
|
+
/**
|
|
46
|
+
* Make the browse dialog pick a folder rather than files.
|
|
47
|
+
*
|
|
48
|
+
* Dropping a folder works either way; the platform only forces the choice on
|
|
49
|
+
* the click-to-browse path.
|
|
50
|
+
*/
|
|
51
|
+
get directories(): boolean;
|
|
52
|
+
set directories(value: boolean);
|
|
33
53
|
get dropLabel(): string;
|
|
34
54
|
set dropLabel(value: string);
|
|
35
55
|
get dropMessage(): string;
|
|
@@ -39,10 +59,24 @@ export declare class ContentUploader extends BaseElement {
|
|
|
39
59
|
get state(): Readonly<UploaderState> | null;
|
|
40
60
|
/** The live queue controller. Null until configured. */
|
|
41
61
|
get uploaderController(): ContentUploaderController | null;
|
|
62
|
+
/**
|
|
63
|
+
* Attributes that only affect how the drop zone presents itself. Everything
|
|
64
|
+
* else feeds the session config, so changing it re-creates the queue.
|
|
65
|
+
*/
|
|
66
|
+
private static readonly PRESENTATION_ATTRIBUTES;
|
|
42
67
|
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
43
68
|
connectedCallback(): void;
|
|
44
69
|
disconnectedCallback(): void;
|
|
45
70
|
addFiles(files: UploadFileLike[]): UploadQueueItem[];
|
|
71
|
+
/**
|
|
72
|
+
* Queue files that carry a directory path relative to the destination, the
|
|
73
|
+
* way a dropped folder does. Requires `createFolder` on the transport;
|
|
74
|
+
* without it each pathed file is rejected with `folder-unsupported`.
|
|
75
|
+
*/
|
|
76
|
+
addEntries(entries: Array<{
|
|
77
|
+
file: UploadFileLike;
|
|
78
|
+
path?: string;
|
|
79
|
+
}>): UploadQueueItem[];
|
|
46
80
|
start(): void;
|
|
47
81
|
cancelItem(itemId: string): void;
|
|
48
82
|
retryItem(itemId: string): void;
|
|
@@ -53,6 +87,15 @@ export declare class ContentUploader extends BaseElement {
|
|
|
53
87
|
private startController;
|
|
54
88
|
private readConfig;
|
|
55
89
|
private subscribeToController;
|
|
90
|
+
/**
|
|
91
|
+
* Coalesce the re-render for a burst of controller events.
|
|
92
|
+
*
|
|
93
|
+
* A folder drop adds up to `fileLimit` items in one go, each emitting its own
|
|
94
|
+
* `itemAdded`; rendering per event rebuilt the whole queue once per file,
|
|
95
|
+
* which is quadratic in the size of the drop. One render per microtask turn
|
|
96
|
+
* gives the same result for a fraction of the work.
|
|
97
|
+
*/
|
|
98
|
+
private scheduleUpdate;
|
|
56
99
|
private teardownController;
|
|
57
100
|
protected renderTemplate(): void;
|
|
58
101
|
protected setupListeners(): void;
|
|
@@ -7,6 +7,8 @@ import { BaseElement } from "../../core/index.js";
|
|
|
7
7
|
import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
|
|
8
8
|
import { boePanel, boeRadius } from "../../foundations/geometry/index.js";
|
|
9
9
|
const DEFAULT_TAG_NAME = "box-content-uploader";
|
|
10
|
+
/** Matches the box-ui-elements ceiling, so a port behaves the same way. */
|
|
11
|
+
const DEFAULT_FILE_LIMIT = 100;
|
|
10
12
|
const escapeHtml = (value) => value
|
|
11
13
|
.replaceAll("&", "&")
|
|
12
14
|
.replaceAll("<", "<")
|
|
@@ -216,7 +218,9 @@ export class ContentUploader extends BaseElement {
|
|
|
216
218
|
"concurrency",
|
|
217
219
|
"drop-label",
|
|
218
220
|
"drop-message",
|
|
221
|
+
"directories",
|
|
219
222
|
"extensions",
|
|
223
|
+
"file-limit",
|
|
220
224
|
"folder-id",
|
|
221
225
|
"language",
|
|
222
226
|
"max-file-size",
|
|
@@ -232,6 +236,7 @@ export class ContentUploader extends BaseElement {
|
|
|
232
236
|
footerEl;
|
|
233
237
|
summaryEl;
|
|
234
238
|
queueSignature = "";
|
|
239
|
+
updateScheduled = false;
|
|
235
240
|
get folderId() {
|
|
236
241
|
return this.getAttribute("folder-id");
|
|
237
242
|
}
|
|
@@ -293,6 +298,34 @@ export class ContentUploader extends BaseElement {
|
|
|
293
298
|
}
|
|
294
299
|
this.removeAttribute("max-file-size");
|
|
295
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Most files the queue will hold, default 100 — the same ceiling
|
|
303
|
+
* box-ui-elements uses. Further files are rejected with
|
|
304
|
+
* `file-limit-reached` rather than enqueued.
|
|
305
|
+
*
|
|
306
|
+
* A default matters here in a way it does not for the other constraints: a
|
|
307
|
+
* dropped folder can carry thousands of files, and an unbounded queue has no
|
|
308
|
+
* back pressure at all.
|
|
309
|
+
*/
|
|
310
|
+
get fileLimit() {
|
|
311
|
+
const raw = Number(this.getAttribute("file-limit"));
|
|
312
|
+
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : DEFAULT_FILE_LIMIT;
|
|
313
|
+
}
|
|
314
|
+
set fileLimit(value) {
|
|
315
|
+
this.setAttribute("file-limit", String(value));
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Make the browse dialog pick a folder rather than files.
|
|
319
|
+
*
|
|
320
|
+
* Dropping a folder works either way; the platform only forces the choice on
|
|
321
|
+
* the click-to-browse path.
|
|
322
|
+
*/
|
|
323
|
+
get directories() {
|
|
324
|
+
return this.hasAttribute("directories");
|
|
325
|
+
}
|
|
326
|
+
set directories(value) {
|
|
327
|
+
this.toggleAttribute("directories", value);
|
|
328
|
+
}
|
|
296
329
|
get dropLabel() {
|
|
297
330
|
return this.getAttribute("drop-label") ?? "Upload files";
|
|
298
331
|
}
|
|
@@ -319,9 +352,17 @@ export class ContentUploader extends BaseElement {
|
|
|
319
352
|
get uploaderController() {
|
|
320
353
|
return this.controller;
|
|
321
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* Attributes that only affect how the drop zone presents itself. Everything
|
|
357
|
+
* else feeds the session config, so changing it re-creates the queue.
|
|
358
|
+
*/
|
|
359
|
+
static PRESENTATION_ATTRIBUTES = new Set([
|
|
360
|
+
"directories",
|
|
361
|
+
"drop-label",
|
|
362
|
+
"drop-message",
|
|
363
|
+
]);
|
|
322
364
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
323
|
-
|
|
324
|
-
if (name !== "drop-label" && name !== "drop-message") {
|
|
365
|
+
if (!ContentUploader.PRESENTATION_ATTRIBUTES.has(name)) {
|
|
325
366
|
this.scheduleStart();
|
|
326
367
|
}
|
|
327
368
|
super.attributeChangedCallback(name, oldValue, newValue);
|
|
@@ -336,6 +377,14 @@ export class ContentUploader extends BaseElement {
|
|
|
336
377
|
addFiles(files) {
|
|
337
378
|
return this.controller?.addFiles(files) ?? [];
|
|
338
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Queue files that carry a directory path relative to the destination, the
|
|
382
|
+
* way a dropped folder does. Requires `createFolder` on the transport;
|
|
383
|
+
* without it each pathed file is rejected with `folder-unsupported`.
|
|
384
|
+
*/
|
|
385
|
+
addEntries(entries) {
|
|
386
|
+
return this.controller?.addEntries(entries) ?? [];
|
|
387
|
+
}
|
|
339
388
|
start() {
|
|
340
389
|
this.controller?.start();
|
|
341
390
|
}
|
|
@@ -396,6 +445,7 @@ export class ContentUploader extends BaseElement {
|
|
|
396
445
|
autoStart: this.autoStart,
|
|
397
446
|
concurrency: this.concurrency,
|
|
398
447
|
extensions: this.extensions.length ? this.extensions : undefined,
|
|
448
|
+
fileLimit: this.fileLimit,
|
|
399
449
|
folderId: this.folderId,
|
|
400
450
|
language: this.language ?? undefined,
|
|
401
451
|
maxFileSizeBytes: this.maxFileSize,
|
|
@@ -422,10 +472,28 @@ export class ContentUploader extends BaseElement {
|
|
|
422
472
|
composed: true,
|
|
423
473
|
detail: payload,
|
|
424
474
|
}));
|
|
475
|
+
this.scheduleUpdate();
|
|
476
|
+
}));
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Coalesce the re-render for a burst of controller events.
|
|
480
|
+
*
|
|
481
|
+
* A folder drop adds up to `fileLimit` items in one go, each emitting its own
|
|
482
|
+
* `itemAdded`; rendering per event rebuilt the whole queue once per file,
|
|
483
|
+
* which is quadratic in the size of the drop. One render per microtask turn
|
|
484
|
+
* gives the same result for a fraction of the work.
|
|
485
|
+
*/
|
|
486
|
+
scheduleUpdate() {
|
|
487
|
+
if (!this.isRendered || this.updateScheduled) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
this.updateScheduled = true;
|
|
491
|
+
queueMicrotask(() => {
|
|
492
|
+
this.updateScheduled = false;
|
|
425
493
|
if (this.isRendered) {
|
|
426
494
|
this.update();
|
|
427
495
|
}
|
|
428
|
-
})
|
|
496
|
+
});
|
|
429
497
|
}
|
|
430
498
|
teardownController() {
|
|
431
499
|
for (const unsubscribe of this.unsubscribeFns) {
|
|
@@ -458,9 +526,12 @@ export class ContentUploader extends BaseElement {
|
|
|
458
526
|
}
|
|
459
527
|
setupListeners() {
|
|
460
528
|
this.dropZoneEl.addEventListener("files-selected", event => {
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
529
|
+
const detail = event.detail;
|
|
530
|
+
// Prefer entries: they carry the folder each file came from. `files` is
|
|
531
|
+
// the flat fallback for a host dispatching this event by hand.
|
|
532
|
+
const entries = detail?.entries ?? (detail?.files ?? []).map(file => ({ file, path: "" }));
|
|
533
|
+
if (entries.length) {
|
|
534
|
+
this.controller?.addEntries(entries);
|
|
464
535
|
}
|
|
465
536
|
});
|
|
466
537
|
this.queueEl.addEventListener("click", event => {
|
|
@@ -536,6 +607,14 @@ export class ContentUploader extends BaseElement {
|
|
|
536
607
|
}
|
|
537
608
|
this.dropZoneEl.label = this.dropLabel;
|
|
538
609
|
this.dropZoneEl.message = this.dropMessage;
|
|
610
|
+
this.dropZoneEl.directories = this.directories;
|
|
611
|
+
// Greys out the files the queue would reject anyway, so the picker stops
|
|
612
|
+
// offering a choice that ends in a rejection. The queue's own check still
|
|
613
|
+
// decides — `accept` is advisory, and does nothing for a drop.
|
|
614
|
+
// A leading dot is optional in `extensions`, as it is for the queue check.
|
|
615
|
+
this.dropZoneEl.accept = this.extensions
|
|
616
|
+
.map(extension => `.${extension.replace(/^\./, "")}`)
|
|
617
|
+
.join(",");
|
|
539
618
|
const items = this.controller?.getState().items ?? [];
|
|
540
619
|
// Structural changes rebuild the rows; progress ticks patch the bars in
|
|
541
620
|
// place so in-row focus (Cancel while uploading) survives.
|
|
@@ -11,14 +11,51 @@ export declare class ContentUploaderController extends Controller<UploaderState,
|
|
|
11
11
|
private counter;
|
|
12
12
|
private readonly files;
|
|
13
13
|
private readonly abortControllers;
|
|
14
|
+
/** Directory path -> the in-flight or settled promise for its folder id. */
|
|
15
|
+
private readonly folderIds;
|
|
16
|
+
/**
|
|
17
|
+
* Aborted only when the controller is destroyed.
|
|
18
|
+
*
|
|
19
|
+
* Folder creation is shared by every file in that folder, so it cannot belong
|
|
20
|
+
* to any one of them: cancelling the file that happened to start first would
|
|
21
|
+
* abort the shared request and fail all its siblings — as an abort, so they
|
|
22
|
+
* would report themselves cancelled by the person, which never happened.
|
|
23
|
+
*/
|
|
24
|
+
private readonly lifetime;
|
|
14
25
|
constructor(config: UploaderSessionConfig);
|
|
15
26
|
get concurrency(): number;
|
|
16
27
|
get autoStart(): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Slots left before `fileLimit` is reached. Settled items still count: the
|
|
30
|
+
* limit is on the queue a person is looking at, not on throughput.
|
|
31
|
+
*/
|
|
32
|
+
private remainingCapacity;
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a directory path to a destination folder id, creating each segment
|
|
35
|
+
* that does not exist yet.
|
|
36
|
+
*
|
|
37
|
+
* The **promise** is memoised rather than the id, because concurrent uploads
|
|
38
|
+
* into the same folder resolve the same path at the same moment; caching only
|
|
39
|
+
* the settled value would let two of them each create "docs" and scatter the
|
|
40
|
+
* files across two folders with the same name.
|
|
41
|
+
*/
|
|
42
|
+
private resolveFolderId;
|
|
17
43
|
/**
|
|
18
44
|
* Validate and enqueue files. Rejected files emit `itemRejected` and never
|
|
19
45
|
* enter the queue. Returns the accepted queue items.
|
|
20
46
|
*/
|
|
21
47
|
addFiles(files: UploadFileLike[]): UploadQueueItem[];
|
|
48
|
+
/**
|
|
49
|
+
* Enqueue files that carry a directory path, as a dropped folder does.
|
|
50
|
+
*
|
|
51
|
+
* A path is only honoured when the transport can create folders. Without
|
|
52
|
+
* that capability the files are refused rather than flattened into the
|
|
53
|
+
* destination root — see `UploadTransport.createFolder`.
|
|
54
|
+
*/
|
|
55
|
+
addEntries(entries: Array<{
|
|
56
|
+
file: UploadFileLike;
|
|
57
|
+
path?: string;
|
|
58
|
+
}>): UploadQueueItem[];
|
|
22
59
|
/** Begin (or resume) uploading queued items, up to the concurrency limit. */
|
|
23
60
|
start(): void;
|
|
24
61
|
/** Abort an in-flight upload or withdraw a queued item. */
|
|
@@ -13,6 +13,17 @@ export class ContentUploaderController extends Controller {
|
|
|
13
13
|
counter = 0;
|
|
14
14
|
files = new Map();
|
|
15
15
|
abortControllers = new Map();
|
|
16
|
+
/** Directory path -> the in-flight or settled promise for its folder id. */
|
|
17
|
+
folderIds = new Map();
|
|
18
|
+
/**
|
|
19
|
+
* Aborted only when the controller is destroyed.
|
|
20
|
+
*
|
|
21
|
+
* Folder creation is shared by every file in that folder, so it cannot belong
|
|
22
|
+
* to any one of them: cancelling the file that happened to start first would
|
|
23
|
+
* abort the shared request and fail all its siblings — as an abort, so they
|
|
24
|
+
* would report themselves cancelled by the person, which never happened.
|
|
25
|
+
*/
|
|
26
|
+
lifetime = typeof AbortController === "function" ? new AbortController() : null;
|
|
16
27
|
constructor(config) {
|
|
17
28
|
super({ items: [], uploading: false });
|
|
18
29
|
this.config = config;
|
|
@@ -24,18 +35,99 @@ export class ContentUploaderController extends Controller {
|
|
|
24
35
|
get autoStart() {
|
|
25
36
|
return this.config.autoStart ?? true;
|
|
26
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Slots left before `fileLimit` is reached. Settled items still count: the
|
|
40
|
+
* limit is on the queue a person is looking at, not on throughput.
|
|
41
|
+
*/
|
|
42
|
+
remainingCapacity() {
|
|
43
|
+
const limit = this.config.fileLimit;
|
|
44
|
+
if (limit === undefined || !Number.isFinite(limit)) {
|
|
45
|
+
return Number.POSITIVE_INFINITY;
|
|
46
|
+
}
|
|
47
|
+
return Math.max(0, Math.floor(limit) - this.state.items.length);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a directory path to a destination folder id, creating each segment
|
|
51
|
+
* that does not exist yet.
|
|
52
|
+
*
|
|
53
|
+
* The **promise** is memoised rather than the id, because concurrent uploads
|
|
54
|
+
* into the same folder resolve the same path at the same moment; caching only
|
|
55
|
+
* the settled value would let two of them each create "docs" and scatter the
|
|
56
|
+
* files across two folders with the same name.
|
|
57
|
+
*/
|
|
58
|
+
resolveFolderId(path) {
|
|
59
|
+
if (!path) {
|
|
60
|
+
return Promise.resolve(this.config.folderId);
|
|
61
|
+
}
|
|
62
|
+
const existing = this.folderIds.get(path);
|
|
63
|
+
if (existing) {
|
|
64
|
+
return existing;
|
|
65
|
+
}
|
|
66
|
+
const separator = path.lastIndexOf("/");
|
|
67
|
+
const parentPath = separator > 0 ? path.slice(0, separator) : "";
|
|
68
|
+
const name = separator > 0 ? path.slice(separator + 1) : path;
|
|
69
|
+
const pending = (async () => {
|
|
70
|
+
const parentFolderId = await this.resolveFolderId(parentPath);
|
|
71
|
+
const createFolder = this.config.transport.createFolder;
|
|
72
|
+
if (!createFolder) {
|
|
73
|
+
throw new Error("This transport cannot create folders");
|
|
74
|
+
}
|
|
75
|
+
const signal = this.lifetime?.signal;
|
|
76
|
+
const result = await createFolder.call(this.config.transport, {
|
|
77
|
+
name,
|
|
78
|
+
parentFolderId,
|
|
79
|
+
token: this.config.token,
|
|
80
|
+
...(signal ? { signal } : {}),
|
|
81
|
+
});
|
|
82
|
+
return result.folderId;
|
|
83
|
+
})();
|
|
84
|
+
this.folderIds.set(path, pending);
|
|
85
|
+
// A failed creation must not be cached, or every file in that folder fails
|
|
86
|
+
// against a poisoned entry instead of retrying the folder.
|
|
87
|
+
void pending.catch(() => {
|
|
88
|
+
if (this.folderIds.get(path) === pending) {
|
|
89
|
+
this.folderIds.delete(path);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
return pending;
|
|
93
|
+
}
|
|
27
94
|
/**
|
|
28
95
|
* Validate and enqueue files. Rejected files emit `itemRejected` and never
|
|
29
96
|
* enter the queue. Returns the accepted queue items.
|
|
30
97
|
*/
|
|
31
98
|
addFiles(files) {
|
|
99
|
+
return this.addEntries(files.map(file => ({ file, path: "" })));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Enqueue files that carry a directory path, as a dropped folder does.
|
|
103
|
+
*
|
|
104
|
+
* A path is only honoured when the transport can create folders. Without
|
|
105
|
+
* that capability the files are refused rather than flattened into the
|
|
106
|
+
* destination root — see `UploadTransport.createFolder`.
|
|
107
|
+
*/
|
|
108
|
+
addEntries(entries) {
|
|
32
109
|
const added = [];
|
|
33
|
-
|
|
110
|
+
const canCreateFolders = typeof this.config.transport.createFolder === "function";
|
|
111
|
+
// Counted against the live queue, so two drops in a row cannot together
|
|
112
|
+
// exceed the limit the way two independent checks would let them.
|
|
113
|
+
let remaining = this.remainingCapacity();
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const { file } = entry;
|
|
116
|
+
const path = entry.path ?? "";
|
|
117
|
+
if (path && !canCreateFolders) {
|
|
118
|
+
this.emit("itemRejected", { file, reason: "folder-unsupported" });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
34
121
|
const rejection = resolveUploadRejection(file, this.config);
|
|
35
122
|
if (rejection) {
|
|
36
123
|
this.emit("itemRejected", { file, reason: rejection });
|
|
37
124
|
continue;
|
|
38
125
|
}
|
|
126
|
+
if (remaining <= 0) {
|
|
127
|
+
this.emit("itemRejected", { file, reason: "file-limit-reached" });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
remaining -= 1;
|
|
39
131
|
this.counter += 1;
|
|
40
132
|
const item = {
|
|
41
133
|
id: `upload-${this.counter}`,
|
|
@@ -43,6 +135,7 @@ export class ContentUploaderController extends Controller {
|
|
|
43
135
|
size: file.size,
|
|
44
136
|
status: "queued",
|
|
45
137
|
progress: 0,
|
|
138
|
+
...(path ? { path } : {}),
|
|
46
139
|
};
|
|
47
140
|
this.files.set(item.id, file);
|
|
48
141
|
added.push(item);
|
|
@@ -118,6 +211,8 @@ export class ContentUploaderController extends Controller {
|
|
|
118
211
|
abortController.abort();
|
|
119
212
|
}
|
|
120
213
|
this.abortControllers.clear();
|
|
214
|
+
this.lifetime?.abort();
|
|
215
|
+
this.folderIds.clear();
|
|
121
216
|
super.destroy();
|
|
122
217
|
}
|
|
123
218
|
getItem(itemId) {
|
|
@@ -169,10 +264,24 @@ export class ContentUploaderController extends Controller {
|
|
|
169
264
|
this.emit("itemStarted", { item: started });
|
|
170
265
|
}
|
|
171
266
|
try {
|
|
267
|
+
// Resolved per upload rather than at enqueue time, so a folder is only
|
|
268
|
+
// created when a file in it actually starts — a queue cancelled before it
|
|
269
|
+
// ran leaves no empty folders behind.
|
|
270
|
+
const folderId = await this.resolveFolderId(item.path ?? "");
|
|
271
|
+
// Cancelling during folder creation cannot abort the shared request, so
|
|
272
|
+
// the cancellation is honoured here instead, before any bytes move.
|
|
273
|
+
if (abortController?.signal.aborted) {
|
|
274
|
+
// A plain Error, not a DOMException: the latter is not `instanceof
|
|
275
|
+
// Error` in a browser, so the abort check below would miss it and the
|
|
276
|
+
// item would report itself failed rather than cancelled.
|
|
277
|
+
const aborted = new Error("AbortError");
|
|
278
|
+
aborted.name = "AbortError";
|
|
279
|
+
throw aborted;
|
|
280
|
+
}
|
|
172
281
|
const result = await this.config.transport.uploadFile({
|
|
173
282
|
file,
|
|
174
283
|
fileName: item.name,
|
|
175
|
-
folderId
|
|
284
|
+
folderId,
|
|
176
285
|
token: this.config.token,
|
|
177
286
|
language: this.config.language,
|
|
178
287
|
...(abortController ? { signal: abortController.signal } : {}),
|
|
@@ -19,6 +19,11 @@ export interface UploadQueueItem {
|
|
|
19
19
|
errorMessage?: string;
|
|
20
20
|
/** Remote file id from the transport once the upload succeeds. */
|
|
21
21
|
fileId?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Directory path relative to the destination folder, e.g. `"docs/2026"`, for
|
|
24
|
+
* a file that arrived inside a dropped folder. Empty for a loose file.
|
|
25
|
+
*/
|
|
26
|
+
path?: string;
|
|
22
27
|
}
|
|
23
28
|
export interface UploadRequest {
|
|
24
29
|
file: UploadFileLike;
|
|
@@ -35,6 +40,16 @@ export interface UploadResult {
|
|
|
35
40
|
name?: string;
|
|
36
41
|
size?: number;
|
|
37
42
|
}
|
|
43
|
+
export interface CreateFolderRequest {
|
|
44
|
+
name: string;
|
|
45
|
+
/** The folder to create it in. */
|
|
46
|
+
parentFolderId: string;
|
|
47
|
+
token: string;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
}
|
|
50
|
+
export interface CreateFolderResult {
|
|
51
|
+
folderId: string;
|
|
52
|
+
}
|
|
38
53
|
/**
|
|
39
54
|
* One narrow capability: move a single file to the destination folder. How —
|
|
40
55
|
* multipart, chunked upload sessions, a BFF — is the transport's business;
|
|
@@ -42,6 +57,29 @@ export interface UploadResult {
|
|
|
42
57
|
*/
|
|
43
58
|
export interface UploadTransport {
|
|
44
59
|
uploadFile(request: UploadRequest): Promise<UploadResult>;
|
|
60
|
+
/**
|
|
61
|
+
* Recreate a dropped folder tree in the destination. **Optional**, because
|
|
62
|
+
* adding it as a requirement would break every transport already written
|
|
63
|
+
* against this interface, and because plenty of destinations have no notion
|
|
64
|
+
* of folders at all.
|
|
65
|
+
*
|
|
66
|
+
* Without it a folder drop is *refused* — every file in it is rejected with
|
|
67
|
+
* `folder-unsupported`. The alternative, flattening the tree into the
|
|
68
|
+
* destination root, silently scatters a hundred files out of the structure
|
|
69
|
+
* the person dropped them in, and there is no undo for that.
|
|
70
|
+
*
|
|
71
|
+
* A transport that implements this should be idempotent about an existing
|
|
72
|
+
* name, or map the conflict to the existing folder's id: a retried upload
|
|
73
|
+
* asks for the same folder again.
|
|
74
|
+
*
|
|
75
|
+
* Folder creation is **not** undone if the uploads into it then fail or are
|
|
76
|
+
* cancelled, so an interrupted folder upload leaves the folders it had got to
|
|
77
|
+
* behind, some of them empty. There is no atomic "create this tree" anywhere
|
|
78
|
+
* underneath, so the alternative would be the uploader deleting folders on a
|
|
79
|
+
* destination it does not own — far worse than a stray empty folder. Retrying
|
|
80
|
+
* reuses them; a host that wants them cleaned up owns that decision.
|
|
81
|
+
*/
|
|
82
|
+
createFolder?(request: CreateFolderRequest): Promise<CreateFolderResult>;
|
|
45
83
|
}
|
|
46
84
|
export interface UploaderConstraints {
|
|
47
85
|
/**
|
|
@@ -51,6 +89,16 @@ export interface UploaderConstraints {
|
|
|
51
89
|
extensions?: string[];
|
|
52
90
|
/** Reject files larger than this many bytes. Omitted means no limit. */
|
|
53
91
|
maxFileSizeBytes?: number;
|
|
92
|
+
/**
|
|
93
|
+
* Most files the queue will hold. Further files are rejected with
|
|
94
|
+
* `file-limit-reached` rather than enqueued.
|
|
95
|
+
*
|
|
96
|
+
* Omitted means no limit, which is the wrong default for a drop target — a
|
|
97
|
+
* dropped folder can carry thousands of files, and the queue has no natural
|
|
98
|
+
* back pressure. Hosts should set one; `box-content-uploader` defaults it to
|
|
99
|
+
* 100, matching box-ui-elements.
|
|
100
|
+
*/
|
|
101
|
+
fileLimit?: number;
|
|
54
102
|
}
|
|
55
103
|
export interface UploaderSessionConfig extends UploaderConstraints {
|
|
56
104
|
folderId: string;
|
|
@@ -62,7 +110,9 @@ export interface UploaderSessionConfig extends UploaderConstraints {
|
|
|
62
110
|
/** Start uploading as soon as files are added. Defaults to true. */
|
|
63
111
|
autoStart?: boolean;
|
|
64
112
|
}
|
|
65
|
-
export type UploadRejectionReason = "extension-not-allowed" | "file-too-large"
|
|
113
|
+
export type UploadRejectionReason = "extension-not-allowed" | "file-too-large" | "file-limit-reached"
|
|
114
|
+
/** A folder was dropped but the transport cannot create folders. */
|
|
115
|
+
| "folder-unsupported";
|
|
66
116
|
export interface UploaderState {
|
|
67
117
|
items: UploadQueueItem[];
|
|
68
118
|
/** True while any item is actively uploading. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unofficialbox/box-open-elements",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Box Open Elements: an open, framework-agnostic design system and web component library for Box-style experiences",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://unofficialbox.github.io/box-open-elements",
|