diff --git a/.biomeignore b/.biomeignore index e7863f4..a9d54e3 100644 --- a/.biomeignore +++ b/.biomeignore @@ -1,2 +1,3 @@ **/*.md + diff --git a/.coderabbit.yaml b/.coderabbit.yaml index bd13f9b..4d70725 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,28 +1,20 @@ -# CodeRabbit 설정 파일 -# 모든 브랜치에서 자동 리뷰 활성화 +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "ko-KR" +early_access: false reviews: - # 모든 브랜치에서 리뷰 활성화 + profile: "chill" + request_changes_workflow: false + high_level_summary: true + poem: false review_status: true - # 기본 브랜치가 아닌 브랜치에서도 리뷰 실행 - base_branches: - - main - - develop - - "feature/**" - - "fix/**" - - "release/**" - -# 리뷰 범위 설정 -language: - # TypeScript, JavaScript 파일 리뷰 - typescript: - enabled: true - javascript: + collapse_walkthrough: false + auto_review: enabled: true + drafts: false + base_branches: + - main + - develop -# 리뷰 스타일 설정 -review: - # 간결한 리뷰 - concise: true - # 코드 개선 제안 포함 - suggestions: true +chat: + auto_reply: true diff --git a/.github/ACCESSIBILITY.md b/.github/ACCESSIBILITY.md index cb0f338..d78d735 100644 --- a/.github/ACCESSIBILITY.md +++ b/.github/ACCESSIBILITY.md @@ -314,3 +314,4 @@ CI 환경에서는 접근성 테스트가 자동으로 실행되며, 위반 사 + diff --git a/.github/HUSKY_SETUP.md b/.github/HUSKY_SETUP.md index 74ae4df..69bc7a9 100644 --- a/.github/HUSKY_SETUP.md +++ b/.github/HUSKY_SETUP.md @@ -131,3 +131,4 @@ TypeScript 타입 오류를 수정한 후 다시 커밋을 시도하세요. + diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 4f4789a..b9bd1d0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -69,3 +69,4 @@ disabled 상태에서도 onClick 이벤트가 발생합니다 + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 01811bc..ec1d40d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -55,3 +55,4 @@ Input 컴포넌트를 추가하고 싶습니다. 다음 기능이 필요합니 + diff --git a/.github/RELEASE_TEMPLATE.md b/.github/RELEASE_TEMPLATE.md index 545bd0a..6315888 100644 --- a/.github/RELEASE_TEMPLATE.md +++ b/.github/RELEASE_TEMPLATE.md @@ -103,3 +103,4 @@ function App() { + diff --git a/.storybook/main.ts b/.storybook/main.ts index 4cf0567..b1f3b89 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,5 +1,6 @@ import type { StorybookConfig } from "@storybook/react-vite"; import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; +import tsconfigPaths from "vite-tsconfig-paths"; import path from "path"; import { fileURLToPath } from "url"; @@ -21,15 +22,22 @@ const config: StorybookConfig = { }, async viteFinal(config) { if (config.plugins) { + config.plugins.push( + tsconfigPaths({ + root: path.resolve(dirname, ".."), + }) + ); config.plugins.push(vanillaExtractPlugin()); } // Storybook에서도 alias 사용 가능하도록 설정 // @storybook/* 패키지는 node_modules에서 찾아야 하므로 alias에서 제외 if (config.resolve) { config.resolve.alias = { + ...config.resolve.alias, "@": path.resolve(dirname, "../src"), "@/components": path.resolve(dirname, "../src/components"), "@/tokens": path.resolve(dirname, "../src/tokens"), + "@/icons": path.resolve(dirname, "../src/icons"), "@/stories": path.resolve(dirname, "../src/stories"), }; } diff --git a/.storybook/tsconfig.json b/.storybook/tsconfig.json new file mode 100644 index 0000000..aab8895 --- /dev/null +++ b/.storybook/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "types": ["vite/client", "@testing-library/jest-dom", "@storybook/addon-vitest"] + }, + "include": ["../src/**/*", "**/*"] +} + diff --git a/.storybook/utils.ts b/.storybook/utils.ts index 60883dc..72b068a 100644 --- a/.storybook/utils.ts +++ b/.storybook/utils.ts @@ -14,18 +14,24 @@ export const isTestEnvironment = /** * vitest 환경에서 전역 expect를 안전하게 가져옵니다. * isTestEnvironment가 true일 때만 호출해야 하며, 이 경우 expect는 항상 정의되어 있습니다. + * @testing-library/jest-dom/vitest를 통해 jest-dom matcher가 포함된 expect를 반환합니다. * - * @returns vitest의 expect 함수 + * @returns vitest의 expect 함수 (jest-dom matcher 포함) * @throws isTestEnvironment가 false일 때 호출하면 에러 발생 */ -export function getExpect(): typeof import("vitest").expect { +export function getExpect() { if (!isTestEnvironment) { throw new Error( "expect는 테스트 환경에서만 사용할 수 있습니다. isTestEnvironment를 확인해주세요." ); } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return (globalThis as { expect: typeof import("vitest").expect }).expect!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-explicit-any + // @testing-library/jest-dom/vitest가 vitest의 expect 타입을 모듈 확장을 통해 확장하지만, + // 함수 반환 타입에서는 확장된 타입을 직접 참조할 수 없습니다. + // 따라서 타입 단언을 사용하여 jest-dom matcher가 포함된 expect를 반환합니다. + // 실제 런타임에서는 @testing-library/jest-dom/vitest가 이미 로드되어 있으므로 + // jest-dom matcher들이 정상적으로 작동합니다. + return (globalThis as unknown as { expect: any }).expect!; } /** diff --git a/CHANGELOG.md b/CHANGELOG.md index 92dfd90..46cb803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,3 +27,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + diff --git a/LICENSE b/LICENSE index 65e1bfa..31f2afd 100644 --- a/LICENSE +++ b/LICENSE @@ -18,3 +18,4 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + diff --git a/biome.json b/biome.json index 6386f40..ad82f8e 100644 --- a/biome.json +++ b/biome.json @@ -15,7 +15,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "a11y": { + "noSvgWithoutTitle": "off" + } } }, "javascript": { diff --git a/docs/STYLING.md b/docs/STYLING.md index f7f8fa9..7e55e0d 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -218,3 +218,4 @@ export const buttonLoading = style({ + diff --git a/docs/THEMING.md b/docs/THEMING.md index 1b9bcd2..5ba0b36 100644 --- a/docs/THEMING.md +++ b/docs/THEMING.md @@ -223,3 +223,4 @@ export const button = style({ + diff --git a/package.json b/package.json index ee7d77a..43bc9bb 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "typescript": "^5.9.3", "vite": "^7.2.4", "vite-plugin-dts": "^4.5.4", + "vite-tsconfig-paths": "^5.1.4", "vitest": "^4.0.13" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea6c5c9..8cc76e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: vite-plugin-dts: specifier: ^4.5.4 version: 4.5.4(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)) vitest: specifier: ^4.0.13 version: 4.0.13(@types/node@24.10.1)(@vitest/browser-playwright@4.0.13)(@vitest/ui@4.0.13)(jiti@2.6.1)(jsdom@27.2.0) @@ -1707,6 +1710,9 @@ packages: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2358,6 +2364,16 @@ packages: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + tsconfig-paths@4.2.0: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} @@ -2416,6 +2432,14 @@ packages: vite: optional: true + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + vite@7.2.4: resolution: {integrity: sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4158,6 +4182,8 @@ snapshots: dependencies: ini: 4.1.1 + globrex@0.1.2: {} + graceful-fs@4.2.11: {} has-flag@4.0.0: {} @@ -4767,6 +4793,10 @@ snapshots: ts-dedent@2.2.0: {} + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 @@ -4844,6 +4874,17 @@ snapshots: - rollup - supports-color + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + optionalDependencies: + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1) + transitivePeerDependencies: + - supports-color + - typescript + vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1): dependencies: esbuild: 0.25.12 diff --git a/src/components/Button/Button.css.ts b/src/components/Button/Button.css.ts index aa95f83..bf382c5 100644 --- a/src/components/Button/Button.css.ts +++ b/src/components/Button/Button.css.ts @@ -1,9 +1,100 @@ -import { style } from "@vanilla-extract/css"; +import { style, styleVariants } from "@vanilla-extract/css"; +import { colors, radius, shadows, textStyles } from "../../tokens"; -export const button = style({ - padding: "8px 12px", - borderRadius: "9999px", +const baseButton = style({ + ...textStyles.heading4, + padding: "0px 16px", + borderRadius: radius.base, border: "none", cursor: "pointer", - fontWeight: 500, + + display: "grid", + gridTemplateColumns: "24px 1fr 24px", + alignItems: "center", + transition: "background-color 0.2s, box-shadow 0.2s", + width: "100%", + height: "48px", +}); + +// Variant styles +export const buttonVariants = styleVariants({ + default: [ + baseButton, + { + backgroundColor: colors.grey[50], + color: colors.grey[500], + ":hover": { + backgroundColor: colors.grey[100], + }, + selectors: { + '&[data-pressed="true"]': { + backgroundColor: colors.grey[100], + boxShadow: shadows["btn-pressed"], + }, + "&:disabled": { + backgroundColor: colors.grey[50], + color: colors.grey[300], + cursor: "not-allowed", + }, + }, + }, + ], + gray: [ + baseButton, + { + backgroundColor: colors.grey[800], + color: colors.base.white, + ":hover": { + backgroundColor: colors.grey[700], + }, + selectors: { + '&[data-pressed="true"]': { + backgroundColor: colors.grey[900], + boxShadow: shadows["btn-pressed"], + }, + "&:disabled": { + backgroundColor: colors.grey[300], + color: colors.grey[100], + cursor: "not-allowed", + }, + }, + }, + ], + primary: [ + baseButton, + { + backgroundColor: colors.orange[500], + color: colors.base.white, + ":hover": { + backgroundColor: colors.orange[600], + }, + selectors: { + '&[data-pressed="true"]': { + backgroundColor: colors.orange[600], + boxShadow: shadows["btn-pressed"], + }, + "&:disabled": { + backgroundColor: colors.orange[100], + cursor: "not-allowed", + }, + }, + }, + ], +}); + +// Default export for backward compatibility +export const button = buttonVariants.default; + +// Icon wrapper +export const iconWrapper = style({ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "24px", +}); + +export const contentWrapper = style({ + display: "flex", + alignItems: "center", + justifyContent: "center", }); diff --git a/src/components/Button/Button.stories.ts b/src/components/Button/Button.stories.ts index 4f359f9..726af01 100644 --- a/src/components/Button/Button.stories.ts +++ b/src/components/Button/Button.stories.ts @@ -1,11 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { within } from "@testing-library/react"; -import { Button } from "@/components/Button/Button"; import { getExpect, isTestEnvironment } from "../../../.storybook/utils"; - -// vitest.config.ts에서 globals: true로 설정되어 있으므로 전역 expect 사용 가능 -// @testing-library/jest-dom/vitest는 vitest.setup.ts에서 import됨 -// isTestEnvironment가 true일 때만 expect를 사용하므로 안전합니다 +import { Button } from "./Button"; const meta = { title: "Components/Button", @@ -19,6 +15,11 @@ const meta = { control: "text", description: "버튼 내부에 표시될 내용", }, + variant: { + control: "select", + options: ["default", "gray", "primary"], + description: "버튼의 스타일 변형", + }, disabled: { control: "boolean", description: "버튼 비활성화 여부", @@ -43,9 +44,7 @@ export const Default: Story = { const canvas = within(canvasElement); const button = canvas.getByRole("button", { name: "Button" }); - // 버튼이 렌더링되었는지 확인 await expect(button).toBeInTheDocument(); - // 버튼이 활성화되어 있는지 확인 await expect(button).not.toBeDisabled(); } : undefined, @@ -80,9 +79,7 @@ export const Disabled: Story = { name: "Disabled Button", }); - // 비활성화 상태 확인 await expect(button).toBeDisabled(); - // aria-disabled 속성 확인 await expect(button).toHaveAttribute("disabled"); } : undefined, @@ -103,3 +100,74 @@ export const LongText: Story = { } : undefined, }; + +export const VariantDefault: Story = { + args: { + children: "Default Button", + variant: "default", + }, + play: isTestEnvironment + ? async ({ canvasElement }) => { + const expect = getExpect(); + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: "Default Button" }); + + await expect(button).toBeInTheDocument(); + await expect(button).toHaveAttribute("data-variant", "default"); + } + : undefined, +}; + +export const VariantGray: Story = { + args: { + children: "Gray Button", + variant: "gray", + }, + play: isTestEnvironment + ? async ({ canvasElement }) => { + const expect = getExpect(); + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: "Gray Button" }); + + await expect(button).toBeInTheDocument(); + await expect(button).toHaveAttribute("data-variant", "gray"); + } + : undefined, +}; + +export const VariantPrimary: Story = { + args: { + children: "Primary Button", + variant: "primary", + }, + play: isTestEnvironment + ? async ({ canvasElement }) => { + const expect = getExpect(); + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: "Primary Button" }); + + await expect(button).toBeInTheDocument(); + await expect(button).toHaveAttribute("data-variant", "primary"); + } + : undefined, +}; + +export const WithIcon: Story = { + args: { + children: "알림", + variant: "primary", + icon: "🔔", + style: { width: "300px" }, + }, + play: isTestEnvironment + ? async ({ canvasElement }) => { + const expect = getExpect(); + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: "🔔 알림" }); + + await expect(button).toBeInTheDocument(); + await expect(button).toHaveTextContent("🔔"); + await expect(button).toHaveTextContent("알림"); + } + : undefined, +}; diff --git a/src/components/Button/Button.test.tsx b/src/components/Button/Button.test.tsx index 749b7cc..0c3f796 100644 --- a/src/components/Button/Button.test.tsx +++ b/src/components/Button/Button.test.tsx @@ -1,11 +1,18 @@ import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { useRef } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Button } from "./Button"; // vanilla-extract CSS 파일 모킹 vi.mock("./Button.css", () => ({ - button: "mock-button-class", + buttonVariants: { + default: "mock-button-default", + gray: "mock-button-gray", + primary: "mock-button-primary", + }, + iconWrapper: "mock-icon-wrapper", + contentWrapper: "mock-content-wrapper", })); describe("Button", () => { @@ -86,4 +93,304 @@ describe("Button", () => { const button = screen.getByRole("button", { name: "버튼" }); expect(button.tagName).toBe("BUTTON"); }); + + it("variant prop이 적용되어야 합니다", () => { + const { rerender } = render(); + let button = screen.getByRole("button", { name: "버튼" }); + expect(button).toHaveAttribute("data-variant", "default"); + + rerender(); + button = screen.getByRole("button", { name: "버튼" }); + expect(button).toHaveAttribute("data-variant", "gray"); + + rerender(); + button = screen.getByRole("button", { name: "버튼" }); + expect(button).toHaveAttribute("data-variant", "primary"); + }); + + it("기본 variant는 default여야 합니다", () => { + render(); + const button = screen.getByRole("button", { name: "버튼" }); + expect(button).toHaveAttribute("data-variant", "default"); + }); + + it("icon이 있을 때 렌더링되어야 합니다", () => { + const Icon = () => 🔔; + const { container } = render( + , + ); + const iconElement = screen.getByText("🔔"); + expect(iconElement).toBeInTheDocument(); + const iconWrapper = container.querySelector("[data-icon]"); + expect(iconWrapper).toBeInTheDocument(); + expect(iconWrapper).toContainElement(iconElement); + }); + + it("icon이 없어도 아이콘 span은 렌더링되어야 합니다", () => { + const { container } = render(); + const iconElement = container.querySelector("[data-icon]"); + expect(iconElement).toBeInTheDocument(); + expect(iconElement?.textContent).toBe(""); + }); + + it("children이 없어도 contentWrapper span은 렌더링되어야 합니다", () => { + const { container } = render(); + const button = container.querySelector("button"); + // 버튼 내부에 2개의 span이 있어야 함 (icon span, content span) + const spans = button?.querySelectorAll("span"); + expect(spans?.length).toBe(2); + + // 첫 번째 span은 아이콘 + const iconSpan = container.querySelector("[data-icon]"); + expect(iconSpan).toBeInTheDocument(); + expect(iconSpan?.textContent).toBe("🔔"); + + // 두 번째 span은 children + expect(screen.getByText("버튼")).toBeInTheDocument(); + }); + + it("icon과 children이 모두 있을 때 올바르게 렌더링되어야 합니다", () => { + const Icon = () => 🔔; + render( + , + ); + + // 아이콘이 렌더링되어야 함 + expect(screen.getByText("🔔")).toBeInTheDocument(); + + // children이 렌더링되어야 함 + expect(screen.getByText("알림 버튼")).toBeInTheDocument(); + + // 버튼이 올바르게 렌더링되어야 함 (아이콘이 포함된 이름으로 검색) + const button = screen.getByRole("button", { name: /알림 버튼/ }); + expect(button).toBeInTheDocument(); + }); + + it("마우스 다운 시 pressed 상태가 되어야 합니다", async () => { + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button", { name: "버튼" }); + + await user.pointer({ keys: "[MouseLeft>]", target: button }); + expect(button).toHaveAttribute("data-pressed", "true"); + }); + + it("마우스 업 시 pressed 상태가 해제되어야 합니다", async () => { + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button", { name: "버튼" }); + + await user.pointer({ keys: "[MouseLeft>]", target: button }); + expect(button).toHaveAttribute("data-pressed", "true"); + + await user.pointer({ keys: "[/MouseLeft]" }); + expect(button).toHaveAttribute("data-pressed", "false"); + }); + + it("마우스가 벗어날 때 pressed 상태가 해제되어야 합니다", async () => { + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button", { name: "버튼" }); + + await user.pointer({ keys: "[MouseLeft>]", target: button }); + expect(button).toHaveAttribute("data-pressed", "true"); + + await user.hover(button); + await user.unhover(button); + expect(button).toHaveAttribute("data-pressed", "false"); + }); + + it("disabled 상태일 때 마우스 다운해도 pressed 상태가 되지 않아야 합니다", async () => { + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button", { name: "버튼" }); + + await user.pointer({ keys: "[MouseLeft>]", target: button }); + expect(button).toHaveAttribute("data-pressed", "false"); + }); + + it("ref가 전달되어야 합니다", () => { + const TestComponent = () => { + const buttonRef = useRef(null); + + return ( + <> + +
+ {buttonRef.current ? "ref-set" : "ref-not-set"} +
+ + ); + }; + + const { rerender } = render(); + // ref는 렌더링 후에 설정되므로 rerender를 통해 확인 + rerender(); + expect(screen.getByTestId("ref-check")).toHaveTextContent("ref-set"); + }); + + it("기본 type은 button이어야 합니다", () => { + render(); + const button = screen.getByRole("button", { name: "버튼" }); + expect(button).toHaveAttribute("type", "button"); + }); + + it("type prop이 submit일 때 적용되어야 합니다", () => { + render(); + const button = screen.getByRole("button", { name: "제출" }); + expect(button).toHaveAttribute("type", "submit"); + }); + + it("type prop이 reset일 때 적용되어야 합니다", () => { + render(); + const button = screen.getByRole("button", { name: "리셋" }); + expect(button).toHaveAttribute("type", "reset"); + }); + + it("onMouseDown 핸들러가 호출되어야 합니다", async () => { + const handleMouseDown = vi.fn(); + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button", { name: "버튼" }); + + await user.pointer({ keys: "[MouseLeft>]", target: button }); + expect(handleMouseDown).toHaveBeenCalledTimes(1); + }); + + it("onMouseUp 핸들러가 호출되어야 합니다", async () => { + const handleMouseUp = vi.fn(); + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button", { name: "버튼" }); + + await user.pointer({ keys: "[MouseLeft>]", target: button }); + await user.pointer({ keys: "[/MouseLeft]" }); + expect(handleMouseUp).toHaveBeenCalledTimes(1); + }); + + it("onMouseLeave 핸들러가 호출되어야 합니다", async () => { + const handleMouseLeave = vi.fn(); + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button", { name: "버튼" }); + + await user.hover(button); + await user.unhover(button); + expect(handleMouseLeave).toHaveBeenCalledTimes(1); + }); + + it("disabled 상태일 때 onMouseDown이 호출되지 않아야 합니다", async () => { + const handleMouseDown = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + const button = screen.getByRole("button", { name: "버튼" }); + + await user.pointer({ keys: "[MouseLeft>]", target: button }); + expect(handleMouseDown).not.toHaveBeenCalled(); + }); + + it("Enter 키로 버튼을 활성화할 수 있어야 합니다", async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button", { name: "버튼" }); + + button.focus(); + await user.keyboard("{Enter}"); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("Space 키로 버튼을 활성화할 수 있어야 합니다", async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button", { name: "버튼" }); + + button.focus(); + await user.keyboard(" "); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("disabled 상태일 때 키보드로 활성화할 수 없어야 합니다", async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + const button = screen.getByRole("button", { name: "버튼" }); + + button.focus(); + await user.keyboard("{Enter}"); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it("variant className과 custom className이 함께 적용되어야 합니다", () => { + render(); + const button = screen.getByRole("button", { name: "버튼" }); + expect(button).toHaveClass("mock-button-default"); + expect(button).toHaveClass("custom-class"); + }); + + it("displayName이 설정되어야 합니다", () => { + expect(Button.displayName).toBe("Button"); + }); + + it("여러 개의 버튼이 독립적으로 동작해야 합니다", async () => { + const handleClick1 = vi.fn(); + const handleClick2 = vi.fn(); + const user = userEvent.setup(); + + render( + <> + + + , + ); + + const button1 = screen.getByRole("button", { name: "버튼 1" }); + const button2 = screen.getByRole("button", { name: "버튼 2" }); + + await user.click(button1); + expect(handleClick1).toHaveBeenCalledTimes(1); + expect(handleClick2).not.toHaveBeenCalled(); + + await user.click(button2); + expect(handleClick1).toHaveBeenCalledTimes(1); + expect(handleClick2).toHaveBeenCalledTimes(1); + }); + + it("빈 children일 때도 정상적으로 렌더링되어야 합니다", () => { + render(); + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + }); + + it("복잡한 ReactNode children을 렌더링할 수 있어야 합니다", () => { + render( + , + ); + expect(screen.getByText("텍스트")).toBeInTheDocument(); + expect(screen.getByText("강조")).toBeInTheDocument(); + }); }); diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index db81523..08d622e 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,12 +1,81 @@ -import type { ButtonHTMLAttributes, PropsWithChildren } from "react"; -import { button } from "./Button.css"; - -type Props = ButtonHTMLAttributes & PropsWithChildren; - -export const Button = ({ children, ...rest }: Props) => { - return ( - - ); -}; +import type { ButtonHTMLAttributes, MouseEvent, ReactNode } from "react"; +import { forwardRef, useState } from "react"; +import { buttonVariants, contentWrapper, iconWrapper } from "./Button.css"; + +type ButtonVariant = keyof typeof buttonVariants; + +export interface ButtonProps extends ButtonHTMLAttributes { + /** + * 버튼의 스타일 변형 + * @default "default" + */ + variant?: ButtonVariant; + /** + * 버튼 왼쪽에 표시할 아이콘 + */ + icon?: ReactNode; +} + +export const Button = forwardRef( + ( + { + children, + variant = "default", + icon, + type = "button", + className, + disabled, + onMouseDown, + onMouseUp, + onMouseLeave, + ...rest + }, + ref, + ) => { + const [isPressed, setIsPressed] = useState(false); + const isDisabled = !!disabled; + + const handleMouseDown = (e: MouseEvent) => { + if (!isDisabled) { + setIsPressed(true); + } + onMouseDown?.(e); + }; + + const handleMouseUp = (e: MouseEvent) => { + setIsPressed(false); + onMouseUp?.(e); + }; + + const handleMouseLeave = (e: MouseEvent) => { + setIsPressed(false); + onMouseLeave?.(e); + }; + + const mergedClassName = [buttonVariants[variant], className] + .filter(Boolean) + .join(" "); + + return ( + + ); + }, +); + +Button.displayName = "Button"; diff --git a/src/components/FloatingButton/FloatingButton.css.ts b/src/components/FloatingButton/FloatingButton.css.ts new file mode 100644 index 0000000..a701154 --- /dev/null +++ b/src/components/FloatingButton/FloatingButton.css.ts @@ -0,0 +1,42 @@ +import { style } from "@vanilla-extract/css"; +import { colors, radius, shadows, textStyles } from "../../tokens"; + +export const floatingButton = style({ + ...textStyles.heading4, + paddingLeft: "20px", + paddingRight: "14px", + borderRadius: radius.xl, + border: `none`, + cursor: "pointer", + display: "flex", + gap: "4px", + alignItems: "center", + transition: "background-color 0.2s, box-shadow 0.2s", + width: "auto", + height: "40px", + backgroundColor: colors.base.white, + color: colors.grey[500], + boxShadow: shadows.floating, + ":hover": { + backgroundColor: colors.grey[50], + }, + selectors: { + '&[data-pressed="true"]': { + backgroundColor: colors.grey[100], + boxShadow: shadows.none, + }, + "&:disabled": { + backgroundColor: colors.grey[50], + color: colors.grey[300], + boxShadow: shadows.none, + opacity: 0.6, + cursor: "not-allowed", + }, + }, +}); + +export const contentWrapper = style({ + display: "flex", + alignItems: "center", + justifyContent: "center", +}); diff --git a/src/components/FloatingButton/FloatingButton.stories.ts b/src/components/FloatingButton/FloatingButton.stories.ts new file mode 100644 index 0000000..05fec0d --- /dev/null +++ b/src/components/FloatingButton/FloatingButton.stories.ts @@ -0,0 +1,81 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { within } from "@testing-library/react"; +import { getExpect, isTestEnvironment } from "../../../.storybook/utils"; +import { FloatingButton } from "./FloatingButton"; + +const meta = { + title: "Components/FloatingButton", + component: FloatingButton, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + children: { + control: "text", + description: "버튼 내부에 표시될 내용", + }, + disabled: { + control: "boolean", + description: "버튼 비활성화 여부", + }, + onClick: { + action: "clicked", + description: "버튼 클릭 이벤트 핸들러", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: "Text", + }, + play: isTestEnvironment + ? async ({ canvasElement }) => { + const expect = getExpect(); + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: "Text +" }); + + await expect(button).toBeInTheDocument(); + await expect(button).not.toBeDisabled(); + } + : undefined, +}; + +export const WithText: Story = { + args: { + children: "클릭하세요", + }, + play: isTestEnvironment + ? async ({ canvasElement, args }) => { + const expect = getExpect(); + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: "클릭하세요 +" }); + + await expect(button).toBeInTheDocument(); + await expect(button).toHaveTextContent(args.children as string); + } + : undefined, +}; + +export const Disabled: Story = { + args: { + children: "Text", + disabled: true, + }, + play: isTestEnvironment + ? async ({ canvasElement }) => { + const expect = getExpect(); + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { + name: "Text +", + }); + + await expect(button).toBeDisabled(); + await expect(button).toHaveAttribute("disabled"); + } + : undefined, +}; diff --git a/src/components/FloatingButton/FloatingButton.test.tsx b/src/components/FloatingButton/FloatingButton.test.tsx new file mode 100644 index 0000000..2e72616 --- /dev/null +++ b/src/components/FloatingButton/FloatingButton.test.tsx @@ -0,0 +1,198 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRef } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FloatingButton } from "./FloatingButton"; + +// vanilla-extract CSS 파일 모킹 +vi.mock("./FloatingButton.css", () => ({ + floatingButton: "mock-floating-button", + iconWrapper: "mock-icon-wrapper", + contentWrapper: "mock-content-wrapper", +})); + +describe("FloatingButton", () => { + afterEach(() => { + cleanup(); + }); + + it("렌더링되어야 합니다", () => { + render(클릭하세요); + const button = screen.getByRole("button", { name: /클릭하세요/ }); + expect(button).toBeInTheDocument(); + }); + + it("children을 표시해야 합니다", () => { + render(테스트 버튼); + expect(screen.getByText("테스트 버튼")).toBeInTheDocument(); + }); + + it("disabled 상태일 때 비활성화되어야 합니다", () => { + render(비활성화 버튼); + const button = screen.getByRole("button", { name: /비활성화 버튼/ }); + expect(button).toBeDisabled(); + }); + + it("disabled 상태가 아닐 때 활성화되어야 합니다", () => { + render(활성화 버튼); + const button = screen.getByRole("button", { name: /활성화 버튼/ }); + expect(button).not.toBeDisabled(); + }); + + it("onClick 핸들러가 호출되어야 합니다", async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + + render(클릭 테스트); + const button = screen.getByRole("button", { name: /클릭 테스트/ }); + + await user.click(button); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("disabled 상태일 때 onClick이 호출되지 않아야 합니다", async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + + render( + + 비활성화 클릭 테스트 + , + ); + const button = screen.getByRole("button", { + name: /비활성화 클릭 테스트/, + }); + + await user.click(button); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it("HTML 속성을 전달받아야 합니다", () => { + render( + + 제출 + , + ); + const button = screen.getByRole("button", { name: "제출 버튼" }); + expect(button).toHaveAttribute("type", "submit"); + expect(button).toHaveAttribute("aria-label", "제출 버튼"); + }); + + it("className이 적용되어야 합니다", () => { + render( + 커스텀 클래스, + ); + const button = screen.getByRole("button", { name: /커스텀 클래스/ }); + expect(button).toHaveClass("custom-class"); + }); + + it("기본 button 요소로 렌더링되어야 합니다", () => { + render(버튼); + const button = screen.getByRole("button", { name: /버튼/ }); + expect(button.tagName).toBe("BUTTON"); + }); + + it("icon prop이 표시되어야 합니다", () => { + render(텍스트); + const button = screen.getByRole("button", { name: /텍스트/ }); + // PlusIcon이 aria-label="+"로 렌더링됨 + expect(screen.getByLabelText("+")).toBeInTheDocument(); + expect(button).toHaveTextContent("텍스트"); + }); + + it("icon이 없을 때도 렌더링되어야 합니다", () => { + render(텍스트만); + const button = screen.getByRole("button", { name: /텍스트만/ }); + expect(button).toBeInTheDocument(); + }); + + it("ref가 전달되어야 합니다", () => { + const TestComponent = () => { + const ref = useRef(null); + return ( + <> + 버튼 +
+ {ref.current ? "ref-set" : "ref-not-set"} +
+ + ); + }; + + const { rerender } = render(); + // ref는 렌더링 후에 설정되므로 rerender를 통해 확인 + rerender(); + expect(screen.getByTestId("ref-check")).toHaveTextContent("ref-set"); + }); + + it("마우스 다운 시 pressed 상태가 되어야 합니다", async () => { + const user = userEvent.setup(); + render(버튼); + const button = screen.getByRole("button", { name: /버튼/ }); + + await user.pointer({ keys: "[MouseLeft>]", target: button }); + expect(button).toHaveAttribute("data-pressed", "true"); + + await user.pointer({ keys: "[/MouseLeft]" }); + expect(button).toHaveAttribute("data-pressed", "false"); + }); + + it("disabled 상태일 때 pressed 상태가 되지 않아야 합니다", async () => { + const user = userEvent.setup(); + render(버튼); + const button = screen.getByRole("button", { name: /버튼/ }); + + await user.pointer({ keys: "[MouseLeft>]", target: button }); + expect(button).toHaveAttribute("data-pressed", "false"); + }); + + it("마우스가 벗어날 때 pressed 상태가 해제되어야 합니다", async () => { + const user = userEvent.setup(); + render(버튼); + const button = screen.getByRole("button", { name: /버튼/ }); + + await user.pointer({ keys: "[MouseLeft>]", target: button }); + expect(button).toHaveAttribute("data-pressed", "true"); + + await user.hover(button); + await user.unhover(button); + expect(button).toHaveAttribute("data-pressed", "false"); + }); + + it("onMouseDown 핸들러가 호출되어야 합니다", async () => { + const handleMouseDown = vi.fn(); + const user = userEvent.setup(); + + render(버튼); + const button = screen.getByRole("button", { name: /버튼/ }); + + await user.pointer({ keys: "[MouseLeft>]", target: button }); + expect(handleMouseDown).toHaveBeenCalledTimes(1); + }); + + it("onMouseUp 핸들러가 호출되어야 합니다", async () => { + const handleMouseUp = vi.fn(); + const user = userEvent.setup(); + + render(버튼); + const button = screen.getByRole("button", { name: /버튼/ }); + + await user.pointer({ keys: "[MouseLeft>]", target: button }); + await user.pointer({ keys: "[/MouseLeft]" }); + expect(handleMouseUp).toHaveBeenCalledTimes(1); + }); + + it("onMouseLeave 핸들러가 호출되어야 합니다", async () => { + const handleMouseLeave = vi.fn(); + const user = userEvent.setup(); + + render( + 버튼, + ); + const button = screen.getByRole("button", { name: /버튼/ }); + + await user.hover(button); + await user.unhover(button); + expect(handleMouseLeave).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/FloatingButton/FloatingButton.tsx b/src/components/FloatingButton/FloatingButton.tsx new file mode 100644 index 0000000..36ae2c7 --- /dev/null +++ b/src/components/FloatingButton/FloatingButton.tsx @@ -0,0 +1,69 @@ +import type { ButtonHTMLAttributes, MouseEvent } from "react"; +import { forwardRef, useState } from "react"; +import { PlusIcon } from "../../icons"; +import { contentWrapper, floatingButton } from "./FloatingButton.css"; + +export interface FloatingButtonProps + extends ButtonHTMLAttributes {} + +export const FloatingButton = forwardRef< + HTMLButtonElement, + FloatingButtonProps +>( + ( + { + children, + type = "button", + className, + disabled, + onMouseDown, + onMouseUp, + onMouseLeave, + ...rest + }, + ref, + ) => { + const [isPressed, setIsPressed] = useState(false); + const isDisabled = !!disabled; + + const handleMouseDown = (e: MouseEvent) => { + if (!isDisabled) { + setIsPressed(true); + } + onMouseDown?.(e); + }; + + const handleMouseUp = (e: MouseEvent) => { + setIsPressed(false); + onMouseUp?.(e); + }; + + const handleMouseLeave = (e: MouseEvent) => { + setIsPressed(false); + onMouseLeave?.(e); + }; + + const mergedClassName = [floatingButton, className] + .filter(Boolean) + .join(" "); + + return ( + + ); + }, +); + +FloatingButton.displayName = "FloatingButton"; diff --git a/src/components/Switch/Switch.css.ts b/src/components/Switch/Switch.css.ts new file mode 100644 index 0000000..c1ded5d --- /dev/null +++ b/src/components/Switch/Switch.css.ts @@ -0,0 +1,60 @@ +import { style } from "@vanilla-extract/css"; +import { colors, radius } from "../../tokens"; + +const SWITCH_WIDTH = "38px"; +const SWITCH_HEIGHT = "22px"; +const THUMB_SIZE = "14px"; +const THUMB_OFFSET = "4px"; + +export const switchButton = style({ + border: "none", + background: "transparent", + padding: 0, + cursor: "pointer", + outline: "none", + selectors: { + "&:focus-visible": { + outline: `2px solid ${colors.orange[500]}`, + outlineOffset: "2px", + borderRadius: radius.full, + }, + }, +}); + +export const switchTrack = style({ + position: "relative", + display: "inline-block", + width: SWITCH_WIDTH, + height: SWITCH_HEIGHT, + borderRadius: radius.full, + backgroundColor: colors.grey[100], + transition: "background-color 0.2s", + selectors: { + [`${switchButton}[data-checked="true"] &`]: { + backgroundColor: colors.orange[500], + }, + [`${switchButton}:active[data-checked="false"] &`]: { + backgroundColor: colors.grey[200], + }, + [`${switchButton}:active[data-checked="true"] &`]: { + backgroundColor: colors.orange[600], + }, + }, +}); + +export const switchThumb = style({ + position: "absolute", + top: THUMB_OFFSET, + left: THUMB_OFFSET, + width: THUMB_SIZE, + height: THUMB_SIZE, + borderRadius: radius.full, + backgroundColor: colors.base.white, + transition: "transform 0.2s", + transform: "translateX(0)", + selectors: { + [`${switchButton}[data-checked="true"] &`]: { + transform: `translateX(calc(${SWITCH_WIDTH} - ${THUMB_SIZE} - ${THUMB_OFFSET} * 2))`, + }, + }, +}); diff --git a/src/components/Switch/Switch.stories.ts b/src/components/Switch/Switch.stories.ts new file mode 100644 index 0000000..e7b0dec --- /dev/null +++ b/src/components/Switch/Switch.stories.ts @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { within } from "@testing-library/react"; +import { getExpect, isTestEnvironment } from "../../../.storybook/utils"; +import { Switch } from "./Switch"; + +const meta = { + title: "Components/Switch", + component: Switch, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + checked: { + control: "boolean", + description: "Switch의 켜짐/꺼짐 상태", + }, + onChange: { + action: "changed", + description: "Switch 상태 변경 이벤트 핸들러", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + checked: false, + }, + play: isTestEnvironment + ? async ({ canvasElement }) => { + const expect = getExpect(); + const canvas = within(canvasElement); + const switchElement = canvas.getByRole("switch"); + + await expect(switchElement).toBeInTheDocument(); + await expect(switchElement).toHaveAttribute("aria-checked", "false"); + } + : undefined, +}; + +export const Checked: Story = { + args: { + checked: true, + }, + play: isTestEnvironment + ? async ({ canvasElement }) => { + const expect = getExpect(); + const canvas = within(canvasElement); + const switchElement = canvas.getByRole("switch"); + + await expect(switchElement).toBeInTheDocument(); + await expect(switchElement).toHaveAttribute("aria-checked", "true"); + } + : undefined, +}; diff --git a/src/components/Switch/Switch.test.tsx b/src/components/Switch/Switch.test.tsx new file mode 100644 index 0000000..0067c96 --- /dev/null +++ b/src/components/Switch/Switch.test.tsx @@ -0,0 +1,237 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRef, useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Switch } from "./Switch"; + +// vanilla-extract CSS 파일 모킹 +vi.mock("./Switch.css", () => ({ + switchButton: "mock-switch-button", + switchTrack: "mock-switch-track", + switchThumb: "mock-switch-thumb", +})); + +describe("Switch", () => { + afterEach(() => { + cleanup(); + }); + + it("렌더링되어야 합니다", () => { + render(); + const switchElement = screen.getByRole("switch"); + expect(switchElement).toBeInTheDocument(); + }); + + it("기본적으로 꺼진 상태여야 합니다", () => { + render(); + const switchElement = screen.getByRole("switch"); + expect(switchElement).toHaveAttribute("aria-checked", "false"); + expect(switchElement).toHaveAttribute("data-checked", "false"); + }); + + it("checked prop이 true일 때 켜진 상태여야 합니다", () => { + render(); + const switchElement = screen.getByRole("switch"); + expect(switchElement).toHaveAttribute("aria-checked", "true"); + expect(switchElement).toHaveAttribute("data-checked", "true"); + }); + + it("checked prop이 false일 때 꺼진 상태여야 합니다", () => { + render(); + const switchElement = screen.getByRole("switch"); + expect(switchElement).toHaveAttribute("aria-checked", "false"); + expect(switchElement).toHaveAttribute("data-checked", "false"); + }); + + it("클릭 시 onChange 핸들러가 호출되어야 합니다", async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render(); + const switchElement = screen.getByRole("switch"); + + await user.click(switchElement); + expect(handleChange).toHaveBeenCalledTimes(1); + expect(handleChange).toHaveBeenCalledWith(true); + }); + + it("켜진 상태에서 클릭 시 꺼진 상태로 변경되어야 합니다", async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render(); + const switchElement = screen.getByRole("switch"); + + await user.click(switchElement); + expect(handleChange).toHaveBeenCalledWith(false); + }); + + it("HTML 속성을 전달받아야 합니다", () => { + render(); + const switchElement = screen.getByRole("switch", { name: "알림 설정" }); + expect(switchElement).toBeInTheDocument(); + }); + + it("className이 적용되어야 합니다", () => { + render(); + const switchElement = screen.getByRole("switch"); + expect(switchElement).toHaveClass("mock-switch-button"); + expect(switchElement).toHaveClass("custom-class"); + }); + + it("기본 button 요소로 렌더링되어야 합니다", () => { + render(); + const switchElement = screen.getByRole("switch"); + expect(switchElement.tagName).toBe("BUTTON"); + }); + + it("role이 switch여야 합니다", () => { + render(); + const switchElement = screen.getByRole("switch"); + expect(switchElement).toHaveAttribute("role", "switch"); + }); + + it("기본 type은 button이어야 합니다", () => { + render(); + const switchElement = screen.getByRole("switch"); + expect(switchElement).toHaveAttribute("type", "button"); + }); + + it("ref가 전달되어야 합니다", () => { + const TestComponent = () => { + const switchRef = useRef(null); + + return ( + <> + +
+ {switchRef.current ? "ref-set" : "ref-not-set"} +
+ + ); + }; + + const { rerender } = render(); + // ref는 렌더링 후에 설정되므로 rerender를 통해 확인 + rerender(); + expect(screen.getByTestId("ref-check")).toHaveTextContent("ref-set"); + }); + + it("onClick 핸들러가 호출되어야 합니다", async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + + render(); + const switchElement = screen.getByRole("switch"); + + await user.click(switchElement); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("onClick과 onChange가 모두 호출되어야 합니다", async () => { + const handleClick = vi.fn(); + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + const switchElement = screen.getByRole("switch"); + + await user.click(switchElement); + expect(handleClick).toHaveBeenCalledTimes(1); + expect(handleChange).toHaveBeenCalledTimes(1); + expect(handleChange).toHaveBeenCalledWith(true); + }); + + it("Enter 키로 Switch를 토글할 수 있어야 합니다", async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render(); + const switchElement = screen.getByRole("switch"); + + switchElement.focus(); + await user.keyboard("{Enter}"); + expect(handleChange).toHaveBeenCalledWith(true); + }); + + it("Space 키로 Switch를 토글할 수 있어야 합니다", async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render(); + const switchElement = screen.getByRole("switch"); + + switchElement.focus(); + await user.keyboard(" "); + expect(handleChange).toHaveBeenCalledWith(true); + }); + + it("displayName이 설정되어야 합니다", () => { + expect(Switch.displayName).toBe("Switch"); + }); + + it("여러 개의 Switch가 독립적으로 동작해야 합니다", async () => { + const handleChange1 = vi.fn(); + const handleChange2 = vi.fn(); + const user = userEvent.setup(); + + render( + <> + + + , + ); + + const switch1 = screen.getByRole("switch", { name: "Switch 1" }); + const switch2 = screen.getByRole("switch", { name: "Switch 2" }); + + await user.click(switch1); + expect(handleChange1).toHaveBeenCalledTimes(1); + expect(handleChange2).not.toHaveBeenCalled(); + + await user.click(switch2); + expect(handleChange1).toHaveBeenCalledTimes(1); + expect(handleChange2).toHaveBeenCalledTimes(1); + }); + + it("제어 컴포넌트로 동작해야 합니다", async () => { + const TestComponent = () => { + const [checked, setChecked] = useState(false); + + return ( + <> + +
{checked ? "켜짐" : "꺼짐"}
+ + ); + }; + + const user = userEvent.setup(); + render(); + + const switchElement = screen.getByRole("switch", { name: "제어 Switch" }); + const status = screen.getByTestId("status"); + + expect(status).toHaveTextContent("꺼짐"); + + await user.click(switchElement); + expect(status).toHaveTextContent("켜짐"); + + await user.click(switchElement); + expect(status).toHaveTextContent("꺼짐"); + }); +}); diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx new file mode 100644 index 0000000..aa754c9 --- /dev/null +++ b/src/components/Switch/Switch.tsx @@ -0,0 +1,46 @@ +import type { ButtonHTMLAttributes, MouseEvent } from "react"; +import { forwardRef } from "react"; +import { switchButton, switchThumb, switchTrack } from "./Switch.css"; + +export interface SwitchProps + extends Omit, "onChange"> { + /** + * Switch의 켜짐/꺼짐 상태 + * @default false + */ + checked?: boolean; + /** + * Switch 상태 변경 이벤트 핸들러 + */ + onChange?: (checked: boolean) => void; +} + +export const Switch = forwardRef( + ({ checked = false, onChange, className, onClick, ...rest }, ref) => { + const handleClick = (e: MouseEvent) => { + onChange?.(!checked); + onClick?.(e); + }; + + const mergedClassName = [switchButton, className].filter(Boolean).join(" "); + + return ( + + ); + }, +); + +Switch.displayName = "Switch"; diff --git a/src/icons/PlusIcon.tsx b/src/icons/PlusIcon.tsx new file mode 100644 index 0000000..0a1b24e --- /dev/null +++ b/src/icons/PlusIcon.tsx @@ -0,0 +1,48 @@ +import type { SVGProps } from "react"; + +export interface PlusIconProps extends SVGProps { + /** + * 아이콘 크기 + * @default 24 + */ + size?: number; + /** + * 아이콘의 접근성 레이블 + * 제공하지 않으면 장식용으로 처리됩니다 (aria-hidden="true") + */ + "aria-label"?: string; +} + +export const PlusIcon = ({ + size = 24, + "aria-label": ariaLabel, + ...props +}: PlusIconProps) => { + return ( + + + + + ); +}; + +PlusIcon.displayName = "PlusIcon"; diff --git a/src/icons/index.ts b/src/icons/index.ts new file mode 100644 index 0000000..6028757 --- /dev/null +++ b/src/icons/index.ts @@ -0,0 +1 @@ +export { PlusIcon } from "./PlusIcon"; diff --git a/src/index.ts b/src/index.ts index cadb9ff..57f8ca1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,5 @@ -export * from "@/components/Button/Button"; -export * from "@/tokens"; +export * from "./components/Button/Button"; +export * from "./components/FloatingButton/FloatingButton"; +export * from "./components/Switch/Switch"; +export * from "./icons"; +export * from "./tokens"; diff --git a/src/stories/tokens/Colors.stories.tsx b/src/stories/tokens/Colors.stories.tsx index 731c375..781f46a 100644 --- a/src/stories/tokens/Colors.stories.tsx +++ b/src/stories/tokens/Colors.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { type ColorPalette, colors } from "@/tokens/colors"; +import { type ColorPalette, colors } from "../../tokens/colors.css"; const meta = { title: "Design Tokens/Colors", diff --git a/src/stories/tokens/Radius.stories.tsx b/src/stories/tokens/Radius.stories.tsx index 842cea6..a107828 100644 --- a/src/stories/tokens/Radius.stories.tsx +++ b/src/stories/tokens/Radius.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { colors } from "@/tokens/colors"; -import { type RadiusKey, radius } from "@/tokens/radius"; +import { colors } from "../../tokens/colors.css"; +import { type RadiusKey, radius } from "../../tokens/radius.css"; const meta = { title: "Design Tokens/Radius", diff --git a/src/stories/tokens/Shadows.stories.tsx b/src/stories/tokens/Shadows.stories.tsx index 6a297da..a283a1c 100644 --- a/src/stories/tokens/Shadows.stories.tsx +++ b/src/stories/tokens/Shadows.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { colors } from "@/tokens/colors"; -import { type ShadowKey, shadows } from "@/tokens/shadows"; +import { colors } from "../../tokens/colors.css"; +import { type ShadowKey, shadows } from "../../tokens/shadows.css"; const meta = { title: "Design Tokens/Shadows", diff --git a/src/stories/tokens/TextStyles.stories.tsx b/src/stories/tokens/TextStyles.stories.tsx index e7d6a52..9ad5d90 100644 --- a/src/stories/tokens/TextStyles.stories.tsx +++ b/src/stories/tokens/TextStyles.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { colors } from "@/tokens/colors"; -import { type TextStyleKey, textStyles } from "@/tokens/textStyles"; +import { colors } from "../../tokens/colors.css"; +import { type TextStyleKey, textStyles } from "../../tokens/textStyles.css"; const meta = { title: "Design Tokens/Text Styles", diff --git a/src/stories/tokens/Typography.stories.tsx b/src/stories/tokens/Typography.stories.tsx index a3dda6a..bbc36ff 100644 --- a/src/stories/tokens/Typography.stories.tsx +++ b/src/stories/tokens/Typography.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { colors } from "@/tokens/colors"; -import { typography } from "@/tokens/typography"; +import { colors } from "../../tokens/colors.css"; +import { typography } from "../../tokens/typography.css"; const meta = { title: "Design Tokens/Typography", diff --git a/src/tokens/colors.ts b/src/tokens/colors.css.ts similarity index 100% rename from src/tokens/colors.ts rename to src/tokens/colors.css.ts diff --git a/src/tokens/index.ts b/src/tokens/index.ts index 6808936..ff25194 100644 --- a/src/tokens/index.ts +++ b/src/tokens/index.ts @@ -3,15 +3,15 @@ export { type ColorShade, type Colors, colors, -} from "@/tokens/colors"; -export { type Radius, type RadiusKey, radius } from "@/tokens/radius"; -export { type ShadowKey, type Shadows, shadows } from "@/tokens/shadows"; +} from "./colors.css"; +export { type Radius, type RadiusKey, radius } from "./radius.css"; +export { type ShadowKey, type Shadows, shadows } from "./shadows.css"; export { type TextStyle, type TextStyleKey, type TextStyles, textStyles, -} from "@/tokens/textStyles"; +} from "./textStyles.css"; export { type FontFamily, type FontSize, @@ -20,4 +20,4 @@ export { type LineHeight, type Typography, typography, -} from "@/tokens/typography"; +} from "./typography.css"; diff --git a/src/tokens/radius.ts b/src/tokens/radius.css.ts similarity index 100% rename from src/tokens/radius.ts rename to src/tokens/radius.css.ts diff --git a/src/tokens/shadows.ts b/src/tokens/shadows.css.ts similarity index 100% rename from src/tokens/shadows.ts rename to src/tokens/shadows.css.ts diff --git a/src/tokens/textStyles.ts b/src/tokens/textStyles.css.ts similarity index 98% rename from src/tokens/textStyles.ts rename to src/tokens/textStyles.css.ts index d7f50f7..4ca3569 100644 --- a/src/tokens/textStyles.ts +++ b/src/tokens/textStyles.css.ts @@ -5,7 +5,7 @@ import { type LineHeight, type Typography, typography, -} from "@/tokens/typography"; +} from "./typography.css"; const baseTextStyle = { fontFamily: typography.fontFamily.sans, diff --git a/src/tokens/typography.ts b/src/tokens/typography.css.ts similarity index 100% rename from src/tokens/typography.ts rename to src/tokens/typography.css.ts diff --git a/tsconfig.json b/tsconfig.json index 4c1d61d..e8e4ede 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,10 +17,7 @@ "types": ["vite/client", "@testing-library/jest-dom"], "baseUrl": ".", "paths": { - "@/*": ["./src/*"], - "@/components/*": ["./src/components/*"], - "@/tokens/*": ["./src/tokens/*"], - "@/stories/*": ["./src/stories/*"] + "@/*": ["src/*"] } }, "include": ["src", "vitest.setup.ts", "vitest.shims.d.ts", ".storybook"] diff --git a/vite.config.ts b/vite.config.ts index f082d33..52e9fd9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,44 +1,121 @@ -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react-swc"; import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; -import { resolve } from "path"; +import tsconfigPaths from "vite-tsconfig-paths"; import dts from "vite-plugin-dts"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; +import { playwright } from "@vitest/browser-playwright"; + +const dirname = + typeof __dirname !== "undefined" + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ plugins: [ react(), + tsconfigPaths(), vanillaExtractPlugin(), dts({ insertTypesEntry: true, - include: ["src/**/*"], - exclude: ["src/**/*.stories.ts", "src/**/*.test.ts"], + exclude: [ + "**/*.test.ts", + "**/*.test.tsx", + "**/*.stories.ts", + "**/*.stories.tsx", + ], }), ], - resolve: { - alias: { - "@": resolve(__dirname, "./src"), - "@/components": resolve(__dirname, "./src/components"), - "@/tokens": resolve(__dirname, "./src/tokens"), - "@/stories": resolve(__dirname, "./src/stories"), - }, - }, build: { lib: { - entry: resolve(__dirname, "src/index.ts"), - name: "DaepyroUI", - fileName: (format) => `index.${format === "es" ? "mjs" : "js"}`, + entry: path.resolve(dirname, "src/index.ts"), + name: "DesignSystem", formats: ["es", "cjs"], + fileName: (format) => `index.${format === "es" ? "mjs" : "js"}`, }, rollupOptions: { - external: ["react", "react-dom", "@vanilla-extract/css"], + external: ["react", "react-dom", "react/jsx-runtime"], output: { globals: { react: "React", "react-dom": "ReactDOM", + "react/jsx-runtime": "react/jsx-runtime", + }, + assetFileNames: (assetInfo) => { + if (assetInfo.name?.endsWith(".css")) { + return "styles.css"; + } + return assetInfo.name || "asset"; }, - assetFileNames: "styles.css", }, }, - sourcemap: true, + emptyOutDir: true, + }, + optimizeDeps: { + exclude: [ + "@storybook/addon-docs", + "@storybook/addon-docs/blocks", + "@storybook/addon-vitest/internal/setup-file", + "@storybook/addon-vitest/internal/global-setup", + "@storybook/addon-vitest/internal/test-utils", + ], + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./vitest.setup.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html", "lcov"], + exclude: [ + "node_modules/", + "dist/", + "**/*.stories.{ts,tsx}", + "**/*.test.{ts,tsx}", + "**/*.config.{ts,js}", + ".storybook/", + ], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + projects: [ + { + test: { + name: "unit", + environment: "jsdom", + include: ["**/*.test.{ts,tsx}"], + exclude: ["**/*.stories.{ts,tsx}", "node_modules/**"], + setupFiles: ["./vitest.setup.ts"], + }, + }, + { + extends: true, + plugins: [ + storybookTest({ + configDir: path.join(dirname, ".storybook"), + }), + ], + test: { + name: "storybook", + browser: { + enabled: true, + headless: true, + provider: playwright({}), + instances: [ + { + browser: "chromium", + }, + ], + }, + setupFiles: [".storybook/vitest.setup.ts"], + }, + }, + ], }, }); diff --git a/vitest.config.ts b/vitest.config.ts index d2f7326..b7560ee 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react-swc"; import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; +import tsconfigPaths from "vite-tsconfig-paths"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; @@ -12,15 +13,7 @@ const dirname = // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ - plugins: [react(), vanillaExtractPlugin()], - resolve: { - alias: { - "@": path.resolve(dirname, "./src"), - "@/components": path.resolve(dirname, "./src/components"), - "@/tokens": path.resolve(dirname, "./src/tokens"), - "@/stories": path.resolve(dirname, "./src/stories"), - }, - }, + plugins: [react(), tsconfigPaths(), vanillaExtractPlugin()], optimizeDeps: { exclude: [ "@storybook/addon-docs",