@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.
@@ -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
- [part="input"] {
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 ["description", "label", "message"];
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
- <label part="zone">
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
- </label>
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="input"]');
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.inputEl.addEventListener("change", () => {
146
- const files = Array.from(this.inputEl.files ?? []);
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";
@@ -0,0 +1,3 @@
1
+ // Reading dropped files and folders from the platform's DataTransfer and
2
+ // FileSystemEntry APIs, shared by box-drop-zone and the uploader pattern.
3
+ export * from "./directory-entries.js";