@shipstatic/drop 0.1.6 → 0.1.8

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
@@ -4,10 +4,26 @@
4
4
 
5
5
  A focused React hook for preparing files for deployment with [@shipstatic/ship](https://github.com/shipstatic/ship). Handles ZIP extraction, path normalization, and validation - everything needed before calling `ship.deploy()`.
6
6
 
7
- **v0.1.6 Update:** Now includes built-in drag & drop support with prop getters! No need to manually implement drag & drop handlers - just spread `{...drop.getDropzoneProps()}` on your container and `{...drop.getInputProps()}` on the input element. Folder structure preservation is handled automatically.
7
+ Built-in drag & drop support with prop getters! No need to manually implement drag & drop handlers - just spread `{...drop.getDropzoneProps()}` on your container and `{...drop.getInputProps()}` on the input element. Folder structure preservation is handled automatically.
8
8
 
9
9
  **Note:** MD5 calculation is handled by Ship SDK during deployment. Drop focuses on file processing and UI state management.
10
10
 
11
+ ## Table of Contents
12
+
13
+ - [Why Headless?](#why-headless)
14
+ - [Features](#features)
15
+ - [Installation](#installation)
16
+ - [Requirements](#requirements)
17
+ - [Quick Start](#quick-start)
18
+ - [Configuration Architecture](#️-configuration-architecture)
19
+ - [Advanced Usage](#advanced-programmatic-file-picker)
20
+ - [API Reference](#api)
21
+ - [State Machine](#state-machine)
22
+ - [Error Handling](#error-handling)
23
+ - [Types](#types)
24
+ - [Ship SDK Integration](#direct-ship-sdk-integration)
25
+ - [Architecture Decisions](#architecture-decisions)
26
+
11
27
  ## Why Headless?
12
28
 
13
29
  This package provides **zero UI components** - just a React hook with built-in drag & drop functionality. You bring your own styling.
@@ -16,8 +32,7 @@ This package provides **zero UI components** - just a React hook with built-in d
16
32
  1. **Built-in drag & drop** - Proper folder support with `webkitGetAsEntry` API, all handled internally
17
33
  2. **Prop getters API** - Similar to `react-dropzone`, just spread props on your elements
18
34
  3. **Full styling control** - No imposed CSS, design system, or theming
19
- 4. **Smaller bundle** - No UI components means less bloat
20
- 5. **Ship SDK integration** - Purpose-built for Ship deployments, not a generic file upload library
35
+ 4. **Ship SDK integration** - Purpose-built for Ship deployments, not a generic file upload library
21
36
 
22
37
  **What's different from other libraries:**
23
38
  - Generic dropzone libraries don't preserve folder structure properly
@@ -34,6 +49,7 @@ This package provides **zero UI components** - just a React hook with built-in d
34
49
  - 🔒 **Path Sanitization** - Defense-in-depth protection against directory traversal attacks
35
50
  - 📁 **Folder Structure Preservation** - Proper folder paths via `webkitRelativePath`
36
51
  - 🎨 **Headless UI** - No visual components, just logic and state management
52
+ - 📘 **Full TypeScript Support** - Complete type definitions with discriminated unions for state machine
37
53
  - 🚀 **Focused Scope** - File processing and UI state only. MD5 calculation and deployment handled by Ship SDK
38
54
 
39
55
  ## Installation
@@ -44,6 +60,17 @@ npm install @shipstatic/drop
44
60
  pnpm add @shipstatic/drop
45
61
  ```
46
62
 
63
+ ## Requirements
64
+
65
+ - **React**: ^18.0.0 or ^19.0.0
66
+ - **TypeScript**: Full TypeScript support with exported types
67
+ - **Browsers**: Modern browsers with support for:
68
+ - File API (universal support)
69
+ - DataTransfer API for drag & drop (universal support)
70
+ - `webkitGetAsEntry` for folder uploads (Chrome, Edge, Safari 11.1+, Firefox 50+)
71
+
72
+ **Note on folder uploads**: The folder drag & drop feature uses the `webkitGetAsEntry` API. While widely supported, older browsers may only support file-by-file selection. ZIP extraction works universally as a fallback.
73
+
47
74
  ## Quick Start
48
75
 
49
76
  ```tsx
@@ -80,11 +107,15 @@ function MyUploader() {
80
107
  {drop.isDragging ? '📂 Drop here' : '📁 Click or drag files/folders'}
81
108
  </div>
82
109
 
83
- {/* Status */}
84
- <p>{drop.statusText}</p>
110
+ {/* Status - using state machine */}
111
+ {drop.state.status && (
112
+ <p>
113
+ <strong>{drop.state.status.title}:</strong> {drop.state.status.details}
114
+ </p>
115
+ )}
85
116
 
86
117
  {/* File list */}
87
- {drop.files.map(file => (
118
+ {drop.state.files.map(file => (
88
119
  <div key={file.id}>
89
120
  {file.name} - {file.status}
90
121
  </div>
@@ -93,7 +124,7 @@ function MyUploader() {
93
124
  {/* Upload button */}
94
125
  <button
95
126
  onClick={handleUpload}
96
- disabled={drop.getValidFiles().length === 0}
127
+ disabled={drop.state.value !== 'ready'}
97
128
  >
98
129
  Upload {drop.getValidFiles().length} files
99
130
  </button>
@@ -203,19 +234,15 @@ interface DropOptions {
203
234
 
204
235
  ```typescript
205
236
  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;
237
+ // State machine
238
+ /** Current state of the drop hook */
239
+ state: DropState;
240
+
241
+ // Convenience getters (computed from state)
213
242
  /** Whether currently processing files (ZIP extraction, etc.) */
214
243
  isProcessing: boolean;
215
244
  /** Whether user is currently dragging over the dropzone */
216
245
  isDragging: boolean;
217
- /** Last validation error if any */
218
- validationError: ClientError | null;
219
246
 
220
247
  // Primary API: Prop getters for easy integration
221
248
  /** Get props to spread on dropzone element (handles drag & drop) */
@@ -231,7 +258,7 @@ interface DropReturn {
231
258
  type: 'file';
232
259
  style: { display: string };
233
260
  multiple: boolean;
234
- webkitdirectory: string;
261
+ webkitdirectory: string; // Note: React expects string ('') for boolean attributes
235
262
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
236
263
  };
237
264
 
@@ -253,19 +280,111 @@ interface DropReturn {
253
280
  progress?: number;
254
281
  }) => void;
255
282
  }
283
+
284
+ // State machine types
285
+ type DropStateValue =
286
+ | 'idle' // The hook is ready for files
287
+ | 'dragging' // The user is dragging files over the dropzone
288
+ | 'processing' // Files are being validated and processed
289
+ | 'ready' // Files are valid and ready for deployment
290
+ | 'error'; // An error occurred during processing
291
+
292
+ interface DropState {
293
+ value: DropStateValue;
294
+ files: ProcessedFile[];
295
+ sourceName: string;
296
+ status: DropStatus | null;
297
+ }
298
+
299
+ interface DropStatus {
300
+ title: string;
301
+ details: string;
302
+ }
303
+ ```
304
+
305
+ ## State Machine
306
+
307
+ 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.
308
+
309
+ ### State Flow
310
+
311
+ ```
312
+ idle → dragging → idle (drag leave without drop)
313
+ idle → dragging → processing → ready (successful)
314
+ idle → dragging → processing → error (failed)
315
+ ready → dragging → processing → ... (new drop)
316
+ error → dragging → processing → ... (retry)
317
+ ```
318
+
319
+ ### Using the State Machine
320
+
321
+ ```tsx
322
+ function StatusIndicator({ drop }) {
323
+ const { state } = drop;
324
+
325
+ switch (state.value) {
326
+ case 'idle':
327
+ return <p>Drop files here or click to select</p>;
328
+
329
+ case 'dragging':
330
+ return <p>Drop your files now!</p>;
331
+
332
+ case 'processing':
333
+ return <p>{state.status?.details || 'Processing...'}</p>;
334
+
335
+ case 'ready':
336
+ return (
337
+ <div>
338
+ <p>✓ {state.files.length} files ready</p>
339
+ <button>Upload to Ship</button>
340
+ </div>
341
+ );
342
+
343
+ case 'error':
344
+ return (
345
+ <div>
346
+ <p>✗ {state.status?.title}</p>
347
+ <p>{state.status?.details}</p>
348
+ <button onClick={drop.clearAll}>Try Again</button>
349
+ </div>
350
+ );
351
+ }
352
+ }
353
+ ```
354
+
355
+ ### Convenience Getters
356
+
357
+ For simpler use cases, boolean convenience getters are provided:
358
+
359
+ ```tsx
360
+ // These are computed from state.value (read-only projections)
361
+ drop.isProcessing // true when state.value === 'processing'
362
+ drop.isDragging // true when state.value === 'dragging'
363
+
364
+ // For error information, use the state object
365
+ drop.state.value === 'error' // Check if in error state
366
+ drop.state.status?.title // Error title
367
+ drop.state.status?.details // Error details
256
368
  ```
257
369
 
370
+ ### Benefits
371
+
372
+ - **No impossible states** - Can't be `isProcessing=true` AND `isDragging=true`
373
+ - **Clear transitions** - State flow is explicit and predictable
374
+ - **Better TypeScript** - Discriminated unions provide type safety
375
+ - **Easier debugging** - Single source of truth for what's happening
376
+
258
377
  ## Error Handling
259
378
 
260
379
  ### Per-File Error Display
261
380
 
262
- Each file in the `files` array contains its own `status` and `statusMessage`, allowing you to display granular errors for individual files:
381
+ Each file in the `state.files` array contains its own `status` and `statusMessage`, allowing you to display granular errors for individual files:
263
382
 
264
383
  ```tsx
265
384
  function FileList({ drop }) {
266
385
  return (
267
386
  <div>
268
- {drop.files.map(file => (
387
+ {drop.state.files.map(file => (
269
388
  <div key={file.id}>
270
389
  <span>{file.path}</span>
271
390
 
@@ -282,7 +401,7 @@ function FileList({ drop }) {
282
401
  ))}
283
402
 
284
403
  {/* If validation fails, allow user to clear all and try again */}
285
- {drop.validationError && (
404
+ {drop.state.value === 'error' && (
286
405
  <button onClick={drop.clearAll}>
287
406
  Clear All & Try Again
288
407
  </button>
@@ -298,15 +417,15 @@ function FileList({ drop }) {
298
417
  - `empty_file` - File is 0 bytes
299
418
  - `ready` - File passed all validation and is ready for upload
300
419
 
301
- ### Validation Error Summary
420
+ ### Error State Summary
302
421
 
303
- The `validationError` provides a summary when any files fail validation:
422
+ When files fail validation or processing, check the error state:
304
423
 
305
424
  ```tsx
306
- {drop.validationError && (
425
+ {drop.state.value === 'error' && drop.state.status && (
307
426
  <div>
308
- <p>{drop.validationError.error}</p>
309
- <p>{drop.validationError.details}</p>
427
+ <p>{drop.state.status.title}</p>
428
+ <p>{drop.state.status.details}</p>
310
429
  </div>
311
430
  )}
312
431
  ```
@@ -336,10 +455,10 @@ Use `clearAll()` to reset and try again:
336
455
 
337
456
  ```tsx
338
457
  // If validation fails, show user which files failed
339
- {drop.validationError && (
458
+ {drop.state.value === 'error' && (
340
459
  <div>
341
460
  <p>Validation failed. Please fix the issues and try again:</p>
342
- {drop.files.map(file => (
461
+ {drop.state.files.map(file => (
343
462
  <div key={file.id}>
344
463
  {file.path}: {file.statusMessage}
345
464
  </div>
package/dist/index.cjs CHANGED
@@ -37,9 +37,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
37
37
 
38
38
  // node_modules/.pnpm/jszip@3.10.1/node_modules/jszip/dist/jszip.min.js
39
39
  var require_jszip_min = __commonJS({
40
- "node_modules/.pnpm/jszip@3.10.1/node_modules/jszip/dist/jszip.min.js"(exports, module) {
40
+ "node_modules/.pnpm/jszip@3.10.1/node_modules/jszip/dist/jszip.min.js"(exports$1, module) {
41
41
  !(function(e) {
42
- if ("object" == typeof exports && "undefined" != typeof module) module.exports = e();
42
+ if ("object" == typeof exports$1 && "undefined" != typeof module) module.exports = e();
43
43
  else if ("function" == typeof define && define.amd) define([], e);
44
44
  else {
45
45
  ("undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof self ? self : this).JSZip = e();
@@ -2349,7 +2349,7 @@ var require_jszip_min = __commonJS({
2349
2349
 
2350
2350
  // node_modules/.pnpm/mime-db@1.54.0/node_modules/mime-db/db.json
2351
2351
  var require_db = __commonJS({
2352
- "node_modules/.pnpm/mime-db@1.54.0/node_modules/mime-db/db.json"(exports, module) {
2352
+ "node_modules/.pnpm/mime-db@1.54.0/node_modules/mime-db/db.json"(exports$1, module) {
2353
2353
  module.exports = {
2354
2354
  "application/1d-interleaved-parityfec": {
2355
2355
  source: "iana"
@@ -11697,7 +11697,7 @@ var require_db = __commonJS({
11697
11697
 
11698
11698
  // node_modules/.pnpm/mime-db@1.54.0/node_modules/mime-db/index.js
11699
11699
  var require_mime_db = __commonJS({
11700
- "node_modules/.pnpm/mime-db@1.54.0/node_modules/mime-db/index.js"(exports, module) {
11700
+ "node_modules/.pnpm/mime-db@1.54.0/node_modules/mime-db/index.js"(exports$1, module) {
11701
11701
  module.exports = require_db();
11702
11702
  }
11703
11703
  });
@@ -11835,35 +11835,39 @@ function stripCommonPrefix(files) {
11835
11835
  }));
11836
11836
  }
11837
11837
  async function traverseFileTree(entry, files, currentPath = "") {
11838
- if (entry.isFile) {
11839
- const file = await new Promise((resolve, reject) => {
11840
- entry.file(resolve, reject);
11841
- });
11842
- const relativePath = currentPath ? `${currentPath}/${file.name}` : file.name;
11843
- Object.defineProperty(file, "webkitRelativePath", {
11844
- value: relativePath,
11845
- writable: false
11846
- });
11847
- files.push(file);
11848
- } else if (entry.isDirectory) {
11849
- const dirReader = entry.createReader();
11850
- let allEntries = [];
11851
- const readEntriesBatch = async () => {
11852
- const batch = await new Promise(
11853
- (resolve, reject) => {
11854
- dirReader.readEntries(resolve, reject);
11838
+ try {
11839
+ if (entry.isFile) {
11840
+ const file = await new Promise((resolve, reject) => {
11841
+ entry.file(resolve, reject);
11842
+ });
11843
+ const relativePath = currentPath ? `${currentPath}/${file.name}` : file.name;
11844
+ Object.defineProperty(file, "webkitRelativePath", {
11845
+ value: relativePath,
11846
+ writable: false
11847
+ });
11848
+ files.push(file);
11849
+ } else if (entry.isDirectory) {
11850
+ const dirReader = entry.createReader();
11851
+ let allEntries = [];
11852
+ const readEntriesBatch = async () => {
11853
+ const batch = await new Promise(
11854
+ (resolve, reject) => {
11855
+ dirReader.readEntries(resolve, reject);
11856
+ }
11857
+ );
11858
+ if (batch.length > 0) {
11859
+ allEntries = allEntries.concat(batch);
11860
+ await readEntriesBatch();
11855
11861
  }
11856
- );
11857
- if (batch.length > 0) {
11858
- allEntries = allEntries.concat(batch);
11859
- await readEntriesBatch();
11862
+ };
11863
+ await readEntriesBatch();
11864
+ for (const childEntry of allEntries) {
11865
+ const entryPath = childEntry.isDirectory ? currentPath ? `${currentPath}/${childEntry.name}` : childEntry.name : currentPath;
11866
+ await traverseFileTree(childEntry, files, entryPath);
11860
11867
  }
11861
- };
11862
- await readEntriesBatch();
11863
- for (const childEntry of allEntries) {
11864
- const entryPath = childEntry.isDirectory ? currentPath ? `${currentPath}/${childEntry.name}` : childEntry.name : currentPath;
11865
- await traverseFileTree(childEntry, files, entryPath);
11866
11868
  }
11869
+ } catch (error) {
11870
+ console.warn(`Error traversing file tree for entry ${entry.name}:`, error);
11867
11871
  }
11868
11872
  }
11869
11873
  function useDrop(options) {
@@ -11873,28 +11877,32 @@ function useDrop(options) {
11873
11877
  onFilesReady,
11874
11878
  stripPrefix = true
11875
11879
  } = 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);
11880
+ const initialState = {
11881
+ value: "idle",
11882
+ files: [],
11883
+ sourceName: "",
11884
+ status: null
11885
+ };
11886
+ const [state, setState] = react.useState(initialState);
11882
11887
  const isProcessingRef = react.useRef(false);
11883
11888
  const inputRef = react.useRef(null);
11889
+ const isProcessing = react.useMemo(() => state.value === "processing", [state.value]);
11890
+ const isDragging = react.useMemo(() => state.value === "dragging", [state.value]);
11884
11891
  const processFiles = react.useCallback(async (newFiles) => {
11885
11892
  if (isProcessingRef.current) {
11886
11893
  console.warn("File processing already in progress. Ignoring duplicate call.");
11887
11894
  return;
11888
11895
  }
11889
11896
  if (!newFiles || newFiles.length === 0) {
11890
- setStatusText("No files selected.");
11891
11897
  return;
11892
11898
  }
11893
11899
  isProcessingRef.current = true;
11894
- setIsProcessing(true);
11895
- setFiles([]);
11896
- setValidationError(null);
11897
- setStatusText("Processing files...");
11900
+ setState({
11901
+ value: "processing",
11902
+ files: [],
11903
+ sourceName: "",
11904
+ status: { title: "Processing...", details: "Validating and preparing files." }
11905
+ });
11898
11906
  try {
11899
11907
  let detectedSourceName = "";
11900
11908
  if (newFiles.length === 1 && isZipFile(newFiles[0])) {
@@ -11907,12 +11915,14 @@ function useDrop(options) {
11907
11915
  detectedSourceName = newFiles[0].name;
11908
11916
  }
11909
11917
  }
11910
- setSourceName(detectedSourceName);
11911
11918
  const allFiles = [];
11912
11919
  const shouldExtractZip = newFiles.length === 1 && isZipFile(newFiles[0]);
11913
11920
  if (shouldExtractZip) {
11914
11921
  const zipFile = newFiles[0];
11915
- setStatusText(`Extracting ${zipFile.name}...`);
11922
+ setState((prev) => ({
11923
+ ...prev,
11924
+ status: { title: "Extracting...", details: `Extracting ${zipFile.name}...` }
11925
+ }));
11916
11926
  const { files: extractedFiles, errors } = await extractZipToFiles(zipFile);
11917
11927
  if (errors.length > 0) {
11918
11928
  console.warn("ZIP extraction errors:", errors);
@@ -11928,29 +11938,44 @@ function useDrop(options) {
11928
11938
  const filePaths = allFiles.map(getFilePath);
11929
11939
  const validPaths = new Set(ship.filterJunk(filePaths));
11930
11940
  const cleanFiles = allFiles.filter((f) => validPaths.has(getFilePath(f)));
11931
- setStatusText("Processing files...");
11941
+ setState((prev) => ({
11942
+ ...prev,
11943
+ status: { title: "Processing...", details: "Processing files..." }
11944
+ }));
11932
11945
  const processedFiles = await Promise.all(
11933
11946
  cleanFiles.map((file) => createProcessedFile(file))
11934
11947
  );
11935
11948
  const finalFiles = stripPrefix ? stripCommonPrefix(processedFiles) : processedFiles;
11936
11949
  const config = await ship$1.getConfig();
11937
11950
  const validation = ship.validateFiles(finalFiles, config);
11938
- setFiles(validation.files);
11939
- setValidationError(validation.error);
11940
11951
  if (validation.error) {
11941
- setStatusText(validation.error.details);
11952
+ setState({
11953
+ value: "error",
11954
+ files: validation.files,
11955
+ sourceName: detectedSourceName,
11956
+ status: { title: validation.error.error, details: validation.error.details }
11957
+ });
11942
11958
  onValidationError?.(validation.error);
11943
11959
  } else if (validation.validFiles.length > 0) {
11944
- setStatusText(`${validation.validFiles.length} file(s) ready.`);
11960
+ setState({
11961
+ value: "ready",
11962
+ files: validation.files,
11963
+ sourceName: detectedSourceName,
11964
+ status: { title: "Ready", details: `${validation.validFiles.length} file(s) are ready.` }
11965
+ });
11945
11966
  onFilesReady?.(validation.validFiles);
11946
11967
  } else {
11947
11968
  const noValidError = {
11948
11969
  error: "No Valid Files",
11949
- details: "No files are valid for upload after processing.",
11970
+ details: "None of the provided files could be processed.",
11950
11971
  isClientError: true
11951
11972
  };
11952
- setStatusText(noValidError.details);
11953
- setValidationError(noValidError);
11973
+ setState({
11974
+ value: "error",
11975
+ files: validation.files,
11976
+ sourceName: detectedSourceName,
11977
+ status: { title: noValidError.error, details: noValidError.details }
11978
+ });
11954
11979
  onValidationError?.(noValidError);
11955
11980
  }
11956
11981
  } catch (error) {
@@ -11959,69 +11984,92 @@ function useDrop(options) {
11959
11984
  details: `Failed to process files: ${error instanceof Error ? error.message : String(error)}`,
11960
11985
  isClientError: true
11961
11986
  };
11962
- setStatusText(processingError.details);
11963
- setValidationError(processingError);
11987
+ setState((prev) => ({
11988
+ ...prev,
11989
+ value: "error",
11990
+ status: { title: processingError.error, details: processingError.details }
11991
+ }));
11964
11992
  onValidationError?.(processingError);
11965
11993
  } finally {
11966
11994
  isProcessingRef.current = false;
11967
- setIsProcessing(false);
11968
11995
  }
11969
11996
  }, [ship$1, onValidationError, onFilesReady, stripPrefix]);
11970
11997
  const clearAll = react.useCallback(() => {
11971
- setFiles([]);
11972
- setSourceName("");
11973
- setStatusText("");
11974
- setValidationError(null);
11975
- setIsDragging(false);
11998
+ setState(initialState);
11976
11999
  isProcessingRef.current = false;
11977
- setIsProcessing(false);
11978
12000
  }, []);
11979
12001
  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
- ));
12002
+ return getValidFiles(state.files);
12003
+ }, [state.files]);
12004
+ const updateFileStatus = react.useCallback((fileId, fileState) => {
12005
+ setState((prev) => ({
12006
+ ...prev,
12007
+ files: prev.files.map(
12008
+ (file) => file.id === fileId ? { ...file, ...fileState } : file
12009
+ )
12010
+ }));
11986
12011
  }, []);
11987
12012
  const handleDragOver = react.useCallback((e) => {
11988
12013
  e.preventDefault();
11989
- setIsDragging(true);
12014
+ setState((prev) => {
12015
+ if (prev.value === "idle" || prev.value === "ready" || prev.value === "error") {
12016
+ return { ...prev, value: "dragging" };
12017
+ }
12018
+ return prev;
12019
+ });
11990
12020
  }, []);
11991
12021
  const handleDragLeave = react.useCallback((e) => {
11992
12022
  e.preventDefault();
11993
- setIsDragging(false);
12023
+ setState((prev) => {
12024
+ if (prev.value !== "dragging") return prev;
12025
+ const nextValue = prev.files.length > 0 ? prev.status?.title === "Ready" ? "ready" : "error" : "idle";
12026
+ return { ...prev, value: nextValue };
12027
+ });
11994
12028
  }, []);
11995
12029
  const handleDrop = react.useCallback(async (e) => {
11996
12030
  e.preventDefault();
11997
- setIsDragging(false);
11998
12031
  const items = Array.from(e.dataTransfer.items);
11999
- const files2 = [];
12032
+ const files = [];
12000
12033
  let hasEntries = false;
12001
12034
  for (const item of items) {
12002
12035
  if (item.kind === "file") {
12003
- const entry = item.webkitGetAsEntry?.();
12004
- if (entry) {
12005
- hasEntries = true;
12006
- await traverseFileTree(
12007
- entry,
12008
- files2,
12009
- entry.isDirectory ? entry.name : ""
12010
- );
12036
+ try {
12037
+ const entry = item.webkitGetAsEntry?.();
12038
+ if (entry) {
12039
+ hasEntries = true;
12040
+ await traverseFileTree(
12041
+ entry,
12042
+ files,
12043
+ entry.isDirectory ? entry.name : ""
12044
+ );
12045
+ } else {
12046
+ const file = item.getAsFile();
12047
+ if (file) {
12048
+ files.push(file);
12049
+ }
12050
+ }
12051
+ } catch (error) {
12052
+ console.warn("Error processing drop item:", error);
12053
+ const file = item.getAsFile();
12054
+ if (file) {
12055
+ files.push(file);
12056
+ }
12011
12057
  }
12012
12058
  }
12013
12059
  }
12014
12060
  if (!hasEntries && e.dataTransfer.files.length > 0) {
12015
- files2.push(...Array.from(e.dataTransfer.files));
12061
+ files.push(...Array.from(e.dataTransfer.files));
12016
12062
  }
12017
- if (files2.length > 0) {
12018
- await processFiles(files2);
12063
+ if (files.length > 0) {
12064
+ await processFiles(files);
12065
+ } else if (state.value === "dragging") {
12066
+ setState((prev) => ({ ...prev, value: "idle" }));
12019
12067
  }
12020
- }, [processFiles]);
12068
+ }, [processFiles, state.value]);
12021
12069
  const handleInputChange = react.useCallback((e) => {
12022
- const files2 = Array.from(e.target.files || []);
12023
- if (files2.length > 0) {
12024
- processFiles(files2);
12070
+ const files = Array.from(e.target.files || []);
12071
+ if (files.length > 0) {
12072
+ processFiles(files);
12025
12073
  }
12026
12074
  }, [processFiles]);
12027
12075
  const open = react.useCallback(() => {
@@ -12042,13 +12090,11 @@ function useDrop(options) {
12042
12090
  onChange: handleInputChange
12043
12091
  }), [handleInputChange]);
12044
12092
  return {
12045
- // State
12046
- files,
12047
- sourceName,
12048
- statusText,
12093
+ // State machine
12094
+ state,
12095
+ // Convenience getters (computed from state)
12049
12096
  isProcessing,
12050
12097
  isDragging,
12051
- validationError,
12052
12098
  // Primary API: Prop getters
12053
12099
  getDropzoneProps,
12054
12100
  getInputProps,