diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 0000000..2773522 --- /dev/null +++ b/.github/workflows/chromatic.yml @@ -0,0 +1,61 @@ +name: 'Chromatic Deployment' + +run-name: Storybook deployment by ${{ github.actor }} + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**/*.stories.tsx' + - '**/*.stories.ts' + - '**/*.stories.jsx' + - '**/*.stories.js' + - 'src/components/**' + +jobs: + chromatic: + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-build') }} + name: Run Chromatic Deployment + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + outputs: + status: ${{ job.status }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + with: + run_install: false + + - name: Set up Node.js version + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: 'pnpm' + cache-dependency-path: '**/pnpm-lock.yaml' + + - name: Install dependencies + run: pnpm install + + - name: Run Chromatic + id: chromatic + uses: chromaui/action@latest + with: + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + buildScriptName: build-storybook + onlyChanged: true + + - name: Comment PR + uses: thollander/actions-comment-pull-request@v1 + if: ${{ github.event_name == 'pull_request' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + message: '스토리북 URL 확인하기 - ${{ steps.chromatic.outputs.storybookUrl }}' diff --git a/.storybook/main.ts b/.storybook/main.ts index f8109ce..f7e58ae 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -2,17 +2,35 @@ import type { StorybookConfig } from '@storybook/nextjs'; const config: StorybookConfig = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], - addons: [ - '@chromatic-com/storybook', - '@storybook/addon-docs', - '@storybook/addon-onboarding', - '@storybook/addon-a11y', - '@storybook/addon-vitest', - ], + addons: ['@storybook/addon-onboarding', '@chromatic-com/storybook', '@storybook/addon-a11y'], framework: { name: '@storybook/nextjs', options: {}, }, - staticDirs: ['../public'], + webpackFinal: async (config) => { + if (!config.module || !config.module.rules) { + return config; + } + + config.module.rules = [ + ...config.module.rules.map((rule) => { + if (!rule || rule === '...') { + return rule; + } + + if (rule.test && /svg/.test(String(rule.test))) { + return { ...rule, exclude: /\.svg$/i }; + } + + return rule; + }), + { + test: /\.svg$/, + use: ['@svgr/webpack'], + }, + ]; + + return config; + }, }; export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts index a9e11e2..858e3d8 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,4 +1,5 @@ import type { Preview } from '@storybook/nextjs'; +import '../src/styles/globals.css'; const preview: Preview = { parameters: { @@ -8,13 +9,6 @@ const preview: Preview = { date: /Date$/i, }, }, - - a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail CI on a11y violations - // 'off' - skip a11y checks entirely - test: 'todo', - }, }, }; diff --git a/.storybook/vitest.setup.ts b/.storybook/vitest.setup.ts deleted file mode 100644 index e8c7ed3..0000000 --- a/.storybook/vitest.setup.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; -import { setProjectAnnotations } from '@storybook/nextjs-vite'; -import * as projectAnnotations from './preview'; - -// This is an important step to apply the right configuration when testing your stories. -// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations -setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); diff --git a/.vscode/settings.json b/.vscode/settings.json index f5458bf..30ae42d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,6 @@ "files.eol": "\n", "files.insertFinalNewline": true, "typescript.preferences.importModuleSpecifier": "non-relative", - "javascript.updateImportsOnFileMove.enabled": "always" + "javascript.updateImportsOnFileMove.enabled": "always", + "css.lint.unknownAtRules": "ignore" } diff --git a/next.config.ts b/next.config.ts index c9157e8..5399318 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,25 +8,14 @@ const nextConfig: NextConfig = { typeof rule.test === 'object' && rule.test instanceof RegExp && rule.test?.test?.('.svg') ); - if (!fileLoaderRule) { - throw new Error('File loader rule not found'); - } - - config.module.rules.push( - { - ...fileLoaderRule, - test: /\.svg$/i, - resourceQuery: /url/, - }, - { + if (fileLoaderRule) { + config.module.rules.push({ test: /\.svg$/i, - issuer: fileLoaderRule.issuer, - resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, use: ['@svgr/webpack'], - } - ); + }); - fileLoaderRule.exclude = /\.svg$/i; + fileLoaderRule.exclude = /\.svg$/i; + } return config; }, diff --git a/package.json b/package.json index 2de735a..6dafc23 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "prettier": "prettier --write .", "prepare": "husky", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "chromatic": "npx chromatic" }, "lint-staged": { "*.{js,ts,tsx,jsx}": [ @@ -43,6 +44,7 @@ "@types/webpack": "^5.28.5", "@vitest/browser": "^3.2.4", "@vitest/coverage-v8": "^3.2.4", + "chromatic": "^13.3.3", "eslint": "^9", "eslint-config-next": "15.5.4", "eslint-config-prettier": "^10.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78bcd76..9793af1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@vitest/coverage-v8': specifier: ^3.2.4 version: 3.2.4(@vitest/browser@3.2.4)(vitest@3.2.4) + chromatic: + specifier: ^13.3.3 + version: 13.3.3 eslint: specifier: ^9 version: 9.37.0(jiti@2.6.1) @@ -2416,6 +2419,18 @@ packages: '@chromatic-com/playwright': optional: true + chromatic@13.3.3: + resolution: {integrity: sha512-89w0hiFzIRqLbwGSkqSQzhbpuqaWpXYZuevSIF+570Wb+T/apeAkp3px8nMJcFw+zEdqw/i6soofkJtfirET1Q==} + hasBin: true + peerDependencies: + '@chromatic-com/cypress': ^0.*.* || ^1.0.0 + '@chromatic-com/playwright': ^0.*.* || ^1.0.0 + peerDependenciesMeta: + '@chromatic-com/cypress': + optional: true + '@chromatic-com/playwright': + optional: true + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -7777,6 +7792,8 @@ snapshots: chromatic@12.2.0: {} + chromatic@13.3.3: {} + chrome-trace-event@1.0.4: {} cipher-base@1.0.7: diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c517ba7..9ec184e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,9 @@ import type { Metadata } from 'next'; import localFont from 'next/font/local'; -import './globals.css'; +import '@/styles/globals.css'; const pretendard = localFont({ src: [{ path: '../assets/fonts/PretendardVariable.woff2', style: 'normal', weight: '100 900' }], - variable: '--font-family-sans', display: 'swap', fallback: ['system-ui', 'Arial', 'sans-serif'], }); @@ -14,13 +13,9 @@ export const metadata: Metadata = { description: '함께 모으는 우리만의 기록, 공유 가계부 MOA', }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - +
{children}
diff --git a/src/components/Button/constants.ts b/src/components/Button/constants.ts new file mode 100644 index 0000000..46d45a3 --- /dev/null +++ b/src/components/Button/constants.ts @@ -0,0 +1,36 @@ +export const BUTTON_BASE_STYLES = + 'inline-flex items-center justify-center font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed'; + +export const BUTTON_BORDER_RADIUS = { + xs: 'rounded-[var(--rounded-sm)]', + sm: 'rounded-[var(--rounded-sm)]', + md: 'rounded-[var(--rounded-md)]', + lg: 'rounded-[var(--rounded-md)]', + xl: 'rounded-[var(--rounded-md)]', +} as const; + +export const BUTTON_VARIANTS = { + primary: + 'bg-[var(--color-green-normal)] text-white hover:bg-[var(--color-green-normal-hover)] active:bg-[var(--color-green-normal-active)]', + back: 'bg-[var(--color-green-dark)] text-white hover:bg-[var(--color-green-dark-hover)] active:bg-[var(--color-green-dark-active)]', + cancel: + 'bg-[var(--color-grey-light)] text-white hover:bg-[var(--color-grey-light-hover)] active:bg-[var(--color-grey-light-active)]', + outline: + 'bg-[var(--color-green-light)] !text-[var(--color-green-normal)] border border-[var(--color-green-normal)] hover:bg-[var(--color-green-light-hover)] active:bg-[var(--color-green-light-active)]', + text: 'text-[var(--color-green-darker)] hover:text-[var(--color-green-darker-hover)] active:text-[var(--color-green-darker-active)] font-medium', + underline: + 'text-[var(--color-grey-normal)] hover:text-[var(--color-grey-normal-hover)] active:text-[var(--color-grey-normal-active)] font-medium underline decoration-[var(--color-grey-normal)] underline-offset-[4px]', +} as const; + +export const BUTTON_TEXT_SIZES = { + text: 'text-xs-custom', + underline: 'text-sm-custom', +} as const; + +export const BUTTON_SIZES = { + xs: 'h-8 w-[100px] max-w-full text-base-custom', + sm: 'h-8 w-[200px] max-w-full text-base-custom', + md: 'h-10 w-[72px] max-w-full text-lg-custom', + lg: 'h-10 w-[228px] max-w-full text-lg-custom', + xl: 'h-10 w-[300px] max-w-full text-lg-custom', +} as const; diff --git a/src/components/Button/index.stories.tsx b/src/components/Button/index.stories.tsx new file mode 100644 index 0000000..191e00d --- /dev/null +++ b/src/components/Button/index.stories.tsx @@ -0,0 +1,105 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import { Button } from './index'; + +const meta = { + title: 'Components/Button', + component: Button, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { + children: '입력 완료', + variant: 'primary', + }, +}; + +export const Back: Story = { + args: { + children: '이전', + variant: 'back', + }, +}; + +export const Cancel: Story = { + args: { + children: '취소', + variant: 'cancel', + }, +}; + +export const Outline: Story = { + args: { + children: '새 가계부 만들기', + variant: 'outline', + }, +}; + +export const Text: Story = { + args: { + children: '건너뛰기', + variant: 'text', + }, +}; + +export const Underline: Story = { + args: { + children: '건너뛰기', + variant: 'underline', + }, +}; + +export const SizeXS: Story = { + args: { + children: 'XS', + size: 'xs', + }, +}; + +export const SizeSM: Story = { + args: { + children: '다음', + size: 'sm', + }, +}; + +export const SizeMD: Story = { + args: { + children: '확인', + size: 'md', + }, +}; + +export const SizeLG: Story = { + args: { + children: '확인', + size: 'lg', + }, +}; + +export const SizeXL: Story = { + args: { + children: '입력 완료', + size: 'xl', + }, +}; + +export const Loading: Story = { + args: { + children: '입력 완료', + isLoading: true, + }, +}; + +export const Disabled: Story = { + args: { + children: '입력 완료', + disabled: true, + }, +}; diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx new file mode 100644 index 0000000..6ed07d5 --- /dev/null +++ b/src/components/Button/index.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { FC, ButtonHTMLAttributes, ReactNode } from 'react'; +import { + BUTTON_BASE_STYLES, + BUTTON_BORDER_RADIUS, + BUTTON_SIZES, + BUTTON_TEXT_SIZES, + BUTTON_VARIANTS, +} from './constants'; + +export type ButtonVariant = 'primary' | 'back' | 'cancel' | 'outline' | 'text' | 'underline'; +export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + isLoading?: boolean; + children: ReactNode; +} + +const getSizeClasses = (variant: ButtonVariant, size: ButtonSize): string => { + const isTextVariant = variant === 'text' || variant === 'underline'; + return isTextVariant + ? BUTTON_TEXT_SIZES[variant] + : `${BUTTON_SIZES[size]} ${BUTTON_BORDER_RADIUS[size]}`; +}; + +const getButtonClasses = ( + variant: ButtonVariant, + size: ButtonSize, + additionalClasses: string +): string => { + const sizeClasses = getSizeClasses(variant, size); + return `${BUTTON_BASE_STYLES} ${BUTTON_VARIANTS[variant]} ${sizeClasses} ${additionalClasses}`; +}; + +export const Button: FC = ({ + variant = 'primary', + size = 'md', + isLoading = false, + children, + className = '', + disabled, + ...props +}) => { + return ( + + ); +}; + +export default Button; diff --git a/src/app/globals.css b/src/styles/globals.css similarity index 51% rename from src/app/globals.css rename to src/styles/globals.css index 693bfb2..cc86aca 100644 --- a/src/app/globals.css +++ b/src/styles/globals.css @@ -1,18 +1,18 @@ @import 'tailwindcss'; -:root { +@theme { --background: #ffffff; --foreground: #000000; --font-family-sans: 'Pretendard', sans-serif; --font-family-mono: 'Menlo', monospace; - --font-size-xs: 8px; - --font-size-sm: 10px; - --font-size-base: 12px; - --font-size-lg: 14px; - --font-size-xl: 16px; - --font-size-xxl: 24px; + --text-size-xs: 0.5rem; /* 8px */ + --text-size-sm: 0.625rem; /* 10px */ + --text-size-base: 0.75rem; /* 12px */ + --text-size-lg: 0.875rem; /* 14px */ + --text-size-xl: 1rem; /* 16px */ + --text-size-xxl: 1.5rem; /* 24px */ --font-weight-regular: 400; --font-weight-medium: 500; @@ -35,16 +35,18 @@ --color-green-dark-active: #004c41; --color-green-darker: #003b33; - --color-grey-light: #f3f3f3; - --color-grey-light-hover: #ededed; - --color-grey-light-active: #d9d9d9; + --color-grey-light: #c8c8c8; + --color-grey-light-hover: #bdbdbd; + --color-grey-light-active: #afafaf; --color-grey-normal: #848484; - --color-grey-normal-hover: #777777; - --color-grey-normal-active: #6a6a6a; - --color-grey-dark: #636363; - --color-grey-dark-hover: #4f4f4f; - --color-grey-dark-active: #3b3b3b; - --color-grey-darker: #2e2e2e; + --color-grey-normal-hover: #707070; + --color-grey-normal-active: #606060; + --color-grey-dark: #2e2e2e; + --color-grey-dark-hover: #212121; + --color-grey-dark-active: #0f0f0f; + + --color-grey-lighter: #fdfdfd; + --color-grey-lighter-hover: #f2f2f2; --color-red: #ed4242; @@ -52,13 +54,38 @@ --layout-width: 440px; - --radius-sm: 4px; - --radius-md: 8px; + --rounded-sm: 4px; + --rounded-md: 8px; --color-background: var(--background); --color-foreground: var(--foreground); } +/* Custom text size utilities */ +.text-xs-custom { + font-size: var(--text-size-xs); +} + +.text-sm-custom { + font-size: var(--text-size-sm); +} + +.text-base-custom { + font-size: var(--text-size-base); +} + +.text-lg-custom { + font-size: var(--text-size-lg); +} + +.text-xl-custom { + font-size: var(--text-size-xl); +} + +.text-xxl-custom { + font-size: var(--text-size-xxl); +} + body { background: var(--background); color: var(--foreground); diff --git a/svgr.d.ts b/svgr.d.ts index bd3438f..100cfb5 100644 --- a/svgr.d.ts +++ b/svgr.d.ts @@ -3,8 +3,3 @@ declare module '*.svg' { const content: FC>; export default content; } - -declare module '*.svg?url' { - const content: string; - export default content; -} diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index e4644ff..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { defineConfig } from 'vitest/config'; - -import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; - -const dirname = - typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); - -// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon -export default defineConfig({ - test: { - projects: [ - { - extends: true, - plugins: [ - // The plugin will run tests for the stories defined in your Storybook config - // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest - storybookTest({ configDir: path.join(dirname, '.storybook') }), - ], - test: { - name: 'storybook', - browser: { - enabled: true, - headless: true, - provider: 'playwright', - instances: [{ browser: 'chromium' }], - }, - setupFiles: ['.storybook/vitest.setup.ts'], - }, - }, - ], - }, -}); diff --git a/vitest.shims.d.ts b/vitest.shims.d.ts deleted file mode 100644 index a1d31e5..0000000 --- a/vitest.shims.d.ts +++ /dev/null @@ -1 +0,0 @@ -///