Intro
안녕하세요. 오늘은 디프만에서 맡은 역할 중 하나인 공통 컴포넌트 제작에 대해서 정리를 해보겠습니다.
프론트에서 화면을 구현할 때, 여러 페이지에서 공통적으로 쓰이는 컴포넌트가 있을 것입니다.
만약 똑같은 디자인의 버튼을 메인 페이지에서도 사용하고, 마이페이지에서도 사용할 경우
하나의 버튼만 만들어서 두 곳에서 import 하면 훨씬 간단하겠죠?
프로덕트의 스케일이 크면 클수록 아주 작은 아이콘부터 모달, 탭, 바텀 시트 등 여러 컴포넌트가 많은 페이지에 사용됩니다.
저는 이번에 탭 컴포넌트와 버튼 컴포넌트를 담당하게 되었습니다.
디자이너와 프로그래머의 만남이라는 이름대로 이번 프로젝트에서는 디자이너 분들이 모든 uiux를 담당해주셨는데요.
디자이너 분들이 디자인 시스템을 완성하면 프론트엔드는 이것을 컴포넌트 단위로 구현하여 개발을 진행하고 있습니다.
Tab 컴포넌트 만들기
탭 컴포넌트는 다음과 같이 크게 primary / secondary / assistive 라는 3가지 디자인이 있습니다.
각 디자인 별로 작은 탭과 탭의 테두리인 TabItem이 모두 다르다는 것을 알 수 있습니다.
또한 같은 디자인이어도 선택된 탭과 그렇지 않은 탭의 UI에 차이가 있습니다.
primary 같은 경우에는 fill 일 때와 fit-content일 때에도 차이가 있어 여러 컨디션을 고려해야 했습니다.
export type TabItemProps = {
selected: boolean;
text: string;
onClick: () => void;
variant?: 'fill' | 'fit-content';
type?: 'primary' | 'secondary' | 'assistive';
};
export interface ClickTabItemProps extends TabItemProps {
onClick: () => void;
}
export type TabProps = {
variant?: 'fill' | 'fit-content';
children?: React.ReactNode;
};
export type TabTypeProps = {
type?: 'primary' | 'secondary' | 'assistive';
};
이를 기반으로 작성한 초기 type과 interface입니다.
TS로 웹 개발을 한 적이 거의 없어 type과 interface가 끔찍하게 혼용된 것을 볼 수 있습니다... (또한 불필요한 extends도 있네요;)
대충 겉테두리인 tabitem과 실제로 선택되는 tab을 나누려고 했던 코드입니다.
import { css } from '@/styled-system/css';
import { TabProps, TabTypeProps } from './type';
/**
*
* @param type primary 디폴트값
* @param variant fill 디폴트값 (fit-content는 primary에만 적용)
*/
const Tab = ({ children, variant, type }: TabProps & TabTypeProps) => {
const tabStyles = css({
width:
type === 'primary' ? '375px' : type === 'assistive' ? '150px' : '335px',
height:
type === 'primary' ? '56px' : type === 'assistive' ? '34px' : '44px',
display: 'flex',
justifyContent: variant === 'fill' ? 'center' : 'left',
alignItems: 'center',
gap: '8px',
backgroundColor:
type === 'primary'
? 'white'
: type === 'assistive'
? ''
: 'background.gray',
borderBottom: type === 'primary' ? '1px solid' : '',
borderColor: type === 'primary' ? 'line.neutral' : '',
borderRadius: type === 'primary' ? '' : type === 'assistive' ? '' : '12px',
padding: type === 'primary' ? '' : '3px',
});
return <div className={tabStyles}>{children}</div>;
};
export default Tab;
import { css } from '@/styled-system/css';
import { ClickTabItemProps } from './type';
/**
*
* @param selected 선택 여부
* @param text 텍스트
* @param onClick tab 클릭 시 함수
* @param type primary 디폴트값
* @param variant fill 디폴트값 (fit-content는 primary에만 적용)
*/
export const TabItem = ({
selected,
text,
onClick,
type,
variant,
}: ClickTabItemProps) => {
const tabItemStyles = css({
width:
variant === 'fit-content'
? 'auto'
: type === 'assistive'
? '67px'
: '100%',
height: type === 'primary' ? '56px' : '38px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
cursor: 'pointer',
padding: type === 'primary' ? '16px 10px' : '8px 10px',
backgroundColor:
type === 'primary' && selected
? 'white'
: type === 'secondary' && selected
? 'white'
: type === 'assistive' && selected
? 'coolNeutral.25'
: '',
borderBottom:
type === 'primary' && selected
? '2px solid'
: type === 'secondary' && selected
? ''
: '',
border: type === 'assistive' ? '1px solid' : '',
borderColor:
type === 'primary' && selected
? 'blue.60'
: type === 'secondary' && selected
? ''
: 'line.normal',
borderRadius:
type === 'primary' && selected
? ''
: type === 'secondary' && selected
? '10px'
: type === 'assistive'
? '999px'
: '',
shadow:
type === 'primary' && selected
? ''
: type === 'secondary' && selected
? 'normal'
: '',
color:
type === 'primary' && selected
? 'text.normal'
: type === 'secondary' && selected
? 'text.normal'
: type === 'assistive' && selected
? 'background.white'
: 'text.alternative',
fontSize:
type === 'primary' ? '17px' : type === 'secondary' ? '15px' : '13px',
});
return (
<div className={tabItemStyles} onClick={onClick}>
{text}
</div>
);
};
보다시피 모든 스타일링을 tapStyles 또는 tabItemStyles에서 삼항 연산자로 분기처리 하고 있습니다.
각 디자인 타입 별로, 선택 여부에 따라 스타일링을 나누다 보니 코드가 매우 복잡하고 구분하기 어렵습니다.
이에 대해 팀원 분이 cx를 사용해 보라는 코드 리뷰를 남겨주셨습니다.
cx란?
classnames라는 라이브러리는 CSS 클래스를 조건부로 설정할 때 매우 편리한데요,
여러 가지 종류의 파라미터를 조합해 CSS 클래스를 간결하게 만들 수 있기 때문입니다. (이름의 유래는 이곳 확인)
pandaCSS의 예제를 통해 간단하게 확인해봅시다.
https://play.panda-css.com/QiMuP9AY0U
colorPalette: 'yellow'인 스타일링과 someButton()의 스타일링을 cx로 합쳐서 사용한 게 첫 번째 버튼입니다.
someButton은 cva로 조합된 컴포넌트로 기본 variant가 primary인데, 여기서는 다크 모드라서 _dark로 보시면 됩니다.
css ({}) 안에 들어간 것들은 컴파일 시 추출되어 string 형태의 classname이 되는데, 이것을 조합하는 게 바로 cx입니다.
그렇다면 탭 컴포넌트에서는 cx를 어떻게 활용할 수 있을까요?
const baseStyles = css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
cursor: 'pointer',
});
const primaryStyles = css({
height: '56px',
padding: '16px 10px',
backgroundColor: selected ? 'white' : '',
borderBottom: selected ? '2px solid' : '',
borderColor: selected ? 'blue.60' : '',
color: selected ? 'text.normal' : 'text.alternative',
fontSize: '17px',
});
const secondaryStyles = css({
height: '38px',
padding: '8px 10px',
backgroundColor: selected ? 'white' : '',
borderRadius: selected ? '10px' : '',
shadow: selected ? 'normal' : '',
color: selected ? 'text.normal' : 'text.alternative',
fontSize: '15px',
});
const assistiveStyles = css({
width: '67px',
height: '38px',
padding: '8px 10px',
backgroundColor: selected ? 'coolNeutral.25' : '',
border: '1px solid',
borderColor: 'line.normal',
borderRadius: '999px',
color: selected ? 'background.white' : 'text.alternative',
fontSize: '13px',
});
const fitContentStyles = css({
width: 'auto',
});
const fullWidthStyles = css({
width: '100%',
});
const tabItemStyles = cx(
baseStyles,
type === 'primary' && primaryStyles,
type === 'secondary' && secondaryStyles,
type === 'assistive' && assistiveStyles,
variant === 'fit-content' ? fitContentStyles : fullWidthStyles,
);
바로 이런 식으로 tabStyles에 모든 스타일링을 때려 박지 말고, 각 디자인에 맞는 스타일링을 변수화 하는 것입니다.
기본 디자인인 baseStyles와 type별로 존재하는 ___styles, variant에 맞는 크기 관련 styles와 cx를 해주면 됩니다.
하지만 여기서도 보면 type과 variant에 따라 한줄씩 모든 상황을 나열하고 있는데, 이것도 좀 더 다듬을 수가 있습니다.
const typeStylesMap = new Map([
['primary', primaryStyles],
['secondary', secondaryStyles],
['assistive', assistiveStyles],
]);
const variantStylesMap = new Map([
['fit-content', fitContentStyles],
['full', fullWidthStyles],
]);
const tabItemStyles = cx(
baseStyles,
typeStylesMap.get(type),
variantStylesMap.get(variant)
);
바로 코테에서나 사용했던 Map 객체를 사용하면 됩니다!
만약 type이 primary라면 typeStylesMap.get(primary)로 primaryStyles를 불러와 비슷하지만 훨씬 깔끔하게 나눌 수 있습니다.
(Map에서 get 메서드를 사용할 때 type에 값이 없으면 undefined를 반환하니 옵셔널 값의 경우 default 값이 필요합니다.)
이런 식으로 cx를 사용해 각 조건별로 스타일링을 조합하고, Map으로 정리까지 하여 훨씬 깔끔한 코드가 구현되었습니다.
참고로 초반에 개떡같았던 type도 수정되었습니다.
import { ReactNode } from 'react';
type TabVariant = 'fill' | 'fit-content';
type TabType = 'primary' | 'secondary' | 'assistive';
export interface TabItemProps {
selected: boolean;
text: string;
onClick: () => void;
variant?: TabVariant;
type?: TabType;
}
export interface TabProps {
variant?: TabVariant;
type?: TabType;
children?: ReactNode;
className?: string;
}
Tab과 TabItem 디자인이 variant와 type에 따라 달라지므로 공통적인 type을 추출해 중복 코드를 줄여 주었습니다.
너무 너무 감사하고 따수웠던 우리 웹팀의 코드리뷰 한번 보시지요...
다음은 병렬적으로 함께 만들고 있었던 버튼 컴포넌트입니다.
Button 컴포넌트 만들기
버튼 컴포넌트를 하나 구현하는 데에도 아주 많은 디자인이 사용됩니다. ^^
자세히 보여드리긴 어렵지만 [solid / outlined / text], [primary / secondary / assitive]가 기준이 되겠습니다.
이 UI를 기준으로 부수적인 요소들까지 정리해 ButtonProps를 만들면 다음과 같습니다.
export interface ButtonProps {
disabled?: boolean;
label: string;
size?: 'large' | 'medium' | 'small';
interaction?: 'normal' | 'hovered' | 'focused' | 'pressed';
variant?: 'solid' | 'outlined' | 'text';
buttonType?: 'primary' | 'secondary' | 'assistive';
type?: 'button' | 'reset' | 'submit';
leftIconSrc?: string;
rightIconSrc?: string;
onClick?: () => void;
className?: string;
isLoading?: boolean;
}
버튼의 비활성화 여부, 라벨, 크기, 인터랙션, variant, 디자인적 타입, 기능적 타입, 아이콘의 유무, onClick 함수가 기본으로 들어갑니다.
추가적인 스타일링을 위한 className, 로딩 중일 때 아이콘이 추가되었습니다.
처음에는 type이 없었고, buttonType이 type이라는 이름으로 사용되었는데요.
실제로 버튼이 담당하는 역할을 지정할 때 이름이 겹치는 이슈가 있어 수정하게 되었습니다.
웬만하면 실제 엘리먼트에 사용되는 이름으로 변수 이름을 정하지 않도록 하는 게 좋겠습니다 🥲
하나의 버튼을 만들어도 비활성화 여부나 variant, buttonType에 따라 backgroundColor와 color, borderline이 변경됩니다.
그렇기 때문에 구현하면서 여러 조건을 고려해야 하니 코드가 상당히 더러워 가독성이 좋지 않았던 문제가 있었습니다.
가장 처음 작성한 코드는 아래와 같습니다.
const Button = ({
size,
disabled,
leftIcon,
rightIcon,
leftIconSrc,
rightIconSrc,
label,
variant = 'solid',
type = 'primary',
}: ButtonPropsWithIcons) => {
const buttonStyles = css({
backgroundColor:
variant === 'solid' ? (disabled ? 'fill.disable' : 'blue.60') : 'white',
border:
variant === 'solid'
? disabled
? 'none '
: 'none'
: type === 'primary'
? '1px solid'
: type === 'secondary'
? '1px solid'
: '1px solid',
borderColor:
variant === 'solid'
? disabled
? 'none '
: 'none'
: type === 'primary'
? '#3385FF'
: type === 'secondary'
? '#70737C38'
: '#70737C38',
color:
variant === 'solid'
? disabled
? 'text.placeHolder'
: 'white'
: type === 'primary'
? 'blue.60'
: type === 'secondary'
? 'blue.60'
: 'text.normal',
width: size
? size
: size === 'large'
? '149px'
: size === 'medium'
? '125px'
: '102px',
height: size === 'large' ? '48px' : size === 'medium' ? '40px' : '32px',
padding:
size === 'large'
? '12px 28px'
: size === 'medium'
? '9px 20px'
: '7px 14px',
borderRadius: size === 'large' ? '10px' : size === 'small' ? '6px' : '4px',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: leftIcon && rightIcon ? 'space-between' : 'center',
cursor: disabled ? 'not-allowed' : 'pointer',
});
const iconSize = size === 'large' ? 20 : size === 'medium' ? 18 : 16;
const iconWrapperStyles = css({
width: `${iconSize}px`,
height: `${iconSize}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 4px',
});
return (
<button className={buttonStyles}>
{leftIcon && leftIconSrc && (
<div className={iconWrapperStyles}>
<Image
src={leftIconSrc}
alt="left icon"
width={iconSize}
height={iconSize}
/>
</div>
)}
{label}
{rightIcon && rightIconSrc && (
<div className={iconWrapperStyles}>
<Image
src={rightIconSrc}
alt="right icon"
width={iconSize}
height={iconSize}
/>
</div>
)}
</button>
);
};
export default Button;
상당히 끔찍한 코드입니다.
탭 컴포넌트를 만들 때와 같이 buttonStyles라는 스타일링 하나로 모든 조건을 관리하고 있습니다.
backgroundColor:
variant === 'solid' ? (disabled ? 'fill.disable' : 'blue.60') : 'white',
color:
variant === 'solid'
? disabled
? 'text.placeHolder'
: 'white'
: type === 'primary'
? 'blue.60'
: type === 'secondary'
? 'blue.60'
: 'text.normal',
이렇게 모든 스타일링을 삼항 연산자로 구현하고 있어 코드를 직관적으로 이해하기 어렵다는 문제가 있었습니다.
탭 컴포넌트의 코드 리뷰를 바탕으로 이것도 리팩토링을 진행해보았습니다.
애석하게도 버튼 컴포넌트는 탭 컴포넌트보다 훨씬 디자인의 분기가 많아 코드가 매우 깁니다.
export const Button = ({
size = 'medium',
disabled = false,
leftIconSrc,
rightIconSrc,
label,
variant,
buttonType,
interaction = 'normal',
type,
onClick,
className,
isLoading,
}: ButtonProps) => {
const baseStyles = flex({
alignItems: 'center',
justifyContent: leftIconSrc && rightIconSrc ? 'space-between' : 'center',
position: 'relative',
cursor: disabled ? 'not-allowed' : 'pointer',
});
const sizeStylesMap = new Map([
[
'large',
css({
height: '48px',
padding: '12px 28px',
borderRadius: '10px',
textStyle: 'body1.normal',
gap: '6px',
}),
],
[
'medium',
css({
height: '40px',
padding: '9px 20px',
borderRadius: '8px',
textStyle: 'body2.normal',
gap: '5px',
}),
],
[
'small',
css({
height: '32px',
padding: '7px 14px',
borderRadius: '6px',
textStyle: 'label2',
gap: '4px',
}),
],
]);
const variantStylesMap = new Map([
[
'solid',
css({
backgroundColor: disabled ? 'fill.disable' : 'blue.60',
border: 'none',
}),
],
[
'outlined',
css({
backgroundColor: 'white',
border: '1px solid',
borderColor: disabled
? 'line.normal'
: buttonType === 'primary'
? '#3B87F4'
: '#70737C38',
}),
],
[
'text',
css({ backgroundColor: 'white', border: 'none', padding: '4px 0px' }),
],
]);
const typeStylesMap = new Map([
[
'primary',
css({
color: disabled
? 'text.placeHolder'
: variant === 'solid'
? 'white'
: 'blue.60',
}),
],
[
'secondary',
css({
color: disabled
? 'text.placeHolder'
: variant === 'solid'
? 'white'
: '#3B87F4',
}),
],
[
'assistive',
css({
color: disabled
? 'text.placeHolder'
: variant === 'solid'
? 'white'
: variant === 'outlined'
? 'text.normal'
: 'text.alternative',
}),
],
]);
const interactionStylesMap = new Map([
['hovered', css({ '&:hover::after': { opacity: 0.075 } })],
['focused', css({ '&:focus::after': { opacity: 0.12 } })],
['pressed', css({ '&:active::after': { opacity: 0.18 } })],
]);
const buttonStyles = cx(
className,
baseStyles,
sizeStylesMap.get(size),
variant && variantStylesMap.get(variant),
buttonType && typeStylesMap.get(buttonType),
interactionStylesMap.get(interaction),
css({
fontWeight: '600',
'&::after': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: 'inherit',
backgroundColor:
buttonType === 'primary' &&
(variant === 'outlined' || variant === 'text')
? 'blue.60'
: 'text.normal',
opacity: 0,
},
}),
);
const iconSize = size === 'large' ? 20 : size === 'medium' ? 18 : 16;
const iconWrapperStyles = flex({
width: `${iconSize}px`,
height: `${iconSize}px`,
alignItems: 'center',
justifyContent: 'center',
});
return (
<button className={buttonStyles} onClick={onClick} type={type}>
{(leftIconSrc || isLoading) && (
<div className={iconWrapperStyles}>
{isLoading && <LoadingIcon />}
{leftIconSrc && (
<Image
src={leftIconSrc}
alt="left icon"
width={iconSize}
height={iconSize}
/>
)}
</div>
)}
{label}
{rightIconSrc && (
<div className={iconWrapperStyles}>
<Image
src={rightIconSrc}
alt="right icon"
width={iconSize}
height={iconSize}
/>
</div>
)}
</button>
);
};
buttonStyles는 크게 6~7가지 스타일링을 갖고 있습니다.
1. baseStyles
2. sizeStyles
3. variantStyles
4. buttonTypeStyles
5. interactionStyles
이외로 추가 스타일링 시 사용되는 classname과 interaction에서 사용되는 css가 있습니다.
size, variant, buttonType, interaction은 Map 객체에서 get 메소드로 값을 추출해 오는 형식입니다.
여기에 label의 앞뒤로 아이콘이나 로딩 상태를 띄울 수 있도록 조건식을 더해주었습니다.
Storybook 사용하기
스토리북은 이전에도 블로그에서 몇번 다뤘던 UI 테스트 툴입니다.
간단하게 위와 같이 버튼이나 탭이 각자 스타일대로 들어갔는지 확인할 수 있는 도구입니다.
만약 스토리북이 없다면 저는 저 props를 모두 바꿔가면서 컴포넌트를 일일이 확인해야 했을 것입니다.
import type { Meta, StoryObj } from '@storybook/react';
import { css } from '@/styled-system/css';
import { Button } from './button';
const meta: Meta<typeof Button> = {
title: 'Example/Button',
component: Button,
tags: ['autodocs'],
decorators: [
(Story) => (
<div className={css({ m: 10 })}>
<Story />
</div>
),
],
argTypes: {
size: {
control: { type: 'select', options: ['small', 'medium', 'large'] },
},
disabled: {
control: 'boolean',
},
variant: {
control: { type: 'select', options: ['solid', 'outlined', 'text'] },
},
buttonType: {
control: {
type: 'select',
options: ['primary', 'secondary', 'assistive'],
},
},
interaction: {
control: {
type: 'select',
options: ['normal', 'hovered', 'focused', 'pressed'],
},
},
label: {
control: 'text',
},
leftIconSrc: {
control: 'text',
},
rightIconSrc: {
control: 'text',
},
className: {
control: 'text',
},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Default: Story = {
args: {
label: 'Label',
size: 'large',
interaction: 'normal',
},
};
export const outlined: Story = {
args: {
...Default.args,
variant: 'outlined',
},
};
export const text: Story = {
args: {
...Default.args,
variant: 'text',
},
};
스토리북의 코드를 위와 같이 작성합니다.
하나씩 뜯어보도록 하겠습니다.
1. Meta 타입 정의
const meta: Meta<typeof Button> = {
title: 'Example/Button',
component: Button,
tags: ['autodocs'],
decorators: [
(Story) => (
<div className={css({ m: 10 })}>
<Story />
</div>
),
],
argTypes: {
...
},
};
스토리북의 Meta와 StoryObj 타입을 가져옵니다.
Meta는 컴포넌트에 대한 메타 데이터를 정의하는 객체이고, StoryObj는 개별 스토리에 대한 설정을 정의하는 객체입니다.
간단하게 title을 Example/Button으로 하고, components는 Button으로 하겠다는 뜻입니다.
tags는 해당 스토리에 추가적인 태그를 지정하는데, autodocs란 자동으로 문서를 생성하는데 사용됩니다.
decorator는 모든 스토리에 적용되는 스타일링을 말합니다.
이 경우 컴포넌트가 div로 랩핑되고 margin 10px을 갖습니다.
argTypes는 스토리에서 사용하는 여러 매개변수들의 타입과 옵션을 지정합니다.
size, disabled, variant 등등이 그렇습니다.
2. 스토리 정의하기
export const Default: Story = {
args: {
label: 'Label',
size: 'large',
interaction: 'normal',
},
};
기본적인 스토리입니다. label은 Label, 크기는 large, interaction은 normal이 기본적인 스토리입니다.
export const outlined: Story = {
args: {
...Default.args,
variant: 'outlined',
},
};
export const text: Story = {
args: {
...Default.args,
variant: 'text',
},
};
outlined이라면 기본적인 스토리의 args에서 variant를 outlined으로만 바꾼 것입니다.
동일하게 text는 기본적인 스토리의 args에서 variant를 text로만 바꾼 것이겠죠!
문서에 예시로 default와 Outlined, Text만 만들어두었습니다.
어처피 배포된 스토리북에서 직접 props들을 컨트롤하면서 테스트할 수 있기 때문입니다.
현재 배포를 할 때마다 크로마틱에서 이슈가 있어서 꽤 이전 버전이지만 현재 스토리북에서 UI를 테스트할 수 있습니다.
이렇게 디자인 시스템을 구축해본 적도 없고, 타입 스크립트로 웹 개발을 스케일 있게 해본 적이 없어 많이 어려웠습니다 🥲
게다가 다들 잘하는데 나만 못하는 것 같아서 많이 주눅들기도 했었지만 이렇게 정리를 하고 보니 꽤 뿌듯하네요!
천재만재 팀원들을 만나 많은 것을 배우고 있습니다... 내가왕이될상인가 2팀 파이팅!!!!!!
'🛠️ 프로젝트 > ➕ 디프만 15기' 카테고리의 다른 글
[디프만 15기] next-auth 없이 웹에서 애플 로그인 구현하기 (0) | 2024.09.10 |
---|---|
[디프만 15기] Next로 소셜 로그인 구현하기 (feat: cookie, route handler, middleware) (0) | 2024.08.05 |
[디프만 15기] pandaCSS 맛보기 (0) | 2024.07.30 |
[디프만 15기] 서류 지원 및 면접 후기 (+ 최종 합격) (0) | 2024.05.26 |