@react-magma/dropzone 14.0.0-next.1 → 14.0.0-next.3

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
 
@@ -187,6 +189,14 @@ const Wrapper = styled.div<{ isInverse?: boolean }>`
187
189
  font-weight: 500;
188
190
  padding: ${({ theme }) => theme.spaceScale.spacing01};
189
191
  `;
192
+
193
+ const PreviewList = styled.ul`
194
+ list-style: none;
195
+ padding: 0;
196
+ `;
197
+
198
+ const PreviewItem = styled.li``;
199
+
190
200
  export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
191
201
  (props, ref) => {
192
202
  const {
@@ -221,16 +231,19 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
221
231
 
222
232
  const [files, setFiles] = React.useState<FilePreview[]>([]);
223
233
  const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
234
+ const [announcement, setAnnouncement] = React.useState<string>('');
224
235
 
225
236
  const isInverse = useIsInverse(isInverseProp);
226
237
  const theme: ThemeInterface = React.useContext(ThemeContext);
227
238
  const i18n: I18nInterface = React.useContext(I18nContext);
228
239
  const id = useGenerateId(defaultId);
240
+ const helperMessageId = useGenerateId(`${id}-helper`);
241
+
242
+ const browseFileButtonRef = React.useRef<HTMLButtonElement>(null);
229
243
 
230
244
  const onDrop = React.useCallback(
231
245
  (acceptedFiles: FilePreview[], rejectedFiles: FileRejection[]) => {
232
- setFiles((files: FilePreview[]) => [
233
- ...files,
246
+ const newFiles = [
234
247
  ...acceptedFiles.map((file: FilePreview) =>
235
248
  Object.assign(file, {
236
249
  preview: URL.createObjectURL(file),
@@ -242,9 +255,25 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
242
255
  errors,
243
256
  })
244
257
  ),
245
- ]);
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();
246
275
  },
247
- []
276
+ [i18n]
248
277
  );
249
278
 
250
279
  const {
@@ -274,50 +303,84 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
274
303
 
275
304
  const inputProps = getInputProps({ id });
276
305
 
277
- const dragState: DragState = errorMessage
278
- ? 'error'
279
- : isDragAccept
280
- ? 'dragAccept'
281
- : isDragReject
282
- ? 'dragReject'
283
- : isDragActive
284
- ? 'dragActive'
285
- : '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
+ }
286
317
 
287
318
  const handleRemoveFile = (removedFile: FilePreview) => {
288
- setFiles(files => files.filter(file => file !== removedFile));
289
- onRemoveFile &&
290
- typeof onRemoveFile === 'function' &&
319
+ setFiles(prevFiles => prevFiles.filter(file => file !== removedFile));
320
+
321
+ if (onRemoveFile && typeof onRemoveFile === 'function') {
291
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();
292
333
  };
293
334
 
294
335
  const handleDeleteFile = (removedFile: FilePreview) => {
295
- setFiles(files => files.filter(file => file !== removedFile));
296
- onDeleteFile &&
297
- typeof onDeleteFile === 'function' &&
336
+ setFiles(prevFiles => prevFiles.filter(file => file !== removedFile));
337
+
338
+ if (onDeleteFile && typeof onDeleteFile === 'function') {
298
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();
299
350
  };
300
351
 
301
- const setProgress = (props: { percent: number; file: FilePreview }) => {
302
- setFiles(files =>
303
- files.map(file =>
304
- 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
305
359
  ? Object.assign(file, {
306
360
  processor: {
307
361
  ...file.processor,
308
- percent: `${props.percent}%`,
362
+ percent: `${progressProps.percent}%`,
309
363
  status: 'pending',
310
364
  },
311
365
  })
312
366
  : file
313
367
  )
314
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
+ }
315
378
  };
316
379
 
317
- const setFinished = (props: { file: FilePreview }) => {
318
- setFiles(files =>
319
- files.map(file =>
320
- file === props.file
380
+ const setFinished = (finishedProps: { file: FilePreview }) => {
381
+ setFiles(prevFiles =>
382
+ prevFiles.map(file =>
383
+ file === finishedProps.file
321
384
  ? Object.assign(file, {
322
385
  processor: {
323
386
  ...file.processor,
@@ -328,14 +391,25 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
328
391
  : file
329
392
  )
330
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);
331
402
  };
332
403
 
333
- const setError = (props: { errors: FileError[]; file: FilePreview }) => {
334
- setFiles(files =>
335
- files.map(file =>
336
- 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
337
411
  ? Object.assign(file, {
338
- errors: props.errors,
412
+ errors: errorProps.errors,
339
413
  processor: { ...file.processor, status: 'error' },
340
414
  })
341
415
  : file
@@ -343,22 +417,6 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
343
417
  );
344
418
  };
345
419
 
346
- const formatError = (
347
- code: string | null,
348
- constraints: { maxFiles?: number; minFiles?: number }
349
- ) => {
350
- if (code === null) return null;
351
- const error = i18n.dropzone.errors[code];
352
- switch (code) {
353
- case 'too-many-files':
354
- return `${error.message} ${constraints.maxFiles} ${i18n.dropzone.files}.`;
355
- case 'too-few-files':
356
- return `${error.message} ${constraints.minFiles} ${i18n.dropzone.files}.`;
357
- default:
358
- return error.message;
359
- }
360
- };
361
-
362
420
  React.useEffect(
363
421
  () => () => {
364
422
  files.forEach(
@@ -369,37 +427,61 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
369
427
  );
370
428
 
371
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
+
372
447
  const minFileError = minFiles && files.length < minFiles;
373
448
  const maxFileError = maxFiles && files.length > maxFiles;
374
449
 
375
- setErrorMessage(
376
- formatError(
377
- maxFileError
378
- ? 'too-many-files'
379
- : minFileError
380
- ? 'too-few-files'
381
- : null,
382
- { minFiles, maxFiles }
383
- )
384
- );
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 }));
385
459
 
386
460
  if (sendFiles && files.length > 0 && !maxFileError && !minFileError) {
387
- setFiles((files: FilePreview[]) => {
388
- return files.map((file: FilePreview) => {
389
- !file.errors &&
390
- !file.processor &&
391
- onSendFile &&
461
+ setFiles((prevFiles: FilePreview[]) => {
462
+ return prevFiles.map((file: FilePreview) => {
463
+ if (!file.errors && !file.processor && onSendFile) {
392
464
  onSendFile({
393
465
  file,
394
466
  onError: setError,
395
467
  onFinish: setFinished,
396
468
  onProgress: setProgress,
397
469
  });
470
+ }
471
+
398
472
  return file;
399
473
  });
400
474
  });
401
475
  }
402
- }, [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
+ ]);
403
485
 
404
486
  return (
405
487
  <InverseContext.Provider value={{ isInverse }}>
@@ -416,7 +498,11 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
416
498
  messageStyle={{ minHeight: 0 }}
417
499
  data-testid={testId}
418
500
  >
419
- <HelperMessage theme={theme} isInverse={isInverse}>
501
+ <HelperMessage
502
+ id={helperMessageId}
503
+ theme={theme}
504
+ isInverse={isInverse}
505
+ >
420
506
  {helperMessage}
421
507
  </HelperMessage>
422
508
  <Container
@@ -451,6 +537,8 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
451
537
  isInverse={isInverse}
452
538
  onClick={open}
453
539
  style={{ margin: 0 }}
540
+ ref={browseFileButtonRef}
541
+ aria-describedby={helperMessage ? helperMessageId : undefined}
454
542
  >
455
543
  {i18n.dropzone.browseFiles}
456
544
  </Button>
@@ -476,6 +564,8 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
476
564
  onClick={open}
477
565
  style={{ margin: 0 }}
478
566
  variant={ButtonVariant.solid}
567
+ ref={browseFileButtonRef}
568
+ aria-describedby={helperMessage ? helperMessageId : undefined}
479
569
  >
480
570
  {i18n.dropzone.browseFiles}
481
571
  </Button>
@@ -483,19 +573,26 @@ export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
483
573
  )}
484
574
  </Container>
485
575
  </FormFieldContainer>
486
- {files.map((file: FilePreview) => (
487
- <Preview
488
- accept={accept}
489
- file={file}
490
- isInverse={isInverse}
491
- key={file.name}
492
- maxSize={maxSize}
493
- minSize={minSize}
494
- onDeleteFile={handleDeleteFile}
495
- onRemoveFile={handleRemoveFile}
496
- thumbnails={thumbnails}
497
- />
498
- ))}
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>
499
596
  </InverseContext.Provider>
500
597
  );
501
598
  }
@@ -218,7 +218,7 @@ export const Preview = forwardRef<HTMLDivElement, PreviewProps>(
218
218
  onClick={handleRemoveFile}
219
219
  variant={ButtonVariant.link}
220
220
  color={ButtonColor.secondary}
221
- aria-label={i18n.dropzone.removeFile}
221
+ aria-label={`${i18n.dropzone.removeFile} ${file.name}`}
222
222
  icon={<CloseIcon />}
223
223
  />
224
224
  </StatusIcons>
@@ -248,7 +248,7 @@ export const Preview = forwardRef<HTMLDivElement, PreviewProps>(
248
248
  onClick={handleDeleteFile}
249
249
  variant={ButtonVariant.link}
250
250
  color={ButtonColor.secondary}
251
- aria-label={i18n.dropzone.deleteFile}
251
+ aria-label={`${i18n.dropzone.deleteFile} ${file.name}`}
252
252
  icon={<DeleteIcon />}
253
253
  />
254
254
  </Transition>