From eace26d1edd8186560b8bfab1d374bc59adee317 Mon Sep 17 00:00:00 2001 From: Cla6Shade <111969754+cla6shade@users.noreply.github.com> Date: Fri, 4 Oct 2024 13:55:44 +0900 Subject: [PATCH] Weekly/5th week (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * week5/harugi7 (#14) * chore: install emotion * chore: 폴더 구조 수정 * feat: ESLint에 맞춰 CamelCase로 변환 * feat: Container 컴포넌트 구현 * Base component: Grid, Spacing (#17) * chore: emotion 설치 * chore: storybook 설치 * feat: Grid 컴포넌트 가져오기 - storybook 파일 생성 * feat: Spacing 컴포넌트 가져오기 * fix: Grid eslint 에러 해결 - react/require-default-props 비활성화 * fix: storybook 파일 eslint 해제 * fix: Spacing eslint 에러 해결 * style: 함수 내보내기 코드 수정 * fix: height 처리 및 반응형 스타일링 개선 * refactor: export Grid 및 Spacing 모듈 추가 * fix: eslint 에러 수정 - dist 폴더, stories.tsx 파일 검사 제외 * fix: 빌드 에러 fix --------- Co-authored-by: cla6shade * Base Components (#16) * chore: add support for the css prop * refactor: ignore eslint * refactor: styled 대신 serialized css 사용하도록 수정 * chore: deprecated 패턴과 css prop 관련 eslint 설정 * refactor: index 모듈로 변경 * chore: setup storybook * chore: configure svgr, emotion * chore: add packages * feat: shared components * chore: ignore build info file * feat: avatar * chore: add configuration of the router and query client * refactor: 현재의 폴더 구조에 맞게 컴포넌트 경로 수정 --------- Co-authored-by: harugi7 <132315935+harugi7@users.noreply.github.com> Co-authored-by: Lee Minkyeong <122078277+mingkyeongg@users.noreply.github.com> --- .eslintrc.cjs | 119 +- .gitignore | 3 + .storybook/main.ts | 26 + .storybook/preview.tsx | 22 + index.html | 2 +- package.json | 26 +- src/app/.gitkeep | 0 src/app/main.tsx | 6 - src/assets/icons/default-avatar.svg | 5 + src/assets/icons/eye-off.svg | 1 + src/assets/icons/eye.svg | 1 + src/assets/icons/tag.svg | 11 + src/assets/icons/x.svg | 4 + src/components/avatar/avatar.stories.tsx | 25 + src/components/avatar/index.tsx | 21 + src/components/avatar/useAvatarStyle.ts | 16 + src/components/button/button.stories.tsx | 53 + src/components/button/index.tsx | 31 + src/components/button/useButtonStyle.ts | 93 + src/components/checkbox/checkbox.stories.ts | 19 + src/components/checkbox/index.tsx | 20 + src/components/checkbox/useCheckboxStyle.ts | 21 + src/components/container/index.tsx | 51 + src/components/grid/index.stories.tsx | 60 + src/components/grid/index.tsx | 49 + src/components/input/index.tsx | 95 + src/components/input/input.stories.tsx | 42 + src/components/input/useInputStyle.ts | 54 + src/components/internal/dynamic-icon.tsx | 21 + src/components/label/index.tsx | 19 + src/components/label/useLabelStyle.ts | 19 + src/components/radio/index.tsx | 25 + src/components/radio/radio.stories.tsx | 30 + src/components/radio/useRadioStyle.ts | 45 + src/components/select/index.tsx | 32 + src/components/select/select.stories.tsx | 25 + src/components/select/useSelectStyle.ts | 35 + src/components/spacing/index.stories.tsx | 48 + src/components/spacing/index.tsx | 42 + src/components/switch/index.tsx | 36 + src/components/switch/switch.stories.tsx | 35 + src/components/switch/useSwitchHandler.tsx | 25 + src/components/switch/useSwitchStyle.ts | 59 + src/components/tag/index.tsx | 53 + src/components/tag/tag.stories.tsx | 57 + src/components/tag/useTagStyle.ts | 81 + src/components/text/index.tsx | 67 + src/components/textarea/index.tsx | 49 + src/components/textarea/textarea.stories.tsx | 19 + src/components/textarea/useTextAreaStyle.ts | 25 + src/constants/breakpoints.tsx | 14 + src/entities/.gitkeep | 0 src/hooks/useTheme.ts | 13 + src/main.tsx | 17 + src/providers/BrowserRouterProvider.tsx | 18 + src/providers/PassportProvider.tsx | 30 + src/shared/.gitkeep | 0 src/shared/index.test.ts | 5 - src/styles/colors/index.ts | 41 + src/styles/corners/index.ts | 10 + src/styles/theme/index.ts | 9 + src/types/index.d.ts | 61 + src/utils/index.ts | 24 + src/utils/utils.test.ts | 25 + src/vite-env.d.ts | 3 + src/widgets/.gitkeep | 0 tsconfig.app.json | 13 +- vite.config.ts | 23 +- yarn.lock | 3945 +++++++++++++++++- 69 files changed, 5714 insertions(+), 260 deletions(-) create mode 100644 .storybook/main.ts create mode 100644 .storybook/preview.tsx delete mode 100644 src/app/.gitkeep delete mode 100644 src/app/main.tsx create mode 100644 src/assets/icons/default-avatar.svg create mode 100644 src/assets/icons/eye-off.svg create mode 100644 src/assets/icons/eye.svg create mode 100644 src/assets/icons/tag.svg create mode 100644 src/assets/icons/x.svg create mode 100644 src/components/avatar/avatar.stories.tsx create mode 100644 src/components/avatar/index.tsx create mode 100644 src/components/avatar/useAvatarStyle.ts create mode 100644 src/components/button/button.stories.tsx create mode 100644 src/components/button/index.tsx create mode 100644 src/components/button/useButtonStyle.ts create mode 100644 src/components/checkbox/checkbox.stories.ts create mode 100644 src/components/checkbox/index.tsx create mode 100644 src/components/checkbox/useCheckboxStyle.ts create mode 100644 src/components/container/index.tsx create mode 100644 src/components/grid/index.stories.tsx create mode 100644 src/components/grid/index.tsx create mode 100644 src/components/input/index.tsx create mode 100644 src/components/input/input.stories.tsx create mode 100644 src/components/input/useInputStyle.ts create mode 100644 src/components/internal/dynamic-icon.tsx create mode 100644 src/components/label/index.tsx create mode 100644 src/components/label/useLabelStyle.ts create mode 100644 src/components/radio/index.tsx create mode 100644 src/components/radio/radio.stories.tsx create mode 100644 src/components/radio/useRadioStyle.ts create mode 100644 src/components/select/index.tsx create mode 100644 src/components/select/select.stories.tsx create mode 100644 src/components/select/useSelectStyle.ts create mode 100644 src/components/spacing/index.stories.tsx create mode 100644 src/components/spacing/index.tsx create mode 100644 src/components/switch/index.tsx create mode 100644 src/components/switch/switch.stories.tsx create mode 100644 src/components/switch/useSwitchHandler.tsx create mode 100644 src/components/switch/useSwitchStyle.ts create mode 100644 src/components/tag/index.tsx create mode 100644 src/components/tag/tag.stories.tsx create mode 100644 src/components/tag/useTagStyle.ts create mode 100644 src/components/text/index.tsx create mode 100644 src/components/textarea/index.tsx create mode 100644 src/components/textarea/textarea.stories.tsx create mode 100644 src/components/textarea/useTextAreaStyle.ts create mode 100644 src/constants/breakpoints.tsx delete mode 100644 src/entities/.gitkeep create mode 100644 src/hooks/useTheme.ts create mode 100644 src/main.tsx create mode 100644 src/providers/BrowserRouterProvider.tsx create mode 100644 src/providers/PassportProvider.tsx delete mode 100644 src/shared/.gitkeep delete mode 100644 src/shared/index.test.ts create mode 100644 src/styles/colors/index.ts create mode 100644 src/styles/corners/index.ts create mode 100644 src/styles/theme/index.ts create mode 100644 src/types/index.d.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/utils.test.ts delete mode 100644 src/widgets/.gitkeep diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 2e702e5..ea9dd1d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,60 +1,59 @@ -module.exports = { - "env": { - "browser": true, - "es2021": true - }, - "extends": [ - "airbnb", "airbnb/hooks", "airbnb-typescript", "@feature-sliced" - ], - "ignorePatterns": ["*.pnp.*", "*.config.ts", "node_modules", ".yarn", ".eslintrc.cjs"], - "overrides": [ - { - "env": { - "node": true - }, - "files": [ - ".eslintrc.{js,cjs}" - ], - "parserOptions": { - "sourceType": "script" - } - } - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module", - "project": "./tsconfig.app.json" - }, - "plugins": [ - "@typescript-eslint", - "react" - ], - "settings": { - "import/resolver": { - "typescript": { - "alwaysTryTypes": true - } - }, - }, - "rules": { - "react/react-in-jsx-scope": 'off', - 'import/extensions': [ - 'error', - 'ignorePackages', - { - '': 'never', - 'js': 'never', - 'jsx': 'never', - 'ts': 'never', - 'tsx': 'never' - } - ], - 'import/no-internal-modules': [ - 'error', - { - forbid: ["@*/**/*"], - }, - ], - } -} +module.exports = { + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "airbnb", + "airbnb/hooks", + "airbnb-typescript", + "plugin:storybook/recommended" + ], + "ignorePatterns": ["*.pnp.*", "*.config.ts", "node_modules", ".yarn", ".eslintrc.cjs", "dist/**", "**/*.stories.tsx"], + "overrides": [ + { + "env": { + "node": true + }, + "files": [ + ".eslintrc.{js,cjs}" + ], + "parserOptions": { + "sourceType": "script" + } + } + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module", + "project": "./tsconfig.app.json" + }, + "plugins": [ + "@typescript-eslint", + "react" + ], + "rules": { + "react/react-in-jsx-scope": 'off', + 'import/extensions': [ + 'error', + 'ignorePackages', + { + '': 'never', + 'js': 'never', + 'jsx': 'never', + 'ts': 'never', + 'tsx': 'never' + } + ], + 'react/require-default-props': 'off', + 'react/no-unknown-property': [ + 'error', + { + ignore: ['css'], + } + ], + 'react/jsx-props-no-spreading': 'off', + '@typescript-eslint/no-use-before-define': 'off' + }, +} diff --git a/.gitignore b/.gitignore index 1b64202..bac712f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* +*.tsbuildinfo node_modules dist @@ -24,3 +25,5 @@ dist-ssr *.njsproj *.sln *.sw? + +*storybook.log diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..89752a5 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,26 @@ +import type { StorybookConfig } from "@storybook/react-vite"; + +import { join, dirname } from "path"; + +/** + * This function is used to resolve the absolute path of a package. + * It is needed in projects that use Yarn PnP or are set up within a monorepo. + */ +function getAbsolutePath(value: string): any { + return dirname(require.resolve(join(value, "package.json"))); +} +const config: StorybookConfig = { + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: [ + getAbsolutePath("@storybook/addon-onboarding"), + getAbsolutePath("@storybook/addon-links"), + getAbsolutePath("@storybook/addon-essentials"), + getAbsolutePath("@chromatic-com/storybook"), + getAbsolutePath("@storybook/addon-interactions"), + ], + framework: { + name: getAbsolutePath("@storybook/react-vite"), + options: {}, + }, +}; +export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000..111ab37 --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,22 @@ +import type { Preview } from "@storybook/react"; +import PassportProvider from '../src/providers/PassportProvider'; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, + decorators: [ + (Story) => ( + + + + ) + ], +}; + +export default preview; diff --git a/index.html b/index.html index b8bd5f2..fcb3ac2 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,6 @@
- + diff --git a/package.json b/package.json index 3ddfcdf..88aafb6 100644 --- a/package.json +++ b/package.json @@ -10,15 +10,32 @@ "preview": "vite preview", "test": "vitest", "test:ci": "vitest run", - "ci": "yarn lint && yarn test:ci && yarn build" + "ci": "yarn lint && yarn test:ci && yarn build", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "dependencies": { + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "@tanstack/react-query": "^5.59.0", + "emotion-normalize": "^11.0.1", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" }, "devDependencies": { + "@chromatic-com/storybook": "1.9.0", + "@emotion/babel-plugin": "^11.12.0", "@eslint/js": "^9.9.0", "@feature-sliced/eslint-config": "^0.1.1", + "@storybook/addon-essentials": "8.3.4", + "@storybook/addon-interactions": "8.3.4", + "@storybook/addon-links": "8.3.4", + "@storybook/addon-onboarding": "8.3.4", + "@storybook/blocks": "8.3.4", + "@storybook/react": "8.3.4", + "@storybook/react-vite": "8.3.4", + "@storybook/test": "8.3.4", "@types/eslint-plugin-jsx-a11y": "^6", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -35,12 +52,15 @@ "eslint-plugin-react": "^7.36.1", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.9", + "eslint-plugin-storybook": "^0.9.0", "globals": "^15.9.0", + "storybook": "8.3.4", "typescript": "^5.5.3", "typescript-eslint": "^8.0.1", "vite": "^5.4.1", + "vite-plugin-svgr": "^4.2.0", "vite-tsconfig-paths": "^5.0.1", "vitest": "^2.1.1" }, - "packageManager": "yarn@4.5.0+sha512.837566d24eec14ec0f5f1411adb544e892b3454255e61fdef8fd05f3429480102806bac7446bc9daff3896b01ae4b62d00096c7e989f1596f2af10b927532f39" + "packageManager": "yarn@4.5.0" } diff --git a/src/app/.gitkeep b/src/app/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/main.tsx b/src/app/main.tsx deleted file mode 100644 index 535c5e5..0000000 --- a/src/app/main.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; - -createRoot(document.getElementById('root')!).render( - , -); diff --git a/src/assets/icons/default-avatar.svg b/src/assets/icons/default-avatar.svg new file mode 100644 index 0000000..430421d --- /dev/null +++ b/src/assets/icons/default-avatar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/eye-off.svg b/src/assets/icons/eye-off.svg new file mode 100644 index 0000000..4c7f02c --- /dev/null +++ b/src/assets/icons/eye-off.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/eye.svg b/src/assets/icons/eye.svg new file mode 100644 index 0000000..8e8e994 --- /dev/null +++ b/src/assets/icons/eye.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/tag.svg b/src/assets/icons/tag.svg new file mode 100644 index 0000000..f334bfc --- /dev/null +++ b/src/assets/icons/tag.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/x.svg b/src/assets/icons/x.svg new file mode 100644 index 0000000..5438edc --- /dev/null +++ b/src/assets/icons/x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/avatar/avatar.stories.tsx b/src/components/avatar/avatar.stories.tsx new file mode 100644 index 0000000..2b62b9b --- /dev/null +++ b/src/components/avatar/avatar.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Avatar from '@components/avatar'; + +const meta: Meta = { + title: 'Components/Avatar', + component: Avatar, + argTypes: { + css: { control: 'object' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const AvatarImage: Story = { + args: { + src: 'https://picsum.photos/200', + }, +}; + +export const DefaultAvatar: Story = { + args: { + }, +}; diff --git a/src/components/avatar/index.tsx b/src/components/avatar/index.tsx new file mode 100644 index 0000000..f2574ee --- /dev/null +++ b/src/components/avatar/index.tsx @@ -0,0 +1,21 @@ +import { ImgHTMLAttributes } from 'react'; +import useAvatarStyle from '@components/avatar/useAvatarStyle'; +import { CSSObject } from '@emotion/react'; +import defaultAvatar from '@assets/icons/default-avatar.svg'; + +interface AvatarProps extends ImgHTMLAttributes { + src?: string; + alt?: string; + css?: CSSObject; +} + +function Avatar({ + src, alt, css, ...rest +}: AvatarProps) { + const { avatarStyle } = useAvatarStyle(); + return ( + {alt + ); +} + +export default Avatar; diff --git a/src/components/avatar/useAvatarStyle.ts b/src/components/avatar/useAvatarStyle.ts new file mode 100644 index 0000000..a04ffd5 --- /dev/null +++ b/src/components/avatar/useAvatarStyle.ts @@ -0,0 +1,16 @@ +import useTheme from '@hooks/useTheme'; +import { css } from '@emotion/react'; + +function useAvatarStyle() { + const theme = useTheme(); + + const avatarStyle = css` + border-radius: ${theme.corners.round}; + width: 60px; + height: 60px; + `; + + return { avatarStyle }; +} + +export default useAvatarStyle; diff --git a/src/components/button/button.stories.tsx b/src/components/button/button.stories.tsx new file mode 100644 index 0000000..349c37c --- /dev/null +++ b/src/components/button/button.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Button from '@components/button'; +import icon from '@assets/icons/eye.svg'; + +const meta: Meta = { + title: 'Components/Button', + component: Button, + argTypes: { + + buttonTheme: { + control: 'radio', + options: ['default', 'dark', 'light-outlined'], + }, + children: { + control: 'text', + defaultValue: 'Button Text', + }, + css: { control: 'object' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + buttonTheme: 'default', + children: 'Default Button', + }, +}; + +export const WithIcon: Story = { + args: { + buttonTheme: 'dark', + children: 'Dark Button', + icon, + }, +}; + +export const CustomStyled: Story = { + args: { + buttonTheme: 'default', + children: 'Custom Styled Button', + css: { + backgroundColor: 'lightblue', + padding: '10px 20px', + borderRadius: '5px', + border: 'none', + cursor: 'pointer', + }, + }, +}; diff --git a/src/components/button/index.tsx b/src/components/button/index.tsx new file mode 100644 index 0000000..528c587 --- /dev/null +++ b/src/components/button/index.tsx @@ -0,0 +1,31 @@ +import { ButtonHTMLAttributes, forwardRef, ReactNode } from 'react'; +import { CSSObject } from '@emotion/react'; +import DynamicIcon from '@components/internal/dynamic-icon'; +import useButtonStyle from '@components/button/useButtonStyle'; +import { ButtonTheme } from '@/types'; + +export interface ButtonProps extends ButtonHTMLAttributes { + css?: CSSObject; + buttonTheme?: ButtonTheme; + icon?: ReactNode | string; +} + +const Button = forwardRef(({ + children, buttonTheme = 'default', css, icon, ...rest +}: ButtonProps, ref) => { + const { buttonStyle, buttonIconStyle } = useButtonStyle({ buttonTheme }); + + return ( + + ); +}); + +export default Button; diff --git a/src/components/button/useButtonStyle.ts b/src/components/button/useButtonStyle.ts new file mode 100644 index 0000000..05a0875 --- /dev/null +++ b/src/components/button/useButtonStyle.ts @@ -0,0 +1,93 @@ +import useTheme from '@hooks/useTheme'; +import { css } from '@emotion/react'; +import { ButtonTheme } from '@/types'; + +interface UseButtonStyleProps { + buttonTheme?: ButtonTheme; +} + +function useButtonStyle({ buttonTheme = 'default' }: UseButtonStyleProps) { + const globalTheme = useTheme(); + + const buttonStyle = css` + display: flex; + align-items: center; + justify-content: center; + outline: none; + padding: 10px 18px; + border-radius: 100px; + color: ${buttonTheme === 'dark' ? globalTheme.colors.primary.main : globalTheme.colors.text.prominent}; + border: ${getBorderStyle()}; + background-color: ${getBackgroundColor()}; + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; + cursor: pointer; + gap: 5px; + &:hover { + background-color: ${getHoverBackgroundColor()}; + color: ${getHoverColor()}; + border-color: ${getHoverBorderColor()}; + } + `; + + const buttonIconStyle = css` + width: 16px; + height: 16px; + `; + + function getBackgroundColor() { + if (buttonTheme === 'light-outlined') { + return 'transparent'; + } + + if (buttonTheme === 'dark') { + return globalTheme.colors.text.prominent; + } + + return globalTheme.colors.background.main; + } + + function getBorderStyle() { + if (buttonTheme === 'light-outlined') { + return `2px solid ${globalTheme.colors.absolute.black}`; + } + + const baseStyle = '1px solid '; + + return baseStyle + (buttonTheme === 'dark' ? 'transparent' : globalTheme.colors.text.subtle); + } + + function getHoverBackgroundColor() { + if (buttonTheme === 'light-outlined') { + return globalTheme.colors.text.prominent; + } + + if (buttonTheme === 'dark') { + return globalTheme.colors.primary.main; + } + + return globalTheme.colors.background.darken; + } + + function getHoverColor() { + if (buttonTheme === 'light-outlined') { + return globalTheme.colors.background.main; + } + + return globalTheme.colors.text.prominent; + } + + function getHoverBorderColor() { + if (buttonTheme === 'light-outlined') { + return globalTheme.colors.absolute.black; + } + + return buttonTheme === 'dark' ? globalTheme.colors.primary.main : globalTheme.colors.border.prominent; + } + + return { + buttonStyle, + buttonIconStyle, + }; +} + +export default useButtonStyle; diff --git a/src/components/checkbox/checkbox.stories.ts b/src/components/checkbox/checkbox.stories.ts new file mode 100644 index 0000000..f254828 --- /dev/null +++ b/src/components/checkbox/checkbox.stories.ts @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Checkbox from '@components/checkbox'; + +const meta: Meta = { + title: 'Components/Checkbox', + component: Checkbox, + argTypes: { + css: { control: 'object' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + }, +}; diff --git a/src/components/checkbox/index.tsx b/src/components/checkbox/index.tsx new file mode 100644 index 0000000..be0603e --- /dev/null +++ b/src/components/checkbox/index.tsx @@ -0,0 +1,20 @@ +import { InputHTMLAttributes } from 'react'; +import { CSSObject } from '@emotion/react'; +import useCheckboxStyle from '@components/checkbox/useCheckboxStyle'; + +interface RadioProps extends InputHTMLAttributes { + type?: 'checkbox'; + css?: CSSObject; + defaultChecked?: boolean; + checked?: boolean; +} + +function Radio({ type = 'checkbox', ...rest }: RadioProps) { + const { checkboxStyle } = useCheckboxStyle(); + + return ( + + ); +} + +export default Radio; diff --git a/src/components/checkbox/useCheckboxStyle.ts b/src/components/checkbox/useCheckboxStyle.ts new file mode 100644 index 0000000..83e36b7 --- /dev/null +++ b/src/components/checkbox/useCheckboxStyle.ts @@ -0,0 +1,21 @@ +import useTheme from '@hooks/useTheme'; +import { css } from '@emotion/react'; + +function useCheckboxStyle() { + const theme = useTheme(); + + const checkboxStyle = ( + css` + width: 18px; + height: 18px; + accent-color: ${theme.colors.primary.main}; + border: 2px solid ${theme.colors.border.prominent}; + ` + ); + + return { + checkboxStyle, + }; +} + +export default useCheckboxStyle; diff --git a/src/components/container/index.tsx b/src/components/container/index.tsx new file mode 100644 index 0000000..0a1f5c8 --- /dev/null +++ b/src/components/container/index.tsx @@ -0,0 +1,51 @@ +import { ReactNode } from 'react'; +import { css } from '@emotion/react'; + +export interface ContainerProps { + children?: ReactNode; + direction?: 'row' | 'column'; + justify?: 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly'; + align?: 'stretch' | 'flex-start' | 'center' | 'flex-end'; + width?: string; + height?: string; + gap?: string; +} + +function StyledContainer({ + children, direction, justify, align, width, height, gap, +}: ContainerProps) { + const style = css` + display: flex; + flex-direction: ${direction || 'column'}; + justify-content: ${justify || 'center'}; + align-items: ${align || 'center'}; + width: ${width || '100%'}; + height: ${height || 'auto'}; + gap: ${gap || '0'}; + `; + + return ( +
+ {children} +
+ ); +} + +function Container({ + children, direction, justify, align, width, height, gap, +}: ContainerProps) { + return ( + + {children} + + ); +} + +export default Container; diff --git a/src/components/grid/index.stories.tsx b/src/components/grid/index.stories.tsx new file mode 100644 index 0000000..05efff9 --- /dev/null +++ b/src/components/grid/index.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Grid from '.'; + +/* eslint-disable */ + +function ChildComponent() { + return ( + <> +
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+ + ); +} + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta = { + title: 'Common/Layout/Grid', + component: Grid, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const NumberColumns: Story = { + args: { + gap: 0, + columns: 3, + children: , + }, +}; + +export const ResponsiveColumns: Story = { + args: { + gap: 0, + columns: { + initial: 1, + sm: 2, + md: 3, + lg: 4, + }, + children: , + }, +}; diff --git a/src/components/grid/index.tsx b/src/components/grid/index.tsx new file mode 100644 index 0000000..d6005b6 --- /dev/null +++ b/src/components/grid/index.tsx @@ -0,0 +1,49 @@ +import styled from '@emotion/styled'; + +const vars = { + initial: '0', + xs: '520px', + sm: '768px', + md: '1024px', + lg: '1280px', +}; + +type ResponseGridStyle = { + // eslint-disable-next-line + [key in keyof typeof vars]?: number; +}; + +type Props = { + columns: number | ResponseGridStyle; + gap?: number; +} & React.HTMLAttributes; + +const Wrapper = styled.div>( + { + width: '100%', + display: 'grid', + }, + ({ gap }: { gap?: number }) => ({ + gap: gap ? `${gap}px` : '0', + }), + ({ columns }: { columns: number | ResponseGridStyle }) => { + if (typeof columns === 'number') { + return { + gridTemplateColumns: `repeat(${columns}, 1fr)`, + }; + } + + const breakpoints = Object.keys(columns) as (keyof typeof vars)[]; + return breakpoints + .map((breakpoint) => `@media screen and (min-width: ${vars[breakpoint]}) { grid-template-columns: repeat(${columns[breakpoint]}, 1fr); }`) + .join(' '); + }, +); + +export default function Grid({ children, columns, ...props }: Props): JSX.Element { + return ( + + {children} + + ); +} diff --git a/src/components/input/index.tsx b/src/components/input/index.tsx new file mode 100644 index 0000000..cec5d78 --- /dev/null +++ b/src/components/input/index.tsx @@ -0,0 +1,95 @@ +import { + Dispatch, + forwardRef, + HTMLInputTypeAttribute, + InputHTMLAttributes, + ReactNode, + SetStateAction, + useRef, + useState, +} from 'react'; +import { CSSObject } from '@emotion/react'; +import toggleShowIcon from '@assets/icons/eye.svg'; +import toggleHideIcon from '@assets/icons/eye-off.svg'; +import DynamicIcon from '@components/internal/dynamic-icon'; +import useInputStyle from '@components/input/useInputStyle'; +import Label from '@components/label'; +import { generateRandomId } from '@/utils'; + +interface InputProps extends InputHTMLAttributes { + icon?: string | ReactNode; + enableToggleShow?: boolean; + type: HTMLInputTypeAttribute; + label?: string; + css?: CSSObject; +} + +const Input = forwardRef(({ + icon, enableToggleShow, type, label, css, ...rest +}, ref) => { + const inputId = useRef(generateRandomId()); + const [isHidden, setIsHidden] = useState(true); + const { + inputStyle, + inputContainerStyle, + inputIconStyle, + } = useInputStyle({ enableToggleShow, icon }); + + if (enableToggleShow && type !== 'password') { + throw new Error('Cannot enable toggle while the type of input is not password'); + } + + return ( + <> + { + label + ? ( + + ) + : null + } +
+ + + { + enableToggleShow + ? + : null + } +
+ + ); +}); + +interface ToggleVisibilityIconProps { + isHidden: boolean; + setIsHidden: Dispatch> +} + +function ToggleVisibilityIcon({ + isHidden, + setIsHidden, +}: ToggleVisibilityIconProps) { + const { inputIconStyle } = useInputStyle({ enableToggleShow: true }); + + return ( + toggle show setIsHidden((prevState: boolean) => !prevState)} + role="presentation" + /> + ); +} + +export default Input; diff --git a/src/components/input/input.stories.tsx b/src/components/input/input.stories.tsx new file mode 100644 index 0000000..5874095 --- /dev/null +++ b/src/components/input/input.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Input from '@components/input'; + +const meta: Meta = { + title: 'Components/Input', + component: Input, + argTypes: { + css: { control: 'object' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + enableToggleShow: true, + type: 'password', + }, +}; + +export const InputWithLabel: Story = { + args: { + type: 'text', + label: 'label', + }, +}; + +export const PasswordWithLabel: Story = { + args: { + enableToggleShow: true, + type: 'password', + label: '비밀번호', + }, +}; + +export const DisabledInput: Story = { + args: { + disabled: true, + }, +}; diff --git a/src/components/input/useInputStyle.ts b/src/components/input/useInputStyle.ts new file mode 100644 index 0000000..4649531 --- /dev/null +++ b/src/components/input/useInputStyle.ts @@ -0,0 +1,54 @@ +import useTheme from '@hooks/useTheme'; +import { css } from '@emotion/react'; +import { ReactNode } from 'react'; + +interface UseInputStyleProps { + enableToggleShow?: boolean; + icon?: string | ReactNode; +} + +function useInputStyle({ enableToggleShow, icon }: UseInputStyleProps) { + const theme = useTheme(); + + const inputStyle = ( + css` + padding: 10px ${enableToggleShow ? '34px' : '10px'} 10px ${icon ? '34px' : '10px'}; + border-radius: ${theme.corners.small}; + border: 1px solid ${theme.colors.border.subtle}; + width: 100%; + box-sizing: border-box; + font-size: 15px; + + &:focus { + outline: none; + border: 1px solid ${theme.colors.border.prominent} + } + ` + ); + + function inputIconStyle(isToggleShowIcon?: boolean) { + return ( + css` + position: absolute; + left: ${isToggleShowIcon ? 'auto' : '10px'}; + right: ${isToggleShowIcon ? '10px' : 'auto'}; + top: 10px; + width: 16px; + height: 16px; + cursor: ${isToggleShowIcon ? 'pointer' : 'default'}; + ` + ); + } + + const inputContainerStyle = css` + position: relative; + `; + + return { + inputStyle, + inputContainerStyle, + inputIconStyle, + }; +} + +export default useInputStyle; diff --git a/src/components/internal/dynamic-icon.tsx b/src/components/internal/dynamic-icon.tsx new file mode 100644 index 0000000..71f4d0a --- /dev/null +++ b/src/components/internal/dynamic-icon.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from 'react'; +import { CSSObject } from '@emotion/react'; + +interface IconProps { + icon?: string | ReactNode; + css?: CSSObject; +} + +function DynamicIcon({ icon, css }: IconProps) { + if (!icon) { + return null; + } + + if (typeof icon === 'string') { + return button icon; + } + + return icon; +} + +export default DynamicIcon; diff --git a/src/components/label/index.tsx b/src/components/label/index.tsx new file mode 100644 index 0000000..372cdd2 --- /dev/null +++ b/src/components/label/index.tsx @@ -0,0 +1,19 @@ +import useLabelStyle from '@components/label/useLabelStyle'; +import { LabelHTMLAttributes, ReactNode } from 'react'; + +interface LabelProps extends LabelHTMLAttributes { + children?: ReactNode; +} + +function Label({ children, ...rest }: LabelProps) { + const { labelStyle } = useLabelStyle(); + + return ( + // eslint-disable-next-line + + ); +} + +export default Label; diff --git a/src/components/label/useLabelStyle.ts b/src/components/label/useLabelStyle.ts new file mode 100644 index 0000000..67ef90d --- /dev/null +++ b/src/components/label/useLabelStyle.ts @@ -0,0 +1,19 @@ +import useTheme from '@hooks/useTheme'; +import { css } from '@emotion/react'; + +function useLabelStyle() { + const theme = useTheme(); + const labelStyle = ( + css` + font-size: 13px; + color: ${theme.colors.text.moderate}; + display: block; + padding-bottom: 5px; + padding-top: 0; + ` + ); + + return { labelStyle }; +} + +export default useLabelStyle; diff --git a/src/components/radio/index.tsx b/src/components/radio/index.tsx new file mode 100644 index 0000000..cbe9988 --- /dev/null +++ b/src/components/radio/index.tsx @@ -0,0 +1,25 @@ +import { InputHTMLAttributes, MouseEvent } from 'react'; +import { CSSObject } from '@emotion/react'; +import useRadioStyle from '@components/radio/useRadioStyle'; + +interface RadioProps extends InputHTMLAttributes { + type?: 'radio'; + css?: CSSObject; + defaultChecked?: boolean; + checked?: boolean; +} + +function Radio({ type = 'radio', ...rest }: RadioProps) { + const { radioStyle } = useRadioStyle(); + + const onClick = (e: MouseEvent) => { + const input = e.target as HTMLInputElement; + input.checked = true; + }; + + return ( + + ); +} + +export default Radio; diff --git a/src/components/radio/radio.stories.tsx b/src/components/radio/radio.stories.tsx new file mode 100644 index 0000000..592b6c9 --- /dev/null +++ b/src/components/radio/radio.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Radio from '@components/radio'; + +const meta: Meta = { + title: 'Components/Radio', + component: Radio, + argTypes: { + css: { control: 'object' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + }, +}; + +export const Radios: Story = { + render: () => ( +
+ + + + + + ), +}; diff --git a/src/components/radio/useRadioStyle.ts b/src/components/radio/useRadioStyle.ts new file mode 100644 index 0000000..408361f --- /dev/null +++ b/src/components/radio/useRadioStyle.ts @@ -0,0 +1,45 @@ +import useTheme from '@hooks/useTheme'; +import { css } from '@emotion/react'; + +function useRadioStyle() { + const theme = useTheme(); + + const radioStyle = ( + css` + width: 18px; + height: 18px; + border: 2px solid ${theme.colors.border.prominent}; + border-radius: ${theme.corners.round}; + position: relative; + appearance: none; + outline: none; + + &::after { + content: ''; + width: 12px; + height: 12px; + background-color: ${theme.colors.primary.main}; + position: absolute; + border-radius: ${theme.corners.round}; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: 0; + } + + &:checked { + border: 2px solid ${theme.colors.primary.main}; + } + + &:checked::after { + opacity: 1; + } + ` + ); + + return { + radioStyle, + }; +} + +export default useRadioStyle; diff --git a/src/components/select/index.tsx b/src/components/select/index.tsx new file mode 100644 index 0000000..b819d3c --- /dev/null +++ b/src/components/select/index.tsx @@ -0,0 +1,32 @@ +import { ReactNode, SelectHTMLAttributes, useRef } from 'react'; +import Label from '@components/label'; +import { CSSObject } from '@emotion/react'; +import useSelectStyle from '@components/select/useSelectStyle'; +import { generateRandomId } from '@/utils'; + +interface SelectProps extends SelectHTMLAttributes { + children?: ReactNode; + icon?: string | ReactNode; + label?: string; + placeholder?: string; + css?: CSSObject; +} + +function Select({ + children, icon, label, placeholder, css, ...rest +}: SelectProps) { + const selectId = useRef(generateRandomId()); + const { selectStyle, selectContainerStyle } = useSelectStyle(); + + return ( +
+ {label ? : null} + +
+ ); +} + +export default Select; diff --git a/src/components/select/select.stories.tsx b/src/components/select/select.stories.tsx new file mode 100644 index 0000000..76efccc --- /dev/null +++ b/src/components/select/select.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Select from '@components/select'; +import eye from '@assets/icons/eye.svg'; + +const meta: Meta = { + title: 'Components/Select', + component: Select, + argTypes: { + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + ), +}; diff --git a/src/components/select/useSelectStyle.ts b/src/components/select/useSelectStyle.ts new file mode 100644 index 0000000..431c72c --- /dev/null +++ b/src/components/select/useSelectStyle.ts @@ -0,0 +1,35 @@ +import useTheme from '@hooks/useTheme'; +import { css } from '@emotion/react'; + +function useSelectStyle() { + const theme = useTheme(); + + const selectContainerStyle = ( + css` + position: relative; + ` + ); + + const selectStyle = ( + css` + outline: none; + border: 1px solid ${theme.colors.border.subtle}; + border-radius: ${theme.corners.small}; + height: 30px; + padding: 5px; + &::after { + color: ${theme.colors.text.subtle}; + } + ` + ); + + const selectIconStyle = ( + css` + position: absolute; + ` + ); + + return { selectIconStyle, selectContainerStyle, selectStyle }; +} + +export default useSelectStyle; diff --git a/src/components/spacing/index.stories.tsx b/src/components/spacing/index.stories.tsx new file mode 100644 index 0000000..8f56d4b --- /dev/null +++ b/src/components/spacing/index.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Spacing from '.'; + +/* eslint-disable */ + +const meta = { + title: 'Components/Spacing', + component: Spacing, + argTypes: { + height: { + control: { + type: 'object', + }, + }, + backgroundColor: { + control: 'color', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + height: 16, + backgroundColor: 'inherit', + }, +}; + +export const ResponsiveHeight: Story = { + args: { + height: { + initial: 16, + sm: 32, + md: 48, + lg: 64, + }, + backgroundColor: 'inherit', + }, +}; + +export const CustomBackgroundColor: Story = { + args: { + height: 16, + backgroundColor: 'red', + }, +}; diff --git a/src/components/spacing/index.tsx b/src/components/spacing/index.tsx new file mode 100644 index 0000000..538c059 --- /dev/null +++ b/src/components/spacing/index.tsx @@ -0,0 +1,42 @@ +import styled from '@emotion/styled'; + +const vars = { + initial: '0', + xs: '520px', + sm: '768px', + md: '1024px', + lg: '1280px', +}; + +type ResponseGridStyle = { + // eslint-disable-next-line + [key in keyof typeof vars]?: number; +}; + +type Props = { + height?: number | ResponseGridStyle; + backgroundColor?: string; +} & React.HTMLAttributes; + +const Wrapper = styled.div>( + { + width: '100%', + }, + ({ backgroundColor }: { backgroundColor?: string }) => ({ backgroundColor: backgroundColor ?? 'inherit' }), + ({ height = 16 } : { height?: number | ResponseGridStyle }) => { + if (typeof height === 'number') { + return { + height: `${height}px`, + }; + } + + const breakpoints = Object.keys(height) as (keyof typeof vars)[]; + return breakpoints + .map((breakpoint) => `@media screen and (min-width: ${vars[breakpoint]}) { height: ${height[breakpoint]}px; }`) + .join(' '); + }, +); + +export default function Spacing({ height, backgroundColor = 'inherit', ...props }: Props) { + return ; +} diff --git a/src/components/switch/index.tsx b/src/components/switch/index.tsx new file mode 100644 index 0000000..6eb5019 --- /dev/null +++ b/src/components/switch/index.tsx @@ -0,0 +1,36 @@ +import { + forwardRef, InputHTMLAttributes, useRef, +} from 'react'; +import { CSSObject } from '@emotion/react'; +import useSwitchHandler from '@components/switch/useSwitchHandler'; +import useSwitchStyle from '@components/switch/useSwitchStyle'; +import { generateRandomId } from '@/utils'; + +interface SwitchProps extends InputHTMLAttributes { + type: 'checkbox'; + wrapperCss?: CSSObject; + circleCss?: CSSObject; + checked?: boolean; + defaultChecked?: boolean; +} + +const Switch = forwardRef(({ + type = 'checkbox', wrapperCss, circleCss, checked, defaultChecked, ...rest +}, ref) => { + const inputIdRef = useRef(generateRandomId()); + const { handleClick } = useSwitchHandler({ inputId: inputIdRef.current }); + const { switchWrapperStyle, switchCircleStyle, switchInputStyle } = useSwitchStyle(); + const isChecked = checked || defaultChecked; + + // TODO: 하드코딩된 className 처리 + return ( +
+ +
+
+
+
+ ); +}); + +export default Switch; diff --git a/src/components/switch/switch.stories.tsx b/src/components/switch/switch.stories.tsx new file mode 100644 index 0000000..cc109f2 --- /dev/null +++ b/src/components/switch/switch.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Switch from '@components/switch'; +import { css } from '@emotion/react'; + +const meta: Meta = { + title: 'Components/Switch', + component: Switch, + argTypes: { + wrapperCss: { control: 'object' }, + circleCss: { control: 'object' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + + }, +}; + +export const CustomStyled: Story = { + args: { + wrapperCss: ( + css` + .switch-checked + & { + background-color: #e8baba; + } + ` + ), + defaultChecked: true, + }, +}; diff --git a/src/components/switch/useSwitchHandler.tsx b/src/components/switch/useSwitchHandler.tsx new file mode 100644 index 0000000..8821f46 --- /dev/null +++ b/src/components/switch/useSwitchHandler.tsx @@ -0,0 +1,25 @@ +interface UseSwitchHandlerProps { + inputId: string; +} + +function useSwitchHandler({ inputId }: UseSwitchHandlerProps) { + const handleClick = () => { + if (!document || !inputId) return; + + const domInput = document.getElementById(inputId) as HTMLInputElement; + const checked = !domInput.checked; + domInput.checked = checked; + + if (checked) { + domInput.classList.add('switch-checked'); + + return; + } + + domInput.classList.remove('switch-checked'); + }; + + return { handleClick }; +} + +export default useSwitchHandler; diff --git a/src/components/switch/useSwitchStyle.ts b/src/components/switch/useSwitchStyle.ts new file mode 100644 index 0000000..440748b --- /dev/null +++ b/src/components/switch/useSwitchStyle.ts @@ -0,0 +1,59 @@ +import { css } from '@emotion/react'; +import useTheme from '@hooks/useTheme'; + +function useSwitchStyle() { + const theme = useTheme(); + const switchInputStyle = ( + css` + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + margin: 0; + display: block; + ` + ); + + const switchWrapperStyle = ( + css` + width: 42px; + height: 24px; + border: 1px solid ${theme.colors.border.subtle}; + background-color: transparent; + border-radius: ${theme.corners.round}; + position: relative; + transition: 0.2s; + box-sizing: border-box; + + .switch-checked + & { + background-color: ${theme.colors.primary.main}; + border: 1px solid transparent; + } + ` + ); + + const switchCircleStyle = ( + css` + background-color: ${theme.colors.absolute.black}; + border-radius: 100%; + width: 14px; + height: 14px; + position: absolute; + top: 5px; + left: 5px; + transition: all 0.2s ease-in-out; + + .switch-checked + div > & { + left: 22px; + transition: all 0.2s ease-in-out; + } + ` + ); + + return { + switchInputStyle, + switchWrapperStyle, + switchCircleStyle, + }; +} + +export default useSwitchStyle; diff --git a/src/components/tag/index.tsx b/src/components/tag/index.tsx new file mode 100644 index 0000000..a5c313d --- /dev/null +++ b/src/components/tag/index.tsx @@ -0,0 +1,53 @@ +import { MouseEventHandler, ReactNode } from 'react'; +import DynamicIcon from '@components/internal/dynamic-icon'; +import { CSSObject } from '@emotion/react'; +import CloseButton from '@assets/icons/x.svg?react'; +import useTagStyle from '@components/tag/useTagStyle'; +import useTheme from '@hooks/useTheme'; +import { TagTheme } from '@/types'; + +interface TagProps { + icon?: ReactNode | string; + children?: ReactNode; + css?: CSSObject; + tagTheme?: TagTheme; + enableClose?: boolean; + onClose?: MouseEventHandler; +} + +function Tag({ + icon, css, children, tagTheme = 'default', enableClose, onClose, +}: TagProps) { + const { + tagContainerStyle, + tagIconStyle, + tagStyle, + closeIconContainerStyle, + } = useTagStyle({ + enableClose, tagTheme, + }); + const theme = useTheme(); + + if (!enableClose && !!onClose) { + throw new Error('Cannot add onClose listener while the value of value is falsy'); + } + + return ( +
+
+ + {children} +
+ { + enableClose + && ( +
+ +
+ ) + } +
+ ); +} + +export default Tag; diff --git a/src/components/tag/tag.stories.tsx b/src/components/tag/tag.stories.tsx new file mode 100644 index 0000000..b4bae07 --- /dev/null +++ b/src/components/tag/tag.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Tag from '@components/tag/index'; +import icon from '@assets/icons/tag.svg'; +import { css } from '@emotion/react'; +import colorTheme from '@styles/colors'; + +const meta: Meta = { + title: 'Components/Tag', + component: Tag, + argTypes: { + children: { + control: 'text', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'label', + icon, + }, +}; + +export const EnableClose: Story = { + args: { + children: 'label', + icon, + enableClose: true, + }, +}; + +export const EnableCloseStyled: Story = { + args: { + children: 'label', + icon, + enableClose: true, + css: css` + background-color: ${colorTheme.primary.passive}; + border: none; + `, + onClose: () => { console.log('close'); }, + }, +}; + +export const EnableClosePrimary: Story = { + args: { + children: 'label', + icon, + enableClose: true, + tagTheme: 'primary', + onClose: () => { console.log('close'); }, + }, +}; diff --git a/src/components/tag/useTagStyle.ts b/src/components/tag/useTagStyle.ts new file mode 100644 index 0000000..d83a028 --- /dev/null +++ b/src/components/tag/useTagStyle.ts @@ -0,0 +1,81 @@ +import { css } from '@emotion/react'; +import useTheme from '@hooks/useTheme'; +import { TagTheme } from '@/types'; + +interface UseTagStyleProps { + enableClose?: boolean; + tagTheme?: TagTheme; +} + +function useTagStyle({ enableClose, tagTheme }: UseTagStyleProps) { + const theme = useTheme(); + const tagStyle = ( + css` + padding: 6px 16px; + box-sizing: border-box; + display: flex; + width: fit-content; + height: fit-content; + align-items: center; + gap: 8px; + flex-grow: 0; + border-radius: ${theme.corners.round} ${getRightCorner()} ${getRightCorner()} ${theme.corners.round}; + border: 2px solid ${getBorderStyle()}; + background-color: ${getBackgroundColor()}; + color: ${getTextColor()}; + font-size: 15px; + ` + ); + + const tagContainerStyle = css` + display: flex; + gap: 3px; + width: fit-content; + height: fit-content; + `; + + const closeIconContainerStyle = ( + css` + border-radius: ${theme.corners.small} ${theme.corners.round} ${theme.corners.round} ${theme.corners.small}; + border: 2px solid ${getBorderStyle()}; + display: flex; + align-items: center; + padding: 7px 9px 7px 7px; + width: fit-content; + height: fit-content; + cursor: pointer; + user-select: none; + background-color: ${getBackgroundColor()}; + ` + ); + + const tagIconStyle = css` + width: 16px; + height: 16px; + `; + + function getRightCorner() { + return enableClose ? theme.corners.small : theme.corners.round; + } + + function getBorderStyle() { + return tagTheme === 'primary' ? 'transparent' : theme.colors.border.subtle; + } + + function getBackgroundColor() { + return tagTheme === 'primary' ? theme.colors.primary.passive : 'transparent'; + } + + function getTextColor() { + return tagTheme === 'primary' ? theme.colors.primary.main : theme.colors.text.prominent; + } + + return { + tagStyle, + tagIconStyle, + tagContainerStyle, + closeIconContainerStyle, + }; +} + +export default useTagStyle; diff --git a/src/components/text/index.tsx b/src/components/text/index.tsx new file mode 100644 index 0000000..841260e --- /dev/null +++ b/src/components/text/index.tsx @@ -0,0 +1,67 @@ +import { ElementType, ReactNode } from 'react'; +import styled from '@emotion/styled'; + +export type FontWeight = 'regular' | 'medium' | 'bold' | 'lighter' | 'bolder'; + +interface TextProps { + children: ReactNode; + weight?: FontWeight; + // eslint-disable-next-line react/no-unused-prop-types + as?: ElementType; + // eslint-disable-next-line react/no-unused-prop-types + fontSize?: string; +} + +const Text = styled.p` + font-weight: ${({ weight }) => weight || 'regular'}; + margin: 0; + ${({ fontSize }) => fontSize && `font-size: ${fontSize};`} +`; + +export default Text; + +export const Paragraph = { + Large: ({ children, weight }: TextProps) => ( + + {children} + + ), + Medium: ({ children, weight }: TextProps) => ( + + {children} + + ), + Small: ({ children, weight }: TextProps) => ( + + {children} + + ), +}; + +export const Heading = { + H1: ({ children, weight }: TextProps) => ( + + {children} + + ), + H2: ({ children, weight }: TextProps) => ( + + {children} + + ), + H3: ({ children, weight }: TextProps) => ( + + {children} + + ), + H4: ({ children, weight }: TextProps) => ( + + {children} + + ), + H5: ({ children, weight }: TextProps) => ( + + {children} + + ), +}; diff --git a/src/components/textarea/index.tsx b/src/components/textarea/index.tsx new file mode 100644 index 0000000..e454b33 --- /dev/null +++ b/src/components/textarea/index.tsx @@ -0,0 +1,49 @@ +import { + forwardRef, + HTMLAttributes, + useRef, +} from 'react'; +import { CSSObject } from '@emotion/react'; +import useTextAreaStyle from '@components/textarea/useTextAreaStyle'; +import Label from '@components/label'; +import { generateRandomId } from '@/utils'; + +interface TextareaProps extends HTMLAttributes { + label?: string; + css?: CSSObject; + rows?: number; + cols?: number; + maxLength?: number; +} + +const TextArea = forwardRef(({ + onChange, label, css, rows = 4, cols = 50, maxLength, ...rest +}, ref) => { + const textareaId = useRef(generateRandomId()); + const { textAreaStyle } = useTextAreaStyle(); + + return ( + <> + { + label + ? ( + + ) + : null + } +