From f24b3f6221f9aceb4d7f3dd5ac938d61ddc560ce Mon Sep 17 00:00:00 2001 From: Junhyuck Ko <56826914+mrbartrns@users.noreply.github.com> Date: Mon, 8 Jan 2024 11:30:23 +0900 Subject: [PATCH] Refactor Button and Button Storybook (#197) * [Feat] Add Button disabled option * [Feat] create new spinner * [Docs] Edit Button Storybook --- .../common/Button.new/Button.stories.tsx | 97 +++++++++++- src/components/common/Button.new/Button.tsx | 144 +++++++++++++++--- src/components/common/Spinner/Spinner.ts | 26 ++++ src/components/common/Spinner/index.ts | 3 + src/components/icons/LoadingSpinner.tsx | 36 +++++ 5 files changed, 281 insertions(+), 25 deletions(-) create mode 100644 src/components/common/Spinner/Spinner.ts create mode 100644 src/components/common/Spinner/index.ts create mode 100644 src/components/icons/LoadingSpinner.tsx diff --git a/src/components/common/Button.new/Button.stories.tsx b/src/components/common/Button.new/Button.stories.tsx index 401419f..efad960 100644 --- a/src/components/common/Button.new/Button.stories.tsx +++ b/src/components/common/Button.new/Button.stories.tsx @@ -1,19 +1,102 @@ +import { RxArrowRight, RxMagnifyingGlass } from 'react-icons/rx'; import Button from './Button'; -import type { StoryObj, StoryFn, Meta } from '@storybook/react'; +import type { StoryObj, Meta } from '@storybook/react'; type ComponentMeta = Meta; -type StoryTemplate = StoryFn; type StoryComponent = StoryObj; export default { title: 'Components/new/Button', component: Button, + tags: ['autodocs'], + argTypes: { + variant: { + options: ['solid', 'soft', 'surface', 'outline', 'ghost'], + control: { + type: 'select', + }, + }, + radius: { + options: ['none', 'small', 'medium', 'large', 'full'], + control: { + type: 'select', + }, + }, + size: { + options: ['sm', 'md', 'lg', 'xl'], + control: { + type: 'select', + }, + }, + color: { + options: ['accent'], + control: { + type: 'select', + }, + }, + loading: { + contorl: { + type: 'boolean', + }, + }, + disabled: { + control: { + type: 'boolean', + }, + }, + onClick: { + action: 'onClick', + }, + }, } as ComponentMeta; -const Template: StoryTemplate = (...args) => ( - -); - export const Default: StoryComponent = { - render: Template, + render: (args) => , + args: { + variant: 'soft', + radius: 'small', + size: 'md', + color: 'accent', + loading: false, + }, +}; + +export const Loading: StoryComponent = { + render: (args) => , + args: { + variant: 'soft', + radius: 'small', + size: 'md', + color: 'accent', + loading: true, + }, +}; + +export const Disabled: StoryComponent = { + render: (args) => , + args: { + variant: 'soft', + radius: 'small', + size: 'md', + color: 'accent', + disabled: true, + }, +}; + +export const WithSideContent: StoryComponent = { + render: (args) => ( + + ), + args: { + variant: 'soft', + radius: 'small', + size: 'md', + color: 'accent', + }, }; diff --git a/src/components/common/Button.new/Button.tsx b/src/components/common/Button.new/Button.tsx index 5fae48e..fb2dec6 100644 --- a/src/components/common/Button.new/Button.tsx +++ b/src/components/common/Button.new/Button.tsx @@ -1,6 +1,7 @@ -import { forwardRef, useCallback } from 'react'; +import React, { forwardRef, useCallback } from 'react'; import classNames from 'classnames'; import styled from 'styled-components'; +import Spinner from '~components/common/Spinner'; import { noop } from '~lib/util/function'; import type { ButtonHTMLAttributes } from 'react'; @@ -11,6 +12,8 @@ interface Props extends ButtonHTMLAttributes { size?: 'sm' | 'md' | 'lg' | 'xl'; color?: 'accent'; loading?: boolean; + leftContent?: React.ReactNode; + rightContent?: React.ReactNode; } // TODO - 모바일 환경에서 hover 효과 변경하기 @@ -29,56 +32,58 @@ const Button = ( onMouseMove = noop, onMouseUp = noop, disabled, + leftContent, + rightContent, ...props }: Props, ref: React.ForwardedRef ) => { const handleClick: React.MouseEventHandler = useCallback( (e) => { - if (!loading) { + if (!disabled) { onClick?.(e); } }, - [loading, onClick] + [disabled, onClick] ); const handleMouseDown: React.MouseEventHandler = useCallback( (e) => { - if (!loading) { + if (!disabled) { onMouseDown?.(e); } }, - [loading, onMouseDown] + [disabled, onMouseDown] ); const handleMouseUp: React.MouseEventHandler = useCallback( (e) => { - if (!loading) { + if (!disabled) { onMouseUp?.(e); } }, - [loading, onMouseUp] + [disabled, onMouseUp] ); const handleMouseEnter: React.MouseEventHandler = useCallback( (e) => { - if (!loading) { + if (!disabled) { onMouseEnter?.(e); } }, - [loading, onMouseEnter] + [disabled, onMouseEnter] ); const handleMouseMove: React.MouseEventHandler = useCallback( (e) => { - if (!loading) { + if (!disabled) { onMouseMove?.(e); } }, - [loading, onMouseMove] + [disabled, onMouseMove] ); const handleMouseLeave: React.MouseEventHandler = @@ -94,10 +99,13 @@ const Button = ( return ( - {children} +
+ +
+
+ {leftContent && ( + {leftContent} + )} + + {children} + + {rightContent && ( + {rightContent} + )} +
); }; @@ -139,12 +161,52 @@ const Container = styled.button` display: inline-flex; justify-content: center; align-items: center; - gap: 8px; + position: relative; user-select: none; vertical-align: top; flex-shrink: 0; font-weight: 500; - transition: color 100ms cubic-bezier(0.075, 0.82, 0.165, 1); + transition: color 200ms cubic-bezier(0.075, 0.82, 0.165, 1); + + .content-wrapper { + display: inline-flex; + justify-content: center; + align-items: center; + gap: 6px; + } + + .inner { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + .spinner-wrapper { + position: absolute; + display: none; + align-items: center; + + .loading-spinner { + width: 20px; + height: 20px; + border: 2px solid; + border-bottom-color: transparent; + } + + &::after { + content: ''; + } + } + + &.button-loading { + .spinner-wrapper { + display: flex; + } + + .inner { + visibility: hidden; + } + } &:disabled { cursor: not-allowed; @@ -175,15 +237,20 @@ const Container = styled.button` background-color: var(--accent-9); color: var(--accent-9-contrast); - &.button:hover:enabled { + &.button:hover:not(:disabled) { background-color: var(--accent-10); } - &.button:active:enabled { + &.button:active:not(:disabled) { background-color: var(--accent-11); } } + &.color--accent-loading { + background-color: var(--accent-a8); + color: var(--accent-9-contrast); + } + &:disabled { color: var(--gray-a8); background-color: var(--gray-a3); @@ -205,6 +272,11 @@ const Container = styled.button` } } + &.color--accent-loading { + background-color: var(--accent-a3); + color: var(--accent-a8); + } + &:disabled { color: var(--gray-a8); background-color: var(--gray-a3); @@ -227,6 +299,12 @@ const Container = styled.button` } } + &.color--accent-loading { + background-color: var(--color-surface-accent); + box-shadow: inset 0 0 0 1px var(--accent-a6); + color: var(--accent-a8); + } + &:disabled { color: var(--gray-a8); box-shadow: inset 0 0 0 1px var(--gray-a6); @@ -248,6 +326,11 @@ const Container = styled.button` } } + &.color--accent-loading { + box-shadow: inset 0 0 0 1px var(--accent-a6); + color: var(--accent-a8); + } + &:disabled { color: var(--gray-a8); box-shadow: inset 0 0 0 1px var(--gray-a7); @@ -268,6 +351,10 @@ const Container = styled.button` } } + &.color--accent-loading { + color: var(--accent-a8); + } + &:disabled { color: var(--gray-a8); background-color: transparent; @@ -282,6 +369,11 @@ const Container = styled.button` font-size: 12px; line-height: 16px; letter-spacing: 0.0025em; + + .loading-spinner { + width: 14px; + height: 14px; + } } &.size--md { @@ -290,6 +382,11 @@ const Container = styled.button` font-size: 14px; line-height: 20px; letter-spacing: 0em; + + .loading-spinner { + width: 18px; + height: 18px; + } } &.size--lg { @@ -298,13 +395,24 @@ const Container = styled.button` font-size: 16px; line-height: 24px; letter-spacing: 0em; + + .loading-spinner { + width: 22px; + height: 22px; + } } &.size--xl { + height: calc(60px * 1); padding: 0 24px; font-size: 18px; line-height: 26px; letter-spacing: -0.0025em; + + .loading-spinner { + width: 24px; + height: 24px; + } } } diff --git a/src/components/common/Spinner/Spinner.ts b/src/components/common/Spinner/Spinner.ts new file mode 100644 index 0000000..cb6e5dd --- /dev/null +++ b/src/components/common/Spinner/Spinner.ts @@ -0,0 +1,26 @@ +import styled from 'styled-components'; + +const Spinner = styled.div` + display: inline-block; + width: 16px; + height: 16px; + border-style: solid; + border-width: 2px; + border-top-color: transparent; + border-right-color: inherit; + border-bottom-color: inherit; + border-left-color: inherit; + border-radius: 50%; + animation: 1s linear 0s infinite normal none running rotation; + + @keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } +`; + +export default Spinner; diff --git a/src/components/common/Spinner/index.ts b/src/components/common/Spinner/index.ts new file mode 100644 index 0000000..0484da0 --- /dev/null +++ b/src/components/common/Spinner/index.ts @@ -0,0 +1,3 @@ +import Spinner from './Spinner'; + +export default Spinner; diff --git a/src/components/icons/LoadingSpinner.tsx b/src/components/icons/LoadingSpinner.tsx new file mode 100644 index 0000000..93f1aae --- /dev/null +++ b/src/components/icons/LoadingSpinner.tsx @@ -0,0 +1,36 @@ +import classNames from 'classnames'; +import styled from 'styled-components'; +import { type HTMLAttributes } from 'react'; + +interface Props extends HTMLAttributes { + size?: 'sm' | 'md' | 'lg' | 'xl'; +} + +const LoadingSpinner = ({ className, ...props }: Props) => { + return ; +}; + +export default LoadingSpinner; + +const Container = styled.span` + &.loader { + display: inline-block; + width: 48px; + height: 48px; + border: 5px solid #fff; + border-bottom-color: transparent; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: rotation 1s linear infinite; + } + + @keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } +`;