이전 포스팅에서는 피그마에서 명시된 기초 스타일을 tailwind의 config로 옮기는 작업을 했었다. 이번엔 UI 컴포넌트를 구현해보려고 한다.
가보자고!
먼저 요 UI 컴포넌트들을 담을 폴더를 생성해야 하는데, 네이밍을 어떤 식으로 하는 것이 좋을지 고민하다가 Components/Atomic 폴더가 적당하다는 생각이 들었다. 앞으로 만들어질 컴포넌트들은 이 UI 컴포넌트들을 기반으로 레이아웃이 짜여질 것이니 요놈들이 기본, 즉, Atomic!
그래서 아래와 같은 폴더 구조 및 파일을 생성하였다. 각각의 폴더와 파일 이름은 피그마에서 가져왔다.
├─Components │ └─Atomic │ ├─Buttons │ │ Button.tsx │ │ Checkbox.tsx │ │ RadioButton.tsx │ │ TextIconButton.tsx │ │ │ ├─Cards │ │ AdditionalProductCard.tsx │ │ BenefitBlock.tsx │ │ CardCategory.tsx │ │ CardItem.tsx │ │ PlanCard.tsx │ │ │ ├─Dropdowns │ │ Dropdown.tsx │ │ DropdownFAQ.tsx │ │ │ ├─Inputs │ │ DatePicker.tsx │ │ InputSelect.tsx │ │ InputStepper.tsx │ │ InputText.tsx │ │ │ └─Navigations │ Navbar.tsx │ NavbarLink.tsx │ StepIndicator.tsx
작업을 하다보니까 안 건데, 이 분도 만들다보니 놓친 게 좀 있으셨다. 주로 반응형에서 헷갈리셨는데,, 그렇다보니 나도 만들면서 많이 헷갈렸다. 이걸 타블렛에도 그대로 적용하는 건가? 싶은 생각들.
또, 디자인 작업 방식이 다른게, 나는 상세 내용에 각 타입별로 다 자세하게 적는 편인데 이 분은 상세 설명에 특징적인 부분만 잡아놓으셨다. 그 말은? 난 상세 설명대로 만들었는데 다시 보니 Button Type 별로 구현 디자인이 다른 것😇 전체적인 디자인을 보고 작업을 들어가자.
이건 딱 정해진 건 아니지만, 내가 그동안 UI 컴포넌트를 개발하면서 느낀 것이다.
1. Shape (모양)
2. Layout (배치)
3. State (상태)
요렇게 나눈 상태에서 접근을 해야 꽤나 편안하다. 1. 모양은 변하지 않는 경우가 대부분이고 2. 배치만을 다루는 녀석을 따로 떼어두면 반응형에 대응하기 쉽고 3. 상태가 변하면 덮어씌우면 되기 때문.
여기에서도 방법이 좀 갈리는데, 미디어에 따라서 1, 2, 3을 바꿀 것인지, 1, 2, 3 안에서 미디어에 따라 바꿀 것인지가 달라진다. 예를 들면 다음과 같다.
const shapeStyle = 'bg-white text-black'
const layoutStyle = 'flex justify-center items-center'
const stateStyle = 'hover:bg-lightgray active:bg-darkgray'
<div className={`${shapeStyle} ${layoutStyle} ${stateStyle}`}>
이런 코드가 있다고 했을 때, 전자의 경우는 아래와 같이 미디어에 따라 분기를 나누는 것이다. (스타일이 같은 건 무시바람!)
if (isMobile) {
const shapeStyle = 'bg-white text-black'
const layoutStyle = 'flex justify-center items-center'
const stateStyle = 'hover:bg-lightgray active:bg-darkgray'
<div className={`${shapeStyle} ${layoutStyle} ${stateStyle}`}>
}
if (isTablet) {
const shapeStyle = 'bg-white text-black'
const layoutStyle = 'flex justify-center items-center'
const stateStyle = 'hover:bg-lightgray active:bg-darkgray'
<div className={`${shapeStyle} ${layoutStyle} ${stateStyle}`}>
}
if (isDesktop) {
const shapeStyle = 'bg-white text-black'
const layoutStyle = 'flex justify-center items-center'
const stateStyle = 'hover:bg-lightgray active:bg-darkgray'
<div className={`${shapeStyle} ${layoutStyle} ${stateStyle}`}>
}
후자의 경우는 아래와 같다.
const shapeStyle =
'bg-white text-black ' +
'tablet:bg-white tablet:text-black ' +
'desktop:bg-white desktop:text-black '
const layoutStyle =
'flex justify-center items-center ' +
'tablet:justify-center tablet:items-center ' +
'desktop:justify-center desktop:items-center '
const stateStyle =
'hover:bg-lightgray active:bg-darkgray ' +
'tablet:hover:bg-lightgray tablet:active:bg-darkgray ' +
'desktop:hover:bg-lightgray desktop:active:bg-darkgray '
<div className={`${shapeStyle} ${layoutStyle} ${stateStyle}`}>
전자의 경우엔 특정 미디어에 대한 코드를 모아두었기 때문에 한 눈에 알아보기 좋지만, 중복된 코드가 늘어난다.
후자의 경우엔 중복된 코드가 없이 작성할 수 있지만, 특정 미디어에 대한 코드를 한 눈에 알아보기 힘들다. 또한, 이렇게 작성하게 되면 ...
ㅋㅋ머.. 그렇지만 누가 html 보겠냐고~
아니면 페이지 용량이 어쨌든 늘어나게 되는 거니까 피해야 할까 싶기도 한데, 이거 줄여서 로딩시간 줄이는 것보다 비즈니스 로직 최적화해서 로딩시간 줄이는 게 더 이득이라 본다.
여튼! 말하고자 하는 건, 이런 식으로 'CSS' 딱 하나로 뭉그뜨려서 생각하기 보다 모양, 배치, 상태에 따라 나눠서 생각하는 것이 도움이 된다는 것이다. 또한, 모든 코드를 전자로만 또는 후자로만 작성할 필요도 없다. 알아보는 게 중요하니까~
button 컴포넌트를 만들 때 아이콘을 넣어줘야 했었다. CRA에선 웹팩에서 file-loader가 있어서 그냥 불러와도 괜찮았는데, vite에선 좀 뭔가 다르게 해야 하나 보더라. 아래의 방법은 vite-plugin-svgr를 이용해서 svg파일을 컴포넌트처럼 다루는 방법이다.
1. svgr plugin 설치
yarn add vite-plugin-svgr -D
2. vite.config.ts 추가
plugins: [react(), svgr()]
3. src/vite-env.d.ts 추가
/// <reference types="vite-plugin-svgr/client" />
4. src/svg.d.ts 생성
declare module "*.svg" { const content: React.FC<React.SVGProps<SVGElement>>; export default content; }
5. tsconfig.json 추가
"include": ["src", "**/*.ts", "**/*.tsx", "svg.d.ts"],
이렇게 설치 및 설정한 뒤에 다음과 같이 사용하면 된다.
import { ReactComponent as LeftIcon } from "@Static/Icons/west_300_opsz24.svg";
<LeftIcon width={24} height={24} fill="white" />
Velog에도 모룽이 쓸 수 있게 해주면 좋겠다... 내사랑 모룽
기본 뼈대가 될 코드는 다음과 같다.
import React from "react";
type ButtonProp = {
type?: "primary" | "secondary" | "tertiary";
rightIcon?: boolean;
leftIcon?: boolean;
disabled?: boolean;
children: React.ReactNode;
};
const Button = ({
type = "primary",
rightIcon = false,
leftIcon = false,
disabled = false,
children,
}: ButtonProp) => {
/** Type별 Flag */
const isPrimary = type === "primary";
const isSecondary = type === "secondary";
const isTertiary = type === "tertiary";
/** */
const shapeStyle = '';
const layoutStyle = '';
const stateStyle = '';
return (
<button
disabled={disabled}
className={}
>
{leftIcon && <LeftIcon className={iconStyle} />}
{children}
{rightIcon && <RightIcon className={iconStyle} />}
</button>
);
};
export default Button;
어떻게 설계를 하면 유지보수가 쉬울까 생각해봤을 때 꽤나 생각해야 할 것이 많아서 머리가 복잡했다. 처음에 생각한 것은, 버튼 컴포넌트가 한번 생성되면 언마운트될 때까지 변하지 않는 값은 'type'값이다. 그러니 prop으로 넘겨받은 type을 기준으로 각 스타일을 생성해서 적용하면 유지보수가 간결하다고 생각했다.
그 다음 디자인에서 수정사항이 있으면 어떤 케이스가 있을지 생각해보았다.
- 주현씨~ primary 타입에서 배경색을 바꿔야 할 것 같아요.
- 주현씨~ 모든 타입에 대해서, 타블렛 + 데스크탑 환경의 hover시에 border를 추가해야 할 것 같아요.
- 주현씨~ 타블렛 환경에서 모든 타입의 패딩을 좀 깎아야 할 것 같아요.
- 주현씨~ 모바일 환경에서 active시에 애니메이션을 넣으면 좋을 것 같아요.
???: 그만 부르세요.
갑자기 어지러운데, 정신을 가다듬고 천천히 생각해보자. 먼저,, 위의 요구사항들의 1차 기준을 바꿔야 하는 속성으로 접근하는 건 어떨까? shape별(배경색), layout별(패딩), state별(hover:border, active:animate)로 나누게 된다면, 잘 분리가 되어 여러 코드를 고치지 않아도 될 것처럼 보이지만,, 타입과 미디어가 속성의 하위 분류로 들어가게 되어 큰 카테고리를 먼저 생각하게 되는 인간의 심리상 접근하기가 썩 좋아보이진 않는다.
(보통 모바일에서 primary 타입의 배경색을 바꿔주세요, 라고 말하지 배경색을 바꿀 건데요, 이게 primary타입이고 그건 모바일이에요 라고 말하는 순간 디자이너를 빤히 쳐다볼 게 분명)
그러면 타입별로 분리를 하면? 중복된 코드가 늘어날 게 좀 거슬린다. 또, 미디어 별로 바꿔줘야 하는 상황이라면 코드가 여러 군데 분포가 되어있어 접근하기 불편할 것 같다.
모르겠다...! 이러나 저러나 장단점이 확실해서 결정하기 쉽지 않다. 그러면,, 일단 해봐! 그리고 코드 예쁜 거 골라!(ㅋㅋ)
/** Type별 Shape 스타일 */
const shapeStyle =
(isPrimary ? "bg-black border-none text-white " : "") +
(isSecondary ? "bg-white border border-black text-black " : "") +
(isTertiary ? "bg-black border border-white text-white " : "");
/** Layout 스타일 */
const layoutStyle =
"group button flex w-full place-content-center place-items-center " +
"gap-m8 px-m24 pb-m14 pt-m16 " +
"tablet:gap-t8 tablet:px-t24 tablet:pb-t14 tablet:pt-t16 tablet:min-w-[22.78vw] " +
"desktop:gap-d8 desktop:px-d24 desktop:pb-d14 desktop:pt-d16 desktop:min-w-[12.15vw] ";
/** Mobile State Style */
const mobileStateStyle =
(isPrimary ? "disabled:bg-lightgray disabled:text-gray " : "") +
(isSecondary
? "disabled:bg-white disabled:border disabled:border-lightgray disabled:text-gray"
: "") +
(isTertiary
? "disabled:bg-black disabled:border disabled:border-lightgray disabled:text-gray " +
"active:bg-white active:border-none active:text-black "
: "");
/** Tablet State Style */
const tabletStateStyle =
(isPrimary
? "tablet:hover:bg-gray tablet:hover:border-none " +
"tablet:active:bg-black tablet:active:text-white "
: "") +
(isSecondary
? "tablet:hover:bg-black tablet:hover:text-white " +
"tablet:active:bg-white tablet:active:border tablet:active:border-black tablet:active:text-black "
: "") +
(isTertiary
? "tablet:hover:bg-white tablet:hover:text-gray " +
"tablet:active:bg-white tablet:active:border tablet:active:border-black tablet:active:text-black "
: "");
현재 디자인은 타입에 따라 레이아웃이 달라지지 않아서 공통으로 넣었지만 달라졌다면 역시 구분을 해줬을 것.
if (isPrimary) {
const primaryShapeStyle = "bg-black border-none text-white ";
const primaryLayoutStyle =
"group button flex w-full place-content-center place-items-center " +
"gap-m8 px-m24 pb-m14 pt-m16 " +
"tablet:gap-t8 tablet:px-t24 tablet:pb-t14 tablet:pt-t16 tablet:min-w-[22.78vw] " +
"desktop:gap-d8 desktop:px-d24 desktop:pb-d14 desktop:pt-d16 desktop:min-w-[12.15vw] ";
const primaryStateStyle =
"disabled:bg-lightgray disabled:text-gray " +
"tablet:hover:bg-gray tablet:hover:border-none ";
}
if (isSecondary) {
const secondaryShapeStyle = "bg-white border border-black text-black ";
const secondaryLayoutStyle =
"group button flex w-full place-content-center place-items-center " +
"gap-m8 px-m24 pb-m14 pt-m16 " +
"tablet:gap-t8 tablet:px-t24 tablet:pb-t14 tablet:pt-t16 tablet:min-w-[22.78vw] " +
"desktop:gap-d8 desktop:px-d24 desktop:pb-d14 desktop:pt-d16 desktop:min-w-[12.15vw] ";
const secondaryStateStyle =
"disabled:bg-white disabled:border disabled:border-lightgray disabled:text-gray " +
"tablet:hover:bg-black tablet:hover:text-white " +
"tablet:active:bg-white tablet:active:border tablet:active:border-black tablet:active:text-black ";
}
if (isTertiary) {
const tertiaryShapeStyle = "bg-black border border-white text-white ";
const tertiaryLayoutStyle =
"group button flex w-full place-content-center place-items-center " +
"gap-m8 px-m24 pb-m14 pt-m16 " +
"tablet:gap-t8 tablet:px-t24 tablet:pb-t14 tablet:pt-t16 tablet:min-w-[22.78vw] " +
"desktop:gap-d8 desktop:px-d24 desktop:pb-d14 desktop:pt-d16 desktop:min-w-[12.15vw] ";
const tertiaryStateStyle =
"disabled:bg-black disabled:border disabled:border-lightgray disabled:text-gray " +
"active:bg-white active:border-none active:text-black " +
"tablet:hover:bg-white tablet:hover:text-gray " +
"tablet:active:bg-white tablet:active:border tablet:active:border-black tablet:active:text-black ";
}
여기도 역시 layout이 타입에 따라 달라지진 않지만 공통으로 넣어줬다.
코드로 보니 더 정하기 어려운 너낌,,, ~.~ 이건 정말 취향의 문제인 것 같다. 보통 타입에 따라 구분을 하는 것이 일반적인 것 같은데,,, 이렇게 미디어에 따라 달라지는 스타일이 많다면 타입에 따라 구분하는 게 보기 좋아보이고, 만약 그렇게 많이 달라지는 게 없다면 타입을 내부 분기로 만드는 것도 괜찮아 보인다.
나의 취향은 타입 별로 구분하는 게 좀 더 내 취향이라, 이 방식으로 접근해보겠다.
import React from "react";
import { ReactComponent as LeftIcon } from "@Static/Icons/west_300_opsz24.svg";
import { ReactComponent as RightIcon } from "@Static/Icons/east_wght300_opsz24.svg";
type ButtonProp = {
type?: "primary" | "secondary" | "tertiary";
rightIcon?: boolean;
leftIcon?: boolean;
disabled?: boolean;
children: React.ReactNode;
};
const Button = ({
type = "primary",
rightIcon = false,
leftIcon = false,
disabled = false,
children,
}: ButtonProp) => {
/** Type별 Flag */
const isPrimary = type === "primary";
const isSecondary = type === "secondary";
const isTertiary = type === "tertiary";
let buttonStyle = "";
let iconStyle = "";
if (isPrimary) {
const primaryShapeStyle = "bg-black border-none text-white ";
const primaryLayoutStyle =
"group button flex w-full place-content-center place-items-center " +
"gap-m8 px-m24 pb-m14 pt-m16 " +
"tablet:gap-t8 tablet:px-t24 tablet:pb-t14 tablet:pt-t16 tablet:min-w-[22.78vw] " +
"desktop:gap-d8 desktop:px-d24 desktop:pb-d14 desktop:pt-d16 desktop:min-w-[12.15vw] ";
const primaryStateStyle =
"disabled:bg-lightgray disabled:text-gray " +
"tablet:hover:bg-gray tablet:hover:border-gray " +
"tablet:active:bg-black tablet:active:text-white";
buttonStyle = primaryShapeStyle + primaryLayoutStyle + primaryStateStyle;
iconStyle =
"fill-white " +
"aspect-square w-m24 " +
"tablet:w-t24 desktop:w-d24 " +
"group-disabled:fill-gray " +
"tablet:group-hover:fill-white tablet:group-active:fill-white ";
}
if (isSecondary) {
const secondaryShapeStyle = "bg-white border border-black text-black ";
const secondaryLayoutStyle =
"group button flex w-full place-content-center place-items-center " +
"gap-m8 px-m24 pb-m14 pt-m16 " +
"tablet:gap-t8 tablet:px-t24 tablet:pb-t14 tablet:pt-t16 tablet:min-w-[22.78vw] " +
"desktop:gap-d8 desktop:px-d24 desktop:pb-d14 desktop:pt-d16 desktop:min-w-[12.15vw] ";
const secondaryStateStyle =
"disabled:bg-white disabled:border disabled:border-lightgray disabled:text-gray " +
"tablet:hover:bg-black tablet:hover:text-white " +
"tablet:active:bg-white tablet:active:border tablet:active:border-black tablet:active:text-black ";
buttonStyle =
secondaryShapeStyle + secondaryLayoutStyle + secondaryStateStyle;
iconStyle =
"fill-black " +
"aspect-square w-m24 " +
"tablet:w-t24 desktop:w-d24" +
"group-disabled:fill-gray " +
"tablet:group-hover:fill-white tablet:group-active:fill-black ";
}
if (isTertiary) {
const tertiaryShapeStyle = "bg-black border border-white text-white ";
const tertiaryLayoutStyle =
"group button flex w-full place-content-center place-items-center " +
"gap-m8 px-m24 pb-m14 pt-m16 " +
"tablet:gap-t8 tablet:px-t24 tablet:pb-t14 tablet:pt-t16 tablet:min-w-[22.78vw] " +
"desktop:gap-d8 desktop:px-d24 desktop:pb-d14 desktop:pt-d16 desktop:min-w-[12.15vw] ";
const tertiaryStateStyle =
"disabled:bg-black disabled:border disabled:border-lightgray disabled:text-gray " +
"active:bg-white active:border-white active:text-black " +
"tablet:hover:bg-white tablet:hover:text-gray " +
"tablet:active:bg-white tablet:active:border tablet:active:border-white tablet:active:text-black ";
buttonStyle = tertiaryShapeStyle + tertiaryLayoutStyle + tertiaryStateStyle;
iconStyle =
"fill-white " +
"aspect-square w-m24 " +
"tablet:w-t24 desktop:w-d24" +
"group-disabled:fill-gray group-active:fill-black " +
"tablet:group-hover:fill-gray tablet:group-active:fill-black ";
}
return (
<button disabled={disabled} className={buttonStyle}>
{leftIcon && <LeftIcon className={iconStyle} />}
{children}
{rightIcon && <RightIcon className={iconStyle} />}
</button>
);
};
export default Button;
아이콘에 대한 스타일은 코드가 짧기 때문에 따로따로 변수 지정을 해주진 않았고 행 구분으로 shape, layout, state를 구분하였다.
이렇게 놓고 보니 나중에 수정할 때도 괜찮을 것 같기도 하고,,,?.? 앞으로 만들 컴포넌트가 많으니까 해보면서 좀 나름의 기준을 세워보자.
좋은 글 감사합니다. 자주 방문할게요 :)