@netlify/spark-ui 1.28.0 → 1.30.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,11 +46,18 @@ interface Props {
46
46
  * properties. Stays provider-agnostic — wire it to Segment/Amplitude/GA in the
47
47
  * consuming app, e.g. `onTrack={(name, props) => new Analytics().track(name, props)}`.
48
48
  * Emitted events (all include `dropzone_id` when `analyticsId` is set):
49
- * - `dropzone_files_dropped` — files dropped via drag ({ method: 'drag', file_count })
49
+ * - `dropzone_files_dropped` — files dropped via drag ({ method: 'drag', file_count, file_types })
50
50
  * - `dropzone_browse_opened` — file picker opened ({ method: 'keyboard' | 'click' })
51
- * - `dropzone_build_required` — drop needs a build; handed off to signup ({ method, reason, file_count })
52
- * - `dropzone_deploy_succeeded` — deploy went live ({ method, site_name, file_count })
53
- * - `dropzone_deploy_failed` — deploy errored out ({ method, reason, file_count? })
51
+ * - `dropzone_build_required` — drop needs a build; handed off to signup ({ method, reason, file_count, file_types })
52
+ * - `dropzone_deploy_succeeded` — deploy went live ({ method, site_name, file_count, file_types })
53
+ * - `dropzone_deploy_failed` — deploy errored out ({ method, reason, file_count?, file_types })
54
+ *
55
+ * `file_types` is the sorted, de-duplicated list of MIME types in the
56
+ * selection, with `application/octet-stream` standing in wherever the browser
57
+ * reports none (a dragged folder, an `.exe`). It is omitted when the browser
58
+ * exposes no files at all. Note `dropzone_files_dropped` only fires for drag —
59
+ * a file-picker selection reports its types on the outcome events, as
60
+ * `dropzone_browse_opened` fires before any file exists.
54
61
  */
55
62
  onTrack?: TrackFn;
56
63
  /**
@@ -1,72 +1,73 @@
1
1
  import { u as e } from "../../../chunks/jsxRuntime.module.Bzuv3cXw.js";
2
- import { A as D, d as E, y as I } from "../../../chunks/hooks.module.DRcxknIz.js";
3
- import { readFileList as P, readDataTransfer as U } from "./drop-core/readFiles.js";
2
+ import { A, d as I, y as P } from "../../../chunks/hooks.module.DRcxknIz.js";
3
+ import { readFileList as U, readDataTransfer as j } from "./drop-core/readFiles.js";
4
+ import { getFileTypes as D } from "./drop-core/fileTypes.js";
4
5
  import "./drop-core/detectBuild.js";
5
- import { useDropDeploy as j } from "./useDropDeploy.js";
6
- import { Fragment as k } from "preact";
7
- import '../../../assets/DropZone.css';const B = 120, F = (n) => `https://app.netlify.com/drop/${n}`, M = "https://app.netlify.com/signup?next=/drop&utm_campaign=drop";
8
- function H({
6
+ import { useDropDeploy as B } from "./useDropDeploy.js";
7
+ import { Fragment as w } from "preact";
8
+ import '../../../assets/DropZone.css';const M = 120, N = (n) => `https://app.netlify.com/drop/${n}`, O = "https://app.netlify.com/signup?next=/drop&utm_campaign=drop";
9
+ function W({
9
10
  children: n,
10
11
  apiBase: l,
11
12
  client: i,
12
- claimUrl: s = F,
13
- buildSignupUrl: u = M,
14
- onBuildRequired: w,
15
- onDeploy: A,
16
- onError: C,
13
+ claimUrl: s = N,
14
+ buildSignupUrl: u = O,
15
+ onBuildRequired: T,
16
+ onDeploy: C,
17
+ onError: _,
17
18
  showStatus: f = !0,
18
- onTrack: c,
19
+ onTrack: d,
19
20
  analyticsId: g,
20
- className: T
21
+ className: S
21
22
  }) {
22
- const h = (r, t = {}) => c == null ? void 0 : c(r, g ? { dropzone_id: g, ...t } : t), { status: a, warning: m, busy: o, deploy: v, reset: _ } = j({
23
+ const m = (r, t = {}) => d == null ? void 0 : d(r, g ? { dropzone_id: g, ...t } : t), { status: a, warning: h, busy: o, deploy: v, reset: R } = B({
23
24
  apiBase: l,
24
25
  client: i,
25
26
  claimUrl: s,
26
27
  buildSignupUrl: u,
27
- onBuildRequired: w,
28
- onDeploy: A,
29
- onError: C,
30
- track: h
31
- }), { active: p, keepActive: S, endDrag: R } = N(), b = D(null), y = (r) => {
28
+ onBuildRequired: T,
29
+ onDeploy: C,
30
+ onError: _,
31
+ track: m
32
+ }), { active: p, keepActive: $, endDrag: x } = V(), y = A(null), b = (r) => {
32
33
  var t;
33
- h("dropzone_browse_opened", { method: r }), (t = b.current) == null || t.click();
34
- }, $ = f && (p || o || a.kind === "error"), x = [
34
+ m("dropzone_browse_opened", { method: r }), (t = y.current) == null || t.click();
35
+ }, E = f && (p || o || a.kind === "error"), F = [
35
36
  "n-dropzone",
36
37
  p && "is-active",
37
38
  o && "is-busy",
38
39
  !f && "is-bare",
39
- T
40
+ S
40
41
  ].filter(Boolean).join(" ");
41
42
  return /* @__PURE__ */ e(
42
43
  "div",
43
44
  {
44
- class: x,
45
+ class: F,
45
46
  "data-state": a.kind,
46
47
  role: "button",
47
48
  tabIndex: o ? -1 : 0,
48
49
  "aria-label": "Drop a site folder or zip here to deploy it, or press Enter to browse for files",
49
50
  "aria-busy": o,
50
51
  onDragOver: (r) => {
51
- o || (r.preventDefault(), S());
52
+ o || (r.preventDefault(), $());
52
53
  },
53
54
  onDrop: (r) => {
54
- if (r.preventDefault(), R(), o) return;
55
+ if (r.preventDefault(), x(), o) return;
55
56
  const t = r.dataTransfer;
56
- t && v(() => U(t), "drag");
57
+ t && v(() => j(t), "drag", D(t.files));
57
58
  },
58
59
  onClick: (r) => {
59
- !o && r.target === r.currentTarget && y("click");
60
+ !o && r.target === r.currentTarget && b("click");
60
61
  },
61
62
  onKeyDown: (r) => {
62
- o || (r.key === "Enter" || r.key === " ") && (r.preventDefault(), y("keyboard"));
63
+ o || (r.key === "Enter" || r.key === " ") && (r.preventDefault(), b("keyboard"));
63
64
  },
64
65
  children: [
65
66
  /* @__PURE__ */ e("div", { class: "n-dropzone-content", children: n }),
66
67
  /* @__PURE__ */ e(
67
68
  "input",
68
69
  {
69
- ref: b,
70
+ ref: y,
70
71
  type: "file",
71
72
  hidden: !0,
72
73
  multiple: !0,
@@ -75,17 +76,21 @@ function H({
75
76
  onChange: (r) => {
76
77
  var z;
77
78
  const t = r.currentTarget;
78
- (z = t.files) != null && z.length && v(() => P(t.files), "browse"), t.value = "";
79
+ if ((z = t.files) != null && z.length) {
80
+ const k = t.files;
81
+ v(() => U(k), "browse", D(k));
82
+ }
83
+ t.value = "";
79
84
  }
80
85
  }
81
86
  ),
82
- $ && /* @__PURE__ */ e(
87
+ E && /* @__PURE__ */ e(
83
88
  "div",
84
89
  {
85
90
  class: "n-dropzone-overlay n-fadeIn",
86
91
  children: [
87
- /* @__PURE__ */ e(O, { status: a, active: p, onReset: _ }),
88
- m && a.kind !== "error" && o && /* @__PURE__ */ e("p", { class: "n-dropzone-warning", role: "status", children: m })
92
+ /* @__PURE__ */ e(G, { status: a, active: p, onReset: R }),
93
+ h && a.kind !== "error" && o && /* @__PURE__ */ e("p", { class: "n-dropzone-warning", role: "status", children: h })
89
94
  ]
90
95
  },
91
96
  a.kind === "idle" ? "active" : a.kind
@@ -94,15 +99,15 @@ function H({
94
99
  }
95
100
  );
96
101
  }
97
- function N() {
98
- const [n, l] = E(!1), i = D();
99
- return I(() => () => clearTimeout(i.current), []), { active: n, keepActive: () => {
100
- l(!0), clearTimeout(i.current), i.current = setTimeout(() => l(!1), B);
102
+ function V() {
103
+ const [n, l] = I(!1), i = A();
104
+ return P(() => () => clearTimeout(i.current), []), { active: n, keepActive: () => {
105
+ l(!0), clearTimeout(i.current), i.current = setTimeout(() => l(!1), M);
101
106
  }, endDrag: () => {
102
107
  clearTimeout(i.current), l(!1);
103
108
  } };
104
109
  }
105
- function O({
110
+ function G({
106
111
  status: n,
107
112
  active: l,
108
113
  onReset: i
@@ -112,34 +117,34 @@ function O({
112
117
  case "idle":
113
118
  return l ? /* @__PURE__ */ e("p", { class: "n-dropzone-hint", children: "Drop to deploy" }) : null;
114
119
  case "reading":
115
- return /* @__PURE__ */ e(d, { label: "Reading files…" });
120
+ return /* @__PURE__ */ e(c, { label: "Reading files…" });
116
121
  case "creating":
117
- return /* @__PURE__ */ e(d, { label: "Creating deploy…" });
122
+ return /* @__PURE__ */ e(c, { label: "Creating deploy…" });
118
123
  case "processing":
119
- return /* @__PURE__ */ e(d, { label: "Processing — almost live…" });
124
+ return /* @__PURE__ */ e(c, { label: "Processing — almost live…" });
120
125
  case "redirecting":
121
126
  return /* @__PURE__ */ e(
122
- d,
127
+ c,
123
128
  {
124
129
  label: n.target === "signup" ? "This project needs a build — taking you to Netlify…" : "Site’s live — taking you to claim it…"
125
130
  }
126
131
  );
127
132
  case "uploading": {
128
133
  const s = n.total ? Math.round(n.uploaded / n.total * 100) : 0;
129
- return /* @__PURE__ */ e(k, { children: [
130
- /* @__PURE__ */ e(d, { label: `Uploading ${n.uploaded}/${n.total} files…` }),
134
+ return /* @__PURE__ */ e(w, { children: [
135
+ /* @__PURE__ */ e(c, { label: `Uploading ${n.uploaded}/${n.total} files…` }),
131
136
  /* @__PURE__ */ e("div", { class: "n-dropzone-progress", role: "progressbar", "aria-valuenow": s, children: /* @__PURE__ */ e("div", { class: "n-dropzone-progress-bar", style: { width: `${s}%` } }) })
132
137
  ] });
133
138
  }
134
139
  case "error":
135
- return /* @__PURE__ */ e(k, { children: [
140
+ return /* @__PURE__ */ e(w, { children: [
136
141
  /* @__PURE__ */ e("p", { class: "n-dropzone-title n-dropzone-title--error", children: "Something went wrong" }),
137
142
  /* @__PURE__ */ e("p", { class: "n-dropzone-subtitle", role: "alert", children: n.message }),
138
143
  /* @__PURE__ */ e("button", { type: "button", class: "n-dropzone-browse", onClick: i, children: "Try again" })
139
144
  ] });
140
145
  }
141
146
  }
142
- function d({ label: n }) {
147
+ function c({ label: n }) {
143
148
  return /* @__PURE__ */ e("div", { class: "n-dropzone-loading", children: [
144
149
  /* @__PURE__ */ e("span", { class: "n-dropzone-spinner", "aria-hidden": "true" }),
145
150
  /* @__PURE__ */ e("p", { class: "n-dropzone-subtitle", "aria-live": "polite", children: [
@@ -150,5 +155,5 @@ function d({ label: n }) {
150
155
  ] });
151
156
  }
152
157
  export {
153
- H as default
158
+ W as default
154
159
  };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Distinct MIME types across a drop or file-picker selection, sorted so the same
3
+ * selection always yields the same array.
4
+ *
5
+ * Read from the raw `File.type` at the drop boundary rather than from
6
+ * `ProcessedFile`s: the MIME type only exists on the original `File`, and reading
7
+ * can throw before any `ProcessedFile` does (an unsupported single file) — the
8
+ * very case the file type is most worth knowing about.
9
+ *
10
+ * Browsers leave `File.type` empty for anything they don't recognise — a dragged
11
+ * folder, an `.exe`, an extensionless file — so those collapse to
12
+ * `application/octet-stream` and the property never carries an empty string.
13
+ *
14
+ * Call this synchronously inside the drop handler: a `DataTransfer` detaches once
15
+ * the event turn ends.
16
+ */
17
+ export declare function getFileTypes(files: FileList | null | undefined): string[];
@@ -0,0 +1,11 @@
1
+ const n = "application/octet-stream";
2
+ function e(t) {
3
+ if (!(t != null && t.length)) return [];
4
+ const r = /* @__PURE__ */ new Set();
5
+ for (const o of Array.from(t))
6
+ r.add(o.type || n);
7
+ return Array.from(r).sort().slice(0, 20);
8
+ }
9
+ export {
10
+ e as getFileTypes
11
+ };
@@ -1,6 +1,7 @@
1
1
  export * from './types';
2
2
  export * from './digest';
3
3
  export * from './readFiles';
4
+ export * from './fileTypes';
4
5
  export * from './detectBuild';
5
6
  export * from './client';
6
7
  export * from './deploy';
@@ -1,19 +1,21 @@
1
1
  import { getDigest as o } from "./digest.js";
2
- import { getSingleNonIndexHtmlFileName as n, hasMultipleHtmlFilesWithoutIndex as l, readDataTransfer as i, readFileList as m, renameSingleNonIndexHtmlToIndex as p } from "./readFiles.js";
3
- import { detectBuild as x } from "./detectBuild.js";
4
- import { AnonymousDropClient as s } from "./client.js";
5
- import { deployFiles as u } from "./deploy.js";
6
- import { UnsupportedDropError as D, humanizeDropError as F } from "./errors.js";
2
+ import { getSingleNonIndexHtmlFileName as l, hasMultipleHtmlFilesWithoutIndex as n, readDataTransfer as p, readFileList as i, renameSingleNonIndexHtmlToIndex as m } from "./readFiles.js";
3
+ import { getFileTypes as d } from "./fileTypes.js";
4
+ import { detectBuild as s } from "./detectBuild.js";
5
+ import { AnonymousDropClient as g } from "./client.js";
6
+ import { deployFiles as D } from "./deploy.js";
7
+ import { UnsupportedDropError as I, humanizeDropError as h } from "./errors.js";
7
8
  export {
8
- s as AnonymousDropClient,
9
- D as UnsupportedDropError,
10
- u as deployFiles,
11
- x as detectBuild,
9
+ g as AnonymousDropClient,
10
+ I as UnsupportedDropError,
11
+ D as deployFiles,
12
+ s as detectBuild,
12
13
  o as getDigest,
13
- n as getSingleNonIndexHtmlFileName,
14
- l as hasMultipleHtmlFilesWithoutIndex,
15
- F as humanizeDropError,
16
- i as readDataTransfer,
17
- m as readFileList,
18
- p as renameSingleNonIndexHtmlToIndex
14
+ d as getFileTypes,
15
+ l as getSingleNonIndexHtmlFileName,
16
+ n as hasMultipleHtmlFilesWithoutIndex,
17
+ h as humanizeDropError,
18
+ p as readDataTransfer,
19
+ i as readFileList,
20
+ m as renameSingleNonIndexHtmlToIndex
19
21
  };
@@ -1,3 +1,3 @@
1
1
  export { default as DropZone } from './DropZone';
2
- export { AnonymousDropClient, deployFiles, detectBuild, readDataTransfer, readFileList, } from './drop-core';
2
+ export { AnonymousDropClient, deployFiles, detectBuild, getFileTypes, readDataTransfer, readFileList, } from './drop-core';
3
3
  export type { BuildDetection, BuildReason, DropClient, DropResponse, Digest, DeployResult, DeployProgress, DeployPhase, ProcessedFile, } from './drop-core';
@@ -1,13 +1,15 @@
1
1
  import { default as r } from "./DropZone.js";
2
- import { readDataTransfer as p, readFileList as a } from "./drop-core/readFiles.js";
2
+ import { readDataTransfer as p, readFileList as f } from "./drop-core/readFiles.js";
3
+ import { getFileTypes as l } from "./drop-core/fileTypes.js";
3
4
  import { detectBuild as d } from "./drop-core/detectBuild.js";
4
- import { AnonymousDropClient as m } from "./drop-core/client.js";
5
+ import { AnonymousDropClient as s } from "./drop-core/client.js";
5
6
  import { deployFiles as n } from "./drop-core/deploy.js";
6
7
  export {
7
- m as AnonymousDropClient,
8
+ s as AnonymousDropClient,
8
9
  r as DropZone,
9
10
  n as deployFiles,
10
11
  d as detectBuild,
12
+ l as getFileTypes,
11
13
  p as readDataTransfer,
12
- a as readFileList
14
+ f as readFileList
13
15
  };
@@ -55,7 +55,7 @@ export declare function useDropDeploy({ apiBase, client, claimUrl, buildSignupUr
55
55
  status: DeployStatus;
56
56
  warning: string | null;
57
57
  busy: boolean;
58
- deploy: (read: () => Promise<ProcessedFile[]>, source: DeploySource) => Promise<void>;
58
+ deploy: (read: () => Promise<ProcessedFile[]>, source: DeploySource, fileTypes: string[]) => Promise<void>;
59
59
  reset: () => void;
60
60
  };
61
61
  export {};
@@ -1,10 +1,10 @@
1
- import { d as h, A as F } from "../../../chunks/hooks.module.DRcxknIz.js";
2
- import { renameSingleNonIndexHtmlToIndex as H, getSingleNonIndexHtmlFileName as W, hasMultipleHtmlFilesWithoutIndex as $ } from "./drop-core/readFiles.js";
3
- import { detectBuild as v } from "./drop-core/detectBuild.js";
4
- import { AnonymousDropClient as A } from "./drop-core/client.js";
5
- import { deployFiles as C } from "./drop-core/deploy.js";
6
- import { humanizeDropError as T } from "./drop-core/errors.js";
7
- function q(n) {
1
+ import { d as y, A as W } from "../../../chunks/hooks.module.DRcxknIz.js";
2
+ import { renameSingleNonIndexHtmlToIndex as $, getSingleNonIndexHtmlFileName as v, hasMultipleHtmlFilesWithoutIndex as A } from "./drop-core/readFiles.js";
3
+ import { detectBuild as C } from "./drop-core/detectBuild.js";
4
+ import { AnonymousDropClient as P } from "./drop-core/client.js";
5
+ import { deployFiles as q } from "./drop-core/deploy.js";
6
+ import { humanizeDropError as M } from "./drop-core/errors.js";
7
+ function T(n) {
8
8
  switch (n.phase) {
9
9
  case "creating":
10
10
  return { kind: "creating" };
@@ -15,75 +15,81 @@ function q(n) {
15
15
  return { kind: "processing" };
16
16
  }
17
17
  }
18
- function M(n) {
19
- const l = W(n);
20
- return l ? `No index.html found — "${l}" won’t be served at the site root.` : $(n) ? "No index.html found — your site may not have a landing page." : null;
18
+ function j(n) {
19
+ const l = v(n);
20
+ return l ? `No index.html found — "${l}" won’t be served at the site root.` : A(n) ? "No index.html found — your site may not have a landing page." : null;
21
21
  }
22
- function K({
22
+ function O({
23
23
  apiBase: n,
24
24
  client: l,
25
- claimUrl: _,
26
- buildSignupUrl: k,
27
- onBuildRequired: g,
28
- onDeploy: a,
29
- onError: u,
25
+ claimUrl: k,
26
+ buildSignupUrl: w,
27
+ onBuildRequired: f,
28
+ onDeploy: u,
29
+ onError: c,
30
30
  track: s
31
31
  }) {
32
- const [c, i] = h({ kind: "idle" }), [y, m] = h(null), w = F(l ?? new A(n)).current, N = c.kind !== "idle" && c.kind !== "error";
33
- async function b(I, o) {
34
- m(null), i({ kind: "reading" });
35
- const f = (e, t, d) => {
36
- i({ kind: "error", message: e }), s("dropzone_deploy_failed", { method: o, reason: t, ...d }), u == null || u({ message: e, reason: t });
37
- }, S = (e, t) => {
32
+ const [m, o] = y({ kind: "idle" }), [N, p] = y(null), b = W(l ?? new P(n)).current, x = m.kind !== "idle" && m.kind !== "error";
33
+ async function I(z, i, h) {
34
+ p(null), o({ kind: "reading" });
35
+ const a = h.length ? { file_types: h } : {}, _ = (e, t, d) => {
36
+ o({ kind: "error", message: e }), s("dropzone_deploy_failed", { method: i, reason: t, ...a, ...d }), c == null || c({ message: e, reason: t });
37
+ }, F = (e, t) => {
38
38
  const d = e instanceof Error ? e.message : String(e);
39
- f(T(e), d, t);
39
+ _(M(e), d, t);
40
40
  };
41
41
  try {
42
- const e = H(await I());
42
+ const e = $(await z());
43
43
  if (!e.length) {
44
- f("No files found to deploy.", "No files found to deploy.", { file_count: 0 });
44
+ _("No files found to deploy.", "No files found to deploy.", { file_count: 0 });
45
45
  return;
46
46
  }
47
- o === "drag" && s("dropzone_files_dropped", { method: o, file_count: e.length });
48
- const t = v(e);
47
+ i === "drag" && s("dropzone_files_dropped", {
48
+ method: i,
49
+ file_count: e.length,
50
+ ...a
51
+ });
52
+ const t = C(e);
49
53
  if (t.buildRequired) {
50
54
  if (s("dropzone_build_required", {
51
- method: o,
55
+ method: i,
52
56
  reason: t.reason,
53
- file_count: e.length
54
- }), g) {
55
- i({ kind: "idle" }), g(t);
57
+ file_count: e.length,
58
+ ...a
59
+ }), f) {
60
+ o({ kind: "idle" }), f(t);
56
61
  return;
57
62
  }
58
- i({ kind: "redirecting", target: "signup" }), window.location.assign(k);
63
+ o({ kind: "redirecting", target: "signup" }), window.location.assign(w);
59
64
  return;
60
65
  }
61
- m(M(e));
66
+ p(j(e));
62
67
  const {
63
68
  url: d,
64
- token: p,
69
+ token: g,
65
70
  deploy: r
66
- } = await C(w, e, {
67
- onProgress: (z) => i(q(z))
71
+ } = await q(b, e, {
72
+ onProgress: (H) => o(T(H))
68
73
  });
69
74
  try {
70
- localStorage.setItem("drop-token", p), localStorage.setItem("dropSiteName", r.subdomain);
75
+ localStorage.setItem("drop-token", g), localStorage.setItem("dropSiteName", r.subdomain);
71
76
  } catch {
72
77
  }
73
- i({ kind: "redirecting", target: "claim" }), s("dropzone_deploy_succeeded", {
74
- method: o,
78
+ o({ kind: "redirecting", target: "claim" }), s("dropzone_deploy_succeeded", {
79
+ method: i,
75
80
  site_name: r.subdomain,
76
- file_count: e.length
77
- }), a == null || a({ url: d, token: p, siteName: r.subdomain, siteId: r.id }), window.location.assign(`${_(r.subdomain)}#drop_token=${p}`);
81
+ file_count: e.length,
82
+ ...a
83
+ }), u == null || u({ url: d, token: g, siteName: r.subdomain, siteId: r.id }), window.location.assign(`${k(r.subdomain)}#drop_token=${g}`);
78
84
  } catch (e) {
79
- S(e);
85
+ F(e);
80
86
  }
81
87
  }
82
- function x() {
83
- m(null), i({ kind: "idle" });
88
+ function S() {
89
+ p(null), o({ kind: "idle" });
84
90
  }
85
- return { status: c, warning: y, busy: N, deploy: b, reset: x };
91
+ return { status: m, warning: N, busy: x, deploy: I, reset: S };
86
92
  }
87
93
  export {
88
- K as useDropDeploy
94
+ O as useDropDeploy
89
95
  };
@@ -1,2 +1,3 @@
1
1
  export { pluralize } from './pluralize';
2
2
  export { kebabCase } from './kebab';
3
+ export { useClickOutside } from './useClickOutside';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@netlify/spark-ui",
3
3
  "description": "Assets, design tokens, components, and utilities",
4
- "version": "1.28.0",
4
+ "version": "1.30.0",
5
5
  "type": "module",
6
6
  "main": "dist/components/preact/index.js",
7
7
  "types": "dist/components/preact/index.d.ts",
@@ -1,6 +1,12 @@
1
1
  import { useEffect, useRef, useState } from 'preact/hooks';
2
2
  import type { ComponentChildren } from 'preact';
3
- import { readDataTransfer, readFileList, type BuildDetection, type DropClient } from './drop-core';
3
+ import {
4
+ getFileTypes,
5
+ readDataTransfer,
6
+ readFileList,
7
+ type BuildDetection,
8
+ type DropClient,
9
+ } from './drop-core';
4
10
  import {
5
11
  useDropDeploy,
6
12
  type DeployedSite,
@@ -68,11 +74,18 @@ interface Props {
68
74
  * properties. Stays provider-agnostic — wire it to Segment/Amplitude/GA in the
69
75
  * consuming app, e.g. `onTrack={(name, props) => new Analytics().track(name, props)}`.
70
76
  * Emitted events (all include `dropzone_id` when `analyticsId` is set):
71
- * - `dropzone_files_dropped` — files dropped via drag ({ method: 'drag', file_count })
77
+ * - `dropzone_files_dropped` — files dropped via drag ({ method: 'drag', file_count, file_types })
72
78
  * - `dropzone_browse_opened` — file picker opened ({ method: 'keyboard' | 'click' })
73
- * - `dropzone_build_required` — drop needs a build; handed off to signup ({ method, reason, file_count })
74
- * - `dropzone_deploy_succeeded` — deploy went live ({ method, site_name, file_count })
75
- * - `dropzone_deploy_failed` — deploy errored out ({ method, reason, file_count? })
79
+ * - `dropzone_build_required` — drop needs a build; handed off to signup ({ method, reason, file_count, file_types })
80
+ * - `dropzone_deploy_succeeded` — deploy went live ({ method, site_name, file_count, file_types })
81
+ * - `dropzone_deploy_failed` — deploy errored out ({ method, reason, file_count?, file_types })
82
+ *
83
+ * `file_types` is the sorted, de-duplicated list of MIME types in the
84
+ * selection, with `application/octet-stream` standing in wherever the browser
85
+ * reports none (a dragged folder, an `.exe`). It is omitted when the browser
86
+ * exposes no files at all. Note `dropzone_files_dropped` only fires for drag —
87
+ * a file-picker selection reports its types on the outcome events, as
88
+ * `dropzone_browse_opened` fires before any file exists.
76
89
  */
77
90
  onTrack?: TrackFn;
78
91
  /**
@@ -156,7 +169,8 @@ export default function DropZone({
156
169
  if (!dt) return;
157
170
  // Capture entries synchronously — readDataTransfer reads them before
158
171
  // its first await, so the DataTransfer isn't detached out from under us.
159
- deploy(() => readDataTransfer(dt), 'drag');
172
+ // Same reason the MIME types are read here and not inside `deploy`.
173
+ deploy(() => readDataTransfer(dt), 'drag', getFileTypes(dt.files));
160
174
  }}
161
175
  onClick={e => {
162
176
  // Only the zone's own empty area opens the picker; clicks that land on
@@ -184,7 +198,11 @@ export default function DropZone({
184
198
  directory=""
185
199
  onChange={e => {
186
200
  const input = e.currentTarget as HTMLInputElement;
187
- if (input.files?.length) deploy(() => readFileList(input.files!), 'browse');
201
+ // Read the types before `input.value = ''` below clears the FileList.
202
+ if (input.files?.length) {
203
+ const files = input.files;
204
+ deploy(() => readFileList(files), 'browse', getFileTypes(files));
205
+ }
188
206
  input.value = '';
189
207
  }}
190
208
  />
@@ -0,0 +1,34 @@
1
+ /** What we report when the browser gives us no MIME type at all. */
2
+ const UNKNOWN_TYPE = 'application/octet-stream';
3
+
4
+ /**
5
+ * Distinct types kept per event. Deduplication alone keeps this small for a real
6
+ * site (a handful of types across hundreds of files); the cap is a backstop so an
7
+ * unusual folder can't push an oversized property into the analytics payload.
8
+ */
9
+ const MAX_TYPES = 20;
10
+
11
+ /**
12
+ * Distinct MIME types across a drop or file-picker selection, sorted so the same
13
+ * selection always yields the same array.
14
+ *
15
+ * Read from the raw `File.type` at the drop boundary rather than from
16
+ * `ProcessedFile`s: the MIME type only exists on the original `File`, and reading
17
+ * can throw before any `ProcessedFile` does (an unsupported single file) — the
18
+ * very case the file type is most worth knowing about.
19
+ *
20
+ * Browsers leave `File.type` empty for anything they don't recognise — a dragged
21
+ * folder, an `.exe`, an extensionless file — so those collapse to
22
+ * `application/octet-stream` and the property never carries an empty string.
23
+ *
24
+ * Call this synchronously inside the drop handler: a `DataTransfer` detaches once
25
+ * the event turn ends.
26
+ */
27
+ export function getFileTypes(files: FileList | null | undefined): string[] {
28
+ if (!files?.length) return [];
29
+ const types = new Set<string>();
30
+ for (const file of Array.from(files)) {
31
+ types.add(file.type || UNKNOWN_TYPE);
32
+ }
33
+ return Array.from(types).sort().slice(0, MAX_TYPES);
34
+ }
@@ -1,6 +1,7 @@
1
1
  export * from './types';
2
2
  export * from './digest';
3
3
  export * from './readFiles';
4
+ export * from './fileTypes';
4
5
  export * from './detectBuild';
5
6
  export * from './client';
6
7
  export * from './deploy';
@@ -3,6 +3,7 @@ export {
3
3
  AnonymousDropClient,
4
4
  deployFiles,
5
5
  detectBuild,
6
+ getFileTypes,
6
7
  readDataTransfer,
7
8
  readFileList,
8
9
  } from './drop-core';
@@ -103,22 +103,36 @@ export function useDropDeploy({
103
103
 
104
104
  const busy = status.kind !== 'idle' && status.kind !== 'error';
105
105
 
106
- async function deploy(read: () => Promise<ProcessedFile[]>, source: DeploySource) {
106
+ /**
107
+ * `fileTypes` is the MIME types of the selection, read at the drop boundary
108
+ * (see `getFileTypes`) and passed in rather than derived here — it has to be
109
+ * captured before `read()` runs so it survives a read that throws. Omitted
110
+ * from events when empty rather than sent as `[]`.
111
+ */
112
+ async function deploy(
113
+ read: () => Promise<ProcessedFile[]>,
114
+ source: DeploySource,
115
+ fileTypes: string[]
116
+ ) {
107
117
  setWarning(null);
108
118
  setStatus({ kind: 'reading' });
109
119
 
120
+ const typeProps = fileTypes.length ? { file_types: fileTypes } : {};
121
+
110
122
  // Surface a failure: move to the error state and emit the matching
111
123
  // analytics event. Centralised so every failure path (empty selection, a
112
124
  // read/deploy throw) reports uniformly — the mirror of the
113
125
  // `dropzone_deploy_succeeded` event on the happy path. `file_count` is only
114
- // included where the file set is known (it isn't if `read()` itself threw).
126
+ // included where the file set is known (it isn't if `read()` itself threw);
127
+ // `file_types` rides along on every failure precisely because it *is* known
128
+ // in that case — a rejected single file is the main thing we want to see.
115
129
  // `reason` goes to analytics as-is (raw, e.g. "drop failed: 429"); `message`
116
130
  // is what the user sees, which for a caught error is humanized separately.
117
131
  // `onError` gets both so a consumer (e.g. with `showStatus={false}`) can show
118
132
  // the humanized message while keeping the raw reason for their own logging.
119
133
  const fail = (message: string, reason: string, extra?: Record<string, unknown>) => {
120
134
  setStatus({ kind: 'error', message });
121
- track('dropzone_deploy_failed', { method: source, reason, ...extra });
135
+ track('dropzone_deploy_failed', { method: source, reason, ...typeProps, ...extra });
122
136
  onError?.({ message, reason });
123
137
  };
124
138
 
@@ -141,7 +155,11 @@ export function useDropDeploy({
141
155
  // A drop is the "user provided files" signal; the browse path is already
142
156
  // tracked when the picker opens.
143
157
  if (source === 'drag') {
144
- track('dropzone_files_dropped', { method: source, file_count: files.length });
158
+ track('dropzone_files_dropped', {
159
+ method: source,
160
+ file_count: files.length,
161
+ ...typeProps,
162
+ });
145
163
  }
146
164
 
147
165
  // Build-required projects can't deploy through the anonymous drop API —
@@ -155,6 +173,7 @@ export function useDropDeploy({
155
173
  method: source,
156
174
  reason: build.reason,
157
175
  file_count: files.length,
176
+ ...typeProps,
158
177
  });
159
178
  if (onBuildRequired) {
160
179
  // The consumer owns the hand-off now; drop back to idle so the zone
@@ -197,6 +216,7 @@ export function useDropDeploy({
197
216
  method: source,
198
217
  site_name: result.subdomain,
199
218
  file_count: files.length,
219
+ ...typeProps,
200
220
  });
201
221
  onDeploy?.({ url, token, siteName: result.subdomain, siteId: result.id });
202
222
 
@@ -1,2 +1,3 @@
1
1
  export { pluralize } from './pluralize';
2
2
  export { kebabCase } from './kebab';
3
+ export { useClickOutside } from './useClickOutside';