diff --git a/frontend/src/components/@common/Input/Input.stories.tsx b/frontend/src/components/@common/Input/Input.stories.tsx new file mode 100644 index 000000000..c46653d5d --- /dev/null +++ b/frontend/src/components/@common/Input/Input.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Input, { InputVariant } from './Input'; +import { StoryContainer, StoryItemContainer, StoryItemTitle } from 'styles/storybook'; +import { Size } from 'constants/components/common'; + +const meta: Meta = { + title: 'common/Input', + component: Input, + args: { + variant: 'outline', + size: 'medium', + labelText: '라벨', + supportingText: '안내 문구는 여기에 나타납니다', + isError: false, + required: true, + placeholder: 'placeholder', + }, + argTypes: { + variant: { + description: '미리 정의해놓은 인풋의 스타일입니다.', + options: Object.values(InputVariant), + control: { type: 'radio' }, + }, + size: { + description: '크기에 따라 padding과 font-size가 바뀝니다.', + options: Object.values(Size), + control: { type: 'radio' }, + }, + labelText: { + description: '라벨 텍스트입니다.', + control: { type: 'text' }, + }, + supportingText: { + description: '인풋 아래에 나타나는 안내 문구 텍스트입니다.', + control: { type: 'text' }, + }, + placeholder: { + description: '인풋 안에 나타나는 placeholder 텍스트입니다.', + control: { type: 'text' }, + }, + isError: { + description: 'Error 상태를 나타냅니다.', + control: { type: 'boolean' }, + }, + required: { + description: 'input의 필수 입력 여부를 나타냅니다.', + control: { type: 'boolean' }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; diff --git a/frontend/src/components/@common/Input/Input.tsx b/frontend/src/components/@common/Input/Input.tsx new file mode 100644 index 000000000..65d0547eb --- /dev/null +++ b/frontend/src/components/@common/Input/Input.tsx @@ -0,0 +1,163 @@ +import type { ComponentPropsWithRef, ForwardedRef, ReactElement } from 'react'; +import { forwardRef, useId } from 'react'; +import { RuleSet, css, styled } from 'styled-components'; +import { Size } from 'types/components/common'; + +export const InputVariant = ['outline', 'filled', 'unstyled', 'underlined'] as const; +export type InputVariant = (typeof InputVariant)[number]; + +type Props = { + size: Size; + labelText: string; + supportingText: string; + variant: InputVariant; + isError: boolean; +} & Omit, 'size'>; + +const Input = ( + { + size = 'large', + labelText, + supportingText, + variant = 'outline', + isError = false, + ...rest + }: Partial, + ref: ForwardedRef, +) => { + const inputId = useId(); + return ( + + {labelText && ( + + {labelText} + + )} + + {supportingText && {supportingText}} + + ); +}; + +export default forwardRef(Input); + +const genVariantStyle = ( + variant: Required['variant'], + isError: Required['isError'], +): RuleSet => { + const styles: Record> = { + outline: css` + ${({ theme }) => css` + border: 1px solid ${isError ? theme.color.red6 : theme.color.gray6}; + outline: 1px solid ${theme.color.gray1}; + + &:focus { + border: 1px solid ${isError ? theme.color.red6 : theme.color.gray6}; + outline: 1px solid ${isError ? theme.color.red6 : theme.color.gray8}; + } + `} + `, + filled: css` + ${({ theme }) => css` + background-color: ${isError ? theme.color.red1 : theme.color.gray4}; + border: 1px solid ${theme.color.gray1}; + outline: 1px solid ${theme.color.gray1}; + + &:focus { + background-color: ${theme.color.gray1}; + outline: 1px solid ${isError ? theme.color.red6 : theme.color.gray8}; + } + `} + `, + unstyled: css` + ${({ theme }) => css` + border: 1px solid ${theme.color.gray1}; + outline: 1px solid ${isError ? theme.color.red6 : theme.color.gray1}; + + &:focus { + outline: 1px solid ${isError ? theme.color.red6 : theme.color.gray8}; + } + `} + `, + underlined: css` + ${({ theme }) => css` + border: 1px solid ${theme.color.gray1}; + border-bottom: 1px solid ${isError ? theme.color.red6 : theme.color.gray6}; + border-radius: 0; + outline: 1px solid ${theme.color.gray1}; + `} + `, + }; + return styles[variant]; +}; + +const genSizeStyle = (size: Required['size']): RuleSet => { + const styles: Record> = { + small: css` + padding: 0.6rem 0.6rem; + font-size: 1.3rem; + `, + medium: css` + padding: 0.8rem 1rem; + font-size: 1.4rem; + `, + large: css` + padding: 1rem 1.2rem; + font-size: 1.5rem; + `, + }; + return styles[size]; +}; + +const S = { + InputContainer: styled.div` + display: flex; + flex-direction: column; + gap: 0.6rem; + font-size: 1.3rem; + `, + + Label: styled.label<{ $required: boolean | undefined; $variant: InputVariant }>` + font-weight: 500; + ${({ $required, theme }) => + $required && + css` + &::after { + content: '*'; + margin-left: 0.2rem; + color: ${theme.color.red6}; + } + `}; + `, + Input: styled.input<{ + $size: Size; + $variant: InputVariant; + $isError: boolean; + }>` + border: none; + border-radius: 4px; + + ${({ $size }) => genSizeStyle($size)}; + ${({ $variant, $isError }) => genVariantStyle($variant, $isError)}; + `, + SupportingText: styled.p<{ $isError: boolean | undefined }>` + color: ${({ $isError, theme }) => ($isError ? theme.color.red6 : theme.color.gray7)}; + `, + Underline: styled.div` + position: absolute; + bottom: 0; + left: 0; + height: 2px; + width: 100%; + background-color: ${({ theme }) => theme.color.primary}; + transform: scaleX(0); + transition: all 0.3s ease; + `, +}; diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts index 788fb9aff..b9ffa1396 100644 --- a/frontend/src/styles/theme.ts +++ b/frontend/src/styles/theme.ts @@ -5,6 +5,17 @@ const color = { tistory: '#FF5A4A', medium: '#000000', + red1: '#fff1f0', + red2: '#ffccc7', + red3: '#ffa39e', + red4: '#ff7875', + red5: '#ff4d4f', + red6: '#f5222d', + red7: '#cf1322', + red8: '#a8071a', + red9: '#820014', + red10: '#5c0011', + gray1: '#ffffff', gray2: '#fafafa', gray3: '#f5f5f5',