ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [React] Container Component 제작 - ClickOutside, Tooltip
    Front-End(Web)/React - 프레임워크(React, Next) 2022. 7. 5. 02:10
    반응형

    이번 포스팅 역시 과제를 진행하면서 새로이 경험한 React 개발내용을 복기하면서 정리하는 글이다.

    하나는 요소에 마우스를 호버했을 때 표현되는 툴팁, 다른 하나는 박스 외부를 클릭했을 때 닫히는 Click-Outside 기능이다.

     

    기능적으로 연관성은 없으나, 코드상으로 둘 다 Container Component를 사용했다는 공통점이 있다.

    또한, 회사에서는 둘 다 Vue의 Custom Directive로 구현되었는데, React에서는 어떻게 구현할지 리서치해보니 생각보다 컨테이너로 쉽게 구현이 되어서 이를 소개해보려고 한다.

     


     

    💙 Click Outside 구현

    * 출처 : https://blog.logrocket.com/detect-click-outside-react-component-how-to/

     

    위처럼, 특정버튼을 누르면 팝업창이 표현되고, 창 외부를 클릭하면 닫히는 UX를 제공하기 위한 기능이다.

    검색창의 자동완성창, 프로필 등 클릭시 서브메뉴 등 다양한 케이스들에 적용할 수 있는 기능이다.

     

    최초엔, Custom Hooks를 달아서 해당 state에 따라 on/off를 제어하려고 했다.

    하지만, 팝업창의 on/off state나 제어함수를 부모 컴포넌트에서 필요로 하는 경우도 존재했기에,

    팝업창 컴포넌트만 감싸기 위한 Click Ouside 기능 전용의 컨테이너를 만드는 방법을 채택했다.

     

    - 코드구현

    import React, { useEffect, useRef } from 'react'
    import styled from 'styled-components'
    
    type Props = {
      children: React.ReactNode | React.ReactNode[]
      handleClickOutside: () => void;
    }
    
    const useClickOutside = (ref: React.RefObject<HTMLSpanElement>, handler: () => void) => {
      useEffect(() => {
        const handleClickOutside = (e: React.MouseEvent<HTMLElement>) => {
          if (ref.current && !ref.current.contains(e.target)) handler();
        }
    
        document.addEventListener("click", handleClickOutside);
        return () => {
          document.removeEventListener("click", handleClickOutside);
        };
      }, [ref])
    }
    
    const RClickOutside: React.FC<Props> = ({ children, handleClickOutside }) => {
      const ref = useRef<HTMLSpanElement>(null);
      useClickOutside(ref, handleClickOutside);
    
      return (
        <ScClickOutside ref={ref}>
          {children}
        </ScClickOutside>
      )
    }
    
    export default RClickOutside
    • Props로는 Container 이므로 children이 무조건 포함되어야하고, 외부를 누를때 실행할 handleClickOutside 함수를 받는다.
    • useClickOutside는 기존에 Custom Hooks로 분리하려고한 부분으로, 인자는 ref(대상 요소), handler() 함수 2개를 받는다.
    • useEffect를 통해 click 이벤트 리스너를 추가/제거 하고, 이벤트 핸들러는 handleClickOutside() 함수가 된다.
      핸들러는 e(이벤트 객체)를 인자로 받아, ref.current 엘리먼트가 존재하되 e.target이 여기에 포함되지 않는 경우에만 handler() 함수를 실행하는 것이다.
    • <RClickOutside>Click-Outside를 적용할 컴포넌트를 감싸는 컨테이너다. 해당 컴포넌트에 ref를 걸고, useClickOutside 첫 번째 인자로 넘겨준다.
    • Props로 바깥을 클릭했을 때 실행할 handleClickOutside() 함수를 받아, useClickOutside 두 번째 인자로 넘겨준다.

     

    👉 사용예시

    import React, { useState, useRef } from 'react'
    import SearchAutoComplete from './SearchAutoComplete';
    import RClickOutside from '@/views/components/common/hoc/RClickOutside';
    
    const HeaderSearch = () => {
      const [searchName, setSearchName] = useState<string>('');
      const [onAutoComplete, setOnAutoComplete] = useState<boolean>(false);
    
      const routeToSearchSummoner = (name: string) => {
        if (!name.trim()) alert('소환사명을 입력해주세요!')
        else {
          setOnAutoComplete(false);
          goToSummonerPage(name);
        }
      }
    
      return (
        <ScHeaderSearch className='header-search'>
          <RClickOutside handleClickOutside={() => setOnAutoComplete(false)}>
    		// 내부 엘리먼트..
            {onAutoComplete && <SearchAutoComplete name={searchName} onClickItem={routeToSearchSummoner} />}
          </RClickOutside>
        </ScHeaderSearch>
      )
    }
    
    export default HeaderSearch

    검색바의 자동완성창을 닫기 위해 <RClickOutside> 를 적용한 부분이다.

    onAutoComplete 상태값이 true여야만 <SearchAutoComplete> 컴포넌트가 보이므로, 핸들러 함수는 이를 false로 바꿔주는 역할로 넘겨줬다.

     

    회사 Vue에서 v-click-outside를 구현한 코드를 상기하며 난이도가 있을거라 우려했으나, 생각보다 구현에 큰 어려움은 없었다.

    * RClickOutside 라는 용어도, Vue의 v-click-outside 라이브러리를 벤치마킹한 것이다!

     


     

    💙 Tooltip 구현

    이렇게 마우스를 Hover 했을 때, 말풍선 등 문구가 뜨는 tool-tip을 만들어야했고, 마찬가지로 Container가 감싸는 형태로 구현했다.

     

    일전에 선배가 Select Box를 만들때, 펼쳐지는 박스도 최상단 엘리먼트로 올려서 구현했었던 기억이 있다.

     

    마찬가지로, 이번 툴팁의 구현컨셉도 2가지로, 1) 컨테이너의 absolute로 문구 표현, 2) React-Portal을 통한 최상단에 문구 표현 이다.

    물론, 1번의 방법으로도 기본적인 툴팁을 구현이 가능하나, 왜 2번의 방법이 더 권장되는지까지 기술해보도록 하겠다.

     

     

    1. 코드구현 : 컨테이너의 absolute로 문구 표현

    import React, { useState } from 'react'
    import styled from 'styled-components'
    
    type Props = {
      children: React.ReactNode | React.ReactNode[];
      message: string;
      className?: string;
    }
    
    const RTooltip: React.FC<Props> = ({ children, message, className }) => {
      return (
        <ScRTooltip className={className}>
          {children}
          <p className='tooltip'>{message}</p>
        </ScRTooltip>
      )
    }
    
    const ScRTooltip = styled.span`
      position: relative;
      
      .tooltip {
        position: absolute;
        left: -100%;
        top: -100%;
        min-width: 150px;
        min-height: 40px;
        padding: 7px;
        background-color: #777;
        color: #fff;
        font-size: 12px;
        border-radius: 4px;
        z-index: 10;
        display: none;
      }
    
      &:hover {
        .tooltip {
          display: block;
        }
      }
    `
    
    export default RTooltip
    •  매우 심플한 툴팁구현 방법이다. <RTooltip> 컨테이너 컴포넌트는 특정요소(children)를 감싼다.
    • Props로 받은 message를 absolute로 배치된 <p>태그에서 표현해준다. 단, 컨테이너를 hover한 경우에만 보여준다.

     

    * 이슈사항

    위처럼 구현하면 1개의 툴팁을 표현하는데는 문제가 없지만, 위 그림처럼 여러개의 툴팁이 표현되는 경우 문제가 발생한다.

     

    absolute 요소의 z-index는 상단의 relative 요소에 의존한다. 그렇기에, z-index 값을 아무리 높여도 다음 요소보다 아래에 있는 것이다.

    * 특별히 z-index를 설정하지 않은 경우, Dom 계층구조의 동일레벨에서 나중에 렌더링된 요소는 이전 요소보다 z높이가 높다고 판정

     

    그렇다고, 렌더링하는 요소마다 z-index를 내림차순으로 주는 것도 비효율적이며, 이외의 바깥요소와 중첩 가능성까지 고려하면 적절한 방법이 아니다.

     

    그렇기에 우리는, 보여지기로는 마치 그 요소의 주변에 있는 것처럼 그리지만, 실제는 최상단에서 해당 컴포넌트의 위치 주변에 부착되는 형태로 툴팁을 그려야하는 것이다.

     

     

    2. 코드구현 : React-Portal 을 통한 최상단에 문구 표현

    Portal부모요소의 DOM 계층구조 바깥에 자식요소를 렌더링하는 기법이다. 자세한 개념 및 사용법은 이전 포스팅을 참고해달라!

    * [React] Portal(포탈), Modal 구현하기 : https://abangpa1ace.tistory.com/229?category=905014 

     

    [React] Portal (포탈), Modal 구현하기

    회사에서 선배와 React로 모달 구현에 대해 고민하다가 알게된 내용이었다. 우리 회사 프로젝트는 Vue를 사용하기 때문에, 플러그인(this.$modal) 메서드를 통해 루트에 모달 컴포넌트를 주입, 삭제해

    abangpa1ace.tistory.com

     

    Portal을 통해 제2의 루트를 두고, 여기에 툴팁 메세지(말풍선) 부분만 렌더링하는 방법이다. 코드로 각 단계를 설명해보도록 하겠다.

     

    1) index.html - tooltip 포탈루트 추가

    <!DOCTYPE html>
    <html lang="ko">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>OP.GG 클론</title>
      </head>
      <body>
        <div id="root"></div>
        <!-- tooltip 포탈루트 추가 -->
        <div id="tooltip-root" />
        <script type="module" src="/src/main.tsx"></script>
      </body>
    </html>

    먼저, index.html에 포탈을 통해 렌더링 될 위치를 추가해준다. tooltip-root 위치는 어디던 상관없지만, 전역적으로 사용하기 위해 root 옆에 두었다.

     

     

    2) TooltipPortal.tsx - Portal 컴포넌트 추가

    import React from 'react';
    import ReactDOM from 'react-dom';
    
    const TooltipPortal: React.FC = ({ children }) => {
      const tooltipRoot = document.getElementById('tooltip-root');
      return ReactDOM.createPortal(children, tooltipRoot as Element);
    }
    
    export default TooltipPortal;

    포탈루트로 자식요소를 렌더링시키는 <TooltipPortal> 컴포넌트를 추가한다.

    Portal 사용문법에 기반해서 구현하면 되며, 타겟은 위에 추가한 #tooltip-root 요소, 그려지는 요소는 해당 컴포넌트의 children 이다.

     

     

    3) TooltipBox.tsx - Tooltip 메세지 박스 요소 제작

    import React from 'react'
    import styled from 'styled-components'
    import s, { theme } from '@/styles';
    
    type Props = {
      message: string;
      style?: { left: number, top: number };
      type?: string;
    }
    
    const TooltipBox: React.FC<Props> = ({ message, style, type }) => {
      return (
        <ScTooltipBox className={`${type}`} style={style}>
          {message}
        </ScTooltipBox>
      )
    }
    
    const ScTooltipBox = styled.div`
      ${s(`abs; w(170); p(6,10); bgc(${theme.darkGrey}); c(#fff); fs(12,16); br(4); z(1);`)}
      transform: translateX(calc(-50% + 12px));
    
      &:after ${s(`cnt(''); abs; wh(0);`)}
    
      &.top { transform: translate(calc(-50% + 12px), -100%);
        &:after ${s(`alb(50%,-6); -l(7, transparent); -r(7, transparent); -t(7, ${theme.darkGrey}); t-xc;`)}
      }
      
      &.bottom {
        &:after ${s(`alt(50%,-6); -l(7, transparent); -r(7, transparent); -b(7, ${theme.darkGrey}); t-xc;`)}
      }
    `
    
    export default TooltipBox

    이후에 만들 <RPortalTooltip> 컨테이너에서 그려질 박스 컴포넌트를 만든다. Props는 message, style(위치), type(위/아래) 3개다.

     

    message는 메세지 박스에서 그릴 문구로 컴포넌트 내부에 렌더링해주면 된다.

    style은 tooltip-root 내에서 박스가 그려칠 좌표, type은 대상 엘리먼트 위치에 따라 박스가 위 혹은 아래 중 어디에 보여질지 정하는 방향값이다.

     

    style(좌표)의 계산은 <RPortalTooltip> 에서 진행해서 보내줄 것이며,

    CSS가 자체 제작한 싱글라인으로 적혀있으나, type(방향)이 top이냐 bottom이냐에 따라 transform 위치, 화살표(&:after) 방향 등이 달라지는 것 정도로만 이해해주길 바란다!

     

     

    4) tooltip.ts - getPosition() 좌표, 타입 설정 함수 제작

    // Tooltip Coordinates
    export type PosType = {
      style: { left: number, top: number };
      type: string;
    }
    
    export const getPosition = (ref: React.RefObject<HTMLSpanElement>, gap = 5): PosType => {
      const rect = ref.current?.getBoundingClientRect() || { top: 0, left: 0 }
      const h = ref.current?.clientHeight as number
      const isAbove = rect?.top + h/2 <= window.innerHeight / 2
      const top = rect.top + (isAbove ? h+gap : -gap);
      // 좌우, 대각선 조건도 추가하면 좋을듯?
    
      return {
        style: { left: rect?.left, top },
        type: isAbove ? 'bottom' : 'top' 
      }
    }
    • 인자는 ref(<RTooltipPortal>) 요소 정보, gap 이격값 2가지를 받는다.
    • 반환하는 값은, style(툴팁박스의 기본 x, y 좌표), type(툴팁박스의 표현방향) 2가지이다. (PosType 참고)
    • rect는 ref의 getBoundingClientRect() 로 획득한 top, left 값, h는 ref의 높이값(clientHeight) 이다.
    • isAbove는 <RTooltipPortal> 대상요소가 화면 세로기준, 상단에 있는지 혹은 하단에 있는지에 대한 Boolean 값이다.
      top좌표와 h(높이)의 절반을 더한 값이 화면 절반(window.innerHeight / 2) 보다 작은지 큰지를 계산했다.
    • 가로정렬은 고려하지 않았기에 style(좌표)에서 left는 그대로 사용한다. top상단인 경우 아래에 그려주기 위해 h(높이), gap(이격값)을 더해주고, 하단인 경우 위에 그려주기 위해 좌표에서 gap(이격값) 만큼만 빼준다.
    • type(방향)상단인 경우 아래에 그려주고(bottom), 하단인 경우 위에 그려준다(top). (화면을 벗어나지 않기 위한 UI/UX)

     

    5) RPortalTooltip.tsx - Tooltip 대상을 감싸는 컨테이너

    import React, { useState, useRef, useEffect } from 'react'
    import styled from 'styled-components'
    import TooltipPortal from '../tooltip/TooltipPortal';
    import TooltipBox from '@/views/components/common/tooltip/TooltipBox';
    import { getPosition, PosType } from '@/utils/hoc';
    
    type Props = {
      children: React.ReactNode | React.ReactNode[];
      message: string;
      className?: string;
    }
    
    const RPortalTooltip: React.FC<Props> = ({ children, message, className }) => {
      const ref = useRef<HTMLSpanElement>(null);
      const [show, setShow] = useState<boolean>(false);
      const pos = useRef<PosType | null>(null)
    
      const handleMouseOver = () => {
        pos.current = getPosition(ref);
        setShow(true)
      }
    
      return (
        <ScRPortalTooltip
          ref={ref}
          onMouseOver={handleMouseOver}
          onMouseLeave={() => setShow(false)}
          className={className}
        >
          {children}
          {show && 
            <TooltipPortal>
              <TooltipBox message={message} style={pos.current?.style} type={pos.current?.type} />
            </TooltipPortal>
          }
        </ScRPortalTooltip>
      )
    }
    
    export default RPortalTooltip
    • Props는 message, children 정도만 받으면 된다. className 등은 선택적이다.
    • ref는 엘리먼트 정보를 참고, posgetPosition() 함수해당 ref에 대한 style(좌표), type(위/아래) 정보들을 계산한 저장값이다.
    • show툴팁 표현여부에 대한 상태값이다. 이에 따라 <TooltipPortal>을 토글하면 된다.
    • onMouseOver 이벤트로 툴팁표현(hover시작) 을 설정한다. handleMouseOver() 함수는 pos에 getPosition() 정보를 저장하며, show를 true로 만든다.
      (이 정보가 내부의 <TooltipBox> 에 전달되어 렌더링 위치/모양 등을 잡아주는데 쓰임)
    • onMouseLeave 이벤트로 툴팁제거(hover종료) 를 설정한다. show를 false로 바꿔 <TooltipPortal>을 제거한다.

     

    👉 사용예시

    import React from "react";
    import styled from "styled-components";
    import s, { theme } from "@/styles";
    import { getStatus } from "@/utils/data";
    import { STATUS_STYLES } from "@/constants";
    import RPortalTooltip from "@/views/components/common/hoc/RPortalTooltip";
    import { useRecoilValue } from "recoil";
    import { itemsInfoSelector } from "@/recoil/store";
    
    type Props = {
      match: MatchGameType;
    };
    
    const SummaryItems: React.FC<Props> = ({ match }) => {
      const itemsInfo = useRecoilValue(itemsInfoSelector);
      const items = match.items.slice(0, -1);
    
      const setItemDesc = (imageUrl: string) => {
        const key = imageUrl.split("/").pop()?.slice(0, -4);
        return itemsInfo[+(key || 0)].plaintext;
      };
    
      const setItems = () => {
        return Array.from({ length: 6 }, (_, i) => i).map((i) => {
          const item = items[i];
          return item ? (
            <RPortalTooltip
              message={setItemDesc(item.imageUrl)}
              key={item.imageUrl + i}
              className="image-item"
            >
              <img src={item.imageUrl} />
            </RPortalTooltip>
          ) : 
          // item이 없는 경우..
        });
      };
    
      return (
        <ScSummaryItems>
          <div className="item-images">
            {setItems()}
    		// 내부 엘리먼트..
          </div>
        </ScSummaryItems>
      );
    };
    
    export default SummaryItems;

    적용법은 간단하다. 툴팁을 적용할 요소를 <RPortalTooltip> 으로 감싸고, message Props만 넘겨주면 된다.


    Portal 외의 이론은 특별히 포함되지 않는 포스팅이었다. 기술적인 내용보단, 구현에 초점을 맞춰 정리하였다.

     

    사실 라이브러리들이 있는 기능이기도 하고 이를 차용하면 편리하지만, 그만큼 커스텀 자유도도 사라지게 되고 무엇보다 라이브러리 문법과 버저닝에 의존하게 되는 단점도 존재한다.

     

    * 만약 더 좋은 방법이 있다면 댓글에 많은 정보 공유를 부탁드리는 바입니다!

     

     

    🧷 출처

    - [Click-Outside] hoon2 님의 블로그 : https://velog.io/@tonyk0901/TIL30-React-outside-click-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0  
    - [Click-Outside] LogRocket 블로그 : https://blog.logrocket.com/detect-click-outside-react-component-how-to/  

    - [Tooltip #1] NB#log 님의 블로그 : https://velog.io/@altmshfkgudtjr/Custom-Tooltip-%EC%A0%9C%EC%9E%91%EA%B8%B0  

    - [Tooltip #2] jercy 님의 블로그 : https://jercy.tistory.com/8

    반응형
Designed by Tistory.