@unofficialbox/box-open-elements 0.16.0 → 0.18.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 +55 -0
- package/dist/components/files/drop-zone.js +242 -21
- 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/foundations/tokens/box-defaults.js +36 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/patterns/content-uploader/content-uploader.d.ts +76 -0
- package/dist/patterns/content-uploader/content-uploader.js +260 -22
- 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
|
@@ -5,15 +5,70 @@ export declare class DropZone extends BaseElement {
|
|
|
5
5
|
private dragging;
|
|
6
6
|
private zoneEl;
|
|
7
7
|
private inputEl;
|
|
8
|
+
private folderInputEl;
|
|
8
9
|
private labelEl;
|
|
9
10
|
private messageEl;
|
|
11
|
+
private browseEl;
|
|
12
|
+
private folderBrowseEl;
|
|
13
|
+
private separatorEl;
|
|
14
|
+
private illustrationEl;
|
|
10
15
|
get label(): string;
|
|
11
16
|
set label(value: string);
|
|
12
17
|
get message(): string;
|
|
13
18
|
set message(value: string);
|
|
14
19
|
get description(): string;
|
|
15
20
|
set description(value: string);
|
|
21
|
+
/**
|
|
22
|
+
* Offer folder selection from the browse controls as well as files.
|
|
23
|
+
*
|
|
24
|
+
* A single `<input>` is either a file picker or a folder picker, never both —
|
|
25
|
+
* so this adds a *second* control backed by its own `webkitdirectory` input
|
|
26
|
+
* rather than converting the first, which is how box-ui-elements does it and
|
|
27
|
+
* means a person is never made to choose between the two.
|
|
28
|
+
*
|
|
29
|
+
* Drag-and-drop reads folders regardless; a dropped directory is always
|
|
30
|
+
* traversed whatever this is set to.
|
|
31
|
+
*/
|
|
32
|
+
get directories(): boolean;
|
|
33
|
+
set directories(value: boolean);
|
|
34
|
+
/** Text of the file-browse control. */
|
|
35
|
+
get browseLabel(): string;
|
|
36
|
+
set browseLabel(value: string);
|
|
37
|
+
/** Text of the folder-browse control, shown only when `directories` is set. */
|
|
38
|
+
get folderLabel(): string;
|
|
39
|
+
set folderLabel(value: string);
|
|
40
|
+
/**
|
|
41
|
+
* `"compact"` (default) keeps the small inline target; `"hero"` is the tall
|
|
42
|
+
* centred empty state, with room for an illustration in the `illustration`
|
|
43
|
+
* slot.
|
|
44
|
+
*/
|
|
45
|
+
get variant(): string;
|
|
46
|
+
set variant(value: string);
|
|
47
|
+
/**
|
|
48
|
+
* What the browse dialog offers, in the `<input accept>` syntax —
|
|
49
|
+
* `".pdf,.docx"` or `"image/*"`.
|
|
50
|
+
*
|
|
51
|
+
* A hint, never a guarantee: it greys out other files in the picker, but a
|
|
52
|
+
* person can still choose "All files", and it has no effect at all on a drop.
|
|
53
|
+
* Whatever accepts the selection afterwards stays the real check.
|
|
54
|
+
*/
|
|
55
|
+
get accept(): string;
|
|
56
|
+
set accept(value: string);
|
|
16
57
|
protected renderTemplate(): void;
|
|
17
58
|
protected setupListeners(): void;
|
|
59
|
+
/**
|
|
60
|
+
* Report a selection.
|
|
61
|
+
*
|
|
62
|
+
* `files` stays a flat list so existing hosts keep working unchanged;
|
|
63
|
+
* `entries` adds the directory each file came from, which is the only way a
|
|
64
|
+
* host can recreate a dropped folder. `skipped` names anything the browser
|
|
65
|
+
* refused to read, so a lost file is reported rather than simply absent.
|
|
66
|
+
*
|
|
67
|
+
* Nothing is dispatched when there is nothing to say — a drag that lands on
|
|
68
|
+
* the zone carrying no files is not an upload of nothing. A drop where
|
|
69
|
+
* everything was skipped still reports, because that is a failure, not
|
|
70
|
+
* silence.
|
|
71
|
+
*/
|
|
72
|
+
private emitSelection;
|
|
18
73
|
protected update(): void;
|
|
19
74
|
}
|
|
@@ -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";
|
|
@@ -10,12 +11,19 @@ const dropZoneStyles = `
|
|
|
10
11
|
font: inherit;
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/* Without this, the host's own display:block beats the UA rule for [hidden]
|
|
15
|
+
and the element stays visible when a host hides it. */
|
|
16
|
+
:host([hidden]) {
|
|
17
|
+
display: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
[part="zone"] {
|
|
14
21
|
position: relative;
|
|
15
22
|
display: grid;
|
|
16
23
|
gap: 0.55rem;
|
|
17
24
|
justify-items: start;
|
|
18
25
|
padding: 0.75rem;
|
|
26
|
+
text-align: start;
|
|
19
27
|
border: 1.5px dashed color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 16%, var(--boe-token-stroke-stroke, #e8e8e8) 84%);
|
|
20
28
|
border-radius: ${boeRadius.large};
|
|
21
29
|
background: var(--boe-token-surface-surface-secondary, #fbfbfb);
|
|
@@ -48,7 +56,9 @@ const dropZoneStyles = `
|
|
|
48
56
|
0 14px 28px rgba(15, 23, 42, 0.05);
|
|
49
57
|
}
|
|
50
58
|
|
|
51
|
-
|
|
59
|
+
/* The inputs are never the focusable control — the browse buttons are — so
|
|
60
|
+
they are taken out of the tab order rather than merely hidden. */
|
|
61
|
+
[part~="file-input"] {
|
|
52
62
|
position: absolute;
|
|
53
63
|
inline-size: 1px;
|
|
54
64
|
block-size: 1px;
|
|
@@ -56,8 +66,6 @@ const dropZoneStyles = `
|
|
|
56
66
|
pointer-events: none;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
|
-
${boeFocusVisibleStyles('[part="input"]')}
|
|
60
|
-
|
|
61
69
|
[part="label"] {
|
|
62
70
|
font-size: 1rem;
|
|
63
71
|
font-weight: 700;
|
|
@@ -69,17 +77,99 @@ const dropZoneStyles = `
|
|
|
69
77
|
line-height: 1.5;
|
|
70
78
|
max-width: 34ch;
|
|
71
79
|
}
|
|
80
|
+
|
|
81
|
+
[part="actions"] {
|
|
82
|
+
display: flex;
|
|
83
|
+
flex-wrap: wrap;
|
|
84
|
+
align-items: baseline;
|
|
85
|
+
gap: 0.25rem 0.5rem;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
[part~="browse"] {
|
|
89
|
+
appearance: none;
|
|
90
|
+
margin: 0;
|
|
91
|
+
padding: 0;
|
|
92
|
+
border: 0;
|
|
93
|
+
background: none;
|
|
94
|
+
font: inherit;
|
|
95
|
+
font-weight: 600;
|
|
96
|
+
color: var(--boe-token-surface-surface-brand, #0061d5);
|
|
97
|
+
text-decoration: none;
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
border-radius: ${boeRadius.control};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
[part~="browse"]:hover {
|
|
103
|
+
text-decoration: underline;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
${boeFocusVisibleStyles('[part~="browse"]')}
|
|
107
|
+
|
|
108
|
+
[part="separator"] {
|
|
109
|
+
color: var(--boe-token-text-text-secondary, #6f6f6f);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* Hero: the full-height empty state, centred, with room for an illustration.
|
|
113
|
+
Matches the shape box-ui-elements gives its uploader. */
|
|
114
|
+
:host([variant="hero"]) [part="zone"] {
|
|
115
|
+
justify-items: center;
|
|
116
|
+
align-content: center;
|
|
117
|
+
gap: 0.35rem;
|
|
118
|
+
min-block-size: 18rem;
|
|
119
|
+
padding: 2rem 1.5rem;
|
|
120
|
+
text-align: center;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
:host([variant="hero"]) [part="label"] {
|
|
124
|
+
font-size: 1.0625rem;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
:host([variant="hero"]) [part~="description"] {
|
|
128
|
+
max-width: 42ch;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
[part="illustration"] {
|
|
132
|
+
display: none;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
:host([variant="hero"]) [part="illustration"] {
|
|
136
|
+
display: block;
|
|
137
|
+
/* The art carries its own padding inside the viewBox, so the box is set a
|
|
138
|
+
little larger than the 130px box-ui-elements uses for the drawn area. */
|
|
139
|
+
inline-size: min(10.5rem, 70%);
|
|
140
|
+
margin-block-end: 0.15rem;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
::slotted([slot="illustration"]) {
|
|
144
|
+
display: block;
|
|
145
|
+
inline-size: 100%;
|
|
146
|
+
block-size: auto;
|
|
147
|
+
}
|
|
72
148
|
`;
|
|
73
149
|
export class DropZone extends BaseElement {
|
|
74
150
|
static tagName = DEFAULT_TAG_NAME;
|
|
75
151
|
static get observedAttributes() {
|
|
76
|
-
return [
|
|
152
|
+
return [
|
|
153
|
+
"accept",
|
|
154
|
+
"browse-label",
|
|
155
|
+
"description",
|
|
156
|
+
"directories",
|
|
157
|
+
"folder-label",
|
|
158
|
+
"label",
|
|
159
|
+
"message",
|
|
160
|
+
"variant",
|
|
161
|
+
];
|
|
77
162
|
}
|
|
78
163
|
dragging = false;
|
|
79
164
|
zoneEl;
|
|
80
165
|
inputEl;
|
|
166
|
+
folderInputEl;
|
|
81
167
|
labelEl;
|
|
82
168
|
messageEl;
|
|
169
|
+
browseEl;
|
|
170
|
+
folderBrowseEl;
|
|
171
|
+
separatorEl;
|
|
172
|
+
illustrationEl;
|
|
83
173
|
get label() {
|
|
84
174
|
return this.getAttribute("label") ?? "Upload files";
|
|
85
175
|
}
|
|
@@ -98,20 +188,95 @@ export class DropZone extends BaseElement {
|
|
|
98
188
|
set description(value) {
|
|
99
189
|
this.setAttribute("description", value);
|
|
100
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* Offer folder selection from the browse controls as well as files.
|
|
193
|
+
*
|
|
194
|
+
* A single `<input>` is either a file picker or a folder picker, never both —
|
|
195
|
+
* so this adds a *second* control backed by its own `webkitdirectory` input
|
|
196
|
+
* rather than converting the first, which is how box-ui-elements does it and
|
|
197
|
+
* means a person is never made to choose between the two.
|
|
198
|
+
*
|
|
199
|
+
* Drag-and-drop reads folders regardless; a dropped directory is always
|
|
200
|
+
* traversed whatever this is set to.
|
|
201
|
+
*/
|
|
202
|
+
get directories() {
|
|
203
|
+
return this.hasAttribute("directories");
|
|
204
|
+
}
|
|
205
|
+
set directories(value) {
|
|
206
|
+
this.toggleAttribute("directories", value);
|
|
207
|
+
}
|
|
208
|
+
/** Text of the file-browse control. */
|
|
209
|
+
get browseLabel() {
|
|
210
|
+
return this.getAttribute("browse-label") ?? "Browse your device";
|
|
211
|
+
}
|
|
212
|
+
set browseLabel(value) {
|
|
213
|
+
this.setAttribute("browse-label", value);
|
|
214
|
+
}
|
|
215
|
+
/** Text of the folder-browse control, shown only when `directories` is set. */
|
|
216
|
+
get folderLabel() {
|
|
217
|
+
return this.getAttribute("folder-label") ?? "Select folders";
|
|
218
|
+
}
|
|
219
|
+
set folderLabel(value) {
|
|
220
|
+
this.setAttribute("folder-label", value);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* `"compact"` (default) keeps the small inline target; `"hero"` is the tall
|
|
224
|
+
* centred empty state, with room for an illustration in the `illustration`
|
|
225
|
+
* slot.
|
|
226
|
+
*/
|
|
227
|
+
get variant() {
|
|
228
|
+
return this.getAttribute("variant") === "hero" ? "hero" : "compact";
|
|
229
|
+
}
|
|
230
|
+
set variant(value) {
|
|
231
|
+
this.setAttribute("variant", value);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* What the browse dialog offers, in the `<input accept>` syntax —
|
|
235
|
+
* `".pdf,.docx"` or `"image/*"`.
|
|
236
|
+
*
|
|
237
|
+
* A hint, never a guarantee: it greys out other files in the picker, but a
|
|
238
|
+
* person can still choose "All files", and it has no effect at all on a drop.
|
|
239
|
+
* Whatever accepts the selection afterwards stays the real check.
|
|
240
|
+
*/
|
|
241
|
+
get accept() {
|
|
242
|
+
return this.getAttribute("accept") ?? "";
|
|
243
|
+
}
|
|
244
|
+
set accept(value) {
|
|
245
|
+
if (value) {
|
|
246
|
+
this.setAttribute("accept", value);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
this.removeAttribute("accept");
|
|
250
|
+
}
|
|
101
251
|
renderTemplate() {
|
|
102
252
|
if (!this.shadowRoot) {
|
|
103
253
|
return;
|
|
104
254
|
}
|
|
255
|
+
// A div rather than a label wrapping the input: the browse controls are
|
|
256
|
+
// real buttons, and a button inside a label is both ambiguous to assistive
|
|
257
|
+
// technology and prone to firing the picker twice.
|
|
105
258
|
this.shadowRoot.innerHTML = `
|
|
106
259
|
<style>${dropZoneStyles}</style>
|
|
107
|
-
<
|
|
108
|
-
<input type="file" part="input" multiple />
|
|
260
|
+
<div part="zone">
|
|
261
|
+
<input type="file" part="input file-input" multiple tabindex="-1" aria-hidden="true" />
|
|
262
|
+
<input type="file" part="folder-input file-input" multiple tabindex="-1" aria-hidden="true" webkitdirectory />
|
|
263
|
+
<span part="illustration"><slot name="illustration"></slot></span>
|
|
109
264
|
<strong part="label"></strong>
|
|
110
265
|
<span part="description message"></span>
|
|
111
|
-
|
|
266
|
+
<span part="actions">
|
|
267
|
+
<button type="button" part="browse browse-files"></button>
|
|
268
|
+
<span part="separator" hidden>or</span>
|
|
269
|
+
<button type="button" part="browse browse-folders" hidden></button>
|
|
270
|
+
</span>
|
|
271
|
+
</div>
|
|
112
272
|
`;
|
|
113
273
|
this.zoneEl = this.shadowRoot.querySelector('[part="zone"]');
|
|
114
|
-
this.inputEl = this.shadowRoot.querySelector('[part
|
|
274
|
+
this.inputEl = this.shadowRoot.querySelector('[part~="input"]');
|
|
275
|
+
this.folderInputEl = this.shadowRoot.querySelector('[part~="folder-input"]');
|
|
276
|
+
this.browseEl = this.shadowRoot.querySelector('[part~="browse-files"]');
|
|
277
|
+
this.folderBrowseEl = this.shadowRoot.querySelector('[part~="browse-folders"]');
|
|
278
|
+
this.separatorEl = this.shadowRoot.querySelector('[part="separator"]');
|
|
279
|
+
this.illustrationEl = this.shadowRoot.querySelector('[part="illustration"]');
|
|
115
280
|
this.labelEl = this.shadowRoot.querySelector('[part="label"]');
|
|
116
281
|
this.messageEl = this.shadowRoot.querySelector('[part~="description"]');
|
|
117
282
|
}
|
|
@@ -134,22 +299,63 @@ export class DropZone extends BaseElement {
|
|
|
134
299
|
this.zoneEl.addEventListener("drop", event => {
|
|
135
300
|
event.preventDefault();
|
|
136
301
|
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
302
|
this.update();
|
|
303
|
+
// Captured synchronously: the DataTransferItemList is emptied the moment
|
|
304
|
+
// this handler returns, so awaiting anything before reading it loses the
|
|
305
|
+
// drop entirely. Traversal happens afterwards, off the captured entries.
|
|
306
|
+
const captured = captureDropEntries(event.dataTransfer);
|
|
307
|
+
const skipped = [];
|
|
308
|
+
void collectEntries(captured, name => skipped.push(name)).then(entries => {
|
|
309
|
+
this.emitSelection(entries, skipped);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
// Clicking the body of the zone still opens the file picker, as it did when
|
|
313
|
+
// the zone was a label. Clicks that land on a button are left alone, or the
|
|
314
|
+
// picker would be asked to open twice.
|
|
315
|
+
this.zoneEl.addEventListener("click", event => {
|
|
316
|
+
if (event.target.closest('[part~="browse"]')) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
this.inputEl.click();
|
|
320
|
+
});
|
|
321
|
+
this.browseEl.addEventListener("click", () => {
|
|
322
|
+
this.inputEl.click();
|
|
144
323
|
});
|
|
145
|
-
this.
|
|
146
|
-
|
|
147
|
-
this.dispatchEvent(new CustomEvent("files-selected", {
|
|
148
|
-
bubbles: true,
|
|
149
|
-
composed: true,
|
|
150
|
-
detail: { files },
|
|
151
|
-
}));
|
|
324
|
+
this.folderBrowseEl.addEventListener("click", () => {
|
|
325
|
+
this.folderInputEl.click();
|
|
152
326
|
});
|
|
327
|
+
for (const input of [this.inputEl, this.folderInputEl]) {
|
|
328
|
+
input.addEventListener("change", () => {
|
|
329
|
+
// A folder picked through `webkitdirectory` reports its tree through
|
|
330
|
+
// each file's webkitRelativePath rather than through entries.
|
|
331
|
+
this.emitSelection(entriesFromFileList(Array.from(input.files ?? [])));
|
|
332
|
+
// Cleared so choosing the same folder twice in a row still fires.
|
|
333
|
+
input.value = "";
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Report a selection.
|
|
339
|
+
*
|
|
340
|
+
* `files` stays a flat list so existing hosts keep working unchanged;
|
|
341
|
+
* `entries` adds the directory each file came from, which is the only way a
|
|
342
|
+
* host can recreate a dropped folder. `skipped` names anything the browser
|
|
343
|
+
* refused to read, so a lost file is reported rather than simply absent.
|
|
344
|
+
*
|
|
345
|
+
* Nothing is dispatched when there is nothing to say — a drag that lands on
|
|
346
|
+
* the zone carrying no files is not an upload of nothing. A drop where
|
|
347
|
+
* everything was skipped still reports, because that is a failure, not
|
|
348
|
+
* silence.
|
|
349
|
+
*/
|
|
350
|
+
emitSelection(entries, skipped = []) {
|
|
351
|
+
if (!entries.length && !skipped.length) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
this.dispatchEvent(new CustomEvent("files-selected", {
|
|
355
|
+
bubbles: true,
|
|
356
|
+
composed: true,
|
|
357
|
+
detail: { entries, files: entries.map(entry => entry.file), skipped },
|
|
358
|
+
}));
|
|
153
359
|
}
|
|
154
360
|
update() {
|
|
155
361
|
if (!this.zoneEl) {
|
|
@@ -158,6 +364,21 @@ export class DropZone extends BaseElement {
|
|
|
158
364
|
this.zoneEl.dataset.dragging = String(this.dragging);
|
|
159
365
|
this.labelEl.textContent = this.label;
|
|
160
366
|
this.messageEl.textContent = this.message;
|
|
367
|
+
// Hidden rather than left empty, so it does not open a gap in the stack.
|
|
368
|
+
this.messageEl.hidden = this.message.trim() === "";
|
|
369
|
+
this.browseEl.textContent = this.browseLabel;
|
|
370
|
+
this.folderBrowseEl.textContent = this.folderLabel;
|
|
371
|
+
// The folder control is a second input rather than a mode on the first, so
|
|
372
|
+
// files and folders are both reachable instead of mutually exclusive.
|
|
373
|
+
const directories = this.directories;
|
|
374
|
+
this.folderBrowseEl.hidden = !directories;
|
|
375
|
+
this.separatorEl.hidden = !directories;
|
|
376
|
+
// webkitdirectory is a property as well as an attribute, and only the
|
|
377
|
+
// property is honoured reliably once the input exists.
|
|
378
|
+
this.folderInputEl.webkitdirectory = true;
|
|
379
|
+
// A folder picker offers folders, so an extension filter would only mislead.
|
|
380
|
+
this.inputEl.accept = this.accept;
|
|
381
|
+
this.illustrationEl.hidden = this.variant !== "hero";
|
|
161
382
|
}
|
|
162
383
|
}
|
|
163
384
|
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";
|