2024년 11월 12일
컴포넌트는 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 컴포넌트 테스트
// 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"})
});
})
// 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;
// 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"})
});
})
ref
prop을 넘겨서 그 내부에 있는 HTML 엘리먼트에 접근을 하게 해줌 https://www.daleseo.com/react-forward-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;
// 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);
});
})
// 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"
}
}
};
... 생략 ...
// 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;
// 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