@unofficialbox/box-open-elements 0.17.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,8 +5,13 @@ 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;
@@ -14,15 +19,31 @@ export declare class DropZone extends BaseElement {
14
19
  get description(): string;
15
20
  set description(value: string);
16
21
  /**
17
- * Make the browse dialog pick a folder rather than files.
22
+ * Offer folder selection from the browse controls as well as files.
18
23
  *
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.
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.
23
31
  */
24
32
  get directories(): boolean;
25
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);
26
47
  /**
27
48
  * What the browse dialog offers, in the `<input accept>` syntax —
28
49
  * `".pdf,.docx"` or `"image/*"`.
@@ -11,12 +11,19 @@ const dropZoneStyles = `
11
11
  font: inherit;
12
12
  }
13
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
+
14
20
  [part="zone"] {
15
21
  position: relative;
16
22
  display: grid;
17
23
  gap: 0.55rem;
18
24
  justify-items: start;
19
25
  padding: 0.75rem;
26
+ text-align: start;
20
27
  border: 1.5px dashed color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 16%, var(--boe-token-stroke-stroke, #e8e8e8) 84%);
21
28
  border-radius: ${boeRadius.large};
22
29
  background: var(--boe-token-surface-surface-secondary, #fbfbfb);
@@ -49,7 +56,9 @@ const dropZoneStyles = `
49
56
  0 14px 28px rgba(15, 23, 42, 0.05);
50
57
  }
51
58
 
52
- [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"] {
53
62
  position: absolute;
54
63
  inline-size: 1px;
55
64
  block-size: 1px;
@@ -57,8 +66,6 @@ const dropZoneStyles = `
57
66
  pointer-events: none;
58
67
  }
59
68
 
60
- ${boeFocusVisibleStyles('[part="input"]')}
61
-
62
69
  [part="label"] {
63
70
  font-size: 1rem;
64
71
  font-weight: 700;
@@ -70,17 +77,99 @@ const dropZoneStyles = `
70
77
  line-height: 1.5;
71
78
  max-width: 34ch;
72
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
+ }
73
148
  `;
74
149
  export class DropZone extends BaseElement {
75
150
  static tagName = DEFAULT_TAG_NAME;
76
151
  static get observedAttributes() {
77
- return ["accept", "description", "directories", "label", "message"];
152
+ return [
153
+ "accept",
154
+ "browse-label",
155
+ "description",
156
+ "directories",
157
+ "folder-label",
158
+ "label",
159
+ "message",
160
+ "variant",
161
+ ];
78
162
  }
79
163
  dragging = false;
80
164
  zoneEl;
81
165
  inputEl;
166
+ folderInputEl;
82
167
  labelEl;
83
168
  messageEl;
169
+ browseEl;
170
+ folderBrowseEl;
171
+ separatorEl;
172
+ illustrationEl;
84
173
  get label() {
85
174
  return this.getAttribute("label") ?? "Upload files";
86
175
  }
@@ -100,12 +189,15 @@ export class DropZone extends BaseElement {
100
189
  this.setAttribute("description", value);
101
190
  }
102
191
  /**
103
- * Make the browse dialog pick a folder rather than files.
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.
104
198
  *
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.
199
+ * Drag-and-drop reads folders regardless; a dropped directory is always
200
+ * traversed whatever this is set to.
109
201
  */
110
202
  get directories() {
111
203
  return this.hasAttribute("directories");
@@ -113,6 +205,31 @@ export class DropZone extends BaseElement {
113
205
  set directories(value) {
114
206
  this.toggleAttribute("directories", value);
115
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
+ }
116
233
  /**
117
234
  * What the browse dialog offers, in the `<input accept>` syntax —
118
235
  * `".pdf,.docx"` or `"image/*"`.
@@ -135,16 +252,31 @@ export class DropZone extends BaseElement {
135
252
  if (!this.shadowRoot) {
136
253
  return;
137
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.
138
258
  this.shadowRoot.innerHTML = `
139
259
  <style>${dropZoneStyles}</style>
140
- <label part="zone">
141
- <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>
142
264
  <strong part="label"></strong>
143
265
  <span part="description message"></span>
144
- </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>
145
272
  `;
146
273
  this.zoneEl = this.shadowRoot.querySelector('[part="zone"]');
147
- 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"]');
148
280
  this.labelEl = this.shadowRoot.querySelector('[part="label"]');
149
281
  this.messageEl = this.shadowRoot.querySelector('[part~="description"]');
150
282
  }
@@ -177,11 +309,30 @@ export class DropZone extends BaseElement {
177
309
  this.emitSelection(entries, skipped);
178
310
  });
179
311
  });
180
- this.inputEl.addEventListener("change", () => {
181
- // `webkitdirectory` reports the tree through each file's
182
- // webkitRelativePath rather than through entries.
183
- this.emitSelection(entriesFromFileList(Array.from(this.inputEl.files ?? [])));
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();
323
+ });
324
+ this.folderBrowseEl.addEventListener("click", () => {
325
+ this.folderInputEl.click();
184
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
+ }
185
336
  }
186
337
  /**
187
338
  * Report a selection.
@@ -213,13 +364,21 @@ export class DropZone extends BaseElement {
213
364
  this.zoneEl.dataset.dragging = String(this.dragging);
214
365
  this.labelEl.textContent = this.label;
215
366
  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.
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.
219
373
  const directories = this.directories;
220
- this.inputEl.toggleAttribute("webkitdirectory", directories);
221
- this.inputEl.webkitdirectory = 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.
222
380
  this.inputEl.accept = this.accept;
381
+ this.illustrationEl.hidden = this.variant !== "hero";
223
382
  }
224
383
  }
225
384
  DropZone.register();
@@ -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
  };
@@ -14,6 +14,13 @@ export declare class ContentUploader extends BaseElement {
14
14
  private summaryEl;
15
15
  private queueSignature;
16
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;
17
24
  get folderId(): string | null;
18
25
  set folderId(value: string | null);
19
26
  get token(): string | null;
@@ -50,8 +57,24 @@ export declare class ContentUploader extends BaseElement {
50
57
  */
51
58
  get directories(): boolean;
52
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
+ */
53
72
  get dropLabel(): string;
54
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
+ */
55
78
  get dropMessage(): string;
56
79
  set dropMessage(value: string);
57
80
  get transport(): UploadTransport | null;
@@ -98,7 +121,17 @@ export declare class ContentUploader extends BaseElement {
98
121
  private scheduleUpdate;
99
122
  private teardownController;
100
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;
101
132
  protected setupListeners(): void;
133
+ /** Cancel every item still queued or in flight. Settled items are untouched. */
134
+ cancelAll(): void;
102
135
  private rebuildQueue;
103
136
  private patchQueueProgress;
104
137
  protected update(): void;
@@ -4,6 +4,7 @@ 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";
@@ -171,10 +172,19 @@ const elementStyles = `
171
172
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 18%, transparent);
172
173
  }
173
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. */
174
181
  [part="footer"] {
175
182
  display: flex;
183
+ flex-wrap: wrap;
176
184
  align-items: center;
177
- 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);
178
188
  }
179
189
 
180
190
  [part="footer"][hidden] {
@@ -182,39 +192,81 @@ const elementStyles = `
182
192
  }
183
193
 
184
194
  [part="summary"] {
185
- 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;
186
199
  font-size: 0.85rem;
187
200
  color: var(--boe-token-text-text-secondary, #6f6f6f);
188
201
  }
189
202
 
190
- [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"] {
191
215
  appearance: none;
192
216
  font: inherit;
193
217
  font-size: 0.875rem;
194
218
  font-weight: 600;
195
- padding: 0.4rem 0.7rem;
219
+ padding: 0.5rem 0.9rem;
196
220
  border-radius: ${boeRadius.control};
197
221
  border: 1px solid var(--boe-token-stroke-stroke, #e8e8e8);
198
222
  background: var(--boe-token-surface-surface, #ffffff);
199
223
  color: var(--boe-token-text-text, #222222);
200
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};
201
230
  }
202
231
 
203
- [part="clear-completed"]:hover {
232
+ [part~="action"]:hover:not(:disabled) {
204
233
  background: var(--boe-token-surface-surface-hover, #f4f4f4);
205
234
  border-color: var(--boe-token-stroke-stroke-hover, #bcbcbc);
206
235
  }
207
236
 
208
- [part="clear-completed"]:focus-visible {
237
+ [part~="action"]:focus-visible {
209
238
  outline: none;
210
239
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 18%, transparent);
211
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
+ }
212
263
  `;
213
264
  export class ContentUploader extends BaseElement {
214
265
  static tagName = DEFAULT_TAG_NAME;
215
266
  static get observedAttributes() {
216
267
  return [
217
268
  "auto-start",
269
+ "closable",
218
270
  "concurrency",
219
271
  "drop-label",
220
272
  "drop-message",
@@ -237,6 +289,17 @@ export class ContentUploader extends BaseElement {
237
289
  summaryEl;
238
290
  queueSignature = "";
239
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
+ };
240
303
  get folderId() {
241
304
  return this.getAttribute("folder-id");
242
305
  }
@@ -326,14 +389,39 @@ export class ContentUploader extends BaseElement {
326
389
  set directories(value) {
327
390
  this.toggleAttribute("directories", value);
328
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
+ */
329
412
  get dropLabel() {
330
- 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"));
331
415
  }
332
416
  set dropLabel(value) {
333
417
  this.setAttribute("drop-label", value);
334
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
+ */
335
423
  get dropMessage() {
336
- return this.getAttribute("drop-message") ?? "Drag files here or click to browse.";
424
+ return this.getAttribute("drop-message") ?? "";
337
425
  }
338
426
  set dropMessage(value) {
339
427
  this.setAttribute("drop-message", value);
@@ -357,6 +445,7 @@ export class ContentUploader extends BaseElement {
357
445
  * else feeds the session config, so changing it re-creates the queue.
358
446
  */
359
447
  static PRESENTATION_ATTRIBUTES = new Set([
448
+ "closable",
360
449
  "directories",
361
450
  "drop-label",
362
451
  "drop-message",
@@ -368,10 +457,12 @@ export class ContentUploader extends BaseElement {
368
457
  super.attributeChangedCallback(name, oldValue, newValue);
369
458
  }
370
459
  connectedCallback() {
460
+ globalThis.addEventListener?.(DESIGN_SYSTEM_CHANGE_EVENT, this.handleDesignSystemChange);
371
461
  super.connectedCallback();
372
462
  this.scheduleStart();
373
463
  }
374
464
  disconnectedCallback() {
465
+ globalThis.removeEventListener?.(DESIGN_SYSTEM_CHANGE_EVENT, this.handleDesignSystemChange);
375
466
  this.teardownController();
376
467
  }
377
468
  addFiles(files) {
@@ -511,11 +602,18 @@ export class ContentUploader extends BaseElement {
511
602
  this.shadowRoot.innerHTML = `
512
603
  <style>${elementStyles}</style>
513
604
  <div part="panel">
514
- <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>
515
608
  <ul part="queue" aria-label="Upload queue"></ul>
516
- <footer part="footer" hidden>
609
+ <footer part="footer">
610
+ <button type="button" part="action close">Close</button>
517
611
  <span part="summary" role="status" aria-live="polite"></span>
518
- <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>
519
617
  </footer>
520
618
  </div>
521
619
  `;
@@ -523,6 +621,25 @@ export class ContentUploader extends BaseElement {
523
621
  this.queueEl = this.shadowRoot.querySelector('[part="queue"]');
524
622
  this.footerEl = this.shadowRoot.querySelector('[part="footer"]');
525
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
+ }
526
643
  }
527
644
  setupListeners() {
528
645
  this.dropZoneEl.addEventListener("files-selected", event => {
@@ -554,9 +671,33 @@ export class ContentUploader extends BaseElement {
554
671
  this.removeItem(itemId);
555
672
  }
556
673
  });
557
- this.footerEl.querySelector('[part="clear-completed"]')?.addEventListener("click", () => {
674
+ this.clearCompletedEl.addEventListener("click", () => {
558
675
  this.clearCompleted();
559
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
+ }
560
701
  }
561
702
  rebuildQueue(items) {
562
703
  this.queueEl.innerHTML = items
@@ -605,6 +746,7 @@ export class ContentUploader extends BaseElement {
605
746
  if (!this.queueEl) {
606
747
  return;
607
748
  }
749
+ this.renderIllustration();
608
750
  this.dropZoneEl.label = this.dropLabel;
609
751
  this.dropZoneEl.message = this.dropMessage;
610
752
  this.dropZoneEl.directories = this.directories;
@@ -625,12 +767,29 @@ export class ContentUploader extends BaseElement {
625
767
  }
626
768
  this.patchQueueProgress(items);
627
769
  const summary = summarizeUploadQueue(items);
628
- this.footerEl.hidden = summary.total === 0;
629
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;
630
787
  this.summaryEl.textContent =
631
- active > 0
632
- ? `Uploading ${String(summary.uploading)} · ${String(summary.queued)} queued`
633
- : `${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` : ""}`;
634
793
  }
635
794
  }
636
795
  DropZone.register();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unofficialbox/box-open-elements",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",