@rainbow-oh/yee-c 0.9.2 → 0.10.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 (109) hide show
  1. package/dist/cjs/Input/style/index.less +1 -0
  2. package/dist/cjs/Tabs/demo/overflow-center.d.ts +3 -0
  3. package/dist/cjs/Tabs/demo/overflow-center.js +54 -0
  4. package/dist/cjs/Tabs/hooks/useNavListMove.d.ts +2 -1
  5. package/dist/cjs/Tabs/hooks/useNavListMove.js +51 -16
  6. package/dist/cjs/Tabs/hooks/useTabScroll.d.ts +1 -0
  7. package/dist/cjs/Tabs/hooks/useTabScroll.js +5 -1
  8. package/dist/cjs/Tabs/interface.d.ts +9 -0
  9. package/dist/cjs/Tabs/style/index.less +5 -0
  10. package/dist/cjs/Tabs/tab-header/index.d.ts +1 -0
  11. package/dist/cjs/Tabs/tab-header/index.js +9 -2
  12. package/dist/cjs/Tabs/tabs.js +4 -1
  13. package/dist/cjs/Upload/demo/auto-upload.d.ts +3 -0
  14. package/dist/cjs/Upload/demo/auto-upload.js +65 -0
  15. package/dist/cjs/Upload/demo/basic.js +5 -10
  16. package/dist/cjs/Upload/demo/controlled.js +4 -4
  17. package/dist/cjs/Upload/demo/pictureWall.js +14 -13
  18. package/dist/cjs/Upload/dragger.d.ts +2 -1
  19. package/dist/cjs/Upload/dragger.js +32 -21
  20. package/dist/cjs/Upload/index.d.ts +1 -1
  21. package/dist/cjs/Upload/interface.d.ts +67 -120
  22. package/dist/cjs/Upload/style/index.less +40 -13
  23. package/dist/cjs/Upload/upload.d.ts +2 -2
  24. package/dist/cjs/Upload/upload.js +435 -185
  25. package/dist/cjs/Upload/uploaded-list/index.d.ts +2 -1
  26. package/dist/cjs/Upload/uploaded-list/index.js +6 -3
  27. package/dist/cjs/Upload/uploaded-list/uploaded-item.d.ts +6 -3
  28. package/dist/cjs/Upload/uploaded-list/uploaded-item.js +127 -138
  29. package/dist/cjs/style/size.css +1 -1
  30. package/dist/cjs/style/themes.css +1 -1
  31. package/dist/cjs/style/variables.css +2 -2
  32. package/dist/cjs/utils/ax/core.d.ts +3 -1
  33. package/dist/cjs/utils/ax/core.js +24 -37
  34. package/dist/cjs/utils/ax/index.d.ts +3 -1
  35. package/dist/cjs/utils/folder-scanner.d.ts +1 -1
  36. package/dist/cjs/utils/folder-scanner.js +43 -34
  37. package/dist/cjs/version.d.ts +1 -1
  38. package/dist/cjs/version.js +1 -1
  39. package/dist/esm/Input/style/index.less +1 -0
  40. package/dist/esm/Tabs/demo/overflow-center.d.ts +3 -0
  41. package/dist/esm/Tabs/demo/overflow-center.js +61 -0
  42. package/dist/esm/Tabs/hooks/useNavListMove.d.ts +2 -1
  43. package/dist/esm/Tabs/hooks/useNavListMove.js +62 -25
  44. package/dist/esm/Tabs/hooks/useTabScroll.d.ts +1 -0
  45. package/dist/esm/Tabs/hooks/useTabScroll.js +9 -0
  46. package/dist/esm/Tabs/interface.d.ts +9 -0
  47. package/dist/esm/Tabs/style/index.less +5 -0
  48. package/dist/esm/Tabs/tab-header/index.d.ts +1 -0
  49. package/dist/esm/Tabs/tab-header/index.js +13 -2
  50. package/dist/esm/Tabs/tabs.js +4 -2
  51. package/dist/esm/Upload/demo/auto-upload.d.ts +3 -0
  52. package/dist/esm/Upload/demo/auto-upload.js +42 -0
  53. package/dist/esm/Upload/demo/basic.js +5 -11
  54. package/dist/esm/Upload/demo/controlled.js +8 -6
  55. package/dist/esm/Upload/demo/pictureWall.js +17 -14
  56. package/dist/esm/Upload/dragger.d.ts +2 -1
  57. package/dist/esm/Upload/dragger.js +10 -5
  58. package/dist/esm/Upload/index.d.ts +1 -1
  59. package/dist/esm/Upload/interface.d.ts +67 -120
  60. package/dist/esm/Upload/style/index.less +40 -13
  61. package/dist/esm/Upload/upload.d.ts +2 -2
  62. package/dist/esm/Upload/upload.js +573 -271
  63. package/dist/esm/Upload/uploaded-list/index.d.ts +2 -1
  64. package/dist/esm/Upload/uploaded-list/index.js +9 -4
  65. package/dist/esm/Upload/uploaded-list/uploaded-item.d.ts +6 -3
  66. package/dist/esm/Upload/uploaded-list/uploaded-item.js +145 -150
  67. package/dist/esm/style/size.css +1 -1
  68. package/dist/esm/style/themes.css +1 -1
  69. package/dist/esm/style/variables.css +2 -2
  70. package/dist/esm/utils/ax/core.d.ts +3 -1
  71. package/dist/esm/utils/ax/core.js +32 -40
  72. package/dist/esm/utils/ax/index.d.ts +3 -1
  73. package/dist/esm/utils/folder-scanner.d.ts +1 -1
  74. package/dist/esm/utils/folder-scanner.js +76 -75
  75. package/dist/esm/version.d.ts +1 -1
  76. package/dist/esm/version.js +1 -1
  77. package/dist/umd/yee-c.min.css +2 -2
  78. package/dist/umd/yee-c.min.js +1 -1
  79. package/package.json +1 -1
  80. package/src/Input/style/index.less +1 -0
  81. package/src/Tabs/demo/overflow-center.tsx +35 -0
  82. package/src/Tabs/hooks/useNavListMove.ts +60 -18
  83. package/src/Tabs/hooks/useTabScroll.ts +7 -1
  84. package/src/Tabs/index.en-US.md +33 -31
  85. package/src/Tabs/index.zh-CN.md +33 -31
  86. package/src/Tabs/interface.ts +9 -0
  87. package/src/Tabs/style/index.less +5 -0
  88. package/src/Tabs/tab-header/index.tsx +12 -2
  89. package/src/Tabs/tabs.tsx +2 -0
  90. package/src/Upload/demo/auto-upload.tsx +35 -0
  91. package/src/Upload/demo/basic.tsx +7 -11
  92. package/src/Upload/demo/controlled.tsx +12 -8
  93. package/src/Upload/demo/directory.tsx +3 -4
  94. package/src/Upload/demo/drag.tsx +5 -12
  95. package/src/Upload/demo/multiple.tsx +3 -4
  96. package/src/Upload/demo/pictureWall.tsx +22 -21
  97. package/src/Upload/dragger.tsx +47 -34
  98. package/src/Upload/index.en-US.md +63 -43
  99. package/src/Upload/index.ts +8 -1
  100. package/src/Upload/index.zh-CN.md +62 -42
  101. package/src/Upload/interface.ts +68 -120
  102. package/src/Upload/style/index.less +40 -13
  103. package/src/Upload/upload.tsx +546 -215
  104. package/src/Upload/uploaded-list/index.tsx +9 -5
  105. package/src/Upload/uploaded-list/uploaded-item.tsx +199 -228
  106. package/src/style/size.css +1 -1
  107. package/src/utils/ax/core.ts +28 -41
  108. package/src/utils/folder-scanner.ts +51 -37
  109. package/src/version.ts +1 -1
@@ -1,33 +1,129 @@
1
1
  import clsx from 'clsx';
2
2
  import { Plus } from 'lucide-react';
3
- import React, { forwardRef, useContext, useRef, useState } from 'react';
3
+ import React, {
4
+ forwardRef,
5
+ useContext,
6
+ useEffect,
7
+ useImperativeHandle,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
4
11
  import { v4 as uuidv4 } from 'uuid';
5
12
  import Button from '../Button';
6
13
  import { GlobalContext } from '../Config-Provider';
7
- import mergeContextToProps from '../utils/mergeContextToProps';
8
- import UploadList from './uploaded-list/index';
9
-
10
14
  import useMergedState from '../hooks/useMergedState';
11
15
  import ax from '../utils/ax';
12
16
  import folderScanner from '../utils/folder-scanner';
13
- import type { UploadFile, UploadProps } from './interface';
17
+ import mergeContextToProps from '../utils/mergeContextToProps';
18
+ import type {
19
+ UploadFile,
20
+ UploadInstance,
21
+ UploadProps,
22
+ UploadRequestAbort,
23
+ } from './interface';
14
24
  import './style/index.less';
25
+ import UploadList from './uploaded-list/index';
26
+
27
+ type ActiveRequest = {
28
+ token: symbol;
29
+ abort?: () => void;
30
+ };
31
+
32
+ const getFileExtension = (fileName: string) => {
33
+ const dotIndex = fileName.lastIndexOf('.');
34
+ return dotIndex > -1 ? fileName.slice(dotIndex + 1).toLowerCase() : '';
35
+ };
15
36
 
16
37
  const wrapFile = (file: File): UploadFile => {
17
38
  return {
18
39
  uid: uuidv4(),
19
40
  name: file.name,
20
41
  size: file.size,
21
- type: typeof file?.name === 'string' ? file.name.split('.')[1] : '',
42
+ type: file.type || getFileExtension(file.name),
22
43
  percent: 0,
23
44
  status: 'ready',
24
45
  raw: file,
25
46
  };
26
47
  };
27
48
 
28
- const Upload = forwardRef<HTMLDivElement, UploadProps>((baseprops, ref) => {
29
- const { upload } = useContext(GlobalContext);
30
- const props = mergeContextToProps(baseprops, upload);
49
+ const isSameFile = (left: UploadFile, right: UploadFile) => {
50
+ if (left.uid && right.uid) {
51
+ return left.uid === right.uid;
52
+ }
53
+ return left === right;
54
+ };
55
+
56
+ const matchesAccept = (file: File, accept?: string) => {
57
+ if (!accept) {
58
+ return true;
59
+ }
60
+
61
+ const fileName = file.name.toLowerCase();
62
+ const mimeType = file.type.toLowerCase();
63
+
64
+ return accept
65
+ .split(',')
66
+ .map((item) => item.trim().toLowerCase())
67
+ .filter(Boolean)
68
+ .some((rule) => {
69
+ if (rule.startsWith('.')) {
70
+ return fileName.endsWith(rule);
71
+ }
72
+ if (rule.endsWith('/*')) {
73
+ return mimeType.startsWith(rule.slice(0, -1));
74
+ }
75
+ return mimeType === rule;
76
+ });
77
+ };
78
+
79
+ const describeErrorDetail = (detail: unknown) => {
80
+ if (typeof detail === 'string' && detail) {
81
+ return detail;
82
+ }
83
+ try {
84
+ const text = JSON.stringify(detail) ?? '';
85
+ return text.length > 200 ? `${text.slice(0, 200)}…` : text;
86
+ } catch {
87
+ return String(detail);
88
+ }
89
+ };
90
+
91
+ const toError = (error: unknown) => {
92
+ if (error instanceof Error) {
93
+ return error;
94
+ }
95
+ if (typeof error === 'object' && error !== null && 'error' in error) {
96
+ const detail = (error as { error: unknown }).error;
97
+ if (detail instanceof Error) {
98
+ return detail;
99
+ }
100
+ if (detail !== null && detail !== undefined) {
101
+ // e.g. ax rejects { status: 'error', error: xhr.response }
102
+ const message = describeErrorDetail(detail);
103
+ if (message) {
104
+ return new Error(message);
105
+ }
106
+ }
107
+ }
108
+ return new Error(typeof error === 'string' ? error : 'Upload failed');
109
+ };
110
+
111
+ // ax rejects aborts as { status: 'abort', error: event }
112
+ const isAbortRejection = (error: unknown) =>
113
+ typeof error === 'object' &&
114
+ error !== null &&
115
+ (error as { status?: unknown }).status === 'abort';
116
+
117
+ const getAbort = (request?: UploadRequestAbort | void) => {
118
+ if (typeof request === 'function') {
119
+ return request;
120
+ }
121
+ return request?.abort;
122
+ };
123
+
124
+ const Upload = forwardRef<UploadInstance, UploadProps>((baseprops, ref) => {
125
+ const { upload: uploadConfig } = useContext(GlobalContext);
126
+ const props = mergeContextToProps(baseprops, uploadConfig);
31
127
  const {
32
128
  prefixCls = 'yee-upload',
33
129
  name = 'file',
@@ -48,6 +144,7 @@ const Upload = forwardRef<HTMLDivElement, UploadProps>((baseprops, ref) => {
48
144
  accept,
49
145
  showUploadList = true,
50
146
  progress = true,
147
+ autoUpload = true,
51
148
  maxFileSize,
52
149
  maxCount,
53
150
  customRequest,
@@ -60,255 +157,517 @@ const Upload = forwardRef<HTMLDivElement, UploadProps>((baseprops, ref) => {
60
157
  } = props;
61
158
 
62
159
  const inputRef = useRef<HTMLInputElement>(null);
160
+ const rootRef = useRef<HTMLDivElement>(null);
161
+ const activeRequestsRef = useRef<Map<string, ActiveRequest>>(new Map());
162
+ const pendingPreparationsRef = useRef<Map<string, symbol>>(new Map());
163
+ const mountedRef = useRef(true);
63
164
  const [dragState, setDragState] = useState<'dragover' | 'drop' | null>(null);
64
165
 
65
- const [mergedFileList, setMergedFileList] = useMergedState([], {
166
+ const [mergedFileList, setMergedFileList] = useMergedState<UploadFile[]>([], {
66
167
  value: propsFileList,
67
168
  defaultValue: defaultFileList,
68
169
  });
170
+ const fileListRef = useRef(mergedFileList);
171
+ fileListRef.current = mergedFileList;
172
+
173
+ useEffect(() => {
174
+ return () => {
175
+ mountedRef.current = false;
176
+ pendingPreparationsRef.current.clear();
177
+ const activeRequests = Array.from(activeRequestsRef.current.values());
178
+ activeRequestsRef.current.clear();
179
+ activeRequests.forEach(({ abort }) => abort?.());
180
+ };
181
+ }, []);
69
182
 
70
- let acceptDirectory = {};
183
+ const setFileList = (nextFileList: UploadFile[]) => {
184
+ fileListRef.current = nextFileList;
185
+ setMergedFileList(nextFileList);
186
+ };
71
187
 
72
- if (directory) {
73
- acceptDirectory = {
74
- webkitdirectory: '',
75
- directory: '',
76
- multiple: '',
77
- };
78
- }
188
+ const emitChange = (
189
+ file: UploadFile,
190
+ nextFileList: UploadFile[],
191
+ event?: any,
192
+ ) => {
193
+ setFileList(nextFileList);
194
+ onChange?.({ file, fileList: nextFileList, event });
195
+ };
79
196
 
80
- const updateFileList = (file: UploadFile) => {
81
- setMergedFileList((state) => {
82
- return state.map((item) => {
83
- if (item.uid === file.uid) {
84
- return file;
85
- }
86
- return item;
87
- });
88
- });
197
+ const appendFile = (file: UploadFile, event?: any) => {
198
+ const nextFileList = [...fileListRef.current, file];
199
+ emitChange(file, nextFileList, event);
200
+ return file;
89
201
  };
90
202
 
91
- const postFile = (file: UploadFile) => {
92
- const formData = new FormData();
93
- formData.append(name, file.raw);
203
+ const replaceFile = (
204
+ target: UploadFile,
205
+ replacement: UploadFile,
206
+ event?: any,
207
+ ) => {
208
+ const index = fileListRef.current.findIndex((item) =>
209
+ isSameFile(item, target),
210
+ );
211
+ if (index < 0) {
212
+ return undefined;
213
+ }
214
+
215
+ const nextFileList = [...fileListRef.current];
216
+ nextFileList[index] = replacement;
217
+ emitChange(replacement, nextFileList, event);
218
+ return replacement;
219
+ };
94
220
 
95
- if (data) {
96
- const extraData = typeof data === 'function' ? data(file.raw) : data;
97
- Object.keys(extraData).forEach((key) => {
98
- formData.append(key, extraData[key]);
99
- });
221
+ const updateFile = (
222
+ target: UploadFile,
223
+ patch: Partial<UploadFile>,
224
+ event?: any,
225
+ ) => {
226
+ const current = fileListRef.current.find((item) =>
227
+ isSameFile(item, target),
228
+ );
229
+ if (!current) {
230
+ return undefined;
100
231
  }
232
+ return replaceFile(target, { ...current, ...patch }, event);
233
+ };
101
234
 
102
- const url = typeof action === 'function' ? action() : action;
235
+ const abortRequest = (file: UploadFile) => {
236
+ if (!file.uid) {
237
+ return;
238
+ }
239
+ const activeRequest = activeRequestsRef.current.get(file.uid);
240
+ if (activeRequest) {
241
+ activeRequestsRef.current.delete(file.uid);
242
+ activeRequest.abort?.();
243
+ }
244
+ };
103
245
 
104
- // Use custom upload
105
- if (customRequest) {
106
- customRequest({
107
- file: file.raw,
108
- onProgress: (percent) => {
109
- const current = { ...file, status: 'uploading' as const, percent };
110
- updateFileList(current);
111
- onChange?.({
112
- file: current,
113
- fileList: mergedFileList,
114
- event: { percent },
115
- });
116
- },
117
- onError: (error) => {
118
- const current = { ...file, status: 'error' as const, error };
119
- updateFileList(current);
120
- onChange?.({
121
- file: current,
122
- fileList: mergedFileList,
123
- event: error,
124
- });
125
- },
126
- onSuccess: (response) => {
127
- const current = { ...file, status: 'success' as const, response };
128
- updateFileList(current);
129
- onChange?.({
130
- file: current,
131
- fileList: mergedFileList,
132
- event: response,
133
- });
134
- },
135
- });
246
+ const postFile = (sourceFile: UploadFile) => {
247
+ let file = sourceFile;
248
+ if (!file.uid) {
249
+ const withUid = { ...file, uid: uuidv4() };
250
+ file = replaceFile(file, withUid) || withUid;
251
+ }
252
+
253
+ const requestKey = file.uid as string;
254
+ abortRequest(file);
255
+ const token = Symbol(requestKey);
256
+ activeRequestsRef.current.set(requestKey, { token });
257
+
258
+ const isActive = () =>
259
+ activeRequestsRef.current.get(requestKey)?.token === token;
260
+ const finish = () => {
261
+ if (isActive()) {
262
+ activeRequestsRef.current.delete(requestKey);
263
+ }
264
+ };
265
+ const updateIfActive = (patch: Partial<UploadFile>, event?: any) => {
266
+ if (!isActive()) {
267
+ return undefined;
268
+ }
269
+ return updateFile(file, patch, event);
270
+ };
271
+ const fail = (error: unknown) => {
272
+ // Requests aborted outside of remove/retry/unmount (e.g. by the browser)
273
+ // go back to 'ready' instead of showing an error
274
+ if (isAbortRejection(error)) {
275
+ updateIfActive({ status: 'ready', percent: 0 });
276
+ finish();
277
+ return;
278
+ }
279
+ const normalizedError = toError(error);
280
+ updateIfActive({ status: 'error', error: normalizedError }, error);
281
+ finish();
282
+ };
283
+
284
+ updateIfActive({ status: 'uploading', percent: 0, error: undefined });
285
+
286
+ if (!file.raw) {
287
+ fail(new Error(`File ${file.name} has no source file to upload`));
136
288
  return;
137
289
  }
138
290
 
139
- // Default upload
140
- // @ts-ignore
141
- ax.post(url, formData, {
142
- withCredentials,
143
- headers: { ...headers },
144
- onUploadProgress: (e: any) => {
145
- const percentage = parseInt((e.loaded / e.total) * 100 + '');
146
- let current: UploadFile;
147
- if (percentage < 100) {
148
- current = {
149
- ...file,
150
- status: 'uploading' as const,
151
- percent: percentage,
152
- };
153
- } else {
154
- current = { ...file, status: 'success' as const, percent: 100 };
291
+ if (customRequest) {
292
+ try {
293
+ const request = customRequest({
294
+ file: file.raw,
295
+ onProgress: (percent) => {
296
+ const normalizedPercent = Math.min(100, Math.max(0, percent));
297
+ updateIfActive(
298
+ { status: 'uploading', percent: normalizedPercent },
299
+ { percent: normalizedPercent },
300
+ );
301
+ },
302
+ onError: fail,
303
+ onSuccess: (response) => {
304
+ updateIfActive(
305
+ {
306
+ status: 'success',
307
+ percent: 100,
308
+ response,
309
+ error: undefined,
310
+ },
311
+ response,
312
+ );
313
+ finish();
314
+ },
315
+ });
316
+ const activeRequest = activeRequestsRef.current.get(requestKey);
317
+ if (activeRequest?.token === token) {
318
+ activeRequest.abort = getAbort(request);
155
319
  }
320
+ } catch (error) {
321
+ fail(error);
322
+ }
323
+ return;
324
+ }
156
325
 
157
- updateFileList(current);
326
+ try {
327
+ const url = typeof action === 'function' ? action() : action;
328
+ if (!url) {
329
+ throw new Error(
330
+ 'Upload action is required when customRequest is not set',
331
+ );
332
+ }
158
333
 
159
- onChange?.({
160
- file: current,
161
- fileList: mergedFileList,
162
- event: e,
163
- });
164
- },
165
- })
166
- .then((res: any) => {
167
- const current = { ...file, status: 'success' as const, response: res };
168
- updateFileList(current);
169
- onChange?.({
170
- file: current,
171
- fileList: mergedFileList,
172
- event: res,
173
- });
174
- })
175
- .catch((err: Error) => {
176
- const current = { ...file, status: 'error' as const, error: err };
177
- updateFileList(current);
178
-
179
- onChange?.({
180
- file: current,
181
- fileList: mergedFileList,
182
- event: err,
334
+ const formData = new FormData();
335
+ formData.append(name, file.raw);
336
+ if (data) {
337
+ const extraData = typeof data === 'function' ? data(file.raw) : data;
338
+ Object.keys(extraData).forEach((key) => {
339
+ formData.append(key, extraData[key]);
183
340
  });
184
- });
185
- };
341
+ }
186
342
 
187
- const post = (file: UploadFile) => {
188
- setMergedFileList((state) => [...state, file]);
189
- postFile(file);
343
+ const request = (ax as any).post(url, formData, {
344
+ withCredentials,
345
+ headers: { ...headers },
346
+ onUploadProgress: (event: ProgressEvent) => {
347
+ const percentage = event.total
348
+ ? Math.round((event.loaded / event.total) * 100)
349
+ : 0;
350
+ updateIfActive({ status: 'uploading', percent: percentage }, event);
351
+ },
352
+ }) as Promise<any> & { abort?: () => void };
353
+
354
+ const activeRequest = activeRequestsRef.current.get(requestKey);
355
+ if (activeRequest?.token === token) {
356
+ activeRequest.abort = request.abort;
357
+ }
358
+
359
+ request
360
+ .then((response) => {
361
+ updateIfActive(
362
+ {
363
+ status: 'success',
364
+ percent: 100,
365
+ response,
366
+ error: undefined,
367
+ },
368
+ response,
369
+ );
370
+ finish();
371
+ })
372
+ .catch(fail);
373
+ } catch (error) {
374
+ fail(error);
375
+ }
190
376
  };
191
377
 
192
- const isValidFile = (file: File): boolean => {
193
- // Check file size
378
+ const isValidFile = (file: File) => {
194
379
  if (maxFileSize && file.size > maxFileSize) {
195
380
  console.warn(`File ${file.name} exceeds maximum size limit`);
196
381
  return false;
197
382
  }
198
-
199
- // Check file count
200
- if (maxCount && mergedFileList.length >= maxCount) {
201
- console.warn(`Maximum file count (${maxCount}) exceeded`);
383
+ if (!matchesAccept(file, accept)) {
384
+ console.warn(`File ${file.name} does not match accepted file types`);
202
385
  return false;
203
386
  }
204
-
205
387
  return true;
206
388
  };
207
389
 
208
- const uploadFiles = (files: FileList | File[], e: any) => {
209
- const postFiles = Array.from(files) as File[];
210
- postFiles.forEach((file) => {
211
- if (!isValidFile(file)) {
390
+ const prepareFile = async (
391
+ file: File,
392
+ wrapped: UploadFile,
393
+ postFiles: File[],
394
+ preparationToken: symbol,
395
+ event?: any,
396
+ ) => {
397
+ const preparationKey = wrapped.uid as string;
398
+ const isActive = () =>
399
+ mountedRef.current &&
400
+ pendingPreparationsRef.current.get(preparationKey) === preparationToken &&
401
+ fileListRef.current.some((item) => isSameFile(item, wrapped));
402
+ const finish = () => {
403
+ if (
404
+ pendingPreparationsRef.current.get(preparationKey) === preparationToken
405
+ ) {
406
+ pendingPreparationsRef.current.delete(preparationKey);
407
+ }
408
+ };
409
+
410
+ try {
411
+ const result = beforeUpload
412
+ ? await Promise.resolve(beforeUpload(file, postFiles))
413
+ : true;
414
+ if (!isActive()) {
415
+ finish();
416
+ return;
417
+ }
418
+ if (result === false) {
419
+ // beforeUpload rejected the file: drop it from the list
420
+ setFileList(
421
+ fileListRef.current.filter((item) => !isSameFile(item, wrapped)),
422
+ );
423
+ finish();
212
424
  return;
213
425
  }
214
426
 
215
- const wrapped = wrapFile(file);
216
- onChange?.({
217
- file: wrapped,
218
- fileList: mergedFileList,
219
- event: e,
220
- });
221
-
222
- if (beforeUpload) {
223
- const res = beforeUpload(file, postFiles);
224
- if (res && res instanceof Promise) {
225
- res.then((progress) => {
226
- if (progress === false) {
227
- return;
228
- }
229
- post(typeof progress === 'object' ? wrapFile(progress) : wrapped);
230
- });
231
- } else if (res !== false) {
232
- post(wrapped);
427
+ let nextFile = wrapped;
428
+ if (result instanceof File) {
429
+ nextFile = { ...wrapFile(result), uid: wrapped.uid };
430
+ if (!replaceFile(wrapped, nextFile, event)) {
431
+ finish();
432
+ return;
233
433
  }
234
- } else {
235
- post(wrapped);
236
434
  }
237
- });
435
+ finish();
436
+ postFile(nextFile);
437
+ } catch (error) {
438
+ if (isActive()) {
439
+ updateFile(wrapped, { status: 'error', error: toError(error) }, error);
440
+ }
441
+ finish();
442
+ }
238
443
  };
239
444
 
240
- const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
241
- const files = e.target.files;
242
- if (!files) return;
243
- uploadFiles(files, e);
244
- if (inputRef.current) {
245
- inputRef.current.value = '';
445
+ const uploadFiles = (files: FileList | File[], event?: any) => {
446
+ const selectedFiles = Array.from(files);
447
+ const filesAllowedByMultiple =
448
+ multiple || directory ? selectedFiles : selectedFiles.slice(0, 1);
449
+ const validFiles = filesAllowedByMultiple.filter(isValidFile);
450
+ const remainingCount =
451
+ typeof maxCount === 'number'
452
+ ? Math.max(0, maxCount - fileListRef.current.length)
453
+ : validFiles.length;
454
+ const postFiles = validFiles.slice(0, remainingCount);
455
+
456
+ if (postFiles.length < validFiles.length) {
457
+ console.warn(`Maximum file count (${maxCount}) exceeded`);
246
458
  }
459
+
460
+ postFiles.forEach((file) => {
461
+ const wrapped = appendFile(wrapFile(file), event);
462
+
463
+ // Manual mode: files only join the list and wait for upload()
464
+ if (!autoUpload) {
465
+ return;
466
+ }
467
+
468
+ const preparationToken = Symbol(wrapped.uid);
469
+ pendingPreparationsRef.current.set(
470
+ wrapped.uid as string,
471
+ preparationToken,
472
+ );
473
+ void prepareFile(file, wrapped, postFiles, preparationToken, event);
474
+ });
247
475
  };
248
476
 
249
- const handleClick = () => {
250
- if (!disabled) {
251
- inputRef.current?.click();
477
+ // Instance method: upload files waiting in the list (status 'ready')
478
+ const upload = (file?: UploadFile | UploadFile[]) => {
479
+ const requested =
480
+ file === undefined
481
+ ? fileListRef.current
482
+ : Array.isArray(file)
483
+ ? file
484
+ : [file];
485
+ const readyFiles = requested.filter((item) => item.status === 'ready');
486
+
487
+ readyFiles.forEach((item) => {
488
+ let target = item;
489
+ if (!target.uid) {
490
+ target = replaceFile(item, { ...item, uid: uuidv4() }) ?? item;
491
+ }
492
+ if (!target.raw) {
493
+ updateFile(target, {
494
+ status: 'error',
495
+ error: new Error(`File ${target.name} has no source file to upload`),
496
+ });
497
+ return;
498
+ }
499
+
500
+ const preparationToken = Symbol(target.uid);
501
+ pendingPreparationsRef.current.set(
502
+ target.uid as string,
503
+ preparationToken,
504
+ );
505
+ void prepareFile(
506
+ target.raw,
507
+ target,
508
+ readyFiles
509
+ .map((ready) => ready.raw)
510
+ .filter((raw): raw is File => raw instanceof File),
511
+ preparationToken,
512
+ );
513
+ });
514
+ };
515
+
516
+ // Expose the root element plus the upload() method on the ref
517
+ useImperativeHandle(
518
+ ref,
519
+ () =>
520
+ Object.assign(rootRef.current ?? document.createElement('div'), {
521
+ upload,
522
+ }),
523
+ [upload],
524
+ );
525
+
526
+ const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
527
+ const files = event.target.files;
528
+ if (!files) {
529
+ return;
252
530
  }
531
+ uploadFiles(files, event);
532
+ event.target.value = '';
253
533
  };
254
534
 
255
- const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
256
- e.preventDefault();
257
- if (disabled) return;
258
- setDragState('dragover');
535
+ const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
536
+ event.preventDefault();
537
+ if (!disabled) {
538
+ setDragState('dragover');
539
+ }
259
540
  };
260
541
 
261
- const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
262
- e.preventDefault();
542
+ const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
543
+ event.preventDefault();
263
544
  setDragState(null);
264
545
  };
265
546
 
266
- const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
267
- e.preventDefault();
268
- if (disabled) return;
547
+ const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
548
+ event.preventDefault();
549
+ if (disabled) {
550
+ return;
551
+ }
269
552
  setDragState('drop');
270
553
 
271
- const { files, items } = e.dataTransfer;
272
-
273
- if (files.length === 1) {
274
- const item = items[0].webkitGetAsEntry();
275
- // Dropped item is a folder
276
- if (item?.isDirectory) {
277
- // @ts-ignore
278
- const folderFiles = (await folderScanner(item, [])) as File[];
279
- uploadFiles(folderFiles, e);
280
- } else if (item?.isFile) {
281
- uploadFiles(files, e);
554
+ try {
555
+ const entries = Array.from(event.dataTransfer.items)
556
+ .map((item) => item.webkitGetAsEntry?.())
557
+ .filter(
558
+ (entry): entry is FileSystemEntry =>
559
+ entry !== null && entry !== undefined,
560
+ );
561
+
562
+ if (entries.some((entry) => entry.isDirectory)) {
563
+ const nestedFiles = await Promise.all(
564
+ entries.map((entry) => folderScanner(entry, [])),
565
+ );
566
+ uploadFiles(nestedFiles.flat(), event);
567
+ } else {
568
+ uploadFiles(event.dataTransfer.files, event);
569
+ }
570
+ } catch (error) {
571
+ console.warn('Failed to scan dropped files', error);
572
+ } finally {
573
+ setDragState(null);
574
+ }
575
+ };
576
+
577
+ const handleRemove = async (file: UploadFile) => {
578
+ if (file.uid) {
579
+ pendingPreparationsRef.current.delete(file.uid);
580
+ }
581
+ try {
582
+ const shouldRemove = onRemove
583
+ ? await Promise.resolve(onRemove(file))
584
+ : true;
585
+ if (shouldRemove === false) {
586
+ return;
282
587
  }
283
- } else if (files.length > 1) {
284
- uploadFiles(files, e);
588
+ abortRequest(file);
589
+ setFileList(
590
+ fileListRef.current.filter((item) => !isSameFile(item, file)),
591
+ );
592
+ } catch (error) {
593
+ console.warn(`Failed to remove file ${file.name}`, error);
594
+ }
595
+ };
596
+
597
+ const handleReUpload = (file: UploadFile) => {
598
+ if (file.uid) {
599
+ pendingPreparationsRef.current.delete(file.uid);
600
+ }
601
+ abortRequest(file);
602
+ const nextFile = updateFile(file, {
603
+ uid: file.uid || uuidv4(),
604
+ status: 'ready',
605
+ percent: 0,
606
+ error: undefined,
607
+ response: undefined,
608
+ });
609
+ if (nextFile) {
610
+ postFile(nextFile);
285
611
  }
286
612
  };
287
613
 
614
+ const handlePreview = (file: UploadFile) => {
615
+ if (onPreview) {
616
+ onPreview(file);
617
+ return;
618
+ }
619
+ if (file.url) {
620
+ window.open(file.url, '_blank', 'noopener,noreferrer');
621
+ return;
622
+ }
623
+ if (!file.raw) {
624
+ return;
625
+ }
626
+
627
+ const url = URL.createObjectURL(file.raw);
628
+ const previewWindow = window.open(url, '_blank');
629
+ if (!previewWindow) {
630
+ URL.revokeObjectURL(url);
631
+ return;
632
+ }
633
+ const cleanup = () => URL.revokeObjectURL(url);
634
+ previewWindow.addEventListener('load', cleanup, { once: true });
635
+ window.setTimeout(cleanup, 60_000);
636
+ };
637
+
288
638
  const renderUploadTrigger = () => {
639
+ const acceptDirectory = directory
640
+ ? ({ webkitdirectory: '', directory: '' } as Record<string, string>)
641
+ : {};
642
+
289
643
  return (
290
644
  <span
291
645
  className={clsx(`${prefixCls}-input`, {
292
646
  [`${prefixCls}-input-disabled`]: disabled,
293
647
  })}
294
- onClick={handleClick}
648
+ onClick={(event) => {
649
+ if (!disabled && event.target !== inputRef.current) {
650
+ inputRef.current?.click();
651
+ }
652
+ }}
295
653
  >
296
654
  <input
297
655
  type="file"
656
+ aria-label="Upload file"
657
+ className={`${prefixCls}-native-input`}
298
658
  disabled={disabled}
299
659
  {...acceptDirectory}
300
- multiple={multiple}
660
+ multiple={Boolean(multiple || directory)}
301
661
  accept={accept}
302
662
  onChange={handleChange}
303
- style={{ display: 'none' }}
304
663
  ref={inputRef}
305
664
  />
306
665
  {children ? (
307
666
  children
308
667
  ) : listType === 'picture-wall' ? (
309
- <div className={`${prefixCls}-picture-wall-trigger`}>
668
+ <span className={`${prefixCls}-picture-wall-trigger`}>
310
669
  <Plus />
311
- </div>
670
+ </span>
312
671
  ) : (
313
672
  <Button disabled={disabled}>Upload</Button>
314
673
  )}
@@ -316,35 +675,6 @@ const Upload = forwardRef<HTMLDivElement, UploadProps>((baseprops, ref) => {
316
675
  );
317
676
  };
318
677
 
319
- const handleRemove = async (file: UploadFile) => {
320
- const promise = onRemove?.(file);
321
- const shouldRemove =
322
- promise instanceof Promise ? await promise : promise !== false;
323
-
324
- if (shouldRemove !== false) {
325
- setMergedFileList((state) =>
326
- state.filter((item) => item.uid !== file.uid),
327
- );
328
- }
329
- };
330
-
331
- const handleReUpload = (file: UploadFile) => {
332
- const newFile = { ...file, status: 'ready' as const, percent: 0 };
333
- updateFileList(newFile);
334
- postFile(newFile);
335
- };
336
-
337
- const handlePreview = (file: UploadFile) => {
338
- if (onPreview) {
339
- onPreview(file);
340
- } else if (file.raw) {
341
- // Default preview behavior: open in new window
342
- const url = URL.createObjectURL(file.raw);
343
- window.open(url);
344
- setTimeout(() => URL.revokeObjectURL(url), 100);
345
- }
346
- };
347
-
348
678
  const renderUploadList = (extraProps?: object) => {
349
679
  if (!showUploadList) {
350
680
  return null;
@@ -360,6 +690,7 @@ const Upload = forwardRef<HTMLDivElement, UploadProps>((baseprops, ref) => {
360
690
  onRemove={handleRemove}
361
691
  onReUpload={handleReUpload}
362
692
  onPreview={handlePreview}
693
+ isPreviewable={(file) => Boolean(onPreview || file.url || file.raw)}
363
694
  {...extraProps}
364
695
  />
365
696
  );
@@ -368,7 +699,7 @@ const Upload = forwardRef<HTMLDivElement, UploadProps>((baseprops, ref) => {
368
699
  if (type === 'drag') {
369
700
  return (
370
701
  <div
371
- ref={ref}
702
+ ref={rootRef}
372
703
  {...rest}
373
704
  className={clsx(
374
705
  prefixCls,
@@ -382,7 +713,7 @@ const Upload = forwardRef<HTMLDivElement, UploadProps>((baseprops, ref) => {
382
713
  style={style}
383
714
  >
384
715
  <div
385
- className={clsx(`${prefixCls}-drag-container`)}
716
+ className={`${prefixCls}-drag-container`}
386
717
  onDrop={handleDrop}
387
718
  onDragOver={handleDragOver}
388
719
  onDragLeave={handleDragLeave}
@@ -403,7 +734,7 @@ const Upload = forwardRef<HTMLDivElement, UploadProps>((baseprops, ref) => {
403
734
  if (listType === 'picture-wall') {
404
735
  return (
405
736
  <div
406
- ref={ref}
737
+ ref={rootRef}
407
738
  {...rest}
408
739
  className={clsx(prefixCls, `${prefixCls}-picture-wall`, className)}
409
740
  style={style}
@@ -415,7 +746,7 @@ const Upload = forwardRef<HTMLDivElement, UploadProps>((baseprops, ref) => {
415
746
 
416
747
  return (
417
748
  <div
418
- ref={ref}
749
+ ref={rootRef}
419
750
  {...rest}
420
751
  className={clsx(
421
752
  prefixCls,