@netlify/spark-ui 1.26.0-alpha.5 → 1.26.0-alpha.7

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.
@@ -1,87 +1,95 @@
1
1
  @import url('../../../styles/animations.css');
2
2
 
3
3
  /* ----------------------------- */
4
- /* Container */
4
+ /* Container (invisible layer) */
5
5
  /* ----------------------------- */
6
+ /* The zone wraps arbitrary content and stays out of the way until a drag
7
+ enters it. Only a subtle dashed border hints that it's a drop target. */
6
8
  .n-dropzone {
7
9
  --n-dropzone-accent: var(--color-brand-primary, #0e7fe1);
8
10
  --n-dropzone-radius: var(--radius-2xl, 1rem);
9
11
 
10
12
  box-sizing: border-box;
11
13
  position: relative;
12
- display: flex;
13
- flex-direction: column;
14
- align-items: center;
15
- justify-content: center;
16
- gap: var(--space-s);
17
- width: 100%;
18
- height: 100%;
19
- min-height: 18rem;
20
- padding: var(--space-2xl, 2.5rem) var(--space-l, 1.5rem);
21
- text-align: center;
14
+ min-height: 4rem;
22
15
  color: var(--color-text-default, #14161a);
23
- background: var(--color-bg, #fff);
24
16
  border: 2px dashed var(--color-border-default, rgba(0, 0, 0, 0.15));
25
17
  border-radius: var(--n-dropzone-radius);
18
+ outline: none;
26
19
  transition:
27
20
  border-color 200ms ease-out,
28
21
  background-color 200ms ease-out,
29
22
  box-shadow 200ms ease-out;
30
23
  }
31
24
 
25
+ .n-dropzone:focus-visible {
26
+ border-color: var(--n-dropzone-accent);
27
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--n-dropzone-accent) 40%, transparent);
28
+ }
29
+
32
30
  /* Drag-over: the target glows without resizing. */
33
31
  .n-dropzone.is-active {
34
32
  border-color: var(--n-dropzone-accent);
35
- background: var(--color-bg-2, #f6f8fa);
36
33
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--n-dropzone-accent) 18%, transparent);
37
34
  }
38
35
 
36
+ /* While deploying, the dashes settle into a solid, quieter frame. */
39
37
  .n-dropzone.is-busy {
40
38
  border-style: solid;
41
39
  border-color: var(--color-border-subtle, rgba(0, 0, 0, 0.08));
42
40
  }
43
41
 
44
- /* A soft accent wash that fades in while dragging. */
45
- .n-dropzone::before {
46
- content: '';
47
- position: absolute;
48
- inset: 0;
49
- border-radius: inherit;
50
- background: radial-gradient(
51
- 120% 120% at 50% 0%,
52
- color-mix(in srgb, var(--n-dropzone-accent) 8%, transparent),
53
- transparent 60%
54
- );
55
- opacity: 0;
56
- transition: opacity 200ms ease-out;
57
- pointer-events: none;
42
+ /* ----------------------------- */
43
+ /* Wrapped content */
44
+ /* ----------------------------- */
45
+ /* Kept in flow (so the zone doesn't collapse) but faded out of the way once a
46
+ drag enters or a deploy starts, letting the overlay take over. */
47
+ .n-dropzone-content {
48
+ transition: opacity 150ms ease-out;
58
49
  }
59
50
 
60
- .n-dropzone.is-active::before {
61
- opacity: 1;
51
+ /* `.is-bare` (showStatus={false}) keeps content visible so a custom animation
52
+ can play in place of the built-in overlay. */
53
+ .n-dropzone.is-active:not(.is-bare) .n-dropzone-content,
54
+ .n-dropzone.is-busy:not(.is-bare) .n-dropzone-content {
55
+ opacity: 0;
56
+ pointer-events: none;
62
57
  }
63
58
 
64
59
  /* ----------------------------- */
65
- /* State panel (keyed → replays) */
60
+ /* Overlay (drag tint / status) */
66
61
  /* ----------------------------- */
67
- .n-dropzone-panel {
62
+ .n-dropzone-overlay {
63
+ position: absolute;
64
+ inset: 0;
65
+ z-index: 1;
68
66
  display: flex;
69
67
  flex-direction: column;
70
68
  align-items: center;
71
- gap: var(--space-xs, 0.75rem);
72
- width: 100%;
73
- animation: dropPanelIn 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
69
+ justify-content: center;
70
+ gap: var(--space-s, 1rem);
71
+ padding: var(--space-l, 1.5rem);
72
+ text-align: center;
73
+ border-radius: inherit;
74
+ /* During a hover-only drag the overlay is a translucent wash and lets the
75
+ drop event fall through to the container beneath it. */
76
+ pointer-events: none;
77
+ background: color-mix(in srgb, var(--n-dropzone-accent) 10%, transparent);
74
78
  }
75
79
 
76
- @keyframes dropPanelIn {
77
- from {
78
- opacity: 0;
79
- transform: translateY(8px) scale(0.98);
80
- }
81
- to {
82
- opacity: 1;
83
- transform: none;
84
- }
80
+ /* Deploying / error: opaque scrim that captures interaction. */
81
+ .n-dropzone.is-busy .n-dropzone-overlay,
82
+ .n-dropzone[data-state='error'] .n-dropzone-overlay {
83
+ pointer-events: auto;
84
+ background: color-mix(in srgb, var(--color-bg, #fff) 88%, transparent);
85
+ backdrop-filter: blur(2px);
86
+ }
87
+
88
+ .n-dropzone-hint {
89
+ margin: 0;
90
+ font-size: 1.0625rem;
91
+ font-weight: var(--font-weight-semibold, 600);
92
+ color: var(--n-dropzone-accent);
85
93
  }
86
94
 
87
95
  .n-dropzone-title {
@@ -103,41 +111,6 @@
103
111
  line-height: var(--line-height-text, 1.5);
104
112
  }
105
113
 
106
- /* ----------------------------- */
107
- /* Idle upload glyph */
108
- /* ----------------------------- */
109
- .n-dropzone-glyph {
110
- display: grid;
111
- place-items: center;
112
- width: 3.5rem;
113
- height: 3.5rem;
114
- color: var(--n-dropzone-accent);
115
- }
116
-
117
- .n-dropzone-glyph svg {
118
- width: 100%;
119
- height: 100%;
120
- }
121
-
122
- /* The arrow gently bobs to invite a drop; the tray stays put. */
123
- .n-dropzone-glyph-arrow {
124
- animation: dropArrowBob 2.4s ease-in-out infinite;
125
- }
126
-
127
- .n-dropzone.is-active .n-dropzone-glyph-arrow {
128
- animation-duration: 0.9s;
129
- }
130
-
131
- @keyframes dropArrowBob {
132
- 0%,
133
- 100% {
134
- transform: translateY(0);
135
- }
136
- 50% {
137
- transform: translateY(-3px);
138
- }
139
- }
140
-
141
114
  /* ----------------------------- */
142
115
  /* Loading / spinner */
143
116
  /* ----------------------------- */
@@ -222,24 +195,13 @@
222
195
  /* Warning banner */
223
196
  /* ----------------------------- */
224
197
  .n-dropzone-warning {
225
- display: flex;
226
- align-items: center;
227
- gap: var(--space-2xs, 0.5rem);
228
198
  margin: 0;
229
- margin-block-start: var(--space-xs, 0.75rem);
230
199
  padding: var(--space-2xs, 0.5rem) var(--space-s, 1rem);
231
200
  max-width: 32rem;
232
201
  border-radius: var(--radius-m, 0.5rem);
233
202
  background: var(--color-warning-bg, #fdf3d7);
234
203
  color: var(--color-warning-text, #7a5b00);
235
204
  font-size: 0.8125rem;
236
- text-align: left;
237
- }
238
-
239
- .n-dropzone-warning-icon {
240
- flex: none;
241
- width: 1rem;
242
- height: 1rem;
243
205
  }
244
206
 
245
207
  /* ----------------------------- */
@@ -247,8 +209,7 @@
247
209
  /* ----------------------------- */
248
210
  @media (prefers-reduced-motion: reduce) {
249
211
  .n-dropzone,
250
- .n-dropzone-panel,
251
- .n-dropzone-glyph-arrow,
212
+ .n-dropzone-overlay,
252
213
  .n-dropzone-spinner,
253
214
  .n-dropzone-progress-bar {
254
215
  animation: none !important;
@@ -1,29 +1,18 @@
1
- import { useRef, useState } from 'preact/hooks';
2
- import Icon from '../Icon';
3
- import {
4
- AnonymousDropClient,
5
- deployFiles,
6
- getSingleNonIndexHtmlFileName,
7
- hasMultipleHtmlFilesWithoutIndex,
8
- readDataTransfer,
9
- readFileList,
10
- type DeployProgress,
11
- type DropClient,
12
- type ProcessedFile,
13
- } from './drop-core';
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
+ import type { ComponentChildren } from 'preact';
3
+ import { readDataTransfer, readFileList, type DropClient } from './drop-core';
4
+ import { useDropDeploy, type DeployedSite, type DeployStatus, type TrackFn } from './useDropDeploy';
14
5
 
15
6
  import './DropZone.css';
16
7
 
17
- type Status =
18
- | { kind: 'idle' }
19
- | { kind: 'reading' }
20
- | { kind: 'creating' }
21
- | { kind: 'uploading'; uploaded: number; total: number }
22
- | { kind: 'processing' }
23
- | { kind: 'redirecting' }
24
- | { kind: 'error'; message: string };
8
+ /** How long after the last `dragover` event we consider the drag gone. */
9
+ const DRAG_INACTIVE_MS = 120;
10
+
11
+ const defaultClaimUrl = (siteName: string) => `https://app.netlify.com/drop/${siteName}`;
25
12
 
26
13
  interface Props {
14
+ /** Content the drop layer sits on top of. The zone is invisible until you drag over it. */
15
+ children?: ComponentChildren;
27
16
  /** Override the API base (e.g. for a staging environment). */
28
17
  apiBase?: string;
29
18
  /** Inject a custom client (e.g. for testing). Defaults to AnonymousDropClient. */
@@ -31,160 +20,189 @@ interface Props {
31
20
  /** Build the "claim this site" URL from the assigned site name (subdomain). */
32
21
  claimUrl?: (siteName: string) => string;
33
22
  /** Called once the deploy is live. */
34
- onDeploy?: (result: { url: string; token: string; siteName: string; siteId: string }) => void;
23
+ onDeploy?: (result: DeployedSite) => void;
24
+ /**
25
+ * Render the built-in status overlay (drag tint, spinner, progress bar, error).
26
+ * Set to `false` to suppress it entirely and keep your children visible, then
27
+ * drive your own feedback from the `data-state` attribute on the root element.
28
+ * Note: in this mode you're responsible for surfacing errors yourself.
29
+ */
30
+ showStatus?: boolean;
31
+ /**
32
+ * Analytics hook. Called at each tracked moment with an event name and
33
+ * properties. Stays provider-agnostic — wire it to Segment/Amplitude/GA in the
34
+ * consuming app, e.g. `onTrack={(name, props) => new Analytics().track(name, props)}`.
35
+ * Emitted events (all include `dropzone_id` when `analyticsId` is set):
36
+ * - `dropzone_files_dropped` — files dropped via drag ({ method: 'drag', file_count })
37
+ * - `dropzone_browse_opened` — file picker opened ({ method: 'keyboard' | 'click' })
38
+ * - `dropzone_deploy_succeeded` — deploy went live ({ method, site_name, file_count })
39
+ */
40
+ onTrack?: TrackFn;
41
+ /**
42
+ * Stable identifier for this instance, attached to every tracked event as
43
+ * `dropzone_id`. Set it when a page renders more than one Drop Zone so the
44
+ * events can be told apart in Amplitude.
45
+ */
46
+ analyticsId?: string;
35
47
  className?: string;
36
48
  }
37
49
 
38
- const defaultClaimUrl = (siteName: string) => `https://app.netlify.com/drop/${siteName}`;
39
-
40
- function progressPhaseToStatus(e: DeployProgress): Status {
41
- switch (e.phase) {
42
- case 'creating':
43
- return { kind: 'creating' };
44
- case 'uploading':
45
- return { kind: 'uploading', uploaded: e.uploaded ?? 0, total: e.total ?? 0 };
46
- case 'processing':
47
- case 'ready':
48
- return { kind: 'processing' };
49
- }
50
- }
51
-
52
50
  export default function DropZone({
51
+ children,
53
52
  apiBase,
54
53
  client,
55
54
  claimUrl = defaultClaimUrl,
56
55
  onDeploy,
56
+ showStatus = true,
57
+ onTrack,
58
+ analyticsId,
57
59
  className,
58
60
  }: Props) {
59
- const [status, setStatus] = useState<Status>({ kind: 'idle' });
60
- const [warning, setWarning] = useState<string | null>(null);
61
- const [active, setActive] = useState(false);
62
- const dropClient = useRef(client ?? new AnonymousDropClient(apiBase)).current;
63
-
64
- const busy = status.kind !== 'idle' && status.kind !== 'error';
65
-
66
- async function run(read: () => Promise<ProcessedFile[]>) {
67
- setWarning(null);
68
- setStatus({ kind: 'reading' });
69
- try {
70
- const files = await read();
71
- if (!files.length) {
72
- setStatus({ kind: 'error', message: 'No files found to deploy.' });
73
- return;
74
- }
75
-
76
- // Surface index.html warnings without blocking the deploy.
77
- const singleNonIndex = getSingleNonIndexHtmlFileName(files);
78
- if (singleNonIndex) {
79
- setWarning(`No index.html found — "${singleNonIndex}" won’t be served at the site root.`);
80
- } else if (hasMultipleHtmlFilesWithoutIndex(files)) {
81
- setWarning('No index.html found — your site may not have a landing page.');
82
- }
83
-
84
- const { url, token, deploy } = await deployFiles(dropClient, files, {
85
- onProgress: e => setStatus(progressPhaseToStatus(e)),
86
- });
87
-
88
- // Best-effort local persistence. Note this does NOT reach the dashboard:
89
- // localStorage is per-origin, so a token written here (e.g. www.netlify.com)
90
- // is invisible to app.netlify.com. The cross-origin handoff is the URL
91
- // fragment below; this only helps if the drop and claim share an origin.
92
- try {
93
- localStorage.setItem('drop-token', token);
94
- localStorage.setItem('dropSiteName', deploy.subdomain);
95
- } catch {
96
- /* private mode / storage disabled — non-fatal */
97
- }
98
-
99
- setStatus({ kind: 'redirecting' });
100
- onDeploy?.({ url, token, siteName: deploy.subdomain, siteId: deploy.id });
101
-
102
- // Hand the drop token to the dashboard via the URL fragment — localStorage
103
- // does not cross origins. The dashboard's /drop/:name page reads
104
- // `#drop_token=` and persists it for the claim + post-signup steps.
105
- window.location.assign(`${claimUrl(deploy.subdomain)}#drop_token=${token}`);
106
- } catch (err) {
107
- setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
108
- }
109
- }
110
-
111
- function reset() {
112
- setWarning(null);
113
- setStatus({ kind: 'idle' });
114
- }
61
+ // Tag every event with this instance's id so multiple zones on a page can be
62
+ // told apart, then hand it off to the consumer's analytics.
63
+ const track: TrackFn = (event, properties = {}) =>
64
+ onTrack?.(event, analyticsId ? { dropzone_id: analyticsId, ...properties } : properties);
65
+
66
+ const { status, warning, busy, deploy, reset } = useDropDeploy({
67
+ apiBase,
68
+ client,
69
+ claimUrl,
70
+ onDeploy,
71
+ track,
72
+ });
73
+
74
+ const { active, keepActive, endDrag } = useDragActive();
75
+
76
+ const inputRef = useRef<HTMLInputElement>(null);
77
+ const openPicker = (method: 'keyboard' | 'click') => {
78
+ track('dropzone_browse_opened', { method });
79
+ inputRef.current?.click();
80
+ };
81
+
82
+ // The overlay only paints when there's something to show — dragging, deploying,
83
+ // or an error. With `showStatus` off, the consumer drives their own feedback.
84
+ const showOverlay = showStatus && (active || busy || status.kind === 'error');
85
+ const rootClass = [
86
+ 'n-dropzone',
87
+ active && 'is-active',
88
+ busy && 'is-busy',
89
+ !showStatus && 'is-bare',
90
+ className,
91
+ ]
92
+ .filter(Boolean)
93
+ .join(' ');
115
94
 
116
95
  return (
117
96
  <div
118
- class={`n-dropzone${active ? ' is-active' : ''}${busy ? ' is-busy' : ''}${className ? ` ${className}` : ''}`}
97
+ class={rootClass}
119
98
  data-state={status.kind}
99
+ role="button"
100
+ tabIndex={busy ? -1 : 0}
101
+ aria-label="Drop a site folder or zip here to deploy it, or press Enter to browse for files"
102
+ aria-busy={busy}
120
103
  onDragOver={e => {
121
104
  if (busy) return;
122
105
  e.preventDefault();
123
- setActive(true);
124
- }}
125
- onDragLeave={e => {
126
- // Ignore drag-leave bubbling from children.
127
- if (e.currentTarget === e.target) setActive(false);
106
+ keepActive();
128
107
  }}
129
108
  onDrop={e => {
130
109
  e.preventDefault();
131
- setActive(false);
110
+ endDrag();
132
111
  if (busy) return;
133
112
  const dt = e.dataTransfer;
134
113
  if (!dt) return;
135
114
  // Capture entries synchronously — readDataTransfer reads them before
136
115
  // its first await, so the DataTransfer isn't detached out from under us.
137
- run(() => readDataTransfer(dt));
116
+ deploy(() => readDataTransfer(dt), 'drag');
117
+ }}
118
+ onClick={e => {
119
+ // Only the zone's own empty area opens the picker; clicks that land on
120
+ // children are left to those children so they stay interactive.
121
+ if (!busy && e.target === e.currentTarget) openPicker('click');
122
+ }}
123
+ onKeyDown={e => {
124
+ if (busy) return;
125
+ if (e.key === 'Enter' || e.key === ' ') {
126
+ e.preventDefault();
127
+ openPicker('keyboard');
128
+ }
138
129
  }}
139
130
  >
140
- {/* keyed so each state change remounts and replays its enter animation */}
141
- <div class="n-dropzone-panel" key={status.kind}>
142
- <StatusView status={status} onReset={reset} onFiles={run} />
143
- </div>
144
-
145
- {warning && status.kind !== 'error' && (
146
- <p class="n-dropzone-warning n-fadeIn" role="status">
147
- <Icon name="lightbulb" className="n-dropzone-warning-icon" />
148
- {warning}
149
- </p>
131
+ <div class="n-dropzone-content">{children}</div>
132
+
133
+ {/* Invisible-but-present fallback: keyboard/click opens the file picker. */}
134
+ <input
135
+ ref={inputRef}
136
+ type="file"
137
+ hidden
138
+ multiple
139
+ // @ts-expect-error non-standard directory-picker attributes
140
+ webkitdirectory=""
141
+ directory=""
142
+ onChange={e => {
143
+ const input = e.currentTarget as HTMLInputElement;
144
+ if (input.files?.length) deploy(() => readFileList(input.files!), 'browse');
145
+ input.value = '';
146
+ }}
147
+ />
148
+
149
+ {showOverlay && (
150
+ <div
151
+ class="n-dropzone-overlay n-fadeIn"
152
+ key={status.kind === 'idle' ? 'active' : status.kind}
153
+ >
154
+ <StatusView status={status} active={active} onReset={reset} />
155
+ {warning && status.kind !== 'error' && busy && (
156
+ <p class="n-dropzone-warning" role="status">
157
+ {warning}
158
+ </p>
159
+ )}
160
+ </div>
150
161
  )}
151
162
  </div>
152
163
  );
153
164
  }
154
165
 
166
+ /**
167
+ * Track whether a drag is currently over the zone. A drag has no reliable
168
+ * "cancelled" event — pressing Escape or dropping elsewhere just stops the
169
+ * stream of `dragover` events — so we stay active only while they keep arriving
170
+ * and clear shortly after they stop. This also handles drags over children,
171
+ * where `dragleave` targets a child rather than the zone.
172
+ */
173
+ function useDragActive() {
174
+ const [active, setActive] = useState(false);
175
+ const timer = useRef<ReturnType<typeof setTimeout>>();
176
+
177
+ useEffect(() => () => clearTimeout(timer.current), []);
178
+
179
+ const keepActive = () => {
180
+ setActive(true);
181
+ clearTimeout(timer.current);
182
+ timer.current = setTimeout(() => setActive(false), DRAG_INACTIVE_MS);
183
+ };
184
+
185
+ const endDrag = () => {
186
+ clearTimeout(timer.current);
187
+ setActive(false);
188
+ };
189
+
190
+ return { active, keepActive, endDrag };
191
+ }
192
+
155
193
  function StatusView({
156
194
  status,
195
+ active,
157
196
  onReset,
158
- onFiles,
159
197
  }: {
160
- status: Status;
198
+ status: DeployStatus;
199
+ active: boolean;
161
200
  onReset: () => void;
162
- onFiles: (read: () => Promise<ProcessedFile[]>) => void;
163
201
  }) {
164
202
  switch (status.kind) {
203
+ // Idle-but-active means a drag is hovering the zone.
165
204
  case 'idle':
166
- return (
167
- <>
168
- <UploadGlyph />
169
- <p class="n-dropzone-title">Drag &amp; drop your site folder or zip</p>
170
- <p class="n-dropzone-subtitle">Deploys instantly to a live URL — no account needed.</p>
171
- <label class="n-dropzone-browse">
172
- Browse files
173
- <input
174
- type="file"
175
- hidden
176
- multiple
177
- // @ts-expect-error non-standard directory-picker attributes
178
- webkitdirectory=""
179
- directory=""
180
- onChange={e => {
181
- const input = e.currentTarget as HTMLInputElement;
182
- if (input.files?.length) onFiles(() => readFileList(input.files!));
183
- }}
184
- />
185
- </label>
186
- </>
187
- );
205
+ return active ? <p class="n-dropzone-hint">Drop to deploy</p> : null;
188
206
 
189
207
  case 'reading':
190
208
  return <Spinner label="Reading files…" />;
@@ -232,27 +250,3 @@ function Spinner({ label }: { label: string }) {
232
250
  </div>
233
251
  );
234
252
  }
235
-
236
- function UploadGlyph() {
237
- return (
238
- <span class="n-dropzone-glyph" aria-hidden="true">
239
- <svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
240
- <path
241
- class="n-dropzone-glyph-tray"
242
- d="M8 30v6a4 4 0 0 0 4 4h24a4 4 0 0 0 4-4v-6"
243
- stroke="currentColor"
244
- stroke-width="3"
245
- stroke-linecap="round"
246
- />
247
- <path
248
- class="n-dropzone-glyph-arrow"
249
- d="M24 32V10m0 0-8 8m8-8 8 8"
250
- stroke="currentColor"
251
- stroke-width="3"
252
- stroke-linecap="round"
253
- stroke-linejoin="round"
254
- />
255
- </svg>
256
- </span>
257
- );
258
- }