본문 바로가기
React.js

drag & drop 파일 커스텀 훅 제작

by Zih0 2022. 5. 22.

블로그를 시작하고 2번째로 올렸던 react-dropzone 라이브러리의 useDropzone hook을 입맛에 맞게 직접 구현해봤다.

https://zih0.tistory.com/2

React-dropzone

react-dropzone은 input을 감싼 태그(div)에 getRootProps()를 지정해준다.

div를 클릭했는데 어떻게 파일을 업로드하지? 라는 의문에 깃헙 코드를 찾아봤고

window.showOpenFilePicker() 라는 함수를 이용하여 구현하고 있었다. 

감싼 태그(div)에 click, keydown 이벤트 발생 시 해당 함수를 실행시켜 파일 업로드를 받는다.

해당코드

Difference

나는 위의 과정을

기존 html의 input 태그와 label 태그의 for-id 속성을 이용하여 구현하는게 낫지 않을까 생각해서 Custom Hook을 제작하게 되었다.

(+ 0604 : 밑에 다시 div와 input을 활용하는 방식으로 변경한 내용이 나온다.)

Implementaition

input

우선 input 태그부터 공략해보자.

inputRef를 생성하여 input의 type을 file로 지정해줬다.

그리고 files state를 만들어 파일을 업로드할 때마다 해당 state에 저장시키는 함수를 만들었고,

inputRef에 change 이벤트로 넣어줬다.

import { useCallback, useEffect, useRef, useState } from 'react';

function useFileDrop() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [files, setFiles] = useState<File[]>([]);

  const onChangeFile = useCallback(
    (e: Event) => {
      if (!(e.target as HTMLInputElement).files) return;

      const selectFiles = (e.target as HTMLInputElement).files as FileList;
      const uploadFiles = Array.from(selectFiles);

      setFiles((prevFiles) => [...prevFiles, ...uploadFiles]);
    },
    [files]
  );

  useEffect(() => {
    if (!inputRef.current) return;

    inputRef.current.setAttribute('type', 'file');

    inputRef.current.addEventListener('change', onChangeFile);
    return () => {
      inputRef.current?.removeEventListener('change', onChangeFile);
    };
  }, [inputRef]);

  return {
    inputRef,
    files,
  };
}

export default useFileDrop;

options

파일 업로드할 때 보통 우리는 multiple 속성과 accept 속성을 자주 사용한다.

이 속성을 hook 사용 시 입력 받아 사용할 수 있도록 해주었다.

import { useCallback, useEffect, useRef, useState } from 'react';

interface IOptions {
  accept?: string;
  multiple?: boolean;
}

function useFileDrop(options?: IOptions) {
  const inputRef = useRef<HTMLInputElement>(null);
  const [files, setFiles] = useState<File[]>([]);

  ...

  useEffect(() => {
    if (!inputRef.current || !options) return;

    if (options.accept) {
      inputRef.current.setAttribute('accept', options.accept);
    }

    if (options.multiple) {
      inputRef.current.setAttribute('multiple', 'multiple');
    }
  }, [inputRef, options]);

  ...

  return {
    inputRef,
    files,
  };
}

export default useFileDrop;

사용 예시

const { inputRef, files } = useFileDrop({ multiple: true, accept: 'image/*' });

label

다음으로는 label에 drag & drop 이벤트를 구현해보자.

drag & drop 이벤트에는 4가지가 있다.

dragenter , dragleave , dragover , drop

라벨에 파일을 드래그 했을 때,

라벨로부터 나갔을 때,

라벨에 마우스를 올리고 있을 때,

라벨에 파일을 내려놨을 때,

4가지에 대한 처리를 함수로 구현했다.

import { useCallback, useEffect, useRef, useState } from 'react';

interface IOptions {
  accept?: string;
  multiple?: boolean;
}

function useFileDrop(options?: IOptions) {
  const inputRef = useRef<HTMLInputElement>(null);
  const labelRef = useRef<HTMLLabelElement>(null);
  const [isDragActive, setIsDragActive] = useState(false);
  const [files, setFiles] = useState<File[]>([]);

  ...

  const onDragFile = useCallback(
    (e: DragEvent) => {
      if (!e?.dataTransfer?.files) return;

      const selectFiles = e.dataTransfer.files;
      const uploadFiles = Array.from(selectFiles);

      setFiles((prevFiles) => [...prevFiles, ...uploadFiles]);
    },
    [files]
  );

  const onDragEnter = useCallback((e: DragEvent) => {
    e.preventDefault();
    e.stopPropagation();

    setIsDragActive(true);
  }, []);

  const onDragLeave = useCallback((e: DragEvent) => {
    e.preventDefault();
    e.stopPropagation();

    setIsDragActive(false);
  }, []);

  const onDragOver = useCallback((e: DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const onDrop = useCallback(
    (e: DragEvent) => {
      e.preventDefault();
      e.stopPropagation();

      onDragFile(e);
      setIsDragActive(false);
    },
    [onDragFile]
  );

  ...

  useEffect(() => {
    if (!labelRef.current) return;

    labelRef.current.addEventListener('dragenter', onDragEnter);
    labelRef.current.addEventListener('dragleave', onDragLeave);
    labelRef.current.addEventListener('dragover', onDragOver);
    labelRef.current.addEventListener('drop', onDrop);

    return () => {
      labelRef.current?.removeEventListener('dragenter', onDragEnter);
      labelRef.current?.removeEventListener('dragleave', onDragLeave);
      labelRef.current?.removeEventListener('dragover', onDragOver);
      labelRef.current?.removeEventListener('drop', onDrop);
    };
  }, [labelRef, onDragEnter, onDragLeave, onDragOver, onDrop]);

  ...

  return {
    inputRef,
    labelRef,
    files,
    isDragActive,
  };
}

export default useFileDrop;

 

 

import React, { useEffect } from 'react';
import useFileDrop from '@zih0/use-file-drop';

const App () => {
  const { inputRef, labelRef, files, isDragActive } = useFileDrop();

  return (
    <div>
      <input ref={inputRef} id="upload" />
      <label ref={labelRef} htmlFor="upload">
        {isDragActive ? <span>Drop the file!</span> : <span>Drag and drop the file.</span>}
      </label>
    </div>
  )
};

전체코드

https://github.com/Zih0/use-file-drop/blob/main/src/index.ts

주절주절

라이브러리를 처음으로 배포해봤다...!

microbundle이라는 번들러를 사용했고, 아래 글을 참고했다.

https://codewithhugo.com/microbundle-typescript-npm-module/

 

Use microbundle for a TypeScript npm module · Code with Hugo

<p>For those looking to write a package and publish it to npm, TypeScript + microbundle is a low-friction way to build a high-quality library.</p> <p>I’ve

codewithhugo.com


+ 0529

 

@zih0/use-file-drop

react hook for drag & drop input file. Latest version: 0.0.5, last published: 7 days ago. Start using @zih0/use-file-drop in your project by running `npm i @zih0/use-file-drop`. There are no other projects in the npm registry using @zih0/use-file-drop.

www.npmjs.com

엥???? 위클리 다운로드 수가 222라는게 믿기지 않는다...👀  진짠가?? 

검색해보니 봇인 듯 싶다

 


+ 0604

 

웹 접근성에 대한 관점

input["file"] 을 display: none 하고 label을 사용하는 방법은 웹 접근성이 좋지 않다는 것을 알았다. (관련 MDN 링크)

1. foucs가 되지 않는다.

2. label은 clickable한 요소가 아니기 때문에, space나 enter 같은 Keydown 이벤트가 일어나지 않는다. 

 

그러면 어떻게 해야하는가?

 

<button>

button 요소를 사용하여 클릭시 input 요소의 click 이벤트를 발생시키는 방법을 사용할 수 있다.

가장 간단하다. button을 사용하면 keydown 이벤트를 따로 설정하지 않아도 된다.

// HTML
<button>파일 업로드</button>
<input type="file" />



// JS
document.querySelector("button").addEventListener("click", () => {
  document.querySelector("input").click();
});

 

 

div ? label ?

button 태그가 아닌 다른 태그를 쓸 경우엔 아래와 같이 tabindex를 추가하고 키다운 이벤트를 추가해주자.

tabindex를 쓰면 tab 키로 해당 Element를 focus 상태로 만들 수 있다. 

이 부분은 트위터의 이미지 업로드를 보고 알 수 있었다.

// HTML

<div role="button" tabindex="0">파일 업로드</div>
<input type="file" />



// JS 

const buttonElement = document.querySelector("div");

buttonElement.addEventListener("click", () => {
  document.querySelector("input").click();
});

// 키다운 이벤트 추가
buttonElement.addEventListener("keydown", (event) => {
  if (!buttonElement.isEqualNode(event.target)) {
    return;
  }
  
  // Space Or Enter
  if (event.key === " " || event.key === "Enter") {
    event.preventDefault();
    document.querySelector("input").click();
  }
});

이 부분은 트위터의 업로드 방식을 참고했다.

 

label태그를 그대로 사용하고 키다운 이벤트와 tabindex 설정해주는건 어떤가?

// HTML

<label for="file-upload" role="button" tabindex="0">파일 업로드</label>
<input id="file-upload" type="file" />


// JS 
const labelElement = document.querySelector("label");

// 키다운 이벤트 추가
labelElement.addEventListener("keydown", (event) => {
  if (!labelElement.isEqualNode(event.target)) {
    return;
  }
  
  // Space Or Enter
  if (event.key === " " || event.key === "Enter") {
    event.preventDefault();
    document.querySelector("input").click();
  }
});

 

결론

div를 쓰고 클릭 이벤트도 추가해주는게 나을까? label을 쓰는게 나을까?

처음에는 클릭 이벤트를 따로 추가해주지 않아도 되는 label을 쓰는게 더 나은 방안이라고 생각을 했지만,

다시 한번 생각해보니 클릭 이벤트를 따로 지정해주는 방식이 어떤 태그에도 쓰일 수 있게 되는 확장성을 가지게 된다.

 

react-dropzone의 예시로 div 태그가 있어서 'div냐 label이냐' 이분법적으로만 시야가 좁아졌었던 것 같다.

왜 react-dropzone이 저런 방식으로 구현했는지 어느정도 알게 된 것 같다!

 

+ HTML Attribute와 웹 접근성에 대해 생각하게 되는 계기가 되었다.

+ "event.keyCode"는 deprecated 되었다고한다.

그래서 권장하는 "event.key"를 이용해 Space와 Enter에 대한 이벤트 처리를 해주는게 바람직하다고 생각한다.

(관련링크)


+ 0606

react-dropzone은 window.showOpenFilePicker() 를 default로 파일 업로드 창을 띄운다.

하지만 이슈를 보면 Linux 환경에서의 크롬에선 동작하지 않는다는 것을 알 수 있다. 아직 실험적인 기능이기에 지원하지 않는 브라우저도 많아 보인다. (링크)

 

react-dropzone 메인테이너는 showOpenFilePicker가 프로미스 함수여서 catch 상황에 대한 cancel 처리를 위해 도입한 듯 싶다.

하지만 아직은 시기상조라는게 일반적인 반응들이었고, 메인테이너는 해당 기능에 대해 투표를 받고 있다. (링크)

아마 조만간 default 방식을 수정할 것으로 보인다.

 

 

 

 

 

댓글