@unofficialbox/box-open-elements 0.15.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.
@@ -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
- const files = Array.from(this.inputEl.files ?? []);
147
- this.dispatchEvent(new CustomEvent("files-selected", {
148
- bubbles: true,
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,55 @@
1
+ import { FormattedValue } from "./formatted-value.js";
2
+ /**
3
+ * How long something takes or lasts, in the reader's locale.
4
+ *
5
+ * ```html
6
+ * <box-formatted-duration value="5400"></box-formatted-duration>
7
+ * ```
8
+ *
9
+ * `value` is a count of seconds or an ISO 8601 duration (`PT1H30M`), because
10
+ * hosts have both: an API field is usually a number, while `<time datetime>`
11
+ * wants the ISO form. Whichever comes in, the ISO form goes back out in
12
+ * `datetime`, so the exact quantity survives for anything reading the document
13
+ * rather than looking at it.
14
+ *
15
+ * Not to be confused with `box-relative-time`, which renders *when* something
16
+ * happened relative to now ("3 days ago"). This renders *how long* — a
17
+ * retention period, an SLA, a processing time — and carries no direction.
18
+ *
19
+ * `Intl.DurationFormat` does the rendering where the browser has it. Where it
20
+ * does not, the same output is composed from `Intl.NumberFormat`'s unit style
21
+ * and `Intl.ListFormat`, both long supported; the two paths were compared
22
+ * across styles and locales and agree.
23
+ */
24
+ export declare class FormattedDuration extends FormattedValue {
25
+ static readonly tagName: string;
26
+ static get observedAttributes(): string[];
27
+ /** Seconds, or an ISO 8601 duration such as `PT1H30M`. */
28
+ get value(): string;
29
+ set value(next: string);
30
+ /**
31
+ * `short` (default, `1 hr, 30 min`), `long` (`1 hour, 30 minutes`) or
32
+ * `narrow` (`1h 30m`).
33
+ *
34
+ * Named `format-style` rather than `style` because `style` is a global HTML
35
+ * attribute: naming it after the `Intl` option would put CSS on the host and
36
+ * be silently ignored as a formatting instruction.
37
+ */
38
+ get formatStyle(): string;
39
+ set formatStyle(next: string);
40
+ /**
41
+ * How many units to show, default 2.
42
+ *
43
+ * A duration carries more precision than a reader wants: 1 day, 3 hours, 12
44
+ * minutes and 8 seconds is a number to scan past, not to read. Two units is
45
+ * enough to convey magnitude.
46
+ */
47
+ get maxUnits(): number;
48
+ set maxUnits(next: number);
49
+ protected usesTimeElement(): boolean;
50
+ protected formatted(): {
51
+ machine?: string;
52
+ text: string;
53
+ } | null;
54
+ private render;
55
+ }
@@ -0,0 +1,131 @@
1
+ import { FormattedValue } from "./formatted-value.js";
2
+ import { parseDuration, resolveDurationParts, toIsoDuration, } from "../../foundations/format/index.js";
3
+ const DEFAULT_TAG_NAME = "box-formatted-duration";
4
+ /** Whether the split came out as a plain zero, which `Intl` renders as nothing. */
5
+ const isZero = (parts) => Object.values(parts).every(amount => amount === 0);
6
+ const DURATION_STYLES = new Set(["long", "short", "narrow"]);
7
+ /** `Intl` names duration fields in the plural; the parts are singular. */
8
+ const INTL_FIELD = {
9
+ day: "days",
10
+ hour: "hours",
11
+ minute: "minutes",
12
+ second: "seconds",
13
+ };
14
+ /** The unit identifiers `Intl.NumberFormat` uses, for the fallback path. */
15
+ const NUMBER_UNIT = {
16
+ day: "day",
17
+ hour: "hour",
18
+ minute: "minute",
19
+ second: "second",
20
+ };
21
+ /**
22
+ * How long something takes or lasts, in the reader's locale.
23
+ *
24
+ * ```html
25
+ * <box-formatted-duration value="5400"></box-formatted-duration>
26
+ * ```
27
+ *
28
+ * `value` is a count of seconds or an ISO 8601 duration (`PT1H30M`), because
29
+ * hosts have both: an API field is usually a number, while `<time datetime>`
30
+ * wants the ISO form. Whichever comes in, the ISO form goes back out in
31
+ * `datetime`, so the exact quantity survives for anything reading the document
32
+ * rather than looking at it.
33
+ *
34
+ * Not to be confused with `box-relative-time`, which renders *when* something
35
+ * happened relative to now ("3 days ago"). This renders *how long* — a
36
+ * retention period, an SLA, a processing time — and carries no direction.
37
+ *
38
+ * `Intl.DurationFormat` does the rendering where the browser has it. Where it
39
+ * does not, the same output is composed from `Intl.NumberFormat`'s unit style
40
+ * and `Intl.ListFormat`, both long supported; the two paths were compared
41
+ * across styles and locales and agree.
42
+ */
43
+ export class FormattedDuration extends FormattedValue {
44
+ static tagName = DEFAULT_TAG_NAME;
45
+ static get observedAttributes() {
46
+ return ["format-style", "locale", "max-units", "value"];
47
+ }
48
+ /** Seconds, or an ISO 8601 duration such as `PT1H30M`. */
49
+ get value() {
50
+ return this.getAttribute("value") ?? "";
51
+ }
52
+ set value(next) {
53
+ this.setAttribute("value", next);
54
+ }
55
+ /**
56
+ * `short` (default, `1 hr, 30 min`), `long` (`1 hour, 30 minutes`) or
57
+ * `narrow` (`1h 30m`).
58
+ *
59
+ * Named `format-style` rather than `style` because `style` is a global HTML
60
+ * attribute: naming it after the `Intl` option would put CSS on the host and
61
+ * be silently ignored as a formatting instruction.
62
+ */
63
+ get formatStyle() {
64
+ const raw = this.getAttribute("format-style");
65
+ return raw && DURATION_STYLES.has(raw) ? raw : "short";
66
+ }
67
+ set formatStyle(next) {
68
+ this.setAttribute("format-style", next);
69
+ }
70
+ /**
71
+ * How many units to show, default 2.
72
+ *
73
+ * A duration carries more precision than a reader wants: 1 day, 3 hours, 12
74
+ * minutes and 8 seconds is a number to scan past, not to read. Two units is
75
+ * enough to convey magnitude.
76
+ */
77
+ get maxUnits() {
78
+ const raw = Number(this.getAttribute("max-units"));
79
+ return Number.isFinite(raw) && raw >= 1 ? Math.trunc(raw) : 2;
80
+ }
81
+ set maxUnits(next) {
82
+ this.setAttribute("max-units", String(next));
83
+ }
84
+ usesTimeElement() {
85
+ // A duration is a valid `datetime` value, so `<time>` is honest here.
86
+ return true;
87
+ }
88
+ formatted() {
89
+ const seconds = parseDuration(this.value);
90
+ if (seconds === null) {
91
+ return null;
92
+ }
93
+ const parts = resolveDurationParts(seconds, this.maxUnits);
94
+ const machine = toIsoDuration(parts);
95
+ const style = this.formatStyle;
96
+ try {
97
+ return { machine, text: this.render(parts, style) };
98
+ }
99
+ catch {
100
+ // A locale Intl rejects, or a runtime missing both paths. The duration is
101
+ // real, so fall back to the machine form rather than losing it.
102
+ return { machine, text: machine };
103
+ }
104
+ }
105
+ render(parts, style) {
106
+ const durationFormat = Intl.DurationFormat;
107
+ if (durationFormat) {
108
+ const input = Object.fromEntries(Object.entries(parts).map(([unit, amount]) => [INTL_FIELD[unit], amount]));
109
+ // A zero duration otherwise formats to the empty string, leaving a visible
110
+ // element with no text in it — zero is a real answer. Scoped to that case:
111
+ // asking for it always appends "0 sec" to every other duration in
112
+ // browsers that honour the option, which Node's Intl quietly does not.
113
+ const options = isZero(parts)
114
+ ? { secondsDisplay: "always", style }
115
+ : { style };
116
+ return new durationFormat(this.locale, options).format(input);
117
+ }
118
+ const unitDisplay = style === "long" ? "long" : style === "narrow" ? "narrow" : "short";
119
+ const pieces = Object.entries(parts).map(([unit, amount]) => new Intl.NumberFormat(this.locale, {
120
+ style: "unit",
121
+ unit: NUMBER_UNIT[unit],
122
+ unitDisplay,
123
+ }).format(amount));
124
+ // `narrow` runs the units together (`1h 30m`); the wider styles take the
125
+ // locale's own unit-list joiner, which is what DurationFormat uses too.
126
+ return style === "narrow"
127
+ ? pieces.join(" ")
128
+ : new Intl.ListFormat(this.locale, { style: "long", type: "unit" }).format(pieces);
129
+ }
130
+ }
131
+ FormattedDuration.register();
@@ -0,0 +1 @@
1
+ export * from "../components/output/formatted-duration.js";
@@ -0,0 +1 @@
1
+ export * from "../components/output/formatted-duration.js";
@@ -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";
@@ -80,3 +80,37 @@ export interface BoeFileSizeParts {
80
80
  * is reported whole.
81
81
  */
82
82
  export declare const resolveFileSizeParts: (bytes: number, units?: BoeByteUnits) => BoeFileSizeParts;
83
+ /** The duration units a formatted duration can be broken into, largest first. */
84
+ export type BoeDurationUnit = "day" | "hour" | "minute" | "second";
85
+ /** A duration split into whole units, ready for `Intl` to render. */
86
+ export type BoeDurationParts = Partial<Record<BoeDurationUnit, number>>;
87
+ /**
88
+ * Parse a duration to whole seconds.
89
+ *
90
+ * Accepts a count of seconds (a number, or a numeric string) or an ISO 8601
91
+ * duration such as `PT1H30M`, because hosts have both: an API field is usually
92
+ * a number, while `<time datetime>` wants the ISO form. Weeks, months and years
93
+ * are deliberately not accepted — `P1M` is ambiguous between a month and a
94
+ * minute in the calendar sense, and a month is not a fixed number of seconds,
95
+ * so rendering one as though it were would be a lie about how long the duration
96
+ * actually is.
97
+ *
98
+ * Negative and unparseable input is `null`. A duration is an elapsed quantity,
99
+ * so a negative one is a host bug rather than a direction — `box-relative-time`
100
+ * is the component that expresses "ago".
101
+ */
102
+ export declare const parseDuration: (value: string | number | null | undefined) => number | null;
103
+ /**
104
+ * Split whole seconds into at most `maxUnits` consecutive units.
105
+ *
106
+ * Consecutive, not merely the largest: 90061 seconds is "1 day, 1 hour" rather
107
+ * than "1 day, 1 minute", because a reader scanning a duration wants its
108
+ * magnitude, and skipping an empty unit to reach a smaller non-empty one
109
+ * overstates the precision.
110
+ *
111
+ * A duration of zero returns whole seconds rather than nothing, so "0 sec"
112
+ * renders instead of the element disappearing — zero is a real answer.
113
+ */
114
+ export declare const resolveDurationParts: (totalSeconds: number, maxUnits?: number) => BoeDurationParts;
115
+ /** Render a parts object back to an ISO 8601 duration, for `<time datetime>`. */
116
+ export declare const toIsoDuration: (parts: BoeDurationParts) => string;
@@ -120,3 +120,102 @@ export const resolveFileSizeParts = (bytes, units = "decimal") => {
120
120
  const exponent = Math.min(Math.floor(Math.log(magnitude) / Math.log(base)), suffixes.length - 1);
121
121
  return { suffix: suffixes[exponent], value: bytes / base ** exponent };
122
122
  };
123
+ const ISO_DURATION = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
124
+ const SECONDS_PER = {
125
+ day: 86_400,
126
+ hour: 3_600,
127
+ minute: 60,
128
+ second: 1,
129
+ };
130
+ const DURATION_ORDER = ["day", "hour", "minute", "second"];
131
+ /**
132
+ * Parse a duration to whole seconds.
133
+ *
134
+ * Accepts a count of seconds (a number, or a numeric string) or an ISO 8601
135
+ * duration such as `PT1H30M`, because hosts have both: an API field is usually
136
+ * a number, while `<time datetime>` wants the ISO form. Weeks, months and years
137
+ * are deliberately not accepted — `P1M` is ambiguous between a month and a
138
+ * minute in the calendar sense, and a month is not a fixed number of seconds,
139
+ * so rendering one as though it were would be a lie about how long the duration
140
+ * actually is.
141
+ *
142
+ * Negative and unparseable input is `null`. A duration is an elapsed quantity,
143
+ * so a negative one is a host bug rather than a direction — `box-relative-time`
144
+ * is the component that expresses "ago".
145
+ */
146
+ export const parseDuration = (value) => {
147
+ if (value == null || value === "") {
148
+ return null;
149
+ }
150
+ if (typeof value === "number") {
151
+ return Number.isFinite(value) && value >= 0 ? Math.round(value) : null;
152
+ }
153
+ const trimmed = value.trim();
154
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
155
+ return Math.round(Number(trimmed));
156
+ }
157
+ const match = ISO_DURATION.exec(trimmed);
158
+ if (!match || trimmed === "P" || trimmed === "PT") {
159
+ return null;
160
+ }
161
+ const [, days, hours, minutes, seconds] = match;
162
+ const total = Number(days ?? 0) * SECONDS_PER.day +
163
+ Number(hours ?? 0) * SECONDS_PER.hour +
164
+ Number(minutes ?? 0) * SECONDS_PER.minute +
165
+ Number(seconds ?? 0);
166
+ return Number.isFinite(total) ? Math.round(total) : null;
167
+ };
168
+ /**
169
+ * Split whole seconds into at most `maxUnits` consecutive units.
170
+ *
171
+ * Consecutive, not merely the largest: 90061 seconds is "1 day, 1 hour" rather
172
+ * than "1 day, 1 minute", because a reader scanning a duration wants its
173
+ * magnitude, and skipping an empty unit to reach a smaller non-empty one
174
+ * overstates the precision.
175
+ *
176
+ * A duration of zero returns whole seconds rather than nothing, so "0 sec"
177
+ * renders instead of the element disappearing — zero is a real answer.
178
+ */
179
+ export const resolveDurationParts = (totalSeconds, maxUnits = 2) => {
180
+ const limit = Math.max(1, Math.trunc(maxUnits));
181
+ if (totalSeconds <= 0) {
182
+ return { second: 0 };
183
+ }
184
+ const collected = [];
185
+ let remaining = Math.round(totalSeconds);
186
+ let started = false;
187
+ for (const unit of DURATION_ORDER) {
188
+ const size = SECONDS_PER[unit];
189
+ const amount = Math.floor(remaining / size);
190
+ if (!started && amount === 0) {
191
+ continue;
192
+ }
193
+ started = true;
194
+ collected.push([unit, amount]);
195
+ remaining -= amount * size;
196
+ if (collected.length === limit) {
197
+ break;
198
+ }
199
+ }
200
+ // Trailing zeroes are noise: an exact hour is "1 hr", not "1 hr, 0 min". Only
201
+ // trailing ones go — an interior zero is load-bearing, since "1 day, 0 hr" as
202
+ // the first two units of 1d 0h 5m would otherwise become "1 day, 5 min" and
203
+ // claim a precision the split does not have.
204
+ while (collected.length > 1 && collected[collected.length - 1][1] === 0) {
205
+ collected.pop();
206
+ }
207
+ return Object.fromEntries(collected);
208
+ };
209
+ /** Render a parts object back to an ISO 8601 duration, for `<time datetime>`. */
210
+ export const toIsoDuration = (parts) => {
211
+ const date = parts.day ? `${parts.day}D` : "";
212
+ const time = [
213
+ parts.hour ? `${parts.hour}H` : "",
214
+ parts.minute ? `${parts.minute}M` : "",
215
+ parts.second ? `${parts.second}S` : "",
216
+ ].join("");
217
+ if (!date && !time) {
218
+ return "PT0S";
219
+ }
220
+ return `P${date}${time ? `T${time}` : ""}`;
221
+ };
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";
@@ -89,6 +90,7 @@ export * from "./components/output/code-block.js";
89
90
  export * from "./components/output/formatted-date.js";
90
91
  export * from "./components/output/relative-time.js";
91
92
  export * from "./components/output/formatted-number.js";
93
+ export * from "./components/output/formatted-duration.js";
92
94
  export * from "./components/output/formatted-file-size.js";
93
95
  export * from "./components/layout/app-shell.js";
94
96
  export * from "./components/layout/divider.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
@@ -97,6 +100,7 @@ export * from "./components/output/code-block.js";
97
100
  export * from "./components/output/formatted-date.js";
98
101
  export * from "./components/output/relative-time.js";
99
102
  export * from "./components/output/formatted-number.js";
103
+ export * from "./components/output/formatted-duration.js";
100
104
  export * from "./components/output/formatted-file-size.js";
101
105
  // Components: layout
102
106
  export * from "./components/layout/app-shell.js";
@@ -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("&", "&amp;")
12
14
  .replaceAll("<", "&lt;")
@@ -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
- // Drop-zone copy is presentation-only; everything else re-creates the queue.
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 files = event.detail?.files ?? [];
462
- if (files.length) {
463
- this.addFiles(files);
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
- for (const file of files) {
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: this.config.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.15.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",