4 분 소요

CON:SEAT는 Dropdown이 정말 많아


관련 PR
dropdownInput, dropdown 컴포넌트 생성
상세페이지 드롭다운 만들기


🔥들어가기

사실 Dropdown 컴포넌트는 Next와 아~~~~~무 관련이 없다. 하지만 CON:SEAT 프로젝트에서 만든 거고.. CON:SEAT는 Next.js로 만든 거고.. 그래서 이 카테고리에 두었다. Next.js 카테고리는 CON:SEAT가 먹을 예정😁



🔥합성 컴포넌트를 사용한 계기

이전 프로젝트에서는 합성 컴포넌트를 사용하지 않았다. 이유는 디자이너가 없어서 프론트 팀원끼리 최대한 일관된 디자인으로 합성 컴포넌트를 사용하지 않아도 모든 상황을 재연할 수 있게 UI를 구상하자고 했기 때문이다.

하지만 지금은? PM과 디자이너와 같이 하는 프로젝트이며 미리 나온 디자인 시안에는 Dropdown이 굉장히 많았다. 또 Dropdown + Input이 있는 곳도 있었다.
-> 무조건 합성 컴포넌트 도입

합성 컴포넌트는 모든 것을 다 만들어두고 필요한 곳필요한 것을 가져다 쓰기만 하면 된다. 모든 케이크 재료가 다 있고 그걸 사용해서 딸기 케이크, 초코 케이크를 만드는 것과 비슷하다.



Effective Component 지속 가능한 성장과 컴포넌트 이 영상을 참고하여 만들었다.


Dropdown.tsx

DropdownMain : Dropdown의 바깥을 누르면 닫히게 하기 위해 ref 인자가 있는 Dropdown 최상위 컴포넌트

interface DropdownProps {
  className?: string;
  children?: ReactNode;
  ref?: React.Ref<HTMLDivElement>;
}

const DropdownMain = ({ className, children, ref }: DropdownProps) => {
  return (
    <div ref={ref} className={classNames(styles.dropdownContainer, className)}>
      {children}
    </div>
  );
};


Dropdown.Trigger : button, input 등 드롭다운을 열리게 하는 trigger 요소들이 위치

interface DropdownTriggerProps {
  as: ReactNode;
}

const DropdownTrigger = ({ as }: DropdownTriggerProps) => {
  return as;
};


Dropdown.Menu : 아래에 나오는 option을 감싸는 컴포넌트

interface DropdownMenuProps {
  children: ReactNode;
  className?: string;
}

const DropdownMenu = ({ children, className }: DropdownMenuProps) => {
  return (
    <ul className={classNames(styles.dropdownMenu, className)}>{children}</ul>
  );
};


Dropdown.Item : option 각각을 보여주는 컴포넌트

interface DropdownItemProps {
  children: string;
  isSelected: boolean;
  onClick: () => void;
  className?: string;
}

const DropdownItem = ({
  children,
  isSelected,
  onClick,
  className,
}: DropdownItemProps) => {
  return (
    <li
      className={classNames(styles.dropdownItem, className, {
        selected: isSelected,
      })}
      onClick={onClick}
    >
      {children}
    </li>
  );
};


DropdownMain에 합치기 : ref가 있기 때문에 assign으로 합쳐준다.

const Dropdown = Object.assign(DropdownMain, {
  Trigger: DropdownTrigger,
  Menu: DropdownMenu,
  Item: DropdownItem,
});

export default Dropdown;



🔥useDropdown 커스텀 훅 만들기

1. 드롭다운이 열려있는지 알 수 있다.
2. 드롭다운은 toggle로 열고 닫을 수 있다.
3. 드롭다운을 열 수 있다.
4. 드롭다운을 닫을 수 있다.
5. 드롭다운 바깥을 클릭하면 닫을 수 있고, 내부의 ref를 알 수 있다.

import { useEffect, useRef, useState } from "react";

const useDropdown = () => {
  const [isDropdownOpen, setIsDropdownOpen] = useState(false);
  const dropdownRef = useRef<HTMLDivElement>(null);

  const handleToggleDropdown = () => {
    setIsDropdownOpen(!isDropdownOpen);
  };

  const handleOpenDropdown = () => {
    setIsDropdownOpen(true);
  };

  const handleCloseDropdown = () => {
    setIsDropdownOpen(false);
  };

  const handleClickOutside = (event: MouseEvent) => {
    if (
      dropdownRef.current &&
      !dropdownRef.current.contains(event.target as Node)
    ) {
      setIsDropdownOpen(false);
    }
  };

  useEffect(() => {
    document.addEventListener("mousedown", handleClickOutside);
    return () => {
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, []);

  return {
    isDropdownOpen,
    handleToggleDropdown,
    handleOpenDropdown,
    handleCloseDropdown,
    dropdownRef,
  };
};

export default useDropdown;



🙏v1. Dropdown 만들어주세요

[구현 사항]

“층을 선택해주세요”를 누르면 dropdown 아이템이 뜬다.

image


[코드 설명]

1. Dropdown ref에 dropdownRef를 달아 바깥이 눌리면 닫히게 한다.
2. Dropdown.Trigger는 button이다. 이 버튼 안에 선택한 value가 보이게 하고, 선택된 것이 없다면 placeholder가 보이게 한다.
3. isDropdownOpen이 true면 아이템들이 보인다.
4. Dropdown.MenuDropdown.Item으로 아이템을 보여주고 아이템을 클릭하면 value가 바뀌면서 dropdown이 닫힌다.

ReviewDropdown.tsx

<Dropdown ref={dropdownRef}>
  <Dropdown.Trigger
    as={
      <button
        type="button"
        onClick={handleToggleDropdown}
        className={classNames(styles.reviewDropdownTrigger, {
          [styles.isOpen]: isDropdownOpen,
        })}
      >
        <span
          className={classNames(styles.reviewDropdownText, {
            [styles.placeholder]: !value,
          })}
        >
          {value || placeholder}
        </span>
        {isDropdownOpen ? <Icon icon="UpArrow" /> : <Icon icon="DownArrow" />}
      </button>
    }
  />

  {isDropdownOpen && (
    <Dropdown.Menu className={styles.reviewDropdownMenu}>
      {options.map((option, index) => (
        <Fragment key={option}>
          <Dropdown.Item
            className={styles.reviewDropdownItem}
            isSelected={value === option}
            onClick={() => {
              onChange(option);
              handleCloseDropdown();
            }}
          >
            {option}
          </Dropdown.Item>
          {index !== options.length - 1 && <Splitter color="subGray7" />}
        </Fragment>
      ))}
    </Dropdown.Menu>
  )}
</Dropdown>


[결과 화면]



🙏v2. DropdownInput 만들어주세요

[구현 사항]

“콘서트명을 검색해주세요”에 포커싱을 하면 dropdown 아이템이 뜨고, 글자를 입력할 수 있다.

image


[코드 설명]

1. Dropdown ref에 dropdownRef를 달아 바깥이 눌리면 닫히게 한다.
2. Dropdown.Trigger는 input의 onFocus이다. 글자를 입력하면 value가 보인다.
3. isDropdownOpen이 true면 아이템들이 보인다.
4. Dropdown.MenuDropdown.Item으로 아이템을 보여주고 아이템을 클릭하면 value가 바뀌면서 dropdown이 닫힌다.
5. onChange 함수가 input에도 있고, Item의 onClick에도 있다. input에 글자를 입력해도 input의 value가 바뀌고, 아이템을 클릭했을 때도 input의 value가 바뀌어야 하기 때문이다.

ReviewDropdownInput.tsx

<Dropdown ref={dropdownRef}>
  <Dropdown.Trigger
    as={
      <div
        className={classNames(styles.reviewDropdownTrigger, {
          [styles.isOpen]: isDropdownOpen,
        })}
      >
        <input
          className={styles.reviewDropdownInput}
          type="text"
          value={value}
          placeholder={placeholder}
          onChange={(e) => {
            const newValue = e.target.value;
            onInputChange(newValue);
          }}
          onFocus={handleOpenDropdown}
          onKeyDown={(e) => {
            if (e.key === "Enter") {
              e.preventDefault();
            }
          }}
        />
        <span>
          {isDropdownOpen ? <Icon icon="UpArrow" /> : <Icon icon="DownArrow" />}
        </span>
      </div>
    }
  />

  {isDropdownOpen && (
    <Dropdown.Menu className={styles.reviewDropdownMenu}>
      {options.map((option, index) => (
        <Fragment key={option.concertId}>
          <Dropdown.Item
            className={styles.reviewDropdownItem}
            isSelected={selectedId === option.concertId}
            onClick={() => {
              onChange(option);
              handleCloseDropdown();
            }}
          >
            {option.concertName}
          </Dropdown.Item>
          {index !== options.length - 1 && <Splitter color="subGray7" />}
        </Fragment>
      ))}
    </Dropdown.Menu>
  )}
</Dropdown>


[결과 화면]



🙏v3. DropdownModal 만들어주세요

[Dropdown.Modal 추가]

Dropdown.tsx

Portal로 띄우는 Modal을 추가한다. Dropdown.Modal의 overlay를 누르면 닫히는 동작을 사용하기 위해 미리 만들어두었던 dropdownRef를 모달 content에 달아주었다. dropdownRef의 외부에 있는 곳인 overlay를 누르면 닫히게 된다.

interface DropdownModalProps extends HTMLAttributes<HTMLDivElement> {
  isOpen: boolean;
  controls?: ReactNode;
  children: ReactNode;
  ref?: Ref<HTMLDivElement>;
}

const DropdownModal = ({
  isOpen,
  controls,
  children,
  ref,
  ...props
}: DropdownModalProps) => {
  return (
    <Portal isOpen={isOpen}>
      <div className={styles.overlayWrapper}>
        <div className={styles.overlay} />
        <div ref={ref} className={styles.modalContent} {...props}>
          {children}
          {controls && <>{controls}</>}
        </div>
      </div>
    </Portal>
  );
};


[구현 사항]

Dropdown 버튼을 클릭하면 dropdown 모달이 뜨고, 선택한 후 모달을 닫는다.

image


[코드 설명]

1. Dropdown.Trigger의 버튼을 클릭하면 Dropdown.Modal이 뜬다.
2. controls는 모달에 대한 동작이다. “초기화”는 state가 초기화되고, “선택 완료”는 현재 state로 dispatch를 실행시켜 api 요청을 트리거하고 모달을 닫는다.

AllDropdownModal.tsx

<Dropdown>
  <Dropdown.Trigger
    as={
      <button
        className={classNames(styles.triggerButton, {
          [styles.selected]: isSelected,
        })}
        onClick={handleToggleDropdown}
      >
        {label}
        <Icon
          icon={isDropdownOpen ? "UpArrow" : "DownArrow"}
          color={isSelected ? "black" : "#F8F9FA"}
        />
      </button>
    }
  />

  <Dropdown.Modal
    ref={dropdownRef}
    isOpen={isDropdownOpen}
    className={styles.modalBody}
    controls={
      <div className={styles.controls}>
        <Button type="reset" className={styles.reset} onClick={onReset}>
          <Icon icon="Reset" />
          초기화
        </Button>
        <Button
          type="submit"
          onClick={() => {
            handleToggleDropdown(); // 선택완료 누르면 모달 닫힘
            onConfirm();
          }}
        >
          선택완료
        </Button>
      </div>
    }
  >
    <>
      <div className={styles.modalTextContainer}>
        <p className={styles.modalTextTitle}>{title}</p>
        {subTitle && (
          <span className={styles.modalTextSubTitle}>{subTitle}</span>
        )}
      </div>

      {children}
    </>
  </Dropdown.Modal>
</Dropdown>


[결과 화면]

카테고리:

업데이트:

댓글남기기