diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..475e7bc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 ConceptBe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f12eec2..39765da 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # conceptbe-design-system -컨셉비 디자인 시스템 +[컨셉비](https://github.com/ConceptBe/conceptbe-frontend)에서 사용하는 디자인 시스템입니다. ## 설치 방법 @@ -20,29 +20,38 @@ ConceptBeProvider를 프로젝트 루트에 감싸주세요. ```jsx import { ConceptBeProvider } from 'concept-be-design-system'; +import { createRoot } from 'react-dom/client'; -const App = ({ children }) => { - return {children}; -}; +import App from './App.tsx'; + +createRoot(document.getElementById('root')!).render( + + + +); ``` ## 사용 방법 ### 컴포넌트 -자세한 내용은 [스토리북](https://65a04fca8611ba47d7f8b115-bdybhmnomg.chromatic.com/)에서 확인하세요. +자세한 내용은 [스토리북](https://65a04fca8611ba47d7f8b115-dqgporpvoy.chromatic.com/)에서 확인해 주세요. -Button 컴포넌트는 다음과 같이 사용할 수 있습니다. +예를 들어, Button 컴포넌트는 다음과 같이 사용할 수 있습니다. ```jsx import { Button } from 'concept-be-design-system'; function SomeComponent() { - return ; + return ( + + ); } ``` -Text 컴포넌트는 다음과 같이 사용할 수 있습니다. +예를 들어, Text 컴포넌트는 다음과 같이 사용할 수 있습니다. ```jsx import { Text } from 'concept-be-design-system'; @@ -56,20 +65,27 @@ function SomeComponent() { } ``` -(중략) +이하 생략. ### 훅 -Field 컴포넌트와 useField 훅은 다음과 같이 사용할 수 있습니다. +아래 훅들은 **하나의 Form 당 하나의 훅을 사용하면 됩니다.** Form에서 여러 개의 분리된 상태를 하나의 통합된 상태로 관리하는 것에 목적이 있습니다. 따라서 Field, CheckboxContainer, RadioContainer, Dropdown 컴포넌트는 각각 useField, useCheckbox, useRadio, useDropdown와 같이 사용하도록 설계되어 있습니다. -```ts +만일 해당 훅들을 사용하지 않고 직접 상태 관리를 하고자 한다면, 각 컴포넌트의 item에 해당하는 Input, Textarea, Checkbox, Radio 컴포넌트와 Text 컴포넌트를 조합하여 작성할 수 있습니다. 단, Dropdown 컴포넌트는 설계 구조상 직접 작성을 지원하지 않습니다. + +> Field 컴포넌트와 useField 훅은 다음과 같이 사용할 수 있습니다. + +```tsx import { Field, useField } from 'concept-be-design-system'; +import { post } from 'somewhere'; + +interface Field { + nickName: string; + intro: string; +} function SomeComponent() { - const { fieldValue, fieldErrorValue, onChangeField } = useField<{ - nickName: string; - intro: string; - }>({ + const { fieldValue, fieldErrorValue, onChangeField } = useField({ nickName: '', intro: '', }); @@ -78,14 +94,27 @@ function SomeComponent() { return [ { validateFn: (value: string) => - /[~!@#$%";'^,&*()_+|=>`?:{[\]}]/g.test(value), + /[~!@#$%";'^,&*()_+|=>`?:{[\]}\s]/g.test(value), errorMessage: '사용 불가한 소개입니다.', }, + { + validateFn: (value: string) => value.length < 2, + errorMessage: '최소 두 글자 이상 작성해야 합니다.', + }, ]; }; + const onSubmitForm = (e: React.FormEvent) => { + e.preventDefault(); + + post({ + nickname: fieldValue.nickName, + intro: fieldValue.intro, + }); + }; + return ( -
+ CheckboxContainer 컴포넌트와 useCheckbox 훅은 다음과 같이 사용할 수 있습니다. ```ts import { CheckboxContainer, useCheckbox } from 'concept-be-design-system'; +import { post } from 'somewhere' interface FilterOption { id: number; name: string; checked: boolean; + [key: string]: any; } function SomeComponent() { - const { checkboxValue, onChangeCheckBox } = useCheckbox<{ - goal: FilterOption[]; - skill: FilterOption[]; + const { checkboxValue, selectedCheckboxId, onChangeCheckBox } = useCheckbox<{ + goals: FilterOption[]; + skills: FilterOption[]; }>({ - goal: goalOptions, - skill: skillOptions, + goals: goalsOptions, + skills: skillsOptions, }); + const onSubmitForm = (e: React.FormEvent) => { + e.preventDefault(); + + post({ + goalIds: selectedCheckboxId.goals, + skillIds: selectedCheckboxId.skills, + }) + } + return ( - + RadioContainer 컴포넌트와 useRadio 훅은 다음과 같이 사용할 수 있습니다. -```ts +```tsx import { RadioContainer, useRadio } from 'concept-be-design-system'; +import { post } from 'somewhere'; interface FilterOption { id: number; name: string; checked: boolean; + [key: string]: any; } function SomeComponent() { - const { radioValue, onChangeRadio } = useRadio<{ - collaboration: FilterOption[]; - skill: FilterOption[]; + const { radioValue, selectedRadioName, onChangeRadio } = useRadio<{ + collaborations: FilterOption[]; + mainSkills: FilterOption[]; }>({ - collaboration: collaborationOptions, - mainSkill: mainSkillOptions, + collaborations: collaborationOptions, + mainSkills: mainSkillsOptions, }); + const onSubmitForm = (e: React.FormEvent) => { + e.preventDefault(); + + post({ + collaborationName: selectedRadioName.collaborations, + mainSkillName: selectedRadioName.mainSkills, + }); + }; + return ( - + - ) + ); } ``` -Dropdown 컴포넌트와 useDropdown 훅은 다음과 같이 사용할 수 있습니다. +> Dropdown 컴포넌트와 useDropdown 훅은 다음과 같이 사용할 수 있습니다. -```ts +```tsx import { useEffect } from 'react'; import { Dropdown, useDropdown } from 'concept-be-design-system'; +import { post } from 'somewhere'; function SomeComponent() { const { dropdownValue, onResetDropdown, onClickDropdown } = useDropdown<{ @@ -215,6 +267,15 @@ function SomeComponent() { detail: '', }); + const onSubmitForm = (e: React.FormEvent) => { + e.preventDefault(); + + post({ + region: dropdownValue.region, + detail: dropdownValue.detail, + }); + }; + useEffect(() => { if (dropdownValue.detail !== '') { onResetDropdown('region'); @@ -223,7 +284,7 @@ function SomeComponent() { }, [dropdownValue, onResetDropdown]); return ( -
+ { + onClick={(value: string) => { onClickDropdown(value, 'region'); }} > @@ -244,14 +305,14 @@ function SomeComponent() { {regionOptions.map(({ id, name }) => ( { + onClick={(value: string) => { onClickDropdown(value, 'detail'); }} > @@ -260,13 +321,13 @@ function SomeComponent() { ))} - ) + ); } ``` ## 링크 -- [스토리북](https://65a04fca8611ba47d7f8b115-bdybhmnomg.chromatic.com/) +- [스토리북](https://65a04fca8611ba47d7f8b115-dqgporpvoy.chromatic.com/) ## 기여 diff --git a/package-lock.json b/package-lock.json index 6ba3c4f..f4880d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "concept-be-design-system", - "version": "0.4.12", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "concept-be-design-system", - "version": "0.4.12", + "version": "0.5.1", "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", diff --git a/package.json b/package.json index 3ff6747..0ef9b96 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "concept-be-design-system", "description": "컨셉비 디자인 시스템", - "version": "0.4.12", + "version": "0.5.1", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/src/App.tsx b/src/App.tsx index 9a975ba..f383f24 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { Button, Spacer } from '.'; import Child from './Child'; +import Checkbox from './components/Checkbox/Checkbox'; import CheckboxContainer from './components/CheckboxContainer/CheckboxContainer'; import Dropdown from './components/Dropdown/Dropdown'; import RadioContainer from './components/RadioContainer/RadioContainer'; @@ -73,7 +74,7 @@ const filterOptions2 = [ ]; const App = () => { - const { checkboxValue, onChangeCheckbox } = useCheckbox<{ + const { checkboxValue, selectedCheckboxId, onChangeCheckbox } = useCheckbox<{ goal: FilterOption[]; name: FilterOption[]; oneMore: FilterOption[]; @@ -82,7 +83,7 @@ const App = () => { name: filterSubOptions2, oneMore: filterSubOptions3, }); - const { radioValue, onChangeRadio } = useRadio<{ + const { radioValue, selectedRadioName, onChangeRadio } = useRadio<{ name: FilterOption[]; age: FilterOption[]; }>({ @@ -104,10 +105,15 @@ const App = () => { } }, [dropdownValue, onResetDropdown]); + console.log(selectedCheckboxId, selectedRadioName); + return ( <> {tags.map((tag) => ( - setTags(tags.filter((tag) => tag !== name))}> + setTags(tags.filter((tag) => tag !== name))} + > {tag} ))} @@ -149,6 +155,18 @@ const App = () => { ))}
+ {['A', 'B', 'C'].map((item) => ( + 0.5} + onChange={(e) => { + console.log(e); + }} + /> + ))} + void; +} + +const Alert = ({ + content, + buttonContent = '확인', + isOpen, + onClose, + ...attributes +}: ModalProps) => { + return ( + <> + {isOpen && ( + + + + + {content} + + + + {buttonContent} + + + + + )} + + ); +}; + +export default Alert; + +const Wrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + position: fixed; + height: 100%; + width: 100%; + inset: 0; + z-index: 10; +`; + +const ModalWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + width: 280px; + height: 167px; + border-radius: 14px; + box-shadow: + rgba(0, 0, 0, 0.2) 0px 11px 15px -7px, + rgba(0, 0, 0, 0.14) 0px 24px 38px 3px, + rgba(0, 0, 0, 0.12) 0px 9px 46px 8px; + background-color: #fff; + color: inherit; + z-index: 11; + white-space: pre-wrap; +`; + +const ContentWrapper = styled.div` + width: 149px; + text-align: center; + font-size: ${({ theme }) => theme.font.suit14r.fontSize}px; + font-weight: ${({ theme }) => theme.font.suit14r.fontWeight}; + line-height: 160%; + word-break: keep-all; +`; + +const ButtonWrapper = styled.div` + border-top: ${({ theme }) => `1px solid ${theme.color.l3}`}; + width: 100%; + min-height: 50px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +`; + +const Overlay = styled.div` + position: fixed; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.54); + inset: 0; +`; diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx new file mode 100644 index 0000000..85c7c33 --- /dev/null +++ b/src/components/Checkbox/Checkbox.tsx @@ -0,0 +1,67 @@ +import styled from '@emotion/styled'; +import { HTMLAttributes } from 'react'; + +import { convertCSS } from '../../utils/convertCSS'; + +interface Props extends HTMLAttributes { + value: string; + checked: boolean; + width?: string | number; + height?: string | number; + padding?: string; +} + +const Checkbox = ({ + value, + checked, + width, + height, + padding, + ...attributes +}: Props) => { + return ( + <> + + + {value} + + + ); +}; + +const CheckboxInput = styled.input` + display: none; +`; + +const CheckboxLabel = styled.label>` + width: ${({ width }) => width && convertCSS(width)}; + height: ${({ height }) => height && convertCSS(height)}; + padding: ${({ padding }) => padding || '11px 16px 12px'}; + display: flex; + justify-content: center; + align-items: center; + height: 40px; + box-sizing: border-box; + border: 1px solid ${({ theme }) => theme.color.l2}; + border-radius: 6px; + background-color: ${({ checked, theme }) => + checked ? theme.color.c1 : theme.color.w1}; + color: ${({ checked, theme }) => (checked ? theme.color.w1 : theme.color.b4)}; + font-size: ${({ theme }) => theme.font.suit15m.fontSize}px; + font-weight: ${({ theme }) => theme.font.suit15m.fontWeight}; + cursor: pointer; +`; + +export default Checkbox; diff --git a/src/components/CheckboxContainer/CheckboxContainer.tsx b/src/components/CheckboxContainer/CheckboxContainer.tsx index 0c808d5..54c404a 100644 --- a/src/components/CheckboxContainer/CheckboxContainer.tsx +++ b/src/components/CheckboxContainer/CheckboxContainer.tsx @@ -8,6 +8,7 @@ interface CheckboxOptions { id: number; name: string; checked: boolean; + [key: string]: any; } interface Config { diff --git a/src/components/Confirm/Confirm.tsx b/src/components/Confirm/Confirm.tsx new file mode 100644 index 0000000..3a23d95 --- /dev/null +++ b/src/components/Confirm/Confirm.tsx @@ -0,0 +1,130 @@ +import styled from '@emotion/styled'; + +import Flex from '../Flex/Flex'; +import Text from '../Text/Text'; + +interface ModalProps { + content: string; + closeButtonContent?: string; + confirmButtonContent?: string; + isOpen: boolean; + onClose: () => void; + onConfirm?: () => void; +} + +const Confirm = ({ + content, + closeButtonContent = '취소', + confirmButtonContent = '확인', + isOpen, + onClose, + onConfirm, + ...attributes +}: ModalProps) => { + const onClickConfirm = () => { + if (onConfirm) onConfirm(); + + onClose(); + }; + + return ( + <> + {isOpen && ( + + + + + {content} + + + {closeButtonContent !== '' && ( + + + {closeButtonContent} + + + )} + {confirmButtonContent !== '' && ( + + + {confirmButtonContent} + + + )} + + + + )} + + ); +}; + +export default Confirm; + +const Wrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + position: fixed; + height: 100%; + width: 100%; + inset: 0; + z-index: 10; +`; + +const ModalWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + width: 280px; + height: 167px; + border-radius: 14px; + box-shadow: + rgba(0, 0, 0, 0.2) 0px 11px 15px -7px, + rgba(0, 0, 0, 0.14) 0px 24px 38px 3px, + rgba(0, 0, 0, 0.12) 0px 9px 46px 8px; + background-color: #fff; + color: inherit; + z-index: 11; + white-space: pre-wrap; + word-break: keep-all; +`; + +const ContentWrapper = styled.div` + width: 149px; + text-align: center; + font-size: ${({ theme }) => theme.font.suit14r.fontSize}px; + font-weight: ${({ theme }) => theme.font.suit14r.fontWeight}; + line-height: 160%; +`; + +const ButtonSectionWrapper = styled.div` + border-top: ${({ theme }) => `1px solid ${theme.color.l3}`}; + width: 100%; + min-height: 50px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +`; + +const ButtonWrapper = styled.div<{ location: 'left' | 'right' }>` + border-right: ${({ theme, location }) => + location === 'left' && `1px solid ${theme.color.l3}`}; + width: 50%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +`; + +const Overlay = styled.div` + position: fixed; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.54); + inset: 0; +`; diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index 1f6f356..85dc2b9 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -87,7 +87,6 @@ const ArrowWrapper = styled.div<{ isActive: boolean }>` const Trigger = styled.div<{ disabled?: boolean }>` display: flex; - user-select: none; justify-content: space-between; align-items: center; height: 40px; diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx new file mode 100644 index 0000000..b8f8035 --- /dev/null +++ b/src/components/Input/Input.tsx @@ -0,0 +1,30 @@ +import styled from '@emotion/styled'; +import { HTMLAttributes } from 'react'; + +import { convertCSS } from '../../utils/convertCSS'; + +interface Props extends HTMLAttributes { + width?: string | number; + height?: string | number; + padding?: string; +} + +const Input = styled.input` + box-sizing: border-box; + width: ${({ width }) => (width && convertCSS(width)) || '100%'}; + height: ${({ height }) => (height && convertCSS(height)) || '44px'}; + border-radius: 6px; + padding: ${({ padding }) => padding || '11px 16px'}; + font-size: ${({ theme }) => theme.font.suit14r.fontSize}px; + font-weight: ${({ theme }) => theme.font.suit14r.fontWeight}; + border: 1px solid ${({ theme }) => theme.color.l2}; + outline: none; + color: ${({ theme }) => theme.color.b4}; + background: ${({ theme }) => theme.color.w1}; + + &:focus { + border-color: ${({ theme }) => theme.color.c1}; + } +`; + +export default Input; diff --git a/src/components/Radio/Radio.tsx b/src/components/Radio/Radio.tsx new file mode 100644 index 0000000..368a8c2 --- /dev/null +++ b/src/components/Radio/Radio.tsx @@ -0,0 +1,101 @@ +import styled from '@emotion/styled'; +import { HTMLAttributes } from 'react'; + +import { convertCSS } from '../../utils/convertCSS'; +import Flex from '../Flex/Flex'; + +interface Props extends HTMLAttributes { + value: string; + checked: boolean; + margin?: string; + gap?: string | number; +} + +const Radio = ({ value, checked, margin, gap = 28, ...attributes }: Props) => { + return ( + <> + + + {checked ? ( + + {value} + + ) : ( + + {value} + + )} + + + ); +}; + +export default Radio; + +const RadioInput = styled.input` + display: none; +`; + +const RadioLabel = styled.label>` + margin: ${({ margin }) => margin}; + position: relative; + padding-left: ${({ gap }) => gap && convertCSS(gap)}; + cursor: pointer; + font-size: 15px; + font-weight: 500; + line-height: 22px; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 22px; + height: 22px; + border-radius: 50%; + } + + &::after { + content: ''; + position: absolute; + top: 1px; + left: 1px; + width: 20px; + height: 20px; + border-radius: 50%; + } +`; + +const CheckedLabel = styled(RadioLabel)` + color: ${({ theme }) => theme.color.b4}; + &::before { + background-color: transparent; + border: 1.5px solid ${({ theme }) => theme.color.c1}; + box-sizing: border-box; + } + + &::after { + width: 12px; + height: 12px; + top: 5px; + left: 5px; + background-color: ${({ theme }) => theme.color.c1}; + } +`; + +const UnCheckedLabel = styled(RadioLabel)` + color: ${({ theme }) => theme.color.b4}; + &::before { + background-color: ${({ theme }) => theme.color.l2}; + } + + &::after { + background-color: ${({ theme }) => theme.color.w1}; + } +`; diff --git a/src/components/RadioContainer/RadioContainer.tsx b/src/components/RadioContainer/RadioContainer.tsx index 7db4bab..47d7500 100644 --- a/src/components/RadioContainer/RadioContainer.tsx +++ b/src/components/RadioContainer/RadioContainer.tsx @@ -10,6 +10,7 @@ interface RadioOptions { id: number; name: string; checked: boolean; + [key: string]: any; } interface Props { label: string; diff --git a/src/components/Skeleton/Skeleton.tsx b/src/components/Skeleton/Skeleton.tsx new file mode 100644 index 0000000..5caacbe --- /dev/null +++ b/src/components/Skeleton/Skeleton.tsx @@ -0,0 +1,62 @@ +import { css, keyframes } from '@emotion/react'; +import { ComponentPropsWithoutRef } from 'react'; + +import theme from '../../styles/theme'; + +export interface SkeletonProps extends ComponentPropsWithoutRef<'div'> { + width?: string; + height?: string; + /** + * Skeleton 모양 + * + * @default 'square' + */ + variant?: 'square' | 'circle'; +} + +const Skeleton = ({ + width = '100%', + height = '24px', + variant = 'square', + className = '', + ...attributes +}: SkeletonProps) => { + return ( +
+ ); +}; + +export default Skeleton; + +const skeletonAnimation = keyframes` + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +`; + +const genSkeletonStyling = ( + width: string, + height: string, + variant: 'square' | 'circle', +) => { + return css({ + width, + height: variant === 'square' ? height : width, + borderRadius: variant === 'square' ? '4px' : '50%', + + background: `linear-gradient(-90deg,${theme.color.l2}, ${theme.color.l1}, ${theme.color.l2}, ${theme.color.l1})`, + backgroundSize: '400%', + + animation: `${skeletonAnimation} 5s infinite ease-out`, + }); +}; diff --git a/src/components/Spinner/Spinner.tsx b/src/components/Spinner/Spinner.tsx new file mode 100644 index 0000000..2deb09f --- /dev/null +++ b/src/components/Spinner/Spinner.tsx @@ -0,0 +1,54 @@ +import styled from '@emotion/styled'; + +type Props = { + backdrop?: boolean; +}; + +const Spinner = ({ backdrop = false, ...attributes }: Props) => ( + <> + {backdrop && } + + + + +); + +export default Spinner; + +const Position = styled.div` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +`; + +const SpinnerContainer = styled.div` + width: 24px; + height: 24px; + border: 3px solid rgba(195, 195, 195, 0.6); + border-radius: 50%; + border-top-color: #636767; + animation: spin 900ms linear infinite; + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +`; + +const Backdrop = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + justify-content: center; + z-index: 9; +`; diff --git a/src/components/TabLayout/TabLayout.tsx b/src/components/TabLayout/TabLayout.tsx index ed71414..4d4a39b 100644 --- a/src/components/TabLayout/TabLayout.tsx +++ b/src/components/TabLayout/TabLayout.tsx @@ -82,7 +82,6 @@ const TabBox = styled.div<{ active: boolean }>` justify-content: center; align-items: center; cursor: pointer; - user-select: none; border-bottom: 2px solid ${({ theme, active }) => (active ? theme.color.b : theme.color.w1)}; transition: border 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; diff --git a/src/components/Text/Text.tsx b/src/components/Text/Text.tsx index a779731..7d7ba33 100644 --- a/src/components/Text/Text.tsx +++ b/src/components/Text/Text.tsx @@ -45,7 +45,6 @@ export const Wrapper = styled.span<{ textFont: FontKeyType; }>` display: flex; - user-select: none; flex-direction: row; color: ${({ theme, textColor }) => theme.color[textColor]}; font-size: ${({ theme, textFont }) => theme.font[textFont].fontSize}px; diff --git a/src/components/Textarea/Textarea.tsx b/src/components/Textarea/Textarea.tsx new file mode 100644 index 0000000..4b43fb4 --- /dev/null +++ b/src/components/Textarea/Textarea.tsx @@ -0,0 +1,30 @@ +import styled from '@emotion/styled'; +import { HTMLAttributes } from 'react'; + +import { convertCSS } from '../../utils/convertCSS'; + +interface Props extends HTMLAttributes { + width?: string | number; + height?: string | number; + padding?: string; +} + +const TextArea = styled.textarea` + box-sizing: border-box; + width: ${({ width }) => (width && convertCSS(width)) || '100%'}; + height: ${({ height }) => (height && convertCSS(height)) || '100px'}; + border-radius: 6px; + padding: ${({ padding }) => padding || '11px 16px'}; + font-size: ${({ theme }) => theme.font.suit14r.fontSize}px; + font-weight: ${({ theme }) => theme.font.suit14r.fontWeight}; + border: 1px solid ${({ theme }) => theme.color.l2}; + outline: none; + color: ${({ theme }) => theme.color.b4}; + background: ${({ theme }) => theme.color.w1}; + + &:focus { + border-color: ${({ theme }) => theme.color.c1}; + } +`; + +export default TextArea; diff --git a/src/hooks/useCheckbox.ts b/src/hooks/useCheckbox.ts index 60f227a..763a0c6 100644 --- a/src/hooks/useCheckbox.ts +++ b/src/hooks/useCheckbox.ts @@ -1,9 +1,10 @@ -import { ChangeEvent, useCallback, useState } from 'react'; +import { ChangeEvent, useCallback, useMemo, useState } from 'react'; interface CheckboxItem { id: number; name: string; checked: boolean; + [key: string]: any; } interface Config { @@ -11,10 +12,32 @@ interface Config { maxCount?: number; } +const getSelectedCheckboxValueId = >( + checkboxValue: T, +) => { + const checkboxValueKeys = Object.keys(checkboxValue) as (keyof T)[]; + + const selectedCheckboxValue = checkboxValueKeys.reduce( + (acc, key) => { + acc[key] = checkboxValue[key] + .filter((checkbox) => checkbox.checked) + .map((checkbox) => checkbox.id); + return acc; + }, + new Object() as Record, + ); + + return selectedCheckboxValue; +}; + const useCheckbox = >( initialValue: T, ) => { const [checkboxValue, setCheckboxValue] = useState(initialValue); + const selectedCheckboxId = useMemo( + () => getSelectedCheckboxValueId(checkboxValue), + [checkboxValue], + ); const onResetCheckbox = useCallback((checkboxKey: keyof T) => { setCheckboxValue((prev) => ({ @@ -56,6 +79,8 @@ const useCheckbox = >( return { checkboxValue, + selectedCheckboxId, + setCheckboxValue, onChangeCheckbox, onResetCheckbox, }; diff --git a/src/hooks/useDropdown.ts b/src/hooks/useDropdown.ts index 168cc03..9ee9c82 100644 --- a/src/hooks/useDropdown.ts +++ b/src/hooks/useDropdown.ts @@ -22,6 +22,7 @@ const useDropdown = >(initialValue: T) => { return { dropdownValue, + setDropdownValue, onResetDropdown, onClickDropdown, }; diff --git a/src/hooks/useField.ts b/src/hooks/useField.ts index 6d40808..7195a1c 100644 --- a/src/hooks/useField.ts +++ b/src/hooks/useField.ts @@ -73,6 +73,7 @@ const useField = >(initialValue: T) => { return { fieldValue, fieldErrorValue, + setFieldValue, setFieldErrorValue, onChangeField, }; diff --git a/src/hooks/useRadio.ts b/src/hooks/useRadio.ts index 722d0c8..7ca289f 100644 --- a/src/hooks/useRadio.ts +++ b/src/hooks/useRadio.ts @@ -1,9 +1,10 @@ -import { ChangeEvent, useCallback, useState } from 'react'; +import { ChangeEvent, useCallback, useMemo, useState } from 'react'; interface RadioItem { id: number; name: string; checked: boolean; + [key: string]: any; } interface OnResetRadioProps { @@ -11,8 +12,28 @@ interface OnResetRadioProps { resetId?: number; } +const getSelectedRadioValueName = >( + radioValue: T, +) => { + const radioValueKeys = Object.keys(radioValue) as (keyof T)[]; + + const selectedRadioValue = radioValueKeys.reduce( + (acc, key) => { + acc[key] = radioValue[key].find((radio) => radio.checked)?.name || ''; + return acc; + }, + new Object() as Record, + ); + + return selectedRadioValue; +}; + const useRadio = >(initialValue: T) => { const [radioValue, setRadioValue] = useState(initialValue); + const selectedRadioName = useMemo( + () => getSelectedRadioValueName(radioValue), + [radioValue], + ); const onResetRadio = useCallback( ({ radioKey, resetId }: OnResetRadioProps) => { @@ -44,6 +65,8 @@ const useRadio = >(initialValue: T) => { return { radioValue, + selectedRadioName, + setRadioValue, onChangeRadio, onResetRadio, }; diff --git a/src/index.ts b/src/index.ts index 35c018d..992f7e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,23 +9,31 @@ import PNGIdeaBackground2 from './assets/image/idea_back_2.png'; import PNGIdeaBackground3 from './assets/image/idea_back_3.png'; import PNGIdeaBackground4 from './assets/image/idea_back_4.png'; import PNGIdeaBackground5 from './assets/image/idea_back_5.png'; +import Alert from './components/Alert/Alert'; import Badge from './components/Badge/Badge'; import BottomSheet from './components/BottomSheet/BottomSheet'; import Box from './components/Box/Box'; import Button from './components/Button/Button'; +import Checkbox from './components/Checkbox/Checkbox'; import CheckboxContainer from './components/CheckboxContainer/CheckboxContainer'; +import Confirm from './components/Confirm/Confirm'; import Divider from './components/Divider/Divider'; import Dropdown from './components/Dropdown/Dropdown'; import Field from './components/Field/Field'; import Flex from './components/Flex/Flex'; import Header from './components/Header/Header'; import ImageView from './components/ImageView/ImageView'; +import Input from './components/Input/Input'; import Navigation from './components/Navigation/Navigation'; +import Radio from './components/Radio/Radio'; import RadioContainer from './components/RadioContainer/RadioContainer'; +import Skeleton from './components/Skeleton/Skeleton'; import Spacer from './components/Spacer/Spacer'; +import Spinner from './components/Spinner/Spinner'; import TabLayout from './components/TabLayout/TabLayout'; import Tag from './components/Tag/Tag'; import Text from './components/Text/Text'; +import Textarea from './components/Textarea/Textarea'; import TextDivider from './components/TextDivider/TextDivider'; import ConceptBeProvider from './ConceptBeProvider'; import useCheckbox from './hooks/useCheckbox'; @@ -90,23 +98,31 @@ export { ReactComponent as SVGProfileMessageDots } from './assets/svg/profile/me export { ReactComponent as SVGProfileBookOpen } from './assets/svg/profile/book_open.svg'; export { + Alert, Badge, BottomSheet, Box, Button, + Checkbox, CheckboxContainer, + Confirm, Divider, Dropdown, Field, Flex, Header, ImageView, + Input, Navigation, + Radio, RadioContainer, + Skeleton, Spacer, + Spinner, TabLayout, Tag, Text, + Textarea, TextDivider, ConceptBeProvider, useCheckbox, diff --git a/src/stories/Alert.stories.tsx b/src/stories/Alert.stories.tsx new file mode 100644 index 0000000..c4b057f --- /dev/null +++ b/src/stories/Alert.stories.tsx @@ -0,0 +1,136 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import Alert from '../components/Alert/Alert'; +import { useEffect, useState } from 'react'; +import Button from '../components/Button/Button'; +import Flex from '../components/Flex/Flex'; +import { userEvent, within } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; + +const meta = { + title: 'Components/Alert', + component: Alert, + tags: ['autodocs'], + argTypes: { + content: { + control: 'text', + description: 'Alert 컴포넌트의 내용을 지정합니다.', + }, + buttonContent: { + control: 'text', + description: 'Alert 컴포넌트의 버튼 텍스트를 지정합니다.', + }, + isOpen: { + control: 'boolean', + description: 'Alert 컴포넌트 표시 유무를 설정합니다.', + }, + onClose: { + control: false, + description: 'Alert 컴포넌트를 닫을 때 사용하는 함수입니다.', + }, + }, +} as Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + content: 'Alert 컴포넌트의 내용입니다.', + buttonContent: '확인', + isOpen: false, + }, + render: ({ content, buttonContent, isOpen }) => { + const [isAlertOpen, setIsAlertOpen] = useState(isOpen); + + useEffect(() => { + setIsAlertOpen(isOpen); + }, [isOpen]); + + return ( + <> + + + + +
+ setIsAlertOpen(false)} + data-testid="alert" + /> +
+ + ); + }, +}; + +export const InteractionTest: Story = { + args: { + content: 'Alert 컴포넌트의 내용입니다.', + buttonContent: '확인', + isOpen: false, + }, + render: ({ content, buttonContent, isOpen }) => { + const [isAlertOpen, setIsAlertOpen] = useState(isOpen); + + useEffect(() => { + setIsAlertOpen(isOpen); + }, [isOpen]); + + return ( + <> + + + + +
+ setIsAlertOpen(false)} + data-testid="alert" + /> +
+ + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const openAlertButton = await canvas.findByTestId('button'); + + await userEvent.click(openAlertButton, { + delay: 300, + }); + + const alert = await canvas.findByTestId('alert'); + const alertContent = alert.children[1].children[0].children[0]; + const alertButton = alert.children[1].children[1].children[0]; + + expect(alert).toBeInTheDocument(); + expect(alertContent.innerHTML).toBe('Alert 컴포넌트의 내용입니다.'); + expect(alertButton.innerHTML).toBe('확인'); + + await userEvent.click(alertButton, { + delay: 300, + }); + + expect(alert).not.toBeInTheDocument(); + }, +}; diff --git a/src/stories/Checkbox.stories.tsx b/src/stories/Checkbox.stories.tsx new file mode 100644 index 0000000..32166d7 --- /dev/null +++ b/src/stories/Checkbox.stories.tsx @@ -0,0 +1,70 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import Checkbox from '../components/Checkbox/Checkbox'; +import { useEffect, useState } from 'react'; + +const meta = { + title: 'Components/Checkbox', + component: Checkbox, + tags: ['autodocs'], + argTypes: { + value: { + control: 'text', + description: 'Checkbox 컴포넌트의 내용(value)을 지정합니다.', + }, + checked: { + control: 'boolean', + description: 'Checkbox 컴포넌트의 체크 여부를 지정합니다.', + }, + width: { + control: 'text', + description: 'Checkbox 컴포넌트 넓이를 설정합니다.', + }, + height: { + control: 'text', + description: 'Checkbox 컴포넌트 높이를 설정합니다.', + }, + padding: { + control: 'text', + description: 'Checkbox 컴포넌트 padding을 설정합니다.', + }, + onChange: { + control: false, + description: + 'Checkbox 컴포넌트의 선택 요소를 확인할 수 있는 onChange 함수입니다.', + }, + }, +} as Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + value: '체크박스', + checked: true, + width: 'max-content', + height: '40px', + padding: undefined, + }, + render: ({ value, checked, width, height, padding }) => { + const [isChecked, setIsChecked] = useState(checked); + + useEffect(() => { + setIsChecked(checked); + }, [checked]); + + return ( + <> + setIsChecked(!isChecked)} + /> + + ); + }, +}; diff --git a/src/stories/Confirm.stories.tsx b/src/stories/Confirm.stories.tsx new file mode 100644 index 0000000..d76effa --- /dev/null +++ b/src/stories/Confirm.stories.tsx @@ -0,0 +1,145 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import Confirm from '../components/Confirm/Confirm'; +import { useEffect, useState } from 'react'; +import Button from '../components/Button/Button'; +import Flex from '../components/Flex/Flex'; +import { userEvent, within } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; + +const meta = { + title: 'Components/Confirm', + component: Confirm, + tags: ['autodocs'], + argTypes: { + content: { + control: 'text', + description: 'Confirm 컴포넌트의 내용을 지정합니다.', + }, + closeButtonContent: { + control: 'text', + description: 'Confirm 컴포넌트의 닫기 버튼 텍스트를 지정합니다.', + }, + confirmButtonContent: { + control: 'text', + description: 'Confirm 컴포넌트의 확인 버튼 텍스트를 지정합니다.', + }, + isOpen: { + control: 'boolean', + description: 'Confirm 컴포넌트 표시 유무를 설정합니다.', + }, + onClose: { + control: false, + description: 'Confirm 컴포넌트를 닫을 때 사용하는 함수입니다.', + }, + onConfirm: { + control: false, + description: + 'Confirm 컴포넌트 확인 버튼 클릭 시 추가로 수행해야하는 동작을 지정할 수 있는 함수입니다.', + }, + }, +} as Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + content: 'Confirm 컴포넌트의 내용입니다.', + closeButtonContent: '취소', + confirmButtonContent: '확인', + isOpen: false, + }, + render: ({ content, closeButtonContent, confirmButtonContent, isOpen }) => { + const [isConfirmOpen, setIsConfirmOpen] = useState(isOpen); + + useEffect(() => { + setIsConfirmOpen(isOpen); + }, [isOpen]); + + return ( + <> + + + + +
+ setIsConfirmOpen(false)} + /> +
+ + ); + }, +}; + +export const InteractionTest: Story = { + args: { + content: 'Confirm 컴포넌트의 내용입니다.', + closeButtonContent: '취소', + confirmButtonContent: '확인', + isOpen: false, + }, + render: ({ content, closeButtonContent, confirmButtonContent, isOpen }) => { + const [isConfirmOpen, setIsConfirmOpen] = useState(isOpen); + + useEffect(() => { + setIsConfirmOpen(isOpen); + }, [isOpen]); + + return ( + <> + + + + +
+ setIsConfirmOpen(false)} + data-testid="confirm" + /> +
+ + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const openAlertButton = await canvas.findByTestId('button'); + + await userEvent.click(openAlertButton, { + delay: 300, + }); + + const confirm = await canvas.findByTestId('confirm'); + const confirmContent = confirm.children[1].children[0].children[0]; + const confirmButton = + confirm.children[1].children[1].children[1].children[0]; + + expect(confirm).toBeInTheDocument(); + expect(confirmContent.innerHTML).toBe('Confirm 컴포넌트의 내용입니다.'); + expect(confirmButton.innerHTML).toBe('확인'); + + await userEvent.click(confirmButton, { + delay: 300, + }); + + expect(confirm).not.toBeInTheDocument(); + }, +}; diff --git a/src/stories/ImageView.stories.tsx b/src/stories/ImageView.stories.tsx index 94a84f1..ed8772b 100644 --- a/src/stories/ImageView.stories.tsx +++ b/src/stories/ImageView.stories.tsx @@ -43,6 +43,10 @@ const meta = { control: 'number', description: 'ImageView 컴포넌트의 Border Radius를 설정합니다.', }, + ratio: { + control: 'text', + description: 'ImageView 컴포넌트의 aspect-ratio 값을 지정합니다.', + }, defaultSrc: { control: 'text', description: @@ -73,3 +77,23 @@ export const Default: Story = {
), }; + +export const Error: Story = { + args: { + src: 'error', + alt: '이미지 입니다.', + width: '100%', + height: '100%', + maxWidth: undefined, + maxHeight: undefined, + objectFit: 'cover', + borderRadius: undefined, + defaultSrc: + 'https://upload.wikimedia.org/wikipedia/commons/b/ba/Error-logo.png', + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/src/stories/Input.stories.tsx b/src/stories/Input.stories.tsx new file mode 100644 index 0000000..43a0e59 --- /dev/null +++ b/src/stories/Input.stories.tsx @@ -0,0 +1,67 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import Input from '../components/Input/Input'; +import { ChangeEvent, useEffect, useState } from 'react'; + +const meta = { + title: 'Components/Input', + component: Input, + tags: ['autodocs'], + argTypes: { + width: { + control: 'text', + description: 'Input 컴포넌트의 넓이를 지정합니다.', + }, + height: { + control: 'text', + description: 'Input 컴포넌트의 높이를 지정합니다.', + }, + padding: { + control: 'text', + description: 'Input 컴포넌트 padding을 설정합니다.', + }, + value: { + control: 'text', + description: 'Input 컴포넌트의 텍스트를 나타내는 값입니다.', + }, + onChange: { + control: false, + description: + 'Input 컴포넌트의 value를 변경할 수 있는 onChange 함수입니다.', + }, + }, +} as Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + width: '100%', + height: '44px', + padding: '11px 16px', + value: 'input', + }, + render: ({ value, width, height, padding }) => { + const userInput = value as string; + const [input, setInput] = useState(userInput); + + useEffect(() => { + setInput(userInput); + }, [userInput]); + + return ( + <> + ) => + setInput(e.target.value) + } + /> + + ); + }, +}; diff --git a/src/stories/Radio.stories.tsx b/src/stories/Radio.stories.tsx new file mode 100644 index 0000000..e1bcf9b --- /dev/null +++ b/src/stories/Radio.stories.tsx @@ -0,0 +1,65 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import Radio from '../components/Radio/Radio'; +import { useEffect, useState } from 'react'; + +const meta = { + title: 'Components/Radio', + component: Radio, + tags: ['autodocs'], + argTypes: { + value: { + control: 'text', + description: 'Radio 컴포넌트의 내용(value)을 지정합니다.', + }, + checked: { + control: 'boolean', + description: 'Radio 컴포넌트의 체크 여부를 지정합니다.', + }, + margin: { + control: 'text', + description: 'Radio 컴포넌트 margin을 설정합니다.', + }, + gap: { + control: 'number', + description: + 'Radio 컴포넌트의 버튼과 설명간 여백을 설정합니다. 기본값은 28px입니다.', + }, + onChange: { + control: false, + description: + 'Radio 컴포넌트의 선택 요소를 확인할 수 있는 onChange 함수입니다.', + }, + }, +} as Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + value: '라디오', + checked: true, + margin: undefined, + gap: 28, + }, + render: ({ value, checked, margin, gap }) => { + const [isChecked, setIsChecked] = useState(checked); + + useEffect(() => { + setIsChecked(checked); + }, [checked]); + + return ( + <> + setIsChecked(!isChecked)} + /> + + ); + }, +}; diff --git a/src/stories/Skeleton.stories.tsx b/src/stories/Skeleton.stories.tsx new file mode 100644 index 0000000..93e363f --- /dev/null +++ b/src/stories/Skeleton.stories.tsx @@ -0,0 +1,40 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import Skeleton from '../components/Skeleton/Skeleton'; + +const meta = { + title: 'Components/Skeleton', + component: Skeleton, + tags: ['autodocs'], + argTypes: { + width: { + control: 'text', + description: 'Skeleton 컴포넌트의 width를 지정합니다.', + }, + height: { + control: 'text', + description: 'Skeleton 컴포넌트의 height를 지정합니다.', + }, + variant: { + control: 'inline-radio', + description: + 'Skeleton 컴포넌트의 모양을 설정합니다. 기본값은 square 입니다.', + }, + }, +} as Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + width: '300px', + height: '300px', + variant: 'square', + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/src/stories/Spinner.stories.tsx b/src/stories/Spinner.stories.tsx new file mode 100644 index 0000000..f3cdb4e --- /dev/null +++ b/src/stories/Spinner.stories.tsx @@ -0,0 +1,29 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import Spinner from '../components/Spinner/Spinner'; + +const meta = { + title: 'Components/Spinner', + component: Spinner, + tags: ['autodocs'], + argTypes: { + backdrop: { + control: 'boolean', + description: 'Spinner 컴포넌트의 backdrop 유무를 지정합니다.', + }, + }, +} as Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + backdrop: false, + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/src/stories/Textarea.stories.tsx b/src/stories/Textarea.stories.tsx new file mode 100644 index 0000000..8a2835e --- /dev/null +++ b/src/stories/Textarea.stories.tsx @@ -0,0 +1,67 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import Textarea from '../components/Textarea/Textarea'; +import { ChangeEvent, useEffect, useState } from 'react'; + +const meta = { + title: 'Components/Textarea', + component: Textarea, + tags: ['autodocs'], + argTypes: { + width: { + control: 'text', + description: 'Textarea 컴포넌트의 넓이를 지정합니다.', + }, + height: { + control: 'text', + description: 'Textarea 컴포넌트의 높이를 지정합니다.', + }, + padding: { + control: 'text', + description: 'Textarea 컴포넌트 padding을 설정합니다.', + }, + value: { + control: 'text', + description: 'Textarea 컴포넌트의 텍스트를 나타내는 값입니다.', + }, + onChange: { + control: false, + description: + 'Textarea 컴포넌트의 value를 변경할 수 있는 onChange 함수입니다.', + }, + }, +} as Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + width: '100%', + height: '100px', + padding: '11px 16px', + value: 'textarea', + }, + render: ({ value, width, height, padding }) => { + const userInput = value as string; + const [textarea, setTextarea] = useState(userInput); + + useEffect(() => { + setTextarea(userInput); + }, [userInput]); + + return ( + <> +