보투게더 사용성 개선하기 (3) - 이미지 클릭 시 이미지 자세히 보기

보투게더·2023년 10월 25일
0
post-custom-banner

구현하게 된 계기

  1. 이미지가 작아서 확대해서 보고 싶은 마음이 들었습니다.
  2. 다른 사이트에서 이미지를 누르면 보통 확대된 이미지가 나오는데요. 보투게더 사이트에서 사용자가 예상한 동작이 작동하지 않으면 불편함을 느낄 것이라고 생각이 들었습니다.

원하는 동작

  1. 이미지를 누르면 이미지를 자세히 볼 수 있는 확대 창이 나온다.

구현 방법

  1. 웹 접근성을 위해서 dialog 태그로 구현했습니다
  2. dialog를 열고 닫기 위해서는 ref를 이용하기에 forwardRef를 이용해 ref를 인자로 받았습니다
  3. 이미지를 클릭하면 기존의 클릭 이벤트는 막아주었습니다. (보투게더의 경우 선택지 이미지를 클릭 시 투표가 되어서 막아주었습니다)
  4. 이미지 클릭 시 src를 state로 저장하고, dialog를 열어줍니다.
  5. 닫기 버튼 혹은 이미지 밖을 누르면 dialog를 닫아줍니다.

특별히 신경 쓴 점으로는 가로가 긴 이미지가 있을 수도 있고, 세로가 긴 이미지가 있을 수도 있는데 각각 사진 비율에 맞게 보여주기 위해 CSS에 신경을 썼습니다.

ImageZoomModal.tsx

import { ForwardedRef, MouseEvent, forwardRef } from 'react';

import cancel from '@assets/x_mark_black.svg';

import * as S from './style';

interface ImageZoomModalProps {
  src: string;
  handleCloseClick: (event: MouseEvent<HTMLDialogElement>) => void;
  closeZoomModal: () => void;
}

const ImageZoomModal = forwardRef(function ImageZoomModal(
  { src, handleCloseClick, closeZoomModal }: ImageZoomModalProps,
  ref: ForwardedRef<HTMLDialogElement>
) {
  return (
    <S.Dialog
      ref={ref}
      tabIndex={1}
      aria-label="이미지를 확대해서 볼 수 있는 창이 열렸습니다. 이미지 확대 창 닫기 버튼을 누르거나 ESC를 누르면 닫을 수 있습니다."
      aria-modal={true}
      onClick={handleCloseClick}
    >
      <S.Container>
        <S.HiddenCloseButton onClick={closeZoomModal}>이미지 확대 창 닫기</S.HiddenCloseButton>
        <S.CloseButton onClick={closeZoomModal} aria-label="이미지 확대 창 닫기">
          <S.IconImage src={cancel} alt="취소 아이콘" />
        </S.CloseButton>
        <S.Image src={src}></S.Image>
      </S.Container>
    </S.Dialog>
  );
});

export default ImageZoomModal;

ImageZoomModal.styles.ts

import { styled } from 'styled-components';

import { theme } from '@styles/theme';

export const Dialog = styled.dialog`
  position: fixed;

  margin: auto;

  overflow: visible;

  background: none;

  z-index: ${theme.zIndex.modal};

  &::backdrop {
    background-color: rgba(0, 0, 0, 0.35);
  }
`;

export const Container = styled.div`
  position: relative;

  width: 100%;
  height: 100%;
`;

export const HiddenCloseButton = styled.button`
  position: absolute;
  top: 0;
  right: 99999px;
`;

export const CloseButton = styled.button`
  display: flex;
  justify-content: center;
  align-items: center;

  position: absolute;
  top: -50px;
  left: 0;
  right: 0;

  width: fit-content;
  margin: 0 auto;
  padding: 8px;
  border-radius: 50%;

  transition: background-color 0.2s ease-in-out;

  background-color: rgba(255, 255, 255, 0.7);

  cursor: pointer;

  &:hover {
    background-color: rgba(255, 255, 255, 1);
  }
`;

export const IconImage = styled.img`
  width: 24px;
  height: 24px;
`;

export const Image = styled.img`
  width: 100%;
  height: 100%;
  max-height: 80vh;

  object-fit: contain;
`;

useDialog.tsx

import { MouseEvent, useRef } from 'react';

export const useDialog = () => {
  const dialogRef = useRef<HTMLDialogElement>(null);

  const openDialog = () => {
    if (!dialogRef.current) return;

    dialogRef.current.showModal();
  };

  const closeDialog = () => {
    if (!dialogRef.current) return;

    dialogRef.current.close();
  };

  const handleCloseClick = (event: MouseEvent<HTMLDialogElement>) => {
    const modalBoundary = event.currentTarget.getBoundingClientRect();

    if (
      modalBoundary.left > event.clientX ||
      modalBoundary.right < event.clientX ||
      modalBoundary.top > event.clientY ||
      modalBoundary.bottom < event.clientY
    ) {
      closeDialog();
    }
  };

  return { dialogRef, openDialog, closeDialog, handleCloseClick };
};

useImageZoomModal.tsx

import { MouseEvent, useState } from 'react';

import { useDialog } from './useDialog';

export const useImageZoomModal = () => {
  const [imageSrc, setImageSrc] = useState('');
  const { closeDialog, dialogRef, handleCloseClick, openDialog } = useDialog();

  const handleImageClick = (event: MouseEvent<HTMLImageElement>) => {
    event.stopPropagation();
    const src = event.currentTarget.src;
    setImageSrc(src);
    openDialog();
  };

  return {
    imageSrc,
    closeZoomModal: closeDialog,
    handleCloseClick,
    zoomModalRef: dialogRef,
    handleImageClick,
  };
};

사용 방법

function ImageZoomModalStory() {
  const { closeZoomModal, handleCloseClick, handleImageClick, imageSrc, zoomModalRef } =
    useImageZoomModal();

  return (
    <>
      <Container>
        {IMAGE_URL_LIST.map(item => (
          <Image key={item} src={item} onClick={handleImageClick} />
        ))}
      </Container>
      <ImageZoomModal
        src={imageSrc}
        closeZoomModal={closeZoomModal}
        handleCloseClick={handleCloseClick}
        ref={zoomModalRef}
      />
    </>
  );
}

구현 후 이미지 확대 창이 열린 사진

가로가 긴 이미지

세로가 긴 이미지

보투게더 ImageZoomModal 스토리북 링크

https://woowacourse-teams.github.io/2023-votogether/?path=/story/components-common-imagezoommodal--default

profile
Fun from Choice! 오늘도 즐거운 한 표
post-custom-banner

1개의 댓글

comment-user-thumbnail
2023년 11월 2일

이 기능 정말 편리하더라구요
이미지 안에 텍스트 있으면 확대해보고 싶었는데 덕분에 편리해졌어요
사용성 시리즈 킵고잉 -제로-

답글 달기