diff --git a/packages/badge/package.json b/packages/badge/package.json index 2592696c..d78c89dc 100644 --- a/packages/badge/package.json +++ b/packages/badge/package.json @@ -22,10 +22,10 @@ }, "dependencies": { "@sipe-team/tokens": "workspace:*", - "@sipe-team/typography": "workspace:*", "clsx": "^2.1.1" }, "devDependencies": { + "@radix-ui/react-slot": "^1.1.0", "@storybook/addon-essentials": "catalog:", "@storybook/addon-interactions": "catalog:", "@storybook/addon-links": "catalog:", @@ -37,6 +37,7 @@ "@testing-library/react": "^16.0.1", "@types/react": "^18.3.12", "@vanilla-extract/css": "catalog:", + "@vanilla-extract/recipes": "^0.5.5", "happy-dom": "catalog:", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/badge/src/Badge.constants.ts b/packages/badge/src/Badge.constants.ts new file mode 100644 index 00000000..fa1fafcc --- /dev/null +++ b/packages/badge/src/Badge.constants.ts @@ -0,0 +1,44 @@ +export const BadgeSize = { + small: 'small', + large: 'large', +} as const; + +export const BadgeVariant = { + solid: 'solid', + default: 'default', +} as const; + +export const BadgeColor = { + white: 'white', + gray: 'gray', + danger: 'danger', + general: 'general', + '1st': '1st', + '2nd': '2nd', + '3rd': '3rd', + '4th': '4th', +} as const; + +export const BadgeIconPosition = { + none: 'none', + left: 'left', + right: 'right', + both: 'both', +} as const; + +export type BadgeSize = (typeof BadgeSize)[keyof typeof BadgeSize]; +export type BadgeVariant = (typeof BadgeVariant)[keyof typeof BadgeVariant]; +export type BadgeColor = (typeof BadgeColor)[keyof typeof BadgeColor]; +export type BadgeIconPosition = (typeof BadgeIconPosition)[keyof typeof BadgeIconPosition]; + +import type React from 'react'; + +export interface BadgeProps extends React.ComponentPropsWithoutRef<'div'> { + size?: BadgeSize; + variant?: BadgeVariant; + color?: BadgeColor; + asChild?: boolean; + icon?: BadgeIconPosition; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; +} diff --git a/packages/badge/src/Badge.css.ts b/packages/badge/src/Badge.css.ts index 080da695..8209e60e 100644 --- a/packages/badge/src/Badge.css.ts +++ b/packages/badge/src/Badge.css.ts @@ -1,71 +1,177 @@ -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; +import { recipe } from '@vanilla-extract/recipes'; -export const BadgeVariant = { - filled: 'filled', - outline: 'outline', - weak: 'weak', -} as const; +import { BadgeColor, BadgeSize, BadgeVariant } from './Badge.constants'; -// 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], +export const badge = recipe({ + base: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 6, + fontWeight: 600, + whiteSpace: 'nowrap', + transition: 'all 0.2s ease-in-out', + userSelect: 'none', + gap: '4px', }, - [BadgeSize.medium]: { - fontSize: fontSizeToken[14], + variants: { + size: { + [BadgeSize.small]: { + padding: '2px 8px', + fontSize: fontSizeToken[12], + lineHeight: '16px', + height: '20px', + }, + [BadgeSize.large]: { + padding: '4px 12px', + fontSize: fontSizeToken[14], + lineHeight: '20px', + height: '24px', + }, + }, + color: { + [BadgeColor.white]: {}, + [BadgeColor.gray]: {}, + [BadgeColor.danger]: {}, + [BadgeColor.general]: {}, + [BadgeColor['1st']]: {}, + [BadgeColor['2nd']]: {}, + [BadgeColor['3rd']]: {}, + [BadgeColor['4th']]: {}, + }, + variant: { + [BadgeVariant.solid]: {}, + [BadgeVariant.default]: {}, + }, }, - [BadgeSize.large]: { - fontSize: fontSizeToken[18], + compoundVariants: [ + // White variants + { + variants: { color: BadgeColor.white, variant: BadgeVariant.solid }, + style: { + backgroundColor: colorToken.white, + color: colorToken.gray900, + }, + }, + { + variants: { color: BadgeColor.white, variant: BadgeVariant.default }, + style: { + backgroundColor: 'transparent', + color: colorToken.white, + border: `1px solid ${colorToken.white}`, + }, + }, + // Gray variants + { + variants: { color: BadgeColor.gray, variant: BadgeVariant.solid }, + style: { + backgroundColor: colorToken.gray700, + color: colorToken.white, + }, + }, + { + variants: { color: BadgeColor.gray, variant: BadgeVariant.default }, + style: { + backgroundColor: colorToken.gray100, + color: colorToken.gray700, + }, + }, + // Danger variants + { + variants: { color: BadgeColor.danger, variant: BadgeVariant.solid }, + style: { + backgroundColor: colorToken.red500, + color: colorToken.white, + }, + }, + { + variants: { color: BadgeColor.danger, variant: BadgeVariant.default }, + style: { + backgroundColor: colorToken.red100, + color: colorToken.red700, + }, + }, + // General variants + { + variants: { color: BadgeColor.general, variant: BadgeVariant.solid }, + style: { + backgroundColor: colorToken.blue500, + color: colorToken.white, + }, + }, + { + variants: { color: BadgeColor.general, variant: BadgeVariant.default }, + style: { + backgroundColor: colorToken.blue100, + color: colorToken.blue700, + }, + }, + // 1st variants + { + variants: { color: BadgeColor['1st'], variant: BadgeVariant.solid }, + style: { + backgroundColor: colorToken.green500, + color: colorToken.white, + }, + }, + { + variants: { color: BadgeColor['1st'], variant: BadgeVariant.default }, + style: { + backgroundColor: colorToken.green100, + color: colorToken.green700, + }, + }, + // 2nd variants + { + variants: { color: BadgeColor['2nd'], variant: BadgeVariant.solid }, + style: { + backgroundColor: colorToken.purple500, + color: colorToken.white, + }, + }, + { + variants: { color: BadgeColor['2nd'], variant: BadgeVariant.default }, + style: { + backgroundColor: colorToken.purple100, + color: colorToken.purple700, + }, + }, + // 3rd variants + { + variants: { color: BadgeColor['3rd'], variant: BadgeVariant.solid }, + style: { + backgroundColor: colorToken.orange500, + color: colorToken.white, + }, + }, + { + variants: { color: BadgeColor['3rd'], variant: BadgeVariant.default }, + style: { + backgroundColor: colorToken.orange100, + color: colorToken.orange700, + }, + }, + // 4th variants + { + variants: { color: BadgeColor['4th'], variant: BadgeVariant.solid }, + style: { + backgroundColor: colorToken.cyan500, + color: colorToken.white, + }, + }, + { + variants: { color: BadgeColor['4th'], variant: BadgeVariant.default }, + style: { + backgroundColor: colorToken.cyan100, + color: colorToken.cyan700, + }, + }, + ], + defaultVariants: { + size: BadgeSize.small, + color: BadgeColor.gray, + variant: BadgeVariant.default, }, }); - -// 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.stories.tsx b/packages/badge/src/Badge.stories.tsx index 892569fa..cb674ea6 100644 --- a/packages/badge/src/Badge.stories.tsx +++ b/packages/badge/src/Badge.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { Badge, type BadgeSize, type BadgeVariant } from './Badge'; -import { BadgeSize as BadgeSizeEnum, BadgeVariant as BadgeVariantEnum } from './Badge.css'; + +import { Badge } from './Badge'; +import { BadgeColor, BadgeIconPosition, BadgeSize, BadgeVariant } from './Badge.constants'; const meta = { title: 'Components/Badge', @@ -8,53 +9,107 @@ const meta = { parameters: { layout: 'centered', }, + tags: ['autodocs'], argTypes: { size: { control: 'select', - options: Object.keys(BadgeSizeEnum), - description: 'Size of the badge', - defaultValue: 'medium', + options: Object.values(BadgeSize), }, variant: { control: 'select', - options: Object.keys(BadgeVariantEnum), - description: 'Visual style of the badge', - defaultValue: 'filled', + options: Object.values(BadgeVariant), + }, + color: { + control: 'select', + options: Object.values(BadgeColor), }, + icon: { + control: 'select', + options: Object.values(BadgeIconPosition), + }, + }, + args: { + children: 'Badge', + size: BadgeSize.small, + variant: BadgeVariant.default, + color: BadgeColor.gray, + icon: BadgeIconPosition.none, }, } satisfies Meta; -export default meta; +export default meta; type Story = StoryObj; -export const Basic: Story = { +export const Default: Story = { args: { - children: '์‚ฌ์ดํ”„', - size: 'medium', - variant: 'filled', + children: 'Badge', }, }; export const Sizes: Story = { - render: (args) => ( -
- {Object.keys(BadgeSizeEnum).map((size) => ( - - {size} - - ))} + render: () => ( +
+ Small + Large
), }; export const Variants: Story = { - render: (args) => ( -
- {Object.keys(BadgeVariantEnum).map((variant) => ( - - {variant} - + render: () => ( +
+ Solid + Default +
+ ), +}; + +export const Colors: Story = { + render: () => ( +
+ White + Gray + Danger + General + 1st + 2nd + 3rd + 4th +
+ ), +}; + +export const ColorVariantMatrix: Story = { + render: () => ( +
+ {Object.values(BadgeVariant).map((variant) => ( +
+ {variant}: + {Object.values(BadgeColor).map((color) => ( + + {color} + + ))} +
))}
), }; + +export const WithIcons: Story = { + render: () => ( +
+
+ + Left Icon + + + Right Icon + + + Both Icons + +
+
+ ), +}; diff --git a/packages/badge/src/Badge.test.tsx b/packages/badge/src/Badge.test.tsx index 3771bd84..6eeb5395 100644 --- a/packages/badge/src/Badge.test.tsx +++ b/packages/badge/src/Badge.test.tsx @@ -1,86 +1,76 @@ 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(ํ…Œ์ŠคํŠธ); - - expect(screen.getByText('ํ…Œ์ŠคํŠธ')).toBeInTheDocument(); -}); - -test('๋ชจ์„œ๋ฆฌ๊ฐ€ 8px radius ํ˜•ํƒœ์ด๋‹ค.', () => { - render(ํ…Œ์ŠคํŠธ); - - expect(screen.getByRole('status')).toHaveStyle({ borderRadius: '8px' }); -}); - -test(`๊ธ€๊ผด ์ƒ‰์ƒ์€ cyan300(${colorToken.cyan300})์ด๋‹ค.`, () => { - render(ํ…Œ์ŠคํŠธ); - - expect(screen.getByText('ํ…Œ์ŠคํŠธ')).toHaveStyle({ color: colorToken.cyan300 }); -}); - -test('๊ธ€๊ผด ๋‘๊ป˜๋Š” semiBold(600)์ด๋‹ค.', () => { - render(ํ…Œ์ŠคํŠธ); +import { describe, expect, it } from 'vitest'; - expect(screen.getByText('ํ…Œ์ŠคํŠธ')).toHaveStyle({ fontWeight: 600 }); -}); - -test(`variant๋ฅผ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด filled(${colorToken.cyan900})๋ฅผ ๊ธฐ๋ณธ ํ˜•ํƒœ๋กœ ์„ค์ •ํ•œ๋‹ค.`, () => { - render(ํ…Œ์ŠคํŠธ); +import { Badge } from './Badge'; +import { BadgeColor, BadgeSize, BadgeVariant } from './Badge.constants'; - expect(screen.getByRole('status')).toHaveStyle({ - backgroundColor: colorToken.cyan900, +describe('Badge', () => { + it('renders with default props', () => { + render(Default Badge); + expect(screen.getByText('Default Badge')).toBeInTheDocument(); }); -}); -test('variant๊ฐ€ weak์ธ ๊ฒฝ์šฐ ๋ฐฐ๊ฒฝ์ƒ‰ gray200๋กœ ํ˜•ํƒœ๋ฅผ ์ ์šฉํ•œ๋‹ค.', () => { - render(ํ…Œ์ŠคํŠธ); + it('renders with all size variants', () => { + const { rerender } = render(Test); - expect(screen.getByRole('status')).toHaveStyle({ - backgroundColor: colorToken.gray200, + Object.values(BadgeSize).forEach((size) => { + rerender(Test); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); }); -}); -test('variant๊ฐ€ outline์ธ ๊ฒฝ์šฐ ๋ฐฐ๊ฒฝ์ƒ‰์€ ํˆฌ๋ช…, ํ…Œ๋‘๋ฆฌ๋Š” 2px ๋‘๊ป˜์˜ cyan900 ์ƒ‰์ƒ ํ˜•ํƒœ๋ฅผ ์ ์šฉํ•œ๋‹ค.', () => { - render(ํ…Œ์ŠคํŠธ); + it('renders with all variant types', () => { + const { rerender } = render(Test); - expect(screen.getByRole('status')).toHaveStyle({ - backgroundColor: 'transparent', - border: `2px solid ${colorToken.cyan900}`, + Object.values(BadgeVariant).forEach((variant) => { + rerender(Test); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); }); -}); -test('size๋ฅผ ์ฃผ์ž…ํ•˜์ง€ ์•Š์œผ๋ฉด medium(์ƒํ•˜ ํŒจ๋”ฉ 8px, ์ขŒ์šฐ ํŒจ๋”ฉ 16px)์„ ๊ธฐ๋ณธ ํฌ๊ธฐ๋กœ ์„ค์ •ํ•œ๋‹ค.', () => { - render(ํ…Œ์ŠคํŠธ); + it('renders with all color options', () => { + const { rerender } = render(Test); - expect(screen.getByRole('status')).toHaveStyle({ - paddingTop: '8px', - paddingBottom: '8px', - paddingLeft: '16px', - paddingRight: '16px', + Object.values(BadgeColor).forEach((color) => { + rerender(Test); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); }); -}); -test('size๊ฐ€ small์ธ ๊ฒฝ์šฐ ์ƒํ•˜ ํŒจ๋”ฉ 4px, ์ขŒ์šฐ ํŒจ๋”ฉ 8px ํ˜•ํƒœ๋ฅผ ์ ์šฉํ•œ๋‹ค.', () => { - render(ํ…Œ์ŠคํŠธ); + it('renders with icons', () => { + render( + + Test + , + ); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); - expect(screen.getByRole('status')).toHaveStyle({ - paddingTop: '4px', - paddingBottom: '4px', - paddingLeft: '8px', - paddingRight: '8px', + it('applies custom className', () => { + render(Custom Badge); + expect(screen.getByText('Custom Badge')).toHaveClass('custom-class'); }); -}); -test('size๊ฐ€ large์ธ ๊ฒฝ์šฐ ์ƒํ•˜ ํŒจ๋”ฉ 12px, ์ขŒ์šฐ ํŒจ๋”ฉ 24px ํ˜•ํƒœ๋ฅผ ์ ์šฉํ•œ๋‹ค.', () => { - render(ํ…Œ์ŠคํŠธ); + it('spreads additional props', () => { + render(Test Badge); + expect(screen.getByTestId('badge-test')).toBeInTheDocument(); + }); - expect(screen.getByRole('status')).toHaveStyle({ - paddingTop: '12px', - paddingBottom: '12px', - paddingLeft: '24px', - paddingRight: '24px', + it('combines all props correctly', () => { + render( + + Combined Badge + , + ); + + const badge = screen.getByTestId('badge-test'); + expect(badge).toHaveClass('custom-class'); + expect(badge).toHaveTextContent('Combined Badge'); }); }); diff --git a/packages/badge/src/Badge.tsx b/packages/badge/src/Badge.tsx index 91035e40..b258b3a1 100644 --- a/packages/badge/src/Badge.tsx +++ b/packages/badge/src/Badge.tsx @@ -1,41 +1,59 @@ -import { Typography } from '@sipe-team/typography'; -import { clsx as cx } from 'clsx'; -import { type ComponentProps, type ForwardedRef, forwardRef } from 'react'; -import * as styles from './Badge.css'; - -export type BadgeSize = keyof typeof styles.BadgeSize; -export type BadgeVariant = keyof typeof styles.BadgeVariant; - -export interface BadgeProps extends ComponentProps<'div'> { - size?: BadgeSize; - variant?: BadgeVariant; -} - -export const Badge = forwardRef(function Badge( - { className, children, size = 'medium', variant = 'filled', ...props }: BadgeProps, - ref: ForwardedRef, -) { - return ( -
- - {children} - -
- ); -}); - -function getTypographySize(size: BadgeSize): 12 | 14 | 18 { - switch (size) { - case 'small': - return 12; - case 'large': - return 18; - default: - return 14; - } -} +import React from 'react'; + +import { Slot } from '@radix-ui/react-slot'; + +import { clsx } from 'clsx'; + +import type { BadgeProps } from './Badge.constants'; +import { BadgeColor, BadgeSize, BadgeVariant } from './Badge.constants'; +import { badge } from './Badge.css'; + +export const Badge = React.forwardRef( + ( + { + asChild, + children, + className, + size = BadgeSize.small, + variant = BadgeVariant.default, + color = BadgeColor.gray, + icon = 'none', + leftIcon, + rightIcon, + ...props + }, + ref, + ) => { + const Comp = asChild ? Slot : 'div'; + + const renderIcon = (iconNode: React.ReactNode) => { + if (!iconNode) return null; + + return ( + + {iconNode} + + ); + }; + + return ( + + {icon === 'left' && leftIcon && renderIcon(leftIcon)} + {icon === 'both' && leftIcon && renderIcon(leftIcon)} + {children} + {icon === 'right' && rightIcon && renderIcon(rightIcon)} + {icon === 'both' && rightIcon && renderIcon(rightIcon)} + + ); + }, +); + +Badge.displayName = 'Badge'; diff --git a/packages/badge/src/index.ts b/packages/badge/src/index.ts index 9c8edca2..c300a7af 100644 --- a/packages/badge/src/index.ts +++ b/packages/badge/src/index.ts @@ -1 +1,3 @@ -export * from './Badge'; +export { Badge } from './Badge'; +export type { BadgeProps } from './Badge.constants'; +export { BadgeColor, BadgeIconPosition, BadgeSize, BadgeVariant } from './Badge.constants'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d73350ad..20d0f76a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,13 +267,13 @@ importers: '@sipe-team/tokens': specifier: workspace:* version: link:../tokens - '@sipe-team/typography': - specifier: workspace:* - version: link:../typography clsx: specifier: ^2.1.1 version: 2.1.1 devDependencies: + '@radix-ui/react-slot': + specifier: ^1.1.0 + version: 1.2.3(@types/react@18.3.23)(react@18.3.1) '@storybook/addon-essentials': specifier: 'catalog:' version: 8.6.14(@types/react@18.3.23)(storybook@8.6.14(prettier@2.8.8)) @@ -307,6 +307,9 @@ importers: '@vanilla-extract/css': specifier: 'catalog:' version: 1.17.4 + '@vanilla-extract/recipes': + specifier: ^0.5.5 + version: 0.5.7(@vanilla-extract/css@1.17.4) happy-dom: specifier: 'catalog:' version: 15.11.7