@react-magma/dropzone 13.0.1-rc.0 → 14.0.0-next.2

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.
@@ -31,6 +31,8 @@ import {
31
31
  useGenerateId,
32
32
  useIsInverse,
33
33
  styled,
34
+ Announce,
35
+ VisuallyHidden,
34
36
  } from 'react-magma-dom';
35
37
  import { CloudUploadIcon } from 'react-magma-icons';
36
38
 
@@ -188,13 +190,20 @@ const Wrapper = styled.div<{ isInverse?: boolean }>`
188
190
  padding: ${({ theme }) => theme.spaceScale.spacing01};
189
191
  `;
190
192
 
193
+ const PreviewList = styled.ul`
194
+ list-style: none;
195
+ padding: 0;
196
+ `;
197
+
198
+ const PreviewItem = styled.li``;
199
+
191
200
  export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
192
201
  (props, ref) => {
193
202
  const {
194
203
  accept,
195
204
  containerStyle,
196
205
  disabled,
197
-
206
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
198
207
  dropzoneOptions = {
199
208
  multiple: true,
200
209
  },
@@ -222,16 +231,19 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
222
231
 
223
232
  const [files, setFiles] = React.useState<FilePreview[]>([]);
224
233
  const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
234
+ const [announcement, setAnnouncement] = React.useState<string>('');
225
235
 
226
236
  const isInverse = useIsInverse(isInverseProp);
227
237
  const theme: ThemeInterface = React.useContext(ThemeContext);
228
238
  const i18n: I18nInterface = React.useContext(I18nContext);
229
239
  const id = useGenerateId(defaultId);
240
+ const helperMessageId = useGenerateId(`${id}-helper`);
241
+
242
+ const browseFileButtonRef = React.useRef<HTMLButtonElement>(null);
230
243
 
231
244
  const onDrop = React.useCallback(
232
245
  (acceptedFiles: FilePreview[], rejectedFiles: FileRejection[]) => {
233
- setFiles((files: FilePreview[]) => [
234
- ...files,
246
+ const newFiles = [
235
247
  ...acceptedFiles.map((file: FilePreview) =>
236
248
  Object.assign(file, {
237
249
  preview: URL.createObjectURL(file),
@@ -243,9 +255,25 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
243
255
  errors,
244
256
  })
245
257
  ),
246
- ]);
258
+ ];
259
+
260
+ setFiles((prevFiles: FilePreview[]) => [...prevFiles, ...newFiles]);
261
+
262
+ if (acceptedFiles.length > 0) {
263
+ const fileNames = acceptedFiles.map(file => file.name).join(', ');
264
+ const message =
265
+ acceptedFiles.length === 1
266
+ ? i18n.dropzone.fileAdded.replace(/\{fileName\}/g, fileNames)
267
+ : i18n.dropzone.filesAdded
268
+ .replace(/\{count\}/g, acceptedFiles.length.toString())
269
+ .replace(/\{fileNames\}/g, fileNames);
270
+
271
+ setAnnouncement(message);
272
+ }
273
+
274
+ browseFileButtonRef.current && browseFileButtonRef.current.focus();
247
275
  },
248
- []
276
+ [i18n]
249
277
  );
250
278
 
251
279
  const {
@@ -275,50 +303,84 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
275
303
 
276
304
  const inputProps = getInputProps({ id });
277
305
 
278
- const dragState: DragState = errorMessage
279
- ? 'error'
280
- : isDragAccept
281
- ? 'dragAccept'
282
- : isDragReject
283
- ? 'dragReject'
284
- : isDragActive
285
- ? 'dragActive'
286
- : 'default';
306
+ let dragState: DragState = 'default';
307
+
308
+ if (errorMessage) {
309
+ dragState = 'error';
310
+ } else if (isDragAccept) {
311
+ dragState = 'dragAccept';
312
+ } else if (isDragReject) {
313
+ dragState = 'dragReject';
314
+ } else if (isDragActive) {
315
+ dragState = 'dragActive';
316
+ }
287
317
 
288
318
  const handleRemoveFile = (removedFile: FilePreview) => {
289
- setFiles(files => files.filter(file => file !== removedFile));
290
- onRemoveFile &&
291
- typeof onRemoveFile === 'function' &&
319
+ setFiles(prevFiles => prevFiles.filter(file => file !== removedFile));
320
+
321
+ if (onRemoveFile && typeof onRemoveFile === 'function') {
292
322
  onRemoveFile(removedFile);
323
+ }
324
+
325
+ const message = i18n.dropzone.fileRemoved.replace(
326
+ /\{fileName\}/g,
327
+ removedFile.name
328
+ );
329
+
330
+ setAnnouncement(message);
331
+
332
+ browseFileButtonRef.current && browseFileButtonRef.current.focus();
293
333
  };
294
334
 
295
335
  const handleDeleteFile = (removedFile: FilePreview) => {
296
- setFiles(files => files.filter(file => file !== removedFile));
297
- onDeleteFile &&
298
- typeof onDeleteFile === 'function' &&
336
+ setFiles(prevFiles => prevFiles.filter(file => file !== removedFile));
337
+
338
+ if (onDeleteFile && typeof onDeleteFile === 'function') {
299
339
  onDeleteFile(removedFile);
340
+ }
341
+
342
+ const message = i18n.dropzone.fileDeleted.replace(
343
+ /\{fileName\}/g,
344
+ removedFile.name
345
+ );
346
+
347
+ setAnnouncement(message);
348
+
349
+ browseFileButtonRef.current && browseFileButtonRef.current.focus();
300
350
  };
301
351
 
302
- const setProgress = (props: { percent: number; file: FilePreview }) => {
303
- setFiles(files =>
304
- files.map(file =>
305
- file === props.file
352
+ const setProgress = (progressProps: {
353
+ percent: number;
354
+ file: FilePreview;
355
+ }) => {
356
+ setFiles(prevFiles =>
357
+ prevFiles.map(file =>
358
+ file === progressProps.file
306
359
  ? Object.assign(file, {
307
360
  processor: {
308
361
  ...file.processor,
309
- percent: `${props.percent}%`,
362
+ percent: `${progressProps.percent}%`,
310
363
  status: 'pending',
311
364
  },
312
365
  })
313
366
  : file
314
367
  )
315
368
  );
369
+
370
+ // Announce progress every 25% to avoid too many announcements
371
+ if (progressProps.percent > 0 && progressProps.percent % 25 === 0) {
372
+ const message = i18n.dropzone.fileUploading
373
+ .replace(/\{fileName\}/g, progressProps.file.name)
374
+ .replace(/\{percent\}/g, progressProps.percent.toString());
375
+
376
+ setAnnouncement(message);
377
+ }
316
378
  };
317
379
 
318
- const setFinished = (props: { file: FilePreview }) => {
319
- setFiles(files =>
320
- files.map(file =>
321
- file === props.file
380
+ const setFinished = (finishedProps: { file: FilePreview }) => {
381
+ setFiles(prevFiles =>
382
+ prevFiles.map(file =>
383
+ file === finishedProps.file
322
384
  ? Object.assign(file, {
323
385
  processor: {
324
386
  ...file.processor,
@@ -329,14 +391,25 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
329
391
  : file
330
392
  )
331
393
  );
394
+
395
+ // Announce successful upload
396
+ const message = i18n.dropzone.fileUploaded.replace(
397
+ /\{fileName\}/g,
398
+ finishedProps.file.name
399
+ );
400
+
401
+ setAnnouncement(message);
332
402
  };
333
403
 
334
- const setError = (props: { errors: FileError[]; file: FilePreview }) => {
335
- setFiles(files =>
336
- files.map(file =>
337
- file === props.file
404
+ const setError = (errorProps: {
405
+ errors: FileError[];
406
+ file: FilePreview;
407
+ }) => {
408
+ setFiles(prevFiles =>
409
+ prevFiles.map(file =>
410
+ file === errorProps.file
338
411
  ? Object.assign(file, {
339
- errors: props.errors,
412
+ errors: errorProps.errors,
340
413
  processor: { ...file.processor, status: 'error' },
341
414
  })
342
415
  : file
@@ -344,23 +417,6 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
344
417
  );
345
418
  };
346
419
 
347
- const formatError = (
348
- code: string | null,
349
- constraints: { maxFiles?: number; minFiles?: number }
350
- ) => {
351
- if (code === null) return null;
352
- const error = i18n.dropzone.errors[code];
353
-
354
- switch (code) {
355
- case 'too-many-files':
356
- return `${error.message} ${constraints.maxFiles} ${i18n.dropzone.files}.`;
357
- case 'too-few-files':
358
- return `${error.message} ${constraints.minFiles} ${i18n.dropzone.files}.`;
359
- default:
360
- return error.message;
361
- }
362
- };
363
-
364
420
  React.useEffect(
365
421
  () => () => {
366
422
  files.forEach(
@@ -371,38 +427,61 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
371
427
  );
372
428
 
373
429
  React.useEffect(() => {
430
+ const formatError = (
431
+ code: string | null,
432
+ constraints: { maxFiles?: number; minFiles?: number }
433
+ ) => {
434
+ if (code === null) return null;
435
+ const error = i18n.dropzone.errors[code];
436
+
437
+ switch (code) {
438
+ case 'too-many-files':
439
+ return `${error.message} ${constraints.maxFiles} ${i18n.dropzone.files}.`;
440
+ case 'too-few-files':
441
+ return `${error.message} ${constraints.minFiles} ${i18n.dropzone.files}.`;
442
+ default:
443
+ return error.message;
444
+ }
445
+ };
446
+
374
447
  const minFileError = minFiles && files.length < minFiles;
375
448
  const maxFileError = maxFiles && files.length > maxFiles;
376
449
 
377
- setErrorMessage(
378
- formatError(
379
- maxFileError
380
- ? 'too-many-files'
381
- : minFileError
382
- ? 'too-few-files'
383
- : null,
384
- { minFiles, maxFiles }
385
- )
386
- );
450
+ let errorCode: string | null = null;
451
+
452
+ if (maxFileError) {
453
+ errorCode = 'too-many-files';
454
+ } else if (minFileError) {
455
+ errorCode = 'too-few-files';
456
+ }
457
+
458
+ setErrorMessage(formatError(errorCode, { minFiles, maxFiles }));
387
459
 
388
460
  if (sendFiles && files.length > 0 && !maxFileError && !minFileError) {
389
- setFiles((files: FilePreview[]) => {
390
- return files.map((file: FilePreview) => {
391
- !file.errors &&
392
- !file.processor &&
393
- onSendFile &&
461
+ setFiles((prevFiles: FilePreview[]) => {
462
+ return prevFiles.map((file: FilePreview) => {
463
+ if (!file.errors && !file.processor && onSendFile) {
394
464
  onSendFile({
395
465
  file,
396
466
  onError: setError,
397
467
  onFinish: setFinished,
398
468
  onProgress: setProgress,
399
469
  });
470
+ }
400
471
 
401
472
  return file;
402
473
  });
403
474
  });
404
475
  }
405
- }, [sendFiles, files.length, onSendFile]);
476
+ }, [
477
+ sendFiles,
478
+ files.length,
479
+ onSendFile,
480
+ maxFiles,
481
+ minFiles,
482
+ i18n.dropzone.errors,
483
+ i18n.dropzone.files,
484
+ ]);
406
485
 
407
486
  return (
408
487
  <InverseContext.Provider value={{ isInverse }}>
@@ -419,7 +498,11 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
419
498
  messageStyle={{ minHeight: 0 }}
420
499
  data-testid={testId}
421
500
  >
422
- <HelperMessage theme={theme} isInverse={isInverse}>
501
+ <HelperMessage
502
+ id={helperMessageId}
503
+ theme={theme}
504
+ isInverse={isInverse}
505
+ >
423
506
  {helperMessage}
424
507
  </HelperMessage>
425
508
  <Container
@@ -454,6 +537,8 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
454
537
  isInverse={isInverse}
455
538
  onClick={open}
456
539
  style={{ margin: 0 }}
540
+ ref={browseFileButtonRef}
541
+ aria-describedby={helperMessage ? helperMessageId : undefined}
457
542
  >
458
543
  {i18n.dropzone.browseFiles}
459
544
  </Button>
@@ -479,6 +564,8 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
479
564
  onClick={open}
480
565
  style={{ margin: 0 }}
481
566
  variant={ButtonVariant.solid}
567
+ ref={browseFileButtonRef}
568
+ aria-describedby={helperMessage ? helperMessageId : undefined}
482
569
  >
483
570
  {i18n.dropzone.browseFiles}
484
571
  </Button>
@@ -486,19 +573,26 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
486
573
  )}
487
574
  </Container>
488
575
  </FormFieldContainer>
489
- {files.map((file: FilePreview) => (
490
- <Preview
491
- accept={accept}
492
- file={file}
493
- isInverse={isInverse}
494
- key={file.name}
495
- maxSize={maxSize}
496
- minSize={minSize}
497
- onDeleteFile={handleDeleteFile}
498
- onRemoveFile={handleRemoveFile}
499
- thumbnails={thumbnails}
500
- />
501
- ))}
576
+ <PreviewList>
577
+ {files.map((file: FilePreview) => (
578
+ <PreviewItem key={file.name}>
579
+ <Preview
580
+ accept={accept}
581
+ file={file}
582
+ isInverse={isInverse}
583
+ maxSize={maxSize}
584
+ minSize={minSize}
585
+ onDeleteFile={handleDeleteFile}
586
+ onRemoveFile={handleRemoveFile}
587
+ thumbnails={thumbnails}
588
+ />
589
+ </PreviewItem>
590
+ ))}
591
+ </PreviewList>
592
+
593
+ <VisuallyHidden>
594
+ <Announce>{announcement}</Announce>
595
+ </VisuallyHidden>
502
596
  </InverseContext.Provider>
503
597
  );
504
598
  }