diff --git a/apps/docs/src/stories/Button.stories.tsx b/apps/docs/src/stories/Button.stories.tsx index af0ce0ff..6b5b4ca0 100644 --- a/apps/docs/src/stories/Button.stories.tsx +++ b/apps/docs/src/stories/Button.stories.tsx @@ -4,9 +4,9 @@ import { IconPlus, IconChevronRight } from '@sopt-makers/icons'; interface ButtonOwnProps { size?: 'sm' | 'md' | 'lg'; - theme?: 'white' | 'black' | 'blue' | 'red'; - rounded?: 'md' | 'lg'; - variant?: 'fill' | 'outlined'; + intent?: 'primary' | 'secondary' | 'success' | 'danger'; + shape?: 'rect' | 'pill'; + variant?: 'fill' | 'outlined' | 'text' | 'floating'; disabled?: boolean; LeftIcon?: React.ComponentType; RightIcon?: React.ComponentType; @@ -20,8 +20,8 @@ export default { tags: ['autodocs'], argTypes: { size: { control: 'radio', options: ['sm', 'md', 'lg'] }, - theme: { control: 'radio', options: ['white', 'black', 'blue', 'red'] }, - rounded: { control: 'radio', options: ['md', 'lg'] }, + intent: { control: 'radio', options: ['primary', 'secondary', 'success', 'danger'] }, + shape: { control: 'radio', options: ['rect', 'pill'] }, variant: { control: 'radio', options: ['fill', 'outlined'] }, LeftIcon: { control: false }, RightIcon: { control: false }, @@ -33,8 +33,8 @@ export const Default: StoryObj = { args: { children: 'Default Button', size: 'md', - theme: 'white', - rounded: 'md', + intent: 'primary', + shape: 'rect', disabled: false, }, }; @@ -44,8 +44,8 @@ export const Outlined: StoryObj = { args: { children: 'Outlined Button', size: 'md', - theme: 'white', - rounded: 'md', + intent: 'primary', + shape: 'rect', variant: 'outlined', disabled: false, }, @@ -56,8 +56,8 @@ export const LeftIcon: StoryObj = { args: { children: 'LeftIcon Button', size: 'sm', - theme: 'red', - rounded: 'lg', + intent: 'danger', + shape: 'pill', disabled: false, LeftIcon: IconPlus, }, @@ -67,9 +67,29 @@ export const RightIcon: StoryObj = { args: { children: 'RightIcon Button', size: 'lg', - theme: 'blue', - rounded: 'lg', + intent: 'success', + shape: 'pill', disabled: false, RightIcon: IconChevronRight, }, }; + +// text 버튼 스토리 +export const Text: StoryObj = { + args: { + children: 'Text Button', + variant: 'text', + disabled: false, + RightIcon: IconChevronRight, + }, +}; + +// floating 버튼 스토리 +export const Floating: StoryObj = { + args: { + children: '글쓰기', + variant: 'floating', + disabled: false, + LeftIcon: IconPlus, + }, +}; diff --git a/packages/ui/Button/Button.tsx b/packages/ui/Button/Button.tsx index f13b155a..a1961b0d 100644 --- a/packages/ui/Button/Button.tsx +++ b/packages/ui/Button/Button.tsx @@ -1,7 +1,9 @@ -import React, { type ButtonHTMLAttributes } from 'react'; +import React, { useEffect, useState, type ButtonHTMLAttributes } from 'react'; import * as S from './style.css'; import createButtonVariant from './utils'; import { iconSizes } from './constants'; +import { ButtonIntent, ButtonShape, ButtonVariant } from './types'; +import { useResolvedProps, useScrollDirection } from './hooks'; interface IconProps { color?: string; @@ -13,33 +15,48 @@ interface ButtonProps extends ButtonHTMLAttributes { children?: React.ReactNode; className?: string; size?: 'sm' | 'md' | 'lg'; - theme?: 'white' | 'black' | 'blue' | 'red'; - rounded?: 'md' | 'lg'; - variant?: 'fill' | 'outlined'; + theme?: 'white' | 'black' | 'blue' | 'red'; // @deprecated - `intent` prop 사용 + rounded?: 'md' | 'lg'; // @deprecated - `shape` prop 사용 + variant?: ButtonVariant; LeftIcon?: React.ComponentType; RightIcon?: React.ComponentType; + shape?: ButtonShape; + intent?: ButtonIntent; } function Button({ children, className, size = 'md', - theme = 'white', - rounded = 'md', + theme, + rounded, LeftIcon, RightIcon, variant = 'fill', + shape = 'rect', + intent = 'primary', ...buttonElementProps }: ButtonProps) { - const style = createButtonVariant(theme, rounded, size, variant); + const { finalIntent, finalShape } = useResolvedProps({ intent, shape, theme, rounded }); + const isFloating = variant === 'floating'; + const scrollDirection = isFloating ? useScrollDirection() : null; + const [isExpanded, setIsExpanded] = useState(false); + + useEffect(() => { + if (!isFloating) return; + setIsExpanded(scrollDirection === 'down'); + }, [scrollDirection, isFloating]); + + const style = createButtonVariant(finalIntent, finalShape, size, variant, isExpanded); const iconSize = iconSizes[size]; return ( ); } +Button.displayName = 'Button'; export default Button; diff --git a/packages/ui/Button/constants.ts b/packages/ui/Button/constants.ts index cd011c03..5a801814 100644 --- a/packages/ui/Button/constants.ts +++ b/packages/ui/Button/constants.ts @@ -2,96 +2,173 @@ import theme from '../theme.css'; import type { ButtonColorThemeWithStatus, ButtonSizeTheme } from './types'; export const bgColors: Record = { - 'fill-white-default': theme.colors.white, - 'fill-black-default': theme.colors.gray700, - 'fill-blue-default': theme.colors.success, - 'fill-red-default': theme.colors.error, - 'fill-white-hover': theme.colors.gray50, - 'fill-black-hover': theme.colors.gray600, - 'fill-blue-hover': theme.colors.blue500, - 'fill-red-hover': theme.colors.red500, - 'fill-white-press': theme.colors.gray100, - 'fill-black-press': theme.colors.gray500, - 'fill-blue-press': theme.colors.blue600, - 'fill-red-press': theme.colors.red600, - 'outlined-white-default': 'transparent', - 'outlined-black-default': 'transparent', - 'outlined-blue-default': 'transparent', - 'outlined-red-default': 'transparent', - 'outlined-white-hover': 'transparent', - 'outlined-black-hover': 'transparent', - 'outlined-blue-hover': theme.colors.blueAlpha100, - 'outlined-red-hover': theme.colors.redAlpha100, - 'outlined-white-press': 'transparent', - 'outlined-black-press': 'transparent', - 'outlined-blue-press': theme.colors.blueAlpha100, - 'outlined-red-press': theme.colors.redAlpha100, + 'fill-primary-default': theme.colors.white, + 'fill-secondary-default': theme.colors.gray700, + 'fill-success-default': theme.colors.success, + 'fill-danger-default': theme.colors.error, + 'fill-primary-hover': theme.colors.gray50, + 'fill-secondary-hover': theme.colors.gray600, + 'fill-success-hover': theme.colors.blue500, + 'fill-danger-hover': theme.colors.red500, + 'fill-primary-press': theme.colors.gray100, + 'fill-secondary-press': theme.colors.gray500, + 'fill-success-press': theme.colors.blue600, + 'fill-danger-press': theme.colors.red600, + 'outlined-primary-default': 'transparent', + 'outlined-secondary-default': 'transparent', + 'outlined-success-default': 'transparent', + 'outlined-danger-default': 'transparent', + 'outlined-primary-hover': 'transparent', + 'outlined-secondary-hover': 'transparent', + 'outlined-success-hover': theme.colors.blueAlpha100, + 'outlined-danger-hover': theme.colors.redAlpha100, + 'outlined-primary-press': 'transparent', + 'outlined-secondary-press': 'transparent', + 'outlined-success-press': theme.colors.blueAlpha100, + 'outlined-danger-press': theme.colors.redAlpha100, + 'text-primary-default': 'transparent', + 'text-secondary-default': 'transparent', + 'text-success-default': 'transparent', + 'text-danger-default': 'transparent', + 'text-primary-hover': 'transparent', + 'text-secondary-hover': 'transparent', + 'text-success-hover': 'transparent', + 'text-danger-hover': 'transparent', + 'text-primary-press': 'transparent', + 'text-secondary-press': 'transparent', + 'text-success-press': 'transparent', + 'text-danger-press': 'transparent', + 'floating-primary-default': theme.colors.white, + 'floating-secondary-default': theme.colors.gray700, + 'floating-success-default': theme.colors.success, + 'floating-danger-default': theme.colors.error, + 'floating-primary-hover': theme.colors.gray50, + 'floating-secondary-hover': theme.colors.gray600, + 'floating-success-hover': theme.colors.blue500, + 'floating-danger-hover': theme.colors.red500, + 'floating-primary-press': theme.colors.gray100, + 'floating-secondary-press': theme.colors.gray500, + 'floating-success-press': theme.colors.blue600, + 'floating-danger-press': theme.colors.red600, }; export const textColors: Record = { - 'fill-white-default': theme.colors.gray800, - 'fill-black-default': theme.colors.white, - 'fill-blue-default': theme.colors.white, - 'fill-red-default': theme.colors.white, - 'fill-white-hover': theme.colors.gray800, - 'fill-black-hover': theme.colors.white, - 'fill-blue-hover': theme.colors.white, - 'fill-red-hover': theme.colors.white, - 'fill-white-press': theme.colors.gray800, - 'fill-black-press': theme.colors.white, - 'fill-blue-press': theme.colors.white, - 'fill-red-press': theme.colors.white, - 'outlined-white-default': theme.colors.white, - 'outlined-black-default': theme.colors.white, - 'outlined-blue-default': theme.colors.success, - 'outlined-red-default': theme.colors.error, - 'outlined-white-hover': theme.colors.white, - 'outlined-black-hover': theme.colors.white, - 'outlined-blue-hover': theme.colors.blue500, - 'outlined-red-hover': theme.colors.red500, - 'outlined-white-press': theme.colors.white, - 'outlined-black-press': theme.colors.white, - 'outlined-blue-press': theme.colors.blue600, - 'outlined-red-press': theme.colors.red600, + 'fill-primary-default': theme.colors.gray800, + 'fill-secondary-default': theme.colors.white, + 'fill-success-default': theme.colors.white, + 'fill-danger-default': theme.colors.white, + 'fill-primary-hover': theme.colors.gray800, + 'fill-secondary-hover': theme.colors.white, + 'fill-success-hover': theme.colors.white, + 'fill-danger-hover': theme.colors.white, + 'fill-primary-press': theme.colors.gray800, + 'fill-secondary-press': theme.colors.white, + 'fill-success-press': theme.colors.white, + 'fill-danger-press': theme.colors.white, + 'outlined-primary-default': theme.colors.white, + 'outlined-secondary-default': theme.colors.white, + 'outlined-success-default': theme.colors.success, + 'outlined-danger-default': theme.colors.error, + 'outlined-primary-hover': theme.colors.white, + 'outlined-secondary-hover': theme.colors.white, + 'outlined-success-hover': theme.colors.blue500, + 'outlined-danger-hover': theme.colors.red500, + 'outlined-primary-press': theme.colors.white, + 'outlined-secondary-press': theme.colors.white, + 'outlined-success-press': theme.colors.blue600, + 'outlined-danger-press': theme.colors.red600, + 'text-primary-default': theme.colors.gray30, + 'text-secondary-default': theme.colors.gray30, + 'text-success-default': theme.colors.gray30, + 'text-danger-default': theme.colors.gray30, + 'text-primary-hover': theme.colors.gray50, + 'text-secondary-hover': theme.colors.gray50, + 'text-success-hover': theme.colors.gray50, + 'text-danger-hover': theme.colors.gray50, + 'text-primary-press': theme.colors.gray100, + 'text-secondary-press': theme.colors.gray100, + 'text-success-press': theme.colors.gray100, + 'text-danger-press': theme.colors.gray100, + 'floating-primary-default': theme.colors.black, + 'floating-secondary-default': theme.colors.white, + 'floating-success-default': theme.colors.white, + 'floating-danger-default': theme.colors.white, + 'floating-primary-hover': theme.colors.black, + 'floating-secondary-hover': theme.colors.white, + 'floating-success-hover': theme.colors.white, + 'floating-danger-hover': theme.colors.white, + 'floating-primary-press': theme.colors.black, + 'floating-secondary-press': theme.colors.white, + 'floating-success-press': theme.colors.white, + 'floating-danger-press': theme.colors.white, }; export const borders: Record = { - 'fill-white-default': 'none', - 'fill-black-default': 'none', - 'fill-blue-default': 'none', - 'fill-red-default': 'none', - 'fill-white-hover': 'none', - 'fill-black-hover': 'none', - 'fill-blue-hover': 'none', - 'fill-red-hover': 'none', - 'fill-white-press': 'none', - 'fill-black-press': 'none', - 'fill-blue-press': 'none', - 'fill-red-press': 'none', - 'outlined-white-default': `inset 0 0 0 1px ${theme.colors.white}`, - 'outlined-black-default': `inset 0 0 0 1px ${theme.colors.white}`, - 'outlined-blue-default': `inset 0 0 0 1px ${theme.colors.success}`, - 'outlined-red-default': `inset 0 0 0 1px ${theme.colors.error}`, - 'outlined-white-hover': `inset 0 0 0 1px ${theme.colors.white}`, - 'outlined-black-hover': `inset 0 0 0 1px ${theme.colors.white}`, - 'outlined-blue-hover': `inset 0 0 0 1px ${theme.colors.blue500}`, - 'outlined-red-hover': `inset 0 0 0 1px ${theme.colors.red500}`, - 'outlined-white-press': `inset 0 0 0 1px ${theme.colors.white}`, - 'outlined-black-press': `inset 0 0 0 1px ${theme.colors.white}`, - 'outlined-blue-press': `inset 0 0 0 1px ${theme.colors.blue600}`, - 'outlined-red-press': `inset 0 0 0 1px ${theme.colors.red600}`, + 'fill-primary-default': 'none', + 'fill-secondary-default': 'none', + 'fill-success-default': 'none', + 'fill-danger-default': 'none', + 'fill-primary-hover': 'none', + 'fill-secondary-hover': 'none', + 'fill-success-hover': 'none', + 'fill-danger-hover': 'none', + 'fill-primary-press': 'none', + 'fill-secondary-press': 'none', + 'fill-success-press': 'none', + 'fill-danger-press': 'none', + 'outlined-primary-default': `inset 0 0 0 1px ${theme.colors.white}`, + 'outlined-secondary-default': `inset 0 0 0 1px ${theme.colors.white}`, + 'outlined-success-default': `inset 0 0 0 1px ${theme.colors.success}`, + 'outlined-danger-default': `inset 0 0 0 1px ${theme.colors.error}`, + 'outlined-primary-hover': `inset 0 0 0 1px ${theme.colors.white}`, + 'outlined-secondary-hover': `inset 0 0 0 1px ${theme.colors.white}`, + 'outlined-success-hover': `inset 0 0 0 1px ${theme.colors.blue500}`, + 'outlined-danger-hover': `inset 0 0 0 1px ${theme.colors.red500}`, + 'outlined-primary-press': `inset 0 0 0 1px ${theme.colors.white}`, + 'outlined-secondary-press': `inset 0 0 0 1px ${theme.colors.white}`, + 'outlined-success-press': `inset 0 0 0 1px ${theme.colors.blue600}`, + 'outlined-danger-press': `inset 0 0 0 1px ${theme.colors.red600}`, + 'text-primary-default': 'none', + 'text-secondary-default': 'none', + 'text-success-default': 'none', + 'text-danger-default': 'none', + 'text-primary-hover': `inset 0 -0.8px 0 0 ${theme.colors.gray50}`, + 'text-secondary-hover': `inset 0 -0.8px 0 0 ${theme.colors.gray50}`, + 'text-success-hover': `inset 0 -0.8px 0 0 ${theme.colors.gray50}`, + 'text-danger-hover': `inset 0 -0.8px 0 0 ${theme.colors.gray50}`, + 'text-primary-press': `inset 0 -0.8px 0 0 ${theme.colors.gray100}`, + 'text-secondary-press': `inset 0 -0.8px 0 0 ${theme.colors.gray100}`, + 'text-success-press': `inset 0 -0.8px 0 0 ${theme.colors.gray100}`, + 'text-danger-press': `inset 0 -0.8px 0 0 ${theme.colors.gray100}`, + 'floating-primary-default': 'none', + 'floating-secondary-default': 'none', + 'floating-success-default': 'none', + 'floating-danger-default': 'none', + 'floating-primary-hover': 'none', + 'floating-secondary-hover': 'none', + 'floating-success-hover': 'none', + 'floating-danger-hover': 'none', + 'floating-primary-press': 'none', + 'floating-secondary-press': 'none', + 'floating-success-press': 'none', + 'floating-danger-press': 'none', }; -export const borderRadiuses: Record = { +export const borderRadiuses: Record = { sm: '8px', md: '10px', lg: '12px', + text: 'none', + floating: '18px', }; -export const paddings: Record = { - sm: '9px 14px', - md: '12px 20px', - lg: '16px 26px', +export const paddings: Record = { + 'sm': '9px 14px', + 'md': '12px 20px', + 'lg': '16px 26px', + 'text': '0 0 4px', + 'floating-default': '10px', + 'floating-extended': '12px 14px', }; export const fontSizes: Record = { @@ -100,8 +177,9 @@ export const fontSizes: Record = { lg: '18px', }; -export const iconSizes: Record = { +export const iconSizes: Record = { sm: 16, md: 20, lg: 24, + floating: 28, }; diff --git a/packages/ui/Button/hooks.ts b/packages/ui/Button/hooks.ts new file mode 100644 index 00000000..db9b66c8 --- /dev/null +++ b/packages/ui/Button/hooks.ts @@ -0,0 +1,54 @@ +import React, { useEffect, useState } from 'react'; +import { ButtonColorTheme, ButtonIntent, ButtonShape } from './types'; + +interface UseResolvedProps { + intent: ButtonIntent; + shape: ButtonShape; + theme?: ButtonColorTheme; + rounded?: 'md' | 'lg'; +} + +export const useResolvedProps = ({ intent, shape, theme, rounded }: UseResolvedProps) => { + const finalIntent = React.useMemo(() => { + if (!theme) return intent; + switch (theme) { + case 'white': + return 'primary'; + case 'black': + return 'secondary'; + case 'blue': + return 'success'; + case 'red': + return 'danger'; + default: + return intent; + } + }, [intent, theme]); + + const finalShape = React.useMemo(() => { + if (!rounded) return shape; + return rounded === 'lg' ? 'pill' : 'rect'; + }, [shape, rounded]); + + return { finalIntent, finalShape }; +}; + +export const useScrollDirection = () => { + const [scrollDirection, setScrollDirection] = useState<'up' | 'down' | null>(null); + + useEffect(() => { + let lastScrollY = window.pageYOffset; + const updateScrollDirection = () => { + const scrollY = window.pageYOffset; + const direction = scrollY > lastScrollY ? 'down' : 'up'; + setScrollDirection(direction); + lastScrollY = scrollY > 0 ? scrollY : 0; + }; + window.addEventListener('scroll', updateScrollDirection); + return () => { + window.removeEventListener('scroll', updateScrollDirection); + }; + }, [scrollDirection]); + + return scrollDirection; +}; diff --git a/packages/ui/Button/style.css.ts b/packages/ui/Button/style.css.ts index d041266a..b238696f 100644 --- a/packages/ui/Button/style.css.ts +++ b/packages/ui/Button/style.css.ts @@ -23,13 +23,23 @@ const sprinkleProperties = defineProperties({ ...bgColors, 'fill-disabled': theme.colors.gray800, 'outlined-disabled': 'transparent', + 'text-disabled': 'transparent', + 'floating-disabled': theme.colors.gray800, + }, + color: { + ...textColors, + 'fill-disabled': theme.colors.gray500, + 'outlined-disabled': theme.colors.gray500, + 'text-disabled': theme.colors.gray500, + 'floating-disabled': theme.colors.gray500, }, - color: { ...textColors, 'fill-disabled': theme.colors.gray500, 'outlined-disabled': theme.colors.gray500 }, borderRadius: { ...borderRadiuses, max: '9999px' }, boxShadow: { ...borders, 'fill-disabled': 'none', 'outlined-disabled': `inset 0 0 0 1px ${theme.colors.gray500}`, + 'text-disabled': 'none', + 'floating-disabled': 'none', }, padding: paddings, fontSize: fontSizes, diff --git a/packages/ui/Button/types.ts b/packages/ui/Button/types.ts index 8e5ff8c1..f698b26e 100644 --- a/packages/ui/Button/types.ts +++ b/packages/ui/Button/types.ts @@ -1,10 +1,13 @@ -export type ButtonVariant = 'fill' | 'outlined'; +export type ButtonVariant = 'fill' | 'outlined' | 'text' | 'floating'; export type ButtonColorTheme = 'white' | 'black' | 'blue' | 'red'; export type ButtonBgColorStatus = 'default' | 'hover' | 'press'; -export type ButtonColorThemeWithStatus = `${ButtonVariant}-${ButtonColorTheme}-${ButtonBgColorStatus}`; +export type ButtonColorThemeWithStatus = `${ButtonVariant}-${ButtonIntent}-${ButtonBgColorStatus}`; export type ButtonRadiusTheme = 'md' | 'lg'; export type ButtonSizeTheme = 'sm' | 'md' | 'lg'; + +export type ButtonIntent = 'primary' | 'secondary' | 'success' | 'danger'; +export type ButtonShape = 'rect' | 'pill'; diff --git a/packages/ui/Button/utils.ts b/packages/ui/Button/utils.ts index 8e4e6989..cf66b103 100644 --- a/packages/ui/Button/utils.ts +++ b/packages/ui/Button/utils.ts @@ -1,33 +1,41 @@ import { sprinkles } from './style.css'; -import type { ButtonColorTheme, ButtonRadiusTheme, ButtonSizeTheme } from './types'; +import type { ButtonIntent, ButtonSizeTheme, ButtonShape, ButtonColorTheme, ButtonVariant } from './types'; function createButtonVariant( - colorTheme: ButtonColorTheme, - radiusTheme: ButtonRadiusTheme, + buttonIntent: ButtonIntent, + radiusTheme: ButtonShape, sizeTheme: ButtonSizeTheme, - variant: 'fill' | 'outlined', + variant: ButtonVariant, + isExpanded: boolean, ) { return sprinkles({ backgroundColor: { - default: `${variant}-${colorTheme}-default`, - hover: `${variant}-${colorTheme}-hover`, - active: `${variant}-${colorTheme}-press`, + default: `${variant}-${buttonIntent}-default`, + hover: `${variant}-${buttonIntent}-hover`, + active: `${variant}-${buttonIntent}-press`, disabled: `${variant}-disabled`, }, color: { - default: `${variant}-${colorTheme}-default`, - hover: `${variant}-${colorTheme}-hover`, - active: `${variant}-${colorTheme}-press`, + default: `${variant}-${buttonIntent}-default`, + hover: `${variant}-${buttonIntent}-hover`, + active: `${variant}-${buttonIntent}-press`, disabled: `${variant}-disabled`, }, boxShadow: { - default: `${variant}-${colorTheme}-default`, - hover: `${variant}-${colorTheme}-hover`, - active: `${variant}-${colorTheme}-press`, + default: `${variant}-${buttonIntent}-default`, + hover: `${variant}-${buttonIntent}-hover`, + active: `${variant}-${buttonIntent}-press`, disabled: `${variant}-disabled`, }, - borderRadius: radiusTheme === 'lg' ? 'max' : sizeTheme, - padding: sizeTheme, + borderRadius: variant === 'text' || variant === 'floating' ? variant : radiusTheme === 'pill' ? 'max' : sizeTheme, + padding: + variant === 'floating' + ? isExpanded + ? 'floating-extended' + : 'floating-default' + : variant === 'text' + ? 'text' + : sizeTheme, fontSize: sizeTheme, }); }