본문 바로가기

CSS/공용 ui

[ui] 아코디언 ui 만들기

 

용도


다수의 메뉴 박스처럼 데이터 리스트를 접어서 정리할 때 유용한 ui가 아코디언이다.

해당 ui는 react와 reset css, css의 class 기반 스타일링으로 이뤄졌다.

아래에 소스코드를 첨부했으니 필요하다면 사용하면 된다.

 

app.tsx

import Accordion from "./components/accordion/Accordion";

// 아코디언 ui용 임시 데이터
const menu = [
  {
    id: "00",
    label: "에피타이저",
  },

  {
    id: "01",
    label: "메인 음식",
  },
  {
    id: "02",
    label: "디저트",
  },
  {
    id: "03",
    label: "음료",
  },
  {
    id: "04",
    label: "셀러드",
  },

  {
    id: "05",
    label: "일식",
  },

  {
    id: "06",
    label: "중식",
  },
  {
    id: "07",
    label: "한식",
  },
];

const children = [1, 2, 3, 4, 5, 6, 7].map((e) => (
  <div>
    <h3>item{e}</h3>
    <p>
      Lorem ipsum, dolor sit amet consectetur adipisicing elit. Necessitatibus
      aliquid perferendis cupiditate esse optio rem facilis. Accusamus numquam
      eveniet asperiores eaque provident dicta itaque placeat, debitis fugit
      maiores. Quam, cum.
    </p>
  </div>
));
//

function App() {
  return (
    <div id="app">
    // 아코디언 ui
      <Accordion items={menu}>{children}</Accordion>
    </div>
  );
}

export default App;

 

Accodion.tsx

아코디언 기능의 핵심은 item인 패널을 열고 닫을 때 스타일링 제어이다.

나의 경우, max-height:0;과 max-height:scrollHeight;를 사용했다.

일반적인 height보다 애니메이션 시 리소스를 좀 더 적게 먹는다는 이야기를 gpt로 들어서인데,
따로 확인한 레퍼런스는 존재하지 않는다.

import "../accordion/accordionStyle.css";

export interface accodionItem {
  id: string;
  label: string;
}

interface AccodionProps {
  // 아코디언 패널
  items: accodionItem[];

  // 패널의 내용이 되는 컴포넌트
  children: React.ReactNode;

  // 외부에서 동적 스타일링 시
  dom?: {
    layout?: {
      className?: string;
      style?: {
        [key: string]: React.CSSProperties;
      };
      [key: string]: string | React.CSSProperties | undefined;
    };
  };
}

const Accodion = ({ items, dom, children }: AccodionProps) => {
  // 패널 클릭 시 열고 닫음
  // 패널의 콘텐츠를 클릭하는 경우에는 예외로 무시
  const handleClickPanelOpen = (e: React.MouseEvent<HTMLUListElement>) => {
    const target = e.target as HTMLLIElement;

    //  패널 콘텐츠
    const closet = target.closest('[data-istoggle="false"]');

    if (closet) return;

    const pannel = target.closest("[data-type=pannel]") as HTMLLIElement;

    if (pannel && typeof Number(pannel.dataset.pannelIndex) === "number") {
      const contentWrapper = pannel.querySelector(
        ".accordion__pannel-content-wrapper"
      ) as HTMLLIElement;
      
      //pannel-open 클래스로 패널 열림 제어
      pannel.classList.toggle("pannel-open");
      if (contentWrapper && pannel.classList.contains("pannel-open")) {
        contentWrapper.style.maxHeight = contentWrapper.scrollHeight + "px";
      } else {
        contentWrapper.style.maxHeight = "0";
      }
    }
  };

  return (
    <div className="accordion__layout" style={dom?.layout?.style}>
      <ul
        className="accordion__pannel-wrapper scroll"
        onClick={handleClickPanelOpen}
      >
        {items.map((pannel, pannelIdx) => {
          return (
            <li
              data-type={"pannel"}
              data-pannel-index={pannelIdx}
              className="accordion__pannel"
              key={pannel.id}
            >
              <div className="accordion__pannel-header">
                <div>
                  <span className="accordion__pannel-title"></span>{" "}
                  {pannel.label}
                </div>
                {/* 화살표 아이콘 */}
                <div className="accordion__icon-wrapper">
                  <svg
                    width="16"
                    height="16"
                    viewBox="0 0 16 16"
                    fill="none"
                    xmlns="http://www.w3.org/2000/svg"
                  >
                    <path
                      className="icon-path"
                      d="M3 5.90446L3.8875 5L8 9.19108L12.1125 5L13 5.90446L8 11L3 5.90446Z"
                      fill="#777777"
                    />
                  </svg>
                </div>
              </div>
              <div className="accordion__pannel-content-wrapper scroll">
                <div
                  className="accordion__pannel-content"
                  data-istoggle="false"
                >
                  {Array.isArray(children)
                    ? children?.[pannelIdx]
                    : [children][pannelIdx]}
                </div>
              </div>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export default Accodion;

 

accordionStyle.css

/* Layout */
.accordion__layout {
  background-color: rgb(231, 235, 241);
  width: 100%;
  padding: 20px 0px;
  height: 100%;
  border-radius: 10px;
}

/* Pannel Wrapper */
.accordion__pannel-wrapper {
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow-y: auto;
  gap: 10px;
  padding: 0 10px;
  margin: 0 5px;
}

/*Pannel */
.accordion__pannel {
  cursor: pointer;
  display: flex;
  flex-direction: column;
  background-color: #ffffff;
  border: 1px solid transparent;
  border-radius: 10px;
  color: #000000;
  margin-bottom: 5px;
  padding: 10px;
  box-shadow: 2px 2px 4px 0px #0000001a;
  transition: 0.2s all ease-in-out;
}

/* .pannel-open: 패널이 열릴 때 */
.accordion__pannel:not(.pannel-open):hover {
  filter: brightness(0.98);
}

.accordion__pannel.pannel-open {
  border: 1px solid #397de2;
  color: #0b52be;
  background-color: #f5faff;
}

/* Pannel Content Wrapper */
.accordion__pannel-content-wrapper {
  overflow-y: hidden;
  max-height: 0;
  transition: all 0.2s ease-in-out;
}

.accordion__pannel.pannel-open .accordion__pannel-content-wrapper {
  /* max-height: 600px; */
}

/* Pannel Content */
.accordion__pannel-content {
  cursor: auto;
  color: black;
  font-size: 12px;
}

.accordion__pannel.pannel-open .accordion__pannel-content {
}

/* Icon Wrapper */
.accordion__icon-wrapper {
  display: flex;
  justify-content: center;
  align-items: center;
  left: 0px;
  height: 100%;
  margin-left: 10px;
  transition: all 0.1s ease-in-out;
}

.accordion__pannel.pannel-open .accordion__icon-wrapper {
  transform: rotate(180deg);
}

.accordion__icon-wrapper path {
  fill: initial;
}

.accordion__pannel.pannel-open .accordion__icon-wrapper path {
  fill: #0864f0;
}

/* Pannel Header */
.accordion__pannel-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  margin: 10px 0;
  font-size: 12px;
  color: rgb(80, 80, 80);
  font-weight: 600;
}

.pannel-open .accordion__pannel-header {
  color: #0b52be;
}

.accordion__pannel-title {
  margin-left: 5px;
}

.scroll {
}
.scroll::-webkit-scrollbar {
  width: 6px;
  height: 6px;
  padding: 2px;
  margin: 2px;
}
.scroll::-webkit-scrollbar-thumb {
  background-color: #a3a3a3;
  border-radius: 10px;
}