2024.02.22(목)
src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
src/App.tsx
import Layout from "./components/layout/Layout";
import Detail from "./pages/Detail";
import Home from "./pages/Home";
import ThemeSwitcher from "./components/header/ThemeSwitcher";
import { BookStoreThemeProvider } from "./context/themeContext";
function App() {
return (
<BookStoreThemeProvider>
<ThemeSwitcher />
<Layout>
<Home />
{/* <Detail /> */}
</Layout>
</BookStoreThemeProvider>
);
}
export default App;
src/components/header/ThemeSwitcher.tsx
Context API인 ThemeContext에 의존
import { useContext } from "react";
import { ThemeContext } from "../../context/themeContext";
function ThemeSwitcher() {
const { themeName, toggleTheme } = useContext(ThemeContext)
return (
<button onClick={toggleTheme}>{themeName}</button>
);
}
export default ThemeSwitcher;
src/context/themeContext.tsx
ThemeContext 생성 & 초기값 설정
BookStoreThemeProvider라는 component 생성
useContext
Hook으로 사용할 수 있도록 함import { ReactNode, createContext, useEffect, useState } from "react";
import { ThemeName, getTheme } from "../style/theme";
import { ThemeProvider } from "styled-components";
import { GlobalStyle } from "../style/global";
const DEFAULT_THEME_NAME = "light";
const THEME_LOCALSTORAGE_KEY = "book_store_theme";
interface State {
themeName: ThemeName;
toggleTheme: () => void;
}
export const state = {
themeName: DEFAULT_THEME_NAME as ThemeName,
toggleTheme: () => {}
};
export const ThemeContext = createContext<State>(state);
export const BookStoreThemeProvider = ({ children }: { children: ReactNode }) => {
const [themeName, setThemeName] = useState<ThemeName>(DEFAULT_THEME_NAME);
const toggleTheme = () => {
setThemeName(themeName === "light" ? "dark" : "light");
localStorage.setItem(THEME_LOCALSTORAGE_KEY, themeName === "light" ? "dark" : "light");
};
useEffect(() => {
const savedThemeName = localStorage.getItem(THEME_LOCALSTORAGE_KEY) as ThemeName;
setThemeName(savedThemeName || themeName);
}, []);
return (
<ThemeContext.Provider value={{ themeName, toggleTheme }}>
<ThemeProvider theme={getTheme(themeName)}>
<GlobalStyle themeName={themeName} />
{children}
</ThemeProvider>
</ThemeContext.Provider>
);
};
src/style/theme.ts
HeadingSize, interface Theme에 heading 부분, light과 dark에 heading 객체 추가
dark에서 heading 정보는 공통이므로 light를 spread 연산자로 구조분해 할당한 후 name과 color만 오버라이딩
export type ThemeName = "light" | "dark";
export type ColorKey = "primary" | "background" | "secondary" | "third";
export type HeadingSize = "large" | "medium" | "small";
interface Theme {
name: ThemeName;
color: Record<ColorKey, string>;
heading: {
[key in HeadingSize]: {
fontSize: string;
}
};
}
export const light: Theme = {
name: "light",
color: {
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",
color: {
primary: "coral",
background: "midnightblue",
secondary: "lightblue",
third: "lightgreen",
}
};
export const getTheme = (themeName: ThemeName): Theme => {
switch (themeName) {
case "light":
return light;
case "dark":
return dark;
}
};
src/components/common/Title.tsx
Utility Types: Pick<Type, Keys>
Type
에서Keys
(string literal or union of string literals)에 전달된 프로퍼티를 골라서 타입을 구성
Utility Types: Omit<Type, Keys>
Type
에서Keys
(string literal or union of string literals)에 전달된 프로퍼티는 제외하고 타입을 구성, Pick의 반대
import styled from "styled-components";
import { ColorKey, HeadingSize } from "../../style/theme";
interface Props {
children: React.ReactNode;
size: HeadingSize;
color?: ColorKey;
}
function 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 }) => theme.color[color || "primary"]};
`;
export default Title;
src/pages/Home.tsx
import Title from "../components/common/Title";
function Home() {
return (
<>
<Title size="medium" color="secondary">
제목 테스트
</Title>
<div>home</div>
</>
);
}
export default Home;
🧪src/components/common/Title.spec.tsx
간단한 테스트 코드
theme을 전달해주기 위해 정의해둔 BookStoreThemeProvider
로 감싸주어야 함
import { render, screen } from '@testing-library/react';
import Title from './Title';
import { BookStoreThemeProvider } from '../../context/themeContext';
describe("Title 컴포넌트 테스트", () => {
it("렌더 확인", () => {
render(
<BookStoreThemeProvider>
<Title size="large">제목</Title>
</BookStoreThemeProvider>
);
expect(screen.getByText("제목")).toBeInTheDocument();
});
it("size props 적용", () => {
const { container } = render(
<BookStoreThemeProvider>
<Title size="large">제목</Title>
</BookStoreThemeProvider>
);
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"
})
});
});
npm run test Title
PASS src/components/common/Title.spec.tsx
Title 컴포넌트 테스트
√ 렌더 확인 (22 ms)
√ size props 적용 (14 ms)
√ color props 적용 (6 ms)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 2.562 s
src/style/theme.ts
ButtonSize , ButtonScheme, borderRadius 추가 & 관련 interface, 프로퍼티 추가
export type ThemeName = "light" | "dark";
export type ColorKey = "primary" | "background" | "secondary" | "third";
export type HeadingSize = "large" | "medium" | "small";
export type ButtonSize = "large" | "medium" | "small";
export type ButtonScheme = "primary" | "normal";
interface Theme {
name: ThemeName;
color: Record<ColorKey, string>;
heading: {
[key in HeadingSize]: {
fontSize: string;
}
};
button: {
[key in ButtonSize]: {
fontSize: string;
padding: string;
}
};
buttonScheme: {
[key in ButtonScheme]: {
color: string;
backgroundColor: string;
}
};
borderRadius: {
default: string;
};
}
export const light: Theme = {
name: "light",
color: {
primary: "brown",
background: "lightgray",
secondary: "blue",
third: "green",
},
heading: {
large: {
fontSize: "2rem"
},
medium: {
fontSize: "1.5rem"
},
small: {
fontSize: "1rem"
}
},
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",
color: {
primary: "coral",
background: "midnightblue",
secondary: "lightblue",
third: "lightgreen",
}
};
export const getTheme = (themeName: ThemeName): Theme => {
switch (themeName) {
case "light":
return light;
case "dark":
return dark;
}
};
src/components/common/Button.tsx
import styled from "styled-components";
import { ButtonScheme, ButtonSize } from "../../style/theme";
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
size: ButtonSize;
scheme: ButtonScheme;
disabled?: boolean;
isLoading?: boolean;
}
function Button({ children, size, scheme, disabled, isLoading, ...props }: Props) {
return (
<ButtonStyle size={size} scheme={scheme} disabled={disabled} isLoading={isLoading} {...props}>
{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;
src/pages/Home.tsx
import Button from "../components/common/Button";
import Title from "../components/common/Title";
function Home() {
return (
<>
<Title size="medium" color="secondary">
제목 테스트
</Title>
<Button size="large" scheme="normal">
버튼 테스트
</Button>
<div>home</div>
</>
);
}
export default Home;
🧪src/components/common/Button.spec.tsx
/* eslint-disable testing-library/no-node-access */
import { render, screen } from '@testing-library/react';
import { BookStoreThemeProvider } from '../../context/themeContext';
import Button from './Button';
describe("Button 컴포넌트 테스트", () => {
it("렌더 확인", () => {
render(
<BookStoreThemeProvider>
<Button size="large" scheme="primary">버튼</Button>
</BookStoreThemeProvider>
);
expect(screen.getByText("버튼")).toBeInTheDocument();
});
it("size 및 scheme props 적용", () => {
render(
<BookStoreThemeProvider>
<Button size="large" scheme="primary">버튼</Button>
</BookStoreThemeProvider>
);
expect(screen.getByRole("button")).toHaveStyle({
fontSize: "1.5rem",
padding: "1rem 2rem",
color: "white",
backgroundColor: "midnightblue"
})
});
it("disabled props 적용", () => {
render(
<BookStoreThemeProvider>
<Button size="large" scheme="primary" disabled={true}>버튼</Button>
</BookStoreThemeProvider>
);
expect(screen.getByRole("button")).toHaveStyle({
opacity: 0.5,
"pointer-events": "none",
cursor: "none"
})
});
});
npm run test Button
PASS src/components/common/Button.spec.tsx
Button 컴포넌트 테스트
√ 렌더 확인 (22 ms)
√ size 및 scheme props 적용 (24 ms)
√ disabled props 적용 (12 ms)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 1.399 s
src/style/theme.ts
ColorKey에 border와 text 추가 & 관련 interface, 프로퍼티 추가
export type ThemeName = "light" | "dark";
export type ColorKey = "primary" | "background" | "secondary" | "third" | "border" | "text";
export type HeadingSize = "large" | "medium" | "small";
export type ButtonSize = "large" | "medium" | "small";
export type ButtonScheme = "primary" | "normal";
interface Theme {
name: ThemeName;
color: Record<ColorKey, string>;
heading: {
[key in HeadingSize]: {
fontSize: string;
}
};
button: {
[key in ButtonSize]: {
fontSize: string;
padding: string;
}
};
buttonScheme: {
[key in ButtonScheme]: {
color: string;
backgroundColor: string;
}
};
borderRadius: {
default: string;
};
}
export const light: Theme = {
name: "light",
color: {
primary: "brown",
background: "lightgray",
secondary: "blue",
third: "green",
border: "grey",
text: "black"
},
heading: {
large: {
fontSize: "2rem"
},
medium: {
fontSize: "1.5rem"
},
small: {
fontSize: "1rem"
}
},
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",
color: {
primary: "coral",
background: "midnightblue",
secondary: "lightblue",
third: "lightgreen",
border: "grey",
text: "white"
}
};
export const getTheme = (themeName: ThemeName): Theme => {
switch (themeName) {
case "light":
return light;
case "dark":
return dark;
}
};
src/components/common/InputText.tsx
React.forwardRef()
를 사용하는 이유는 InputText
컴포넌트가 styled-components
를 사용하여 스타일링되었고, 해당 스타일된 입력 요소에 ref를 직접 전달해야하기 때문입니다.styled-components
를 사용하여 스타일링된 컴포넌트에는 내부적으로 생성된 DOM 요소에 대한 접근이 필요할 수 있습니다. 예를 들어, 포커스를 설정하거나 특정 DOM 조작을 수행해야 할 때가 있습니다. 이런 경우에는 해당 요소에 ref를 전달해야 합니다.styled-components
의 스타일링된 컴포넌트는 해당 요소에 대한 참조를 자동으로 전달하지 않습니다. 따라서, React.forwardRef()
를 사용하여 명시적으로 ref를 전달할 수 있도록 해야 합니다. 이렇게 하면 부모 컴포넌트에서 InputText
를 사용할 때 ref를 사용하여 해당 입력 요소에 직접적으로 접근할 수 있습니다.React.forwardRef()
를 사용하여 InputText
컴포넌트를 감싸고, 해당 컴포넌트의 ref를 전달할 수 있도록 합니다. 이를 통해 스타일링된 입력 요소에 대한 외부에서의 ref 접근이 가능해집니다.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.color.border};
border-radius: ${({ theme }) => theme.borderRadius.default};
font-size: 1rem;
line-height: 1.5;
color: ${({ theme }) => theme.color.text};
`;
export default InputText;
src/pages/Home.tsx
import Button from "../components/common/Button";
import InputText from "../components/common/InputText";
import Title from "../components/common/Title";
function Home() {
return (
<>
<Title size="medium" color="secondary">
제목 테스트
</Title>
<Button size="large" scheme="normal">
버튼 테스트
</Button>
<InputText placeholder="여기에 입력하세요" />
<div>home</div>
</>
);
}
export default Home;
🧪src/components/common/Button.spec.tsx
/* eslint-disable testing-library/no-node-access */
import React from 'react';
import { render, screen } from '@testing-library/react';
import { BookStoreThemeProvider } from '../../context/themeContext';
import InputText from './InputText';
describe("Button 컴포넌트 테스트", () => {
it("렌더 확인", () => {
render(
<BookStoreThemeProvider>
<InputText placeholder="여기에 입력" />
</BookStoreThemeProvider>
);
expect(screen.getByPlaceholderText("여기에 입력")).toBeInTheDocument();
});
it("forwardRef 테스트", () => {
const ref = React.createRef<HTMLInputElement>();
render(
<BookStoreThemeProvider>
<InputText placeholder="여기에 입력" ref={ref} />
</BookStoreThemeProvider>
);
expect(ref.current).toBeInstanceOf(HTMLInputElement)
});
});
npm run test InputText
PASS src/components/common/InputText.spec.tsx
Button 컴포넌트 테스트
√ 렌더 확인 (15 ms)
√ forwardRef 테스트 (2 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.532 s
npm install react-icons --save
src\components\common\Header.tsx
import styled from "styled-components";
import logo from "../../assets/images/logo.png"
import { FaSignInAlt, FaRegUser } from "react-icons/fa"
import ThemeSwitcher from "../header/ThemeSwitcher";
const CATEGORY = [
{
id: null,
name: "전체"
},
{
id: 0,
name: "동화"
},
{
id: 1,
name: "소설"
},
{
id: 2,
name: "사회"
},
];
function 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>
<ThemeSwitcher />
</li>
<li>
<a href="/login">
<FaSignInAlt />로그인
</a>
</li>
<li>
<a href="/signup">
<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;
border-bottom: 1px solid ${({ theme }) => theme.color.background};
.logo {
img {
width: 200px;
}
}
.category {
ul {
display: flex;
gap: 32px;
li {
a {
font-size: 1.5rem;
font-weight: 600;
text-decoration: none;
color: ${({ theme }) => theme.color.text};
&:hover {
color: ${({ theme }) => theme.color.primary};
}
}
}
}
}
.auth {
ul {
display: flex;
gap: 16px;
li {
a {
font-size: 1rem;
font-weight: 600;
text-decoration: none;
display: flex;
align-items: center;
line-height: 1;
svg {
margin-right: 6px;
}
}
}
}
}
`;
export default Header;
src\components\common\Footer.tsx
import styled from "styled-components";
import logo from "../../assets/images/logo.png"
function 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};
display: flex;
justify-content: space-between;
padding: 20px 0;
border-top: 1px solid ${({ theme }) => theme.color.background};
.logo {
img {
width: 140px;
}
}
.copyright {
p {
font-size: 0.75rem;
color: ${({ theme }) => theme.color.text};
}
}
`;
export default Footer;
npm install react-router-dom @types/react-router-dom --save
createBrowserRouter
Route
객체 리스트를 인수로 받음path
의 URL과 일치하면 element
를 renderloader
는 route를 render하기 전에 호출되며 element
에게 useLoaderData
를 통해 데이터 제공errorElement
를 renderchildren
프로퍼티로 nested routes 가능createBrowserRouter([
{
path: "/",
element: <Root />,
loader: rootLoader,
children: [
{
path: "events/:id",
element: <Event />,
loader: eventLoader,
},
],
},
]);
RouterProvider
ReactDOM.createRoot(document.getElementById("root")).render(
<RouterProvider
router={router}
fallbackElement={<BigSpinner />}
/>
);
src\components\common\Header.tsx
<a href=""></a>
→ <Link to=""></Link>
로 교체 (화면 깜빡임 제거)
import styled from "styled-components";
import logo from "../../assets/images/logo.png"
import { FaSignInAlt, FaRegUser } from "react-icons/fa"
import ThemeSwitcher from "../header/ThemeSwitcher";
import { Link } from "react-router-dom";
const CATEGORY = [
{
id: null,
name: "전체"
},
{
id: 0,
name: "동화"
},
{
id: 1,
name: "소설"
},
{
id: 2,
name: "사회"
},
];
function Header() {
return (
<HeaderStyle>
<h1 className="logo">
<Link to="/">
<img src={logo} alt="book store" />
</Link>
</h1>
<nav className="category">
<ul>
{
CATEGORY.map((item) => (
<li key={item.id}>
<Link to={item.id === null ? '/books' : `/books?category_id=${item.id}`}>
{item.name}
</Link>
</li>
))
}
</ul>
</nav>
<nav className="auth">
<ul>
<li>
<ThemeSwitcher />
</li>
<li>
<Link to="/login">
<FaSignInAlt />로그인
</Link>
</li>
<li>
<Link to="/signup">
<FaRegUser />회원가입
</Link>
</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;
border-bottom: 1px solid ${({ theme }) => theme.color.background};
.logo {
img {
width: 200px;
}
}
.category {
ul {
display: flex;
gap: 32px;
li {
a {
font-size: 1.5rem;
font-weight: 600;
text-decoration: none;
color: ${({ theme }) => theme.color.text};
&:hover {
color: ${({ theme }) => theme.color.primary};
}
}
}
}
}
.auth {
ul {
display: flex;
gap: 16px;
li {
a {
font-size: 1rem;
font-weight: 600;
text-decoration: none;
display: flex;
align-items: center;
line-height: 1;
svg {
margin-right: 6px;
}
}
}
}
}
`;
export default Header;
src\App.tsx
import Layout from "./components/layout/Layout";
import Home from "./pages/Home";
import { BookStoreThemeProvider } from "./context/themeContext";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import Error from "./components/common/Error";
const router = createBrowserRouter([
{
path: "/",
element: <Layout><Home /></Layout>,
errorElement: <Error />
},
{
path: "/books",
element: <Layout><div>도서 목록</div></Layout>
},
]);
function App() {
return (
<BookStoreThemeProvider>
<RouterProvider router={router} />
</BookStoreThemeProvider>
);
}
export default App;
src\components\common\Error.tsx
Error 정보가 자동으로 넘어옴 → useRouteError
로 사용 가능
import { useRouteError } from "react-router-dom";
interface RouteError {
statusText?: string;
message?: string;
}
function Error() {
const error = useRouteError() as RouteError;
return (
<div>
<h1>오류가 발생했습니다.</h1>
<p>다음과 같은 오류가 발생했습니다.</p>
<p>{error.statusText || error.message}</p>
</div>
);
}
export default Error;
뭔가 클론 코딩하는 느낌이다.☹️ 아직 아는 게 많지가 않아서 그런가..