@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.
@@ -46,6 +46,42 @@ export const boxDefaultDesignSystem = {
46
46
  },
47
47
  illustrations: {
48
48
  "empty-state-folder": illustrationSvg("0 0 140 140", '<g fill="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" fill-rule="evenodd" clip-rule="evenodd" opacity="0.1"><path d="M47.11 36H35.383c-1.261 0-2.294.97-2.378 2.198L33 38.36v48.604c0 2.162 1.716 3.926 3.872 4.031l.546.005h.257l.19-.036c1.743-.375 3.062-1.86 3.152-3.663l.005-.197V73.972a.98.98 0 0 1 .017-.18.87.87 0 0 1-.032-.155L41 73.52V56.717c0-4.209 3.7-7.59 8.268-7.713l.26-.004L97 48.999v-2.26c0-3.08-2.463-5.592-5.546-5.707l-.221-.004H54.118a4.402 4.402 0 0 1-3.51-1.738l-.14-.197-1.375-2.042a2.386 2.386 0 0 0-1.8-1.044L47.11 36Z" /><path d="M51 96a7 7 0 1 1 0 14 7 7 0 0 1 0-14Zm47-66c5.523 0 10 4.477 10 10s-4.477 10-10 10-10-4.477-10-10 4.477-10 10-10Z" /></g><path fill="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" fill-rule="evenodd" d="M95 90c0-7.18-5.82-13-13-13s-13 5.82-13 13 5.82 13 13 13 13-5.82 13-13Zm-12.268-6.723a1.03 1.03 0 0 0-1.269-.107l-.105.079-.061.057-.057.061-4.99 5.898-.074.098a1.06 1.06 0 0 0 .187 1.384l.098.076c.17.115.371.177.578.177H79l.001 4.921c0 .596.497 1.08 1.11 1.08h3.779c.614 0 1.11-.484 1.11-1.081V91h1.96l.115-.006c.52-.057.925-.503.925-1.045 0-.249-.087-.489-.246-.679l-4.932-5.898-.09-.095Z" clip-rule="evenodd" /><path fill="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" d="M44.156 39c.466 0 .844.448.844 1s-.378 1-.844 1h-6.312c-.466 0-.844-.448-.844-1s.378-1 .844-1h6.312Zm41.952 21c.493 0 .892.448.892 1s-.4 1-.892 1H61.892c-.493 0-.892-.448-.892-1s.4-1 .892-1h24.216Z" /><path fill="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" fill-rule="evenodd" d="M99 91a6 6 0 0 0 5.996-5.775L105 85l.005-28.283c0-2.983-2.745-5.672-6.005-5.708l-49.473-.019c-3.55 0-6.403 2.476-6.528 5.518l-.004.209c0 10.13.005 20.255.005 30.387a5.817 5.817 0 0 1-1.497 3.897L65 91a1 1 0 0 1 0 2H37c-3.238 0-5.878-2.521-5.996-5.675L31 87.104V38.347c0-2.33 1.85-4.232 4.174-4.342l.212-.005h11.727a4.4 4.4 0 0 1 3.509 1.739l.14.197 1.376 2.043c.407.607 1.073.99 1.799 1.045l.183.007h37.11c4.208 0 7.636 3.316 7.766 7.456l.004.244v2.284c4.358.24 7.863 3.449 7.996 7.464l.004.238V85a8 8 0 0 1-7.75 7.996L99 93a1 1 0 0 1 0-2ZM35.383 36H47.11l.183.007a2.386 2.386 0 0 1 1.8 1.044l1.375 2.042.14.197a4.402 4.402 0 0 0 3.51 1.738h37.115l.221.004c3.083.115 5.546 2.627 5.546 5.707v2.26L49.528 49l-.26.004C44.7 49.127 41 52.508 41 56.717c0 10.129.022 20.258.022 30.387l-.005.197c-.09 1.803-1.409 3.288-3.152 3.663l-.19.036h-.257l-.546-.005C34.716 90.89 33 89.126 33 86.964V38.36l.005-.162A2.374 2.374 0 0 1 35.383 36Z" clip-rule="evenodd" />'),
49
+ // Fanned file cards behind a cloud carrying an upload arrow — the empty
50
+ // state for box-content-uploader, matching what box-ui-elements shows.
51
+ // Cards are stroked rather than filled so they read on a dark surface too.
52
+ "upload-cloud": illustrationSvg("0 0 140 140", [
53
+ '<ellipse cx="70" cy="116" rx="27" ry="3.5" fill="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" opacity="0.1" />',
54
+ '<g fill="var(--boe-token-surface-surface, #ffffff)" stroke="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" stroke-width="2" stroke-linejoin="round">',
55
+ // Five cards fanned across an arc. Each is drawn at the origin and
56
+ // placed by transform, so the shape is written once.
57
+ [
58
+ { x: 44, y: 72, rotate: -26, glyph: "grid" },
59
+ { x: 57, y: 65, rotate: -13, glyph: "lines" },
60
+ { x: 70, y: 62, rotate: 0, glyph: "lines" },
61
+ { x: 83, y: 65, rotate: 13, glyph: "image" },
62
+ { x: 96, y: 72, rotate: 26, glyph: "grid" },
63
+ ]
64
+ .map(card => {
65
+ const glyphs = {
66
+ lines: '<rect x="5" y="13" width="16" height="2" rx="1" /><rect x="5" y="18" width="16" height="2" rx="1" /><rect x="5" y="23" width="10" height="2" rx="1" />',
67
+ image: '<circle cx="9" cy="15" r="2" /><path d="M5 25l5-6 4 5 3-3 4 5v1H5z" />',
68
+ grid: '<rect x="5" y="13" width="7" height="6" rx="1" /><rect x="14" y="13" width="7" height="6" rx="1" /><rect x="5" y="21" width="7" height="6" rx="1" /><rect x="14" y="21" width="7" height="6" rx="1" />',
69
+ };
70
+ return [
71
+ `<g transform="translate(${String(card.x)} ${String(card.y)}) rotate(${String(card.rotate)}) translate(-13 -17)">`,
72
+ '<path d="M0 4a4 4 0 0 1 4-4h14l8 8v22a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4Z" />',
73
+ '<path d="M18 0v6a2 2 0 0 0 2 2h6" fill="none" />',
74
+ `<g fill="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" stroke="none" opacity="0.45">${glyphs[card.glyph] ?? ""}</g>`,
75
+ "</g>",
76
+ ].join("");
77
+ })
78
+ .join(""),
79
+ "</g>",
80
+ // The cloud sits in front of the cards, so only their tops show.
81
+ '<path d="M52 108A14 14 0 0 1 52 80A20 20 0 0 1 88 80A14 14 0 0 1 88 108Z" fill="var(--boe-token-surface-surface, #ffffff)" stroke="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" stroke-width="2" stroke-linejoin="round" />',
82
+ '<circle cx="70" cy="89" r="11.5" fill="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" fill-opacity="0.12" stroke="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" stroke-width="1.75" stroke-dasharray="2 3.5" stroke-linecap="round" />',
83
+ '<path d="M70 83l5 5h-3v5h-4v-5h-3z" fill="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" />',
84
+ ].join("")),
49
85
  "files-information": illustrationSvg("0 0 140 140", '<path fill="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" fill-opacity="0.1" d="M97.5 99a2.5 2.5 0 0 1 2.5 2.5v7a2.5 2.5 0 1 1-5 0v-7a2.5 2.5 0 0 1 2.5-2.5ZM97.5 91a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5ZM74 78a2 2 0 1 1 0 4H57a2 2 0 1 1 0-4h17ZM90.01 67a1.99 1.99 0 0 1 0 3.978H56.99a1.99 1.99 0 0 1 0-3.978h33.02ZM90 56a2 2 0 1 1 0 4H57a2 2 0 1 1 0-4h33ZM80.058 27c.61 0 1.202.206 1.671.595C83.773 29.286 87.784 32.783 92 37c4.641 4.641 8.41 8.877 9.859 10.81a.73.73 0 0 1-1.105.944L98.89 46.89a3.037 3.037 0 0 0-2.148-.89h-9.264A4.479 4.479 0 0 1 83 41.522v-8.743a4.479 4.479 0 0 0-1.22-3.073L79.226 27h.83Z" /><path fill="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" fill-opacity="0.25" d="M45 91.001a8 8 0 0 0 8 8h28.068c-.044.494-.068.994-.068 1.499a16.42 16.42 0 0 0 2.966 9.439A7.896 7.896 0 0 1 83 110H43c-5.523 0-10-4.477-10-10V47a7 7 0 0 1 7-7h5v51.001Z" /><path fill="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" fill-rule="evenodd" d="M97.5 98.25a3.25 3.25 0 0 1 3.25 3.25v7a3.25 3.25 0 1 1-6.5 0v-7a3.25 3.25 0 0 1 3.25-3.25Zm0 1.5a1.75 1.75 0 0 0-1.75 1.75v7a1.75 1.75 0 0 0 3.5 0v-7a1.75 1.75 0 0 0-1.75-1.75ZM97.5 90.25a3.25 3.25 0 1 1 0 6.5 3.25 3.25 0 0 1 0-6.5Zm0 1.5a1.75 1.75 0 1 0 0 3.5 1.75 1.75 0 0 0 0-3.5Z" clip-rule="evenodd" /><path fill="var(--boe-token-surface-illustration-surface-box-neutral, #0061d5)" fill-rule="evenodd" d="M79.1 26.25a7.75 7.75 0 0 1 5.481 2.27l15.899 15.899a7.749 7.749 0 0 1 2.27 5.48v34.165c6.96 2.222 12 8.74 12 16.436 0 9.527-7.723 17.25-17.25 17.25-5.743 0-10.83-2.807-13.965-7.123-.5.08-1.013.123-1.535.123H42c-5.385 0-9.75-4.365-9.75-9.75V47A7.75 7.75 0 0 1 40 39.25h4.25V34A7.75 7.75 0 0 1 52 26.25h27.1Zm18.4 58.5c-8.698 0-15.75 7.052-15.75 15.75s7.052 15.75 15.75 15.75 15.75-7.052 15.75-15.75-7.052-15.75-15.75-15.75Zm-57.5-44A6.25 6.25 0 0 0 33.75 47v54a8.25 8.25 0 0 0 8.25 8.25h40c.207 0 .413-.011.616-.026a17.172 17.172 0 0 1-2.348-9.473H52a7.75 7.75 0 0 1-7.75-7.75V40.75H40Zm12-13A6.25 6.25 0 0 0 45.75 34v58.001a6.25 6.25 0 0 0 6.25 6.25h28.395c1.103-8.464 8.34-15.001 17.105-15.001 1.288 0 2.543.141 3.75.41V51.112c0-.514-.204-1.007-.567-1.37l-1.902-1.901a3.73 3.73 0 0 0-2.637-1.092h-8.666a5.229 5.229 0 0 1-5.228-5.228v-8.743c0-.951-.364-1.866-1.017-2.558l-1.756-1.864a1.938 1.938 0 0 0-1.41-.607H52Zm30.325 1.441a5.23 5.23 0 0 1 1.425 3.588v8.742c0 2.06 1.67 3.729 3.728 3.729h8.666a5.23 5.23 0 0 1 3.698 1.531l1.056 1.057a6.241 6.241 0 0 0-1.48-2.358L83.52 29.581a6.252 6.252 0 0 0-2.15-1.403l.956 1.013Z" clip-rule="evenodd" />'),
50
86
  },
51
87
  };
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export type { ApplyDesignProfileOptions, DesignProfileChangeDetail, DesignProfil
10
10
  export { boxIconography, boxIconographyAliases } from "./foundations/icons/index.js";
11
11
  export * from "./foundations/layout/index.js";
12
12
  export * from "./foundations/format/index.js";
13
+ export * from "./foundations/files/index.js";
13
14
  export { anchorFloating, parsePlacement, resolvePosition, trackAnchor, } from "./foundations/overlay/index.js";
14
15
  export type { OverlayAlign, OverlayPlacement, OverlaySide, PositionOptions, PositionResult, } from "./foundations/overlay/index.js";
15
16
  export * from "./components/actions/button.js";
package/dist/index.js CHANGED
@@ -9,6 +9,9 @@ export { boxIconography, boxIconographyAliases } from "./foundations/icons/index
9
9
  export * from "./foundations/layout/index.js";
10
10
  // Foundations: locale-aware typed-value formatting (shared by box-formatted-*)
11
11
  export * from "./foundations/format/index.js";
12
+ // Foundations: reading dropped files and folders (shared by box-drop-zone and
13
+ // the content-uploader pattern)
14
+ export * from "./foundations/files/index.js";
12
15
  // Foundations: viewport-aware overlay positioning (for building custom overlays)
13
16
  export { anchorFloating, parsePlacement, resolvePosition, trackAnchor, } from "./foundations/overlay/index.js";
14
17
  // Components: actions
@@ -13,6 +13,14 @@ export declare class ContentUploader extends BaseElement {
13
13
  private footerEl;
14
14
  private summaryEl;
15
15
  private queueSignature;
16
+ private updateScheduled;
17
+ private closeEl;
18
+ private cancelEl;
19
+ private uploadEl;
20
+ private clearCompletedEl;
21
+ private illustrationEl;
22
+ private illustrationSignature;
23
+ private readonly handleDesignSystemChange;
16
24
  get folderId(): string | null;
17
25
  set folderId(value: string | null);
18
26
  get token(): string | null;
@@ -30,8 +38,43 @@ export declare class ContentUploader extends BaseElement {
30
38
  /** Maximum accepted file size in bytes. */
31
39
  get maxFileSize(): number | undefined;
32
40
  set maxFileSize(value: number | undefined);
41
+ /**
42
+ * Most files the queue will hold, default 100 — the same ceiling
43
+ * box-ui-elements uses. Further files are rejected with
44
+ * `file-limit-reached` rather than enqueued.
45
+ *
46
+ * A default matters here in a way it does not for the other constraints: a
47
+ * dropped folder can carry thousands of files, and an unbounded queue has no
48
+ * back pressure at all.
49
+ */
50
+ get fileLimit(): number;
51
+ set fileLimit(value: number);
52
+ /**
53
+ * Make the browse dialog pick a folder rather than files.
54
+ *
55
+ * Dropping a folder works either way; the platform only forces the choice on
56
+ * the click-to-browse path.
57
+ */
58
+ get directories(): boolean;
59
+ set directories(value: boolean);
60
+ /**
61
+ * Show the Close control. On by default, matching box-ui-elements; a host
62
+ * that owns its own dismissal (a drawer with its own close button) sets
63
+ * `closable="false"` rather than styling ours away.
64
+ */
65
+ get closable(): boolean;
66
+ set closable(value: boolean);
67
+ /**
68
+ * Headline of the empty state. Defaults to the box-ui-elements wording, and
69
+ * names folders too when they can be uploaded, so the invitation matches what
70
+ * the zone will actually accept.
71
+ */
33
72
  get dropLabel(): string;
34
73
  set dropLabel(value: string);
74
+ /**
75
+ * Optional supporting line under the headline. Empty by default: the browse
76
+ * controls say what to do, and a sentence repeating them is noise.
77
+ */
35
78
  get dropMessage(): string;
36
79
  set dropMessage(value: string);
37
80
  get transport(): UploadTransport | null;
@@ -39,10 +82,24 @@ export declare class ContentUploader extends BaseElement {
39
82
  get state(): Readonly<UploaderState> | null;
40
83
  /** The live queue controller. Null until configured. */
41
84
  get uploaderController(): ContentUploaderController | null;
85
+ /**
86
+ * Attributes that only affect how the drop zone presents itself. Everything
87
+ * else feeds the session config, so changing it re-creates the queue.
88
+ */
89
+ private static readonly PRESENTATION_ATTRIBUTES;
42
90
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
43
91
  connectedCallback(): void;
44
92
  disconnectedCallback(): void;
45
93
  addFiles(files: UploadFileLike[]): UploadQueueItem[];
94
+ /**
95
+ * Queue files that carry a directory path relative to the destination, the
96
+ * way a dropped folder does. Requires `createFolder` on the transport;
97
+ * without it each pathed file is rejected with `folder-unsupported`.
98
+ */
99
+ addEntries(entries: Array<{
100
+ file: UploadFileLike;
101
+ path?: string;
102
+ }>): UploadQueueItem[];
46
103
  start(): void;
47
104
  cancelItem(itemId: string): void;
48
105
  retryItem(itemId: string): void;
@@ -53,9 +110,28 @@ export declare class ContentUploader extends BaseElement {
53
110
  private startController;
54
111
  private readConfig;
55
112
  private subscribeToController;
113
+ /**
114
+ * Coalesce the re-render for a burst of controller events.
115
+ *
116
+ * A folder drop adds up to `fileLimit` items in one go, each emitting its own
117
+ * `itemAdded`; rendering per event rebuilt the whole queue once per file,
118
+ * which is quadratic in the size of the drop. One render per microtask turn
119
+ * gives the same result for a fraction of the work.
120
+ */
121
+ private scheduleUpdate;
56
122
  private teardownController;
57
123
  protected renderTemplate(): void;
124
+ /**
125
+ * Paints the empty-state art from the active design system, so a host that
126
+ * registers its own system gets its own illustration without touching this.
127
+ *
128
+ * Resolved during update rather than at template time: a host may register or
129
+ * switch design systems after the element upgrades, and the art has to follow.
130
+ */
131
+ private renderIllustration;
58
132
  protected setupListeners(): void;
133
+ /** Cancel every item still queued or in flight. Settled items are untouched. */
134
+ cancelAll(): void;
59
135
  private rebuildQueue;
60
136
  private patchQueueProgress;
61
137
  protected update(): void;
@@ -4,9 +4,12 @@ import { formatItemSize } from "../content-explorer/adapters/item-summary.js";
4
4
  import { DropZone } from "../../components/files/drop-zone.js";
5
5
  import { ProgressBar } from "../../components/feedback/progress-bar.js";
6
6
  import { BaseElement } from "../../core/index.js";
7
+ import { DESIGN_SYSTEM_CHANGE_EVENT, resolveDesignIllustration, } from "../../foundations/tokens/index.js";
7
8
  import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
8
9
  import { boePanel, boeRadius } from "../../foundations/geometry/index.js";
9
10
  const DEFAULT_TAG_NAME = "box-content-uploader";
11
+ /** Matches the box-ui-elements ceiling, so a port behaves the same way. */
12
+ const DEFAULT_FILE_LIMIT = 100;
10
13
  const escapeHtml = (value) => value
11
14
  .replaceAll("&", "&amp;")
12
15
  .replaceAll("<", "&lt;")
@@ -169,10 +172,19 @@ const elementStyles = `
169
172
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 18%, transparent);
170
173
  }
171
174
 
175
+ /* The footer is a bar rather than a caption: Close sits apart on the
176
+ left, the queue-level actions group on the right, as they do in
177
+ box-ui-elements. */
178
+ /* Wraps rather than squeezes: in a narrow column the summary would
179
+ otherwise concertina onto three lines and the button labels break
180
+ mid-phrase. Wrapped, the action group drops to its own row intact. */
172
181
  [part="footer"] {
173
182
  display: flex;
183
+ flex-wrap: wrap;
174
184
  align-items: center;
175
- gap: ${boePanel.gap};
185
+ gap: 0.5rem ${boePanel.gap};
186
+ padding-block-start: ${boePanel.gap};
187
+ border-block-start: 1px solid color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 82%, transparent);
176
188
  }
177
189
 
178
190
  [part="footer"][hidden] {
@@ -180,43 +192,87 @@ const elementStyles = `
180
192
  }
181
193
 
182
194
  [part="summary"] {
183
- flex: 1;
195
+ /* Takes the slack but never forces a wrap of its own: it is a status
196
+ line, not a reason for the buttons to be squeezed. */
197
+ flex: 1 1 auto;
198
+ min-inline-size: 0;
184
199
  font-size: 0.85rem;
185
200
  color: var(--boe-token-text-text-secondary, #6f6f6f);
186
201
  }
187
202
 
188
- [part="clear-completed"] {
203
+ [part="footer-actions"] {
204
+ display: flex;
205
+ flex-wrap: wrap;
206
+ align-items: center;
207
+ justify-content: flex-end;
208
+ /* Keeps the group right-aligned once the footer wraps it onto its
209
+ own row. */
210
+ margin-inline-start: auto;
211
+ gap: 0.5rem;
212
+ }
213
+
214
+ [part~="action"] {
189
215
  appearance: none;
190
216
  font: inherit;
191
217
  font-size: 0.875rem;
192
218
  font-weight: 600;
193
- padding: 0.4rem 0.7rem;
219
+ padding: 0.5rem 0.9rem;
194
220
  border-radius: ${boeRadius.control};
195
221
  border: 1px solid var(--boe-token-stroke-stroke, #e8e8e8);
196
222
  background: var(--boe-token-surface-surface, #ffffff);
197
223
  color: var(--boe-token-text-text, #222222);
198
224
  cursor: pointer;
225
+ /* A label that breaks mid-phrase reads as two controls. */
226
+ white-space: nowrap;
227
+ transition:
228
+ background ${boeMotionDuration.interactive} ${boeMotionEasing.standard},
229
+ border-color ${boeMotionDuration.interactive} ${boeMotionEasing.standard};
199
230
  }
200
231
 
201
- [part="clear-completed"]:hover {
232
+ [part~="action"]:hover:not(:disabled) {
202
233
  background: var(--boe-token-surface-surface-hover, #f4f4f4);
203
234
  border-color: var(--boe-token-stroke-stroke-hover, #bcbcbc);
204
235
  }
205
236
 
206
- [part="clear-completed"]:focus-visible {
237
+ [part~="action"]:focus-visible {
207
238
  outline: none;
208
239
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 18%, transparent);
209
240
  }
241
+
242
+ /* Disabled, not removed: the control keeps its place so the footer does
243
+ not reflow as the queue changes, and stays discoverable. */
244
+ [part~="action"]:disabled {
245
+ cursor: not-allowed;
246
+ opacity: 0.5;
247
+ }
248
+
249
+ [part~="action-primary"] {
250
+ border-color: transparent;
251
+ background: var(--boe-token-surface-surface-brand, #0061d5);
252
+ color: var(--boe-token-text-text-on-color, #ffffff);
253
+ }
254
+
255
+ [part~="action-primary"]:hover:not(:disabled) {
256
+ background: color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 86%, black 14%);
257
+ border-color: transparent;
258
+ }
259
+
260
+ [part="close"][hidden] {
261
+ display: none;
262
+ }
210
263
  `;
211
264
  export class ContentUploader extends BaseElement {
212
265
  static tagName = DEFAULT_TAG_NAME;
213
266
  static get observedAttributes() {
214
267
  return [
215
268
  "auto-start",
269
+ "closable",
216
270
  "concurrency",
217
271
  "drop-label",
218
272
  "drop-message",
273
+ "directories",
219
274
  "extensions",
275
+ "file-limit",
220
276
  "folder-id",
221
277
  "language",
222
278
  "max-file-size",
@@ -232,6 +288,18 @@ export class ContentUploader extends BaseElement {
232
288
  footerEl;
233
289
  summaryEl;
234
290
  queueSignature = "";
291
+ updateScheduled = false;
292
+ closeEl;
293
+ cancelEl;
294
+ uploadEl;
295
+ clearCompletedEl;
296
+ illustrationEl;
297
+ illustrationSignature = "";
298
+ handleDesignSystemChange = () => {
299
+ if (this.isRendered) {
300
+ this.renderIllustration();
301
+ }
302
+ };
235
303
  get folderId() {
236
304
  return this.getAttribute("folder-id");
237
305
  }
@@ -293,14 +361,67 @@ export class ContentUploader extends BaseElement {
293
361
  }
294
362
  this.removeAttribute("max-file-size");
295
363
  }
364
+ /**
365
+ * Most files the queue will hold, default 100 — the same ceiling
366
+ * box-ui-elements uses. Further files are rejected with
367
+ * `file-limit-reached` rather than enqueued.
368
+ *
369
+ * A default matters here in a way it does not for the other constraints: a
370
+ * dropped folder can carry thousands of files, and an unbounded queue has no
371
+ * back pressure at all.
372
+ */
373
+ get fileLimit() {
374
+ const raw = Number(this.getAttribute("file-limit"));
375
+ return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : DEFAULT_FILE_LIMIT;
376
+ }
377
+ set fileLimit(value) {
378
+ this.setAttribute("file-limit", String(value));
379
+ }
380
+ /**
381
+ * Make the browse dialog pick a folder rather than files.
382
+ *
383
+ * Dropping a folder works either way; the platform only forces the choice on
384
+ * the click-to-browse path.
385
+ */
386
+ get directories() {
387
+ return this.hasAttribute("directories");
388
+ }
389
+ set directories(value) {
390
+ this.toggleAttribute("directories", value);
391
+ }
392
+ /**
393
+ * Show the Close control. On by default, matching box-ui-elements; a host
394
+ * that owns its own dismissal (a drawer with its own close button) sets
395
+ * `closable="false"` rather than styling ours away.
396
+ */
397
+ get closable() {
398
+ return this.getAttribute("closable") !== "false";
399
+ }
400
+ set closable(value) {
401
+ if (value) {
402
+ this.removeAttribute("closable");
403
+ return;
404
+ }
405
+ this.setAttribute("closable", "false");
406
+ }
407
+ /**
408
+ * Headline of the empty state. Defaults to the box-ui-elements wording, and
409
+ * names folders too when they can be uploaded, so the invitation matches what
410
+ * the zone will actually accept.
411
+ */
296
412
  get dropLabel() {
297
- return this.getAttribute("drop-label") ?? "Upload files";
413
+ return (this.getAttribute("drop-label") ??
414
+ (this.directories ? "Drag and drop files and folders" : "Drag and drop files"));
298
415
  }
299
416
  set dropLabel(value) {
300
417
  this.setAttribute("drop-label", value);
301
418
  }
419
+ /**
420
+ * Optional supporting line under the headline. Empty by default: the browse
421
+ * controls say what to do, and a sentence repeating them is noise.
422
+ */
302
423
  get dropMessage() {
303
- return this.getAttribute("drop-message") ?? "Drag files here or click to browse.";
424
+ return this.getAttribute("drop-message") ?? "";
304
425
  }
305
426
  set dropMessage(value) {
306
427
  this.setAttribute("drop-message", value);
@@ -319,23 +440,42 @@ export class ContentUploader extends BaseElement {
319
440
  get uploaderController() {
320
441
  return this.controller;
321
442
  }
443
+ /**
444
+ * Attributes that only affect how the drop zone presents itself. Everything
445
+ * else feeds the session config, so changing it re-creates the queue.
446
+ */
447
+ static PRESENTATION_ATTRIBUTES = new Set([
448
+ "closable",
449
+ "directories",
450
+ "drop-label",
451
+ "drop-message",
452
+ ]);
322
453
  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") {
454
+ if (!ContentUploader.PRESENTATION_ATTRIBUTES.has(name)) {
325
455
  this.scheduleStart();
326
456
  }
327
457
  super.attributeChangedCallback(name, oldValue, newValue);
328
458
  }
329
459
  connectedCallback() {
460
+ globalThis.addEventListener?.(DESIGN_SYSTEM_CHANGE_EVENT, this.handleDesignSystemChange);
330
461
  super.connectedCallback();
331
462
  this.scheduleStart();
332
463
  }
333
464
  disconnectedCallback() {
465
+ globalThis.removeEventListener?.(DESIGN_SYSTEM_CHANGE_EVENT, this.handleDesignSystemChange);
334
466
  this.teardownController();
335
467
  }
336
468
  addFiles(files) {
337
469
  return this.controller?.addFiles(files) ?? [];
338
470
  }
471
+ /**
472
+ * Queue files that carry a directory path relative to the destination, the
473
+ * way a dropped folder does. Requires `createFolder` on the transport;
474
+ * without it each pathed file is rejected with `folder-unsupported`.
475
+ */
476
+ addEntries(entries) {
477
+ return this.controller?.addEntries(entries) ?? [];
478
+ }
339
479
  start() {
340
480
  this.controller?.start();
341
481
  }
@@ -396,6 +536,7 @@ export class ContentUploader extends BaseElement {
396
536
  autoStart: this.autoStart,
397
537
  concurrency: this.concurrency,
398
538
  extensions: this.extensions.length ? this.extensions : undefined,
539
+ fileLimit: this.fileLimit,
399
540
  folderId: this.folderId,
400
541
  language: this.language ?? undefined,
401
542
  maxFileSizeBytes: this.maxFileSize,
@@ -422,10 +563,28 @@ export class ContentUploader extends BaseElement {
422
563
  composed: true,
423
564
  detail: payload,
424
565
  }));
566
+ this.scheduleUpdate();
567
+ }));
568
+ }
569
+ /**
570
+ * Coalesce the re-render for a burst of controller events.
571
+ *
572
+ * A folder drop adds up to `fileLimit` items in one go, each emitting its own
573
+ * `itemAdded`; rendering per event rebuilt the whole queue once per file,
574
+ * which is quadratic in the size of the drop. One render per microtask turn
575
+ * gives the same result for a fraction of the work.
576
+ */
577
+ scheduleUpdate() {
578
+ if (!this.isRendered || this.updateScheduled) {
579
+ return;
580
+ }
581
+ this.updateScheduled = true;
582
+ queueMicrotask(() => {
583
+ this.updateScheduled = false;
425
584
  if (this.isRendered) {
426
585
  this.update();
427
586
  }
428
- }));
587
+ });
429
588
  }
430
589
  teardownController() {
431
590
  for (const unsubscribe of this.unsubscribeFns) {
@@ -443,11 +602,18 @@ export class ContentUploader extends BaseElement {
443
602
  this.shadowRoot.innerHTML = `
444
603
  <style>${elementStyles}</style>
445
604
  <div part="panel">
446
- <box-drop-zone part="drop-zone"></box-drop-zone>
605
+ <box-drop-zone part="drop-zone" variant="hero">
606
+ <span slot="illustration" part="drop-illustration"></span>
607
+ </box-drop-zone>
447
608
  <ul part="queue" aria-label="Upload queue"></ul>
448
- <footer part="footer" hidden>
609
+ <footer part="footer">
610
+ <button type="button" part="action close">Close</button>
449
611
  <span part="summary" role="status" aria-live="polite"></span>
450
- <button type="button" part="clear-completed">Clear completed</button>
612
+ <span part="footer-actions">
613
+ <button type="button" part="action clear-completed">Clear completed</button>
614
+ <button type="button" part="action cancel">Cancel</button>
615
+ <button type="button" part="action action-primary upload">Upload</button>
616
+ </span>
451
617
  </footer>
452
618
  </div>
453
619
  `;
@@ -455,12 +621,34 @@ export class ContentUploader extends BaseElement {
455
621
  this.queueEl = this.shadowRoot.querySelector('[part="queue"]');
456
622
  this.footerEl = this.shadowRoot.querySelector('[part="footer"]');
457
623
  this.summaryEl = this.shadowRoot.querySelector('[part="summary"]');
624
+ this.closeEl = this.shadowRoot.querySelector('[part~="close"]');
625
+ this.cancelEl = this.shadowRoot.querySelector('[part~="cancel"]');
626
+ this.uploadEl = this.shadowRoot.querySelector('[part~="upload"]');
627
+ this.clearCompletedEl = this.shadowRoot.querySelector('[part~="clear-completed"]');
628
+ this.illustrationEl = this.shadowRoot.querySelector('[part="drop-illustration"]');
629
+ }
630
+ /**
631
+ * Paints the empty-state art from the active design system, so a host that
632
+ * registers its own system gets its own illustration without touching this.
633
+ *
634
+ * Resolved during update rather than at template time: a host may register or
635
+ * switch design systems after the element upgrades, and the art has to follow.
636
+ */
637
+ renderIllustration() {
638
+ const markup = resolveDesignIllustration("upload-cloud") ?? "";
639
+ if (markup !== this.illustrationSignature) {
640
+ this.illustrationSignature = markup;
641
+ this.illustrationEl.innerHTML = markup;
642
+ }
458
643
  }
459
644
  setupListeners() {
460
645
  this.dropZoneEl.addEventListener("files-selected", event => {
461
- const files = event.detail?.files ?? [];
462
- if (files.length) {
463
- this.addFiles(files);
646
+ const detail = event.detail;
647
+ // Prefer entries: they carry the folder each file came from. `files` is
648
+ // the flat fallback for a host dispatching this event by hand.
649
+ const entries = detail?.entries ?? (detail?.files ?? []).map(file => ({ file, path: "" }));
650
+ if (entries.length) {
651
+ this.controller?.addEntries(entries);
464
652
  }
465
653
  });
466
654
  this.queueEl.addEventListener("click", event => {
@@ -483,9 +671,33 @@ export class ContentUploader extends BaseElement {
483
671
  this.removeItem(itemId);
484
672
  }
485
673
  });
486
- this.footerEl.querySelector('[part="clear-completed"]')?.addEventListener("click", () => {
674
+ this.clearCompletedEl.addEventListener("click", () => {
487
675
  this.clearCompleted();
488
676
  });
677
+ this.uploadEl.addEventListener("click", () => {
678
+ this.start();
679
+ });
680
+ this.cancelEl.addEventListener("click", () => {
681
+ this.cancelAll();
682
+ });
683
+ // The uploader does not own the surface it sits in — a dialog, a drawer, a
684
+ // page — so Close reports the intent and the host decides what closing
685
+ // means. Cancellable, so a host can refuse.
686
+ this.closeEl.addEventListener("click", () => {
687
+ this.dispatchEvent(new CustomEvent("close", { bubbles: true, composed: true, cancelable: true }));
688
+ });
689
+ }
690
+ /** Cancel every item still queued or in flight. Settled items are untouched. */
691
+ cancelAll() {
692
+ const controller = this.controller;
693
+ if (!controller) {
694
+ return;
695
+ }
696
+ for (const item of controller.getState().items) {
697
+ if (item.status === "queued" || item.status === "uploading") {
698
+ controller.cancelItem(item.id);
699
+ }
700
+ }
489
701
  }
490
702
  rebuildQueue(items) {
491
703
  this.queueEl.innerHTML = items
@@ -534,8 +746,17 @@ export class ContentUploader extends BaseElement {
534
746
  if (!this.queueEl) {
535
747
  return;
536
748
  }
749
+ this.renderIllustration();
537
750
  this.dropZoneEl.label = this.dropLabel;
538
751
  this.dropZoneEl.message = this.dropMessage;
752
+ this.dropZoneEl.directories = this.directories;
753
+ // Greys out the files the queue would reject anyway, so the picker stops
754
+ // offering a choice that ends in a rejection. The queue's own check still
755
+ // decides — `accept` is advisory, and does nothing for a drop.
756
+ // A leading dot is optional in `extensions`, as it is for the queue check.
757
+ this.dropZoneEl.accept = this.extensions
758
+ .map(extension => `.${extension.replace(/^\./, "")}`)
759
+ .join(",");
539
760
  const items = this.controller?.getState().items ?? [];
540
761
  // Structural changes rebuild the rows; progress ticks patch the bars in
541
762
  // place so in-row focus (Cancel while uploading) survives.
@@ -546,12 +767,29 @@ export class ContentUploader extends BaseElement {
546
767
  }
547
768
  this.patchQueueProgress(items);
548
769
  const summary = summarizeUploadQueue(items);
549
- this.footerEl.hidden = summary.total === 0;
550
770
  const active = summary.queued + summary.uploading;
771
+ // The empty state and the queue are alternatives, not neighbours: the
772
+ // illustration is the invitation, and once files are in the queue the list
773
+ // is what a person needs to see. Dropping still works over the list.
774
+ this.dropZoneEl.hidden = summary.total > 0;
775
+ // The footer stays put with its controls disabled rather than appearing
776
+ // when the first file lands — a bar that materialises under the pointer is
777
+ // how people click the wrong thing.
778
+ this.uploadEl.disabled = summary.queued === 0;
779
+ this.cancelEl.disabled = active === 0;
780
+ this.clearCompletedEl.disabled = summary.succeeded + summary.cancelled === 0;
781
+ // Closing mid-upload would abandon transfers in flight, so Close waits.
782
+ // box-ui-elements disables it for any non-empty queue; that would trap a
783
+ // person on a finished queue with no way out, so it is only held while
784
+ // something is actually running.
785
+ this.closeEl.disabled = active > 0;
786
+ this.closeEl.hidden = !this.closable;
551
787
  this.summaryEl.textContent =
552
- active > 0
553
- ? `Uploading ${String(summary.uploading)} · ${String(summary.queued)} queued`
554
- : `${String(summary.succeeded)} of ${String(summary.total)} uploaded${summary.failed ? ` · ${String(summary.failed)} failed` : ""}`;
788
+ summary.total === 0
789
+ ? ""
790
+ : active > 0
791
+ ? `Uploading ${String(summary.uploading)} · ${String(summary.queued)} queued`
792
+ : `${String(summary.succeeded)} of ${String(summary.total)} uploaded${summary.failed ? ` · ${String(summary.failed)} failed` : ""}`;
555
793
  }
556
794
  }
557
795
  DropZone.register();