[프로그래머스] 프론트엔드 심화: 프로젝트(3)

Lina Hongbi Ko·2024년 11월 12일
0

Programmers_BootCamp

목록 보기
54/76
post-thumbnail

2024년 11월 12일

✏️ 기본 컴포넌트 작성 - Title 컴포넌트

  • 컴포넌트는 UI에 독립적이고, 재사용가능하게 만드는게 중요 → 각각의 컴포넌트가 모여서 어플리케이션 전체를 구성함

  • 작은 단위의 컴포넌트를 만들어 조립해 전체 화면 또는 어플리케이션을 개발 → 개발 과정을 모듈화 시킴 → 유지보수가 쉬워짐

  • Title 컴포넌트 만들기

// components / common / Title.tsx

import { styled } from 'styled-components'
import { ColorKey, HeadingSize } from '../../style/theme';

interface Props {
  children : React.ReactNode;
  size : HeadingSize;
  color ?: ColorKey;
}

const Title = ({ children, size, color }:Props) => {
  return (
    <TitleStyle size={size} color={color}>{children}</TitleStyle>
  )
}

const TitleStyle = styled.h1<Omit<Props, "children">>`
  font-size : ${({theme, size}) => theme.heading[size].fontSize};
  color :  ${({theme, color}) => color ? theme.colors[color] : theme.colors.primary};
`;

export default Title
// theme.ts

... 생략 ...
export type ColorKey = "primary" | "background" | "secondary" | "third";
export type HeadingSize = "large" | "medium" | "small";

interface Theme {
  name: ThemeName;
  colors: Record<ColorKey, string>;
  heading : {
    [key in HeadingSize] : {
      fontSize: string;
    }
  };
}

export const light: Theme = {
  name : "light",
  colors : {
    primary : "brown",
    background : "lightgray",
    secondary : "blue",
    third : "green",
  },
  heading : {
    large: {
      fontSize: "2rem"
    },
    medium: {
      fontSize: "1.5rem"
    },
    small: {
      fontSize: "1rem"
    }
  }
};

export const dark: Theme = {
  ...light,
  name : "dark",
  colors : {
    primary : "coral",
    background: "midnightblue",
    secondary : "darkblue",
    third : "darkgreen"
  },
  // name, colors만 오버라이딩
};

... 생략 ...
// pages / Home.tsx

import Title from '../components/common/Title';

const Home = () => {
  return (
    <>
      <Title size="large">제목 테스트</Title>
      <div>home body</div>
    </>
  );
}

export default Home;

  • Title 컴포넌트 테스트

    • 테스트는 단순하고 공용으로 쓰는 컴포넌트 일수록 더욱 필요함
      • 여러 작업자가 사용하고, 확장할수록 props가 늘어나고 render방식이 바뀔 확률이 높기 때문에 미리 테스트를 작성해 두고, 여러 단계에서 테스트를 구동해서 화면상의 오류를 사전에 방지하는게 좋음!
// Title.spec.tsx

import { render, screen } from '@testing-library/react';
import Title from './Title';
import { BookStoreThemeProvider } from '../../context/themeContext';

describe("Title 컴포넌트 테스트", () => {
  it('렌더를 확인', () => {
    // 1. 렌더
    render(
      <BookStoreThemeProvider>
        <Title size="large">제목</Title>
      </BookStoreThemeProvider>
    );
    // 2. 확인
    expect(screen.getByText('제목')).toBeInTheDocument();
  });

  it('size props 적용', () => {
    const { container } = 
    // 1. 렌더
    render(
      <BookStoreThemeProvider>
        <Title size="large">제목</Title>
      </BookStoreThemeProvider>
    );

    // 2. 확인
    expect(container?.firstChild).toHaveStyle({fontSize: "2rem"})
  });

  it('color props 적용', () => {
    const { container } = render(
      <BookStoreThemeProvider>
        <Title size="large" color="primary">제목</Title>
      </BookStoreThemeProvider>
    );

    expect(container?.firstChild).toHaveStyle
    ({color : "brown"})
  });
})

✏️ 기본 컴포넌트 작성 - Button 컴포넌트

// components / common / Button.tsx

import { styled } from 'styled-components'
import { ButtonScheme, ButtonSize } from '../../style/theme';

interface Props {
  children: React.ReactNode
  size: ButtonSize;
  scheme: ButtonScheme;
  disabled?: boolean;
  isLoading?: boolean;
}

const Button = ({ children, size, scheme, disabled, isLoading }:Props) => {
  return (
    <ButtonStyle size={size} scheme={scheme} disabled={disabled} isLoading={isLoading}>{children}</ButtonStyle>
  )
}

const ButtonStyle = styled.button<Omit<Props, "children">>`
  font-size : ${({theme, size}) => theme.button[size].fontSize};
  padding : ${({theme, size}) => theme.button[size].padding};
  color : ${({theme, scheme}) => theme.buttonScheme[scheme].color};
  background-color: ${({theme, scheme}) => theme.buttonScheme[scheme].backgroundColor};
  border: 0;
  border-radius : ${({theme}) => theme.borderRadius.default};
  opacity: ${({disabled}) => (disabled ? 0.5 : 1)};
  pointer-events : ${({disabled}) => (disabled ? "none" : "auto")};
  cursor : ${({disabled}) => (disabled ? 'none' : "pointer")};
`;

export default Button
// style / theme.ts

... 생략 ...
export type ButtonSize = "large" | "medium" | "small";
export type ButtonScheme = "primary" | "normal";

interface Theme {

  ... 생략 ...
  
  button: {
    [key in ButtonSize] : {
      fontSize: string;
      padding: string;
    }
  };
  buttonScheme: {
    [key in ButtonScheme] : {
      color: string;
      backgroundColor: string;
    }
  };
  borderRadius: {
    default: string;
  }
}

export const light: Theme = {
  
  ... 생략 ...
  
  button : {
    large: {
      fontSize: "1.5rem",
      padding: "1rem 2rem"
    },
    medium: {
      fontSize: "1rem",
      padding: "0.5rem 1rem"
    },
    small: {
      fontSize: "0.75rem",
      padding: "0.25rem 0.5rem"
    }
  },
  buttonScheme: {
    primary : {
      color : "white",
      backgroundColor : "midnightblue"
    },
    normal : {
      color : "black",
      backgroundColor: "lightgrey"
    }
  },
  borderRadius: {
    default: "4px"
  }
};

export const dark: Theme = {
  ...light,
  name : "dark",
  colors : {
    primary : "coral",
    background: "midnightblue",
    secondary : "darkblue",
    third : "darkgreen"
  }
};

... 생략 ...
// Home.tsx

import Button from '../components/common/Button';
import Title from '../components/common/Title';

const Home = () => {
  return (
    <>
      <Title size="large">제목 테스트</Title>
      <Button size="large" scheme="primary">버튼 테스트</Button>
      <div>home body</div>
    </>
  );
}

export default Home;

  • Button 컴포넌트 테스트
// components / common / Button.spec.tsx

import { render, screen } from '@testing-library/react';
import Button from './Button';
import { BookStoreThemeProvider } from '../../context/themeContext';


describe("Button 컴포넌트 테스트", () => {
  it('렌더 확인', () => {
    render(
      <BookStoreThemeProvider>
        <Button size="large" scheme="primary">버튼</Button>
      </BookStoreThemeProvider>
    );
    expect(screen.getByText('버튼')).toBeInTheDocument();
  });

  it('size props 적용', () => {
    const { container } = 
    // 1. 렌더
    render(
      <BookStoreThemeProvider>
        <Button size="large" scheme="primary">버튼</Button>
      </BookStoreThemeProvider>
    );

    // 2. 확인
    expect(screen.getByRole("button")).toHaveStyle({fontSize: "1.5rem"})
  });
})

✏️ 기본 컴포넌트 작성 - Input 컴포넌트

  • Input 컴포넌트는 foward-ref 방식을 사용할 예정
// components / common / InputText.tsx

// 기본적인 forwardref 방식의 input 컴포넌트 작성

import React, { ForwardedRef } from 'react';
import styled from 'styled-components';

interface Props {
  placeholder?: string;
}

const InputText = React.forwardRef(({ placeholder }: Props, 
ref: ForwardedRef<HTMLInputElement>) => {
  return(
    <InputTextStyle placeholder={placeholder} ref={ref} />
  );
})

const InputTextStyle = styled.input.attrs({ type: "text"})``;

export default InputText;
  • 스타일링 적용
// components / common / InputText.tsx

import React, { ForwardedRef } from 'react';
import styled from 'styled-components';

interface Props {
  placeholder?: string;
}

const InputText = React.forwardRef(({ placeholder }: Props, ref: ForwardedRef<HTMLInputElement>) => {
  return(
    <InputTextStyle placeholder={placeholder} ref={ref} />
  );
})

const InputTextStyle = styled.input.attrs({ type: "text"})`
  padding : 0.25rem 0.75rem;
  border : 1px solid ${({theme}) => theme.colors.border};
  border-radius : ${({theme}) => theme.borderRadius.default};
  font-size : 1rem;
  line-height : 1.5;
  color :  ${({theme}) => theme.colors.text}
`;

export default InputText;
// style / theme.ts

... 생략 ...

export type ColorKey = "primary" | "background" | "secondary"
 | "third" | "border" | "text";

... 생략 ...

export const light: Theme = {
  name : "light",
  colors : {
    primary : "brown",
    background : "lightgrey",
    secondary : "blue",
    third : "green",
    border: "grey",
    text: "black"
  },
  ... 생략 ...
  
  export const dark: Theme = {
  ...light,
  name : "dark",
  colors : {
    primary : "coral",
    background: "midnightblue",
    secondary : "darkblue",
    third : "darkgreen",
    border: "grey",
    text: "black"
    
    ... 생략 ...
// pages / Home.tsx

import Button from '../components/common/Button';
import InputText from '../components/common/InputText';
import Title from '../components/common/Title';

const Home = () => {
  return (
    <>
      <Title size="large">제목 테스트</Title>
      <Button size="large" scheme="primary">버튼 테스트</Button>
      <InputText placeholder='여기에 입력하세요'/>
      <div>home body</div>
    </>
  );
}

export default Home;

  • InputText 컴포넌트 테스트
// components / common / InputText.spec.tsx

import React from 'react';
import { render, screen } from '@testing-library/react';
import InputText from './InputText';
import { BookStoreThemeProvider } from '../../context/themeContext';

describe("InputText 컴포넌트 테스트", () => {
  it('렌더를 확인', () => {
    // 1. 렌더
    render(
      <BookStoreThemeProvider>
        <InputText placeholder='여기에 입력'/>
      </BookStoreThemeProvider>
    );
    // 2. 확인
    expect(screen.getByPlaceholderText('여기에 입력')).toBeInTheDocument();
  });

  it('forwardRef 테스트', () => {
    const ref = React.createRef<HTMLInputElement>();
    render(
      <BookStoreThemeProvider>
        <InputText placeholder='여기에 입력' ref={ref}/>
      </BookStoreThemeProvider>
    );
    // 2.
    expect(ref.current).toBeInstanceOf(HTMLInputElement);
  });
})

✏️ Header & Footer

  • bookstore에 들어가는 헤더와 푸터의 구성

  • Header
// components / common / Header.tsx

import { styled } from 'styled-components';
import logo from "../../assets/logo.png";
import { FaSignInAlt, FaRegUser } from "react-icons/fa";

const CATEGORY = [
  {
    id: null,
    name: "전체"
  },
  {
    id: 0,
    name: "동화"
  },
  {
    id: 1,
    name: "소설"
  },
  {
    id: 2,
    name: "사회"
  }
];
const Header = () => {
  return (
    <HeaderStyle>
      <h1 className="logo">
        <img src={logo} alt="book store"/ >
      </h1>
      <nav className="category">
        <ul>
          {
            CATEGORY.map((item) => (
              <li key={item.id}>
                <a href={item.id === null ? '/books' : `/books?category_id=${item.id}`}>
                  {item.name}
                </a>
              </li>
            ))
          }
        </ul>
      </nav>
      <nav className="auth">
        <ul>
          <li>
            <a href="/login"><FaSignInAlt />로그인</a>
          </li>
          <li>
            <a href="/join"><FaRegUser />회원가입</a>
          </li>
        </ul>
      </nav>
    </HeaderStyle>
  )
}

const HeaderStyle = styled.header`
    width: 100%;
    margin: 0 auto;
    max-width: ${({theme}) => theme.layout.width.large};

    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 20px 0;
    borer-bottom: 1px solid ${({theme}) => theme.colors.background};

    .logo {
      img {
        width: 100px;
      }
    }

    .category {
      ul {
        display: flex;
        gap: 32px;
        li {
          a {
            font-size: 1.5rem;
            font-weight: 600;
            text-decoration: none;
            color: ${({theme}) => theme.colors.text};

            &:hover {
              color: ${({theme}) => theme.colors.primary};
            }
          }
        }
      }
    }

    .auth {
      ul {
        display: flex;
        gap: 16px;
        li {
          a {
            font-size: 1rem;
            font-weight: 600;
            text-decoration: none;
            display: flex;
            align-item: center;
            line-height: 1;
          }

          svg {
            margin-right: 6px;
          }
        }
      }
    }
`;

export default Header;
// style / theme.ts

... 생략 ...

export type LayoutWidth = "large" | "medium" | "small";

interface Theme {

... 생략 ...

	layout: {
    width : {
      [key in LayoutWidth] : string;
    }
  }
}

export const light: Theme = {
  
  ... 생략 ...
  
  layout : {
    width: {
      large: "1020px",
      medium: "760px",
      small: "320px"
    }
  }
};

... 생략 ...

  • Footer
// components / common / Footer.tsx

import styled from 'styled-components';
import logo from '../../assets/logo.png';

const Footer = () => {
  return (
    <FooterStyle>
      <h1 className="logo">
        <img src={logo} alt="book store"/ >
      </h1>
      <div className="copyright">
        <p>copyright(c), 2024, Book Store.</p>
      </div>
    </FooterStyle>
  )
}

const FooterStyle = styled.footer`
    width: 100%;
    margin: 0 auto;
    max-width: ${({theme}) => theme.layout.width.large};
    borer-top: 1px solid ${({theme}) => theme.colors.background};
    padding: 20px 0;
    display: flex;
    justify-content: space-between;
    .logo {
      img {
        width: 70px;
      }
    }

    .copyright{
      p {
        font-size: 0.75rem;
        color: ${({theme}) => theme.colors.text};
      }
    }
`;

export default Footer;

  • Layout
// components / layout / layout.tsx

import Header from '../common/Header';
import Footer from '../common/Footer';
import styled from 'styled-components';

interface LayoutProps {
  children: React.ReactNode; // 리액트로 만든 모든 컴포넌트들이 배치될 수 있다
}

const Layout = ({ children }: LayoutProps) => {
  return (
    <>
      <Header />
      <LayoutStyle>{children}</LayoutStyle>
      <Footer />
    </>
  )
}
const LayoutStyle = styled.main`
  width: 100%;
  margin: 0 auto;
  max-width: ${({theme}) => theme.layout.width.large};
  padding: 20px 0;
`;

export default Layout

profile
프론트엔드개발자가 되고 싶어서 열심히 땅굴 파는 자

0개의 댓글