diff --git a/.gitignore b/.gitignore index 54d57f9..3b28f85 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ dist # test coverage + +*storybook.log +storybook-static diff --git a/.script/publish-local.sh b/.script/publish-local.sh new file mode 100755 index 0000000..6bab0c6 --- /dev/null +++ b/.script/publish-local.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# 로컬에서 배포시 tag, release를 생성하지 않음 +# 직접 version을 수정하고 publish를 실행 +# tag와 release를 직접 생성 + +sh .script/pre-install.sh + +yarn npm publish --access=public + +sh .script/post-install.sh diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..5816c65 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,22 @@ +import type {StorybookConfig} from '@storybook/react-vite'; +import path from 'path'; + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: ['@storybook/addon-onboarding', '@storybook/addon-docs', '@storybook/addon-react-native-web'], + framework: { + name: '@storybook/react-vite', + options: {}, + }, + viteFinal: async config => { + config.resolve = { + ...config.resolve, + alias: { + ...config.resolve?.alias, + '@': path.resolve(__dirname, '../src'), + }, + }; + return config; + }, +}; +export default config; diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000..e3c7d07 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,15 @@ + + diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000..265ca68 --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,27 @@ +import type {Preview} from '@storybook/react-vite'; + +import {EmotionProvider} from '@/react/provider'; +import {theme} from '@/emotion/theme'; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + reactNative: { + // React Native 옵션 설정 + }, + }, + decorators: [ + Story => ( + + + + ), + ], +}; + +export default preview; diff --git a/eslint.config.ts b/eslint.config.ts index e2c93a4..fe1eae1 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,3 +1,6 @@ +// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format +import storybook from 'eslint-plugin-storybook'; + import tseslint from '@typescript-eslint/eslint-plugin'; import tsParser from '@typescript-eslint/parser'; import prettier from 'eslint-config-prettier'; @@ -48,4 +51,5 @@ export default [ }, }, }, + ...storybook.configs['flat/recommended'], ]; diff --git a/package.json b/package.json index 3c35197..6d94b63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ummgoban/shared", - "version": "0.0.3", + "version": "0.0.4-canary.12", "description": "ummgoban 공통 패키지", "main": "dist/index.js", "module": "dist/index.esm.js", @@ -19,6 +19,12 @@ }, "./react": { "types": "./dist/react/index.d.ts" + }, + "./react-native": { + "types": "./dist/react-native/index.d.ts" + }, + "./emotion": { + "types": "./dist/emotion/index.d.ts" } }, "files": [ @@ -37,7 +43,10 @@ "format": "prettier --write src", "format:check": "prettier --check src", "test:all": "yarn lint:check && yarn format:check && yarn type-check && yarn test", - "release": "standard-version" + "release": "standard-version", + "publish:local": "yarn run build && ./.script/publish-local.sh", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "repository": { "type": "git", @@ -50,15 +59,31 @@ "registry": "https://npm.pkg.github.com/" }, "peerDependencies": { + "@emotion/native": ">=11.11.0", + "@emotion/react": ">=11.14.0", "axios": ">=1.7.4", - "react": ">=18.0.0" + "react": ">=18.0.0", + "react-native": ">=0.79.0" }, "devDependencies": { + "@emotion/native": "^11.11.0", + "@emotion/react": "^11.14.0", "@rollup/plugin-alias": "^5.1.1", "@rollup/plugin-commonjs": "^28.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-typescript": "^12.0.0", + "@storybook/addon-docs": "^9.0.5", + "@storybook/addon-essentials": "^8.6.14", + "@storybook/addon-interactions": "^8.6.14", + "@storybook/addon-links": "^9.0.5", + "@storybook/addon-onboarding": "^9.0.5", + "@storybook/addon-react-native-web": "^0.0.29", + "@storybook/blocks": "^8.6.14", + "@storybook/react": "^9.0.5", + "@storybook/react-native": "^9.0.6", + "@storybook/react-vite": "^9.0.5", + "@storybook/react-webpack5": "^9.0.5", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -77,13 +102,17 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-no-relative-import-paths": "^1.5.5", "eslint-plugin-prettier": "^5.4.1", + "eslint-plugin-storybook": "^9.0.5", "jiti": "^2.4.2", "jsdom": "^26.1.0", "prettier": "^3.5.3", "react": "^18.0.0", "react-dom": "^18.0.0", + "react-native": "^0.79.3", + "react-native-web": "^0.20.0", "rollup": "^4.41.1", "standard-version": "^9.5.0", + "storybook": "^9.0.5", "tslib": "^2.8.1", "typescript": "^5.0.0", "vite": "^6.3.5", diff --git a/rollup.config.mjs b/rollup.config.mjs index b00b3a1..7938ece 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -10,7 +10,44 @@ import pkg from './package.json' assert {type: 'json'}; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -export default { +// 공통 플러그인 설정 (TypeScript 제외) +const createCommonPlugins = () => [ + alias({ + entries: [{find: '@', replacement: path.resolve(__dirname, 'src')}], + }), + nodeResolve({ + extensions: ['.js', '.jsx', '.ts', '.tsx'], + browser: true, + preferBuiltins: false, + }), + commonjs(), + json(), +]; + +// 메인 패키지용 TypeScript 플러그인 +const createMainTypescriptPlugin = () => + typescript({ + tsconfig: './tsconfig.build.json', + declaration: true, + declarationDir: 'dist', + outDir: 'dist', + sourceMap: true, + rootDir: 'src', + jsx: 'react-jsx', + exclude: ['**/*.{spec,test}.{ts,tsx}'], + }); + +// 외부 의존성 설정 +const external = [ + ...Object.keys(pkg.peerDependencies || {}), + ...Object.keys(pkg.dependencies || {}), + 'axios', + 'react', + 'react-native', +]; + +// 메인 패키지 설정 +const mainConfig = { input: 'src/index.ts', output: [ { @@ -24,27 +61,8 @@ export default { sourcemap: true, }, ], - external: [...Object.keys(pkg.peerDependencies || {}), ...Object.keys(pkg.dependencies || {}), 'axios', 'react'], - plugins: [ - alias({ - entries: [{find: '@', replacement: path.resolve(__dirname, 'src')}], - }), - nodeResolve({ - extensions: ['.js', '.jsx', '.ts', '.tsx'], - browser: true, - preferBuiltins: false, - }), - commonjs(), - json(), - typescript({ - tsconfig: './tsconfig.build.json', - declaration: true, - declarationDir: 'dist', - outDir: 'dist', - sourceMap: true, - rootDir: 'src', - jsx: 'react-jsx', - exclude: ['**/*.{spec,test}.{ts,tsx}'], - }), - ], + external, + plugins: [...createCommonPlugins(), createMainTypescriptPlugin()], }; + +export default [mainConfig]; diff --git a/src/emotion/index.ts b/src/emotion/index.ts new file mode 100644 index 0000000..7b1f54e --- /dev/null +++ b/src/emotion/index.ts @@ -0,0 +1 @@ +export * from './theme'; diff --git a/src/emotion/theme/emotion.d.ts b/src/emotion/theme/emotion.d.ts new file mode 100644 index 0000000..e733c8d --- /dev/null +++ b/src/emotion/theme/emotion.d.ts @@ -0,0 +1,8 @@ +import '@emotion/react'; +import theme from './theme'; + +type ThemeTpye = typeof theme; + +declare module '@emotion/react' { + export type Theme = ThemeTpye; +} diff --git a/src/emotion/theme/index.ts b/src/emotion/theme/index.ts new file mode 100644 index 0000000..ca8aa24 --- /dev/null +++ b/src/emotion/theme/index.ts @@ -0,0 +1,3 @@ +import theme from './theme'; + +export {theme}; diff --git a/src/emotion/theme/theme.ts b/src/emotion/theme/theme.ts new file mode 100644 index 0000000..6e3490a --- /dev/null +++ b/src/emotion/theme/theme.ts @@ -0,0 +1,129 @@ +const theme = { + colors: { + // primary: green + primary: 'rgba(112, 200, 2, 1)', + primaryHover: 'rgba(112, 200, 2, 0.08)', + primaryPressed: 'rgba(112, 200, 2, 0.18)', + primaryDisabled: 'rgba(112, 200, 2, 0.38)', + + primaryLight: 'rgba(22, 190, 83, 1)', + + // secondary: white + secondary: 'rgba(255, 255, 255, 1)', + secondaryHover: 'rgba(255, 255, 255, 0.08)', + secondaryPressed: 'rgba(255, 255, 255, 0.18)', + secondaryDisabled: 'rgba(255, 255, 255, 0.38)', + + // tertiary: black + tertiary: 'rgba(0, 0, 0, 1)', + tertiaryHover: 'rgba(0, 0, 0, 0.08)', + tertiaryPressed: 'rgba(0, 0, 0, 0.18)', + tertiaryDisabled: 'rgba(0, 0, 0, 0.38)', + + // warning: yellow + warning: 'rgba(255, 152, 0, 1)', + warningHover: 'rgba(255, 152, 0, 0.08)', + warningPressed: 'rgba(255, 152, 0, 0.18)', + warningDisabled: 'rgba(255, 152, 0, 0.38)', + + // error: red + error: 'rgba(255, 44, 44, 1)', + errorHover: 'rgba(255, 44, 44, 0.08)', + errorPressed: 'rgba(255, 44, 44, 0.18)', + errorDisabled: 'rgba(255, 44, 44, 0.38)', + + // disabled + disabled: 'rgba(174, 174, 174, 1)', + disabledHover: 'rgba(174, 174, 174, 0.08)', + disabledPressed: 'rgba(174, 174, 174, 0.18)', + disabledDisabled: 'rgba(174, 174, 174, 0.38)', + + // dark + dark: 'rgba(29, 38, 58, 1)', + darkHover: 'rgba(29, 38, 58, 0.08)', + darkPressed: 'rgba(29, 38, 58, 0.18)', + darkDisabled: 'rgba(29, 38, 58, 0.38)', + }, + fonts: { + h1: { + fontSize: 96, + letterSpacing: -1.5, + lineHeight: 104, + fontFamily: 'Pretendard-Light', + }, + h2: { + fontSize: 60, + letterSpacing: -0.5, + lineHeight: 68, + fontFamily: 'Pretendard-Light', + }, + h3: { + fontSize: 48, + letterSpacing: 0, + lineHeight: 56, + fontFamily: 'Pretendard-Regular', + }, + h4: { + fontSize: 34, + letterSpacing: 0.25, + lineHeight: 42, + fontFamily: 'Pretendard-Regular', + }, + h5: { + fontSize: 24, + letterSpacing: 0, + lineHeight: 32, + fontFamily: 'Pretendard-Regular', + }, + h6: { + fontSize: 20, + letterSpacing: 0.15, + lineHeight: 28, + fontFamily: 'Pretendard-Medium', + }, + subtitle1: { + fontSize: 16, + letterSpacing: 0.15, + lineHeight: 20, + fontFamily: 'Pretendard-Regular', + }, + subtitle2: { + fontSize: 14, + letterSpacing: 0.1, + lineHeight: 20, + fontFamily: 'Pretendard-Bold', + }, + body1: { + fontSize: 16, + letterSpacing: 0.5, + lineHeight: 20, + fontFamily: 'Pretendard-Regular', + }, + body2: { + fontSize: 14, + letterSpacing: 0.25, + lineHeight: 18, + fontFamily: 'Pretendard-Regular', + }, + button: { + fontSize: 14, + letterSpacing: 1.25, + lineHeight: 18, + fontFamily: 'Pretendard-Medium', + }, + caption: { + fontSize: 12, + letterSpacing: 0.4, + lineHeight: 16, + fontFamily: 'Pretendard-Regular', + }, + overline: { + fontSize: 10, + letterSpacing: 1.5, + lineHeight: 14, + fontFamily: 'Pretendard-Regular', + }, + }, +} as const; + +export default theme; diff --git a/src/index.ts b/src/index.ts index f41904b..dc3f21a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,5 @@ -// network -export * from './network'; - -// lib export * from './lib'; - -// react +export * from './network'; export * from './react'; +export * from './react-native'; +export * from './emotion'; diff --git a/src/lib/index.ts b/src/lib/index.ts index a7a8c4f..5aca993 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,8 +1,3 @@ -// constants export * from './constants'; - -// utils -export * from './utils'; - -// types export * from './types'; +export * from './utils'; diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 92fd617..955cdad 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,2 +1,5 @@ // hash export * from './hash'; + +// style +export * from './style'; diff --git a/src/lib/utils/style/index.ts b/src/lib/utils/style/index.ts new file mode 100644 index 0000000..6db7063 --- /dev/null +++ b/src/lib/utils/style/index.ts @@ -0,0 +1 @@ +export * from './jsx-style-to-css'; diff --git a/src/lib/utils/style/jsx-style-to-css.spec.ts b/src/lib/utils/style/jsx-style-to-css.spec.ts new file mode 100644 index 0000000..9344b30 --- /dev/null +++ b/src/lib/utils/style/jsx-style-to-css.spec.ts @@ -0,0 +1,21 @@ +import {jsxStyleToCss} from './jsx-style-to-css'; + +describe('jsxStyleToCss', () => { + it('should convert style object to css string', () => { + const style = { + display: 'flex', + justifyContent: 'center', + }; + const css = jsxStyleToCss(style); + expect(css).toBe('display: flex; justify-content: center;'); + }); + + it('should convert style object to css string with px postfix when value is number', () => { + const style = { + color: 'red', + fontSize: 12, + }; + const css = jsxStyleToCss(style); + expect(css).toBe('color: red; font-size: 12px;'); + }); +}); diff --git a/src/lib/utils/style/jsx-style-to-css.ts b/src/lib/utils/style/jsx-style-to-css.ts new file mode 100644 index 0000000..b1ea405 --- /dev/null +++ b/src/lib/utils/style/jsx-style-to-css.ts @@ -0,0 +1,29 @@ +import {CSSProperties} from 'react'; + +/** + * @description jsx 스타일 스타일 객체를 css 문자열로 변환 + * @param style + * @example + * ```tsx + * const style = { + * color: 'red', + * fontSize: 12, + * } + * const css = jsxStyleToCss(style); + * console.log(css); + * // color: red; font-size: 12px; + * ``` + */ +export function jsxStyleToCss(style: CSSProperties) { + return Object.entries(style) + .map(([key, value]) => { + const kebabCaseKey = key.replace(/([A-Z])/g, '-$1').toLowerCase(); + + if (typeof value === 'number') { + return `${kebabCaseKey}: ${value}px;`; + } + + return `${kebabCaseKey}: ${value};`; + }) + .join(' '); +} diff --git a/src/network/api-client/api-client.ts b/src/network/api-client/api-client.ts index 75881ef..a87aeee 100644 --- a/src/network/api-client/api-client.ts +++ b/src/network/api-client/api-client.ts @@ -1,7 +1,7 @@ import axios, {AxiosInstance, InternalAxiosRequestConfig, AxiosResponse, AxiosRequestConfig, AxiosError} from 'axios'; import {CustomError} from '../error'; -import {SessionType, StorageKeyType} from 'lib/types'; +import {SessionType, StorageKeyType} from '@/lib/types'; export interface ApiClientOptions { serverApiBaseUrl: string; diff --git a/src/react-native/component/Button/Button.stories.tsx b/src/react-native/component/Button/Button.stories.tsx new file mode 100644 index 0000000..e16b12c --- /dev/null +++ b/src/react-native/component/Button/Button.stories.tsx @@ -0,0 +1,66 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import Button from './Button'; + +const meta: Meta = { + title: 'Components/Button', + component: Button, + parameters: {}, + argTypes: { + children: {control: 'text'}, + themeColor: {control: 'radio', options: ['primary', 'secondary', 'tertiary']}, + disabled: {control: 'boolean'}, + onPress: {action: 'pressed'}, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: '버튼', + themeColor: 'primary', + }, +}; + +export const Primary: Story = { + args: { + children: '확인', + themeColor: 'primary', + }, +}; + +export const Secondary: Story = { + args: { + children: '확인', + themeColor: 'secondary', + }, +}; + +export const Tertiary: Story = { + args: { + children: '확인', + themeColor: 'tertiary', + }, +}; + +export const Error: Story = { + args: { + children: '오류', + themeColor: 'error', + }, +}; + +export const Warning: Story = { + args: { + children: '취소', + themeColor: 'warning', + }, +}; + +export const Disabled: Story = { + args: { + children: '비활성화', + disabled: true, + }, +}; diff --git a/src/react-native/component/Button/Button.style.ts b/src/react-native/component/Button/Button.style.ts new file mode 100644 index 0000000..eb8a176 --- /dev/null +++ b/src/react-native/component/Button/Button.style.ts @@ -0,0 +1,40 @@ +import styled from '@emotion/native'; + +import {theme} from '@/emotion/theme'; + +const S = { + Button: styled.TouchableOpacity<{themeColor: keyof (typeof theme)['colors']}>` + padding: 12px 24px; + border-radius: 8px; + + display: flex; + align-items: center; + justify-content: center; + + transition: background-color opacity 0.3s ease-in-out; + + ${({themeColor, theme}) => { + const defaultColorStyle = [`background-color: ${theme.colors[themeColor]};`]; + + if (themeColor.startsWith('secondary')) { + defaultColorStyle.push(`border: 1px solid ${theme.colors.dark};`); + } + + return defaultColorStyle.join(';'); + }} + + ${({disabled}) => disabled && 'opacity: 0.5;'} + `, + Text: styled.Text<{themeColor: keyof (typeof theme)['colors']}>` + ${({themeColor}) => { + if (themeColor.startsWith('secondary')) { + return `color: ${theme.colors.dark};`; + } + return `color: ${theme.colors.secondary};`; + }} + font-size: 16px; + font-weight: bold; + `, +}; + +export default S; diff --git a/src/react-native/component/Button/Button.tsx b/src/react-native/component/Button/Button.tsx new file mode 100644 index 0000000..0de2e8e --- /dev/null +++ b/src/react-native/component/Button/Button.tsx @@ -0,0 +1,12 @@ +import S from './Button.style'; +import {ButtonProps} from './Button.type'; + +const Button = ({children, themeColor = 'primary', ...props}: ButtonProps) => { + return ( + + {children} + + ); +}; + +export default Button; diff --git a/src/react-native/component/Button/Button.type.ts b/src/react-native/component/Button/Button.type.ts new file mode 100644 index 0000000..eb8af85 --- /dev/null +++ b/src/react-native/component/Button/Button.type.ts @@ -0,0 +1,7 @@ +import {TouchableOpacityProps} from 'react-native'; + +import {theme} from '@/emotion/theme'; + +export interface ButtonProps extends TouchableOpacityProps { + themeColor?: keyof (typeof theme)['colors']; +} diff --git a/src/react-native/component/TextInput/TextInput.stories.tsx b/src/react-native/component/TextInput/TextInput.stories.tsx new file mode 100644 index 0000000..36a1554 --- /dev/null +++ b/src/react-native/component/TextInput/TextInput.stories.tsx @@ -0,0 +1,205 @@ +import type {Meta} from '@storybook/react-vite'; +import {useRef, useState} from 'react'; +import {Button, Text, View} from 'react-native'; + +import TextInput from './TextInput'; +import type {TextInputProps, TextInputRef} from './TextInput.type'; + +const meta: Meta = { + title: 'Components/TextInput', + component: TextInput, + parameters: {}, + args: {}, + argTypes: { + label: {control: 'text'}, + errorMessage: {control: 'text'}, + errorStyle: {control: 'object'}, + style: {control: 'object'}, + value: {control: 'text'}, + validation: {}, + full: {control: 'boolean'}, + labelPosition: { + control: 'radio', + options: [ + 'top-left', + 'top-right', + 'top-center', + 'bottom-left', + 'bottom-right', + 'bottom-center', + 'left-top', + 'left-middle', + 'left-bottom', + 'right-top', + 'right-middle', + 'right-bottom', + ], + }, + }, +}; + +export default meta; + +export const Default = (args: TextInputProps) => { + const inputRef = useRef(null); + + return ; +}; + +export const Full = (args: TextInputProps) => { + const inputRef = useRef(null); + + return ; +}; + +export const ReadOnly = (args: TextInputProps) => { + const inputRef = useRef(null); + + return ; +}; + +export const WithLabel = (args: TextInputProps) => { + const inputRef = useRef(null); + + return ( + value.length > 3} + {...args} + /> + ); +}; + +export const WithLabelFull = (args: TextInputProps) => { + const inputRef = useRef(null); + + return ( + value.length > 3} + full + {...args} + /> + ); +}; + +export const WithLabelHorizontal = (args: TextInputProps) => { + const inputRef = useRef(null); + + return ( + value.length > 3} + {...args} + /> + ); +}; + +export const WithLabelHorizontalFull = (args: TextInputProps) => { + const inputRef = useRef(null); + + return ( + value.length > 3} + full + {...args} + /> + ); +}; + +export const WithForm = (args: TextInputProps) => { + const inputRef = useRef(null); + const [result, setResult] = useState(undefined); + + return ( + <> + + value.length > 3} + full + {...args} + /> + +