diff --git a/.stylelintrc.json b/.stylelintrc.json index f77184c..95fc8b1 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -3,6 +3,7 @@ "plugins": ["stylelint-order"], "rules": { "no-descending-specificity": null, + "no-empty-source": null, "order/properties-order": [ "display", "align-items", diff --git a/src/assets/styles/button.ts b/src/assets/styles/button.ts new file mode 100644 index 0000000..f5f4af7 --- /dev/null +++ b/src/assets/styles/button.ts @@ -0,0 +1,140 @@ +import { ReactNode } from 'react'; + +import { BaseButtonProps } from '@/common/components/button/base-button'; + +import { colors } from './colors'; +import { sizeToken } from './space'; + +export const BUTTON_SIZE = { + text: { + small: { + padding: `${sizeToken.padding['xs_2']} ${sizeToken.padding['sm_2']}`, + fontSize: `${sizeToken.fontSize['xs_1']}`, + lineHeight: `${sizeToken.lineHeight['xs_1']}`, + borderRadius: `${sizeToken.borderRadius['xs_1']}`, + }, + medium: { + padding: `${sizeToken.padding['s_2']} ${sizeToken.padding['md_2']}`, + fontSize: `${sizeToken.fontSize['s_1']}`, + lineHeight: `${sizeToken.lineHeight['xs_1']}`, + borderRadius: `${sizeToken.borderRadius['xs_1']}`, + }, + large: { + padding: `${sizeToken.padding['sm_2']} ${sizeToken.padding['lg_1']}`, + fontSize: `${sizeToken.fontSize['s_1']}`, + lineHeight: `${sizeToken.lineHeight['xs_1']}`, + borderRadius: `${sizeToken.borderRadius['xs_1']}`, + }, + }, + icon: { + small: { + padding: `${sizeToken.padding['s_1']}`, + borderRadius: `${sizeToken.borderRadius['xs_1']}`, + }, + medium: { + padding: `${sizeToken.padding['s_2']}`, + borderRadius: `${sizeToken.borderRadius['xs_1']}`, + }, + large: { + padding: `${sizeToken.padding['sm_1']}`, + borderRadius: `${sizeToken.borderRadius['xs_1']}`, + }, + }, + multi: { + small: { + padding: `${sizeToken.padding['xs_2']} ${sizeToken.padding['sm_2']} ${sizeToken.padding['xs_2']} ${sizeToken.padding['s_2']}`, + fontSize: `${sizeToken.fontSize['xs_1']}`, + borderRadius: `${sizeToken.borderRadius['xs_1']}`, + lineHeight: `${sizeToken.lineHeight['xs_1']}`, + gap: `${sizeToken.gap['xs_1']}`, + }, + medium: { + padding: `${sizeToken.padding['s_2']} ${sizeToken.padding['md_2']} ${sizeToken.padding['s_2']} ${sizeToken.padding['md_1']}`, + fontSize: `${sizeToken.fontSize['s_1']}`, + borderRadius: `${sizeToken.borderRadius['xs_1']}`, + lineHeight: `${sizeToken.lineHeight['xs_1']}`, + gap: `${sizeToken.gap['xs_1']}`, + }, + large: { + padding: `${sizeToken.padding['sm_2']} ${sizeToken.padding['lg_1']} ${sizeToken.padding['sm_2']} ${sizeToken.padding['md_2']}`, + fontSize: `${sizeToken.fontSize['s_1']}`, + borderRadius: `${sizeToken.borderRadius['xs_1']}`, + lineHeight: `${sizeToken.lineHeight['xs_1']}`, + gap: `${sizeToken.gap['xs_1']}`, + }, + }, +}; + +export const BUTTON_VARIANTS = { + purlple: { + default: { + background: colors.PURPLE[300], + color: colors.GRAY[950], + }, + hover: { + background: colors.PURPLE[200], + color: colors.GRAY[950], + }, + pressed: { + background: colors.PURPLE[400], + color: colors.GRAY[950], + }, + disabled: { + background: colors.PURPLE[400], + color: colors.GRAY[700], + }, + }, + sora: { + default: { + background: colors.SORA[200], + color: colors.GRAY[950], + }, + hover: { + background: colors.SORA[100], + color: colors.GRAY[950], + }, + pressed: { + background: colors.SORA[400], + color: colors.GRAY[950], + }, + disabled: { + background: colors.SORA[400], + color: colors.GRAY[700], + }, + }, +}; + +// 각 카테고리별 사이즈 키 +export type Usage = keyof typeof BUTTON_SIZE; +export type Size = keyof (typeof BUTTON_SIZE)['text']; +export type ButtonVariant = keyof typeof BUTTON_VARIANTS; + +// 공통 props +export type CommonProps = { + size: Size; + variant: ButtonVariant; +} & BaseButtonProps; + +// 텍스트만 있는 경우 +export type TextOnlyProps = CommonProps & { + usage: 'text'; + children: ReactNode; + icon?: never; + iconPosition?: never; +}; + +// 텍스트와 아이콘이 모두 있는 경우 +export type TextAndIconProps = CommonProps & { + usage: 'multi'; + children: ReactNode; + icon: ReactNode; + iconPosition: 'left' | 'right'; +}; + +// 아이콘만 있는 경우 +export type IconOnlyProps = CommonProps & { + usage: 'icon'; + icon: ReactNode; + children?: never; + iconPosition?: never; +}; diff --git a/src/assets/styles/emotion.d.ts b/src/assets/styles/emotion.d.ts index 662545e..c4a6a9a 100644 --- a/src/assets/styles/emotion.d.ts +++ b/src/assets/styles/emotion.d.ts @@ -3,10 +3,12 @@ import '@emotion/react'; import { ColorsTypes } from '@/assets/styles/colors'; import { FontsTypes } from './fonts'; +import { SizeTypes } from './space'; declare module '@emotion/react' { export interface Theme { fonts: FontsTypes; colors: ColorsTypes; + sizeToken: SizeTypes; } } diff --git a/src/assets/styles/space.ts b/src/assets/styles/space.ts new file mode 100644 index 0000000..f30e207 --- /dev/null +++ b/src/assets/styles/space.ts @@ -0,0 +1,28 @@ +export const sizeToken = { + padding: { + xs_1: '0.4rem', + xs_2: '0.7rem', + s_1: '0.8rem', + s_2: '1.2rem', + sm_1: '1.4rem', + sm_2: '1.6rem', + md_1: '2rem', + md_2: '2.4rem', + lg_1: '3.2rem', + }, + fontSize: { + xs_1: '1.4rem', + s_1: '1.6rem', + }, + borderRadius: { + xs_1: '0.8rem', + }, + lineHeight: { + xs_1: '127%', + }, + gap: { + xs_1: '0.4rem', + }, +} as const; + +export type SizeTypes = typeof sizeToken; diff --git a/src/assets/styles/theme.ts b/src/assets/styles/theme.ts index 3c1fce8..6707b7b 100644 --- a/src/assets/styles/theme.ts +++ b/src/assets/styles/theme.ts @@ -3,10 +3,12 @@ import { Theme } from '@emotion/react'; import { colors } from '@/assets/styles/colors'; import { fonts } from './fonts'; +import { sizeToken } from './space'; export const theme: Theme = { fonts, colors, + sizeToken, }; export type ThemeType = typeof theme; diff --git a/src/common/components/button/Button.tsx b/src/common/components/button/Button.tsx index 58cdabf..9b4ac73 100644 --- a/src/common/components/button/Button.tsx +++ b/src/common/components/button/Button.tsx @@ -1,24 +1,26 @@ import { forwardRef } from 'react'; -import { BaseButton, BaseButtonProps } from '@/common/components/base-button/base-button'; +import { IconOnlyProps, TextAndIconProps, TextOnlyProps } from '@/assets/styles/button'; +import { BaseButton } from '@/common/components/button/base-button'; import * as styles from './button.styles'; -/** TODO: 디자인 시스템에 따라 추가 및 세분화 예정 */ -export type ButtonVariant = 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; -export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'; - -/** 디자인 시스템을 따르는 버튼 */ -export interface ButtonProps extends BaseButtonProps { - variant?: ButtonVariant; - size?: ButtonSize; -} +export type ButtonProps = TextOnlyProps | TextAndIconProps | IconOnlyProps; const Button = forwardRef( - ({ variant = 'default', size = 'default', ...props }, ref) => { - return ; + ({ size, usage, variant, icon, children, iconPosition, ...props }, ref) => { + const content = usage !== 'icon' ? children : icon; + + return ( + + {usage === 'multi' && iconPosition === 'left' && icon} + {content} + {usage === 'multi' && iconPosition === 'right' && icon} + + ); }, ); + Button.displayName = 'Button'; export { Button }; diff --git a/src/common/components/base-button/base-button.styles.ts b/src/common/components/button/base-button.styles.ts similarity index 100% rename from src/common/components/base-button/base-button.styles.ts rename to src/common/components/button/base-button.styles.ts diff --git a/src/common/components/base-button/base-button.tsx b/src/common/components/button/base-button.tsx similarity index 61% rename from src/common/components/base-button/base-button.tsx rename to src/common/components/button/base-button.tsx index 3ef30f8..48bd98d 100644 --- a/src/common/components/base-button/base-button.tsx +++ b/src/common/components/button/base-button.tsx @@ -8,10 +8,12 @@ export interface BaseButtonProps extends ButtonHTMLAttributes asChild?: boolean; } -/** 기본 버튼 스타일 & 주입받은 props를 기반으로 UI를 렌더링하는 컴포넌트 */ +/** 기본 버튼 스타일 & 주입받은 props와 아이콘을 기반으로 최종 UI를 렌더링하는 컴포넌트 */ +/** 상황에 따라 주입받은 스타일이 기본 버튼 스타일에 대한 오버라이딩 가능 */ const BaseButton = forwardRef(({ asChild, ...props }, ref) => { const Comp = asChild ? Slot : 'button'; - return ; + + return ; }); BaseButton.displayName = 'BaseButton'; diff --git a/src/common/components/button/button.stories.tsx b/src/common/components/button/button.stories.tsx new file mode 100644 index 0000000..f63fa16 --- /dev/null +++ b/src/common/components/button/button.stories.tsx @@ -0,0 +1,63 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { Button } from './Button'; +import Icon from '../icon/icon'; + +const meta: Meta = { + title: 'Components/Button', + component: Button, + argTypes: { + size: { + description: '버튼의 크기를 결정합니다.', + control: { type: 'radio' }, + options: ['small', 'medium', 'large'], + }, + variant: { + description: '버튼의 색상 테마를 결정합니다.', + control: { type: 'radio' }, + options: ['purlple', 'sora'], + }, + usage: { + table: { disable: true }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const TextButton: Story = { + args: { + variant: 'sora', + children: 'hello', + usage: 'text', + size: 'small', + }, +}; +export const IconButton: Story = { + args: { + variant: 'sora', + usage: 'icon', + size: 'small', + icon: , + }, +}; + +export const MultiButton: Story = { + args: { + variant: 'purlple', + children: 'hello', + usage: 'multi', + iconPosition: 'left', + size: 'small', + icon: , + }, + argTypes: { + iconPosition: { + description: '아이콘의 위치를 조정합니다.', + control: { type: 'radio' }, + options: ['left', 'right'], + }, + }, +}; diff --git a/src/common/components/button/button.styles.ts b/src/common/components/button/button.styles.ts index b8815f9..2bfcc82 100644 --- a/src/common/components/button/button.styles.ts +++ b/src/common/components/button/button.styles.ts @@ -1,78 +1,58 @@ import { css } from '@emotion/react'; -/* 임시 - variant별 스타일 */ -export const variants = { - default: css` - background-color: transparent; - color: black; +import { ButtonVariant, Size, BUTTON_SIZE, Usage, BUTTON_VARIANTS } from '@/assets/styles/button'; - &:hover { - background-color: #05d; - } +const BUTTON_STYLE_SYSYEM = { + // 사이즈 관련 스타일 + createSizeStyle: (size: Size, usage: Usage) => css` + padding: ${BUTTON_SIZE[usage][size].padding}; + border-radius: ${BUTTON_SIZE[usage][size].borderRadius}; `, - destructive: css` - background-color: #f44; - color: #fff; - &:hover { - background-color: #d22; - } - `, - outline: css` - border: 0.1rem solid #ccc; - background-color: #fff; + // 버튼 상태 관련 스타일 + createStateStyles: (variant: ButtonVariant) => css` + background-color: ${BUTTON_VARIANTS[variant]['default'].background}; + color: ${BUTTON_VARIANTS[variant]['default'].color}; &:hover { - background-color: #f9f9f9; + background-color: ${BUTTON_VARIANTS[variant].hover.background}; + color: ${BUTTON_VARIANTS[variant].hover.color}; } - `, - secondary: css` - background-color: #f5f5f5; - color: #333; - &:hover { - background-color: #ebebeb; + &:active { + background-color: ${BUTTON_VARIANTS[variant].pressed.background}; + color: ${BUTTON_VARIANTS[variant].pressed.color}; } - `, - ghost: css` - background-color: transparent; - color: #333; - &:hover { - background-color: rgb(0 0 0 / 8%); + &:disabled { + background-color: ${BUTTON_VARIANTS[variant].disabled.background}; + color: ${BUTTON_VARIANTS[variant].disabled.color}; } `, - link: css` - background-color: transparent; - color: #06f; - text-decoration: underline; - &:hover { - text-decoration: none; - } - `, + // 용도별 고유 스타일 + createUsageStyle: (usage: Usage, size: Size) => { + const usageStyles = { + text: css` + font-size: ${BUTTON_SIZE['text'][size].fontSize}; + line-height: ${BUTTON_SIZE['text'][size].lineHeight}; + `, + icon: css``, + multi: css` + display: flex; + align-items: center; + gap: ${BUTTON_SIZE['multi'][size].gap}; + font-size: ${BUTTON_SIZE['multi'][size].fontSize}; + line-height: ${BUTTON_SIZE['multi'][size].lineHeight}; + `, + }; + return usageStyles[usage]; + }, }; -/* 임시 - ize별 스타일 */ -export const sizes = { - default: css` - height: 2.25rem; - padding: 0.5rem 1rem; - `, - sm: css` - height: 2rem; - padding: 0.25rem 0.75rem; - font-size: 0.75rem; - `, - lg: css` - height: 2.5rem; - padding: 0.75rem 1.25rem; - font-size: 1rem; - `, - icon: css` - justify-content: center; - width: 2.25rem; - height: 2.25rem; - padding: 0; - `, -}; +/* 최종 스타일 생성 함수 */ +export const buttonsStyle = (size: Size, usage: Usage, variant: ButtonVariant) => css` + ${BUTTON_STYLE_SYSYEM.createSizeStyle(size, usage)}; + ${BUTTON_STYLE_SYSYEM.createStateStyles(variant)} + ${BUTTON_STYLE_SYSYEM.createUsageStyle(usage, size)} +`; diff --git a/src/common/components/kakao-auth-button/kakao-auth-button.tsx b/src/common/components/kakao-auth-button/kakao-auth-button.tsx index e2ca60b..29c7334 100644 --- a/src/common/components/kakao-auth-button/kakao-auth-button.tsx +++ b/src/common/components/kakao-auth-button/kakao-auth-button.tsx @@ -1,6 +1,6 @@ import { forwardRef } from 'react'; -import { BaseButton, BaseButtonProps } from '@/common/components/base-button/base-button'; +import { BaseButton, BaseButtonProps } from '@/common/components/button/base-button'; import * as styles from './kakao-auth-button.styles'; diff --git a/src/features/total-evaluation/components/custom-buttons/accordion-trigger-button.tsx b/src/features/total-evaluation/components/custom-buttons/accordion-trigger-button.tsx index daa6fd6..50ce029 100644 --- a/src/features/total-evaluation/components/custom-buttons/accordion-trigger-button.tsx +++ b/src/features/total-evaluation/components/custom-buttons/accordion-trigger-button.tsx @@ -1,6 +1,6 @@ import { forwardRef } from 'react'; -import { BaseButton, BaseButtonProps } from '@/common/components/base-button/base-button'; +import { BaseButton, BaseButtonProps } from '@/common/components/button/base-button'; import * as styles from './accordion-trigger-button.styles'; diff --git a/src/features/total-evaluation/components/custom-buttons/sidebar-close-button.tsx b/src/features/total-evaluation/components/custom-buttons/sidebar-close-button.tsx index d42fd51..aad237b 100644 --- a/src/features/total-evaluation/components/custom-buttons/sidebar-close-button.tsx +++ b/src/features/total-evaluation/components/custom-buttons/sidebar-close-button.tsx @@ -1,7 +1,7 @@ import { ButtonHTMLAttributes, forwardRef } from 'react'; import { MdOutlineKeyboardDoubleArrowRight } from 'react-icons/md'; -import { BaseButton } from '@/common/components/base-button/base-button'; +import { BaseButton } from '@/common/components/button/base-button'; const SidebarCloseButton = forwardRef>( (props, ref) => ( diff --git a/src/features/total-evaluation/components/custom-buttons/sidebar-open-button.tsx b/src/features/total-evaluation/components/custom-buttons/sidebar-open-button.tsx index 298cee5..1af1609 100644 --- a/src/features/total-evaluation/components/custom-buttons/sidebar-open-button.tsx +++ b/src/features/total-evaluation/components/custom-buttons/sidebar-open-button.tsx @@ -1,7 +1,7 @@ import { ButtonHTMLAttributes, forwardRef } from 'react'; import { MdOutlineKeyboardDoubleArrowLeft } from 'react-icons/md'; -import { BaseButton } from '@/common/components/base-button/base-button'; +import { BaseButton } from '@/common/components/button/base-button'; const SidebarOpenButton = forwardRef>( (props, ref) => ( diff --git a/src/features/total-evaluation/components/custom-buttons/single-content-button.tsx b/src/features/total-evaluation/components/custom-buttons/single-content-button.tsx index ae27bbc..c4f8bc0 100644 --- a/src/features/total-evaluation/components/custom-buttons/single-content-button.tsx +++ b/src/features/total-evaluation/components/custom-buttons/single-content-button.tsx @@ -1,6 +1,6 @@ import { forwardRef } from 'react'; -import { BaseButton, BaseButtonProps } from '../../../../common/components/base-button/base-button'; +import { BaseButton, BaseButtonProps } from '../../../../common/components/button/base-button'; import * as styles from './single-content-button.styles'; diff --git a/src/features/upload/components/file-upload/file-upload.tsx b/src/features/upload/components/file-upload/file-upload.tsx index 0918d73..d2532e6 100644 --- a/src/features/upload/components/file-upload/file-upload.tsx +++ b/src/features/upload/components/file-upload/file-upload.tsx @@ -4,8 +4,6 @@ import { Controller, FieldValues, SubmitErrorHandler, useForm } from 'react-hook import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; -import { Button } from '@/common/components/button/Button'; - import * as styles from './file-upload.styles'; const MAX_FILE_SIZE = 1024 * 1024 * 5; @@ -83,10 +81,10 @@ function Dropzone({ onChange }: { onChange?: (...event: unknown[]) => void }) { {!isDragActive &&
{acceptedFiles?.[0]?.name}
} - + ); }