React 컴포넌트로 alert, confirm, prompt 구현하기

엘리(Ellie)·2023년 7월 14일
3
post-thumbnail

브라우저 지원 함수인 alert, confirm, prompt를 실행하면 다이얼로그가 뜨고 상호작용이 끝나서 다이얼로그가 닫히면 그 결과를 return 받을 수 있다.
이 다이얼로그 함수는 사용이 간단한 대신 동기 함수라는 점과 UI 커스텀이 어렵다는 단점이 있어 활용도는 낮은 편이다. 동기 함수이기 때문에 다이얼로그가 닫히기 전까지 자바스크립트 실행이 멈춰 있게 되고, UI 커스텀이 어려우니 사용자에게 일관적인 경험 제공이 어려워진다.

그래서 이를 해결하기 위해 직접 커스텀 다이얼로그를 만들어 사용하기도 하는데, 이번 포스팅에서는 어떻게 이 다이얼로그들을 React 컴포넌트로 만들 수 있을까 고민하고 만들어 본 내용을 공유하려 한다.

전체 코드는 여기서 보실 수 있습니다.

목표

세 함수의 동작이 비슷하니 그 중에서 confirm 구현에 초점을 맞춰보자.

우리는 컴포넌트 안에서 아래 코드처럼 사용하고 싶다.

// confirm 함수 호출해서 Confirm 다이얼로그 띄우기
const confirmResult = confirm('are you sure?');
// 다이얼로그 결과 받기
console.log(confirmResult);

아이디어

confirm() 호출 시 기대 동작은 다음과 같다.

  1. Confirm 다이얼로그가 뜬다.
  2. 다이얼로그가 닫히면 결과를 return 한다.

Confirm 다이얼로그를 띄우기 위해서는 showDialog 같은 상태를 변경시키면 될 것 같고, 상호작용이 끝나는 시점에 결과를 return 하기 위해서는 Promise의 resolve를 이용하면 될 것 같다.

const confirm = (message?: string) => new Promise((resolve) => {
  // Confirm 다이얼로그 띄우기
  // resolve 함수를 Dialog의 close 핸들러로 등록
});

이 함수는 Confirm 다이얼로그와 밀접한 관련이 있으므로 Confirm 다이얼로그를 관리하는 컴포넌트에서 confirm 함수를 제공하고, 하위 컴포넌트에서 호출하게 하면 될 것 같다. 하위 컴포넌트에서 자유롭게 confirm 함수를 사용할 수 있도록 함수는 Context API로 관리하자.

구현

ConfirmContext

먼저 confirm 함수를 제공할 ConfirmContext를 만들자.

type Type = {
  confirm: (message?: string) => Promise<boolean>;
};

const ConfirmContext = createContext<Type>({
  confirm: () => new Promise((_, reject) => reject()),
});

타입은 브라우저 지원 confirm 함수 처럼 다이얼로그에 표시할 message를 선택적으로 받고, 확인/취소 선택 여부에 따라 boolean 값을 리턴하도록 했다.
초기값은 외부에서 초기화를 하지 않으면 사용할 수 없도록 강제하기 위해 항상 실패하는 Promise를 만들어 넣었다.

ConfirmDialog 컴포넌트

ConfirmDialog는 Confirm 다이얼로그를 제어하고 ConfirmContext를 제공하는 핵심 컴포넌트이다.

type ConfirmState = {
  message: string;
  onClickOK: () => void;
  onClickCancel: () => void;
};

const ConfirmDialog = ({ children }: { children: React.ReactNode }) => {
  const [state, setState] = useState<ConfirmState>();

  const confirm = (message?: string): Promise<boolean> => {
    return new Promise((resolve) => {
      // state를 변경해 Confirm 다이얼로그를 띄운다.
      setState({
        message: message ?? '',
        onClickOK: () => {
          // ok 클릭한 경우, 다이얼로그 닫고 true로 Promise 종료
          setState(undefined);
          resolve(true);
        },
        onClickCancel: () => {
          // cancel 클릭한 경우, 다이얼로그 닫고 false로 Promise 종료
          setState(undefined);
          resolve(false);
        },
      });
    });
  };

  return (
    <ConfirmContext.Provider value={{ confirm }}>
      {children}
      {/* state 여부에 따라 Confirm 다이얼로그 띄우기 */}
    </ConfirmContext.Provider>
  );
};

Confirm 다이얼로그에 필요한 상태는 ConfirmState로 구성해 한 번에 관리하게 했다.

Confirm 컴포넌트

이제 다이얼로그 UI를 담당하는 Confirm 컴포넌트를 만들자.

interface Props {
  message: string;
  onClickOK: () => void;
  onClickCancel: () => void;
}

const Confirm = ({ message, onClickOK, onClickCancel }: Props) => {
  return (
    <div className="dialog-container">
      <div className="dialog">
        <h2 className="title">Confirm</h2>
        <div className="text">{message}</div>
        <div className="buttons">
          <button onClick={onClickCancel}>cancel</button>
          <button onClick={onClickOK}>ok</button>
        </div>
      </div>
    </div>
  );
};

Confirm UI는 이후에 추가할 Alert, Prompt UI와 비슷하기 때문에 일반적인 구조로 만들었다.

이제 앞서 만든 ConfirmDialog에서 이 Confirm을 보여준다.

const ConfirmDialog = ({ children }: { children: React.ReactNode }) => {
  ...
  
  return (
    <ConfirmContext.Provider value={{ confirm }}>
      {children}
      {state && (
        <Confirm
          message={state.message}
          onClickOK={state.onClickOK}
          onClickCancel={state.onClickCancel}
        />
      )}
    </ConfirmContext.Provider>
  );
}

사용하기

이제 필요한 기능은 다 만들었으니 다이얼로그를 띄워보자!

먼저 최상위 컴포넌트 App.tsx에 가서 ConfirmDialog를 사용할 수 있도록 감싸 주었다.

function App() {
  return (
    <ConfirmDialog>
      <Home />
    </ConfirmDialog>
  );
}

그러면 <ConfirmDialog>의 하위 컴포넌트인 <Home>에서는 confirm 함수를 아래처럼 사용할 수 있다!

const Home = () => {
  const { confirm } = useContext(ConfirmContext);

  const onConfirmClick = async () => {
    const result = await confirm('are you sure?');
    console.log(result);
  };

  return (
    <main className="home">
      <h1>Home</h1>
      <button onClick={onConfirmClick}>다이얼로그 띄우기</button>
      </div>
    </main>
  );
};

추가 기능

목표한 기능은 구현했고, 이제 추가적으로 브라우저 confirm 동작과 비슷하게 아래 기능을 넣어주려고 한다.

  1. OK 버튼 auto focus
    : 다이얼로그를 띄우면 자동으로 'OK'에 포커스가 되어 있다.
  2. ESC로 다이얼로그 닫기
    : 다이얼로그가 떠 있을 때 ESC로 닫을 수 있다.
  3. 화면 이벤트 막기
    : 다이얼로그가 떠 있는 동안 다른 상호작용을 막는다.

OK 버튼 auto focus

이 기능은 간단히 OK 버튼에 autoFocus 속성을 추가하면 된다.

<button onClick={onClickOK} autoFocus>ok</button>

ESC로 다이얼로그 닫기

<Confirm> 에서 keydown 이벤트를 받아서 ESC 키를 처리하자.
ESC로 다이얼로그를 닫는 것도 취소 동작으로 간주한다.

const Confirm = (...) => {
  useEffect(() => {
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onClickCancel();
      }
    };

    document.addEventListener('keydown', handleEscape);

    return () => document.removeEventListener('keydown', handleEscape);
  }, [onClickCancel]);

  ...
};

화면 이벤트 막기

다이얼로그를 제외한 화면 이벤트를 어떻게 막을 수 있을까 고민하다가 화면 전체를 덮는 overlay 요소 하나를 추가해서 overlay에서 이벤트를 다 먹어버리는 것으로 처리할 수 있겠다고 생각했다.

<Confirm>에 overlay 요소를 하나 추가하고, 그 요소에서는 캡처링 단계에서 stopPropagation을 이용해 이벤트가 하위 요소로 넘어가는 것을 막는다.

const Confirm = (...) => {
  ...
  
  return (
    <div className="dialog-container">
      <div className="overlay" onClickCapture={(e) => e.stopPropagation()} />
      <div className="dialog">
        ...
    </div>
  );
});

결과

confirm-test-gif

이렇게 브라우저 지원 confirm 과 비슷한 동작을 하는 React 컴포넌트 Confirm을 만들어보았다! 같은 메커니즘으로 Alert, Prompt도 만들 수 있는데, 이에 대한 코드는 전체 코드에서 볼 수 있다.

profile
신기하고 재미있는 것 만들기를 좋아합니다 :)

2개의 댓글

comment-user-thumbnail
2023년 12월 15일

감사합니다 사랑합니다

답글 달기
comment-user-thumbnail
2024년 1월 19일

잘쓰겠습니다. 감사합니다.

답글 달기