React 트랜지션을 이용한 모달팝업 컴포넌트

React

웹 접근성 이슈로 인한 displaytransition 문제

작업을 진행할 때, 웹 접근성 이슈로 displaytransition을 사용해야 하는 경우가 생긴다.(다른 방법 있나? 있음 알려주쇼)

그런데 다들 알고 있겠지만, display: none 상태에서 transition이 작동하지 않는다.

 

display 속성 자체가 애니메이션화(transition)할 수 없기 때문이고, display 속성은 요소가 화면에 표시될지 여부를 결정하는 속성인데, 이는 이진 상태(보이거나 보이지 않음)로 처리되기 때문이다.

 

따라서 display: none에서 display: block 등으로 변경할 때 중간 상태가 없으므로 transition 애니메이션이 작동하지 않는다. 

 

뭐 암튼 그렇다고 나도 잘 몰라 🥲 

 

top, center, bottom을 적용해놨고 1개했는데 3개를 쓸 수 있는 효과~

 

골치 아픈 상황

여하튼 이게 참 골때리는 게, 꼭 displaytransition을 공존해서 사용해야 하는 경우가 생긴다.

CSS와 JS의 부분에 아직 리팩토링을 더 진행해야 하는 부분이 있지만, 사실 CSS는 건들지도 않음... 😂

결론

거, 간단하게 좀 갑시다.

 

 

import styled, { css } from 'styled-components';

// 공통 스타일 정의
const CommonSkin = css`
  // 딤
  position: fixed;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 100%;
  flex-direction: column;

  z-index: 1000;
  overflow: hidden;
  transition: opacity 0.5s ease, visibility 0.5s ease;
  background-color: rgba(0, 0, 0, 0.5);
  > .inner {
    height: 0; /* 초기 높이 */
    box-sizing: border-box;
    transition: height 0.3s ease-in-out; /* 트랜지션 애니메이션 settimeout의 일치시킨다. */
    background: #fff;
    overflow: hidden;
  }

  &.isActive {
    display: flex;
    visibility: visible; /* 보이도록 설정 */
    opacity: 1; /* 불투명하도록 설정 */

    > .inner {
      height: auto; /* 높이를 자동으로 설정하여 콘텐츠가 보이도록 설정 */
    }
  }

  &.popup-top {
    justify-content: flex-start;
  }

  &.popup-center {
    justify-content: center;
    > .inner {
      width: 80%;
      margin: 0 auto;
    }
  }
  &.popup-bottom {
    justify-content: flex-end;
  }
`;

const BasicSkin = css`
  /* 추가적인 BasicSkin 스타일이 여기에 들어갈 수 있습니다. */
`;

const ThemeSkin = css`
  /* 추가적인 ThemeSkin 스타일이 여기에 들어갈 수 있습니다. */
`;

const Style = (type) => {
  switch (type) {
    case 'BasicSkin':
      return styled.div`
        ${CommonSkin}
        ${BasicSkin}
      `;
    case 'ThemeSkin':
      return styled.div`
        ${CommonSkin}
        ${ThemeSkin}
      `;
    default:
      return styled.div`
        ${CommonSkin}
        ${BasicSkin}
      `;
  }
};

export default Style;
// Modal 컴포넌트

import React, { useState, useEffect, useRef } from 'react';
import Style from './ModalStyle'; // CSS 모듈을 임포트합니다.
import { IconClosed } from '../../assets/images/common/IconSet';
import Heading from '../Heading/Heading';

const Modal = Style('BasicSkin'); // 스킨 타입에 맞게 스타일 적용

const ModalWrap = ({ title, content, className, modalOpen, closeModal }) => {
  const popupRef = useRef(null);
  const [isClosing, setIsClosing] = useState(false); // 모달 닫기 상태를 관리

  // 키보드 포커스 이동 웹 접근성 관련
  useEffect(() => {
    const popup = popupRef.current;
    if (!popup) return;
    // 포커스 가능한 요소들 찾기
    const focusableElements = popup.querySelectorAll(
      'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex]:not([tabindex="-1"]), *[contenteditable]'
    );

    if (focusableElements.length === 0) return;

    const firstFocusableElement = focusableElements[0];
    const lastFocusableElement = focusableElements[focusableElements.length - 1];

    const handleTabKey = (e) => {
      if (e.key === 'Tab') {
        if (e.shiftKey) {
          // Shift + Tab: 역방향 이동
          if (document.activeElement === firstFocusableElement) {
            e.preventDefault();
            lastFocusableElement.focus();
          }
        } else {
          // Tab: 순방향 이동
          if (document.activeElement === lastFocusableElement) {
            e.preventDefault();
            firstFocusableElement.focus();
          }
        }
      }
    };

    // 팝업 열렸을 때 첫 번째 포커스 가능한 요소로 포커스 이동
    firstFocusableElement?.focus();

    // 포커스 트랩을 위한 이벤트 핸들러 등록
    document.addEventListener('keydown', handleTabKey);

    return () => {
      // cleanup
      document.removeEventListener('keydown', handleTabKey);
    };
  }, [modalOpen]);

  // 탑, 바톰 팝업이 열릴때 실행
  useEffect(() => {
    if (popupRef.current) {
      const isChecked = popupRef.current.classList.contains('popup-center');
      if (isChecked) return;
    }

    if (modalOpen && popupRef.current) {
      const inner = popupRef.current.querySelector('.inner');
      if (inner) {
        inner.style.height = '0px'; // 애니메이션 시작 전 높이를 0으로 설정
        const height = inner.scrollHeight; // 실제 높이를 계산
        inner.style.height = `${height}px`; // 높이를 설정하여 슬라이드 다운 애니메이션 적용
      }
    }
  }, [modalOpen]); // 모달이 열릴 때만 실행되도록 합니다.

  // 팝업이 닫힐 때
  const handleClose = () => {
    if (popupRef.current) {
      const isChecked = popupRef.current.classList.contains('popup-center');
      if (isChecked) {
        // 디폴트 "센터" 팝업이 닫힐때
        closeModal(); // 전달받은 closeModal을 닫는다.
        setIsClosing(false); // 닫히는 상태를 다시 false로 설정
      } else {
        // 탑, 바톰 팝업이 닫힐때
        setIsClosing(true); // 닫히는 상태를 true로 설정합니다.
        const inner = popupRef.current.querySelector('.inner');
        if (inner) {
          inner.style.height = '0px'; // 높이를 0으로 설정하여 닫히는 애니메이션 실행
        }
        // 애니메이션이 끝난 후에 모달을 닫습니다.
        setTimeout(() => {
          closeModal(); // 전달받은 closeModal을 닫는다.
          setIsClosing(false); // 닫히는 상태를 다시 false로 설정
        }, 300); // 트랜지션 시간과 일치시킵니다 (0.3초)
      }
    }
  };

  if (!modalOpen && !isClosing) return null; // 모달이 보이지 않을 때는 렌더링 안함.

  return (
    <Modal
      className={
        modalOpen ? `isActive ${className || 'popup-center'}` : className || 'popup-center'
      }
      ref={popupRef}
      style={{ display: modalOpen || isClosing ? 'flex' : 'none' }}
    >
      <div className={'inner'}>
        <header>
          <Heading level={2} title={title || '팝업 기본 타이틀 입니다.'} />
          <button className={'btn-closed'} onClick={handleClose}>
            <IconClosed />
            <span className={'blind'}>닫기</span>
          </button>
        </header>
        <main>{content || '팝업 컨텐츠를 넣어주세요.'}</main>
        <footer>
          <button className="close" onClick={handleClose}>
            close
          </button>
        </footer>
      </div>
    </Modal>
  );
};

export default ModalWrap;
import React, { useEffect, useState, useRef } from 'react';
import Button from '../../Components/Button/Button';
import Modal from '../../Components/Popup/Modal';

const MainWrap = Style(APP_SKIN);

const PubMain = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);

  // 모달 팝업 포커스 이동 관련
  const triggerButtonRef = useRef(null); // 버튼의 참조를 저장하기 위한 ref

  // 모달 열기
  const openModal = (e) => {
    triggerButtonRef.current = e.currentTarget; // 클릭된 버튼 참조를 저장
    setIsModalOpen(true);
    console.log('열기 버튼 클릭 ');
  };

  // 모달 닫기
  const closeModal = () => {
    setIsModalOpen(false);
    console.log('닫기 버튼 클릭');
  };

  // 모달이 닫힌 후에 포커스를 저장된 버튼으로 이동
  useEffect(() => {
    if (!isModalOpen && triggerButtonRef.current) {
      triggerButtonRef.current.focus(); // 저장된 버튼으로 포커스 이동
    }
  }, [isModalOpen]);

  const modalItem = {
    title: '타이틀',
    content: '컨텐츠 그리고 또는 컴포넌트',
    className: 'popup-top' // popup-top, popup-bottom 를 제공하고 default는 center이 된다.
  };

  return (
    <>

    {/*모달 팝업 샘플 테스트 */}
    <Button onClick={openModal} name={'모달 팝업'} />
    <Modal
      title={modalItem.title}
      content={modalItem.content}
      className={modalItem.className}
      modalOpen={isModalOpen}
      closeModal={closeModal}
    />

    </>
  );
};

export default PubMain;