@shipstatic/drop 0.1.6 → 0.1.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.
package/README.md CHANGED
@@ -80,11 +80,15 @@ function MyUploader() {
80
80
  {drop.isDragging ? '📂 Drop here' : '📁 Click or drag files/folders'}
81
81
  </div>
82
82
 
83
- {/* Status */}
84
- <p>{drop.statusText}</p>
83
+ {/* Status - using state machine */}
84
+ {drop.state.status && (
85
+ <p>
86
+ <strong>{drop.state.status.title}:</strong> {drop.state.status.details}
87
+ </p>
88
+ )}
85
89
 
86
90
  {/* File list */}
87
- {drop.files.map(file => (
91
+ {drop.state.files.map(file => (
88
92
  <div key={file.id}>
89
93
  {file.name} - {file.status}
90
94
  </div>
@@ -93,7 +97,7 @@ function MyUploader() {
93
97
  {/* Upload button */}
94
98
  <button
95
99
  onClick={handleUpload}
96
- disabled={drop.getValidFiles().length === 0}
100
+ disabled={drop.state.value !== 'ready'}
97
101
  >
98
102
  Upload {drop.getValidFiles().length} files
99
103
  </button>
@@ -203,19 +207,15 @@ interface DropOptions {
203
207
 
204
208
  ```typescript
205
209
  interface DropReturn {
206
- // State
207
- /** All processed files with their status */
208
- files: ProcessedFile[];
209
- /** Name of the source (file/folder/ZIP) that was dropped/selected */
210
- sourceName: string;
211
- /** Current status text */
212
- statusText: string;
210
+ // State machine
211
+ /** Current state of the drop hook */
212
+ state: DropState;
213
+
214
+ // Convenience getters (computed from state)
213
215
  /** Whether currently processing files (ZIP extraction, etc.) */
214
216
  isProcessing: boolean;
215
217
  /** Whether user is currently dragging over the dropzone */
216
218
  isDragging: boolean;
217
- /** Last validation error if any */
218
- validationError: ClientError | null;
219
219
 
220
220
  // Primary API: Prop getters for easy integration
221
221
  /** Get props to spread on dropzone element (handles drag & drop) */
@@ -253,19 +253,111 @@ interface DropReturn {
253
253
  progress?: number;
254
254
  }) => void;
255
255
  }
256
+
257
+ // State machine types
258
+ type DropStateValue =
259
+ | 'idle' // The hook is ready for files
260
+ | 'dragging' // The user is dragging files over the dropzone
261
+ | 'processing' // Files are being validated and processed
262
+ | 'ready' // Files are valid and ready for deployment
263
+ | 'error'; // An error occurred during processing
264
+
265
+ interface DropState {
266
+ value: DropStateValue;
267
+ files: ProcessedFile[];
268
+ sourceName: string;
269
+ status: DropStatus | null;
270
+ }
271
+
272
+ interface DropStatus {
273
+ title: string;
274
+ details: string;
275
+ }
276
+ ```
277
+
278
+ ## State Machine
279
+
280
+ The drop hook uses a state machine for predictable, clear state management. Instead of multiple boolean flags, you have a single `state.value` that represents exactly what's happening.
281
+
282
+ ### State Flow
283
+
284
+ ```
285
+ idle → dragging → idle (drag leave without drop)
286
+ idle → dragging → processing → ready (successful)
287
+ idle → dragging → processing → error (failed)
288
+ ready → dragging → processing → ... (new drop)
289
+ error → dragging → processing → ... (retry)
290
+ ```
291
+
292
+ ### Using the State Machine
293
+
294
+ ```tsx
295
+ function StatusIndicator({ drop }) {
296
+ const { state } = drop;
297
+
298
+ switch (state.value) {
299
+ case 'idle':
300
+ return <p>Drop files here or click to select</p>;
301
+
302
+ case 'dragging':
303
+ return <p>Drop your files now!</p>;
304
+
305
+ case 'processing':
306
+ return <p>{state.status?.details || 'Processing...'}</p>;
307
+
308
+ case 'ready':
309
+ return (
310
+ <div>
311
+ <p>✓ {state.files.length} files ready</p>
312
+ <button>Upload to Ship</button>
313
+ </div>
314
+ );
315
+
316
+ case 'error':
317
+ return (
318
+ <div>
319
+ <p>✗ {state.status?.title}</p>
320
+ <p>{state.status?.details}</p>
321
+ <button onClick={drop.clearAll}>Try Again</button>
322
+ </div>
323
+ );
324
+ }
325
+ }
256
326
  ```
257
327
 
328
+ ### Convenience Getters
329
+
330
+ For simpler use cases, boolean convenience getters are provided:
331
+
332
+ ```tsx
333
+ // These are computed from state.value (read-only projections)
334
+ drop.isProcessing // true when state.value === 'processing'
335
+ drop.isDragging // true when state.value === 'dragging'
336
+
337
+ // For error information, use the state object
338
+ drop.state.value === 'error' // Check if in error state
339
+ drop.state.status?.title // Error title
340
+ drop.state.status?.details // Error details
341
+ ```
342
+
343
+ ### Benefits
344
+
345
+ - **No impossible states** - Can't be `isProcessing=true` AND `isDragging=true`
346
+ - **Clear transitions** - State flow is explicit and predictable
347
+ - **Better TypeScript** - Discriminated unions provide type safety
348
+ - **Easier debugging** - Single source of truth for what's happening
349
+
258
350
  ## Error Handling
259
351
 
260
352
  ### Per-File Error Display
261
353
 
262
- Each file in the `files` array contains its own `status` and `statusMessage`, allowing you to display granular errors for individual files:
354
+ Each file in the `state.files` array contains its own `status` and `statusMessage`, allowing you to display granular errors for individual files:
263
355
 
264
356
  ```tsx
265
357
  function FileList({ drop }) {
266
358
  return (
267
359
  <div>
268
- {drop.files.map(file => (
360
+ {drop.state.files.map(file => (
269
361
  <div key={file.id}>
270
362
  <span>{file.path}</span>
271
363
 
@@ -282,7 +374,7 @@ function FileList({ drop }) {
282
374
  ))}
283
375
 
284
376
  {/* If validation fails, allow user to clear all and try again */}
285
- {drop.validationError && (
377
+ {drop.state.value === 'error' && (
286
378
  <button onClick={drop.clearAll}>
287
379
  Clear All & Try Again
288
380
  </button>
@@ -298,15 +390,15 @@ function FileList({ drop }) {
298
390
  - `empty_file` - File is 0 bytes
299
391
  - `ready` - File passed all validation and is ready for upload
300
392
 
301
- ### Validation Error Summary
393
+ ### Error State Summary
302
394
 
303
- The `validationError` provides a summary when any files fail validation:
395
+ When files fail validation or processing, check the error state:
304
396
 
305
397
  ```tsx
306
- {drop.validationError && (
398
+ {drop.state.value === 'error' && drop.state.status && (
307
399
  <div>
308
- <p>{drop.validationError.error}</p>
309
- <p>{drop.validationError.details}</p>
400
+ <p>{drop.state.status.title}</p>
401
+ <p>{drop.state.status.details}</p>
310
402
  </div>
311
403
  )}
312
404
  ```
@@ -336,10 +428,10 @@ Use `clearAll()` to reset and try again:
336
428
 
337
429
  ```tsx
338
430
  // If validation fails, show user which files failed
339
- {drop.validationError && (
431
+ {drop.state.value === 'error' && (
340
432
  <div>
341
433
  <p>Validation failed. Please fix the issues and try again:</p>
342
- {drop.files.map(file => (
434
+ {drop.state.files.map(file => (
343
435
  <div key={file.id}>
344
436
  {file.path}: {file.statusMessage}
345
437
  </div>
package/dist/index.cjs CHANGED
@@ -11873,28 +11873,32 @@ function useDrop(options) {
11873
11873
  onFilesReady,
11874
11874
  stripPrefix = true
11875
11875
  } = options;
11876
- const [files, setFiles] = react.useState([]);
11877
- const [sourceName, setSourceName] = react.useState("");
11878
- const [statusText, setStatusText] = react.useState("");
11879
- const [isProcessing, setIsProcessing] = react.useState(false);
11880
- const [isDragging, setIsDragging] = react.useState(false);
11881
- const [validationError, setValidationError] = react.useState(null);
11876
+ const initialState = {
11877
+ value: "idle",
11878
+ files: [],
11879
+ sourceName: "",
11880
+ status: null
11881
+ };
11882
+ const [state, setState] = react.useState(initialState);
11882
11883
  const isProcessingRef = react.useRef(false);
11883
11884
  const inputRef = react.useRef(null);
11885
+ const isProcessing = react.useMemo(() => state.value === "processing", [state.value]);
11886
+ const isDragging = react.useMemo(() => state.value === "dragging", [state.value]);
11884
11887
  const processFiles = react.useCallback(async (newFiles) => {
11885
11888
  if (isProcessingRef.current) {
11886
11889
  console.warn("File processing already in progress. Ignoring duplicate call.");
11887
11890
  return;
11888
11891
  }
11889
11892
  if (!newFiles || newFiles.length === 0) {
11890
- setStatusText("No files selected.");
11891
11893
  return;
11892
11894
  }
11893
11895
  isProcessingRef.current = true;
11894
- setIsProcessing(true);
11895
- setFiles([]);
11896
- setValidationError(null);
11897
- setStatusText("Processing files...");
11896
+ setState({
11897
+ value: "processing",
11898
+ files: [],
11899
+ sourceName: "",
11900
+ status: { title: "Processing...", details: "Validating and preparing files." }
11901
+ });
11898
11902
  try {
11899
11903
  let detectedSourceName = "";
11900
11904
  if (newFiles.length === 1 && isZipFile(newFiles[0])) {
@@ -11907,12 +11911,14 @@ function useDrop(options) {
11907
11911
  detectedSourceName = newFiles[0].name;
11908
11912
  }
11909
11913
  }
11910
- setSourceName(detectedSourceName);
11911
11914
  const allFiles = [];
11912
11915
  const shouldExtractZip = newFiles.length === 1 && isZipFile(newFiles[0]);
11913
11916
  if (shouldExtractZip) {
11914
11917
  const zipFile = newFiles[0];
11915
- setStatusText(`Extracting ${zipFile.name}...`);
11918
+ setState((prev) => ({
11919
+ ...prev,
11920
+ status: { title: "Extracting...", details: `Extracting ${zipFile.name}...` }
11921
+ }));
11916
11922
  const { files: extractedFiles, errors } = await extractZipToFiles(zipFile);
11917
11923
  if (errors.length > 0) {
11918
11924
  console.warn("ZIP extraction errors:", errors);
@@ -11928,29 +11934,44 @@ function useDrop(options) {
11928
11934
  const filePaths = allFiles.map(getFilePath);
11929
11935
  const validPaths = new Set(ship.filterJunk(filePaths));
11930
11936
  const cleanFiles = allFiles.filter((f) => validPaths.has(getFilePath(f)));
11931
- setStatusText("Processing files...");
11937
+ setState((prev) => ({
11938
+ ...prev,
11939
+ status: { title: "Processing...", details: "Processing files..." }
11940
+ }));
11932
11941
  const processedFiles = await Promise.all(
11933
11942
  cleanFiles.map((file) => createProcessedFile(file))
11934
11943
  );
11935
11944
  const finalFiles = stripPrefix ? stripCommonPrefix(processedFiles) : processedFiles;
11936
11945
  const config = await ship$1.getConfig();
11937
11946
  const validation = ship.validateFiles(finalFiles, config);
11938
- setFiles(validation.files);
11939
- setValidationError(validation.error);
11940
11947
  if (validation.error) {
11941
- setStatusText(validation.error.details);
11948
+ setState({
11949
+ value: "error",
11950
+ files: validation.files,
11951
+ sourceName: detectedSourceName,
11952
+ status: { title: validation.error.error, details: validation.error.details }
11953
+ });
11942
11954
  onValidationError?.(validation.error);
11943
11955
  } else if (validation.validFiles.length > 0) {
11944
- setStatusText(`${validation.validFiles.length} file(s) ready.`);
11956
+ setState({
11957
+ value: "ready",
11958
+ files: validation.files,
11959
+ sourceName: detectedSourceName,
11960
+ status: { title: "Ready", details: `${validation.validFiles.length} file(s) are ready.` }
11961
+ });
11945
11962
  onFilesReady?.(validation.validFiles);
11946
11963
  } else {
11947
11964
  const noValidError = {
11948
11965
  error: "No Valid Files",
11949
- details: "No files are valid for upload after processing.",
11966
+ details: "None of the provided files could be processed.",
11950
11967
  isClientError: true
11951
11968
  };
11952
- setStatusText(noValidError.details);
11953
- setValidationError(noValidError);
11969
+ setState({
11970
+ value: "error",
11971
+ files: validation.files,
11972
+ sourceName: detectedSourceName,
11973
+ status: { title: noValidError.error, details: noValidError.details }
11974
+ });
11954
11975
  onValidationError?.(noValidError);
11955
11976
  }
11956
11977
  } catch (error) {
@@ -11959,44 +11980,52 @@ function useDrop(options) {
11959
11980
  details: `Failed to process files: ${error instanceof Error ? error.message : String(error)}`,
11960
11981
  isClientError: true
11961
11982
  };
11962
- setStatusText(processingError.details);
11963
- setValidationError(processingError);
11983
+ setState((prev) => ({
11984
+ ...prev,
11985
+ value: "error",
11986
+ status: { title: processingError.error, details: processingError.details }
11987
+ }));
11964
11988
  onValidationError?.(processingError);
11965
11989
  } finally {
11966
11990
  isProcessingRef.current = false;
11967
- setIsProcessing(false);
11968
11991
  }
11969
11992
  }, [ship$1, onValidationError, onFilesReady, stripPrefix]);
11970
11993
  const clearAll = react.useCallback(() => {
11971
- setFiles([]);
11972
- setSourceName("");
11973
- setStatusText("");
11974
- setValidationError(null);
11975
- setIsDragging(false);
11994
+ setState(initialState);
11976
11995
  isProcessingRef.current = false;
11977
- setIsProcessing(false);
11978
11996
  }, []);
11979
11997
  const getValidFilesCallback = react.useCallback(() => {
11980
- return getValidFiles(files);
11981
- }, [files]);
11982
- const updateFileStatus = react.useCallback((fileId, state) => {
11983
- setFiles((prev) => prev.map(
11984
- (file) => file.id === fileId ? { ...file, ...state } : file
11985
- ));
11998
+ return getValidFiles(state.files);
11999
+ }, [state.files]);
12000
+ const updateFileStatus = react.useCallback((fileId, fileState) => {
12001
+ setState((prev) => ({
12002
+ ...prev,
12003
+ files: prev.files.map(
12004
+ (file) => file.id === fileId ? { ...file, ...fileState } : file
12005
+ )
12006
+ }));
11986
12007
  }, []);
11987
12008
  const handleDragOver = react.useCallback((e) => {
11988
12009
  e.preventDefault();
11989
- setIsDragging(true);
12010
+ setState((prev) => {
12011
+ if (prev.value === "idle" || prev.value === "ready" || prev.value === "error") {
12012
+ return { ...prev, value: "dragging" };
12013
+ }
12014
+ return prev;
12015
+ });
11990
12016
  }, []);
11991
12017
  const handleDragLeave = react.useCallback((e) => {
11992
12018
  e.preventDefault();
11993
- setIsDragging(false);
12019
+ setState((prev) => {
12020
+ if (prev.value !== "dragging") return prev;
12021
+ const nextValue = prev.files.length > 0 ? prev.status?.title === "Ready" ? "ready" : "error" : "idle";
12022
+ return { ...prev, value: nextValue };
12023
+ });
11994
12024
  }, []);
11995
12025
  const handleDrop = react.useCallback(async (e) => {
11996
12026
  e.preventDefault();
11997
- setIsDragging(false);
11998
12027
  const items = Array.from(e.dataTransfer.items);
11999
- const files2 = [];
12028
+ const files = [];
12000
12029
  let hasEntries = false;
12001
12030
  for (const item of items) {
12002
12031
  if (item.kind === "file") {
@@ -12005,23 +12034,25 @@ function useDrop(options) {
12005
12034
  hasEntries = true;
12006
12035
  await traverseFileTree(
12007
12036
  entry,
12008
- files2,
12037
+ files,
12009
12038
  entry.isDirectory ? entry.name : ""
12010
12039
  );
12011
12040
  }
12012
12041
  }
12013
12042
  }
12014
12043
  if (!hasEntries && e.dataTransfer.files.length > 0) {
12015
- files2.push(...Array.from(e.dataTransfer.files));
12044
+ files.push(...Array.from(e.dataTransfer.files));
12016
12045
  }
12017
- if (files2.length > 0) {
12018
- await processFiles(files2);
12046
+ if (files.length > 0) {
12047
+ await processFiles(files);
12048
+ } else if (state.value === "dragging") {
12049
+ setState((prev) => ({ ...prev, value: "idle" }));
12019
12050
  }
12020
- }, [processFiles]);
12051
+ }, [processFiles, state.value]);
12021
12052
  const handleInputChange = react.useCallback((e) => {
12022
- const files2 = Array.from(e.target.files || []);
12023
- if (files2.length > 0) {
12024
- processFiles(files2);
12053
+ const files = Array.from(e.target.files || []);
12054
+ if (files.length > 0) {
12055
+ processFiles(files);
12025
12056
  }
12026
12057
  }, [processFiles]);
12027
12058
  const open = react.useCallback(() => {
@@ -12042,13 +12073,11 @@ function useDrop(options) {
12042
12073
  onChange: handleInputChange
12043
12074
  }), [handleInputChange]);
12044
12075
  return {
12045
- // State
12046
- files,
12047
- sourceName,
12048
- statusText,
12076
+ // State machine
12077
+ state,
12078
+ // Convenience getters (computed from state)
12049
12079
  isProcessing,
12050
12080
  isDragging,
12051
- validationError,
12052
12081
  // Primary API: Prop getters
12053
12082
  getDropzoneProps,
12054
12083
  getInputProps,