@gentleduck/upload 0.2.6 → 0.3.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.
Files changed (136) hide show
  1. package/README.md +170 -193
  2. package/dist/core/index.d.ts +2 -3
  3. package/dist/core/index.js +1 -1
  4. package/dist/core-BVgloTiP.js +10 -0
  5. package/dist/index-DCqyBVcx.d.ts +1441 -0
  6. package/dist/index-DsZHPmJm.d.ts +225 -0
  7. package/dist/index-KROXtLQh.d.ts +281 -0
  8. package/dist/index.d.ts +4 -5
  9. package/dist/index.js +1 -1
  10. package/dist/react/index.d.ts +2 -51
  11. package/dist/react/index.js +1 -1
  12. package/dist/react-euPrDv24.js +1 -0
  13. package/dist/strategies/index.d.ts +2 -56
  14. package/dist/strategies/index.js +1 -1
  15. package/dist/strategies-CUF0s68j.js +1 -0
  16. package/package.json +3 -2
  17. package/src/{__test__ → __tests__}/emitter.test.ts +4 -4
  18. package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
  19. package/src/{__test__ → __tests__}/guards.test.ts +15 -5
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
  23. package/src/__tests__/sanitize-filename.test.ts +109 -0
  24. package/src/__tests__/sleep.test.ts +34 -0
  25. package/src/__tests__/static-analysis.test.ts +145 -0
  26. package/src/{__test__ → __tests__}/store-libs.test.ts +57 -3
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/{__test__ → __tests__}/transport.test.ts +5 -5
  29. package/src/core/__tests__/errors.test.ts +152 -0
  30. package/src/core/client.ts +3 -0
  31. package/src/core/contracts/contracts.types.ts +335 -141
  32. package/src/core/contracts/index.ts +3 -8
  33. package/src/core/contracts/transport/index.ts +3 -2
  34. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  35. package/src/core/contracts/transport/transport.libs.ts +7 -8
  36. package/src/core/contracts/transport/transport.ts +91 -83
  37. package/src/core/contracts/transport/transport.types.ts +71 -35
  38. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  39. package/src/core/engine/engine.libs.ts +35 -0
  40. package/src/core/engine/engine.types.ts +367 -241
  41. package/src/core/engine/index.ts +3 -8
  42. package/src/core/engine/reducer.ts +89 -43
  43. package/src/core/engine/store/handlers/add-file.ts +39 -29
  44. package/src/core/engine/store/handlers/cancel.ts +13 -8
  45. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  46. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  47. package/src/core/engine/store/handlers/finalize.ts +30 -13
  48. package/src/core/engine/store/handlers/index.ts +7 -7
  49. package/src/core/engine/store/handlers/pause.ts +8 -6
  50. package/src/core/engine/store/handlers/run-upload.ts +49 -34
  51. package/src/core/engine/store/index.ts +27 -21
  52. package/src/core/engine/store/store.dispatch.ts +10 -8
  53. package/src/core/engine/store/store.libs.ts +184 -41
  54. package/src/core/engine/store/store.runtime.ts +45 -36
  55. package/src/core/engine/store/store.schedule.ts +27 -21
  56. package/src/core/engine/store/store.types.ts +177 -227
  57. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  58. package/src/core/engine/validation/file.ts +7 -3
  59. package/src/core/engine/validation/index.ts +1 -0
  60. package/src/core/engine/validation/intent.ts +49 -27
  61. package/src/core/engine/validation/list.ts +14 -15
  62. package/src/core/engine/validation/mime.ts +39 -0
  63. package/src/core/errors.ts +271 -0
  64. package/src/core/index.ts +28 -5
  65. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  66. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  67. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  68. package/src/core/persistence/adapters.local.ts +9 -3
  69. package/src/core/persistence/adapters.memory.ts +35 -13
  70. package/src/core/persistence/index.ts +5 -5
  71. package/src/core/persistence/persistence.ts +73 -60
  72. package/src/core/persistence/persistence.types.ts +140 -60
  73. package/src/core/utils/__tests__/guards.test.ts +93 -0
  74. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  75. package/src/core/utils/async.ts +7 -2
  76. package/src/core/utils/constants.ts +3 -9
  77. package/src/core/utils/emitter/emitter.ts +7 -24
  78. package/src/core/utils/emitter/emitter.types.ts +33 -16
  79. package/src/core/utils/emitter/index.ts +2 -74
  80. package/src/core/utils/fingerprint.ts +16 -5
  81. package/src/core/utils/guards.ts +91 -1
  82. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  83. package/src/core/utils/hash/index.ts +126 -0
  84. package/src/core/utils/hash/sha256.ts +179 -0
  85. package/src/core/utils/id.ts +33 -1
  86. package/src/core/utils/index.ts +15 -6
  87. package/src/core/utils/mime-sniff.ts +119 -0
  88. package/src/core/utils/sanitize-filename.ts +85 -0
  89. package/src/core/utils/url-safety.ts +156 -0
  90. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  91. package/src/react/index.ts +5 -2
  92. package/src/react/upload-provider.tsx +68 -53
  93. package/src/react/uploader.types.ts +58 -61
  94. package/src/react/use-dropzone.tsx +226 -0
  95. package/src/react/use-uploader.tsx +98 -91
  96. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  97. package/src/strategies/_shared/retry.ts +79 -0
  98. package/src/strategies/index.ts +9 -15
  99. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  100. package/src/strategies/multipart/index.ts +206 -108
  101. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  102. package/src/strategies/post/index.ts +115 -33
  103. package/src/strategies/put/__tests__/put.test.ts +132 -0
  104. package/src/strategies/put/index.ts +139 -0
  105. package/src/strategies/registry/index.ts +12 -9
  106. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  107. package/src/strategies/tus/index.ts +211 -0
  108. package/dist/async-DxRWu4le.js +0 -1
  109. package/dist/core-CMkrP9DX.js +0 -2
  110. package/dist/index-C38Jmizu.d.ts +0 -1005
  111. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  112. package/src/__test__/effect-pool.test.ts +0 -5
  113. package/src/__test__/id.test.ts +0 -14
  114. package/src/__test__/store-integration.test.ts +0 -5
  115. package/src/core/client/client.ts +0 -50
  116. package/src/core/client/client.types.ts +0 -184
  117. package/src/core/client/config.types.ts +0 -77
  118. package/src/core/client/index.ts +0 -4
  119. package/src/core/client/plugins.types.ts +0 -46
  120. package/src/core/client/retry.types.ts +0 -6
  121. package/src/core/client/uploadClient.ts +0 -19
  122. package/src/core/contracts/backendApi.ts +0 -62
  123. package/src/core/contracts/cursor.types.ts +0 -23
  124. package/src/core/contracts/errors.types.ts +0 -67
  125. package/src/core/contracts/fingerprint.types.ts +0 -18
  126. package/src/core/contracts/intent.types.ts +0 -39
  127. package/src/core/contracts/result.types.ts +0 -11
  128. package/src/core/contracts/strategy.types.ts +0 -45
  129. package/src/core/engine/commands.types.ts +0 -17
  130. package/src/core/engine/event-map.types.ts +0 -45
  131. package/src/core/engine/internal-events.types.ts +0 -202
  132. package/src/core/engine/outcome.types.ts +0 -9
  133. package/src/core/engine/progress.types.ts +0 -15
  134. package/src/core/engine/store/handlers.ts +0 -819
  135. package/src/core/engine/validation.ts +0 -216
  136. /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
@@ -1,70 +1,85 @@
1
- 'use client'
1
+ import { createContext, useContext, useEffect } from 'react'
2
+ import type { Contracts } from '../core'
3
+ import type { Store } from '../core/engine/store'
4
+ import type { Uploader } from './uploader.types'
2
5
 
3
- import { createContext, type ReactNode, useContext } from 'react'
4
- import { type CursorMap, type IntentMap, isRecord, type UploadResultBase } from '../core'
5
- import type { UploadStore } from '../core/engine/store'
6
+ const UploadContext = createContext<Store.UploadStore<any, any, any, any> | null>(null)
6
7
 
7
- // React Context can't carry generics, so the runtime value is `unknown`; the
8
- // hooks below re-narrow with `isUploadStore`.
9
- const UploadContext = createContext<unknown | null>(null)
10
-
11
- /** Provides an upload store to descendants. */
8
+ /**
9
+ * Context provider that distributes the upload store across the React component hierarchy.
10
+ *
11
+ * Wrap this provider around the top of your layout to enable child hooks like `useUploader`
12
+ * to access upload status.
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * const store = createUploadStore({ ... });
17
+ *
18
+ * function App() {
19
+ * return (
20
+ * <UploadProvider store={store}>
21
+ * <UploadForm />
22
+ * </UploadProvider>
23
+ * );
24
+ * }
25
+ * ```
26
+ * @author wildduck2 <https://github.com/wildduck2>
27
+ */
12
28
  export function UploadProvider<
13
- M extends IntentMap,
14
- C extends CursorMap<M>,
29
+ M extends Contracts.Intent.Map,
30
+ C extends Contracts.Cursor.Map<M>,
15
31
  P extends string,
16
- R extends UploadResultBase = UploadResultBase,
17
- >({ store, children }: { store: UploadStore<M, C, P, R>; children: ReactNode }): React.JSX.Element {
32
+ R extends Contracts.Result.Base = Contracts.Result.Base,
33
+ >({ store, children }: Uploader.ProviderProps<M, C, P, R>) {
34
+ // Safe cleanup: destroy the store runner instance if the provider is unmounted.
35
+ useEffect(() => {
36
+ return () => {
37
+ if ('destroy' in store && typeof store.destroy === 'function') {
38
+ ;(store as unknown as { destroy: () => void }).destroy()
39
+ }
40
+ }
41
+ }, [store])
42
+
18
43
  return <UploadContext.Provider value={store}>{children}</UploadContext.Provider>
19
44
  }
20
45
 
21
46
  /**
22
- * Read the upload store from context if provided. Returns `null` outside a
23
- * provider so higher-level hooks can keep their context read unconditional
24
- * while still accepting an explicit store argument.
47
+ * Returns the active upload store instance from context.
48
+ * Throws a runtime exception if called outside an `<UploadProvider>`.
49
+ * @author wildduck2 <https://github.com/wildduck2>
25
50
  */
26
- export function useOptionalUploadStore<
27
- M extends IntentMap,
28
- C extends CursorMap<M>,
29
- P extends string,
30
- R extends UploadResultBase = UploadResultBase,
31
- >(): UploadStore<M, C, P, R> | null {
32
- const store = useContext(UploadContext)
33
- if (store === null) {
34
- return null
35
- }
36
-
37
- if (!isUploadStore<M, C, P, R>(store)) {
38
- throw new Error('UploadProvider received an invalid store value')
39
- }
40
-
41
- return store
42
- }
43
-
44
- /** Read the upload store from context. Throws when called outside `UploadProvider`. */
45
51
  export function useUploadStore<
46
- M extends IntentMap,
47
- C extends CursorMap<M>,
52
+ M extends Contracts.Intent.Map,
53
+ C extends Contracts.Cursor.Map<M>,
48
54
  P extends string,
49
- R extends UploadResultBase = UploadResultBase,
50
- >(): UploadStore<M, C, P, R> {
51
- const store = useOptionalUploadStore<M, C, P, R>()
52
- if (!store) {
53
- throw new Error('useUploadStore must be used within UploadProvider')
55
+ R extends Contracts.Result.Base = Contracts.Result.Base,
56
+ >(): Store.UploadStore<M, C, P, R> {
57
+ const context = useContext(UploadContext)
58
+ if (!context) {
59
+ throw new Error('useUploadStore must be used within an <UploadProvider>')
54
60
  }
55
- return store
61
+ return context as Store.UploadStore<M, C, P, R>
56
62
  }
57
63
 
58
- function isUploadStore<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
59
- value: unknown,
60
- ): value is UploadStore<M, C, P, R> {
61
- if (!isRecord(value)) return false
64
+ /**
65
+ * Runtime type guard: returns `true` when `value` exposes the upload-store
66
+ * surface (`getSnapshot`, `subscribe`, `dispatch`, `on`, `off`, `waitFor`).
67
+ * @author wildduck2 <https://github.com/wildduck2>
68
+ */
69
+ export function isUploadStore<
70
+ M extends Contracts.Intent.Map,
71
+ C extends Contracts.Cursor.Map<M>,
72
+ P extends string,
73
+ R extends Contracts.Result.Base = Contracts.Result.Base,
74
+ >(value: unknown): value is Store.UploadStore<M, C, P, R> {
75
+ if (typeof value !== 'object' || value === null) return false
76
+ const val = value as Record<string, unknown>
62
77
  return (
63
- typeof value.getSnapshot === 'function' &&
64
- typeof value.subscribe === 'function' &&
65
- typeof value.dispatch === 'function' &&
66
- typeof value.on === 'function' &&
67
- typeof value.off === 'function' &&
68
- typeof value.waitFor === 'function'
78
+ typeof val['getSnapshot'] === 'function' &&
79
+ typeof val['subscribe'] === 'function' &&
80
+ typeof val['dispatch'] === 'function' &&
81
+ typeof val['on'] === 'function' &&
82
+ typeof val['off'] === 'function' &&
83
+ typeof val['waitFor'] === 'function'
69
84
  )
70
85
  }
@@ -1,83 +1,80 @@
1
+ import type React from 'react'
1
2
  import type { Contracts, Engine } from '../core'
2
3
  import type { Store } from '../core/engine/store'
3
4
 
4
5
  /**
5
- * React `useUploader` surface types.
6
- *
7
- * Houses the public typings consumed by the React hooks so generated
8
- * `.d.ts` files do not leak internal generic names.
9
- *
6
+ * Public typings and namespaces consumed by React hook components.
10
7
  * @author wildduck2 <https://github.com/wildduck2>
11
8
  */
12
9
  export namespace Uploader {
13
10
  /**
14
- * Pick the variant of `Engine.Item` matching a phase.
11
+ * Return state payload returned by the `useUploader` hook.
15
12
  *
16
- * @template Phase Phase literal to filter on.
17
- * @returns The item variant whose `phase` matches `Phase`.
18
- * @author wildduck2 <https://github.com/wildduck2>
13
+ * @template M - Intent map type
14
+ * @template C - Cursor map type
15
+ * @template P - Purpose string union type
16
+ * @template R - Backend result shape
19
17
  */
20
- export type PickItemsByPhase<Phase extends Engine.Phase> = Extract<
21
- Engine.Item<Contracts.IntentMap, Contracts.CursorMap<Contracts.IntentMap>, string, Contracts.IResultBase>,
22
- { phase: Phase }
23
- >
18
+ export type State<
19
+ M extends Contracts.Intent.Map,
20
+ C extends Contracts.Cursor.Map<M>,
21
+ P extends string,
22
+ R extends Contracts.Result.Base = Contracts.Result.Base,
23
+ > = Pick<Store.UploadStore<M, C, P, R>, 'dispatch' | 'on' | 'off'> & {
24
+ /** List of all registered upload items currently tracked. */
25
+ items: Array<Engine.Item<M, C, P, R>>
26
+
27
+ /** Upload items grouped by phase. */
28
+ byPhase: Record<Engine.Phase, Array<Engine.Item<M, C, P, R>>>
29
+
30
+ /** Subset of items currently active in the transfer phase. */
31
+ uploading: Array<Engine.Item<M, C, P, R>>
32
+
33
+ /** Subset of items currently paused. */
34
+ paused: Array<Engine.Item<M, C, P, R>>
35
+
36
+ /** Subset of items that successfully completed uploading and backend finalization. */
37
+ completed: Array<Engine.Item<M, C, P, R>>
38
+
39
+ /** Subset of items currently in the error state. */
40
+ failed: Array<Engine.Item<M, C, P, R>>
41
+
42
+ /** Subset of items validated and waiting to begin uploading. */
43
+ ready: Array<Engine.Item<M, C, P, R>>
44
+ }
24
45
 
25
46
  /**
26
- * Object returned by `useUploader`.
47
+ * Bound action callbacks returned by context hooks.
27
48
  *
28
- * @template M Intent map.
29
- * @template C Cursor map.
30
- * @template P Purpose union.
31
- * @template R Backend result shape.
32
- * @author wildduck2 <https://github.com/wildduck2>
49
+ * Houses both the bound dispatchers and the raw underlying store reference.
50
+ *
51
+ * @template M - Intent map type
52
+ * @template C - Cursor map type
53
+ * @template P - Purpose string union type
54
+ * @template R - Backend result shape
33
55
  */
34
- export interface IUploader<
35
- M extends Contracts.IntentMap,
36
- C extends Contracts.CursorMap<M>,
56
+ export type IActions<
57
+ M extends Contracts.Intent.Map,
58
+ C extends Contracts.Cursor.Map<M>,
37
59
  P extends string,
38
- R extends Contracts.IResultBase,
39
- > {
40
- items: Engine.Item<M, C, P, R>[]
41
- /**
42
- * Items grouped by phase. `Partial<Record<Engine.Phase, ...>>` so consumers
43
- * get autocomplete for the eleven known phases and a missing group reads
44
- * as `undefined`.
45
- */
46
- byPhase: Partial<Record<Engine.Phase, Engine.Item<M, C, P, R>[]>>
47
- dispatch: (cmd: Engine.Command<P>) => void
48
- on: <K extends keyof Engine.EventMap<M, C, P, R> & string>(
49
- type: K,
50
- cb: (payload: Engine.EventMap<M, C, P, R>[K]) => void,
51
- ) => () => void
52
- off: <K extends keyof Engine.EventMap<M, C, P, R> & string>(
53
- type: K,
54
- cb: (payload: Engine.EventMap<M, C, P, R>[K]) => void,
55
- ) => void
56
- uploading: Engine.Item<M, C, P, R>[]
57
- paused: Engine.Item<M, C, P, R>[]
58
- completed: Engine.Item<M, C, P, R>[]
59
- failed: Engine.Item<M, C, P, R>[]
60
- ready: Engine.Item<M, C, P, R>[]
60
+ R extends Contracts.Result.Base = Contracts.Result.Base,
61
+ > = Pick<Store.UploadStore<M, C, P, R>, 'dispatch' | 'on'> & {
62
+ /** The raw upload store instance. */
63
+ store: Store.UploadStore<M, C, P, R>
61
64
  }
62
65
 
63
66
  /**
64
- * Imperative action surface returned by `useUploaderActions`.
65
- *
66
- * Declared explicitly so generated `.d.ts` files do not leak internal
67
- * generic names.
68
- *
69
- * @template M Intent map.
70
- * @template C Cursor map.
71
- * @template P Purpose union.
72
- * @template R Backend result shape; defaults to `Contracts.IResultBase`.
73
- * @author wildduck2 <https://github.com/wildduck2>
67
+ * Props for the `<UploadProvider>` component.
74
68
  */
75
- export type IActions<
76
- M extends Contracts.IntentMap,
77
- C extends Contracts.CursorMap<M>,
69
+ export interface ProviderProps<
70
+ M extends Contracts.Intent.Map,
71
+ C extends Contracts.Cursor.Map<M>,
78
72
  P extends string,
79
- R extends Contracts.IResultBase = Contracts.IResultBase,
80
- > = Pick<Store.IUploadStore<M, C, P, R>, 'dispatch' | 'on'> & {
81
- store: Store.IUploadStore<M, C, P, R>
73
+ R extends Contracts.Result.Base = Contracts.Result.Base,
74
+ > {
75
+ /** The upload store instance to distribute. */
76
+ store: Store.UploadStore<M, C, P, R>
77
+ /** React child elements. */
78
+ children: React.ReactNode
82
79
  }
83
80
  }
@@ -0,0 +1,226 @@
1
+ import type * as React from 'react'
2
+ import { useCallback, useMemo, useRef, useState } from 'react'
3
+ import type { Contracts } from '../core'
4
+ import type { Store } from '../core/engine/store'
5
+ import { useUploadStore } from './upload-provider'
6
+
7
+ /**
8
+ * Test a file against one token from an `accept` string. A token is a MIME type
9
+ * (`image/png`), a MIME wildcard (`image/*`), or a filename extension (`.pdf`).
10
+ * @internal exported for tests.
11
+ */
12
+ export function matchesAcceptToken(file: File, token: string): boolean {
13
+ const t = token.trim().toLowerCase()
14
+ if (!t) return false
15
+
16
+ if (t.startsWith('.')) {
17
+ return file.name.toLowerCase().endsWith(t)
18
+ }
19
+
20
+ const type = (file.type || '').toLowerCase()
21
+ if (t.endsWith('/*')) {
22
+ return type.startsWith(`${t.slice(0, -1)}`) // "image/" prefix
23
+ }
24
+ return type === t
25
+ }
26
+
27
+ /**
28
+ * True when `file` satisfies an `accept` list (comma-separated tokens). An empty
29
+ * or absent list accepts everything.
30
+ * @internal exported for tests.
31
+ */
32
+ export function fileMatchesAccept(file: File, accept?: string): boolean {
33
+ if (!accept) return true
34
+ const tokens = accept
35
+ .split(',')
36
+ .map((s) => s.trim())
37
+ .filter(Boolean)
38
+ if (tokens.length === 0) return true
39
+ return tokens.some((tok) => matchesAcceptToken(file, tok))
40
+ }
41
+
42
+ /**
43
+ * Filter a dropped/selected file list by `accept` and collapse to a single file
44
+ * when `multiple` is false.
45
+ * @internal exported for tests.
46
+ */
47
+ export function selectFiles(
48
+ files: File[],
49
+ opts: { accept?: string | undefined; multiple?: boolean | undefined },
50
+ ): File[] {
51
+ const accepted = files.filter((f) => fileMatchesAccept(f, opts.accept))
52
+ return opts.multiple === false ? accepted.slice(0, 1) : accepted
53
+ }
54
+
55
+ /** Options for {@link useDropzone}. */
56
+ export type UseDropzoneOptions<P extends string> = {
57
+ /** Purpose forwarded to the `addFiles` command. */
58
+ purpose: P
59
+ /** Optional caller metadata forwarded to every backend API call. */
60
+ meta?: Record<string, unknown>
61
+ /**
62
+ * Comma-separated `accept` list (`"image/*,.pdf"`). Filters dropped files and
63
+ * populates the input's `accept` attribute. Absent = accept everything.
64
+ */
65
+ accept?: string
66
+ /** Allow multiple files. Defaults to `true`. */
67
+ multiple?: boolean
68
+ /** Disable all interaction (no drop, no click-to-open). Defaults to `false`. */
69
+ disabled?: boolean
70
+ /** Do not open the file dialog when the root is clicked. Defaults to `false`. */
71
+ noClick?: boolean
72
+ /**
73
+ * Override the default dispatch. When provided, accepted files are handed here
74
+ * instead of being dispatched as `addFiles` to the store.
75
+ */
76
+ onFiles?: (files: File[]) => void
77
+ /** Store instance to dispatch to. Falls back to the `<UploadProvider>` store. */
78
+ store?: Store.UploadStore<Contracts.Intent.Map, Contracts.Cursor.Map<Contracts.Intent.Map>, P, Contracts.Result.Base>
79
+ }
80
+
81
+ /** Return value of {@link useDropzone}. */
82
+ export type UseDropzoneReturn = {
83
+ /** True while files are being dragged over the root. */
84
+ isDragging: boolean
85
+ /** Programmatically open the native file picker. */
86
+ open: () => void
87
+ /** Ref to attach to the hidden `<input type="file">`. */
88
+ inputRef: React.RefObject<HTMLInputElement | null>
89
+ /** Spread onto the drop-target element. */
90
+ getRootProps: (extra?: React.HTMLAttributes<HTMLElement>) => React.HTMLAttributes<HTMLElement>
91
+ /** Spread onto the hidden `<input type="file">`. */
92
+ getInputProps: (
93
+ extra?: React.InputHTMLAttributes<HTMLInputElement>,
94
+ ) => React.InputHTMLAttributes<HTMLInputElement> & { ref: React.Ref<HTMLInputElement> }
95
+ }
96
+
97
+ /**
98
+ * Headless drag-and-drop / file-picker hook.
99
+ *
100
+ * Wires drop and file-input events to the upload store's `addFiles` command
101
+ * (or a custom `onFiles` handler). Returns prop-getters for the drop target and
102
+ * a hidden file input plus a live `isDragging` flag — styling and markup are
103
+ * entirely yours.
104
+ *
105
+ * @example
106
+ * ```tsx
107
+ * function Drop() {
108
+ * const { getRootProps, getInputProps, isDragging, open } = useDropzone({
109
+ * purpose: 'attachment',
110
+ * accept: 'image/*',
111
+ * });
112
+ * return (
113
+ * <div {...getRootProps()} data-active={isDragging}>
114
+ * <input {...getInputProps()} />
115
+ * <button type="button" onClick={open}>Choose files</button>
116
+ * </div>
117
+ * );
118
+ * }
119
+ * ```
120
+ * @author wildduck2 <https://github.com/wildduck2>
121
+ */
122
+ export function useDropzone<P extends string>(options: UseDropzoneOptions<P>): UseDropzoneReturn {
123
+ const { purpose, meta, accept, multiple = true, disabled = false, noClick = false, onFiles } = options
124
+
125
+ // Read the context store unconditionally (hooks rule); prefer an explicit one.
126
+ const contextStore = useUploadStore<
127
+ Contracts.Intent.Map,
128
+ Contracts.Cursor.Map<Contracts.Intent.Map>,
129
+ P,
130
+ Contracts.Result.Base
131
+ >()
132
+ const store = options.store ?? contextStore
133
+
134
+ const inputRef = useRef<HTMLInputElement | null>(null)
135
+ const [isDragging, setIsDragging] = useState(false)
136
+ // Depth counter so nested dragenter/dragleave don't flicker the flag.
137
+ const dragDepth = useRef(0)
138
+
139
+ const handleFiles = useCallback(
140
+ (list: FileList | null) => {
141
+ if (!list || list.length === 0) return
142
+ const chosen = selectFiles(Array.from(list), { accept, multiple })
143
+ if (chosen.length === 0) return
144
+ if (onFiles) {
145
+ onFiles(chosen)
146
+ } else {
147
+ store.dispatch({ type: 'addFiles', files: chosen, purpose, meta })
148
+ }
149
+ },
150
+ [accept, multiple, onFiles, store, purpose, meta],
151
+ )
152
+
153
+ const open = useCallback(() => {
154
+ if (disabled) return
155
+ inputRef.current?.click()
156
+ }, [disabled])
157
+
158
+ const getRootProps = useCallback<UseDropzoneReturn['getRootProps']>(
159
+ (extra = {}) => ({
160
+ ...extra,
161
+ onDragEnter: (e) => {
162
+ extra.onDragEnter?.(e)
163
+ if (disabled) return
164
+ e.preventDefault()
165
+ e.stopPropagation()
166
+ dragDepth.current += 1
167
+ setIsDragging(true)
168
+ },
169
+ onDragOver: (e) => {
170
+ extra.onDragOver?.(e)
171
+ if (disabled) return
172
+ // Required so the browser fires `drop` instead of navigating.
173
+ e.preventDefault()
174
+ e.stopPropagation()
175
+ },
176
+ onDragLeave: (e) => {
177
+ extra.onDragLeave?.(e)
178
+ if (disabled) return
179
+ e.preventDefault()
180
+ e.stopPropagation()
181
+ dragDepth.current = Math.max(0, dragDepth.current - 1)
182
+ if (dragDepth.current === 0) setIsDragging(false)
183
+ },
184
+ onDrop: (e) => {
185
+ extra.onDrop?.(e)
186
+ if (disabled) return
187
+ e.preventDefault()
188
+ e.stopPropagation()
189
+ dragDepth.current = 0
190
+ setIsDragging(false)
191
+ handleFiles(e.dataTransfer?.files ?? null)
192
+ },
193
+ onClick: (e) => {
194
+ extra.onClick?.(e)
195
+ if (disabled || noClick) return
196
+ open()
197
+ },
198
+ }),
199
+ [disabled, noClick, open, handleFiles],
200
+ )
201
+
202
+ const getInputProps = useCallback<UseDropzoneReturn['getInputProps']>(
203
+ (extra = {}) => ({
204
+ ...extra,
205
+ ref: inputRef,
206
+ type: 'file',
207
+ multiple,
208
+ disabled,
209
+ ...(accept ? { accept } : {}),
210
+ // Visually hidden; caller styles can extend but not un-hide by accident.
211
+ style: { display: 'none', ...extra.style },
212
+ onChange: (e) => {
213
+ extra.onChange?.(e)
214
+ handleFiles(e.target.files)
215
+ // Reset so selecting the same file again re-fires change.
216
+ e.target.value = ''
217
+ },
218
+ }),
219
+ [multiple, disabled, accept, handleFiles],
220
+ )
221
+
222
+ return useMemo(
223
+ () => ({ isDragging, open, inputRef, getRootProps, getInputProps }),
224
+ [isDragging, open, getRootProps, getInputProps],
225
+ )
226
+ }