diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..80603b2f --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,140 @@ +language: "en" +early_access: false +reviews: + profile: "assertive" + high_level_summary: true + poem: false + review_status: true + collapse_walkthrough: false + path_filters: + - "!.templates/**" + - "!.changeset/**" + - "!**/pnpm-lock.yaml" + - "!**/node_modules/**" + - "!**/dist/**" + - "!**/storybook-static/**" + - "!**/*.d.ts" + path_instructions: + # React Components + - path: "**/packages/*/src/*.tsx" + instructions: | + Review React component code for: + - Consistent component patterns following existing components (forwardRef, props interface, const enums) + - Proper TypeScript usage with strict typing + - Accessibility best practices (semantic HTML, ARIA attributes) + - Component composition patterns using Radix UI Slot when applicable + - ThemeProvider integration - components should work with all theme variants (1st-4th) + - Consistent prop naming and structure with existing components + - Proper use of clsx for conditional className handling + - Integration with design token system and theme contract variables + + # Vanilla Extract CSS + - path: "**/packages/*/src/*.css.ts" + instructions: | + Review Vanilla Extract styling for: + - Proper usage of design tokens from @sipe-team/tokens (vars.color, vars.spacing, etc.) + - ThemeProvider compatibility - use vars.color.primary instead of hardcoded colors + - Consistent recipe patterns with variants and compound variants + - Proper hover states and transitions following existing patterns + - Theme-aware color usage that works with multiple theme variants (1st-4th themes) + - Avoid hardcoded colors like '#00ffff' - use theme contract variables instead + - Responsive design considerations using token-based breakpoints + - Performance-optimized CSS generation with proper CSS layers + + # Test Files + - path: "**/packages/*/src/*.test.tsx" + instructions: | + Review test files for: + - English test descriptions (convert Korean descriptions to English) + - Comprehensive test coverage including visual, behavioral, and accessibility tests + - Theme compatibility testing - ensure components work with different theme variants + - Proper use of @testing-library/react patterns + - Testing component variants, props, and edge cases + - ThemeProvider wrapper in tests when testing theme-dependent components + - Consistent test structure and naming conventions + - Proper setup and cleanup in test files + + # Storybook Stories + - path: "**/packages/*/src/*.stories.tsx" + instructions: | + Review Storybook stories for: + - Complete component variant coverage in stories + - Theme showcase - demonstrate components with different theme variants (1st-4th) + - ThemeProvider integration in stories for theme-dependent components + - Proper story naming and organization + - Accessible and informative story descriptions + - Consistent args and argTypes definitions + - Documentation value for design system users + + # Design Tokens + - path: "**/packages/tokens/src/**/*.ts" + instructions: | + Review design token changes for: + - Backward compatibility with existing components + - Consistent naming conventions following established patterns + - Proper TypeScript exports and type definitions + - Impact on overall design system consistency + - Documentation of token usage and purpose + + # Package Configuration + - path: "**/packages/*/package.json" + instructions: | + Review package.json changes for: + - Version consistency with changeset workflow + - Proper dependency management (peer deps, dev deps) + - Consistent package structure and scripts + - Proper exports and entry points configuration + + # TypeScript Configuration + - path: "**/tsconfig.json" + instructions: | + Review TypeScript configuration for: + - Strict type checking maintenance + - Consistent compiler options across packages + - Proper path mappings and module resolution + - Build optimization settings + + # Build Configuration + - path: "**/packages/*/tsup.config.ts" + instructions: | + Review build configuration for: + - Consistent build targets and formats + - Proper external dependencies handling + - Source map and declaration generation + - Bundle optimization settings + + # Root Configuration Files + - path: "biome.json" + instructions: | + Review Biome configuration for: + - Consistent formatting and linting rules + - Import organization rules following established patterns + - Accessibility rule consistency + - Integration with existing toolchain + + # Documentation + - path: "**/README.md" + instructions: | + Review documentation for: + - Clear usage examples and API documentation + - Consistency with design system documentation standards + - Accessibility guidelines and examples + - Installation and setup instructions + + auto_review: + enabled: true + ignore_title_keywords: + - "Version Packages" + - "WIP" + - "DO NOT MERGE" + - "[WIP]" + - "draft" + - "Release" + - "release" + drafts: false + base_branches: + - "main" + - "release/v1" + auto_incremental_review: true +chat: + auto_reply: true diff --git a/.github/workflows/chromatic.yaml b/.github/workflows/chromatic.yaml deleted file mode 100644 index 0dea784a..00000000 --- a/.github/workflows/chromatic.yaml +++ /dev/null @@ -1,46 +0,0 @@ -name: Chromatic - -on: - pull_request: - types: [opened, ready_for_review, synchronize] - branches: - - main - paths: - - '**/*.stories.@(js|jsx|ts|tsx)' - push: - branches: - - main - paths: - - '**/*.stories.@(js|jsx|ts|tsx)' - -jobs: - chromatic: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - name: Setup Node.js environment - uses: actions/setup-node@v4 - with: - cache: pnpm - node-version-file: .nvmrc - - name: Install dependencies - run: pnpm install - - name: Run Chromatic - id: chromatic - uses: chromaui/action@latest - with: - projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - name: comment PR - uses: thollander/actions-comment-pull-request@v1 - if: ${{ github.event_name == 'pull_request' }} - env: - GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} - with: - message: "๐Ÿ’… The Storybook has been updated! Click [here](${{ steps.chromatic.outputs.storybookUrl }}) to check it out." diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a78d5e92..9bfed66c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,7 +24,7 @@ jobs: - name: Install dependencies run: pnpm install - name: Lint - run: pnpm --filter="...[origin/${{ github.base_ref }}]" "/lint:*/" + run: pnpm --filter="...[origin/${{ github.base_ref }}]" lint - name: Test run: pnpm --filter="...[origin/${{ github.base_ref }}]" test - name: Type check diff --git a/.github/workflows/storybook-deploy.yaml b/.github/workflows/storybook-deploy.yaml deleted file mode 100644 index 8eeb9940..00000000 --- a/.github/workflows/storybook-deploy.yaml +++ /dev/null @@ -1,38 +0,0 @@ -name: Deploy Storybook - -on: - push: - branches: - - main - paths: - - 'packages/**/src/**/*.stories.@(js|jsx|ts|tsx)' - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - container: pandoc/latex - steps: - - uses: actions/checkout@v2 - - name: Install mustache (to update the date) - run: apk add ruby && gem install mustache - - name: creates output - run: sh ./build.sh - - name: Copy .storybook folder - run: cp -r .storybook output/ - - name: Pushes to another repository - id: push_directory - uses: cpina/github-action-push-to-another-repository@main - env: - API_TOKEN_GITHUB: ${{ secrets.GH_TOKEN }} - DESTINATION_USERNAME: "froggy1014" - DESTINATION_REPO: "side-storybook" - with: - source-directory: "output" - destination-github-username: ${{ env.DESTINATION_USERNAME }} - destination-repository-name: ${{ env.DESTINATION_REPO }} - user-email: ${{ secrets.EMAIL }} - commit-message: ${{ github.event.commits[0].message || 'Manual deployment via workflow_dispatch' }} - target-branch: main - - name: Test get variable exported by push-to-another-repository - run: echo $DESTINATION_CLONED_DIRECTORY diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 836149df..1a8d8f33 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,7 +1,15 @@ import 'sanitize.css'; import 'sanitize.css/typography.css'; + import type { Preview } from '@storybook/react'; export default { tags: ['autodocs'], + parameters: { + options: { + storySort: { + order: ['INTRO', 'Components'], + }, + }, + }, } satisfies Preview; diff --git a/.templates/component/package.json b/.templates/component/package.json index 2ed5cdcf..6f655c1f 100644 --- a/.templates/component/package.json +++ b/.templates/component/package.json @@ -14,8 +14,7 @@ "build": "tsup", "build:storybook": "storybook build", "dev:storybook": "storybook dev -p 6006", - "lint:biome": "pnpm exec biome lint", - "lint:eslint": "pnpm exec eslint", + "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", "prepack": "pnpm run build" diff --git a/.vscode/extensions.json b/.vscode/extensions.json index f2a6e32e..162f7a46 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,6 @@ { "recommendations": [ "biomejs.biome", - "dbaeumer.vscode-eslint", "github.vscode-github-actions" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 575d6e0b..b59dd216 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,26 @@ { "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, "editor.codeActionsOnSave": { "quickfix.biome": "explicit", "source.organizeImports.biome": "explicit" - } + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, } diff --git a/README.md b/README.md index e5f216ad..cfcfc5d3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ![](./public/og-image.png) # Sipe Design System -[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/sipe-team/side/blob/main/LICENSE) ![Package Manager](https://img.shields.io/badge/pnpm-9.7.1-orange?logo=pnpm) [![Storybook](https://img.shields.io/badge/Storybook-8.4.7-ff4785?logo=storybook)](https://67417e47644abe8d4e63f82f-lynsfaiqst.chromatic.com) ![Tests](https://img.shields.io/badge/Vitest-2.1.4-green?logo=vitest) [![codecov](https://codecov.io/gh/sipe-team/side/branch/changeset-release%2Fmain/graph/badge.svg?token=1TNLVUFPXC)](https://codecov.io/gh/sipe-team/side) Github Stars +[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/sipe-team/side/blob/main/LICENSE) ![Package Manager](https://img.shields.io/badge/pnpm-9.7.1-orange?logo=pnpm) [![Storybook](https://img.shields.io/badge/Storybook-8.4.7-ff4785?logo=storybook)](https://storybook.sipe.team/?path=/docs/what-is-side--docs) ![Tests](https://img.shields.io/badge/Vitest-2.1.4-green?logo=vitest) [![codecov](https://codecov.io/gh/sipe-team/side/branch/changeset-release%2Fmain/graph/badge.svg?token=1TNLVUFPXC)](https://codecov.io/gh/sipe-team/side) Github Stars Sipe Design System is a monorepo-based component library built to modernize and standardize the official Sipe website. Drawing inspiration from our existing design patterns, we're creating a robust, type-safe, and accessible component system that can be used across all Sipe projects. diff --git a/biome.json b/biome.json index 27f6d1e0..b2030f4d 100644 --- a/biome.json +++ b/biome.json @@ -9,8 +9,10 @@ "indentStyle": "space", "lineWidth": 120 }, - "linter": { + "domains": { + "react": "recommended" + }, "enabled": true, "rules": { "correctness": { @@ -30,5 +32,37 @@ "formatter": { "quoteStyle": "single" } + }, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true, + "root": "." + }, + "assist": { + "actions": { + "source": { + "organizeImports": { + "level": "on", + "options": { + "groups": [ + ":NODE:", + ":BLANK_LINE:", + ["react", "react/**", "react-*", "react-*/**"], + ":BLANK_LINE:", + ["@sipe-team/tokens", "@sipe-team/typography", "@sipe-team/**"], + ":BLANK_LINE:", + ["@vanilla-extract/**"], + ":BLANK_LINE:", + ["@radix-ui/**"], + ":BLANK_LINE:", + ":PACKAGE:", + ":BLANK_LINE:", + ":PATH:" + ] + } + } + } + } } } diff --git a/build.sh b/build.sh deleted file mode 100644 index 98817681..00000000 --- a/build.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -cd ../ -mkdir output - -cp -r .storybook output/ -cp -r .gitignore output/ -cp -R ./side/* ./output -cp -R ./output ./side/ \ No newline at end of file diff --git a/chromatic.config.json b/chromatic.config.json deleted file mode 100644 index 2ed4407c..00000000 --- a/chromatic.config.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://www.chromatic.com/config-file.schema.json", - "buildScriptName": "build:storybook" -} diff --git a/docs/introduction.mdx b/docs/introduction.mdx index d1153b9c..a14255f6 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -1,17 +1,11 @@ import { Meta } from '@storybook/addon-docs'; import { Typography } from '../packages/typography/src'; - +
- +

Sipe Design System

diff --git a/docs/tokens.mdx b/docs/tokens.mdx new file mode 100644 index 00000000..cede9f15 --- /dev/null +++ b/docs/tokens.mdx @@ -0,0 +1,798 @@ +import { Meta } from '@storybook/addon-docs'; +import { color, semanticColor, themeColor } from '../packages/tokens/src/colors/colors'; +import { fontSize, fontWeight, lineHeight } from '../packages/tokens/src/typography/fonts'; +import { Typography } from '../packages/typography/src'; + + + +
+
+ +

Design Tokens

+
+

+ These are the core tokens of the SIPE design system. They define the fundamental elements for design consistency including colors, typography, spacing, and more. +

+
+ + {/* Base Colors Section */} +
+

+ Base Colors +

+

+ This is the basic color palette used in the SIPE design system. Each color is organized in steps from 50 to 950. +

+ +
+ {['gray', 'red', 'pink', 'purple', 'cyan', 'blue', 'teal', 'green', 'yellow', 'orange'].map((colorName) => { + const colors = Object.entries(color) + .filter(([key]) => key.startsWith(colorName)) + .sort((a, b) => { + const aNum = parseInt(a[0].replace(colorName, '')) || 0; + const bNum = parseInt(b[0].replace(colorName, '')) || 0; + return aNum - bNum; + }); + + return ( +
+

+ {colorName} +

+
+ {colors.map(([key, value]) => ( +
{ + try { + await navigator.clipboard.writeText(value); + console.log(`Copied: ${value}`); + } catch (err) { + console.error('Failed to copy: ', err); + } + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'scale(1.05)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'scale(1)'; + }} + title={`${key}: ${value}`}> +
+ + {key.replace(colorName, '')} + + + {value} + +
+ ))} +
+
+ ); + })} +
+
+ + {/* Theme Colors Section */} +
+

+ Theme Colors +

+

+ These are theme-specific colors used in the SIPE design system. Each theme includes primary, secondary, background, text colors and gradients. +

+ +
+ {Object.entries(themeColor).map(([themeName, theme]) => ( +
+

+ {themeName} +

+
+ {Object.entries(theme).filter(([key]) => key !== 'gradient').map(([key, value]) => ( +
{ + try { + await navigator.clipboard.writeText(value); + console.log(`Copied: ${value}`); + } catch (err) { + console.error('Failed to copy: ', err); + } + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'scale(1.05)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'scale(1)'; + }} + title={`${key}: ${value}`}> +
+ + {key} + + + {value} + +
+ ))} +
+ {theme.gradient && ( +
{ + try { + await navigator.clipboard.writeText(theme.gradient); + console.log(`Copied: ${theme.gradient}`); + } catch (err) { + console.error('Failed to copy: ', err); + } + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'scale(1.02)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'scale(1)'; + }} + title={`gradient: ${theme.gradient}`}> +
+
+ + gradient + + + {theme.gradient} + +
+
+ )} +
+ ))} +
+
+ + {/* Semantic Colors Section */} +
+

+ Semantic Colors +

+

+ Colors defined by their semantic meaning. These include colors that represent states such as success, warning, error, and more. +

+ +
+
+ {Object.entries(semanticColor).map(([key, value]) => ( +
{ + try { + await navigator.clipboard.writeText(value); + console.log(`Copied: ${value}`); + } catch (err) { + console.error('Failed to copy: ', err); + } + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'scale(1.05)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'scale(1)'; + }} + title={`${key}: ${value}`}> +
+ + {key} + + + {value} + +
+ ))} +
+
+
+ + {/* Typography Section */} +
+

+ Typography +

+

+ Typography tokens used in the SIPE design system. Based on the Pretendard Variable font. +

+ +
+

+ Font Family +

+

+ Pretendard Variable Font +

+
+ + {/* Font Sizes */} +
+

+ Font Sizes +

+
+ + + + + + + + + + {Object.entries(fontSize).map(([key, value]) => ( + + + + + + ))} + +
+ Token + + Size + + Example +
+ fontSize.{key} + + {value}px + + SIPE Design System +
+
+
+ + {/* Font Weights */} +
+

+ Font Weights +

+
+ + + + + + + + + + {Object.entries(fontWeight).map(([key, value]) => ( + + + + + + ))} + +
+ Token + + Weight + + Example +
+ fontWeight.{key} + + {value} + + SIPE Design System +
+
+
+ + {/* Line Heights */} +
+

+ Line Heights +

+
+ + + + + + + + + + {Object.entries(lineHeight).map(([key, value]) => ( + + + + + + ))} + +
+ Token + + Value + + Example +
+ lineHeight.{key} + + {Math.round(value * 100)}% + + This is an example of line spacing used in the SIPE Design System.
+ When text spans multiple lines, you can see
+ the spacing between lines. +
+
+
+
+ + {/* Usage Guidelines */} +
+

+ Usage Guidelines +

+ +
+

+ How to Use Tokens +

+ +
+

+ Import Tokens +

+
+ {`import { color, semanticColor, themeColor } from '@sipe/tokens'; +import { fontSize, fontWeight, lineHeight } from '@sipe/tokens';`} +
+
+ +
+

+ Use in Styles +

+
+ {`const styles = { + color: color.gray900, + backgroundColor: color.blue50, + fontSize: fontSize[16], + fontWeight: fontWeight.medium, + lineHeight: lineHeight.normal, +};`} +
+
+ +
+

+ Best Practices +

+
    +
  • Always use tokens to avoid hardcoded values
  • +
  • Prioritize semantic colors
  • +
  • Use theme colors where branding is needed
  • +
  • Apply typography scales consistently
  • +
+
+
+
+
diff --git a/eslint.config.ts b/eslint.config.ts deleted file mode 100644 index f4d23c80..00000000 --- a/eslint.config.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { FlatCompat } from '@eslint/eslintrc'; -import eslint from '@eslint/js'; -import reactPlugin from 'eslint-plugin-react'; -import hooksPlugin from 'eslint-plugin-react-hooks'; -import globals from 'globals'; -import tseslint from 'typescript-eslint'; - -const compat = new FlatCompat(); - -// ? https://typescript-eslint.io/getting-started#step-2-configuration -export default tseslint.config( - // * Base ESLint recommended configuration - eslint.configs.recommended, - // * TypeScript ESLint recommended configuration - tseslint.configs.recommended, - // * Custom rules configuration - { - rules: { - '@typescript-eslint/ban-ts-comment': 'off', - '@typescript-eslint/no-unused-vars': 'off', - '@typescript-eslint/no-explicit-any': 'off', - 'no-duplicate-imports': 'off', - 'no-unused-expressions': 'off', - '@typescript-eslint/no-unused-expressions': [ - 'error', - { - allowShortCircuit: false, - allowTernary: true, - }, - ], - }, - }, - - // * React plugin configuration - { - files: ['**/*.{ts,tsx}'], - plugins: { - react: reactPlugin, - }, - languageOptions: { - globals: { - ...globals.serviceworker, - ...globals.browser, - }, - }, - rules: { - ...reactPlugin.configs.recommended.rules, - 'react/react-in-jsx-scope': 'off', - 'react/prop-types': 'off', - }, - settings: { - react: { - version: 'detect', - }, - }, - }, - - // * React Hooks plugin configuration - ...compat.extends('plugin:react-hooks/recommended'), - { - files: ['**/*.{ts,tsx}'], - plugins: { - 'react-hooks': hooksPlugin, - }, - rules: { - ...hooksPlugin.configs.recommended.rules, - }, - }, - - // * Storybook plugin configuration - ...compat.extends('plugin:storybook/recommended'), - { - files: ['**/*.stories.@(ts|tsx|js|jsx|mjs|cjs)'], - rules: {}, - }, - - // Ignore files configuration - { - ignores: ['**/*/dist/', '**/node_modules/', '*.config.*'], - }, -); diff --git a/package.json b/package.json index fa4a82db..ab0409ae 100644 --- a/package.json +++ b/package.json @@ -5,16 +5,14 @@ "scripts": { "prepare": "husky", "cz": "cz", - "format": "pnpm /^format:.*/", - "format:biome": "biome format --write", - "lint": "pnpm /^lint:.*/", - "lint:biome": "biome lint --write", - "lint:eslint": "eslint --fix", + "format": "biome format --write", + "lint": "biome lint --write", "dev:storybook": "storybook dev -p 6006 public", "build:storybook": "storybook build public", "serve:storybook": "serve storybook-static -p 6006", "create:component": "tsx scripts/createComponent.ts create", - "test": "vitest" + "test": "vitest", + "clean": "pnpm --filter './www' clean && pnpm --filter './packages/*' clean" }, "devDependencies": { "@biomejs/biome": "^2.4.10", @@ -24,7 +22,6 @@ "@commitlint/config-conventional": "^19.6.0", "@commitlint/cz-commitlint": "^19.6.1", "@commitlint/types": "^19.5.0", - "@eslint/eslintrc": "^3.2.0", "@storybook/addon-docs": "^8.4.7", "@storybook/addon-essentials": "catalog:", "@storybook/addon-interactions": "catalog:", @@ -36,19 +33,15 @@ "@storybook/test": "catalog:", "@storybook/theming": "^8.4.7", "@tsconfig/strictest": "^2.0.5", - "@types/eslint__eslintrc": "^2.1.2", "@types/node": "^22.8.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@typescript-eslint/parser": "^8.19.0", + "@vanilla-extract/esbuild-plugin": "catalog:", + "@vanilla-extract/vite-plugin": "catalog:", "@vitest/coverage-v8": "catalog:", "chromatic": "^11.19.0", "clipanion": "4.0.0-rc.4", "commitizen": "^4.3.1", - "eslint": "^9.20.0", - "eslint-plugin-react": "^7.37.3", - "eslint-plugin-react-hooks": "^5.1.0", - "eslint-plugin-storybook": "^0.11.2", "globals": "^15.14.0", "husky": "^9.1.7", "knip": "catalog:", @@ -58,18 +51,13 @@ "tsup": "catalog:", "tsx": "^4.19.2", "typescript": "catalog:", - "typescript-eslint": "^8.19.1", + "vite": "catalog:", "vitest": "catalog:" }, "packageManager": "pnpm@9.7.1", "lint-staged": { - "*.{ts,tsx}": [ - "pnpm format", - "pnpm lint" - ], - "*.{json,css,js}": [ - "pnpm format:biome", - "pnpm lint:biome" + "*.{ts,tsx,css,js}": [ + "biome check --write --unsafe --no-errors-on-unmatched" ] }, "config": { diff --git a/packages/reset/.storybook/main.ts b/packages/accordion/.storybook/main.ts similarity index 100% rename from packages/reset/.storybook/main.ts rename to packages/accordion/.storybook/main.ts diff --git a/packages/accordion/.storybook/preview.ts b/packages/accordion/.storybook/preview.ts new file mode 100644 index 00000000..6c95c9f1 --- /dev/null +++ b/packages/accordion/.storybook/preview.ts @@ -0,0 +1,8 @@ +import 'sanitize.css'; +import 'sanitize.css/typography.css'; + +import type { Preview } from '@storybook/react'; + +export default { + tags: ['autodocs'], +} satisfies Preview; diff --git a/packages/accordion/README.md b/packages/accordion/README.md new file mode 100644 index 00000000..a965dc9f --- /dev/null +++ b/packages/accordion/README.md @@ -0,0 +1,108 @@ +# Accordion + +A collapsible content component for the Sipe Design System. + +## Installation + +```bash +pnpm add @sipe-team/accordion +``` + +## Usage + +```tsx +import { Accordion } from '@sipe-team/accordion'; + +function Example() { + return ( + + + + Trigger + + + Content + + + + ); +} +``` + +## Features + +- Smooth open/close animations +- Accessible by default with correct ARIA attributes +- Customizable styling with vanilla-extract +- TypeScript support +- Using compound component pattern + +## Components + +### Accordion.Root + +The container component for accordion items. + +#### Props + +| Name | Type | Default | Description | +| -------- | --------- | ------- | ---------------------------------------------------------- | +| children | ReactNode | - | The accordion items to render | +| asChild | boolean | false | Change the component to the HTML tag or component supplied | +| className | string | - | Additional CSS class name| +| ...props | - | - | All other props are passed to the underlying div element | + +### Accordion.Item + +An individual accordion section with a header and collapsible content. + +#### Props + +| Name | Type | Default | Description | +| ----------- | --------- | ------- | -------------------------------------------------------- | +| children | ReactNode | - | The content to display when the accordion item is open | +| defaultOpen | boolean | false | Whether the accordion item should be open by default | +| className | string | - | Additional CSS class name| +| ...props | - | - | All other props are passed to the underlying div element | + + +### Accordion.Trigger + +The button that toggles the accordion item. + +#### Props + +| Name | Type | Default | Description | +| ----------- | --------- | ------- | -------------------------------------------------------- | +| children | ReactNode | - | TThe content to display in the trigger button| +| className | string | - | Additional CSS class name| +| ...props | - | - | All other props are passed to the underlying div element | + + +### Accordion.Content + +The collapsible content section. + +#### Props + +| Name | Type | Default | Description | +| ----------- | --------- | ------- | -------------------------------------------------------- | +| children | ReactNode | - | The content to display when the accordion item is open | +| asChild | boolean | false | Change the component to the HTML tag or component supplied | +| className | string | - | Additional CSS class name| +| ...props | - | - | All other props are passed to the underlying div element | + + +## Styling + +- This component uses vanilla-extract for styling. The styles are defined in `Accordion.css.ts`. +- You can customize the appearance by passing className props to individual components. + + +## Preview + +- Run Storybook to preview Accodion + +```bash +pnpm dev:storybook +``` diff --git a/packages/accordion/package.json b/packages/accordion/package.json new file mode 100644 index 00000000..e9328151 --- /dev/null +++ b/packages/accordion/package.json @@ -0,0 +1,73 @@ +{ + "name": "@sipe-team/accordion", + "description": "Accordion for Sipe Design System", + "version": "0.1.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/sipe-team/side" + }, + "type": "module", + "exports": "./src/index.ts", + "files": ["dist"], + "scripts": { + "build": "tsup", + "build:storybook": "storybook build", + "dev:storybook": "storybook dev -p 6006", + "lint:biome": "pnpm exec biome lint", + "lint:eslint": "pnpm exec eslint", + "test": "vitest", + "typecheck": "tsc", + "prepack": "pnpm run build" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.1.0", + "@sipe-team/tokens": "workspace:*", + "@vanilla-extract/css": "catalog:", + "@vanilla-extract/recipes": "^0.5.5", + "clsx": "^2.1.0", + "@sipe-team/icon": "workspace:*" + }, + "devDependencies": { + "@sipe-team/typography": "workspace:*", + "@storybook/addon-essentials": "catalog:", + "@storybook/addon-interactions": "catalog:", + "@storybook/addon-links": "catalog:", + "@storybook/blocks": "catalog:", + "@storybook/react": "catalog:", + "@storybook/react-vite": "catalog:", + "@storybook/test": "catalog:", + "@testing-library/jest-dom": "catalog:", + "@testing-library/react": "catalog:", + "@types/react": "^18.3.12", + "happy-dom": "catalog:", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "storybook": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "react": ">= 18", + "react-dom": ">= 18" + }, + "publishConfig": { + "access": "public", + "registry": "https://npm.pkg.github.com", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./styles.css": "./dist/index.css" + } + }, + "sideEffects": false +} diff --git a/packages/accordion/src/Accordion.css.ts b/packages/accordion/src/Accordion.css.ts new file mode 100644 index 00000000..7a715e54 --- /dev/null +++ b/packages/accordion/src/Accordion.css.ts @@ -0,0 +1,81 @@ +import { color } from '@sipe-team/tokens'; + +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +const whiteColor = color.white; +const backgroundColor = '#1a202c'; +const sipeAccordionBackGroundColor = '#2d3748'; + +export const accordionRoot = style({ + width: '100%', + borderRadius: '12px', + overflow: 'hidden', + padding: '20px', + backgroundColor: backgroundColor, + border: `1px solid ${sipeAccordionBackGroundColor}`, +}); + +export const accordionItem = style({ + borderBottom: `1px solid ${backgroundColor}`, + ':last-child': { + borderBottom: 'none', + }, +}); + +export const accordionTrigger = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + padding: '16px', + backgroundColor: backgroundColor, + border: 'none', + cursor: 'pointer', + textAlign: 'left', + color: whiteColor, +}); + +export const accordionContentWrapper = recipe({ + base: { + overflow: 'hidden', + borderRadius: '8px', + backgroundColor: sipeAccordionBackGroundColor, + }, + variants: { + shouldTransition: { + true: { + transition: 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1)', + }, + false: { + transition: 'none', + }, + }, + }, + defaultVariants: { + shouldTransition: false, + }, +}); + +export const accordionContentInner = style({ + padding: '12px 16px', +}); + +export const chevron = recipe({ + base: { + transition: 'transform 0.3s ease', + }, + variants: { + isOpen: { + true: { + transform: 'rotate(0deg)', + }, + false: { + transform: 'rotate(180deg)', + }, + }, + }, + defaultVariants: { + isOpen: false, + }, +}); diff --git a/packages/accordion/src/Accordion.stories.tsx b/packages/accordion/src/Accordion.stories.tsx new file mode 100644 index 00000000..e0e6407a --- /dev/null +++ b/packages/accordion/src/Accordion.stories.tsx @@ -0,0 +1,155 @@ +import { color } from '@sipe-team/tokens'; +import { Typography } from '@sipe-team/typography'; + +import type { Meta, StoryObj } from '@storybook/react'; + +import { Accordion } from './Accordion'; + +const contentTextColor = color.gray200; + +const meta: Meta = { + title: 'Components/Accordion', + component: Accordion, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: () => ( + + + + + ์ˆ˜๋„๊ถŒ์— ๊ฑฐ์ฃผํ•˜๊ณ  ์žˆ์ง€ ์•Š์ง€๋งŒ ์ฃผ์š” ํ™œ๋™ ์ง€์—ญ์€ ์ˆ˜๋„๊ถŒ์ธ๋ฐ ํ™œ๋™์„ ํ•  ์ˆ˜ ์žˆ๋‚˜์š”? + + + + + + ๋„ค ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ๋‹ค๋งŒ, ๋ชจ๋“  ํ™œ๋™์ด ์ˆ˜๋„๊ถŒ์—์„œ ์ง„ํ–‰๋  ์˜ˆ์ •์œผ๋กœ, ๊ฒฐ์„์ด๋‚˜ ์ง€๊ฐ์„ ํ•˜๋Š” ๊ฒฝ์šฐ ์ˆ˜๋ฃŒ ์กฐ๊ฑด์— ์˜ํ–ฅ์ด + ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + + + + + ), +}; + +export const WithDefaultOpen: Story = { + render: () => ( + + + + ์ด ํ•ญ๋ชฉ์€ ๊ธฐ๋ณธ์œผ๋กœ ์—ด๋ ค์žˆ์Šต๋‹ˆ๋‹ค. + + + + + `Accordion.Item` ์ปดํฌ๋„ŒํŠธ์— `defaultOpen` prop์„ ์ „๋‹ฌํ•˜์—ฌ ๊ธฐ๋ณธ ์ƒํƒœ๋ฅผ ์—ด๋ฆผ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + + + + + + + ์ด ํ•ญ๋ชฉ์€ ๋‹ซํ˜€์žˆ์Šต๋‹ˆ๋‹ค. + + + + + ๋‘ ๋ฒˆ์งธ ํ•ญ๋ชฉ์˜ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค. + + + + + ), +}; + +export const AccordionList: Story = { + render: () => ( +
+ + + + 4๊ธฐ ์„ ๋ฐœ ๊ธฐ์ค€์€ ์–ด๋–ป๊ฒŒ ๋˜๋‚˜์š”? + + + + + ํ•จ๊ป˜ ๋Œ€ํ™”ํ•˜๊ณ  ์‹ถ์€, ๊ตฌ์„ฑ์›๋“ค์˜ ๊ธฐ์ˆ ์  ์„ฑ์žฅ์— ๊ธฐ์—ฌํ•  ์ˆ˜ ์žˆ๋Š”, ๊ทธ๋ฆฌ๊ณ  ๋™์•„๋ฆฌ ํ™œ๋™์— ์„ฑ์‹คํ•˜๊ฒŒ ์ฐธ์—ฌ ๊ฐ€๋Šฅํ•œ + ํ˜„์ง ๊ฐœ๋ฐœ์ž๋ฅผ ์ €ํฌ์˜ ์ธ์žฌ์ƒ์œผ๋กœ ์‚ผ๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. + + + + + + + + + ์ˆ˜๋„๊ถŒ์— ๊ฑฐ์ฃผํ•˜๊ณ  ์žˆ์ง€ ์•Š์ง€๋งŒ ์ฃผ์š” ํ™œ๋™ ์ง€์—ญ์€ ์ˆ˜๋„๊ถŒ์ธ๋ฐ ํ™œ๋™์„ ํ•  ์ˆ˜ ์žˆ๋‚˜์š”? + + + + + + ๋„ค ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ๋‹ค๋งŒ, ๋ชจ๋“  ํ™œ๋™์ด ์ˆ˜๋„๊ถŒ์—์„œ ์ง„ํ–‰๋  ์˜ˆ์ •์œผ๋กœ, ๊ฒฐ์„์ด๋‚˜ ์ง€๊ฐ์„ ํ•˜๋Š” ๊ฒฝ์šฐ ์ˆ˜๋ฃŒ ์กฐ๊ฑด์— ์˜ํ–ฅ์ด + ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + + + + + + + + 4๊ธฐ ์„ ๋ฐœ ์ธ์›์€ ๋ช‡๋ช…์ธ๊ฐ€์š”? + + + + + 4๊ธฐ๋Š” 40๋ช… ๋‚ด์™ธ๋กœ ๊ตฌ์„ฑํ•  ์˜ˆ์ •์ด๋ฉฐ, ์„ ๋ฐœ ์ธ์›์€ ์ง€์›์ž ์ˆ˜์— ๋”ฐ๋ผ์„œ ๋ณ€๋™๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + + + + + + + + ๋ฏธ์„ฑ๋…„์ž์ด์ง€๋งŒ ๊ฐœ๋ฐœ์ž๋กœ ๊ทผ๋ฌดํ•˜๊ณ  ์žˆ๋Š”๋ฐ ์ง€์›ํ•  ์ˆ˜ ์žˆ๋‚˜์š”? + + + + + ์•„๋‹ˆ์˜ค. ์•„์‰ฝ์ง€๋งŒ ์‚ฌ์ดํ”„๋Š” ๋ฏธ์„ฑ๋…„์ž๋Š” ์„ ๋ฐœ ๋Œ€์ƒ์—์„œ ์ œ์™ธํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. + + + + +
+ ), +}; + +export const TriggerUsingAsChild: Story = { + render: () => ( + + + +
+ asChild prop์„ ์‚ฌ์šฉํ•˜์—ฌ `div`๋กœ ๋ Œ๋”๋ง๋œ Trigger + +
+
+ + + `Accordion.Trigger` ์ปดํฌ๋„ŒํŠธ์— `asChild` prop์„ ์ „๋‹ฌํ•˜์—ฌ ๋‹ค๋ฅธ HTML ์š”์†Œ๋กœ ๋ Œ๋”๋งํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + + +
+
+ ), +}; diff --git a/packages/accordion/src/Accordion.test.tsx b/packages/accordion/src/Accordion.test.tsx new file mode 100644 index 00000000..1d4948db --- /dev/null +++ b/packages/accordion/src/Accordion.test.tsx @@ -0,0 +1,190 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; + +import { Accordion } from './Accordion'; + +describe('Accordion.Root ๊ธฐ๋ณธ ์Šคํƒ€์ผ', () => { + test('Accordion์˜ Root์— border-radius๋ฅผ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด ๊ธฐ๋ณธ ๊ฐ’ "12px"์œผ๋กœ border-radius๋ฅผ ์„ค์ •ํ•œ๋‹ค.', () => { + render( + + + Test Trigger + Test Content + + , + ); + const root = screen.getByText('Test Trigger').closest('[class*="accordionRoot"]'); + expect(root).toHaveStyle({ borderRadius: '12px' }); + }); + + test('Accordion์˜ Root์— border ์˜ต์…˜์„ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด ๊ธฐ๋ณธ ๊ฐ’ "1px solid #2d3748"๋กœ border๋ฅผ ์„ค์ •ํ•œ๋‹ค.', () => { + render( + + + Test Trigger + Test Content + + , + ); + const root = screen.getByText('Test Trigger').closest('[class*="accordionRoot"]'); + expect(root).toHaveStyle({ border: '1px solid #2d3748' }); + }); + + test('Accordion์˜ Root์— background-color ์˜ต์…˜์„ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด ๊ธฐ๋ณธ ๊ฐ’ "#1a202c"๋กœ background-color๋ฅผ ์„ค์ •ํ•œ๋‹ค.', () => { + render( + + + Test Trigger + Test Content + + , + ); + const root = screen.getByText('Test Trigger').closest('[class*="accordionRoot"]'); + expect(root).toHaveStyle({ backgroundColor: '#1a202c' }); + }); + + test('Accordion์˜ Root์— padding ์˜ต์…˜์„ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด ๊ธฐ๋ณธ ๊ฐ’ "20px"๋กœ padding์„ ์„ค์ •ํ•œ๋‹ค.', () => { + render( + + + Test Trigger + Test Content + + , + ); + const root = screen.getByText('Test Trigger').closest('[class*="accordionRoot"]'); + expect(root).toHaveStyle({ padding: '20px' }); + }); +}); + +describe('Accordion.Trigger ๊ธฐ๋ณธ ์Šคํƒ€์ผ ๋ฐ ์ปดํฌ๋„ŒํŠธ ๊ตฌ์กฐ', () => { + test('Accordion์˜ Trigger์— ์กด์žฌํ•˜๋Š” ํ…์ŠคํŠธ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์™ผ์ชฝ ์ •๋ ฌ๋œ๋‹ค.', () => { + render( + + + Test Trigger + Test Content + + , + ); + const trigger = screen.getByText('Test Trigger').closest('[class*="accordionTrigger"]'); + expect(trigger).toHaveStyle({ + textAlign: 'left', + }); + }); + + test('aria-expanded๋ฅผ ํ†ตํ•ด ์š”์†Œ ํ™•์žฅ ๋ฐ ์ถ•์†Œ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค', () => { + render( + + + Test Trigger + Test Content + + , + ); + const trigger = screen.getByRole('button'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + test('Accordion.Indicator๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์•„์ด์ฝ˜์„ ๋ Œ๋”๋งํ•  ์ˆ˜ ์žˆ๋‹ค', () => { + render( + + + + Test Trigger + + + Test Content + + , + ); + const trigger = screen.getByRole('button'); + expect(trigger.querySelector('svg')).toBeInTheDocument(); + }); +}); + +describe('Accordion.Content ๊ธฐ๋ณธ ์Šคํƒ€์ผ', () => { + test('Accordion์˜ Content์— borderRadius๋ฅผ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด ๊ธฐ๋ณธ ๊ฐ’ 8px์œผ๋กœ borderRadius๋ฅผ ์„ค์ •ํ•œ๋‹ค.', () => { + render( + + + Test Trigger + Test Content + + , + ); + + const contentElement = screen.getByText('Test Content').closest('[class*="accordionContentWrapper"]'); + expect(contentElement).toHaveStyle({ borderRadius: '8px' }); + }); + + test('Accordion์˜ Content์— background-color๋ฅผ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด ๊ธฐ๋ณธ ๊ฐ’ #2d3748์œผ๋กœ background-color๋ฅผ ์„ค์ •ํ•œ๋‹ค.', () => { + render( + + + Test Trigger + Test Content + + , + ); + + const contentElement = screen.getByText('Test Content').closest('[class*="accordionContentWrapper"]'); + expect(contentElement).toHaveStyle({ backgroundColor: '#2d3748' }); + }); + + test('Accordion์˜ Content์— padding์„ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด ๊ธฐ๋ณธ ๊ฐ’ 12px 16px์œผ๋กœ padding์„ ์„ค์ •ํ•œ๋‹ค.', () => { + render( + + + Test Trigger + Test Content + + , + ); + + const contentElement = screen.getByText('Test Content'); + expect(contentElement).toHaveStyle({ padding: '12px 16px' }); + }); +}); + +describe('Accordion ๋™์ž‘', () => { + test('Trigger ํด๋ฆญ ์‹œ Content์˜ ๋‚ด์šฉ์„ ๋…ธ์ถœ ๋ฐ ์ˆจ๊น€ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค', () => { + render( + + + Test Trigger + Test Content + + , + ); + const trigger = screen.getByRole('button'); + const wrapper = screen.getByText('Test Content').closest('[class*="accordionContentWrapper"]'); + expect(wrapper).toHaveAttribute('aria-hidden', 'true'); + fireEvent.click(trigger); + expect(wrapper).toHaveAttribute('aria-hidden', 'false'); + fireEvent.click(trigger); + expect(wrapper).toHaveAttribute('aria-hidden', 'true'); + }); +}); + +describe('Accordion ๊ตฌ์กฐ', () => { + test('Accordion์˜ children์œผ๋กœ ์ „๋‹ฌํ•œ ์š”์†Œ๋ฅผ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๋ Œ๋”๋งํ•  ์ˆ˜ ์žˆ๋‹ค', () => { + render( + + + Custom Trigger + +

Paragraph

+ Span +
+
+
, + ); + expect(screen.getByText('Paragraph')).toBeInTheDocument(); + expect(screen.getByText('Span')).toBeInTheDocument(); + }); +}); diff --git a/packages/accordion/src/Accordion.tsx b/packages/accordion/src/Accordion.tsx new file mode 100644 index 00000000..7f6e76f4 --- /dev/null +++ b/packages/accordion/src/Accordion.tsx @@ -0,0 +1,125 @@ +import type { ReactNode } from 'react'; +import { type ComponentProps, type ForwardedRef, forwardRef, useState } from 'react'; + +import { AccordionArrowIcon } from '@sipe-team/icon'; + +import { Slot } from '@radix-ui/react-slot'; + +import { clsx as cx } from 'clsx'; + +import * as styles from './Accordion.css'; +import { AccordionItemContext, useAccordionItemContext } from './context/AccordionItemContext'; +import { useAccordionAnimation } from './hooks/useAccordionAnimation'; +export interface AccordionRootProps extends ComponentProps<'div'> { + children: ReactNode; + asChild?: boolean; +} +export interface AccordionItemProps { + children: ReactNode; + className?: string; + defaultOpen?: boolean; +} + +export interface AccordionTriggerProps extends ComponentProps<'button'> { + children: ReactNode; + asChild?: boolean; + className?: string; +} + +export interface AccordionContentProps { + children: ReactNode; + className?: string; + asChild?: boolean; +} + +export const AccordionRoot = forwardRef(function AccordionRoot( + { children, asChild, className, ...props }: AccordionRootProps, + ref: ForwardedRef, +) { + const Component = asChild ? Slot : 'div'; + return ( + + {children} + + ); +}); + +export const AccordionItem = forwardRef(function AccordionItem( + { children, className, defaultOpen = false, ...props }: AccordionItemProps, + ref: ForwardedRef, +) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + const toggleAccordion = () => { + setIsOpen((prev) => !prev); + }; + + const contextValue = { isOpen, toggleAccordion }; + + return ( + +
+ {children} +
+
+ ); +}); + +export const AccordionTrigger = forwardRef(function AccordionTrigger( + { children, className, asChild, ...props }: AccordionTriggerProps, + ref: ForwardedRef, +) { + const { isOpen, toggleAccordion } = useAccordionItemContext(); + const Component = asChild ? Slot : 'button'; + const buttonProps = asChild ? {} : { type: 'button' as const }; + + return ( + + {children} + + ); +}); + +export const AccordionIndicator = () => { + const { isOpen } = useAccordionItemContext(); + return ; +}; + +export const AccordionContent = ({ children, asChild, className, ...props }: AccordionContentProps) => { + const { isOpen } = useAccordionItemContext(); + const { ref, height, shouldTransition } = useAccordionAnimation(isOpen); + + const Component = asChild ? Slot : 'div'; + + return ( +
+ + {children} + +
+ ); +}; + +export const Accordion = Object.assign(AccordionRoot, { + Root: AccordionRoot, + Item: AccordionItem, + Trigger: AccordionTrigger, + Indicator: AccordionIndicator, + Content: AccordionContent, +}); + +Accordion.displayName = 'Accordion'; diff --git a/packages/accordion/src/context/AccordionItemContext.tsx b/packages/accordion/src/context/AccordionItemContext.tsx new file mode 100644 index 00000000..6bd4872a --- /dev/null +++ b/packages/accordion/src/context/AccordionItemContext.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; + +interface AccordionItemContextValue { + isOpen: boolean; + toggleAccordion: () => void; +} + +export const AccordionItemContext = createContext({ + isOpen: false, + toggleAccordion: () => {}, +}); + +export const useAccordionItemContext = () => { + const context = useContext(AccordionItemContext); + if (!context) { + throw new Error('useAccordionItemContext must be used within an AccordionItem'); + } + return context; +}; diff --git a/packages/accordion/src/hooks/useAccordionAnimation.ts b/packages/accordion/src/hooks/useAccordionAnimation.ts new file mode 100644 index 00000000..6abf2159 --- /dev/null +++ b/packages/accordion/src/hooks/useAccordionAnimation.ts @@ -0,0 +1,37 @@ +import { useLayoutEffect, useRef, useState } from 'react'; + +export const useAccordionAnimation = (isOpen: boolean) => { + const ref = useRef(null); + const [height, setHeight] = useState('0px'); + const [shouldTransition, setShouldTransition] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + + if (isOpen) { + setHeight(`${el?.scrollHeight}px`); + setShouldTransition(true); + + const handleTransitionEnd = () => { + if (isOpen) setHeight('auto'); + }; + el?.addEventListener('transitionend', handleTransitionEnd, { once: true }); + return () => el?.removeEventListener('transitionend', handleTransitionEnd); + } + + if (el?.style.height === 'auto') { + setHeight(`${el?.scrollHeight}px`); + requestAnimationFrame(() => { + setShouldTransition(true); + setHeight('0px'); + }); + } else { + setShouldTransition(true); + setHeight('0px'); + } + + return () => {}; + }, [isOpen]); + + return { ref, height, shouldTransition }; +}; diff --git a/packages/accordion/src/index.ts b/packages/accordion/src/index.ts new file mode 100644 index 00000000..63f62bc6 --- /dev/null +++ b/packages/accordion/src/index.ts @@ -0,0 +1 @@ +export * from './Accordion'; diff --git a/packages/accordion/tsconfig.json b/packages/accordion/tsconfig.json new file mode 100644 index 00000000..4082f16a --- /dev/null +++ b/packages/accordion/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/accordion/tsup.config.ts b/packages/accordion/tsup.config.ts new file mode 100644 index 00000000..c533199b --- /dev/null +++ b/packages/accordion/tsup.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + clean: true, + dts: true, + format: ['esm', 'cjs'], +}); diff --git a/packages/accordion/vitest.config.ts b/packages/accordion/vitest.config.ts new file mode 100644 index 00000000..e663baf0 --- /dev/null +++ b/packages/accordion/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineProject, mergeConfig } from 'vitest/config'; + +import defaultConfig from '../../vitest.config'; + +export default mergeConfig( + defaultConfig, + defineProject({ + test: { + setupFiles: './vitest.setup.ts', + }, + }), +); diff --git a/packages/accordion/vitest.setup.ts b/packages/accordion/vitest.setup.ts new file mode 100644 index 00000000..7b0828bf --- /dev/null +++ b/packages/accordion/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/packages/avatar/global.d.ts b/packages/avatar/global.d.ts deleted file mode 100644 index 60260a3a..00000000 --- a/packages/avatar/global.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '*.module.css'; diff --git a/packages/avatar/package.json b/packages/avatar/package.json index 5a3b3816..3a879dc1 100644 --- a/packages/avatar/package.json +++ b/packages/avatar/package.json @@ -13,6 +13,7 @@ "scripts": { "build": "tsup", "build:storybook": "storybook build", + "clean": "rm -rf node_modules dist", "dev:storybook": "storybook dev -p 6006", "lint": "biome lint .", "test": "vitest", @@ -37,6 +38,7 @@ "@types/react": "catalog:react", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", + "@vanilla-extract/css": "catalog:", "happy-dom": "catalog:", "react": "catalog:react", "react-dom": "catalog:react", diff --git a/packages/avatar/src/Avatar.css.ts b/packages/avatar/src/Avatar.css.ts new file mode 100644 index 00000000..e716b4b5 --- /dev/null +++ b/packages/avatar/src/Avatar.css.ts @@ -0,0 +1,57 @@ +import { style, styleVariants } from '@vanilla-extract/css'; +import { AvatarShape, AvatarSize } from './Avatar'; + +export const root = style({ + alignItems: 'center', + backgroundColor: '#e2e8f0', + display: 'flex', + justifyContent: 'center', + overflow: 'hidden', +}); + +export const size = styleVariants({ + [AvatarSize.xs]: { + height: 24, + width: 24, + }, + [AvatarSize.sm]: { + height: 32, + width: 32, + }, + [AvatarSize.md]: { + height: 40, + width: 40, + }, + [AvatarSize.lg]: { + height: 70, + width: 70, + }, + [AvatarSize.xl]: { + height: 96, + width: 96, + }, +}); + +export const shape = styleVariants({ + [AvatarShape.circle]: { + borderRadius: '50%', + }, + [AvatarShape.rounded]: { + borderRadius: 4, + }, + [AvatarShape.square]: { + borderRadius: 0, + }, +}); + +export const image = style({ + height: '100%', + objectFit: 'cover', + width: '100%', +}); + +export const fallback = style({ + color: '#2d3748', + fontSize: '0.8rem', + textAlign: 'center', +}); diff --git a/packages/avatar/src/Avatar.module.css b/packages/avatar/src/Avatar.module.css deleted file mode 100644 index b9b7fafd..00000000 --- a/packages/avatar/src/Avatar.module.css +++ /dev/null @@ -1,22 +0,0 @@ -.avatar { - width: var(--avatar-size); - height: var(--avatar-size); - border-radius: var(--avatar-shape); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - background-color: #e2e8f0; -} - -.image { - width: 100%; - height: 100%; - object-fit: cover; -} - -.fallback { - font-size: 0.8rem; - color: #2d3748; - text-align: center; -} diff --git a/packages/avatar/src/Avatar.test.tsx b/packages/avatar/src/Avatar.test.tsx index 5cec13e5..f80e003d 100644 --- a/packages/avatar/src/Avatar.test.tsx +++ b/packages/avatar/src/Avatar.test.tsx @@ -1,8 +1,7 @@ import { faker } from '@faker-js/faker'; import { render, screen } from '@testing-library/react'; -import { expect, test, describe, it } from 'vitest'; -import { Avatar } from './Avatar'; -import type { AvatarShape, AvatarSize } from './Avatar'; +import { describe, expect, it, test } from 'vitest'; +import { Avatar, type AvatarShape, type AvatarSize } from './Avatar'; const testImage = faker.image.avatar(); diff --git a/packages/avatar/src/Avatar.tsx b/packages/avatar/src/Avatar.tsx index 60bbd16f..30ac1d0b 100644 --- a/packages/avatar/src/Avatar.tsx +++ b/packages/avatar/src/Avatar.tsx @@ -1,26 +1,7 @@ import { Slot } from '@radix-ui/react-slot'; import { clsx as cx } from 'clsx'; -import { type CSSProperties, type ComponentProps, type ForwardedRef, forwardRef } from 'react'; -import styles from './Avatar.module.css'; - -/** -+ * Avatar ์ปดํฌ๋„ŒํŠธ์˜ ํฌ๊ธฐ ์˜ต์…˜ -+ * @type {AvatarSize} -+ * - xs: 24px -+ * - sm: 32px -+ * - md: 40px (๊ธฐ๋ณธ๊ฐ’) -+ * - lg: 70px -+ */ -export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; - -/** -+ * Avatar ์ปดํฌ๋„ŒํŠธ์˜ ๋ชจ์–‘ ์˜ต์…˜ -+ * @type {AvatarShape} -+ * - circle: ์›ํ˜• (50% border-radius) -+ * - rounded: ๋‘ฅ๊ทผ ๋ชจ์„œ๋ฆฌ (4px border-radius) -+ * - square: ์ •์‚ฌ๊ฐํ˜• (0px border-radius) -+ */ -export type AvatarShape = 'circle' | 'rounded' | 'square'; +import { type ComponentProps, type ForwardedRef, forwardRef } from 'react'; +import * as styles from './Avatar.css'; export interface AvatarProps extends ComponentProps<'div'> { asChild?: boolean; @@ -31,21 +12,30 @@ export interface AvatarProps extends ComponentProps<'div'> { fallback?: string; } +export const AvatarSize = { + xs: 'xs', + sm: 'sm', + md: 'md', + lg: 'lg', + xl: 'xl', +} as const; +export type AvatarSize = (typeof AvatarSize)[keyof typeof AvatarSize]; + +export const AvatarShape = { + circle: 'circle', + rounded: 'rounded', + square: 'square', +} as const; +export type AvatarShape = (typeof AvatarShape)[keyof typeof AvatarShape]; + export const Avatar = forwardRef(function Avatar( { asChild, className, src, alt, size = 'md', shape = 'circle', fallback, ...props }: AvatarProps, ref: ForwardedRef, ) { const Component = asChild ? Slot : 'div'; - const style = { - ...props.style, - width: getAvatarSize(size), - height: getAvatarSize(size), - borderRadius: getAvatarShape(shape), - } as CSSProperties; - return ( - + {src ? ( ); }); - -function getAvatarSize(size: AvatarSize) { - switch (size) { - case 'xs': - return '24px'; - case 'sm': - return '32px'; - case 'md': - return '40px'; - case 'lg': - return '70px'; - case 'xl': - return '96px'; - default: - return '40px'; - } -} - -function getAvatarShape(shape: AvatarShape) { - switch (shape) { - case 'rounded': - return '4px'; - case 'square': - return '0px'; - default: - return '50%'; - } -} diff --git a/packages/avatar/src/index.ts b/packages/avatar/src/index.ts index 9cc3279d..27700fe3 100644 --- a/packages/avatar/src/index.ts +++ b/packages/avatar/src/index.ts @@ -1 +1 @@ -export * from './Avatar.tsx'; +export * from './Avatar'; diff --git a/packages/avatar/tsup.config.ts b/packages/avatar/tsup.config.ts index c533199b..efc295fa 100644 --- a/packages/avatar/tsup.config.ts +++ b/packages/avatar/tsup.config.ts @@ -1,8 +1 @@ -import { defineConfig } from 'tsup'; - -export default defineConfig({ - entry: ['src/index.ts'], - clean: true, - dts: true, - format: ['esm', 'cjs'], -}); +export { default } from '../../tsup.config'; diff --git a/packages/avatar/vite.config.ts b/packages/avatar/vite.config.ts new file mode 100644 index 00000000..2484cb4e --- /dev/null +++ b/packages/avatar/vite.config.ts @@ -0,0 +1 @@ +export { default } from '../../vite.config'; diff --git a/packages/avatar/vitest.config.ts b/packages/avatar/vitest.config.ts index adc7d2f1..a9178275 100644 --- a/packages/avatar/vitest.config.ts +++ b/packages/avatar/vitest.config.ts @@ -1,26 +1,11 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - // ํ…Œ์ŠคํŠธ์™€ ๊ด€๋ จํ•œ ์„ค์ • - test: { - // ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํ–‰ํ•  ํ™˜๊ฒฝ - // default: 'node' - // ๋ธŒ๋ผ์šฐ์ € ํ™˜๊ฒฝ์—์„œ ํ…Œ์ŠคํŠธ๋ฅผ ํฌ๋ง์‹œ - 'jsdom' ๋˜๋Š” 'happy-dom'์œผ๋กœ ์„ค์ • - environment: 'happy-dom', - - // ๊ธ€๋กœ๋ฒŒ API๋ฅผ ์‚ฌ์šฉํ• ์ง€ ์—ฌ๋ถ€๋ฅผ ์„ ํƒ - // ex) describe, it, expect ๋“ฑ - globals: true, - - // ํ…Œ์ŠคํŠธ ์‹คํ–‰ ํ™˜๊ฒฝ์— ํ•„์š”ํ•œ ์Šคํฌ๋ฆฝํŠธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์žˆ์Œ - // ex) ๋ชจ๋“ˆ mokcing, matcher extend ๋“ฑ - setupFiles: './vitest.setup.ts', - passWithNoTests: true, - watch: false, - css: true, - }, - - // ํ™˜๊ฒฝ๋ณ„๋กœ ์„ค์ •ํ•ด์ฃผ์–ด์•ผํ•˜๋Š” ์ถ”๊ฐ€ ๊ธฐ๋Šฅ์„ ํ”Œ๋Ÿฌ๊ทธ์ธ์œผ๋กœ ์ฃผ์ž… ๊ฐ€๋Šฅ - // ex) vite-tsconfig-paths - plugins: [], -}); +import { defineProject, mergeConfig } from 'vitest/config'; +import defaultConfig from '../../vitest.config'; + +export default mergeConfig( + defaultConfig, + defineProject({ + test: { + setupFiles: './vitest.setup.ts', + }, + }), +); diff --git a/packages/badge/package.json b/packages/badge/package.json index 3b644782..9fa182dc 100644 --- a/packages/badge/package.json +++ b/packages/badge/package.json @@ -13,14 +13,15 @@ "scripts": { "build": "tsup", "build:storybook": "storybook build", + "clean": "rm -rf node_modules dist", "dev:storybook": "storybook dev -p 6006", - "lint:biome": "pnpm exec biome lint", - "lint:eslint": "pnpm exec eslint", + "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", "prepack": "pnpm run build" }, "dependencies": { + "@sipe-team/tokens": "workspace:*", "@sipe-team/typography": "workspace:*", "clsx": "^2.1.1" }, @@ -35,6 +36,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@types/react": "catalog:react", + "@vanilla-extract/css": "catalog:", "happy-dom": "catalog:", "react": "catalog:react", "react-dom": "catalog:react", diff --git a/packages/badge/src/Badge.css.ts b/packages/badge/src/Badge.css.ts new file mode 100644 index 00000000..080da695 --- /dev/null +++ b/packages/badge/src/Badge.css.ts @@ -0,0 +1,71 @@ +import { style, styleVariants } from '@vanilla-extract/css'; +import { color as colorToken, fontSize as fontSizeToken } from '@sipe-team/tokens'; + +// Define the types for our component +export const BadgeSize = { + small: 'small', + medium: 'medium', + large: 'large', +} as const; + +export const BadgeVariant = { + filled: 'filled', + outline: 'outline', + weak: 'weak', +} as const; + +// Base styles for the badge +export const root = style({ + borderRadius: 8, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', +}); + +// Size variants +export const size = styleVariants({ + [BadgeSize.small]: { + padding: '4px 8px', + }, + [BadgeSize.medium]: { + padding: '8px 16px', + }, + [BadgeSize.large]: { + padding: '12px 24px', + }, +}); + +// Font size by badge size +export const fontSize = styleVariants({ + [BadgeSize.small]: { + fontSize: fontSizeToken[12], + }, + [BadgeSize.medium]: { + fontSize: fontSizeToken[14], + }, + [BadgeSize.large]: { + fontSize: fontSizeToken[18], + }, +}); + +// Variant styles +export const variant = styleVariants({ + [BadgeVariant.filled]: { + backgroundColor: colorToken.cyan900, + border: 'none', + }, + [BadgeVariant.outline]: { + backgroundColor: 'transparent', + border: `2px solid ${colorToken.cyan900}`, + }, + [BadgeVariant.weak]: { + backgroundColor: colorToken.gray200, + border: 'none', + }, +}); + +// Text style +export const text = style({ + color: colorToken.cyan300, + fontWeight: 600, +}); diff --git a/packages/badge/src/Badge.module.css b/packages/badge/src/Badge.module.css deleted file mode 100644 index e04e6679..00000000 --- a/packages/badge/src/Badge.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.root { - background-color: var(--background-color); - border: var(--border); - border-radius: 8px; - padding: var(--padding); -} diff --git a/packages/badge/src/Badge.stories.tsx b/packages/badge/src/Badge.stories.tsx index 306caaf9..892569fa 100644 --- a/packages/badge/src/Badge.stories.tsx +++ b/packages/badge/src/Badge.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { Badge } from './Badge'; +import { Badge, type BadgeSize, type BadgeVariant } from './Badge'; +import { BadgeSize as BadgeSizeEnum, BadgeVariant as BadgeVariantEnum } from './Badge.css'; const meta = { title: 'Components/Badge', @@ -7,6 +8,20 @@ const meta = { parameters: { layout: 'centered', }, + argTypes: { + size: { + control: 'select', + options: Object.keys(BadgeSizeEnum), + description: 'Size of the badge', + defaultValue: 'medium', + }, + variant: { + control: 'select', + options: Object.keys(BadgeVariantEnum), + description: 'Visual style of the badge', + defaultValue: 'filled', + }, + }, } satisfies Meta; export default meta; @@ -15,5 +30,31 @@ type Story = StoryObj; export const Basic: Story = { args: { children: '์‚ฌ์ดํ”„', + size: 'medium', + variant: 'filled', }, }; + +export const Sizes: Story = { + render: (args) => ( +
+ {Object.keys(BadgeSizeEnum).map((size) => ( + + {size} + + ))} +
+ ), +}; + +export const Variants: Story = { + render: (args) => ( +
+ {Object.keys(BadgeVariantEnum).map((variant) => ( + + {variant} + + ))} +
+ ), +}; diff --git a/packages/badge/src/Badge.test.tsx b/packages/badge/src/Badge.test.tsx index 791c7787..3771bd84 100644 --- a/packages/badge/src/Badge.test.tsx +++ b/packages/badge/src/Badge.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; import { expect, test } from 'vitest'; import { Badge } from './Badge'; +import { color as colorToken } from '@sipe-team/tokens'; test('children์œผ๋กœ ์ž…๋ ฅํ•œ ํ…์ŠคํŠธ๋ฅผ ํ‘œ์‹œํ•œ๋‹ค.', () => { render(ํ…Œ์ŠคํŠธ); @@ -14,10 +15,10 @@ test('๋ชจ์„œ๋ฆฌ๊ฐ€ 8px radius ํ˜•ํƒœ์ด๋‹ค.', () => { expect(screen.getByRole('status')).toHaveStyle({ borderRadius: '8px' }); }); -test('๊ธ€๊ผด ์ƒ‰์ƒ์€ teal(#00FFFF)์ด๋‹ค.', () => { +test(`๊ธ€๊ผด ์ƒ‰์ƒ์€ cyan300(${colorToken.cyan300})์ด๋‹ค.`, () => { render(ํ…Œ์ŠคํŠธ); - expect(screen.getByText('ํ…Œ์ŠคํŠธ')).toHaveStyle({ color: '#00FFFF' }); + expect(screen.getByText('ํ…Œ์ŠคํŠธ')).toHaveStyle({ color: colorToken.cyan300 }); }); test('๊ธ€๊ผด ๋‘๊ป˜๋Š” semiBold(600)์ด๋‹ค.', () => { @@ -26,28 +27,28 @@ test('๊ธ€๊ผด ๋‘๊ป˜๋Š” semiBold(600)์ด๋‹ค.', () => { expect(screen.getByText('ํ…Œ์ŠคํŠธ')).toHaveStyle({ fontWeight: 600 }); }); -test('variant๋ฅผ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด filled(๋ฐฐ๊ฒฝ์ƒ‰ #2D3748)๋ฅผ ๊ธฐ๋ณธ ํ˜•ํƒœ๋กœ ์„ค์ •ํ•œ๋‹ค.', () => { +test(`variant๋ฅผ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด filled(${colorToken.cyan900})๋ฅผ ๊ธฐ๋ณธ ํ˜•ํƒœ๋กœ ์„ค์ •ํ•œ๋‹ค.`, () => { render(ํ…Œ์ŠคํŠธ); expect(screen.getByRole('status')).toHaveStyle({ - backgroundColor: '#2D3748', + backgroundColor: colorToken.cyan900, }); }); -test('variant๊ฐ€ weak์ธ ๊ฒฝ์šฐ ๋ฐฐ๊ฒฝ์ƒ‰ #EDF2F7๋กœ ํ˜•ํƒœ๋ฅผ ์ ์šฉํ•œ๋‹ค.', () => { +test('variant๊ฐ€ weak์ธ ๊ฒฝ์šฐ ๋ฐฐ๊ฒฝ์ƒ‰ gray200๋กœ ํ˜•ํƒœ๋ฅผ ์ ์šฉํ•œ๋‹ค.', () => { render(ํ…Œ์ŠคํŠธ); expect(screen.getByRole('status')).toHaveStyle({ - backgroundColor: '#EDF2F7', + backgroundColor: colorToken.gray200, }); }); -test('variant๊ฐ€ outline์ธ ๊ฒฝ์šฐ ๋ฐฐ๊ฒฝ์ƒ‰์€ ํˆฌ๋ช…, ํ…Œ๋‘๋ฆฌ๋Š” 2px ๋‘๊ป˜์˜ #2D3748 ์ƒ‰์ƒ ํ˜•ํƒœ๋ฅผ ์ ์šฉํ•œ๋‹ค.', () => { +test('variant๊ฐ€ outline์ธ ๊ฒฝ์šฐ ๋ฐฐ๊ฒฝ์ƒ‰์€ ํˆฌ๋ช…, ํ…Œ๋‘๋ฆฌ๋Š” 2px ๋‘๊ป˜์˜ cyan900 ์ƒ‰์ƒ ํ˜•ํƒœ๋ฅผ ์ ์šฉํ•œ๋‹ค.', () => { render(ํ…Œ์ŠคํŠธ); expect(screen.getByRole('status')).toHaveStyle({ backgroundColor: 'transparent', - border: '2px solid #2D3748', + border: `2px solid ${colorToken.cyan900}`, }); }); diff --git a/packages/badge/src/Badge.tsx b/packages/badge/src/Badge.tsx index 568a0298..91035e40 100644 --- a/packages/badge/src/Badge.tsx +++ b/packages/badge/src/Badge.tsx @@ -1,16 +1,10 @@ import { Typography } from '@sipe-team/typography'; import { clsx as cx } from 'clsx'; -import { - type CSSProperties, - type ComponentProps, - type ForwardedRef, - forwardRef, -} from 'react'; -import styles from './Badge.module.css'; +import { type ComponentProps, type ForwardedRef, forwardRef } from 'react'; +import * as styles from './Badge.css'; -type BadgeSize = 'small' | 'medium' | 'large'; - -type BadgeVariant = 'filled' | 'outline' | 'weak'; +export type BadgeSize = keyof typeof styles.BadgeSize; +export type BadgeVariant = keyof typeof styles.BadgeVariant; export interface BadgeProps extends ComponentProps<'div'> { size?: BadgeSize; @@ -18,71 +12,24 @@ export interface BadgeProps extends ComponentProps<'div'> { } export const Badge = forwardRef(function Badge( - { - className, - children, - size = 'medium', - style: _style, - variant = 'filled', - ...props - }: BadgeProps, + { className, children, size = 'medium', variant = 'filled', ...props }: BadgeProps, ref: ForwardedRef, ) { - const backgroundColor = getBackgroundColor(variant); - const border = variant === 'outline' ? '2px solid #2D3748' : undefined; - const padding = getPadding(size); - const style = { - ..._style, - '--background-color': backgroundColor, - '--border': border, - '--padding': padding, - } as CSSProperties; - - const fontSize = getFontSize(size); - return (
- + {children}
); }); -function getBackgroundColor(variant: BadgeVariant) { - switch (variant) { - case 'weak': - return '#EDF2F7'; - case 'outline': - return 'transparent'; - default: - return '#2D3748'; - } -} - -function getPadding(size: BadgeSize) { - switch (size) { - case 'small': - return '4px 8px'; - case 'large': - return '12px 24px'; - default: - return '8px 16px'; - } -} - -function getFontSize(size: BadgeSize) { +function getTypographySize(size: BadgeSize): 12 | 14 | 18 { switch (size) { case 'small': return 12; diff --git a/packages/button/global.d.ts b/packages/button/global.d.ts deleted file mode 100644 index 60260a3a..00000000 --- a/packages/button/global.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '*.module.css'; diff --git a/packages/button/package.json b/packages/button/package.json index 18af3006..2f8daab7 100644 --- a/packages/button/package.json +++ b/packages/button/package.json @@ -13,18 +13,19 @@ "scripts": { "build": "tsup", "build:storybook": "storybook build", + "clean": "rm -rf node_modules dist", "dev:storybook": "storybook dev -p 6006", - "lint:biome": "pnpm exec biome lint", - "lint:eslint": "pnpm exec eslint", + "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", "prepack": "pnpm run build" }, "dependencies": { "@radix-ui/react-slot": "^1.1.0", + "@sipe-team/tokens": "workspace:*", "@sipe-team/typography": "workspace:*", - "clsx": "^2.1.1", - "ts-pattern": "^5.6.0" + "@vanilla-extract/recipes": "^0.5.5", + "clsx": "^2.1.1" }, "devDependencies": { "@storybook/addon-essentials": "catalog:", @@ -37,6 +38,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@types/react": "catalog:react", + "@vanilla-extract/css": "catalog:", "happy-dom": "catalog:", "react": "catalog:react", "react-dom": "catalog:react", diff --git a/packages/button/src/Button.css.ts b/packages/button/src/Button.css.ts new file mode 100644 index 00000000..d6cbe620 --- /dev/null +++ b/packages/button/src/Button.css.ts @@ -0,0 +1,74 @@ +import { vars } from '@sipe-team/tokens'; + +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +import { ButtonSize, ButtonVariant } from './Button'; + +export const disabled = style({ + opacity: 0.4, + cursor: 'not-allowed', + pointerEvents: 'none', +}); + +export const button = recipe({ + base: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: vars.radius.md, + fontWeight: vars.typography.fontWeight.semiBold, + cursor: 'pointer', + transition: 'all 0.2s ease-in-out', + border: 'none', + fontFamily: vars.typography.fontFamily, + }, + variants: { + variant: { + [ButtonVariant.filled]: { + backgroundColor: vars.color.primary, + color: vars.color.background, + border: 'none', + ':hover': { + opacity: 0.9, + }, + }, + [ButtonVariant.outline]: { + backgroundColor: 'transparent', + border: `1px solid ${vars.color.primary}`, + color: vars.color.primary, + ':hover': { + backgroundColor: vars.color.primary, + color: vars.color.background, + }, + }, + [ButtonVariant.ghost]: { + backgroundColor: 'transparent', + border: 'none', + color: vars.color.primary, + ':hover': { + backgroundColor: `color-mix(in srgb, ${vars.color.primary} 10%, transparent)`, + }, + }, + }, + size: { + [ButtonSize.sm]: { + height: '32px', + padding: `0 ${vars.spacing.sm}`, + fontSize: vars.typography.fontSize['200'], + lineHeight: vars.typography.lineHeight.compact, + }, + [ButtonSize.lg]: { + height: '48px', + padding: `0 ${vars.spacing.lg}`, + fontSize: vars.typography.fontSize['400'], + lineHeight: vars.typography.lineHeight.regular, + }, + }, + }, + compoundVariants: [], + defaultVariants: { + variant: ButtonVariant.filled, + size: ButtonSize.lg, + }, +}); diff --git a/packages/button/src/Button.module.css b/packages/button/src/Button.module.css deleted file mode 100644 index 84789247..00000000 --- a/packages/button/src/Button.module.css +++ /dev/null @@ -1,29 +0,0 @@ -.button { - display: flex; - align-items: center; - justify-content: center; - padding: 0 16px; - border-radius: 8px; - height: 40px; - font-size: 22px; - line-height: 30.8px; - font-weight: bold; - cursor: pointer; - transition: all 0.2s ease-in-out; - background-color: var(--background-color); - border: var(--border); - color: var(--color); -} - -.button:hover { - background-color: var(--hover-background-color); - color: var(--hover-color); - opacity: var(--hover-opacity); -} - -/* States */ -.disabled { - opacity: 0.4; - cursor: not-allowed; - pointer-events: none; -} diff --git a/packages/button/src/Button.stories.tsx b/packages/button/src/Button.stories.tsx index 53a0bc42..3c648787 100644 --- a/packages/button/src/Button.stories.tsx +++ b/packages/button/src/Button.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; + import { Button } from './Button'; const meta = { @@ -8,18 +9,18 @@ const meta = { layout: 'centered', }, argTypes: { - color: { - description: '๋ฒ„ํŠผ์˜ ์ƒ‰์ƒ์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค', - options: ['primary', 'black', 'white'], + variant: { + description: 'The visual style of the button', + options: ['filled', 'outline', 'ghost'], control: { type: 'radio' }, }, - variant: { - description: '๋ฒ„ํŠผ์˜ ์Šคํƒ€์ผ์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค', - options: ['filled', 'outline', 'weak'], + size: { + description: 'The size of the button', + options: ['sm', 'lg'], control: { type: 'radio' }, }, disabled: { - description: '๋ฒ„ํŠผ์˜ ๋น„ํ™œ์„ฑํ™” ์ƒํƒœ๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค', + description: 'Whether the button is disabled', control: { type: 'boolean' }, }, }, @@ -31,45 +32,42 @@ type Story = StoryObj; export const Basic: Story = { args: { children: 'Button', - color: 'primary', variant: 'filled', + size: 'lg', }, }; -export const Colors: Story = { +export const Variants: Story = { args: { children: 'Button', }, render: (args) => (
- - -
), }; -export const Variants: Story = { +export const Sizes: Story = { args: { children: 'Button', - color: 'primary', + variant: 'filled', }, render: (args) => ( -
- - -
), @@ -78,7 +76,6 @@ export const Variants: Story = { export const States: Story = { args: { children: 'Button', - color: 'primary', }, render: (args) => (
diff --git a/packages/button/src/Button.test.tsx b/packages/button/src/Button.test.tsx index fd3c7f5e..4c513c63 100644 --- a/packages/button/src/Button.test.tsx +++ b/packages/button/src/Button.test.tsx @@ -1,31 +1,80 @@ import { render, screen } from '@testing-library/react'; import { expect, test } from 'vitest'; + import { Button } from './Button'; -test('children์œผ๋กœ ์ž…๋ ฅํ•œ ํ…์ŠคํŠธ๋ฅผ ํ‘œ์‹œํ•œ๋‹ค.', () => { - render(); +test('displays text passed as children', () => { + render(); + + expect(screen.getByText('Test')).toBeInTheDocument(); +}); + +test('applies correct classes', () => { + render(); + + const button = screen.getByRole('button'); + + // Should have button classes applied + expect(button.className).toBeTruthy(); + expect(button.className.length).toBeGreaterThan(0); +}); + +test('uses filled variant as default when variant is not provided', () => { + render(); + + const button = screen.getByRole('button'); + + // Should render properly + expect(button).toBeInTheDocument(); + expect(button.className).toBeTruthy(); +}); + +test('size prop works correctly', () => { + render(); + + const button = screen.getByRole('button'); + + // Should render without errors + expect(button).toBeInTheDocument(); + expect(button.className).toBeTruthy(); +}); - expect(screen.getByText('ํ…Œ์ŠคํŠธ')).toBeInTheDocument(); +test('ghost variant works correctly', () => { + render(); + + const button = screen.getByRole('button'); + + // Should render without errors + expect(button).toBeInTheDocument(); + expect(button.className).toBeTruthy(); }); -test('๋ชจ์„œ๋ฆฌ๊ฐ€ 8px radius ํ˜•ํƒœ์ด๋‹ค.', () => { - render(); +test('outline variant works correctly', () => { + render(); + + const button = screen.getByRole('button'); - expect(screen.getByRole('button')).toHaveStyle({ borderRadius: '8px' }); + // Should render without errors + expect(button).toBeInTheDocument(); + expect(button.className).toBeTruthy(); }); -test('variant๋ฅผ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด filled(๋ฐฐ๊ฒฝ์ƒ‰ #00ffff)๋ฅผ ๊ธฐ๋ณธ ํ˜•ํƒœ๋กœ ์„ค์ •ํ•œ๋‹ค.', () => { - render(); +test('disabled state works correctly', () => { + render(); - expect(screen.getByRole('button')).toHaveStyle({ - backgroundColor: '#00ffff', - }); + const button = screen.getByRole('button'); + + // Should be disabled + expect(button).toBeDisabled(); + expect(button).toBeInTheDocument(); }); -test('color๊ฐ€ primary์ธ ๊ฒฝ์šฐ ๋ฐฐ๊ฒฝ์ƒ‰ #00ffff ํ˜•ํƒœ๋ฅผ ์ ์šฉํ•œ๋‹ค.', () => { - render(); +test('large size works correctly', () => { + render(); + + const button = screen.getByRole('button'); - expect(screen.getByRole('button')).toHaveStyle({ - backgroundColor: '#00ffff', - }); + // Should render without errors + expect(button).toBeInTheDocument(); + expect(button.className).toBeTruthy(); }); diff --git a/packages/button/src/Button.tsx b/packages/button/src/Button.tsx index dd3edbec..f5082d93 100644 --- a/packages/button/src/Button.tsx +++ b/packages/button/src/Button.tsx @@ -1,117 +1,43 @@ +import { type ComponentProps, type ForwardedRef, forwardRef } from 'react'; + import { Slot } from '@radix-ui/react-slot'; + import { clsx as cx } from 'clsx'; -import { - type CSSProperties, - type ComponentProps, - type ReactNode, - forwardRef, -} from 'react'; -import { match } from 'ts-pattern'; -import styles from './Button.module.css'; -type ButtonColor = 'primary' | 'black' | 'white'; +import * as styles from './Button.css'; -type ButtonVariant = 'filled' | 'outline' | 'weak'; +export const ButtonVariant = { + filled: 'filled', + outline: 'outline', + ghost: 'ghost', +} as const; +export type ButtonVariant = (typeof ButtonVariant)[keyof typeof ButtonVariant]; + +export const ButtonSize = { + sm: 'sm', + lg: 'lg', +} as const; +export type ButtonSize = (typeof ButtonSize)[keyof typeof ButtonSize]; export interface ButtonProps extends ComponentProps<'button'> { - color?: ButtonColor; variant?: ButtonVariant; - disabled?: boolean; - className?: string; - children: ReactNode; + size?: ButtonSize; asChild?: boolean; } -export const Button = forwardRef( - function Button( - { - color = 'primary', - variant = 'filled', - asChild, - disabled, - className: _className, - style: _style, - children, - ...rest - }, - ref, - ) { - const Comp = asChild ? Slot : 'button'; - const className = cx( - styles.button, - { [styles.disabled]: disabled }, - _className, - ); - const style = { - ..._style, - ...getButtonStyle({ color, variant }), - } as CSSProperties; - - return ( - - {children} - - ); - }, -); - -function getButtonStyle({ - color, - variant, -}: { color: ButtonColor; variant: ButtonVariant }) { - const primaryColor = '#00ffff'; - const blackColor = 'black'; - const whiteColor = 'white'; - const transparentColor = 'transparent'; - - const backgroundColor = match([color, variant]) - .with(['primary', 'filled'], () => primaryColor) - .with(['black', 'filled'], () => blackColor) - .with(['white', 'filled'], () => whiteColor) - .otherwise(() => transparentColor); - const border = match([color, variant]) - .with(['primary', 'outline'], () => `1px solid ${primaryColor}`) - .with(['black', 'outline'], () => `1px solid ${blackColor}`) - .with(['white', 'outline'], () => `1px solid ${whiteColor}`) - .otherwise(() => 'none'); - const fontColor = match([color, variant]) - .with(['primary', 'filled'], () => blackColor) - .with(['primary', 'outline'], ['primary', 'weak'], () => primaryColor) - .with(['black', 'filled'], () => whiteColor) - .with(['black', 'outline'], ['black', 'weak'], () => blackColor) - .with(['white', 'filled'], () => blackColor) - .with(['white', 'outline'], ['white', 'weak'], () => whiteColor) - .exhaustive(); - const hoverBackgroundColor = match([color, variant]) - .with(['primary', 'filled'], () => '#00d2d2') - .with(['primary', 'outline'], ['primary', 'weak'], () => primaryColor) - .with(['black', 'filled'], () => '#2d3748') - .with(['black', 'outline'], ['black', 'weak'], () => blackColor) - .with(['white', 'filled'], () => '#cbd5e0') - .with(['white', 'outline'], ['white', 'weak'], () => whiteColor) - .exhaustive(); - const hoverFontColor = match([color, variant]) - .with(['primary', 'filled'], ['primary', 'outline'], () => blackColor) - .with(['primary', 'weak'], () => primaryColor) - .with(['black', 'filled'], ['black', 'outline'], () => whiteColor) - .with(['black', 'weak'], () => blackColor) - .with(['white', 'filled'], ['white', 'outline'], () => blackColor) - .with(['white', 'weak'], () => whiteColor) - .exhaustive(); - const hoverOpacity = variant === 'weak' ? 0.1 : 1; - - return { - '--background-color': backgroundColor, - '--border': border, - '--color': fontColor, - '--hover-background-color': hoverBackgroundColor, - '--hover-color': hoverFontColor, - '--hover-opacity': hoverOpacity, - }; -} +export const Button = forwardRef(function Button( + { + variant = ButtonVariant.filled, + size = ButtonSize.lg, + asChild, + disabled, + className: _className, + ...props + }: ButtonProps, + ref: ForwardedRef, +) { + const Comp = asChild ? Slot : 'button'; + const className = cx(styles.button({ variant, size }), { [styles.disabled]: disabled }, _className); + + return ; +}); diff --git a/packages/card/package.json b/packages/card/package.json index 155a5fb1..661c07a9 100644 --- a/packages/card/package.json +++ b/packages/card/package.json @@ -13,9 +13,9 @@ "scripts": { "build": "tsup", "build:storybook": "storybook build", + "clean": "rm -rf node_modules dist", "dev:storybook": "storybook dev -p 6006", - "lint:biome": "pnpm exec biome lint", - "lint:eslint": "pnpm exec eslint", + "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", "prepack": "pnpm run build" @@ -36,6 +36,8 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@types/react": "catalog:react", + "@vanilla-extract/css": "catalog:", + "@vanilla-extract/recipes": "^0.5.5", "happy-dom": "catalog:", "react": "catalog:react", "react-dom": "catalog:react", diff --git a/packages/card/src/Card.css.ts b/packages/card/src/Card.css.ts new file mode 100644 index 00000000..4e9cb3b8 --- /dev/null +++ b/packages/card/src/Card.css.ts @@ -0,0 +1,61 @@ +import { color } from '@sipe-team/tokens'; +import { recipe } from '@vanilla-extract/recipes'; + +export const CardVariant = { + filled: 'filled', + outline: 'outline', +} as const; + +export const CardRatio = { + rectangle: 'rectangle', + square: 'square', + wide: 'wide', + portrait: 'portrait', + auto: 'auto', +} as const; + +export type CardVariant = (typeof CardVariant)[keyof typeof CardVariant]; +export type CardRatio = (typeof CardRatio)[keyof typeof CardRatio]; + +export const card = recipe({ + base: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + borderRadius: '12px', + padding: '20px', + }, + variants: { + variant: { + [CardVariant.filled]: { + backgroundColor: color.gray100, + border: `1px solid ${color.gray200}`, + }, + [CardVariant.outline]: { + backgroundColor: color.gray50, + border: `1px solid ${color.cyan300}`, + }, + }, + ratio: { + [CardRatio.square]: { + aspectRatio: '1 / 1', + }, + [CardRatio.rectangle]: { + aspectRatio: '16 / 9', + }, + [CardRatio.wide]: { + aspectRatio: '21 / 9', + }, + [CardRatio.portrait]: { + aspectRatio: '3 / 4', + }, + [CardRatio.auto]: { + aspectRatio: 'auto', + }, + }, + }, + defaultVariants: { + variant: CardVariant.filled, + ratio: CardRatio.rectangle, + }, +}); diff --git a/packages/card/src/Card.module.css b/packages/card/src/Card.module.css deleted file mode 100644 index 9afcec34..00000000 --- a/packages/card/src/Card.module.css +++ /dev/null @@ -1,7 +0,0 @@ -.card { - background-color: var(--background-color); - border: var(--border); - border-radius: 12px; - padding: var(--padding); - aspect-ratio: var(--aspect-ratio); -} diff --git a/packages/card/src/Card.stories.tsx b/packages/card/src/Card.stories.tsx index cafcb08e..36984ca7 100644 --- a/packages/card/src/Card.stories.tsx +++ b/packages/card/src/Card.stories.tsx @@ -22,9 +22,121 @@ export default meta; type Story = StoryObj; +const RatioVisualizer = ({ label, ratio }: { label: string; ratio: string }) => ( +
+
{label}
+
{ratio}
+
+); + +// Default example export const Default: Story = { args: { children: Card, variant: 'filled', + ratio: 'rectangle', + style: { width: '300px' }, }, }; + +// Filled variant with all ratios in a row +export const FilledVariant: Story = { + render: () => ( +
+

Filled Variant - All Ratios

+
+
+ + + +
Rectangle (16:9)
+
+ +
+ + + +
Square (1:1)
+
+ +
+ + + +
Wide (21:9)
+
+ +
+ + + +
Portrait (3:4)
+
+ +
+ + + +
Auto (Custom Size)
+
+
+
+ ), +}; + +// Outline variant with all ratios in a row +export const OutlineVariant: Story = { + render: () => ( +
+

Outline Variant - All Ratios

+
+
+ + + +
Rectangle (16:9)
+
+ +
+ + + +
Square (1:1)
+
+ +
+ + + +
Wide (21:9)
+
+ +
+ + + +
Portrait (3:4)
+
+ +
+ + + +
Auto (Custom Size)
+
+
+
+ ), +}; diff --git a/packages/card/src/Card.tsx b/packages/card/src/Card.tsx index 6321f087..6821a03c 100644 --- a/packages/card/src/Card.tsx +++ b/packages/card/src/Card.tsx @@ -1,58 +1,19 @@ import { Slot } from '@radix-ui/react-slot'; -import { color } from '@sipe-team/tokens'; import { clsx as cx } from 'clsx'; -import { type CSSProperties, type ComponentProps, type ForwardedRef, forwardRef } from 'react'; -import styles from './Card.module.css'; - -export type CardRatio = 'rectangle' | 'square' | 'wide' | 'portrait' | 'auto'; -export type CardVariant = 'filled' | 'outline'; +import { type ComponentProps, type ForwardedRef, forwardRef } from 'react'; +import { card, type CardVariant, type CardRatio } from './Card.css'; export interface CardProps extends ComponentProps<'div'> { - ratio?: CardRatio; - variant?: CardVariant; asChild?: boolean; + variant?: CardVariant; + ratio?: CardRatio; } export const Card = forwardRef(function Card( - { className, ratio = 'rectangle', style: _style, variant = 'filled', asChild, ...props }: CardProps, + { className, variant, ratio, asChild, ...props }: CardProps, ref: ForwardedRef, ) { - const style = { - '--padding': '20px', - '--background-color': getBackgroundColor(variant), - '--border': variant === 'outline' ? `1px solid ${color.cyan300}` : `1px solid ${color.gray200}`, - '--aspect-ratio': getAspectRatio(ratio), - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - ..._style, - } as CSSProperties; - const Comp = asChild ? Slot : 'div'; - return ; + return ; }); - -const backgroundColors: Record = { - outline: color.gray50, - filled: color.gray100, -}; - -function getBackgroundColor(variant: CardVariant) { - return backgroundColors[variant] ?? color.gray100; -} - -function getAspectRatio(ratio: CardRatio) { - switch (ratio) { - case 'square': - return '1 / 1'; - case 'rectangle': - return '16 / 9'; - case 'wide': - return '21 / 9'; - case 'portrait': - return '3 / 4'; - default: - return 'auto'; - } -} diff --git a/packages/card/src/index.ts b/packages/card/src/index.ts index ca0b0604..640ae6f8 100644 --- a/packages/card/src/index.ts +++ b/packages/card/src/index.ts @@ -1 +1,2 @@ export * from './Card'; +export * from './Card.css'; diff --git a/packages/checkbox/package.json b/packages/checkbox/package.json index 4c2630f8..079a99ad 100644 --- a/packages/checkbox/package.json +++ b/packages/checkbox/package.json @@ -13,17 +13,17 @@ "scripts": { "build": "tsup", "build:storybook": "storybook build", + "clean": "rm -rf node_modules dist", "dev:storybook": "storybook dev -p 6006", - "lint:biome": "pnpm exec biome lint", - "lint:eslint": "pnpm exec eslint", + "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", "prepack": "pnpm run build" }, "dependencies": { - "@radix-ui/react-slot": "^1.1.0", + "@sipe-team/tokens": "workspace:*", "clsx": "^2.1.1", - "nanoid": "^5.0.9" + "@vanilla-extract/recipes": "^0.5.5" }, "devDependencies": { "@storybook/addon-essentials": "catalog:", @@ -35,7 +35,9 @@ "@storybook/test": "catalog:", "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", + "@testing-library/user-event": "catalog:", "@types/react": "catalog:react", + "@vanilla-extract/css": "^1.17.1", "happy-dom": "catalog:", "react": "catalog:react", "react-dom": "catalog:react", diff --git a/packages/checkbox/src/Checkbox.css.ts b/packages/checkbox/src/Checkbox.css.ts new file mode 100644 index 00000000..32d65e01 --- /dev/null +++ b/packages/checkbox/src/Checkbox.css.ts @@ -0,0 +1,201 @@ +import { color } from '@sipe-team/tokens'; +import type { RecipeVariants } from '@vanilla-extract/recipes'; +import { recipe } from '@vanilla-extract/recipes'; +import { CheckboxSize } from './Checkbox'; + +export const CHECKBOX_SIZES = { + [CheckboxSize.small]: { + inputSize: '16px', + fontSize: '14px', + containerPadding: '8px', + containerMargin: '4px', + }, + [CheckboxSize.medium]: { + inputSize: '20px', + fontSize: '16px', + containerPadding: '10px', + containerMargin: '6px', + }, + [CheckboxSize.large]: { + inputSize: '24px', + fontSize: '18px', + containerPadding: '12px', + containerMargin: '8px', + }, +} as const; + +const BORDER_RADIUS_PX = 4; +const BORDER_WIDTH_PX = 1; +const CONTAINER_GAP_PX = 8; + +const COLORS = { + border: color.gray300 || '#D1D5DB', + background: color.white || '#FFFFFF', + checked: '#3B82F6', + disabled: color.gray200 || '#E5E7EB', + hover: color.gray100 || '#F3F4F6', +}; + +const CHECKBOX_STYLE = { + borderRadius: BORDER_RADIUS_PX, + borderWidth: BORDER_WIDTH_PX, + borderColor: COLORS.border, + backgroundColor: COLORS.background, + checkedColor: COLORS.checked, + disabledColor: COLORS.disabled, + hoverColor: COLORS.hover, + backgroundSize: '100%', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + transition: 'all 0.15s ease-in-out', +} as const; + +export const container = recipe({ + base: { + display: 'flex', + alignItems: 'center', + gap: `${CONTAINER_GAP_PX}px`, + }, + variants: { + size: { + [CheckboxSize.small]: { + padding: CHECKBOX_SIZES[CheckboxSize.small].containerPadding, + margin: CHECKBOX_SIZES[CheckboxSize.small].containerMargin, + }, + [CheckboxSize.medium]: { + padding: CHECKBOX_SIZES[CheckboxSize.medium].containerPadding, + margin: CHECKBOX_SIZES[CheckboxSize.medium].containerMargin, + }, + [CheckboxSize.large]: { + padding: CHECKBOX_SIZES[CheckboxSize.large].containerPadding, + margin: CHECKBOX_SIZES[CheckboxSize.large].containerMargin, + }, + }, + disabled: { + true: {}, + false: {}, + }, + }, + defaultVariants: { + size: CheckboxSize.medium, + disabled: false, + }, +}); +export type ContainerVariants = RecipeVariants; + +export const input = recipe({ + base: { + appearance: 'none', + border: `${CHECKBOX_STYLE.borderWidth}px solid ${CHECKBOX_STYLE.borderColor}`, + backgroundColor: CHECKBOX_STYLE.backgroundColor, + backgroundSize: CHECKBOX_STYLE.backgroundSize, + backgroundPosition: CHECKBOX_STYLE.backgroundPosition, + backgroundRepeat: CHECKBOX_STYLE.backgroundRepeat, + transition: CHECKBOX_STYLE.transition, + cursor: 'pointer', + }, + variants: { + size: { + [CheckboxSize.small]: { + width: CHECKBOX_SIZES[CheckboxSize.small].inputSize, + height: CHECKBOX_SIZES[CheckboxSize.small].inputSize, + borderRadius: `${CHECKBOX_STYLE.borderRadius}px`, + }, + [CheckboxSize.medium]: { + width: CHECKBOX_SIZES[CheckboxSize.medium].inputSize, + height: CHECKBOX_SIZES[CheckboxSize.medium].inputSize, + borderRadius: `${CHECKBOX_STYLE.borderRadius}px`, + }, + [CheckboxSize.large]: { + width: CHECKBOX_SIZES[CheckboxSize.large].inputSize, + height: CHECKBOX_SIZES[CheckboxSize.large].inputSize, + borderRadius: `${CHECKBOX_STYLE.borderRadius}px`, + }, + }, + checked: { + true: { + backgroundColor: CHECKBOX_STYLE.checkedColor, + borderColor: CHECKBOX_STYLE.checkedColor, + backgroundImage: `url("public/check.svg")`, + }, + }, + indeterminate: { + true: { + backgroundColor: CHECKBOX_STYLE.checkedColor, + borderColor: CHECKBOX_STYLE.checkedColor, + backgroundImage: `url("public/indeterminate.svg")`, + }, + }, + disabled: { + true: { + backgroundColor: CHECKBOX_STYLE.disabledColor, + borderColor: CHECKBOX_STYLE.disabledColor, + cursor: 'not-allowed', + }, + false: {}, + }, + }, + compoundVariants: [ + { + variants: { + checked: true, + disabled: true, + }, + style: { + backgroundColor: CHECKBOX_STYLE.disabledColor, + borderColor: CHECKBOX_STYLE.disabledColor, + backgroundImage: `url("public/check.svg")`, + opacity: 0.6, + }, + }, + { + variants: { + indeterminate: true, + disabled: true, + }, + style: { + backgroundColor: CHECKBOX_STYLE.disabledColor, + borderColor: CHECKBOX_STYLE.disabledColor, + backgroundImage: `url("public/indeterminate.svg")`, + opacity: 0.6, + }, + }, + ], + defaultVariants: { + size: CheckboxSize.medium, + disabled: false, + }, +}); +export type InputVariants = RecipeVariants; + +// Label recipe +export const label = recipe({ + base: { + cursor: 'pointer', + }, + variants: { + size: { + [CheckboxSize.small]: { + fontSize: CHECKBOX_SIZES[CheckboxSize.small].fontSize, + }, + [CheckboxSize.medium]: { + fontSize: CHECKBOX_SIZES[CheckboxSize.medium].fontSize, + }, + [CheckboxSize.large]: { + fontSize: CHECKBOX_SIZES[CheckboxSize.large].fontSize, + }, + }, + disabled: { + true: { + cursor: 'not-allowed', + opacity: 0.6, + }, + false: {}, + }, + }, + defaultVariants: { + size: CheckboxSize.medium, + disabled: false, + }, +}); +export type LabelVariants = RecipeVariants; diff --git a/packages/checkbox/src/Checkbox.module.css b/packages/checkbox/src/Checkbox.module.css deleted file mode 100644 index c9a42550..00000000 --- a/packages/checkbox/src/Checkbox.module.css +++ /dev/null @@ -1,47 +0,0 @@ -.checkbox { - display: flex; - align-items: center; - gap: 8px; - margin: var(--checkbox-margin); - padding: var(--checkbox-padding); -} - -.checkbox-input { - appearance: none; - width: var(--checkbox-size); - height: var(--checkbox-size); - border: var(--border-width) solid var(--border-color); - border-radius: var(--border-radius); - background-color: var(--background-color); -} - -.checkbox-input:disabled { - background-color: var(--disabled-color); - border-color: var(--disabled-color); -} - -.checkbox-label { - font-size: var(--label-size); - cursor: pointer; -} - -.checkbox.disabled .checkbox-label { - cursor: not-allowed; - opacity: 0.6; -} - -.checkbox-input:is(:checked, :indeterminate) { - background-color: var(--checked-color); - border-color: var(--checked-color); - background-size: var(--background-size); - background-position: var(--background-position); - background-repeat: var(--background-repeat); -} - -.checkbox-input:checked { - background-image: var(--checked-icon); -} - -.checkbox-input:indeterminate { - background-image: var(--indeterminate-icon); -} diff --git a/packages/checkbox/src/Checkbox.stories.tsx b/packages/checkbox/src/Checkbox.stories.tsx index c6a65f1e..facd2254 100644 --- a/packages/checkbox/src/Checkbox.stories.tsx +++ b/packages/checkbox/src/Checkbox.stories.tsx @@ -5,11 +5,39 @@ import { useCheckboxGroup } from './hooks/useCheckboxGroup'; const meta = { title: 'Components/Checkbox', - component: Checkbox, + component: Checkbox.Root, parameters: { layout: 'centered', }, -} satisfies Meta; + tags: ['autodocs'], + argTypes: { + size: { + control: 'select', + options: ['small', 'medium', 'large'], + description: 'Size of the checkbox', + defaultValue: 'medium', + }, + disabled: { + control: 'boolean', + description: 'Whether the checkbox is disabled', + defaultValue: false, + }, + checked: { + control: 'boolean', + description: 'Whether the checkbox is checked (controlled)', + }, + defaultChecked: { + control: 'boolean', + description: 'Default checked state (uncontrolled)', + defaultValue: false, + }, + indeterminate: { + control: 'boolean', + description: 'Whether the checkbox is in an indeterminate state', + defaultValue: false, + }, + }, +} satisfies Meta; export default meta; @@ -17,137 +45,106 @@ type Story = StoryObj; export const Basic: Story = { args: { - label: 'Basic Checkbox', - value: 'test', - name: 'test', - }, -}; - -export const Checked: Story = { - args: { - label: 'Checked Checkbox', - value: 'test', - name: 'test', - checked: true, - }, -}; - -export const Disabled: Story = { - args: { - label: 'Disabled Checkbox', - value: 'test', - name: 'test', - disabled: true, + size: 'medium', + children: ( + <> + + Basic checkbox + + ), }, }; export const Sizes: Story = { - render: () => ( -
- - - -
- ), -}; - -export const CustomStyles: Story = { args: { - label: 'Custom Styled Checkbox', - style: { - padding: '20px', - border: '2px solid #f8f', - borderRadius: '10px', - backgroundColor: '#f8f9fa', - }, - value: 'test', - name: 'test', + children: ( +
+ + + Small size + + + + Medium size + + + + Large size + +
+ ), }, }; export const Controlled: Story = { render: () => { - const [isChecked, setIsChecked] = useState(false); + const [checked, setChecked] = useState(false); - return ; + return ( +
+ + + Controlled checkbox + +
+ ); }, }; export const Uncontrolled: Story = { args: { - label: 'Uncontrolled Checkbox', - value: 'test', - name: 'test', + children: ( +
+ + + Uncontrolled checkbox + +
+ ), }, }; -export const CheckboxGroup: Story = { - render: () => { - const items = ['Item 1', 'Item 2', 'Item 3']; - const { checkedItems, updateCheckedItems, setAllChecked, allChecked } = useCheckboxGroup({ - total: items.length, - }); - - return ( -
- - {items.map((item, index) => ( - updateCheckedItems(index, checked)} - /> - ))} +export const Disabled: Story = { + args: { + children: ( +
+ + + Disabled unchecked + + + + Disabled checked +
- ); + ), }, }; export const Indeterminate: Story = { render: () => { - const [parentChecked, setParentChecked] = useState(false); - const [parentIndeterminate, setParentIndeterminate] = useState(false); - const [childChecked, setChildChecked] = useState([false, false]); - - const updateParentState = (newChildChecked: boolean[]) => { - const checkedCount = newChildChecked.filter(Boolean).length; - setParentIndeterminate(checkedCount > 0 && checkedCount < newChildChecked.length); - setParentChecked(checkedCount === newChildChecked.length); - }; - - const handleParentChange = (checked: boolean) => { - setParentChecked(checked); - setParentIndeterminate(false); - setChildChecked([checked, checked]); - }; - - const handleChildChange = (index: number, checked: boolean) => { - const newChildChecked = [...childChecked]; - newChildChecked[index] = checked; - setChildChecked(newChildChecked); - updateParentState(newChildChecked); - }; + const { allChecked, indeterminate, checkedItems, updateCheckedItems, setAllChecked } = useCheckboxGroup({ + total: 3, + }); return ( -
- +
+ + + Select all options + +
- handleChildChange(0, checked)} - /> - handleChildChange(1, checked)} - /> + {checkedItems.map((item, index) => ( + updateCheckedItems(index, checked)} + > + + {`Option ${index + 1}`} + + ))}
); diff --git a/packages/checkbox/src/Checkbox.test.tsx b/packages/checkbox/src/Checkbox.test.tsx index 500282c4..be99b6ea 100644 --- a/packages/checkbox/src/Checkbox.test.tsx +++ b/packages/checkbox/src/Checkbox.test.tsx @@ -1,92 +1,263 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; -import { Checkbox } from './Checkbox'; -import { CHECKBOX_SIZES, type CheckboxSize } from './constants/size'; +import { Checkbox, CheckboxSize, type CheckBoxRootBaseProps } from './Checkbox'; +import { CHECKBOX_SIZES } from './Checkbox.css'; +import userEvent from '@testing-library/user-event'; -describe('Checkbox ๊ธฐ๋ณธ ๋™์ž‘ ํ…Œ์ŠคํŠธ', () => { - test('์ฒดํฌ๋ฐ•์Šค์˜ ์ƒํƒœ๋ฅผ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋‹ค.', () => { - render(); +const RenderBasicCheckbox = ({ label, ...props }: CheckBoxRootBaseProps & { label?: string }) => { + return ( + + + {label ?? 'Test Checkbox'} + + ); +}; + +describe('Checkbox', () => { + test('should be checked when checked prop is true', () => { + render(); const checkbox = screen.getByLabelText('Test Checkbox') as HTMLInputElement; expect(checkbox.checked).toBe(true); }); - test('์ฒดํฌ๋ฐ•์Šค์˜ ๋ ˆ์ด๋ธ”์„ ํด๋ฆญํ•˜๋ฉด ์ฒดํฌ๋ฐ•์Šค์˜ ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋œ๋‹ค.', () => { + test('should call onCheckedChange when label is clicked', () => { const handleChange = vi.fn(); - render(); + render(); const label = screen.getByText('Test Checkbox'); fireEvent.click(label); expect(handleChange).toHaveBeenCalledWith(true); }); - test('์ฒดํฌ๋ฐ•์Šค๋ฅผ ํด๋ฆญํ•˜๋ฉด ์ฒดํฌ๋ฐ•์Šค์˜ ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋œ๋‹ค.', () => { + test('should call onCheckedChange when checkbox is clicked', () => { const handleChange = vi.fn(); - render(); + render(); const checkbox = screen.getByLabelText('Test Checkbox') as HTMLInputElement; fireEvent.click(checkbox); expect(handleChange).toHaveBeenCalledWith(true); }); }); -describe('Checkbox ์Šคํƒ€์ผ ํ…Œ์ŠคํŠธ', () => { - test('์ „๋‹ฌ๋ฐ›์€ style์„ ์ปดํฌ๋„ŒํŠธ์— ์ ์šฉํ•œ๋‹ค.', () => { - render(); - const checkbox = screen.getByLabelText('Test Checkbox').parentElement; - expect(checkbox).toHaveStyle('margin: 10px'); +describe('Checkbox Styling', () => { + test('should apply custom styles to all components', () => { + const testRootStyle = { backgroundColor: 'red' }; + const testInputStyle = { backgroundColor: 'blue' }; + const testLabelStyle = { color: 'green' }; + + render( + + + Test Checkbox + , + ); + const label = screen.getByText('Test Checkbox'); + const checkbox = screen.getByRole('checkbox'); + const container = label.parentElement; + expect(container).toHaveStyle(testRootStyle); + expect(checkbox).toHaveStyle(testInputStyle); + expect(label).toHaveStyle(testLabelStyle); }); - test('checked๋ฅผ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด checkbox๊ฐ€ ์ฒดํฌ๋˜์ง€ ์•Š์€ ์ƒํƒœ๋กœ ๋ Œ๋”๋ง๋œ๋‹ค.', () => { - render(); + test('should apply custom classNames to all components', () => { + const testRootClass = 'root-class'; + const testInputClass = 'input-class'; + const testLabelClass = 'label-class'; + + render( + + + Test Checkbox + , + ); + + const label = screen.getByText('Test Checkbox'); + const checkbox = screen.getByRole('checkbox'); + const container = label.parentElement; + + expect(container).toHaveClass(testRootClass); + expect(checkbox).toHaveClass(testInputClass); + expect(label).toHaveClass(testLabelClass); + }); + + test('should be unchecked by default when checked prop is not provided', () => { + render(); + const checkbox = screen.getByLabelText('Test Checkbox') as HTMLInputElement; expect(checkbox.checked).toBe(false); }); - test('checked๋ฅผ ์ฃผ์ž…ํ•˜๋ฉด checkbox๊ฐ€ ์ฒดํฌ๋œ ์ƒํƒœ๋กœ ๋ Œ๋”๋ง๋œ๋‹ค.', () => { - render(); + test('should be checked when checked prop is true', () => { + render(); + const checkbox = screen.getByLabelText('Test Checkbox') as HTMLInputElement; expect(checkbox.checked).toBe(true); }); - test('disabled๋ฅผ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด checkbox๊ฐ€ ํ™œ์„ฑํ™”๋œ ์ƒํƒœ๋กœ ๋ Œ๋”๋ง๋œ๋‹ค.', () => { - render(); + test('should be enabled by default when disabled prop is not provided', () => { + render(); + const checkbox = screen.getByLabelText('Test Checkbox') as HTMLInputElement; expect(checkbox.disabled).toBe(false); }); - test('disabled๋ฅผ ์ฃผ์ž…ํ•˜๋ฉด checkbox๊ฐ€ ๋น„ํ™œ์„ฑํ™”๋œ ์ƒํƒœ๋กœ ๋ Œ๋”๋ง๋œ๋‹ค.', () => { - render(); + test('should be disabled when disabled prop is true', () => { + render(); + const checkbox = screen.getByLabelText('Test Checkbox') as HTMLInputElement; expect(checkbox.disabled).toBe(true); }); - test.each(['small', 'medium', 'large'])('size๋กœ %s์ฃผ์ž…์‹œ ํ•ด๋‹น ํฌ๊ธฐ๋กœ checkbox์˜ ํฌ๊ธฐ๋ฅผ ์„ค์ •ํ•œ๋‹ค.', (size) => { - render(); - const checkbox = screen.getByLabelText('Test Checkbox').parentElement; - const expectedSize = CHECKBOX_SIZES[size as CheckboxSize].checkboxSize; - expect(checkbox).toHaveStyle(`--checkbox-size: ${expectedSize}px`); + test.each(Object.values(CheckboxSize))('should have correct size when size prop is %s', (size) => { + render(); + const checkbox = screen.getByLabelText('Test Checkbox'); + expect(checkbox).toHaveStyle({ + width: CHECKBOX_SIZES[size].inputSize, + height: CHECKBOX_SIZES[size].inputSize, + }); }); - test('size๋ฅผ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด ๊ธฐ๋ณธ ๊ฐ’ medium๋กœ ํฌ๊ธฐ๋ฅผ ์„ค์ •ํ•œ๋‹ค.', () => { - render(); - const checkbox = screen.getByLabelText('Test Checkbox').parentElement; - expect(checkbox).toHaveStyle(`--checkbox-size: ${CHECKBOX_SIZES.medium.checkboxSize}px`); + test('should have medium size by default when size prop is not provided', () => { + render(); + + const checkbox = screen.getByLabelText('Test Checkbox'); + expect(checkbox).toHaveStyle({ + width: CHECKBOX_SIZES[CheckboxSize.medium].inputSize, + height: CHECKBOX_SIZES[CheckboxSize.medium].inputSize, + }); }); }); -describe('Checkbox ์ด๋ฒคํŠธ ํ…Œ์ŠคํŠธ', () => { - // default - test('์ฒดํฌ๋ฐ•์Šค ์˜์—ญ์„ ํด๋ฆญํ•˜๋ฉด onChange ์ฝœ๋ฐฑ์ด ํ˜ธ์ถœ๋œ๋‹ค.', () => { +describe('Checkbox Event Handling', () => { + test('should call onCheckedChange when checkbox is clicked', () => { const handleChange = vi.fn(); - render(); + render(); + const checkbox = screen.getByLabelText('Test Checkbox') as HTMLInputElement; fireEvent.click(checkbox); + + expect(handleChange).toHaveBeenCalledWith(true); + }); +}); + +describe('Checkbox Group Behavior', () => { + test('should handle multiple checkboxes with same name as a group', async () => { + const handleSubmit = vi.fn((e) => { + e.preventDefault(); + const formData = new FormData(e.target); + return formData; + }); + + render( +
+ + + + + , + ); + + const appleCheckbox = screen.getByLabelText('Apple'); + const orangeCheckbox = screen.getByLabelText('Orange'); + await userEvent.click(appleCheckbox); + await userEvent.click(orangeCheckbox); + + const submitButton = screen.getByText('Submit'); + fireEvent.click(submitButton); + + expect(handleSubmit).toHaveBeenCalled(); + + const formDataEntries = Array.from(handleSubmit.mock.results[0]?.value?.entries() ?? []); + + const fruitValues = formDataEntries + .filter((entry): entry is [string, string] => Array.isArray(entry) && entry[0] === 'fruits') + .map(([, value]) => value); + + expect(fruitValues).toContain('apple'); + expect(fruitValues).toContain('orange'); + expect(fruitValues).not.toContain('banana'); + expect(fruitValues.length).toBe(2); + }); + + test('should not include unchecked checkboxes in form data', async () => { + const handleSubmit = vi.fn((e) => { + e.preventDefault(); + const formData = new FormData(e.target); + return formData; + }); + + render( +
+ + + + + , + ); + + const submitButton = screen.getByText('Submit'); + fireEvent.click(submitButton); + + expect(handleSubmit).toHaveBeenCalled(); + + const formDataEntries = Array.from(handleSubmit.mock.results[0]?.value?.entries() ?? []); + + const hasOption1 = + formDataEntries.filter((entry): entry is [string, string] => Array.isArray(entry) && entry[0] === 'option1') + .length > 0; + const hasOption2 = + formDataEntries.filter((entry): entry is [string, string] => Array.isArray(entry) && entry[0] === 'option2') + .length > 0; + + expect(hasOption1).toBe(false); + expect(hasOption2).toBe(false); + }); +}); + +describe('Checkbox Label Connection', () => { + test('should check checkbox when clicking associated label', () => { + const handleChange = vi.fn(); + + render(); + + const label = screen.getByText('Test Checkbox'); + fireEvent.click(label); + + expect(handleChange).toHaveBeenCalledWith(true); + }); + + test('should automatically connect label and input with generated ID', () => { + const handleChange = vi.fn(); + + render(); + + const label = screen.getByText('Test Checkbox'); + fireEvent.click(label); + expect(handleChange).toHaveBeenCalledWith(true); }); }); -describe('Checkbox ์ ‘๊ทผ์„ฑ ํ…Œ์ŠคํŠธ', () => { - test('label์„ ํ†ตํ•ด ์ฒดํฌ๋ฐ•์Šค์˜ ์šฉ๋„๋ฅผ ์ œ๊ณตํ•œ๋‹ค.', () => { - render(); - const checkbox = screen.getByLabelText('Accessible Checkbox'); - expect(checkbox).toBeInTheDocument(); +describe('Required Checkbox Behavior', () => { + test('should block form submission when required checkbox is unchecked', async () => { + const handleSubmit = vi.fn((e) => { + e.preventDefault(); + }); + + const handleInvalid = vi.fn(); + + render( +
+ + + , + ); + + const submitButton = screen.getByText('Submit'); + fireEvent.click(submitButton); + expect(handleSubmit).not.toHaveBeenCalled(); + expect(handleInvalid).toHaveBeenCalled(); + const checkbox = screen.getByLabelText('Test Checkbox'); + fireEvent.click(checkbox); + fireEvent.click(submitButton); + expect(handleSubmit).toHaveBeenCalled(); }); }); diff --git a/packages/checkbox/src/Checkbox.tsx b/packages/checkbox/src/Checkbox.tsx index 79923681..df89a165 100644 --- a/packages/checkbox/src/Checkbox.tsx +++ b/packages/checkbox/src/Checkbox.tsx @@ -1,119 +1,152 @@ -import { Slot } from '@radix-ui/react-slot'; -import { clsx as cx } from 'clsx'; -import { type CSSProperties, type ComponentProps, type ReactNode, useEffect, useId, useRef } from 'react'; +import clsx from 'clsx'; +import { + createContext, + forwardRef, + useContext, + useId, + type ChangeEventHandler, + type ComponentProps, + type Ref, +} from 'react'; +import { container, input, label } from './Checkbox.css'; +import { useControllableState } from './hooks/useControllableState'; -import styles from './Checkbox.module.css'; -import { type CheckStyleConfig, DEFAULT_CHECK_STYLE } from './constants/checkStyle'; -import { CHECKBOX_SIZES, type CheckboxSize } from './constants/size'; +export const CheckboxSize = { + small: 'small', + medium: 'medium', + large: 'large', +} as const; +export type CheckboxSize = (typeof CheckboxSize)[keyof typeof CheckboxSize]; -export interface CheckboxProps extends ComponentProps<'div'> { - name?: string; - value?: string; +export type CheckBoxRootBaseProps = Partial, 'size'>> & { size?: CheckboxSize; - checked?: boolean; - indeterminate?: boolean; - disabled?: boolean; - label?: string; onCheckedChange?: (checked: boolean) => void; - asChild?: boolean; - innerRef?: React.RefObject; - children?: ReactNode; - checkStyleConfig?: Partial; + indeterminate?: boolean; +}; +interface CheckboxContextValue extends CheckBoxRootBaseProps { + ref?: Ref | undefined; } -export const Checkbox = ({ - className, - name, - value, - label, - asChild = true, - size = 'medium', - checked, - indeterminate = false, - disabled = false, - onCheckedChange, - children, - style: _style, - innerRef, - checkStyleConfig = {}, - ...props -}: CheckboxProps) => { - const localRef = useRef(null); - const inputRef = useRef(null); - const inputId = useId(); - - useEffect(() => { - if (inputRef.current) { - inputRef.current.indeterminate = indeterminate; - } - }, [indeterminate]); - - const Component = asChild ? Slot : 'div'; - - const handleChange = () => { - if (!disabled && onCheckedChange) { - onCheckedChange(!checked); - } - }; - - const sizeConfig = CHECKBOX_SIZES[size]; - const mergedStyleConfig = { ...DEFAULT_CHECK_STYLE, ...checkStyleConfig }; - - const style = { - '--checkbox-size': `${sizeConfig.checkboxSize}px`, - '--label-size': `${sizeConfig.labelSize}px`, - '--checkbox-padding': `${sizeConfig.padding}px`, - '--checkbox-margin': `${sizeConfig.margin}px`, - '--border-radius': `${mergedStyleConfig.borderRadius}px`, - '--border-width': `${mergedStyleConfig.borderWidth}px`, - '--border-color': mergedStyleConfig.borderColor, - '--background-color': mergedStyleConfig.backgroundColor, - '--checked-color': mergedStyleConfig.checkedColor, - '--disabled-color': mergedStyleConfig.disabledColor, - '--hover-color': mergedStyleConfig.hoverColor, - '--checked-icon': `url(${mergedStyleConfig.checkedIcon})`, - '--indeterminate-icon': `url(${mergedStyleConfig.indeterminateIcon})`, - '--background-size': mergedStyleConfig.backgroundSize, - '--background-position': mergedStyleConfig.backgroundPosition, - '--background-repeat': mergedStyleConfig.backgroundRepeat, - ..._style, - } as CSSProperties; - - if (!label && children) { - return <>{children}; +const CheckboxContext = createContext(null); + +const useCheckbox = () => { + const context = useContext(CheckboxContext); + if (!context) { + throw new Error('Checkbox compound components must be used within Checkbox.Root'); } + return context; +}; + +const Root = forwardRef( + ( + { + className = '', + size = CheckboxSize.medium, + indeterminate = false, + onCheckedChange, + children, + style, + checked, + ...props + }, + ref, + ) => { + const internalId = useId(); + const id = props.id ?? internalId; + + const [checkedState, setCheckedState] = useControllableState({ + prop: checked, + defaultProp: props.defaultChecked || false, + onChange: onCheckedChange || (() => {}), + }); + + const contextValue: CheckboxContextValue = { + ...props, + indeterminate, + ref, + id, + size, + onCheckedChange: setCheckedState, + checked: checkedState === undefined ? false : checkedState, + }; + + return ( +
+ {children} +
+ ); + }, +); + +type CheckboxInputProps = Omit, 'size' | 'checked' | 'id'>; - const content = ( -
+const Input = forwardRef( + ({ onChange, value, name, className, ...props }, ref) => { + const { + id, + checked, + disabled, + size, + onChange: contextOnChange, + onCheckedChange, + name: contextName, + value: contextValue, + indeterminate, + ref: contextRef, + ...rootProps + } = useCheckbox(); + + const handleChange: ChangeEventHandler = (e) => { + try { + if (onChange) { + onChange(e); + } + + if (contextOnChange) { + contextOnChange(e); + } + + onCheckedChange?.(e.target.checked); + } catch (error) { + console.error('Checkbox onChange error', error); + } + }; + + return ( - {label && ( - - )} -
+ ); + }, +); + +const Label = forwardRef>(({ children, className, ...props }, ref) => { + const { id, disabled, size } = useCheckbox(); + + return ( + ); +}); + +Root.displayName = 'Checkbox.Root'; +Input.displayName = 'Checkbox.Input'; +Label.displayName = 'Checkbox.Label'; - return asChild ? {content} : content; +export const Checkbox = { + Root, + Input, + Label, }; diff --git a/packages/checkbox/src/constants/checkStyle.ts b/packages/checkbox/src/constants/checkStyle.ts deleted file mode 100644 index 3bfcac05..00000000 --- a/packages/checkbox/src/constants/checkStyle.ts +++ /dev/null @@ -1,32 +0,0 @@ -import CheckboxIcon from '../images/checkbox-icon.svg'; -import minusIcon from '../images/minus-icon.svg'; - -export interface CheckStyleConfig { - borderRadius: number; - borderWidth: number; - borderColor: string; - backgroundColor: string; - checkedColor: string; - disabledColor: string; - hoverColor: string; - checkedIcon: string; - indeterminateIcon: string; - backgroundSize: string; - backgroundPosition: string; - backgroundRepeat: string; -} - -export const DEFAULT_CHECK_STYLE: CheckStyleConfig = { - borderRadius: 4, - borderWidth: 1, - borderColor: '#D1D5DB', - backgroundColor: '#FFFFFF', - checkedColor: '#3B82F6', - disabledColor: '#E5E7EB', - hoverColor: '#F3F4F6', - checkedIcon: CheckboxIcon, - indeterminateIcon: minusIcon, - backgroundSize: '100%', - backgroundPosition: 'center', - backgroundRepeat: 'no-repeat', -} as const; diff --git a/packages/checkbox/src/constants/size.ts b/packages/checkbox/src/constants/size.ts deleted file mode 100644 index cb6db4fa..00000000 --- a/packages/checkbox/src/constants/size.ts +++ /dev/null @@ -1,29 +0,0 @@ -export type CheckboxSize = 'small' | 'medium' | 'large'; - -export interface SizeConfig { - checkboxSize: number; - labelSize: number; - padding: number; - margin: number; -} - -export const CHECKBOX_SIZES: Record = { - small: { - checkboxSize: 16, - labelSize: 14, - padding: 8, - margin: 4, - }, - medium: { - checkboxSize: 20, - labelSize: 16, - padding: 10, - margin: 6, - }, - large: { - checkboxSize: 24, - labelSize: 18, - padding: 12, - margin: 8, - }, -} as const; diff --git a/packages/checkbox/src/hooks/useControllableState.ts b/packages/checkbox/src/hooks/useControllableState.ts new file mode 100644 index 00000000..bacfc2eb --- /dev/null +++ b/packages/checkbox/src/hooks/useControllableState.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +type UseControllableStateParams = { + prop?: T; + defaultProp?: T; + onChange?: (state: NonNullable) => void; +}; + +function useControllableState({ prop, defaultProp, onChange = () => {} }: UseControllableStateParams) { + const [uncontrolledState, setUncontrolledState] = useState(defaultProp); + const isControlled = prop !== undefined; + const value = isControlled ? prop : uncontrolledState; + + const callbackRef = useRef(onChange); + useEffect(() => { + callbackRef.current = onChange; + }); + + const setValue = useCallback( + (nextValue: T | ((prevValue: T) => T)) => { + const newValue = typeof nextValue === 'function' ? (nextValue as (prevValue: T) => T)(value as T) : nextValue; + + if (isControlled) { + if (newValue !== prop) callbackRef.current(newValue as NonNullable); + } else { + setUncontrolledState(newValue); + } + }, + [isControlled, prop, value], + ); + + const prevValueRef = useRef(value); + useEffect(() => { + if (!isControlled && prevValueRef.current !== value) { + callbackRef.current(value as NonNullable); + prevValueRef.current = value; + } + }, [value, isControlled]); + + return [value, setValue] as const; +} + +export { useControllableState }; diff --git a/packages/checkbox/src/index.ts b/packages/checkbox/src/index.ts index 0da541d4..7ed57d43 100644 --- a/packages/checkbox/src/index.ts +++ b/packages/checkbox/src/index.ts @@ -1,2 +1,3 @@ export * from './Checkbox'; export * from './hooks/useCheckboxGroup'; +export * from './hooks/useControllableState'; diff --git a/packages/checkbox/src/images/checkbox-icon.svg b/packages/checkbox/src/public/check.svg similarity index 100% rename from packages/checkbox/src/images/checkbox-icon.svg rename to packages/checkbox/src/public/check.svg diff --git a/packages/checkbox/src/images/minus-icon.svg b/packages/checkbox/src/public/indeterminate.svg similarity index 100% rename from packages/checkbox/src/images/minus-icon.svg rename to packages/checkbox/src/public/indeterminate.svg diff --git a/packages/checkbox/src/types/svg.d.ts b/packages/checkbox/src/types/svg.d.ts deleted file mode 100644 index cdb2b1a9..00000000 --- a/packages/checkbox/src/types/svg.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module '*.svg' { - const content: string; - export default content; -} diff --git a/packages/checkbox/tsconfig.json b/packages/checkbox/tsconfig.json index 4082f16a..f264cd9a 100644 --- a/packages/checkbox/tsconfig.json +++ b/packages/checkbox/tsconfig.json @@ -1,3 +1,4 @@ { - "extends": "../../tsconfig.json" + "extends": "../../tsconfig.json", + "exclude": ["dist", "node_modules"] } diff --git a/packages/chip/package.json b/packages/chip/package.json new file mode 100644 index 00000000..82c1d792 --- /dev/null +++ b/packages/chip/package.json @@ -0,0 +1,57 @@ +{ + "name": "@side/chip", + "version": "0.0.1", + "description": "Chip component for SIDE design system", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.0.2", + "@vanilla-extract/css": "^1.14.0", + "@vanilla-extract/recipes": "^0.5.1", + "clsx": "^2.0.0" + }, + "devDependencies": { + "@sipe-team/tokens": "workspace:*", + "@testing-library/jest-dom": "catalog:", + "@testing-library/react": "catalog:", + "@testing-library/user-event": "catalog:", + "@types/react": "^18.2.0", + "jsdom": "^26.1.0", + "react": "^18.2.0", + "tsup": "^8.0.0", + "typescript": "^5.0.0", + "vitest": "^1.0.0" + }, + "peerDependencies": { + "react": ">=18.0.0" + }, + "keywords": [ + "chip", + "tag", + "badge", + "design-system", + "react" + ], + "author": "SIDE Team", + "license": "MIT" +} diff --git a/packages/chip/src/Chip.constants.ts b/packages/chip/src/Chip.constants.ts new file mode 100644 index 00000000..0f62c7c5 --- /dev/null +++ b/packages/chip/src/Chip.constants.ts @@ -0,0 +1,21 @@ +export const ChipVariant = { + filled: 'filled', + outline: 'outline', +} as const; +export type ChipVariant = (typeof ChipVariant)[keyof typeof ChipVariant]; + +export const ChipSize = { + small: 'small', + medium: 'medium', + large: 'large', +} as const; +export type ChipSize = (typeof ChipSize)[keyof typeof ChipSize]; + +export const ChipColor = { + primary: 'primary', + secondary: 'secondary', + success: 'success', + warning: 'warning', + danger: 'danger', +} as const; +export type ChipColor = (typeof ChipColor)[keyof typeof ChipColor]; diff --git a/packages/chip/src/Chip.css.ts b/packages/chip/src/Chip.css.ts new file mode 100644 index 00000000..a835836e --- /dev/null +++ b/packages/chip/src/Chip.css.ts @@ -0,0 +1,358 @@ +import { color } from '@sipe-team/tokens'; + +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +const transparentColor = 'transparent'; + +export const disabled = style({ + opacity: 0.4, + cursor: 'not-allowed', + pointerEvents: 'none', +}); + +export const chip = recipe({ + base: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 9999, + fontWeight: 600, + cursor: 'pointer', + transition: 'all 0.2s ease-in-out', + border: '1px solid', + outline: 'none', + userSelect: 'none', + ':disabled': { + cursor: 'not-allowed', + opacity: 0.4, + }, + }, + variants: { + size: { + small: { + padding: '4px 12px', + fontSize: '12px', + lineHeight: '16px', + height: '24px', + }, + medium: { + padding: '8px 16px', + fontSize: '14px', + lineHeight: '20px', + height: '32px', + }, + large: { + padding: '12px 20px', + fontSize: '16px', + lineHeight: '24px', + height: '40px', + }, + }, + variant: { + filled: { + borderColor: transparentColor, + }, + outline: { + backgroundColor: transparentColor, + }, + }, + color: { + primary: {}, + secondary: {}, + success: {}, + warning: {}, + danger: {}, + }, + state: { + default: {}, + selected: {}, + }, + }, + compoundVariants: [ + // Primary color combinations + { + variants: { + color: 'primary', + variant: 'filled', + state: 'default', + }, + style: { + backgroundColor: color.gray700, + color: color.white, + borderColor: color.gray700, + ':hover': { + backgroundColor: color.gray600, + borderColor: color.gray600, + }, + ':focus': { + outlineColor: color.gray500, + }, + }, + }, + { + variants: { + color: 'primary', + variant: 'filled', + state: 'selected', + }, + style: { + backgroundColor: color.cyan400, + color: color.black, + borderColor: color.cyan400, + ':hover': { + backgroundColor: color.cyan300, + borderColor: color.cyan300, + }, + ':focus': { + outlineColor: color.cyan300, + }, + }, + }, + { + variants: { + color: 'primary', + variant: 'outline', + state: 'default', + }, + style: { + backgroundColor: transparentColor, + color: color.gray700, + borderColor: color.gray400, + ':hover': { + backgroundColor: color.gray100, + borderColor: color.gray500, + }, + ':focus': { + outlineColor: color.gray500, + }, + }, + }, + { + variants: { + color: 'primary', + variant: 'outline', + state: 'selected', + }, + style: { + backgroundColor: transparentColor, + color: color.cyan400, + borderColor: color.cyan400, + ':hover': { + backgroundColor: color.cyan100, + borderColor: color.cyan400, + }, + ':focus': { + outlineColor: color.cyan400, + }, + }, + }, + // Secondary color combinations + { + variants: { + color: 'secondary', + variant: 'filled', + state: 'default', + }, + style: { + backgroundColor: color.gray600, + color: color.white, + borderColor: color.gray600, + ':hover': { + backgroundColor: color.gray500, + borderColor: color.gray500, + }, + }, + }, + { + variants: { + color: 'secondary', + variant: 'filled', + state: 'selected', + }, + style: { + backgroundColor: color.purple400, + color: color.white, + borderColor: color.purple400, + ':hover': { + backgroundColor: color.purple300, + borderColor: color.purple300, + }, + }, + }, + // Success color combinations + { + variants: { + color: 'success', + variant: 'filled', + state: 'default', + }, + style: { + backgroundColor: color.green500, + color: color.white, + borderColor: color.green500, + ':hover': { + backgroundColor: color.green400, + borderColor: color.green400, + }, + }, + }, + // Warning color combinations + { + variants: { + color: 'warning', + variant: 'filled', + state: 'default', + }, + style: { + backgroundColor: color.orange500, + color: color.white, + borderColor: color.orange500, + ':hover': { + backgroundColor: color.orange400, + borderColor: color.orange400, + }, + }, + }, + // Danger color combinations + { + variants: { + color: 'danger', + variant: 'filled', + state: 'default', + }, + style: { + backgroundColor: color.red500, + color: color.white, + borderColor: color.red500, + ':hover': { + backgroundColor: color.red400, + borderColor: color.red400, + }, + }, + }, + // Success outline combinations + { + variants: { + color: 'success', + variant: 'outline', + state: 'default', + }, + style: { + backgroundColor: transparentColor, + color: color.green500, + borderColor: color.green500, + ':hover': { + backgroundColor: color.green100, + borderColor: color.green600, + }, + ':focus': { + outlineColor: color.green500, + }, + }, + }, + { + variants: { + color: 'success', + variant: 'outline', + state: 'selected', + }, + style: { + backgroundColor: color.green100, + color: color.green600, + borderColor: color.green500, + ':hover': { + backgroundColor: color.green200, + borderColor: color.green600, + }, + ':focus': { + outlineColor: color.green500, + }, + }, + }, + // Warning outline combinations + { + variants: { + color: 'warning', + variant: 'outline', + state: 'default', + }, + style: { + backgroundColor: transparentColor, + color: color.orange500, + borderColor: color.orange500, + ':hover': { + backgroundColor: color.orange100, + borderColor: color.orange600, + }, + ':focus': { + outlineColor: color.orange500, + }, + }, + }, + { + variants: { + color: 'warning', + variant: 'outline', + state: 'selected', + }, + style: { + backgroundColor: color.orange100, + color: color.orange600, + borderColor: color.orange500, + ':hover': { + backgroundColor: color.orange200, + borderColor: color.orange600, + }, + ':focus': { + outlineColor: color.orange500, + }, + }, + }, + // Danger outline combinations + { + variants: { + color: 'danger', + variant: 'outline', + state: 'default', + }, + style: { + backgroundColor: transparentColor, + color: color.red500, + borderColor: color.red500, + ':hover': { + backgroundColor: color.red100, + borderColor: color.red600, + }, + ':focus': { + outlineColor: color.red500, + }, + }, + }, + { + variants: { + color: 'danger', + variant: 'outline', + state: 'selected', + }, + style: { + backgroundColor: color.red100, + color: color.red600, + borderColor: color.red500, + ':hover': { + backgroundColor: color.red200, + borderColor: color.red600, + }, + ':focus': { + outlineColor: color.red500, + }, + }, + }, + ], + defaultVariants: { + color: 'primary', + variant: 'filled', + size: 'medium', + state: 'default', + }, +}); diff --git a/packages/chip/src/Chip.stories.tsx b/packages/chip/src/Chip.stories.tsx new file mode 100644 index 00000000..bc035fe2 --- /dev/null +++ b/packages/chip/src/Chip.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Chip } from './Chip'; +import { ChipColor, ChipSize, ChipVariant } from './Chip.constants'; + +const meta = { + title: 'Components/Chip', + component: Chip, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + color: { + control: 'select', + options: Object.values(ChipColor), + }, + variant: { + control: 'select', + options: Object.values(ChipVariant), + }, + size: { + control: 'select', + options: Object.values(ChipSize), + }, + selected: { + control: 'boolean', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'SIPE chip', + }, +}; + +export const Colors: Story = { + render: () => ( +
+ Primary + Secondary + Success + Warning + Danger +
+ ), +}; + +export const Variants: Story = { + render: () => ( +
+ Filled + Outline +
+ ), +}; + +export const Sizes: Story = { + render: () => ( +
+ Small + Medium + Large +
+ ), +}; + +export const States: Story = { + render: () => ( +
+ Default + Selected +
+ ), +}; diff --git a/packages/chip/src/Chip.test.tsx b/packages/chip/src/Chip.test.tsx new file mode 100644 index 00000000..e01d3011 --- /dev/null +++ b/packages/chip/src/Chip.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { Chip } from './Chip'; + +describe('Chip', () => { + it('renders correctly', () => { + render(Test Chip); + expect(screen.getByText('Test Chip')).toBeTruthy(); + }); + + it('applies correct default props', () => { + render(Default Chip); + const chip = screen.getByText('Default Chip'); + expect(chip).toBeTruthy(); + }); + + it('applies custom props correctly', () => { + render( + + Custom Chip + , + ); + const chip = screen.getByText('Custom Chip'); + expect(chip).toBeTruthy(); + }); + + it('renders as button by default', () => { + render(Button Chip); + const chip = screen.getByText('Button Chip'); + expect(chip.tagName).toBe('BUTTON'); + }); + + it('renders as custom element with asChild', () => { + render( + +
Div Chip
+
, + ); + const chip = screen.getByText('Div Chip'); + expect(chip.tagName).toBe('DIV'); + }); + + it('applies disabled class when disabled', () => { + render(Disabled Chip); + const chip = screen.getByText('Disabled Chip'); + expect(chip).toBeTruthy(); + }); + + it('handles selected prop correctly', () => { + render(Selected Chip); + const chip = screen.getByText('Selected Chip'); + expect(chip).toBeTruthy(); + }); + + it('handles selected prop as false by default', () => { + render(Default Chip); + const chip = screen.getByText('Default Chip'); + expect(chip).toBeTruthy(); + }); +}); diff --git a/packages/chip/src/Chip.tsx b/packages/chip/src/Chip.tsx new file mode 100644 index 00000000..4b2f63d1 --- /dev/null +++ b/packages/chip/src/Chip.tsx @@ -0,0 +1,41 @@ +import { type ComponentProps, type ForwardedRef, forwardRef } from 'react'; + +import { Slot } from '@radix-ui/react-slot'; + +import { clsx as cx } from 'clsx'; + +import type { ChipColor, ChipSize, ChipVariant } from './Chip.constants'; +import * as styles from './Chip.css'; + +export interface ChipProps extends ComponentProps<'button'> { + color?: ChipColor; + variant?: ChipVariant; + size?: ChipSize; + selected?: boolean; + asChild?: boolean; +} + +export const Chip = forwardRef(function Chip( + { + color = 'primary', + variant = 'filled', + size = 'medium', + selected = false, + asChild, + disabled, + className: _className, + children, + ...props + }: ChipProps, + ref: ForwardedRef, +) { + const Component = asChild ? Slot : 'button'; + const state = selected ? 'selected' : 'default'; + const className = cx(styles.chip({ color, variant, size, state }), { [styles.disabled]: disabled }, _className); + + return ( + + {children} + + ); +}); diff --git a/packages/chip/src/index.ts b/packages/chip/src/index.ts new file mode 100644 index 00000000..86f09db5 --- /dev/null +++ b/packages/chip/src/index.ts @@ -0,0 +1,2 @@ +export * from './Chip'; +export * from './Chip.constants'; diff --git a/packages/chip/tsconfig.json b/packages/chip/tsconfig.json new file mode 100644 index 00000000..94128fa4 --- /dev/null +++ b/packages/chip/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/chip/tsup.config.ts b/packages/chip/tsup.config.ts new file mode 100644 index 00000000..bed0ab27 --- /dev/null +++ b/packages/chip/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + clean: true, + external: ['react', '@radix-ui/react-slot', 'clsx'], +}); diff --git a/packages/chip/vitest.config.ts b/packages/chip/vitest.config.ts new file mode 100644 index 00000000..e663baf0 --- /dev/null +++ b/packages/chip/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineProject, mergeConfig } from 'vitest/config'; + +import defaultConfig from '../../vitest.config'; + +export default mergeConfig( + defaultConfig, + defineProject({ + test: { + setupFiles: './vitest.setup.ts', + }, + }), +); diff --git a/packages/chip/vitest.setup.ts b/packages/chip/vitest.setup.ts new file mode 100644 index 00000000..7b0828bf --- /dev/null +++ b/packages/chip/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/packages/divider/package.json b/packages/divider/package.json index 8329e78a..af278cdf 100644 --- a/packages/divider/package.json +++ b/packages/divider/package.json @@ -13,15 +13,16 @@ "scripts": { "build": "tsup", "build:storybook": "storybook build", + "clean": "rm -rf node_modules dist", "dev:storybook": "storybook dev -p 6006", - "lint:biome": "pnpm exec biome lint", - "lint:eslint": "pnpm exec eslint", + "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", "prepack": "pnpm run build" }, "dependencies": { - "clsx": "^2.1.1" + "clsx": "^2.1.1", + "@sipe-team/tokens": "workspace:*" }, "devDependencies": { "@sipe-team/typography": "workspace:*", @@ -35,6 +36,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@types/react": "catalog:react", + "@vanilla-extract/css": "catalog:", "happy-dom": "catalog:", "react": "catalog:react", "react-dom": "catalog:react", diff --git a/packages/divider/src/Divider.css.ts b/packages/divider/src/Divider.css.ts new file mode 100644 index 00000000..de228394 --- /dev/null +++ b/packages/divider/src/Divider.css.ts @@ -0,0 +1,30 @@ +import { color } from '@sipe-team/tokens'; +import { style, styleVariants } from '@vanilla-extract/css'; +import type { ColorType, OrientationType } from './constants'; + +export const base = style({ + border: 0, + margin: 0, + flexShrink: 0, +}); + +const orientationStyles: Record = { + horizontal: { + width: '100%', + height: '1px', + }, + vertical: { + width: '1px', + height: '100%', + }, +}; + +export const orientations = styleVariants(orientationStyles); + +const colorStyles: Record = { + default: { backgroundColor: color.gray300 }, + primary: { backgroundColor: color.cyan300 }, + dark: { backgroundColor: color.gray900 }, +}; + +export const colors = styleVariants(colorStyles); diff --git a/packages/divider/src/Divider.module.css b/packages/divider/src/Divider.module.css deleted file mode 100644 index 97610b6e..00000000 --- a/packages/divider/src/Divider.module.css +++ /dev/null @@ -1,16 +0,0 @@ -.divider { - border: 0; - margin: 0; - flex-shrink: 0; - background-color: black; -} - -.horizontal.divider { - width: 100%; - height: 1px; -} - -.vertical.divider { - width: 1px; - height: 100%; -} diff --git a/packages/divider/src/Divider.stories.tsx b/packages/divider/src/Divider.stories.tsx index 2bc3fc71..20500b6a 100644 --- a/packages/divider/src/Divider.stories.tsx +++ b/packages/divider/src/Divider.stories.tsx @@ -1,6 +1,7 @@ import { Typography } from '@sipe-team/typography'; import type { Meta, StoryObj } from '@storybook/react'; import { Divider } from './Divider'; +import { DIVIDER_COLORS, DIVIDER_ORIENTATIONS } from './constants'; const meta = { title: 'Components/Divider', @@ -8,6 +9,18 @@ const meta = { parameters: { layout: 'centered', }, + argTypes: { + orientation: { + description: '๊ตฌ๋ถ„์„ ์˜ ๋ฐฉํ–ฅ', + control: 'radio', + options: DIVIDER_ORIENTATIONS, + }, + color: { + description: '๊ตฌ๋ถ„์„ ์˜ ์ƒ‰์ƒ', + control: 'radio', + options: DIVIDER_COLORS, + }, + }, } satisfies Meta; export default meta; @@ -47,26 +60,50 @@ export const Basic: Story = {
- {/* ์Šคํƒ€์ผ ์„น์…˜ */} + {/* ์ƒ‰์ƒ ๋ณ€ํ˜• ์„น์…˜ */} +
+ +

์ƒ‰์ƒ ๋ณ€ํ˜•

+
+ +
+
+ ๊ธฐ๋ณธ ์ƒ‰์ƒ (default) + +
+ +
+ ๋ฉ”์ธ ์ƒ‰์ƒ (primary) + +
+ +
+ ์–ด๋‘์šด ์ƒ‰์ƒ (dark) + +
+
+
+ + {/* ๊ณ ๊ธ‰ ์Šคํƒ€์ผ๋ง ์„น์…˜ */}
-

์Šคํƒ€์ผ๋ง

+

๊ณ ๊ธ‰ ์Šคํƒ€์ผ๋ง

- default - + ์‚ฌ์šฉ์ž ์ •์˜ ์ƒ‰์ƒ +
- colored - + ๋‘๊ป๊ฒŒ (4px) +
- thick - + ์ ์„  +
diff --git a/packages/divider/src/Divider.test.tsx b/packages/divider/src/Divider.test.tsx index e2d7dcb6..694c88e1 100644 --- a/packages/divider/src/Divider.test.tsx +++ b/packages/divider/src/Divider.test.tsx @@ -1,9 +1,10 @@ +import { color } from '@sipe-team/tokens'; import { render, screen } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import { Divider } from './Divider'; describe('Divider', () => { - test('orientation ์†์„ฑ์ด ์—†์œผ๋ฉด ๊ฐ€๋กœ ๋ฐฉํ–ฅ์œผ๋กœ ๊ทธ๋ฆฐ๋‹ค.', () => { + test('orientation ์†์„ฑ์ด ์—†์œผ๋ฉด ๊ฐ€๋กœ ๋ฐฉํ–ฅ์œผ๋กœ ๊ทธ๋ฆฌ๊ณ  ๊ธฐ๋ณธ ์ƒ‰์ƒ์„ ๊ฐ€์ง„๋‹ค.', () => { render(); const divider = screen.getByRole('separator'); @@ -12,6 +13,7 @@ describe('Divider', () => { expect(divider).toHaveStyle({ width: '100%', height: '1px', + backgroundColor: color.gray300, }); }); @@ -42,4 +44,20 @@ describe('Divider', () => { margin: '8px', }); }); + + test('color ์†์„ฑ์— ๋”ฐ๋ผ ๋ฐฐ๊ฒฝ์ƒ‰์ด ๋ณ€๊ฒฝ๋œ๋‹ค.', () => { + render(); + + const divider = screen.getByRole('separator'); + expect(divider).toHaveStyle({ + backgroundColor: color.cyan300, + }); + }); + + test('className์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ „๋‹ฌ๋œ๋‹ค.', () => { + render(); + + const divider = screen.getByRole('separator'); + expect(divider).toHaveClass('custom-class'); + }); }); diff --git a/packages/divider/src/Divider.tsx b/packages/divider/src/Divider.tsx index 5b002cdf..1db39d65 100644 --- a/packages/divider/src/Divider.tsx +++ b/packages/divider/src/Divider.tsx @@ -1,20 +1,23 @@ import { clsx as cx } from 'clsx'; import { type ComponentProps, forwardRef } from 'react'; -import styles from './Divider.module.css'; +import * as styles from './Divider.css'; +import type { ColorType, OrientationType } from './constants'; -interface DividerProps extends ComponentProps<'hr'> { - orientation?: 'horizontal' | 'vertical'; +export interface DividerProps extends ComponentProps<'hr'> { + orientation?: OrientationType; + color?: ColorType; } -export const Divider = forwardRef(function Divider({ - orientation = 'horizontal', - ...props -}: DividerProps) { +export const Divider = forwardRef(function Divider( + { orientation = 'horizontal', color = 'default', className, ...props }: DividerProps, + ref, +) { return (
); }); diff --git a/packages/divider/src/constants/index.ts b/packages/divider/src/constants/index.ts new file mode 100644 index 00000000..5582a3dc --- /dev/null +++ b/packages/divider/src/constants/index.ts @@ -0,0 +1,5 @@ +export const DIVIDER_ORIENTATIONS = ['horizontal', 'vertical'] as const; +export type OrientationType = (typeof DIVIDER_ORIENTATIONS)[number]; + +export const DIVIDER_COLORS = ['default', 'primary', 'dark'] as const; +export type ColorType = (typeof DIVIDER_COLORS)[number]; diff --git a/packages/flex/package.json b/packages/flex/package.json index 7217a541..63d8bec1 100644 --- a/packages/flex/package.json +++ b/packages/flex/package.json @@ -13,9 +13,9 @@ "scripts": { "build": "tsup", "build:storybook": "storybook build", + "clean": "rm -rf node_modules dist", "dev:storybook": "storybook dev -p 6006", - "lint:biome": "pnpm exec biome lint", - "lint:eslint": "pnpm exec eslint", + "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", "prepack": "pnpm run build" @@ -36,6 +36,7 @@ "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@types/react": "catalog:react", + "@vanilla-extract/css": "catalog:", "happy-dom": "catalog:", "react": "catalog:react", "react-dom": "catalog:react", diff --git a/packages/flex/src/Flex.css.ts b/packages/flex/src/Flex.css.ts new file mode 100644 index 00000000..194d13db --- /dev/null +++ b/packages/flex/src/Flex.css.ts @@ -0,0 +1,47 @@ +import { style, styleVariants } from '@vanilla-extract/css'; +import type { FlexDirection, FlexAlign, FlexJustify, FlexWrap } from './constants'; + +export const base = style({ + display: 'flex', +}); + +const directionStyles: Record = { + row: { flexDirection: 'row' }, + column: { flexDirection: 'column' }, + 'row-reverse': { flexDirection: 'row-reverse' }, + 'column-reverse': { flexDirection: 'column-reverse' }, +}; +export const direction = styleVariants(directionStyles); + +const alignStyles: Record = { + 'flex-start': { alignItems: 'flex-start' }, + 'flex-end': { alignItems: 'flex-end' }, + center: { alignItems: 'center' }, + stretch: { alignItems: 'stretch' }, + baseline: { alignItems: 'baseline' }, + normal: { alignItems: 'normal' }, +}; +export const align = styleVariants(alignStyles); + +const justifyStyles: Record = { + 'flex-start': { justifyContent: 'flex-start' }, + 'flex-end': { justifyContent: 'flex-end' }, + center: { justifyContent: 'center' }, + 'space-between': { justifyContent: 'space-between' }, + 'space-around': { justifyContent: 'space-around' }, + 'space-evenly': { justifyContent: 'space-evenly' }, + normal: { justifyContent: 'normal' }, +}; +export const justify = styleVariants(justifyStyles); + +const wrapStyles: Record = { + nowrap: { flexWrap: 'nowrap' }, + wrap: { flexWrap: 'wrap' }, + 'wrap-reverse': { flexWrap: 'wrap-reverse' }, +}; +export const wrap = styleVariants(wrapStyles); + +export const display = styleVariants({ + flex: { display: 'flex' }, + 'inline-flex': { display: 'inline-flex' }, +}); diff --git a/packages/flex/src/Flex.module.css b/packages/flex/src/Flex.module.css deleted file mode 100644 index 22d5bede..00000000 --- a/packages/flex/src/Flex.module.css +++ /dev/null @@ -1,11 +0,0 @@ -.flex { - display: var(--flex-display); - flex-direction: var(--flex-direction); - align-items: var(--flex-align); - justify-content: var(--flex-justify); - flex-wrap: var(--flex-wrap); - gap: var(--flex-gap); - flex-basis: var(--flex-basis); - flex-grow: var(--flex-grow); - flex-shrink: var(--flex-shrink); -} diff --git a/packages/flex/src/Flex.stories.tsx b/packages/flex/src/Flex.stories.tsx index 82acd5fa..c8cc435f 100644 --- a/packages/flex/src/Flex.stories.tsx +++ b/packages/flex/src/Flex.stories.tsx @@ -13,12 +13,12 @@ const meta = { }, align: { control: 'select', - options: ['flex-start', 'flex-end', 'center', 'stretch', 'baseline'], + options: ['flex-start', 'flex-end', 'center', 'stretch', 'baseline', 'normal'], description: 'Align items', }, justify: { control: 'select', - options: ['flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'space-evenly'], + options: ['flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'space-evenly', 'normal'], description: 'Justify content', }, wrap: { @@ -34,6 +34,18 @@ const meta = { control: 'boolean', description: 'Display as inline-flex', }, + grow: { + control: { type: 'number', min: 0, max: 5, step: 1 }, + description: 'Flex grow factor', + }, + shrink: { + control: { type: 'number', min: 0, max: 5, step: 1 }, + description: 'Flex shrink factor', + }, + basis: { + control: 'text', + description: 'Flex basis', + }, }, } satisfies Meta; @@ -70,7 +82,7 @@ export const Basic: Story = { export const Direction: Story = { args: { - direction: 'column', + direction: 'row', gap: '1rem', style: { width: '100%' }, children: [, , ], @@ -139,3 +151,26 @@ export const Wrap: Story = { ], }, }; + +export const FlexGrowShrink: Story = { + render: () => ( + + Fixed width + Grow 1 + Grow 2 + + ), +}; + +export const InlineFlex: Story = { + render: () => ( +
+

Text before

+ + Item 1 + Item 2 + +

Text after

+
+ ), +}; diff --git a/packages/flex/src/Flex.tsx b/packages/flex/src/Flex.tsx index 93f91dd1..69b88b22 100644 --- a/packages/flex/src/Flex.tsx +++ b/packages/flex/src/Flex.tsx @@ -1,13 +1,17 @@ +import { type ComponentProps, type CSSProperties, type ForwardedRef, forwardRef } from 'react'; + import { Slot } from '@radix-ui/react-slot'; + import { clsx as cx } from 'clsx'; -import { type CSSProperties, type ComponentProps, type ForwardedRef, forwardRef } from 'react'; -import styles from './Flex.module.css'; + +import type { FlexAlign, FlexDirection, FlexJustify, FlexWrap } from './constants'; +import * as styles from './Flex.css'; export interface FlexProps extends ComponentProps<'div'> { - align?: CSSProperties['alignItems']; - justify?: CSSProperties['justifyContent']; - wrap?: CSSProperties['flexWrap']; - direction?: CSSProperties['flexDirection']; + direction?: FlexDirection; + align?: FlexAlign; + justify?: FlexJustify; + wrap?: FlexWrap; basis?: CSSProperties['flexBasis']; grow?: CSSProperties['flexGrow']; shrink?: CSSProperties['flexShrink']; @@ -18,14 +22,14 @@ export interface FlexProps extends ComponentProps<'div'> { export const Flex = forwardRef(function Flex( { - align, - justify, - wrap, - direction, + direction = 'row', + align = 'normal', + justify = 'normal', + wrap = 'nowrap', basis, grow, shrink, - inline, + inline = false, gap, className, style, @@ -33,25 +37,30 @@ export const Flex = forwardRef(function Flex( asChild, ...rest }: FlexProps, - ref: ForwardedRef, + ref: ForwardedRef, ) { const Component = asChild ? Slot : 'div'; - const flexStyle = { - '--flex-display': inline ? 'inline-flex' : 'flex', - '--flex-direction': direction ?? 'row', - '--flex-align': align ?? 'normal', - '--flex-justify': justify ?? 'normal', - '--flex-wrap': wrap ?? 'nowrap', - '--flex-gap': gap, - '--flex-basis': basis, - '--flex-grow': grow, - '--flex-shrink': shrink, + const classNames = cx( + styles.base, + styles.direction[direction], + styles.align[align], + styles.justify[justify], + styles.wrap[wrap], + inline ? styles.display['inline-flex'] : styles.display.flex, + className, + ); + + const inlineStyles = { + flexBasis: basis, + flexGrow: grow, + flexShrink: shrink, + gap, ...style, - } as React.CSSProperties; + }; return ( - + {children} ); diff --git a/packages/flex/src/constants/index.ts b/packages/flex/src/constants/index.ts new file mode 100644 index 00000000..05be4d0a --- /dev/null +++ b/packages/flex/src/constants/index.ts @@ -0,0 +1,19 @@ +export const FLEX_DIRECTIONS = ['row', 'column', 'row-reverse', 'column-reverse'] as const; +export type FlexDirection = (typeof FLEX_DIRECTIONS)[number]; + +export const FLEX_ALIGNS = ['flex-start', 'flex-end', 'center', 'stretch', 'baseline', 'normal'] as const; +export type FlexAlign = (typeof FLEX_ALIGNS)[number]; + +export const FLEX_JUSTIFY_CONTENTS = [ + 'flex-start', + 'flex-end', + 'center', + 'space-between', + 'space-around', + 'space-evenly', + 'normal', +] as const; +export type FlexJustify = (typeof FLEX_JUSTIFY_CONTENTS)[number]; + +export const FLEX_WRAPS = ['nowrap', 'wrap', 'wrap-reverse'] as const; +export type FlexWrap = (typeof FLEX_WRAPS)[number]; diff --git a/packages/grid/package.json b/packages/grid/package.json index 59e8c836..7ccd6dc5 100644 --- a/packages/grid/package.json +++ b/packages/grid/package.json @@ -13,9 +13,9 @@ "scripts": { "build": "tsup", "build:storybook": "storybook build", + "clean": "rm -rf node_modules dist", "dev:storybook": "storybook dev -p 6006", - "lint:biome": "pnpm exec biome lint", - "lint:eslint": "pnpm exec eslint", + "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", "prepack": "pnpm run build" @@ -36,6 +36,7 @@ "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@types/react": "catalog:react", + "@vanilla-extract/css": "catalog:", "happy-dom": "catalog:", "react": "catalog:react", "storybook": "catalog:", diff --git a/packages/grid/src/Grid.css.ts b/packages/grid/src/Grid.css.ts new file mode 100644 index 00000000..bc5361e9 --- /dev/null +++ b/packages/grid/src/Grid.css.ts @@ -0,0 +1,31 @@ +import { style } from '@vanilla-extract/css'; + +export const grid = style({ + display: 'var(--grid-display)', + gridTemplateColumns: 'var(--grid-template-columns)', + gridTemplateRows: 'var(--grid-template-rows)', + gridTemplateAreas: 'var(--grid-template-areas)', + gap: 'var(--grid-gap)', + gridAutoFlow: 'var(--grid-auto-flow)', + alignItems: 'var(--grid-align-items)', + justifyItems: 'var(--grid-justify-items)', + alignContent: 'var(--grid-align-content)', + justifyContent: 'var(--grid-justify-content)', +}); + +export const gridItem = style({ + alignSelf: 'var(--grid-align-self)', + justifySelf: 'var(--grid-justify-self)', +}); + +export const gridItemArea = style({ + gridArea: 'var(--grid-area)', +}); + +export const gridItemColumn = style({ + gridColumn: 'var(--grid-column)', +}); + +export const gridItemRow = style({ + gridRow: 'var(--grid-row)', +}); diff --git a/packages/grid/src/Grid.module.css b/packages/grid/src/Grid.module.css deleted file mode 100644 index 6a7c2d9b..00000000 --- a/packages/grid/src/Grid.module.css +++ /dev/null @@ -1,29 +0,0 @@ -.grid { - display: var(--grid-display); - grid-template-columns: var(--grid-template-columns); - grid-template-rows: var(--grid-template-rows); - grid-template-areas: var(--grid-template-areas); - gap: var(--grid-gap); - grid-auto-flow: var(--grid-auto-flow); - align-items: var(--grid-align-items); - justify-items: var(--grid-justify-items); - align-content: var(--grid-align-content); - justify-content: var(--grid-justify-content); -} - -.grid-item { - align-self: var(--grid-align-self); - justify-self: var(--grid-justify-self); -} - -.grid-item-area { - grid-area: var(--grid-area); -} - -.grid-item-column { - grid-column: var(--grid-column); -} - -.grid-item-row { - grid-row: var(--grid-row); -} diff --git a/packages/grid/src/Grid.stories.tsx b/packages/grid/src/Grid.stories.tsx index a45b18c2..0e54686e 100644 --- a/packages/grid/src/Grid.stories.tsx +++ b/packages/grid/src/Grid.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; + import * as Grid from './Grid'; const meta = { diff --git a/packages/grid/src/Grid.test.tsx b/packages/grid/src/Grid.test.tsx index 59d7506f..935d3261 100644 --- a/packages/grid/src/Grid.test.tsx +++ b/packages/grid/src/Grid.test.tsx @@ -1,7 +1,9 @@ +import { createElement } from 'react'; + import { faker } from '@faker-js/faker'; import { render, screen } from '@testing-library/react'; -import { createElement } from 'react'; import { describe, expect, it } from 'vitest'; + import * as Grid from './Grid'; describe('Grid', () => { diff --git a/packages/grid/src/Grid.tsx b/packages/grid/src/Grid.tsx index e3687963..d27611b6 100644 --- a/packages/grid/src/Grid.tsx +++ b/packages/grid/src/Grid.tsx @@ -1,7 +1,10 @@ +import { type ComponentProps, type CSSProperties, type ForwardedRef, forwardRef, useMemo } from 'react'; + import { Slot } from '@radix-ui/react-slot'; + import { clsx as cx } from 'clsx'; -import { type CSSProperties, type ComponentProps, type ForwardedRef, forwardRef, useMemo } from 'react'; -import styles from './Grid.module.css'; + +import * as styles from './Grid.css'; export interface GridProps extends ComponentProps<'div'> { templateColumns?: CSSProperties['gridTemplateColumns']; @@ -32,7 +35,7 @@ export const Grid = forwardRef(function Grid( asChild, ...props }: GridProps, - ref: ForwardedRef, + ref: ForwardedRef, ) { const Component = asChild ? Slot : 'div'; @@ -92,7 +95,7 @@ export const GridItem = forwardRef(function GridItem( children, ...props }: GridItemProps, - ref: ForwardedRef, + ref: ForwardedRef, ) { const Component = asChild ? Slot : 'div'; @@ -111,11 +114,11 @@ export const GridItem = forwardRef(function GridItem( }, [row, rowSpan, rowStart, rowEnd]); const gridItemClasses = cx( - styles['grid-item'], + styles.gridItem, { - [styles['grid-item-column']]: getGridColumn, - [styles['grid-item-row']]: getGridRow, - [styles['grid-item-area']]: area, + [styles.gridItemColumn]: getGridColumn, + [styles.gridItemRow]: getGridRow, + [styles.gridItemArea]: area, }, className, ); diff --git a/packages/grid/src/index.ts b/packages/grid/src/index.ts index c3794cb0..5a1f79b1 100644 --- a/packages/grid/src/index.ts +++ b/packages/grid/src/index.ts @@ -1,2 +1,2 @@ -export { Grid, GridItem, Root, Item } from './Grid'; -export type { GridProps, GridItemProps } from './Grid'; +export type { GridItemProps, GridProps } from './Grid'; +export { Grid, GridItem, Item, Root } from './Grid'; diff --git a/packages/grid/vitest.config.ts b/packages/grid/vitest.config.ts index a9178275..e663baf0 100644 --- a/packages/grid/vitest.config.ts +++ b/packages/grid/vitest.config.ts @@ -1,4 +1,5 @@ import { defineProject, mergeConfig } from 'vitest/config'; + import defaultConfig from '../../vitest.config'; export default mergeConfig( diff --git a/packages/icon/package.json b/packages/icon/package.json index a46f9a44..6214d761 100644 --- a/packages/icon/package.json +++ b/packages/icon/package.json @@ -13,9 +13,9 @@ "scripts": { "build": "tsup", "build:storybook": "storybook build", + "clean": "rm -rf node_modules dist", "dev:storybook": "storybook dev -p 6006", - "lint:biome": "pnpm exec biome lint", - "lint:eslint": "pnpm exec eslint", + "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", "prepack": "pnpm run build", diff --git a/packages/icon/src/components/AccordionArrowIcon.tsx b/packages/icon/src/components/AccordionArrowIcon.tsx index 9fc23675..fd36920c 100644 --- a/packages/icon/src/components/AccordionArrowIcon.tsx +++ b/packages/icon/src/components/AccordionArrowIcon.tsx @@ -1,10 +1,16 @@ import * as React from 'react'; + import type { IconProps } from '../types'; -export const AccordionArrowIcon = React.forwardRef( - ({ color, size = 24, ...props }, ref) => { - return ; - } -); +export const AccordionArrowIcon = React.forwardRef(({ color, size = 24, ...props }, ref) => { + return ( + + + + ); +}); AccordionArrowIcon.displayName = 'AccordionArrowIcon'; diff --git a/packages/input/package.json b/packages/input/package.json index a28b43a8..aa814153 100644 --- a/packages/input/package.json +++ b/packages/input/package.json @@ -13,18 +13,21 @@ "scripts": { "build": "tsup", "build:storybook": "storybook build", + "clean": "rm -rf node_modules dist", "dev:storybook": "storybook dev -p 6006", - "lint:biome": "pnpm exec biome lint", - "lint:eslint": "pnpm exec eslint", + "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", "prepack": "pnpm run build" }, "dependencies": { "@radix-ui/react-slot": "^1.1.0", - "classnames": "^2.5.1" + "@sipe-team/tokens": "workspace:*", + "@vanilla-extract/recipes": "^0.5.5", + "clsx": "^2.1.1" }, "devDependencies": { + "@vanilla-extract/css": "catalog:", "@storybook/addon-essentials": "catalog:", "@storybook/addon-interactions": "catalog:", "@storybook/addon-links": "catalog:", diff --git a/packages/input/src/Input.css.ts b/packages/input/src/Input.css.ts new file mode 100644 index 00000000..9ab4300a --- /dev/null +++ b/packages/input/src/Input.css.ts @@ -0,0 +1,140 @@ +import { color } from '@sipe-team/tokens'; + +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +import type { InputFontSize, InputFontWeight } from './Input'; + +// TODO ThemeProvider ์ ์šฉ +export const colors = { + inputRing: color.gray800, + defaultInputOutline: color.gray400, + disabledBackground: color.gray300, +} as const; + +export const spacing = { + defaultInputPadding: '8px', + defaultBorderRadius: '8px', + defaultActionSize: '24px', +} as const; + +export const weight = { + regular: 400, + medium: 500, + semiBold: 600, + bold: 700, +} as const; + +export const defaultFontSize: InputFontSize = 16; +export const defaultFontWeight: InputFontWeight = 'regular'; + +export const inputWrapper = recipe({ + base: { + display: 'flex', + flex: 1, + alignItems: 'center', + fontStyle: 'normal', + textAlign: 'start', + padding: spacing.defaultInputPadding, + borderRadius: spacing.defaultBorderRadius, + outline: `1px solid ${colors.defaultInputOutline}`, + + '@supports': { + 'selector(:has(*))': { + selectors: { + '&:where(:has(input:focus))': { + outline: `2px solid ${colors.defaultInputOutline}`, + outlineOffset: '-1px', + }, + '&:where(:has(input:disabled))': { + backgroundColor: colors.disabledBackground, + }, + }, + }, + 'not selector(:has(*))': { + selectors: { + '&:where(:focus-within)': { + outline: `2px solid ${colors.defaultInputOutline}`, + outlineOffset: '-1px', + }, + }, + }, + }, + }, + variants: { + fontSize: { + 12: { fontSize: '12px' }, + 14: { fontSize: '14px' }, + 16: { fontSize: '16px' }, + 18: { fontSize: '18px' }, + 20: { fontSize: '20px' }, + 24: { fontSize: '24px' }, + 28: { fontSize: '28px' }, + 32: { fontSize: '32px' }, + 36: { fontSize: '36px' }, + 48: { fontSize: '48px' }, + }, + fontWeight: { + regular: { fontWeight: weight.regular }, + medium: { fontWeight: weight.medium }, + semiBold: { fontWeight: weight.semiBold }, + bold: { fontWeight: weight.bold }, + }, + }, + defaultVariants: { + fontSize: defaultFontSize, + fontWeight: defaultFontWeight, + }, +}); + +export const inputElement = style({ + width: '100%', + display: 'flex', + alignItems: 'center', + textAlign: 'inherit', + outline: '1px solid transparent', + border: 'none', + fontSize: 'inherit', + fontWeight: 'inherit', + + selectors: { + '&::-webkit-search-cancel-button': { + appearance: 'none', + }, + }, + + '@supports': { + 'selector(:has(*))': { + selectors: { + '&:where(:autofill, [data-com-onepassword-filled])': { + backgroundClip: 'text', + WebkitTextFillColor: color.gray900, + }, + '&:where(:disabled)': { + backgroundColor: 'transparent', + }, + }, + }, + }, +}); + +export const inputAction = style({ + all: 'unset', + width: spacing.defaultActionSize, + height: spacing.defaultActionSize, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + borderRadius: spacing.defaultBorderRadius, + + '@supports': { + 'selector(:has(*))': { + selectors: { + '&:focus': { + outline: `2px solid ${colors.defaultInputOutline}`, + outlineOffset: '3px', + }, + }, + }, + }, +}); diff --git a/packages/input/src/Input.module.css b/packages/input/src/Input.module.css deleted file mode 100644 index 794b14d9..00000000 --- a/packages/input/src/Input.module.css +++ /dev/null @@ -1,78 +0,0 @@ -.input-wrapper { - display: flex; - flex: 1; - align-items: center; - font-style: normal; - text-align: start; - - padding: var(--input-padding); - border-radius: var(--input-border-radius); - outline: 1px solid var(--input-ring-color); - - @supports selector(:has(*)) { - &:where(:has(.input:focus)) { - outline: 2px solid var(--input-ring-color); - outline-offset: -1px; - } - - &:where(:has(.input:disabled)) { - background-color: var(--input-disabled-color); - } - } - @supports not selector(:has(*)) { - &:where(:focus-within) { - outline: 2px solid var(--input-ring-color); - outline-offset: -1px; - } - } -} - -.input { - width: 100%; - display: flex; - align-items: center; - text-align: inherit; - - outline: 1px solid transparent; - border: none; - - font-size: var(--font-size); - font-weight: var(--font-weight); - - /* ๊ธฐ๋ณธ ์ทจ์†Œ ๋ฒ„ํŠผ ์ œ๊ฑฐ */ - &::-webkit-search-cancel-button { - appearance: none; - } - - @supports selector(:has(*)) { - &:where(:autofill, [data-com-onepassword-filled]) { - background-clip: text; - -webkit-text-fill-color: var(--gray-12); - } - } - - @supports selector(:has(*)) { - &:where(:disabled) { - background-color: transparent; - } - } -} - -.input-action { - all: unset; - - width: var(--action-size); - height: var(--action-size); - - display: flex; - justify-content: center; - align-items: center; - border-radius: var(--input-border-radius); - - @supports selector(:has(*)) { - &:focus { - outline: 2px solid var(--input-ring-color); - outline-offset: 3px; - } - } -} diff --git a/packages/input/src/Input.test.tsx b/packages/input/src/Input.test.tsx index 434d284d..3ed17606 100644 --- a/packages/input/src/Input.test.tsx +++ b/packages/input/src/Input.test.tsx @@ -1,11 +1,13 @@ import '@testing-library/jest-dom'; + +import { createRef } from 'react'; + import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createRef } from 'react'; import { describe, expect, test } from 'vitest'; + import { Action, Input } from './Input'; -import { colors } from './constants/colors'; -import { Weight, defaultFontSize, defaultFontWeight } from './constants/typhography'; +import { defaultFontSize, defaultFontWeight, weight } from './Input.css'; describe('Input ์ปดํฌ๋„ŒํŠธ', () => { describe('๋ Œ๋”๋ง', () => { @@ -25,12 +27,10 @@ describe('Input ์ปดํฌ๋„ŒํŠธ', () => { expect(ref.current).toBeInstanceOf(HTMLInputElement); }); - test(`disabled์ผ ๋•Œ ๋ฐฐ๊ฒฝ์ƒ‰์ด ์ •์˜๋œ ๋น„ํ™œ์„ฑํ™” ์ƒ‰์ƒ(${colors.disabledBackground})์œผ๋กœ ์„ค์ •๋œ๋‹ค`, () => { - const disableColor = colors.disabledBackground; - const { container } = render(); - const element = container.firstChild as HTMLElement; - const styles = getComputedStyle(element); - expect(styles.getPropertyValue('--input-disabled-color')).toBe(disableColor); + test('disabled ์ƒํƒœ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์„ค์ •๋œ๋‹ค', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toBeDisabled(); }); test('classNames๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ ์šฉ๋œ๋‹ค', () => { @@ -46,9 +46,10 @@ describe('Input ์ปดํฌ๋„ŒํŠธ', () => { test(`fontWeight๊ฐ€ ๋ฏธ์ง€์ •์‹œ ${defaultFontWeight}, fontSize๊ฐ€ ๋ฏธ์ง€์ •์‹œ ${defaultFontSize}px์ด ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์ ์šฉ๋œ๋‹ค`, () => { const { container } = render(); const element = container.firstChild as HTMLElement; - const styles = getComputedStyle(element); - expect(styles.getPropertyValue('--font-size')).toBe(`${defaultFontSize}px`); - expect(styles.getPropertyValue('--font-weight')).toBe(`${Weight[defaultFontWeight]}`); + const computedStyle = getComputedStyle(element); + + expect(computedStyle.fontSize).toBe(`${defaultFontSize}px`); + expect(computedStyle.fontWeight).toBe(`${weight[defaultFontWeight]}`); }); test('๋ณ€๊ฒฝ ํฐํŠธ ์‚ฌ์ด์ฆˆ, ํฐํŠธ ์›จ์ดํŠธ ์ ์šฉ๋œ๋‹ค.', () => { @@ -56,9 +57,10 @@ describe('Input ์ปดํฌ๋„ŒํŠธ', () => { const fontWeight = 'semiBold'; const { container } = render(); const element = container.firstChild as HTMLElement; - const styles = getComputedStyle(element); - expect(styles.getPropertyValue('--font-size')).toBe(`${fontSize}px`); - expect(styles.getPropertyValue('--font-weight')).toBe(`${Weight[fontWeight]}`); + const computedStyle = getComputedStyle(element); + + expect(computedStyle.fontSize).toBe(`${fontSize}px`); + expect(computedStyle.fontWeight).toBe(`${weight[fontWeight]}`); }); }); diff --git a/packages/input/src/Input.tsx b/packages/input/src/Input.tsx index c0593c0c..198b6709 100644 --- a/packages/input/src/Input.tsx +++ b/packages/input/src/Input.tsx @@ -1,19 +1,20 @@ -import classNames from 'classnames'; -import { type CSSProperties, type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react'; +import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react'; import { Slot } from '@radix-ui/react-slot'; -import styles from './Input.module.css'; -import { colors } from './constants/colors'; -import { spacing } from './constants/spacing'; -import { type FontSize, type FontWeight, Weight, defaultFontSize, defaultFontWeight } from './constants/typhography'; +import cx from 'clsx'; +import { defaultFontSize, defaultFontWeight, inputAction, inputElement, inputWrapper, type weight } from './Input.css'; + +export type InputFontSize = 12 | 14 | 16 | 18 | 20 | 24 | 28 | 32 | 36 | 48; +export type InputFontWeight = keyof typeof weight; type AllowedInputTypes = 'email' | 'password' | 'search' | 'tel' | 'text' | 'url'; type InputFieldElement = ElementRef<'input'>; + interface InputProps extends Omit, 'type'> { type?: AllowedInputTypes; - fontSize?: FontSize; - fontWeight?: FontWeight; + fontSize?: InputFontSize; + fontWeight?: InputFontWeight; } const Input = forwardRef((props, forwardedRef) => { @@ -28,22 +29,8 @@ const Input = forwardRef((props, forwardedRef) => } = props; return ( -
- +
+ {children}
); @@ -63,13 +50,12 @@ interface InputFieldActionProps extends Omit, } const InputFieldAction = forwardRef((props, forwardedRef) => { - const { className, asChild, type = 'button', ...slotProps } = props; + const { className, asChild, type, ...slotProps } = props; const Comp = asChild ? Slot : 'button'; - return ; + return ; }); InputFieldAction.displayName = 'Input.Action'; export { InputFieldAction as Action, Input }; - export type { InputProps, InputFieldActionProps as SlotProps }; diff --git a/packages/input/src/constants/colors.ts b/packages/input/src/constants/colors.ts deleted file mode 100644 index 315b4b00..00000000 --- a/packages/input/src/constants/colors.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type ColorKeys = keyof typeof colors; -export const colors = { - inputRing: '#2B2B2B', - defaultInputOutline: '#adadad', - disabledBackground: '#BBBBBB', -} as const; diff --git a/packages/input/src/constants/spacing.ts b/packages/input/src/constants/spacing.ts deleted file mode 100644 index c52097c7..00000000 --- a/packages/input/src/constants/spacing.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type SpacingKeys = keyof typeof spacing; -export const spacing = { - defaultInputPadding: '8px', - defaultBorderRadius: '8px', - defaultActionSize: '24px', -} as const; diff --git a/packages/input/src/constants/typhography.ts b/packages/input/src/constants/typhography.ts deleted file mode 100644 index 8dbab86b..00000000 --- a/packages/input/src/constants/typhography.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type FontSize = 12 | 14 | 16 | 18 | 20 | 24 | 28 | 32 | 36 | 48; -export type FontWeight = keyof typeof Weight; - -export const defaultFontSize: FontSize = 16; -export const defaultFontWeight: FontWeight = 'regular'; - -export const Weight = { - regular: 400, - medium: 500, - semiBold: 600, - bold: 700, -} as const; diff --git a/packages/input/tsconfig.json b/packages/input/tsconfig.json index 4f0f1797..4082f16a 100644 --- a/packages/input/tsconfig.json +++ b/packages/input/tsconfig.json @@ -1,4 +1,3 @@ { - "extends": "../../tsconfig.json", - "exclude": ["dist"] + "extends": "../../tsconfig.json" } diff --git a/packages/plugin-figma-codegen/package.json b/packages/plugin-figma-codegen/package.json index 48c2a252..75c3c389 100644 --- a/packages/plugin-figma-codegen/package.json +++ b/packages/plugin-figma-codegen/package.json @@ -12,6 +12,7 @@ "files": ["dist", "manifest.json"], "scripts": { "build": "tsup", + "clean": "rm -rf node_modules dist", "dev": "pnpm build --watch", "lint": "biome lint .", "typecheck": "tsc", diff --git a/packages/radio/package.json b/packages/radio/package.json index 470d001d..0c722d94 100644 --- a/packages/radio/package.json +++ b/packages/radio/package.json @@ -13,13 +13,19 @@ "scripts": { "build": "tsup", "build:storybook": "storybook build", + "clean": "rm -rf node_modules dist", "dev:storybook": "storybook dev -p 6006", - "lint:biome": "pnpm exec biome lint", - "lint:eslint": "pnpm exec eslint", + "lint": "pnpm exec biome lint", "test": "vitest", "typecheck": "tsc", "prepack": "pnpm run build" }, + "dependencies": { + "@sipe-team/tokens": "workspace:*", + "@sipe-team/typography": "workspace:*", + "@vanilla-extract/recipes": "^0.5.5", + "clsx": "^2.1.1" + }, "devDependencies": { "@storybook/addon-essentials": "catalog:", "@storybook/addon-interactions": "catalog:", @@ -31,8 +37,10 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", "@types/react": "catalog:react", "@types/react-dom": "catalog:react", + "@vanilla-extract/css": "catalog:", "happy-dom": "catalog:", "react": "catalog:react", "react-dom": "catalog:react", diff --git a/packages/radio/src/Radio.css.ts b/packages/radio/src/Radio.css.ts new file mode 100644 index 00000000..be5551f5 --- /dev/null +++ b/packages/radio/src/Radio.css.ts @@ -0,0 +1,240 @@ +import { style, styleVariants } from '@vanilla-extract/css'; +import { type RecipeVariants, recipe } from '@vanilla-extract/recipes'; +import { COLORS } from './constants/colors'; +import { RadioSize } from './constants/sizes'; +import { RADIO_SIZES } from './constants/sizes'; + +export const radioGroup = style({ + display: 'flex', + flexDirection: 'column', + border: 'none', + padding: 0, + margin: 0, +}); + +export const radioGroupLegend = style({ + padding: 0, + margin: 0, + marginBottom: '8px', + fontSize: '16px', + fontWeight: 600, + color: COLORS.text, +}); + +const radioContainerSizeStyles: Record = { + [RadioSize.small]: { + padding: RADIO_SIZES[RadioSize.small].containerPadding, + gap: RADIO_SIZES[RadioSize.small].containerGap, + }, + [RadioSize.medium]: { + padding: RADIO_SIZES[RadioSize.medium].containerPadding, + gap: RADIO_SIZES[RadioSize.medium].containerGap, + }, + [RadioSize.large]: { + padding: RADIO_SIZES[RadioSize.large].containerPadding, + gap: RADIO_SIZES[RadioSize.large].containerGap, + }, +}; + +export const radioContainer = recipe({ + base: { + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + transition: 'all 0.15s ease-in-out', + + ':hover': { + opacity: 0.8, + }, + }, + variants: { + size: styleVariants(radioContainerSizeStyles, (sizeStyle) => sizeStyle), + disabled: { + true: { + cursor: 'not-allowed', + opacity: 0.6, + + ':hover': { + opacity: 0.6, + }, + }, + }, + }, + defaultVariants: { + size: RadioSize.medium, + disabled: false, + }, +}); + +const radioInputSizeStyles: Record< + RadioSize, + { width: string; height: string; '::after': { width: string; height: string } } +> = { + [RadioSize.small]: { + width: RADIO_SIZES[RadioSize.small].inputSize, + height: RADIO_SIZES[RadioSize.small].inputSize, + '::after': { + width: '4px', + height: '4px', + }, + }, + [RadioSize.medium]: { + width: RADIO_SIZES[RadioSize.medium].inputSize, + height: RADIO_SIZES[RadioSize.medium].inputSize, + '::after': { + width: '6px', + height: '6px', + }, + }, + [RadioSize.large]: { + width: RADIO_SIZES[RadioSize.large].inputSize, + height: RADIO_SIZES[RadioSize.large].inputSize, + '::after': { + width: '8px', + height: '8px', + }, + }, +}; + +export const radioInput = recipe({ + base: { + appearance: 'none', + borderRadius: '50%', + border: `2px solid ${COLORS.border}`, + backgroundColor: COLORS.background, + margin: 0, + cursor: 'pointer', + transition: 'all 0.15s ease-in-out', + position: 'relative', + flexShrink: 0, + + '::after': { + content: '""', + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%) scale(0)', + borderRadius: '50%', + backgroundColor: COLORS.background, + transition: 'transform 0.15s ease-in-out', + }, + + ':focus': { + outline: `2px solid ${COLORS.checked}`, + outlineOffset: '2px', + }, + + selectors: { + '&:hover:not(:disabled)': { + borderColor: COLORS.checked, + backgroundColor: COLORS.hover, + }, + '&:checked::after': { + transform: 'translate(-50%, -50%) scale(1)', + }, + }, + + ':checked': { + borderColor: COLORS.checked, + backgroundColor: COLORS.checked, + }, + }, + variants: { + size: styleVariants(radioInputSizeStyles, (sizeStyle) => sizeStyle), + checked: { + true: { + borderColor: COLORS.checked, + backgroundColor: COLORS.checked, + + '::after': { + transform: 'translate(-50%, -50%) scale(1)', + }, + }, + false: {}, + }, + disabled: { + true: { + borderColor: COLORS.disabled, + backgroundColor: COLORS.disabled, + cursor: 'not-allowed', + + ':hover': { + borderColor: COLORS.disabled, + backgroundColor: COLORS.disabled, + }, + + ':checked': { + borderColor: COLORS.disabled, + backgroundColor: COLORS.disabled, + + '::after': { + backgroundColor: COLORS.disabled, + }, + }, + }, + false: {}, + }, + }, + compoundVariants: [ + { + variants: { + checked: true, + disabled: true, + }, + style: { + borderColor: COLORS.disabled, + backgroundColor: COLORS.disabled, + + '::after': { + backgroundColor: COLORS.disabled, + transform: 'translate(-50%, -50%) scale(1)', + }, + }, + }, + ], + defaultVariants: { + size: RadioSize.medium, + checked: false, + disabled: false, + }, +}); + +const radioLabelSizeStyles: Record = { + [RadioSize.small]: { + fontSize: RADIO_SIZES[RadioSize.small].fontSize, + }, + [RadioSize.medium]: { + fontSize: RADIO_SIZES[RadioSize.medium].fontSize, + }, + [RadioSize.large]: { + fontSize: RADIO_SIZES[RadioSize.large].fontSize, + }, +}; + +export const radioLabel = recipe({ + base: { + cursor: 'pointer', + color: COLORS.text, + lineHeight: 1.5, + userSelect: 'none', + marginLeft: '8px', + }, + variants: { + size: styleVariants(radioLabelSizeStyles, (sizeStyle) => sizeStyle), + disabled: { + true: { + color: COLORS.textDisabled, + cursor: 'not-allowed', + }, + false: {}, + }, + }, + defaultVariants: { + size: RadioSize.medium, + disabled: false, + }, +}); + +export type RadioContainerVariants = RecipeVariants; +export type RadioInputVariants = RecipeVariants; +export type RadioLabelVariants = RecipeVariants; diff --git a/packages/radio/src/Radio.tsx b/packages/radio/src/Radio.tsx index 7640ce36..b9b0ba30 100644 --- a/packages/radio/src/Radio.tsx +++ b/packages/radio/src/Radio.tsx @@ -1,41 +1,80 @@ -import { type CSSProperties, type ComponentProps, type PropsWithChildren, useContext } from 'react'; +import clsx from 'clsx'; +import { type ComponentProps, type PropsWithChildren, useContext, useId } from 'react'; +import * as styles from './Radio.css'; import { RadioGroupContext } from './RadioGroup'; -import styles from './RadioGroup.module.css'; +import type { RadioSize } from './constants/sizes'; type RadioProps = PropsWithChildren< - ComponentProps<'input'> & { - size?: 'small' | 'medium' | 'large'; + Omit, 'size'> & { + size?: RadioSize; + className?: string; } >; -export function Radio({ value, defaultChecked, disabled = false, children }: RadioProps) { +export function Radio({ + value, + defaultChecked, + disabled = false, + children, + className, + size: propSize, + ...inputProps +}: RadioProps) { const groupContext = useContext(RadioGroupContext); + const radioId = useId(); - const sizeMap = { - small: '12px', - medium: '16px', - large: '20px', - }; + const isDisabled = groupContext.disabled || disabled; + const radioSize = propSize || groupContext.size || 'medium'; + + const isControlled = groupContext.value !== undefined; + const isChecked = isControlled ? groupContext.value === value : undefined; - const style = { - width: sizeMap[groupContext.size ?? 'medium'], - height: sizeMap[groupContext.size ?? 'medium'], - } as CSSProperties; + const handleChange = (e: React.ChangeEvent) => { + if (!isDisabled) { + groupContext.onChangeValue?.(e.target.value); + } + inputProps.onChange?.(e); + }; return ( -