【React Hook Form / Yup / TypeScript】ドラッグ&ドロップできるファイルアップロード

2023年10月31日 14:31

【React Hook Form / Yup / TypeScript】ドラッグ&ドロップできるファイルアップロード

React Hook FormとTypeScript を活用して、React でドラッグ&ドロップ ファイルアップロード コンポーネントを構築する方法。

目次
完成品

完成品

  • React Hook Form ^7.47.0
  • Yup ^1.3.2

通常時

マウスオーバー時

ファイルアップロード後

ファイルアップロードコンポーネント

import { ChangeEvent, DragEvent, useState } from 'react';
import { Button, IconButton, Paper, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import { bytesToSize } from '@/utils/fileSize';
import CancelIcon from '@mui/icons-material/Cancel';
import CheckCicleIcon from '@mui/icons-material/CheckCircle';
import { Control, useController } from 'react-hook-form';


const VisuallyHiddenInput = styled('input')({
  clip: 'rect(0 0 0 0)',
  clipPath: 'inset(50%)',
  height: 1,
  overflow: 'hidden',
  position: 'absolute',
  bottom: 0,
  left: 0,
  whiteSpace: 'nowrap',
  width: 1,
});

type props = {
  files: File[]
  setFiles: Function
  name: string
  control: Control
}

export default function FileUpload({ files, setFiles, name, control }: props) {
  const {
    field: { ref, onChange, ...rest },
    fieldState: { invalid, error,  }
  } = useController({ name, control })

  const [isOver, setIsOver] = useState(false);

  // Define the event handlers
  const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
    event.preventDefault();
    setIsOver(true);
  };

  const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
    event.preventDefault();
    setIsOver(false);
  };

  const handleDrop = (event: DragEvent<HTMLDivElement>) => {
    event.preventDefault();
    setIsOver(false);

    // Fetch the files
    const droppedFiles = Array.from(event.dataTransfer.files);


    if(droppedFiles.length > 1) {
      console.log('ファイルは1つだけ選択してください。')
      return
    }

    readFiles(droppedFiles)
  };

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    const selectedFiles = Array.from(event.target.files)

    readFiles(selectedFiles)
  }

  const readFiles = async (files) => {
    console.log(files)
    setFiles(files)

    await files.forEach((file) => {
      const reader = new FileReader()

      reader.onloadend = () => {
        console.log(reader.result);
      };

      reader.onerror = () => {
        console.error('There was an issue reading the file.');
      };

      reader.readAsDataURL(file);
      return reader;
    })
  }

  return (
    <>
      <Paper
        onDragOver={handleDragOver}
        onDragLeave={handleDragLeave}
        onDrop={handleDrop}
        sx={{
          padding: 2,
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          alignItems: 'center',
          minWidth: 300,
          backgroundColor: isOver ? (theme) => theme.palette.grey[300] : undefined,
          transition: '.5s all'
        }}
      >
        <Typography fontSize={54} color="grey" lineHeight={1}>
          { error || files.length === 0
            ? <CloudUploadIcon
              fontSize="inherit"
              sx={{
                transition: '.5s all',
                transform: isOver && 'translateY(-10%)'
              }}
            />
            : <CheckCicleIcon fontSize="inherit" color="success" />
          }
        </Typography>
        {files.length === 0
          ? <Button component="label" variant="contained">
            Upload file
            <VisuallyHiddenInput type="file" onChange={handleChange} name={name} />
          </Button>
          : <>
            {files.map(file => (<Typography key={file.name} fontSize="small">{file.name} / {bytesToSize(file.size)}</Typography>))}
            <IconButton color="error" onClick={() => setFiles([])}><CancelIcon /></IconButton>
          </>
        }
      </Paper>
      { error && <Typography fontSize="small" color="error">{error.message}</Typography>}
    </>
  );
}
  1. VisuallyHiddenInput設置
  2. useControllerを使用して、onChangeやerrorを参照できるように
  3. isOverで、ドラッグ&ドロップのイベントを検知
  4. onDropとonChangeイベントでファイル読み込みを走らせる

コンポーネントの呼び出し

const {
  handleSubmit,
  control,
  setValue,
} = useForm<FormValues>({
  resolver: yupResolver(schema),
  mode: 'onBlur',
});

const [ files, setFiles ] = useState<File[]>([]);

const handleSetFiles = async (fileList: File[]) => {
  setFiles(fileList)
  setValue('files', fileList, { shouldValidate: true })
}

<FileUpload
	files={files}
	setFiles={handleSetFiles}
	name="files"
	control={control}
/>

注意すべきポイントは、自作関数handleSetFiles。input[type=hidden]は、reactが変更を検知しないため、react hook formsのsetValueを使って更新する。

バリデーション

今回はYupを使用。

const schema = yup.object({
  files: .array()
    .length(1, 'ファイルを選択してください。')
    .test(
      'is-pdf',
      'CSVファイルを選択してください。',
      (value: [File]) => {
        return value[0] && value[0].type === 'text/csv'
      }
    )
    .test(
      'is-size',
      `ファイルサイズは、${bytesToSize(maxSize)}以下にしてください。`,,
      (value: [File]) => value[0] && value[0].size <= maxSize
    ),
});

ファイルサイズ関数

const units = ['Bytes', 'KB', 'MB', 'GB', 'TB'];

type unit = (typeof units)[number]

export const bytesToSize = (bytes: number): string => {
  if (bytes === 0) return 'n/a';
  const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
  if (i === 0) return `${bytes} ${units[i]}`;
  return `${(bytes / (1024 ** i)).toFixed(1)} ${units[i]}`;
}

export const SizeToBytes = (size: number, unit: unit): number => {
  return size * 1024 ** units.indexOf(unit)
}
まとめ

setValueがポイント

プログラミング記事の一覧に戻る