(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",