728x90
반응형

포트폴리오 디자인을 전부 갈아엎는 과정에서 about me 파트와 modal창에 사용된 가로 차트(horizon chart)와 원형 차트(circle)가 창이 로딩된 후 숫자가 차르륵 올라가는 효과를 어떻게 구현했는지 기록하려고 한다.

 

원형 차트의 숫자가 움직이는 효과를 구현하면서 가로 차트도 이렇게 숫자가 차르륵 올라가면 예쁠텐데, 하고 생각했지만 가로 차트를 만드는 유튜브 영상을 과장 좀 보태서 수십 개를 찾아봐도 숫자가 0, 1, 2 ~ 99, 100 하고 올라가는 효과를 구현해둔 건 찾아볼 수가 없어서 가로형 차트는 css로만 구현해야 하나 싶었다.

그 때 머리를 스치는 생각이 원형 차트를 만드는 방식을 따라해서 가로형 차트의 가상 요소 after 값을 원형 차트가 작동하는 방식으로 바꿔주면 되지 않을까?  였다.

그 예상은 멋드러지게 적중하여 나는 움직이는 원형 차트와 가로형 차트를 얻은 것이다.

 

 

 

일단 원형 차트를 만든 과정부터 시작해보겠다.

 

원형 차트 완성본

 

<div class="circle" data-degree="87" data-color="#d271fe">
  <h2 class="circle_number">87<span>%</span></h2>
  <h4 class="circle_title">css</h4>
</div>
<div class="circle" data-degree="90" data-color="#d271fe">
  <h2 class="circle_number">90<span>%</span></h2>
  <h4 class="circle_title">html</h4>
</div>
<div class="circle" data-degree="75" data-color="#d271fe">
  <h2 class="circle_number">75<span>%</span></h2>
  <h4 class="circle_title">javascript</h4>
</div>
<div class="circle" data-degree="80" data-color="#d271fe">
  <h2 class="circle_number">80<span>%</span></h2>
  <h4 class="circle_title">framework</h4>
</div>

 

원형 차트 완성본 이미지를 보면 data-degree 값과 %의 숫자가 같다는 걸 알 수 있다.

즉, data-degree 값만큼만 숫자가 증가하면서 원형 차트가 차오르는 것이다.

 

숫자가 증가하는 효과는 javascript(자바스크립트)로 구현했으니 자바스크립트도 살펴보자.

 

let circle = document.querySelectorAll(".circle");
  circle.forEach(function (progress) {
    let degree = 0;
    let targetDegree = parseInt(progress.getAttribute("data-degree"));

    let color = progress.getAttribute("data-color");
    let number = progress.querySelector(".circle_number");

    let interval = setInterval(function () {
      degree += 1;

      if (degree > targetDegree) {
        clearInterval(interval);
        return;
      }

      progress.style.background = `conic-gradient(${color} ${degree}%, #222 0%)`;
      number.innerHTML = degree + `<span>%</span>`;
      number.style.color = color;
    }, 50);
  });

 

let degree = 0;

: degree 변수는 애니메이션이 진행될 때 현재 원형 차트의 배경이 얼마나 채워졌는지를 나타내는 값이다. 물론 초기값은 0이다. 

 

let targetDegree = parseInt(progess.getAttribute("data-degree")

: data-degree 라는 커스텀 데이터 속성에서 값을 가져와 숫자로 변환하여 targetDegree 변수에 저장하는 구문이다.

목적은 애니메이션이 종료될 때 배경이 채워질 목표 각도를 설정하는 것이다.

 

color는 원형 차트에 어떤 색을 적용할 것인지에 대한 hex 값을 가져오는 것이니 별로 신경 쓰지 않아도 된다.

 

✏ getAttribute 

:자바스크립트에서 DOM  요소의 특정 속성(attribute) 값을 가져오는 메서드이다.

이 메서드를 사용하면 HTML 요소에 설정된 특정 속성의 값을 읽을 수 있다.

 

let interval = setInterval(funtion () { ... } , 50);

: setInterval을 사용하여 50 밀리초마다 반복해서 실행되는 함수 즉, 애니메이션 루프를 설정하는 구문이다.

목적은 원형 배경이 채워지는 애니메이션을 구현하기 위해 주기적으로 코드를 실행하는 것이다.

 

degree += 1

: 현재의 degree 값을 1씩 증가 시키는 구문이다.

애니메이션이 진행될 수록 원형 배경을 점진적으로 채우기 위해 각도를 증가시키는 역할을 한다.

 

if (degree < targetDegree) { clearInterval(interval); return)

:현재의 degree 값이 목표 각도(targetDegree)를 초과하면 애니메이션을 멈추게 하는 구문이다.

즉, 목표 각도에 도달하면 애니메이션을 종료하게 만든다.

값과 다르게 원이 꽉꽉 들어차는 걸 보고 싶지 않다면 꼭 넣는 게 좋은 구문이다.

 

progress.style.background = \`conic-gradient(${color} ${degree}%, #222, 0%)

:CSS의 conic-gradient를 사용해 배경이 원형으로 채워지도록 설정하게 하는 구문이다.

여기서 degree의 %만큼 color 값의 색으로 채워지고 나머지는 #222(어두운 색)으로 표시된다.

즉, 원형 배경을 애니메이션에 따라 점차적으로 채워지는 효과를 시각적으로 표현하는 것이다.

 

number.innerHTML = degree + \`<span>%</span>

: 현재의 degree 값을 html로 삽입하여 원형 내부에 표시하는 구문이다. 이때 %도 함께 표시된다.

 

 

여기서 progress.style.background = \`conic-gradient(${color} ${degree}%, #222, 0%) 를 보자.

방금은 원형이라 conic-gradient를 사용했지만 가로형 차트를 구현한다면 conic-gradient를 사용할 필요가 없다.

여기만 가로형 차트의 width 값이 되는 것으로만 바꿔준다면 어디서 본 건 있는 나의 욕구를 충족 시켜줄 수 있는 코드를 구현할 수 있다는 뜻이다.

 

 

 

겨우 본론으로 돌아와서, 가로형 차트를 살펴보자.

가로 차트 완성본

 

이게 멋드러지는 가로형 차트의 완성본이다.

다시 생각해도 뿌듯해서 자꾸 보게 된다.

 

 

이제 가로형 차트의 html을 보자

<div class="chart_line" chart-data-degree="85">
  <span class="skill_name">react</span>
  <div class="chart_bar"></div>
  <span class="percent">0<span>%</span></span>
</div>
<div class="chart_line" chart-data-degree="75">
  <span class="skill_name">vite</span>
  <div class="chart_bar"></div>
  <span class="percent">0<span>%</span></span>
</div>
<div class="chart_line" chart-data-degree="78">
  <span class="skill_name">php</span>
  <div class="chart_bar"></div>
  <span class="percent">0<span>%</span></span>
</div>
<div class="chart_line" chart-data-degree="83">
  <span class="skill_name">vue</span>
  <div class="chart_bar"></div>
  <span class="percent">0<span>%</span></span>
</div>
<div class="chart_line" chart-data-degree="80">
  <span class="skill_name">next.js</span>
  <div class="chart_bar"></div>
  <span class="percent">0<span>%</span></span>
</div>
<div class="chart_line" chart-data-degree="65">
  <span class="skill_name">redux</span>
  <div class="chart_bar"></div>
  <span class="percent">0<span>%</span></span>
</div>

 

원형 차트랑 구조는 같다. (사소한 건 다르지만) 

기본적인 뼈대(틀)은 같다.

차트 전체를 구성하는 div 박스에 data-degree, 즉 차트가 채워질 만큼의 커스텀 데이터 값이 있고, 각 차트의 타이틀과 어떤 제목을 가졌고, 출력될 % 값이 있다.

 

차이점은 원형 차트에는 width 값이 필요가 없었는데 가로형 차트(horizon chart)에는 width 값이 필요하다는 것이다.

.chart_line {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-right: 10%;

    .skill_name {
      width: 10%;
      text-transform: uppercase;
      letter-spacing: 0.5vw;
    }

    .chart_bar {
      display: flex;
      width: 35vw;
      height: 1.5vw;
      background-color: rgba(255, 255, 255, 0.5);
      position: relative;

      &::after {
        content: "";
        position: absolute;
        background-color: #d271fe;
        width: var(--progress-width, 0%);
        height: inherit;
        top: 0;
        left: 0;
        z-index: 1;
        transition: width 1s ease;
      }
    }
}

 

이런 식으로 말이다.

여기서 

배경의 불투명한 부분이 chart_bar가 된다. 전체적인 가로형 차트의 배경을 그리고 가상 요소(::after)를 사용하여 그 위를 덮는 형식으로 가로형 차트를 구현했다.

그러니까 chart_bar의 width 값은 건드리지 않고 ::after의 width 값에만 변화를 주어야 하는 것이다.

그것도 값이 전부 다 다른 여섯 개의 차트를 말이다.

 

그래서 찾은 방법이 --progress-width를 사용하는 것이었다. 

css의 커스텀 속성인 --progress-width를 사용하여 프로그레스 바의 너비를 조절하는 방법이다.

이를 사용하면 차트의 chart-data-degree 값을 따라 달라지게 되는 것이다.

 

그래서 자바스크립트를 어떻게 짰냐면,

 let chartLines = document.querySelectorAll(".chart_line");
  chartLines.forEach(function (progress) {
    let degree = 0;
    let targetDegree = parseInt(progress.getAttribute("chart-data-degree"));

    let bar = progress.querySelector(".chart_bar");
    let percent = progress.querySelector(".percent");

    let interval = setInterval(function () {
      degree += 1;

      if (degree > targetDegree) {
        clearInterval(interval);
        return;
      }

      bar.style.setProperty("--progress-width", degree + "%");
      percent.innerHTML = degree + `<span>%</span>`;
    }, 50);

 

원형 차트의 자바스크립트 틀을 그대로 가져왔다.

물론 필요없는 color는 빼버렸다.

 

원형 차트와 똑같이 let chartLines를 document.quertSelectorAll을 사용하여 .chart_line이라는 클래스를 가진 모든 요소를 선택하고, 여러 개인 chartLines에 forEach를 사용하여 각 요소에 대해 순회하면서 콜백함수를 실행할 수 있도록 했다.

여기서의 progress는 각각의 .chart_line이 되겠지.

 

let degree = 0으로 degree의 초기값을 0으로 설정했다.

의외인 점은 let이 변수 선언이라지만 원형 차트도, 가로형 차트도 전부 degree라는 변수를 사용했는데 꼬이지 않고 값들이 제자리를 쏙쏙 찾아 들어갔다는 점이다.

data-degree 값을 찾아서 집어넣는 방식이라 그런가.

 

어쨌든,

let targerDegree = parseInt(progress.getAttribute("chart-data-degree")) 를 사용하여 커스텀 데이터 속성에서 값을 가져와 targetDegree 변수에 저장하게 만든다.

아, 아까는 data-degree였는데 왜 지금은 chart-data-degree를 사용하냐면, 내가 헷갈리기 때문이다. 별 이유는 없다.

 

let bar = progress.querySelector(".chart_bar)를 사용하여 .chart_bar 클래스를 가진 자식 요소를 선택하여 bar에 저장한다.

왜냐면 프로그레스 바의 시각적 요소를 조작하기 위해서이다.

 

let percent = progress.querySelector(".percent")를 사용하여 percent 클래스를 가진 자식 요소를 선택하여 percent 변수에 저장한다. 진행도를 텍스트로 표시할 요소를 선택하여 애니메이션 중 업데이트 할 수 있도록 하기 위해서이다.

대충 숫자가 차르륵 올라가려면 이 과정이 꼭 필요하다는 것만 알아두자.

 

let interval = setInteval( function ( ) { ... }, 50) 

아까처럼 setInterval을 사용해 50밀리초마다 반복해서 실행되는 함수(애니메이션 루프)를 설정한다. 프로그레스 바가 점진적으로 채워지는 애니메이션을 구현하기 위해 주기적으로 코드를 실행해야 하기 때문이다.

 

degree += 1을 사용하여 애니메이션이 진행될 수록 프로그레스 바를 점진적으로 채우기 위해 현재의 degree 값을 1씩 증가시키고, if (degree > targetDegree) {clearInterval(interval); return;)을 사용해 현재의 degree 값이 목표 진행도(targetDegree)를 초과하면 clearInterval로 애니메이션을 멈춘다.

 

bar.style.setProperty("--progress-width", degree + "%");

:CSS의 커스텀 속성(progress-width)을 설정하여 프로그레스 바의 width값을 조정하게 된다. degree 값에 따라 width가 점진적으로 증가하게 한다.

프로그레스 바의 시각적 진행 상태를 업데이트하여 애니메이션 효과를 만들게 하는 것이다.

 

✏  setProperty

: 자바스크립트의 CSS Style Declaration 객체에서 사용되는 메서드로, CSS 변수(커스텀 속성 ex.progree-width)를 설정하거나 일반 CSS 속성의 값을 변경하는데 사용됨. 이 메서드는 주로 element(요소).style을 통해 접근할 수 있으며, CSS 변수의 값을 동적으로 변경할 때  유용함.

 

percent.innerHTML = degree + \`<span>%</span>`

:현재의 degree 값을 HTML로 삽입하여 프로그레스 바  옆에 진행도를 퍼센트로 표시하는 구문이다. 숫자가 차르륵 올라가려면 이 과정이 필수이다.

 

 

이 모든 과정을 거치고 나면 숫자라 차르르르르륵 올라가는 쾌감이 엄청난 차트 바와 원형 차트를 만들 수 있는 것이다.

사실 내가 언젠가 또 써먹고 싶은데 기억 안 나서 헤맬까봐  써놓는 것이다.

뭐... 나처럼 짱 멋진 가로형 차트를 만들고 싶은데 방법을 모르겠는 사람이 보면 더 좋고.

 

728x90
반응형
import "./globals.css";

import { Footer, Navbar } from "@/components";

export const metadata = {
  title: "Car Hub",
  description: "Discover world's best car showcase application",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="relative">
        <Navbar />
        {children}
        <Footer />
      </body>
    </html>
  );
}

layout

: 웹 애플리케이션의 기본적인 레이아웃 구조를 구성하며, 해당 레이아웃 내에 네비게이션 바(navbar), 푸터(footer) 및 기타 컨텐츠를 렌더링 합니다.
주로 JSX 문법을 사용하여 작성되었습니다.
children은 해당 레이아웃에 포함할 내용을 나타냅니다. 즉, 해당 레이아웃 내에 표시될 페이지의 본문 컨텐츠를 children을 통해 렌더링 합니다.

 

 

 

 

 

import { fetchCars } from "@/utils";
import { HomeProps } from "@/types";
import { fuels, yearsOfProduction } from "@/constants";
import { CarCard, ShowMore, SearchBar, CustomFilter, Hero } from "@/components";

export default async function Home({ searchParams }: HomeProps) {
  const allCars = await fetchCars({
    manufacturer: searchParams.manufacturer || "",
    year: searchParams.year || 2022,
    fuel: searchParams.fuel || "",
    limit: searchParams.limit || 10,
    model: searchParams.model || "",
  });

  const isDataEmpty = !Array.isArray(allCars) || allCars.length < 1 || !allCars;

  return (
    <main className="overflow-hidden">
      <Hero />

      <div className="mt-12 padding-x padding-y max-width" id="discover">
        <div className="home__text-container">
          <h1 className="text-4xl font-extrabold">Car Catalogue</h1>
          <p>Explore out cars you might like</p>
        </div>

        <div className="home__filters">
          <SearchBar />

          <div className="home__filter-container">
            <CustomFilter title="fuel" options={fuels} />
            <CustomFilter title="year" options={yearsOfProduction} />
          </div>
        </div>

        {!isDataEmpty ? (
          <section>
            <div className="home__cars-wrapper">
              {allCars?.map((car) => (
                <CarCard car={car} />
              ))}
            </div>

            <ShowMore
              pageNumber={(searchParams.limit || 10) / 10}
              isNext={(searchParams.limit || 10) > allCars.length}
            />
          </section>
        ) : (
          <div className="home__error-container">
            <h2 className="text-black text-xl font-bold">Oops, no results</h2>
            <p>{allCars?.message}</p>
          </div>
        )}
      </div>
    </main>
  );
}


page

: React를 사용하여 구현된 웹 애플리케이션의 홈페이지를 정의하는 컴포넌트 입니다.
React와 관련 라이브러리를 사용하여 동적인 웹 애플리케이션의 홈페이지를 구현하는데 사용됩니다. 사용자에게 자동차 목록을 보여주고 검색 필터를 통해 결과를 필터링하고 페이지네이션을 제공하는 역할을 합니다.

이 컴포넌트는 HomeProps를 매개변수로 받아옵니다.
또한 fetchCars 함수를 사용하여 자동차 데이터를 가져옵니다. 이 데이터는 searchParams 객체를 기반으로 가져오며, searchParams 객체에는 제조사, 연도, 연료 유형, 모델 등의 검색 매개변수가 포함됩니다.

isDateEmpty 변수는 가져온 자동차 데이터가 비어있는지 확인합니다. 데이터가 비어있다면 오류 메시지가 표시됩니다.
홈페이지의 레이아웃은 여러 부분으로 나누어져 있는데 Hero 컴포넌트가 렌더링 되며, 그 아래에는 일부 텍스트와 검색 필터(searchBar, CustomFilter)가 포함된 섹션이 있습니다.
데이터가 존재하는 경우 자동차 데이터를 Car Card 컴포넌트가 렌더링 됩니다. 이 컴포넌트는 자동차의 정보를 보여주는 카드 형식으로 화면에 표시됩니다. 또한 Show More 컴포넌트가 페이지네이션을 처리하고, 다음 페이지로 이동할 수 있는 옵션을 제공합니다.

 

 

 

 

 

"use client";

import { useState } from "react";
import Image from "next/image";

import { calculateCarRent, generateCarImageUrl } from "@/utils";
import { CarProps } from "@/types";
import CustomButton from "./CustomButton";
import CarDetails from "./CarDetails";

interface CarCardProps {
  car: CarProps;
}

const CarCard = ({ car }: CarCardProps) => {
  const { city_mpg, year, make, model, transmission, drive } = car;

  const [isOpen, setIsOpen] = useState(false);

  const carRent = calculateCarRent(city_mpg, year);

  return (
    <div className="car-card group">
      <div className="car-card__content">
        <h2 className="car-card__content-title">
          {make} {model}
        </h2>
      </div>

      <p className="flex mt-6 text-[32px] leading-[38px] font-extrabold">
        <span className="self-start text-[14px] leading-[17px] font-semibold">
          $
        </span>
        {carRent}
        <span className="self-end text-[14px] leading-[17px] font-medium">
          / day
        </span>
      </p>

      <div className="relative w-full h-40 my-3 object-contain">
        <Image
          src={generateCarImageUrl(car)}
          alt="car model"
          fill
          priority
          className="object-contain"
        />
      </div>

      <div className="relative flex w-full mt-2">
        <div className="flex group-hover:invisible w-full justify-between text-grey">
          <div className="flex flex-col justify-center items-center gap-2">
            <Image
              src="/steering-wheel.svg"
              width={20}
              height={20}
              alt="steering wheel"
            />
            <p className="text-[14px] leading-[17px]">
              {transmission === "a" ? "Automatic" : "Manual"}
            </p>
          </div>

          <div className="car-card__icon">
            <Image src="/tire.svg" width={20} height={20} alt="seat" />
            <p className="car-card__icon-text">{drive.toUpperCase()}</p>
          </div>

          <div className="car-card__icon">
            <Image src="/gas.svg" width={20} height={20} alt="seat" />
            <p className="car-card__icon-text">{city_mpg} MPG</p>
          </div>
        </div>

        <div className="car-card__btn-container">
          <CustomButton
            title="View More"
            containerStyles="w-full py-[16px] rounded-full bg-primary-blue"
            textStyles="text-white text-[14px] leading-[17px] font-bold"
            rightIcon="/right-arrow.svg"
            handleClick={() => setIsOpen(true)}
          />
        </div>
      </div>

      <CarDetails
        isOpen={isOpen}
        closeModal={() => setIsOpen(false)}
        car={car}
      />
    </div>
  );
};

export default CarCard;


Car Card

: React를 사용하여 자동차 정보를 나타내는 카드 형식의 컴포넌트인 Car Card를 정의합니다. 이 컴포넌트는 자동차의 사양 및 이미지를 표시하고 View More 버튼을 통해 자세한 자동차 정보를 볼 수 있는 모달 창을 표시합니다.

 

 

 

Detail

-CarCardProps는 car라는 이름의 자동차 정보를 받는 인터페이스를 정의합니다.
-CarCard 함수 컴포넌트는 화면에 자동차 정보를 표시하는 부분입니다. car 객체에서 자동차의 다양한 속성(ex. city_mpg, year, make, model 등)을 추출해냅니다.
-isOpen이라는 상태 변수를 사용하여 자세한 자동차 정보를 표시할 모달 창의 열림/닫힘 상태를 관리합니다.
- calculateCarRent 함수를 사용하여 자동차 대여료를 계산하고 이를 carRent 변수에 저장합니다.
-자동차는 카드 형식으로 표시되며, 이 카드에는 자동차 모델 및 대여료 정보가 포함되어 있습니다. 또한 자동차 이미지도 표시됩니다.
-View More 버튼을 클릭하면, setIsOpen(true)를 호출하여 모달 창을 엽니다.
-CarDetails 컴포넌트가 모달 창을 표시하고 자세한 자동차 정보를 표시합니다.

 

 

 

 

 

import { Fragment } from "react";
import Image from "next/image";

import { Dialog, Transition } from "@headlessui/react";

import { CarProps } from "@/types";
import { generateCarImageUrl } from "@/utils";

interface CarDetailsProps {
  isOpen: boolean;
  closeModal: () => void;
  car: CarProps;
}

const CarDetails = ({ isOpen, closeModal, car }: CarDetailsProps) => (
  <>
    <Transition appear show={isOpen} as={Fragment}>
      <Dialog as="div" className="relative z-10" onClose={closeModal}>
        <Transition.Child
          as={Fragment}
          enter="ease-out duration-300"
          enterFrom="opacity-0"
          enterTo="opacity-100"
          leave="ease-in duration-200"
          leaveFrom="opacity-100"
          leaveTo="opacity-0"
        >
          <div className="fixed inset-0 bg-black bg-opacity-25" />
        </Transition.Child>

        <div className="fixed inset-0 overflow-y-auto">
          <div className="flex min-h-full items-center justify-center p-4 text-center">
            <Transition.Child
              as={Fragment}
              enter="ease-out duration-300"
              enterFrom="opacity-0 scale-95"
              enterTo="opacity-100 scale-100"
              leave="ease-out duration-300"
              leaveFrom="opacity-100 scale-100"
              leaveTo="opacity-0 scale-95"
            >
              <Dialog.Panel
                className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto 
                transform rounded-2xl bg-white p-6 text-left shadow-xl transition-all
                flex flex-col gap-5"
              >
                <button
                  type="button"
                  className="absolute top-2 right-2 z-10 w-fit p-2 bg-primary-blue-100 rounded-full"
                  onClick={closeModal}
                >
                  <Image
                    src="/close.svg"
                    alt="close"
                    width={20}
                    height={20}
                    className="object-contain"
                  />
                </button>

                <div className="flex-1 flex flex-col gap-3">
                  <div className="relative w-full h-40 bg-pattern bg-cover bg-center rounded-lg">
                    <Image
                      src={generateCarImageUrl(car)}
                      alt="car model"
                      fill
                      priority
                      className="object-contain"
                    />
                  </div>

                  <div className="flex gap-3">
                    <div className="flex-1 relative w-full h-24 bg-primary-blue-100 rounded-lg">
                      <Image
                        src={generateCarImageUrl(car, "29")}
                        alt="car model"
                        fill
                        priority
                        className="object-contain"
                      />
                    </div>
                    <div className="flex-1 relative w-full h-24 bg-primary-blue-100 rounded-lg">
                      <Image
                        src={generateCarImageUrl(car, "33")}
                        alt="car model"
                        fill
                        priority
                        className="object-contain"
                      />
                    </div>
                    <div className="flex-1 relative w-full h-24 bg-primary-blue-100 rounded-lg">
                      <Image
                        src={generateCarImageUrl(car, "13")}
                        alt="car model"
                        fill
                        priority
                        className="object-contain"
                      />
                    </div>
                  </div>
                </div>

                <div className="flex-1 flex flex-col gap-2">
                  <h2 className="font-semibold text-xl capitalize">
                    {car.make} {car.model}
                  </h2>

                  <div className="mt-3 flex flex-wrap gap-4">
                    {Object.entries(car).map(([key, value]) => (
                      <div
                        className="flex justify-between gap-5 w-full text-right"
                        key={key}
                      >
                        <h4 className="text-grey capitalize">
                          {key.split("_").join(" ")}
                        </h4>
                        <p className="text-black-100 font-semibold">{value}</p>
                      </div>
                    ))}
                  </div>
                </div>
              </Dialog.Panel>
            </Transition.Child>
          </div>
        </div>
      </Dialog>
    </Transition>
  </>
);

export default CarDetails;

Car Details

:자동차 상세 정보를 표시하기 위한 모달 창을 정의하는 React 컴포넌트인 Car Details입니다.

이 모달 창은 사용자가 자세한 자동차 정보를 볼 수 있는 모달 다이얼로그로 작동합니다.

해당 컴포넌트는 Car Card 컴포넌트에서 View More 버튼을 클릭하면 호출되어 팝업 창을 열고 자세한 자동차 정보를 표시합니다.

 

 

 

Details

-CarDetailsProps는 팝업창을 열거나 닫는데 필요한 상태와 자동차 장보를 받는 인터페이스를 정의합니다.

-CarDetails 함수 컴포넌트는 모달 창의 내용을 렌더링하는 부분입니다. isOpen, closeModal, car 객체를 받아옵니다.

-모달 창은 headlessui 라이브러리의 Transition 및 Dialog 컴포넌트를 사용하여 구현됩니다. 모달이 나타날 때와 사라질 때의 애니메이션 효과를 부여합니다.

-모달 창이 열릴 때, 화면을 어둡게 하는 배경을 생성하여, 모달 창 외부를 클릭하면 모달 창이 닫히도록 합니다. 각 이미지는 Imgage 컴포넌트를 사용하여 표시되며, 이미지 소스는 generateCarImageUrl 함수를 통하여 동적 생성됩니다.

-모달 창 오른쪽 상단에는 닫기 버튼이 있으며, 클릭하면 팝업이 닫힙니다.

-자동차 정보는 제조사, 모델, 그리고 다른 자동차 사양과 속성을 보여줍니다. 이 정보는 car 객체를 통해 동적으로 표시됩니다.

 

✏ headlessui 라이브러리

:headlessui UI는 React와 Vue 같은 프레임워크를 위한 UI 구성 요소 라이브러리.

하지만 보통의 "디자인이 포함된" UI 라이브러라와는 다름.

일반적인 UI 라이브러리는 이미 디자인과 스타일이 입혀져 있는 버튼, 모달, 드롭다운 같은 컴포넌트를 제공하나, headlessui UI는 "디자인 없이 기능만 제공"하는 컴포넌트를 제공함.

즉, 컴포넌트가 어떻게 보일지는 사용자가 직접 CSS나 Tailwind CSS 같은 스타일링 도구를 사용해 결정해야 함.

 

 

 

 

 

"use client";

import Image from "next/image";

import { CustomButtonProps } from "@/types";

const Button = ({
  isDisabled,
  btnType,
  containerStyles,
  textStyles,
  title,
  rightIcon,
  handleClick,
}: CustomButtonProps) => (
  <button
    disabled={isDisabled}
    type={btnType || "button"}
    className={`custom-btn ${containerStyles}`}
    onClick={handleClick}
  >
    <span className={`flex-1 ${textStyles}`}>{title}</span>
    {rightIcon && (
      <div className="relative w-6 h-6">
        <Image
          src={rightIcon}
          alt="arrow_left"
          fill
          className="object-contain"
        />
      </div>
    )}
  </button>
);

export default Button;

Custom Button

: React에서 재사용 가능한 버튼 컴포넌트인 Button을 정의합니다.

이 컴포넌트는 매개변수로 전달된 속성에 따라 버튼의 모양과 동작을 조절할 수 있습니다.

 

 

 

Details

-Custom Button Props는 버튼 컴포넌트에 전달되는 속성을 정의합니다. 이러한 속성에는 버튼의 텍스트, 스타일, 비활성화 상태, 클릭 핸들러 및 기타 속성이 포함됩니다.

-Button 함수 컴포넌트는 버튼을 렌더링하는 부분입니다. 이 함수는 전달된 속성을 사용하여 버튼을 동적으로 생성합니다.

-<button> HTML 요소가 생성됩니다. 이 요소의 속성은 disabled를 통해 isDisabled 속성에 따라 버튼이 활성화 또는 비활성화 되며, btnType 속성에 따라 버튼의 유형을 설정합니다. 기본값은 button이 됩니다. 

-버튼에 적용되는 css 클래스는 custom-btn 및 containerStyles 속성을 통해 지정됩니다.

-버튼 텍스트는 title 속성에 따라 동적으로 설정 됩니다.

-right Icon 속성에 아이콘 이미지 URL이 제고오디면 이미지가 표시됩니다. 이미지는 next/image를 사용하여 효율적으로 렌더링 됩니다.

-이 버튼 컴포넌트는 매개변수로 전달된 속성을 기반으로 버튼을 렌더링 하며, 다양한 컨텍스트<footnote>React의 컴포넌트 컨텍스트를 의미함. 위에서 언급한 Button 컴포넌트는 여러 부모 컴포넌트에서 사용될 수 있고, 각 부모 컴포넌트에서는 다양한 속성을 전달하여 버튼의 동작과 모양을 조절할 수 있음. 이 때, 컨텍스트는 해당 컴포넌트가 어떤 상황이나 환경에서 사용되는지에 대한 배경을 나타냄. </footnote>에서 재사용할 수 있습니다. Custom Button Props를 통해 버튼의 모양과 동작을 설정하고 필요한 곳에서 이 컴포넌트를 사용할 수 있습니다.

 

✏ disabled

HTML, CSS, Javascript에서 자주 사용되는 속성으로, 주로 사용자 인터페이스 요소를 비활성화 하는데 사용됨.

특정 요소가 비활성화 되면 사용자는 그 요소와 상호작용 할 수 없음.

 

HTML

:폼 요소에 주로 사용됨.

예를 들어, 버튼, 입력 필트, 셀렉트 박스 등에 disabled 속성을 추가하면 사용자가 해당 요소를 클릭하거나 입력할 수 없음.

 

CSS

:disabled 상태에 있는 요소에 특정 스타일을 적용할 수 있음.

예를 들어, 비활성화 된 버튼은 회색으로 표시되도록 할 수 있음.

 

Javascript

:동적으로 disabled 속성을 추가하거나 제거할 수 있음.

예를 들어 버튼을 비활성화 하거나, 버튼을 다시 활성화 하는 방식으로 사용할 수 있음.

const button = document.querySelector('button');
button.disabled = true; // 버튼을 비활성화함
button.disabled = false; // 버튼을 다시 활성화함

 

Headless UI

: headless UI 컴포넌트에서도 disable 속성이 지원됨.

<Menu.Item disabled>
   {({ active }) => (
      <a
         className={`${active ? 'bg-blue-500 text-white' : 'text-gray-900'} block px-4 py-2 text-sm`}
      >
         Disabled Item
      </a>
   )}
</Menu.Item>

이 경우, disable 속성이 적용된 메뉴 항목은 클릭할 수 없고 스타일도 달라지게 됨.

 

 

 

 

 

"use client";

import { Fragment, useState } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { Listbox, Transition } from "@headlessui/react";

import { CustomFilterProps } from "@/types";
import { updateSearchParams } from "@/utils";

export default function CustomFilter({ title, options }: CustomFilterProps) {
  const router = useRouter();
  const [selected, setSelected] = useState(options[0]);

  const handleUpdateParams = (e: { title: string; value: string }) => {
    const newPathName = updateSearchParams(title, e.value.toLowerCase());

    router.push(newPathName);
  };

  return (
    <div className="w-fit">
      <Listbox
        value={selected}
        onChange={(e) => {
          setSelected(e);
          handleUpdateParams(e);
        }}
      >
        <div className="relative w-fit z-10">
          <Listbox.Button className="custom-filter__btn">
            <span className="block truncate">{selected.title}</span>
            <Image
              src="/chevron-up-down.svg"
              width={20}
              height={20}
              className="ml-4 object-contain"
              alt="chevron up down"
            />
          </Listbox.Button>
          <Transition
            as={Fragment}
            leave="transition ease-in duration-100"
            leaveFrom="opacity-100"
            leaveTo="opacity-0"
          >
            <Listbox.Options className="custom-filter__options">
              {options.map((option) => (
                <Listbox.Option
                  key={option.title}
                  className={({ active }) =>
                    `relative cursor-default select-none py-2 px-4 ${
                      active ? "bg-primary-blue text-white" : "text-gray-900"
                    }`
                  }
                  value={option}
                >
                  {({ selected }) => (
                    <>
                      <span
                        className={`block truncate ${
                          selected ? "font-medium" : "font-normal"
                        }`}
                      >
                        {option.title}
                      </span>
                    </>
                  )}
                </Listbox.Option>
              ))}
            </Listbox.Options>
          </Transition>
        </div>
      </Listbox>
    </div>
  );
}

Custom Filter

: Listbox를 사용하여 사용자가 특정 옵션을 선택할 수 있는 커스텀 필터를 만드는 React 컴포넌트입니다.

이 컴포넌트는 사용자에게 선택 옵션을 제공하고, 선택이 변경될 때마다 URL을 업데이트하여 페이지를 다시 로드하는 등의 기능을 수행합니다.

 

 

 

Details

-useState 훅을 사용하여 현재 선택된 옵션을 추적하며, 초깃값은 options 배열의 첫 번째 항목으로 설정됩니다.

-handleUpdateParams 함수는 옵션이 변경될 때 호출되는 함수로, 선택된 옵션을 기반으로 URL을 업데이트 합니다. 이 함수는 updateParams 유틸리티 함수<footnote>특정 작업을 수행하기 위해 만들어진 재사용 가능한 함수를 나타냄. 특정한 작업을 처리하거나 특정 값을 계산하는데 사용되며, 특정한 동작을 수행하는데 도움이 되는 함수들을 유틸리티 함수로 분리하여 작성하면 가독성이 높아지고 유지 보수가 쉬워짐.</footnote>를 사용하여 URL을 업데이트하고 페이지를 다시 로드합니다.

-Listbox 컴포넌트는 사용자에게 옵션을 선택할 수 있는 드롭다운 메뉴를 제공합니다. Headless UI<footnote>사용자 인터페이스를 만들기 위한 접근 방식 중 하나로, Headless UI에서 headless는 UI요소가 와관을 갖지 않고 스타일이나 레이아웃이 정의되어 있지 않다는 의미임. UI를 완전히 사용자 지정할 수 있도록 함. 이 패턴은 주로 React나 Vue 같은 컴포넌트 기반 라이브러리와 함께 사용됨. </footnote>에서 제공하는 컴포넌트로 외관 및 동작을 완전히 사용자 지정할 수 있습니다.

-Listbox.Button은 실제로 선택 상태를 보여주는 버튼 역할을 합니다.

-Listbox.Options는 옵션 목록을 나타냅니다. 이 목록은 선택한 옵션에 따라 동적으로 변합니다.

 

 

 

 

 

"use client";

import Image from "next/image";
import React, { useState } from "react";
import { useRouter } from "next/navigation";

import SearchManufacturer from "./SearchManufacturer";

const SearchButton = ({ otherClasses }: { otherClasses: string }) => (
  <button type="submit" className={`-ml-3 z-10 ${otherClasses}`}>
    <Image
      src={"/magnifying-glass.svg"}
      alt={"magnifying glass"}
      width={40}
      height={40}
      className="object-contain"
    />
  </button>
);

const SearchBar = () => {
  const [manufacturer, setManuFacturer] = useState("");
  const [model, setModel] = useState("");

  const router = useRouter();

  const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    if (manufacturer.trim() === "" && model.trim() === "") {
      return alert("Please provide some input");
    }

    updateSearchParams(model.toLowerCase(), manufacturer.toLowerCase());
  };

  const updateSearchParams = (model: string, manufacturer: string) => {
    const searchParams = new URLSearchParams(window.location.search);

    if (model) {
      searchParams.set("model", model);
    } else {
      searchParams.delete("model");
    }

    if (manufacturer) {
      searchParams.set("manufacturer", manufacturer);
    } else {
      searchParams.delete("manufacturer");
    }

    const newPathname = `${
      window.location.pathname
    }?${searchParams.toString()}`;

    router.push(newPathname);
  };

  return (
    <form className="searchbar" onSubmit={handleSearch}>
      <div className="searchbar__item">
        <SearchManufacturer
          manufacturer={manufacturer}
          setManuFacturer={setManuFacturer}
        />
        <SearchButton otherClasses="sm:hidden" />
      </div>
      <div className="searchbar__item">
        <Image
          src="/model-icon.png"
          width={25}
          height={25}
          className="absolute w-[20px] h-[20px] ml-4"
          alt="car model"
        />
        <input
          type="text"
          name="model"
          value={model}
          onChange={(e) => setModel(e.target.value)}
          placeholder="Tiguan..."
          className="searchbar__input"
        />
        <SearchButton otherClasses="sm:hidden" />
      </div>
      <SearchButton otherClasses="max-sm:hidden" />
    </form>
  );
};

export default SearchBar;

Search Bar

: 자동차 검색을 위한 검색 바를 나타냅니다.

이 컴포넌트는 자동차를 검색하는데 사용되며, 사용자가 검색어나 제조사를 입력하면 URL이 업데이트 되어 해당 검색 조건을 가진 자동차 목록을 표시합니다.

 

 

 

Details

-SearchManufacturer 컴포넌트를 통해 제조사 검색을 위한 드롭다운이나 자동완성 기능을 구현하고 있습니다. manufacturer 상태와 setManufacturer 함수로 상태를 업데이트 하고 있습니다.

-Search Button 검색 버튼 컴포넌트는 검색을 시작하는데 사용됩니다. 버튼에는 otherClasses라는 속성이 전달되며, 이는 추가적인 스타일을 적용하는데 사용됩니다.

-handleSearch 함수는 폼 제출 이벤트를 처리하며, 입력값이 유효한지 확인하며, 검색 매개변수를 업데이트한 다음 페이지를 다시 로드합니다.

-URL SearchParams는 updateParams 함수를 통해 URL 매개변수를 업데이트 합니다. 검색어와 제조사가 변경될 때마다 URL을 업데이트하여 해당 검색 조건을 반영합니다.

-Responsive Design 검색 바는 미디어 쿼리를 사용하여 화면의 크기에 따라 다르게 표시됩니다. 화면의 크기에 따라 드롭다운 기능이나 검색 창만 표시되도록 설정되어 있습니다.

 

 

 

import Image from "next/image";
import { Fragment, useState } from "react";
import { Combobox, Transition } from "@headlessui/react";

import { manufacturers } from "@/constants";
import { SearchManuFacturerProps } from "@/types";

const SearchManufacturer = ({
  manufacturer,
  setManuFacturer,
}: SearchManuFacturerProps) => {
  const [query, setQuery] = useState("");

  const filteredManufacturers =
    query === ""
      ? manufacturers
      : manufacturers.filter((item) =>
          item
            .toLowerCase()
            .replace(/\s+/g, "")
            .includes(query.toLowerCase().replace(/\s+/g, ""))
        );

  return (
    <div className="search-manufacturer">
      <Combobox value={manufacturer} onChange={setManuFacturer}>
        <div className="relative w-full">
          <Combobox.Button className="absolute top-[14px]">
            <Image
              src="/car-logo.svg"
              width={20}
              height={20}
              className="ml-4"
              alt="Car Logo"
            />
          </Combobox.Button>

          <Combobox.Input
            className="search-manufacturer__input"
            displayValue={(item: string) => item}
            onChange={(event) => setQuery(event.target.value)}
            placeholder="Volkswagen..."
          />

          <Transition
            as={Fragment}
            leave="transition ease-in duration-100"
            leaveFrom="opacity-100"
            leaveTo="opacity-0"
            afterLeave={() => setQuery("")}
          >
            <Combobox.Options
              className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
              static
            >
              {filteredManufacturers.length === 0 && query !== "" ? (
                <Combobox.Option
                  value={query}
                  className="search-manufacturer__option"
                >
                  Create "{query}"
                </Combobox.Option>
              ) : (
                filteredManufacturers.map((item) => (
                  <Combobox.Option
                    key={item}
                    className={({ active }) =>
                      `relative search-manufacturer__option ${
                        active ? "bg-primary-blue text-white" : "text-gray-900"
                      }`
                    }
                    value={item}
                  >
                    {({ selected, active }) => (
                      <>
                        <span
                          className={`block truncate ${
                            selected ? "font-medium" : "font-normal"
                          }`}
                        >
                          {item}
                        </span>
                        {selected ? (
                          <span
                            className={`absolute inset-y-0 left-0 flex items-center pl-3 ${
                              active
                                ? "text-white"
                                : "text-pribg-primary-purple"
                            }`}
                          ></span>
                        ) : null}
                      </>
                    )}
                  </Combobox.Option>
                ))
              )}
            </Combobox.Options>
          </Transition>
        </div>
      </Combobox>
    </div>
  );
};

export default SearchManufacturer;

SearchManufacturer

: 제조사를 검색하기 위한 드롭다운 또는 자동완성을 제공합니다.

제조사 검색을 쉽게 할 수 있도록 인터랙티브 하게(사용자와 상호작용할 수 있는 기능) 구현되었습니다.

 

 

 

Details

-Headless UI의 Combobox 컴포넌트를 사용하여 검색 및 드롭다운 기능을 구현합니다. 이를 통해 제조사 목록에서 선택하거나 새로운 제조사를 추가할 수 있습니다.

-useState에서는 query라는 상태를 사용하여 검색어를 추적하고, setQuery 함수를 통해 이를 업데이트합니다. 또한 filteredManufacturers 상태를 이용하여 현재 검색어에 따라 필터링 된 제조사 목록을 저장합니다.

-Combobox.Input은 사용자가 검색어를 입력하는 입력란입니다. onChange 핸들러를 사용하여 검색어가 변경될 때마다 setQuery 함수를 호출하여 query 상태를 업데이트합니다.

 

useState

:React에서 상태(State)를 관리하기 위한 훅(hook)

 

ex.

const [state, setState] = useState(initialValue);

 state: 현재 상태 값

setState: 상태 값을 업데이트하는 함수

initiaValue: 상태의 초기값

 

useState에서 query와 setQuery의 역할

 

ex.

const [query, setQuery] = useState('');

query

: 이 변수는 검색어를 저장하는 상태 값임. 

예를 들어 사용자가 검색 입력란에 React라는 단어를 입력하면 query 상태는 이 값을 저장함. 즉, query는 현재 입력된 검색어를 추적하고 보관하는 역할을 함.

 

setQuery

:이 함수는 query 상태를 업데이트 하는데 사용됨.

사용자가 입력란에 새로운 검색어를 입력할 때마다 이 함수가 호출되어 query 상태를 최신값으로 변경함.

예를 들어, 사용자가 입력을 변경할 때마다 setQuery(newValue)를 호출하여 query의 값을 newValue(새로운 값)으로 업데이트 할 수 있음.

 

 

 

 

 

import { CarProps, FilterProps } from "@/types";

export const calculateCarRent = (city_mpg: number, year: number) => {
  const basePricePerDay = 50;
  const mileageFactor = 0.1;
  const ageFactor = 0.05;

  const mileageRate = city_mpg * mileageFactor;
  const ageRate = (new Date().getFullYear() - year) * ageFactor;

  const rentalRatePerDay = basePricePerDay + mileageRate + ageRate;

  return rentalRatePerDay.toFixed(0);
};

export const updateSearchParams = (type: string, value: string) => {
  const searchParams = new URLSearchParams(window.location.search);

  searchParams.set(type, value);

  const newPathname = `${window.location.pathname}?${searchParams.toString()}`;

  return newPathname;
};

export const deleteSearchParams = (type: string) => {
  const newSearchParams = new URLSearchParams(window.location.search);

  newSearchParams.delete(type.toLocaleLowerCase());

  const newPathname = `${
    window.location.pathname
  }?${newSearchParams.toString()}`;

  return newPathname;
};

export async function fetchCars(filters: FilterProps) {
  const { manufacturer, year, model, limit, fuel } = filters;

  const headers: HeadersInit = {
    "X-RapidAPI-Key": "7ffff88ee4msh3ef184e28e73bb3p172057jsndfdd6b4649d7",
    // "X-RapidAPI-Key": "process.env.NEXT_PUBLIC_IMAGIN_API_KEY || "", 해당 환경 변수가 설정 되어 있을 경우 해당 값 사용, 비어있는 경우 빈 문자열 사용
    "X-RapidAPI-Host": "cars-by-api-ninjas.p.rapidapi.com",
  };

  const response = await fetch(
    `https://cars-by-api-ninjas.p.rapidapi.com/v1/cars?make=${manufacturer}&year=${year}&model=${model}&limit=${limit}&fuel_type=${fuel}`,
    {
      headers: headers,
    }
  );

  const result = await response.json();

  return result;
}

export const generateCarImageUrl = (car: CarProps, angle?: string) => {
  const url = new URL("https://cdn.imagin.studio/getimage");
  const { make, model, year } = car;

  url.searchParams.append("customer", "hrjavascript-mastery");
  // url.searchParams.append(
  //   "customer",
  //   process.env.NEXT_PUBLIC_IMAGIN_API_KEY || "" 해당 환경 변수(hrjavascript-mastery)가 설정 되어 있을 경우 해당 값 사용, 비어있는 경우 빈 문자열 사용
  // );
  url.searchParams.append("make", make);
  url.searchParams.append("modelFamily", model.split(" ")[0]);
  url.searchParams.append("zoomType", "fullscreen");
  url.searchParams.append("modelYear", `${year}`);
  url.searchParams.append("angle", `${angle}`);

  return `${url}`;
};

utils - index.ts

: type script를 사용하여 React 애플리케이션에서 사용되는 여러 유틸리티 함수들을 정의하고 있습니다.

 

 

Details

-calculateCarRent

: 자동차 렌탈 요금을 계산하는 함수입니다. 주행 마일리지와 자동차 년도를 고려하여 기본 렌탈 요금을 계산합니다.

city_mpg는 주행 마일리지, year는 자동차의 생산 년도입니다. 기본 일일 렌탈 요금, 주행 마일리지에 따른 추가 비용, 그리고 자동차의 나이에 따른 추가 비용을 고려하여 총 렌탈 요금을 계산하고 그 값을 문자열로 반환합니다.

 

-updateSearchParams

:현재 URL의 쿼리 매개변수를 업데이트하는 함수입니다. 주어진 유형(type)과 값(value)을 사용하여 매개변수를 설정하고, 업데이트된 URL을 반환합니다.

type은 업데이트할 매개변수의 이름이고, value는 해당 매개변수에 설정할 값입니다. 그리고 업데이트 된 URL을 반환합니다.

 

-deleteSearchParams

:현재 URL의 특정 쿼리 매개변수를 삭제하는 함수입니다. 유형에 해당하는 매개변수를 제거하고, 업데이트된 URL을 반환합니다.

여기서의 type은 삭제할 매개변수의 이름입니다. 업데이트된 URL을 반환합니다.

 

-fetchCars

: 외부 API에서 자동차 정보를 가져오는 비동기 함수입니다. 주어진 필터를 사용하여 API에 요청하고, 결과를 반환합니다.

filters 객체는 제조사, 연도, 모델 등을 지정하는데 사용됩니다.

 

-generateCarImageUrl

:Image 스튜디오 API를 사용하여 자동차 이미지 URL을 생성하는 함수입니다. 주어진 자동차 정보와 각도(angle)을 고려하여 이미지 URL을 생성하고 반환합니다.

 

+ Recent posts