프로젝트/소규모 기능 구현
[React] Button Modal 만들기
harusari
2023. 7. 7. 22:41
목표
- 이렇게 생긴 Button 모달들을 최대한 재사용성이 가능하도록 만들기
프로젝트 초기 세팅
폴더 구조
01
- src
- pages : 각각의 pages들이 담겨있는 폴더
- modals : modals pages 내부 요소들이 담겨있는 폴더
- button : button 관련 요소들이 담겨있는 폴더
- button-component : 재사용 가능한 UI 컴포넌트를 보관하는 폴더
- button-container : 여러 컴포넌트를 그룹화하여 관리하는 컴포넌트를 보관하는 폴더
- button-handler (혹은 hooks) : button관련 handler들이 담겨 있는 폴더
- button-layout : 페이지 레이아웃이나 큰 틀을 구성하는 컴포넌트를 보관하는 폴더
- button-style : 스타일링 관련 파일을 보관하는 폴더
- button.style.js : button 관련 styled-components들이 있음
- button.theme.js : button의 속성 및 색상에 따라 달라지는 theme 이 정의되어 있음
- button-utils : button관련 utils들을 모아둠
- constant.js : button관련 상수들을 정의한 파일
- icons.js : button관련 icons들을 담아둔 파일
- input : button 폴더와 내부 구성 구조 동일
- modal : button 폴더와 내부 구성 구조 동일
- select : button 폴더와 내부 구성 구조 동일
- button : button 관련 요소들이 담겨있는 폴더
- modals : modals pages 내부 요소들이 담겨있는 폴더
- redux : redux를 활용한 전역 상태 관리를 위한 폴더
- App.js : 어플리케이션의 진입점
- pages : 각각의 pages들이 담겨있는 폴더
파일별 기능
App.js
import { ThemeProvider } from "styled-components";
import { ButtonLayout, theme } from "./pages";
function App() {
return (
<ThemeProvider theme={theme}>
<ButtonLayout />
</ThemeProvider>
);
}
export default App;
- <ThemeProvider theme={theme}>
- button.theme.js에서 정의한 theme들을 ButtonLayout에 전달하기 위함
- 이렇게 하면 ButtonLayout의 하위요소들에서 theme에 접근할 수 있음
- <ButtonLayout />
- button.layout을 보여줌
button-utils > constants.js
export const ATTRIBUTE_PRIMARY = "primary";
export const ATTRIBUTE_NEGATIVE = "negative";
export const SIZE_LARGE = "large";
export const SIZE_MEDIUM = "medium";
export const SIZE_SMALL = "small";
- 버튼의 두 가지 속성인 primary와 negative라는 String이 계속 반복될 것 같음
- 휴먼 에러를 방지하기 위해 constant로 선언해서 사용하기로 결정
- 마찬가지로, large, medium, small 사이즈에 따라 여러 요소들이 결정되고, 자주 사용할 것 같아 constant로 선언
button.layout.jsx
import { Layout, Title } from "../../styles";
import { BtnContainer } from "../button-container";
import { ATTRIBUTE_NEGATIVE, ATTRIBUTE_PRIMARY } from "../button-utils";
export const ButtonLayout = ({}) => {
return (
<Layout>
<Title>Button</Title>
<BtnContainer attribute={ATTRIBUTE_PRIMARY} />
<BtnContainer attribute={ATTRIBUTE_NEGATIVE} />
</Layout>
);
};
- 위에서 정의한 constant를 사용
- 전체적인 Layout을 감싸주고, 그 안에 Title을 전달하고 두 개의 컨테이너를 불러온다
- 이 곳에 attribute prop으로 각각 primary, negative 속성을 전달하기만 하면 위에서 봤던 large, medium, small 버튼 묶음들을 가져올 수 있다
- 재사용성이 높도록 attribute속성만 전달하면 버튼 묶음을 가져올 수 있다!
- 이 곳에 attribute prop으로 각각 primary, negative 속성을 전달하기만 하면 위에서 봤던 large, medium, small 버튼 묶음들을 가져올 수 있다
- 이 곳에서 사용한 styled-component인 Layout, Title은 다음과 같다.
export const Title = styled.h1`
display: block;
font-size: 2em;
margin-block-start: 0.67em;
margin-block-end: 0.67em;
margin-inline-start: 0px;
margin-inline-end: 0px;
font-weight: bold;
`;
export const Layout = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
`;
- Title은 약간의 스타일링이 가미된 h1태그이고, Layout은 flex속성과 내부 요소들을 column으로 정렬한다.
button.container.jsx
import { Container } from "../../styles";
import { ButtonComponent } from "../button-component";
import { handleButtonClick } from "../button-handler/button.handler";
import {
ATTRIBUTE_NEGATIVE,
ATTRIBUTE_PRIMARY,
SIZE_LARGE,
SIZE_MEDIUM,
SIZE_SMALL,
} from "../button-utils";
export const BtnContainer = ({ attribute }) => {
if (attribute === ATTRIBUTE_PRIMARY) {
return (
<Container>
<ButtonComponent
attribute={attribute}
size={SIZE_LARGE}
onClick={() => handleButtonClick(ATTRIBUTE_PRIMARY)}
>
Large Primary Button
</ButtonComponent>
<ButtonComponent attribute={attribute} size={SIZE_MEDIUM}>
Medium
</ButtonComponent>
<ButtonComponent attribute={attribute} size={SIZE_SMALL}>
Small
</ButtonComponent>
</Container>
);
}
if (attribute === ATTRIBUTE_NEGATIVE) {
return (
<Container>
<ButtonComponent
attribute={attribute}
size={SIZE_LARGE}
onClick={() => handleButtonClick(ATTRIBUTE_NEGATIVE)}
>
Large Negative Button
</ButtonComponent>
<ButtonComponent attribute={attribute} size={SIZE_MEDIUM}>
Medium
</ButtonComponent>
<ButtonComponent attribute={attribute} size={SIZE_SMALL}>
Small
</ButtonComponent>
</Container>
);
}
};
- ButtonContainer는 세 개의 사이즈에 따른 버튼을 각각 가져와야 하므로 코드가 길 수밖에 없다
- layout에서 받아온 attribute 속성을 통해 if문으로 두 가지의 case를 분리한다
- primary 속성을 받은 경우와 negative 속성을 받은 경우로 구분된다
- Container 내부의 코드는 거의 동일한데, 똑같은 ButtonComponent를 불러와 props만 다르게 주는 모습이다.
- 이는 크게 세 가지의 size에 따라 구분된다 (large / medium /small)
- 따라서 ButtonComponent에는 전달받은 attribute와 size만 전달하면 해당하는 attribute, size에 해당하는 버튼 컴포넌트를 가져올 수 있다.
- size가 large일 때 onClick 요소가 걸려있는 걸 알 수 있는데, large의 버튼들을 누르면 다음과 같은 alert 혹은 prompt 창이 나온다.
01
- 이를 구현하기 위함인데, 이와 관련된 로직인 handleButtonClick은 따로 handler 폴더에 빼두었다.
- 자식 컴포넌트에 prop으로 전달하는 이유는 실제 버튼 컴포넌트에 onClick으로 달아주어야 하기 때문이다.
button.handler.js
import { ATTRIBUTE_NEGATIVE, ATTRIBUTE_PRIMARY } from "../button-utils";
export const handleButtonClick = (attribute) => {
if (attribute === ATTRIBUTE_PRIMARY) {
alert("버튼을 만들어보세요");
} else if (attribute === ATTRIBUTE_NEGATIVE) {
let userResponse = window.prompt("어렵나요?");
}
};
- primary일 때 버튼을 클릭하면 단순한 alert창이 나온다
- negative일 때 버튼을 클릭하면 사용자의 input을 받는 prompt창이 나온다
- 사용자가 prompt에 입력을 하고 확인버튼을 누르면 내용이 userResponse 변수에 저장된다
- 현재 사용자 input을 받아 정보를 저장해 사용하는 곳이 없어 useResponse는 사용되지 않고 있다
적용된 스타일
- 우선, Container는 자식 요소들을 가로로 정렬하고 사이에 간격을 두기 위한 스타일링을 진행하였다.
export const Container = styled.div`
display: flex;
flex-direction: row;
gap: 10px;
`;
button.component.jsx
import { ButtonContentWrapper, ThemedButton } from "../button-style";
import { ATTRIBUTE_PRIMARY, NegativeIcon, PrimaryIcon } from "../button-utils";
export const ButtonComponent = ({ attribute, size, onClick, children }) => {
return (
<ThemedButton attribute={attribute} size={size} onClick={onClick}>
<ButtonContentWrapper>
{children}
{size === SIZE_LARGE &&
(attribute === ATTRIBUTE_PRIMARY ? (
<PrimaryIcon />
) : (
<NegativeIcon />
))}
</ButtonContentWrapper>
</ThemedButton>
);
};
- ThemedButton은 button태그로 구성된 styled-component이기 때문에 onClick 핸들러로 props로 받아온 핸들러 함수를 전달해준다
- 그러면 비로소 alert 혹은 prompt가 실행되게 된다
- 여기서 이거 전달 안해주면 안 실행됨
- ButtonContentWrapper안에서는,
- 일단 받아온 children (=버튼 내부 글씨) 보여줌
- size가 large일 때만 각각 icon들이 보여지기 때문에, && 연산자를 통해 size check를 한다
- primary일 때는 primaryIcon이 보여지고, negative일때는 negative icon이 보여지게 된다
styled-components
이곳에서 사용한 styled-components가 꽤 까다롭기 때문에 하나하나 쪼개서 설명함
Wrapper들
export const ButtonContentWrapper = styled.div`
display: flex;
-webkit-box-align: center;
align-items: center;
-webkit-box-pack: center;
justify-content: center;
gap: 7px;
`;
export const IconWrapper = styled.div`
display: flex;
-webkit-box-align: center;
align-items: center;
-webkit-box-pack: center;
justify-content: center;
`;
- 버튼을 둘러싸고 있는 ButtonContentWrapper
- flex 속성과 아이템들을 중앙정렬하기 위한 요소들이 정의되어 있다
- gap도 있음
- 아이콘을 둘러싸고 있는 IconWrapper
- 위와 별 다를 거 없다
button.theme.js
import {
ATTRIBUTE_NEGATIVE,
ATTRIBUTE_PRIMARY,
SIZE_LARGE,
SIZE_MEDIUM,
SIZE_SMALL,
} from "../button-utils";
export const theme = {
attributes: {
[ATTRIBUTE_PRIMARY]: {
color: "rgb(0, 0, 0)",
borderColor: "rgb(85, 239, 196)",
backgroundColor: "rgb(85, 239, 196)",
activeBackgroundColor: {
[SIZE_LARGE]: "rgb(238, 238, 238)",
[SIZE_MEDIUM]: "rgb(0, 184, 148)",
[SIZE_SMALL]: "rgb(0, 184, 148)",
},
},
[ATTRIBUTE_NEGATIVE]: {
color: "rgb(214, 48, 49)",
borderColor: "rgb(250, 177, 160)",
backgroundColor: "rgb(250, 177, 160)",
activeBackgroundColor: {
[SIZE_LARGE]: "rgb(238, 238, 238)",
[SIZE_MEDIUM]: "rgb(225, 112, 85)",
[SIZE_SMALL]: "rgb(225, 112, 85)",
},
},
},
size: {
[SIZE_LARGE]: {
height: "50px",
width: "200px",
fontWeight: "600",
},
[SIZE_MEDIUM]: {
height: "45px",
width: "130px",
fontWeight: "400",
},
[SIZE_SMALL]: {
height: "40px",
width: "100px",
fontWeight: "400",
},
},
};
- styled-component의 장점인 props 받는 거와 theme 적용할 수 있는 것을 최대한 활용해보고자 하였다.
- 우선 Theme부터 살펴보면
- 속성이 primary일 때와 negative일 때 color, borderColor, backgroundColor를 각각 가지고 있다
- 버튼을 눌렀을 때 적용되는 :active 속성이 있는데, 이건 이번에 처음 봐서 넘 신기했다
- :hover는 마우스를 hover했을 때 적용되었는데 이건 버튼을 눌렀을 때 적용된다
- 하여튼 각각 size에 따라 activeBackgroundColor가 달라서 또한 각각 정의했다.
- 또한 primary, negative와 상관없이 size에 따라 공통된 특성이 있었다
- 그것은 바로 height, width, fontWeight
- 이 또한 각각 적용하면 됨
- 아주 쉽지유?
ThemedButton
export const ThemedButton = styled.button`
cursor: pointer;
border-radius: 8px;
color: ${(props) =>
props.theme.attributes[props.attribute]?.color ||
props.theme.attributes[ATTRIBUTE_PRIMARY].color};
border: ${(props) =>
props.size === SIZE_LARGE
? `3px solid ${
props.theme.attributes[props.attribute]?.borderColor ||
props.theme.attributes[ATTRIBUTE_PRIMARY].borderColor
}`
: "none"};
background-color: ${(props) =>
props.size === SIZE_LARGE
? "rgb(255, 255, 255)"
: props.theme.attributes[props.attribute]?.backgroundColor ||
"rgb(255, 255, 255)";
&:active {
background-color: ${(props) =>
props.theme.attributes[props.attribute]?.activeBackgroundColor[
props.size
] ||
props.theme.attributes[ATTRIBUTE_PRIMARY]?.activeBackgroundColor[
SIZE_LARGE
]};
}
height: ${(props) =>
props.theme.size[props.size]?.height || props.theme.size.SIZE_LARGE.height};
width: ${(props) =>
props.theme.size[props.size]?.width || props.theme.size.SIZE_LARGE.width};
font-weight: ${(props) =>
props.theme.size[props.size]?.fontWeight ||
props.theme.size.SIZE_LARGE.fontWeight};
`;
- 위에서 정의한 theme 덕분에 ThemedButton에서는 이를 잘 호출해 주기만 하면 된다
- 우선 기본적으로 styled-component가 props를 받는 방법
속성명 : ${(props) => props.프랍이름 ...}
- 이런 식으로 화살표 함수를 활용하면 된다. 개꿀!
- 돌아가서, 공통되는 cursor와 border-radius 속성부터 정의한다
- color
- 글자 색은 primary, negative로만 구분되기 때문에 theme에 해당 attribute를 전달해 일치하는 color를 반환하도록 하였다
- 만약 전달된 attribute 속성이 없으면 기본값은 primary일 때의 색상이다.
- 여기서 중요한 포인트, 바로 옵셔널 체이닝(= ?.) 이다
- ?. 앞의 속성이 존재하면 그것의 뒷 속성에 접근하라는 뜻
- 즉 여기에서는 props.theme.attributes[props.attribute] 객체가 존재하면 그것의 color 속성에 접근하라는 뜻
- 만약 해당 객체가 존재하지 않으면 null이나 undefined를 반환하고, 오류 발생함
- "Cannot read property 'color' of undefined" 같은 오류 메시지
- border
- size가 large일 때와 그 외의 경우의 border가 조금 다르기 때문에 size large의 경우는 따로 빼준다
- 그 외의 경우는 border가 없음!
- background-color
- border와 마찬가지로 size가 large일 때와 그 외의 경우가 다르기 때문에 삼항연산자를 활용한다
- 기본값은 large일때의 경우이다
- &:active
- 버튼을 클릭했을 때 적용되는 css이다
- 각각 정의해놓은 attribute, size에 따라 다른 css가 적용됨
- 기본은 large, primary일 때이다
- height, width, font-weight
- size에 따라서만 달라지므로, theme에서 각각의 size에 맞는 속성을 가져온다
- 기본값은 large이다
완성!
이제 요로코롬 이쁜 버튼 그룹들을 만날 수 있다! 히히