프로젝트를 진행함에 따라 Figma를 사용해 프레임워크 디자인을 만들다 보니 공통적으로 사용하게 되는 버튼, input, 모달들이 있어 매번 새로운 모달을 각자가 만들기 보다는 재사용성이 가능한 컴포넌트를 만들고 사용하는 것이 더 빠르고 효율적이라 생각하여 제작하게 되었다.
import { MouseEventHandler } from "react";
import styled from "@emotion/styled";
interface IBtnProps {
onClick?:MouseEventHandler<HTMLButtonElement>;
text: string;
type: string;
fill: boolean;
}
// default 속성
const Wrapper = styled.button`
font-size: var(--btn-font-size);
padding: var(--btn-padding);
border-radius: var(--btn-radius);
border: none;
cursor: pointer;
`;
const WrapperSm = styled(Wrapper)<IBtnProps>`
border: 1px solid var(--point-color-green);
color: ${(props) => (props.fill ? "#fff" : "var(--point-color-green)")};
background-color: ${(props) =>
props.fill ? "var(--point-color-green)" : "#fff"};
`;
const WrapperMd = styled(Wrapper)<IBtnProps>`
width: var(--btn-width-md);
height: var(--btn-height);
color: ${(props) => (props.fill ? "#fff" : "var(--point-color-green)")};
background-color: ${(props) =>
props.fill ? "var(--point-color-green)" : "#fff"};
`;
const WrapperLg = styled(Wrapper)<IBtnProps>`
width: var(--btn-width-lg);
height: var(--btn-height);
color: ${(props) => (props.fill ? "#fff" : "var(--point-color-green)")};
background-color: ${(props) =>
props.fill ? "var(--point-color-green)" : "#fff"};
`;
export default function CustomBtn(props: IBtnProps) {
return (
<>
{props.type === "Lg" && (
<WrapperLg fill={props.fill} onClick={props.onClick}>
{props.text}
</WrapperLg>
)}
{props.type === "Md" && (
<WrapperMd fill={props.fill} onClick={props.onClick}>
{props.text}
</WrapperMd>
)}
{props.type === "Sm" && (
<WrapperSm fill={props.fill} onClick={props.onClick}>
{props.text}
</WrapperSm>
)}
</>
);
}
// 적용 예시
<CustomBtn onClickMoveToPage={moveToPage("/boards") text={"상담하러가기"} type={"Md"} fill={true}}
props 중에서, 세가지 타입에 따라 (Sm, Md, Lg) 사이즈 별로 버튼의 크기를 지정하도록 하였고, fill 이라는 prop은 버튼의 스타일 색상이 2가지가 있는데 둘중에 하나를 선택하도록 boolean 형으로 지정하였다.
이렇게 지정한 이유는, 디자인 와이어프레임 에서는 버튼의 디자인이 고정되어 있었고(소, 중, 대), 색상도 2가지 패턴만 있었기 때문에 fill 속성을 추가하여 만들었다.
우선 type 에 따라 조건부 렌더링을 실행하는데, 만약 디자인이 변경되거나 추가되어서 더 많은 패턴의 버튼 크기가 필요하다면? 매번 일일이 추가를 해줘야 할까.. 싶은 생각이 드는 것이다. 물론 fill 속성으로 지정한 2가지 패턴의 디자인도 3개, 4개 언제든 늘어날 가능성은 다분했다. 딱 필요한 부분만 생각하다 보니 공통컴포넌트를 확장성이 제한되고 코드도 지저분하게 되었다.
이를 개선하기 위해 다시 코드를 작성하게 되었다.
// src/components/commons/buttons/CustomBtn.tsx
import styled from "@emotion/styled";
import { CSSProperties, MouseEventHandler } from "react";
import { breakPoints } from "../../../commons/styles/media";
const Button = styled.button`
border: none;
font-size: var(--btn-font-size);
padding: var(--btn-padding);
border-radius: var(--btn-radius-lg);
width: var(--btn-width-lg);
height: var(--btn-height);
@media ${breakPoints.tablet} {
width: var(--btn-width-md);
}
@media ${breakPoints.mobile} {
width: var(--btn-width-sm);
}
`;
interface IBtn2Props {
onClick: MouseEventHandler<HTMLButtonElement>;
text: string;
type: "button" | "submit" | "reset" | undefined;
style?: CSSProperties;
}
export default function CustomBtn({ onClick, text, type, style }: IBtn2Props) {
return (
<Button style={style} type={type} onClick={onClick}>
{text}
</Button>
);
}
// 적용 예시
<CustomBtn
type="button"
text="상담하러가기"
onClick={onClickMoveToPage(`/chatgpt/${userProfile?.id}`)}
style={{ backgroundColor: "var(--point-color-green)", color: "#fff"}}
/>
여기서 fill은 next.js에서 fill 옵션에 대해 네이밍이 충돌된다는 경고 에러창이 계속 발생한 문제도 있고, 스타일이 고정적이기 때문에 없애주었다. 대신 style 속성을 추가하여 fill 역할의 스타일링을 직접 인라인으로 넣도록 하였다. 그리고 type은 button 속성의 type의 종류를 설정하게 하였다.
버튼의 크기를 설정하는 방법은 반응형으로 breakPoint 길이를 기준으로 크기를 조절하였다.
이전 코드보다 훨씬 간결하고 확장성이 좋은 공통 컴포넌트가 만들어졌다.
이러한 방식으로 input, modal 공통 컴포넌트를 제작하였고 만족스러운 개선이 되었다고 생각한다. 항상 코드를 작성함 있어서 옳은 방법인지 생각하고 더 개선점은 없는지 찾아보는 습관을 가질 수 있도록 해야겠다.
다른 공통 컴포넌트도 올리기에는 코드가 길어서 검색 input 컴포넌트까지 올리겠다.
// src/components/commons/search/CustomSearchInput.tsx
import styled from "@emotion/styled";
import { breakPoints } from "../../../commons/styles/media";
import { ChangeEvent, KeyboardEvent } from "react";
import { CloseOutlined, HeartFilled } from "@ant-design/icons";
export const SearchSection = styled.section`
width: 100vw;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--sub-bg-color);
padding: 50px;
`;
export const InfoText = styled.p`
font-size: 2rem;
line-height: 4;
@media ${breakPoints.tablet} {
font-size: 1.5rem;
line-height: 3;
}
@media ${breakPoints.mobile} {
font-size: 1.25rem;
line-height: 3;
}
`;
export const SearchWrapper = styled.div`
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
@media ${breakPoints.mobile} {
width: 100%;
}
`;
export const SearchBox = styled.div`
width: 100%;
position: relative;
.anticon-close-circle {
position: absolute;
top: 15px;
right: 50px;
font-size: var(--font-size-sm);
color: var(--heavy-gary-color);
}
`;
export const SearchInput = styled.input`
display: block;
width: 100%;
height: 45px;
border-radius: 20px;
padding: 15px;
border: 1px solid var(--point-color-beige);
background-color: var(--white-bg-color);
font-size: var(--font-size-sm);
@media ${breakPoints.mobile} {
min-width: 300px;
font-size: var(--font-mobile-size-sm);
}
`;
export const BtnWrap = styled.div`
color: var(--point-color-beige);
font-size: var(--font-size-md);
position: absolute;
right: 15px;
top: 12px;
display: flex;
gap: 10px;
`;
export const ResetBtn = styled(CloseOutlined)`
color: var(--white-bg-color);
font-size: var(--font-size-sm);
width: var(--font-size-md);
height: var(--font-size-md);
line-height: var(--font-size-md);
text-align: center;
background-color: var(--light-gary-color);
border-radius: 50%;
`;
export const SearchIcon = styled(HeartFilled)`
color: var(--point-color-beige);
font-size: var(--font-size-md);
&:hover {
cursor: pointer;
}
`;
interface IInputProps {
type: string;
name: string;
placeholder: string;
value?: string;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
onClick: () => void;
onKeyPress: (e: KeyboardEvent<HTMLInputElement>) => void;
onClickClear: () => void;
}
const CustomSearchInput = (props: IInputProps) => {
return (
<SearchWrapper>
<SearchBox>
<SearchInput
value={props.value}
type={props.type}
name={props.name}
placeholder={props.placeholder}
onChange={props.onChange}
onKeyPress={props.onKeyPress}
/>
<BtnWrap>
{props.value && <ResetBtn onClick={props.onClickClear} />}
<SearchIcon onClick={props.onClick} />
</BtnWrap>
</SearchBox>
</SearchWrapper>
);
};
export default CustomSearchInput;
// 적용 예시
<S.SearchFilterBox>
<CustomSearchInput
value={keyword.user}
placeholder="검색기준: 닉네임, 이메일"
type="text"
name="user"
onChange={handleSearchInput}
onClick={submitUserSearch}
onClickClear={handleClearInput}
onKeyPress={submitKeyPressUserSearch}
/>
</S.SearchFilterBox>