diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..274db9b
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,44 @@
+{
+ "root": true,
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "sourceType": "module",
+ "ecmaFeatures": {
+ "jsx": true
+ }
+ },
+ "plugins": ["@typescript-eslint"],
+ "extends": [
+ "plugin:react/recommended",
+ "prettier",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:storybook/recommended"
+ ],
+ "overrides": [
+ {
+ "files": ["**/?(*.)+(spec|test).[jt]s?(x)"],
+ "plugins": ["jest", "jest-dom", "testing-library"],
+ "extends": [
+ "plugin:jest/recommended",
+ "plugin:jest-dom/recommended",
+ "plugin:testing-library/react"
+ ]
+ },
+ {
+ "files": ["**/?(*.)+(stories).[jt]s?(x)"],
+ "rules": {
+ "@typescript-eslint/ban-ts-comment": "off"
+ }
+ }
+ ],
+ "rules": {
+ "@typescript-eslint/no-unused-vars": "error",
+ "@typescript-eslint/no-explicit-any": "error",
+ "testing-library/no-await-sync-events": ["off", { "eventModules": ["fire", "user"] }]
+ },
+ "settings": {
+ "react": {
+ "version": "detect"
+ }
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d4de8fc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+*.log
+.DS_Store
+node_modules
+.cache
+dist
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..5fb850b
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,21 @@
+{
+ "printWidth": 120,
+ "tabWidth": 2,
+ "semi": true,
+ "bracketSameLine": false,
+ "singleQuote": false,
+ "endOfLine": "lf",
+ "importOrder": [
+ "^@customTypes/(.*)$",
+ "^@src/(.*)$",
+ "^@assets/(.*)$",
+ "^@constants/(.*)$",
+ "^@utils/(.*)$",
+ "^@atoms/(.*)$",
+ "^@molecules/(.*)$",
+ "^@charts/(.*)$",
+ "^[./]"
+ ],
+ "importOrderSortSpecifiers": true,
+ "plugins": ["@trivago/prettier-plugin-sort-imports"]
+}
diff --git a/.storybook/main.ts b/.storybook/main.ts
new file mode 100644
index 0000000..96896f7
--- /dev/null
+++ b/.storybook/main.ts
@@ -0,0 +1,24 @@
+import type { StorybookConfig } from "@storybook/react-vite";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+const config: StorybookConfig = {
+ stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
+ addons: [
+ "@storybook/addon-onboarding",
+ "@storybook/addon-links",
+ "@storybook/addon-essentials",
+ "@chromatic-com/storybook",
+ "@storybook/addon-interactions",
+ "@storybook/addon-themes",
+ "storybook-addon-pseudo-states",
+ ],
+ framework: {
+ name: "@storybook/react-vite",
+ options: {},
+ },
+ async viteFinal(config) {
+ config.plugins = [...(config.plugins || []), tsconfigPaths()];
+ return config;
+ },
+};
+export default config;
diff --git a/.storybook/preview.ts b/.storybook/preview.ts
new file mode 100644
index 0000000..2279129
--- /dev/null
+++ b/.storybook/preview.ts
@@ -0,0 +1,25 @@
+import { withThemeByClassName } from "@storybook/addon-themes";
+import { Preview } from "@storybook/react";
+import "../src/index.css";
+
+const preview: Preview = {
+ parameters: {
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+ },
+ decorators: [
+ withThemeByClassName({
+ themes: {
+ light: "light",
+ dark: "dark",
+ },
+ defaultTheme: "light",
+ }),
+ ],
+};
+
+export default preview;
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0d6babe
--- /dev/null
+++ b/README.md
@@ -0,0 +1,30 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
+
+- Configure the top-level `parserOptions` property like this:
+
+```js
+export default {
+ // other rules...
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ project: ['./tsconfig.json', './tsconfig.node.json'],
+ tsconfigRootDir: __dirname,
+ },
+}
+```
+
+- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
+- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
+- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..e4b78ea
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React + TS
+
+
+
+
+
+
diff --git a/jest.config.ts b/jest.config.ts
new file mode 100644
index 0000000..a140540
--- /dev/null
+++ b/jest.config.ts
@@ -0,0 +1,21 @@
+import type { Config } from "jest";
+
+const config: Config = {
+ setupFilesAfterEnv: ["/jest.setup.ts"],
+ testEnvironment: "jest-environment-jsdom",
+ moduleNameMapper: {
+ "^@atoms/(.*)$": "/src/components/atoms/$1",
+ "^@molecules/(.*)$": "/src/components/molecules/$1",
+ "^@charts/(.*)$": "/src/charts/$1",
+ "^@hooks/(.*)$": "/src/hooks/$1",
+ "^@contexts/(.*)$": "/src/contexts/$1",
+ "^@constants/(.*)$": "/src/constants/$1",
+ "^@utils/(.*)$": "/src/utils/$1",
+ // 필요한 경우 다른 모듈에 대한 alias 추가
+ },
+ transform: {
+ "^.+\\.(ts|tsx)$": "ts-jest", // 추가된 transform 설정
+ },
+};
+
+export default config;
diff --git a/jest.setup.ts b/jest.setup.ts
new file mode 100644
index 0000000..aa0fdfe
--- /dev/null
+++ b/jest.setup.ts
@@ -0,0 +1,5 @@
+// Optional: configure or set up a testing framework before each test.
+// If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js`
+// Used for __tests__/testing-library.js
+// Learn more: https://github.com/testing-library/jest-dom
+import "@testing-library/jest-dom";
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..71b71e7
--- /dev/null
+++ b/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "chistock-ui",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview",
+ "storybook": "storybook dev -p 6006",
+ "build-storybook": "storybook build"
+ },
+ "dependencies": {
+ "d3": "^7.9.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-if": "^4.1.5",
+ "tailwind-variants": "^0.2.1"
+ },
+ "devDependencies": {
+ "@chromatic-com/storybook": "1.4.0",
+ "@storybook/addon-essentials": "^8.1.1",
+ "@storybook/addon-interactions": "^8.1.1",
+ "@storybook/addon-links": "^8.1.1",
+ "@storybook/addon-onboarding": "^8.1.1",
+ "@storybook/addon-themes": "^8.1.1",
+ "@storybook/blocks": "^8.1.1",
+ "@storybook/react": "^8.1.1",
+ "@storybook/react-vite": "^8.1.1",
+ "@storybook/test": "^8.1.1",
+ "@testing-library/react": "^15.0.7",
+ "@trivago/prettier-plugin-sort-imports": "^4.3.0",
+ "@types/d3": "^7.4.3",
+ "@types/jest": "^29.5.12",
+ "@types/react": "^18.2.66",
+ "@types/react-dom": "^18.2.22",
+ "@typescript-eslint/eslint-plugin": "^7.2.0",
+ "@typescript-eslint/parser": "^7.2.0",
+ "@vitejs/plugin-react": "^4.2.1",
+ "autoprefixer": "^10.4.19",
+ "eslint": "^8.47.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-jest": "^28.5.0",
+ "eslint-plugin-jest-dom": "^5.4.0",
+ "eslint-plugin-react": "^7.34.1",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.4.6",
+ "eslint-plugin-storybook": "^0.8.0",
+ "eslint-plugin-testing-library": "^6.2.2",
+ "jest": "^29.7.0",
+ "jest-environment-jsdom": "^29.7.0",
+ "postcss": "^8.4.38",
+ "prettier": "^3.2.5",
+ "storybook": "^8.1.1",
+ "storybook-addon-pseudo-states": "^3.1.1",
+ "tailwindcss": "^3.4.3",
+ "ts-jest": "^29.1.2",
+ "ts-node": "^10.9.2",
+ "tw-colors": "^3.3.1",
+ "typescript": "^5.2.2",
+ "vite": "^5.2.0",
+ "vite-tsconfig-paths": "^4.3.2"
+ }
+}
diff --git a/postcss.config.cjs b/postcss.config.cjs
new file mode 100644
index 0000000..3ea9307
--- /dev/null
+++ b/postcss.config.cjs
@@ -0,0 +1,3 @@
+module.exports = {
+ plugins: [require('tailwindcss'), require('autoprefixer')],
+};
diff --git a/public/vite.svg b/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/atoms/Button/Button.stories.tsx b/src/components/atoms/Button/Button.stories.tsx
new file mode 100644
index 0000000..166645c
--- /dev/null
+++ b/src/components/atoms/Button/Button.stories.tsx
@@ -0,0 +1,151 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import React from "react";
+import { exceptProperty } from "@utils/exceptProperty";
+import Icon from "@atoms/Icon";
+import Button from "./Button";
+
+const meta = {
+ title: "Atom/Button/Button",
+ component: Button,
+ parameters: {
+ layout: "centered",
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ renderAs: {
+ table: {
+ defaultValue: { summary: "button" },
+ type: {
+ summary: `"button" | "a"`,
+ },
+ },
+ },
+ ...exceptProperty(["onClick", "onMouseEnter", "onTouchStart", "innerRef"]),
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => ,
+};
+
+export const Variant: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const Color: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
+
+export const ButtonRounded: Story = {
+ name: "Rounded",
+ render: () => (
+
+
+
+
+ ),
+};
+
+/**
+ * `size` 속성을 통해 버튼의 크기를 조절할 수 있습니다.
+ *
+ * - `s`
+ * - `m`
+ * - `l`
+ *
+ * 기본값으로 `m`이 설정되어있습니다.
+ */
+export const ButtonSize: Story = {
+ name: "Size",
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+/**
+ * Icon을 단독으로 사용하는 경우 isIconButton 속성을 사용합니다.
+ *
+ * isIconButton 속성을 사용할 경우 `label` 속성을 필수로 작성해야합니다.
+ */
+export const IconButton: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const ButtonState: Story = {
+ name: "State",
+ parameters: {
+ pseudo: {
+ hover: ".hover",
+ active: ".press",
+ focusVisible: ".focus",
+ },
+ },
+ render: () => (
+
+
+
+
+
+
+
+
+
+ ),
+};
diff --git a/src/components/atoms/Button/Button.styles.ts b/src/components/atoms/Button/Button.styles.ts
new file mode 100644
index 0000000..9fa02fe
--- /dev/null
+++ b/src/components/atoms/Button/Button.styles.ts
@@ -0,0 +1,112 @@
+import { tv } from "@utils/customTV";
+import { interactionStateVariants } from "@atoms/InteractionState";
+
+export const buttonVariants = tv({
+ extend: interactionStateVariants,
+ base: "relative flex justify-center items-center py-xs cursor-pointer select-none",
+ variants: {
+ /**
+ * 버튼의 형태
+ * @default secondary
+ * */
+ variant: {
+ filled: "",
+ outlined: "border border-outline",
+ ghost: "",
+ },
+ color: {
+ primary: "",
+ primaryContainer: "",
+ secondary: "",
+ secondaryContainer: "",
+ danger: "",
+ dangerContainer: "",
+ default: "",
+ },
+ /**
+ * 버튼의 크기
+ * @default md
+ * */
+ size: {
+ sm: "h-[32rem] px-sm text-sm gap-xs",
+ md: "h-[40rem] px-md text-md gap-sm",
+ lg: "h-[48rem] px-md text-lg gap-sm",
+ },
+ rounded: {
+ rounded: "rounded-md",
+ circular: "rounded-circle",
+ },
+ isIconButton: {
+ true: "aspect-square p-0",
+ },
+ disabled: {
+ true: "",
+ },
+ focusOutlineOffset: {
+ true: "",
+ },
+ },
+ compoundVariants: [
+ //disabled
+ {
+ disabled: true,
+ variant: ["outlined", "ghost"],
+ color: ["primary", "secondary", "danger"],
+ className: ["interaction:hidden"],
+ },
+ //primary
+ {
+ color: "primary",
+ variant: "filled",
+ className: "bg-primary text-primary-on",
+ },
+ {
+ color: "primary",
+ variant: "outlined",
+ className: "text-primary",
+ },
+ {
+ color: "primary",
+ variant: "ghost",
+ className: "text-primary",
+ },
+ //secondary
+ {
+ color: "secondary",
+ variant: "filled",
+ className: "bg-secondary text-secondary-on",
+ },
+ {
+ color: "secondary",
+ variant: "outlined",
+ className: "text-secondary",
+ },
+ {
+ color: "secondary",
+ variant: "ghost",
+ className: "text-secondary",
+ },
+ //error
+ {
+ color: "danger",
+ variant: "filled",
+ className: "bg-error text-error-on",
+ },
+ {
+ color: "danger",
+ variant: "outlined",
+ className: "text-error",
+ },
+ {
+ color: "danger",
+ variant: "ghost",
+ className: "text-error",
+ },
+ ],
+ defaultVariants: {
+ variant: "filled",
+ color: "secondary",
+ size: "md",
+ rounded: "rounded",
+ },
+});
diff --git a/src/components/atoms/Button/Button.tsx b/src/components/atoms/Button/Button.tsx
new file mode 100644
index 0000000..317d9c6
--- /dev/null
+++ b/src/components/atoms/Button/Button.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+import InteractionState from "@atoms/InteractionState";
+import Slot from "@atoms/Slot";
+import { buttonVariants } from "./Button.styles";
+import type { ButtonAlterAs, ButtonDefault, ButtonProps } from "./Button.types";
+import useButton from "./useButton";
+
+/**
+ * 버튼을 표시합니다.
+ */
+const Button = <
+ T extends ButtonDefault | ButtonAlterAs = ButtonDefault,
+ A extends ButtonAlterAs = ButtonAlterAs,
+>(
+ props: ButtonProps,
+) => {
+ const { children, styleVariant, ...otherProps } = useButton(props);
+
+ return (
+
+
+ {children}
+
+ );
+};
+
+export default Button;
diff --git a/src/components/atoms/Button/Button.types.ts b/src/components/atoms/Button/Button.types.ts
new file mode 100644
index 0000000..eb802fa
--- /dev/null
+++ b/src/components/atoms/Button/Button.types.ts
@@ -0,0 +1,33 @@
+import { VariantProps } from "tailwind-variants";
+import type { PolymorphicPropsWithInnerRefType } from "@customTypes/polymorphicType";
+import type { NonNullableProps } from "@customTypes/utilType";
+import { buttonVariants } from "./Button.styles";
+
+/** Button 컴포넌트 기본 타입 */
+export type ButtonDefault = "button";
+
+/** Button 컴포넌트가 렌더링될 수 있는 다른 타입 */
+export type ButtonAlterAs = "a";
+
+export type ButtonBasePropsType = NonNullableProps> & {
+ /** 버튼 비활성화 여부
+ * @default false
+ */
+ disabled?: boolean;
+ /** 렌더링할 Icon 컴포넌트
+ *
+ * @type Icon
+ */
+} & (
+ | {
+ isIconButton?: false;
+ label?: string;
+ }
+ | { isIconButton: true; label: string }
+ );
+
+/** Button Props 타입 */
+export type ButtonProps<
+ T extends React.ElementType,
+ A extends React.ElementType = T,
+> = PolymorphicPropsWithInnerRefType;
diff --git a/src/components/atoms/Button/__test__/Button.test.tsx b/src/components/atoms/Button/__test__/Button.test.tsx
new file mode 100644
index 0000000..1a9ff4a
--- /dev/null
+++ b/src/components/atoms/Button/__test__/Button.test.tsx
@@ -0,0 +1,239 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import React from "react";
+import Button from "..";
+
+describe("Button", () => {
+ describe("button 태그로 렌더링", () => {
+ it("에러 없이 렌더링되어야 합니다.", () => {
+ render();
+ const button = screen.getByRole("button");
+
+ expect(button.tagName).toBe("BUTTON");
+ expect(button).toHaveTextContent("Button");
+ });
+
+ it("tab으로 focus가 되어야합니다.", async () => {
+ render();
+ const button = screen.getByRole("button");
+
+ expect(button).not.toHaveFocus();
+ await userEvent.tab();
+
+ expect(button).toHaveFocus();
+ });
+
+ it("space 키로 onClick 함수를 실행해야합니다.", async () => {
+ const onClick = jest.fn();
+
+ render();
+ const button = screen.getByRole("button");
+
+ await userEvent.tab();
+ expect(button).toHaveFocus();
+ await userEvent.type(button, "{space}");
+
+ expect(onClick).toHaveBeenCalled();
+ expect(button).toHaveFocus();
+ });
+
+ it("enter 키로 onClick 함수를 실행해야합니다.", async () => {
+ const onClick = jest.fn();
+
+ render();
+ const button = screen.getByRole("button");
+
+ await userEvent.tab();
+ expect(button).toHaveFocus();
+ await userEvent.type(button, "{enter}");
+
+ expect(onClick).toHaveBeenCalled();
+ expect(button).toHaveFocus();
+ });
+
+ it("click 시 onClick 함수를 실행해야합니다.", async () => {
+ const onClick = jest.fn();
+
+ render();
+ const button = screen.getByRole("button");
+
+ await userEvent.click(button);
+
+ expect(onClick).toHaveBeenCalled();
+ expect(button).toHaveFocus();
+ });
+
+ //disabled
+
+ it("disabled 속성이 정상적으로 적용되어야합니다..", async () => {
+ render();
+ const button = screen.getByRole("button");
+
+ expect(button).toBeDisabled();
+ });
+
+ it("disabled시 focus가 잡히지않아야 합니다.", async () => {
+ render();
+ const button = screen.getByRole("button");
+
+ expect(button).not.toHaveFocus();
+ await userEvent.tab();
+
+ expect(button).not.toHaveFocus();
+ });
+
+ it("disabled시 click이 불가능해야합니다.", async () => {
+ const onClick = jest.fn();
+
+ render(
+ ,
+ );
+ const button = screen.getByRole("button");
+
+ await userEvent.click(button);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ //accessibility
+
+ it("disabled 시 aria-disabled를 가져야합니다.", async () => {
+ render();
+ const button = screen.getByRole("button");
+
+ expect(button).toHaveAttribute("aria-disabled");
+ });
+
+ it("아이콘 버튼에서는 aria-label을 가져야합니다.", async () => {
+ render();
+ const button = screen.getByRole("button");
+
+ expect(button).toHaveAttribute("aria-label");
+ });
+ });
+
+ describe("a 태그로 렌더링", () => {
+ it("에러 없이 렌더링되어야 합니다.", () => {
+ render();
+ const button = screen.getByRole("button");
+
+ expect(button.tagName).toBe("A");
+ expect(button).toHaveTextContent("Button");
+ });
+
+ it("tab으로 focus가 되어야합니다.", async () => {
+ render();
+ const button = screen.getByRole("button");
+
+ expect(button).not.toHaveFocus();
+ await userEvent.tab();
+
+ expect(button).toHaveFocus();
+ });
+
+ it("space 키로 onClick 함수를 실행해야합니다.", async () => {
+ const onClick = jest.fn();
+
+ render(
+ ,
+ );
+ const button = screen.getByRole("button");
+
+ await userEvent.tab();
+ expect(button).toHaveFocus();
+ await userEvent.type(button, "{space}");
+
+ expect(onClick).toHaveBeenCalled();
+ expect(button).toHaveFocus();
+ });
+
+ it("enter 키로 onClick 함수를 실행해야합니다.", async () => {
+ const onClick = jest.fn();
+
+ render(
+ ,
+ );
+ const button = screen.getByRole("button");
+
+ await userEvent.tab();
+ expect(button).toHaveFocus();
+ await userEvent.type(button, "{enter}");
+
+ expect(onClick).toHaveBeenCalled();
+ expect(button).toHaveFocus();
+ });
+
+ it("click 시 onClick 함수를 실행해야합니다.", async () => {
+ const onClick = jest.fn();
+
+ render(
+ ,
+ );
+ const button = screen.getByRole("button");
+
+ await userEvent.click(button);
+
+ expect(onClick).toHaveBeenCalled();
+ expect(button).toHaveFocus();
+ });
+
+ //disabled
+
+ it("disabled시 focus가 잡히지않아야 합니다.", async () => {
+ render(
+ ,
+ );
+ const button = screen.getByRole("button");
+
+ expect(button).not.toHaveFocus();
+ await userEvent.tab();
+
+ expect(button).not.toHaveFocus();
+ });
+
+ it("disabled시 click이 불가능해야합니다.", async () => {
+ const onClick = jest.fn();
+
+ render(
+ ,
+ );
+ const button = screen.getByRole("button");
+
+ await userEvent.click(button);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ //accessibility
+
+ it("disabled 시 aria-disabled를 가져야합니다.", async () => {
+ render(
+ ,
+ );
+ const button = screen.getByRole("button");
+
+ expect(button).toHaveAttribute("aria-disabled");
+ });
+
+ it("아이콘 버튼에서는 aria-label을 가져야합니다.", async () => {
+ render();
+ const button = screen.getByRole("button");
+
+ expect(button).toHaveAttribute("aria-label");
+ });
+ });
+});
diff --git a/src/components/atoms/Button/__test__/useButton.test.ts b/src/components/atoms/Button/__test__/useButton.test.ts
new file mode 100644
index 0000000..d9d60f8
--- /dev/null
+++ b/src/components/atoms/Button/__test__/useButton.test.ts
@@ -0,0 +1,65 @@
+import { renderHook } from "@testing-library/react";
+import { type ButtonAlterAs, type ButtonDefault, type ButtonProps, useButton } from "..";
+
+describe("useButton 함수 테스트", () => {
+ it("renderAs가 정의되지 않았을 때, renderAs의 기본값이 button이어야 합니다", () => {
+ const props = {};
+ const { result } = renderHook(() => useButton(props));
+
+ expect(result.current.renderAs).toBe("button");
+ });
+
+ it("renderAs가 a로 정의되었을 때, role이 button이어야 합니다", () => {
+ const props = { renderAs: "a" as const };
+ const { result } = renderHook(() => useButton(props));
+
+ expect(result.current.role).toBe("button");
+ });
+
+ it("renderAs가 'a'이고 disabled가 false일 때, tabIndex가 0이 되어야합니다.", () => {
+ const props = { renderAs: "a" as const, disabled: false };
+ const { result } = renderHook(() => useButton(props));
+
+ expect(result.current.tabIndex).toBe(0);
+ });
+
+ it("disabled가 true일 때, aria-disabled가 true가 되어야합니다.", () => {
+ const props = { disabled: true };
+ const { result } = renderHook(() => useButton(props));
+
+ expect(result.current["aria-disabled"]).toBe(true);
+ });
+
+ it("label이 정의되었을 때, aria-label이 해당 label값을 가져야합니다.", () => {
+ const label = "Test Label";
+ const props = { label };
+ const { result } = renderHook(() => useButton(props));
+
+ expect(result.current["aria-label"]).toBe(label);
+ });
+
+ it("renderAs가 'a'이고 disabled가 false일 때, onClick이 원래의 onClick함수를 가져야합니다.", () => {
+ const onClick = jest.fn();
+ const props = { renderAs: "a" as const, disabled: false, onClick };
+ const { result } = renderHook(() => useButton(props));
+
+ expect(result.current.onClick).toBe(onClick);
+ });
+
+ it("renderAs가 'a'이고 disabled가 true일 때, onClick이 undefined가 되어야합니다.", () => {
+ const props = { renderAs: "a" as const, disabled: true, onClick: jest.fn() };
+ const { result } = renderHook(() => useButton(props));
+
+ expect(result.current.onClick).toBeUndefined();
+ });
+
+ it("otherProps가 정의되었을 때, 결과 객체에 otherProps가 포함되어야합니다.", () => {
+ const otherProps = { dataTestId: "test" };
+ const props = { ...otherProps };
+ const { result } = renderHook(() =>
+ useButton(props as ButtonProps),
+ );
+
+ expect(result.current).toHaveProperty("dataTestId", "test");
+ });
+});
diff --git a/src/components/atoms/Button/index.ts b/src/components/atoms/Button/index.ts
new file mode 100644
index 0000000..6542c0d
--- /dev/null
+++ b/src/components/atoms/Button/index.ts
@@ -0,0 +1,20 @@
+import Button from "./Button";
+import { buttonVariants } from "./Button.styles";
+import useButton from "./useButton";
+
+// export type
+export type {
+ ButtonProps,
+ ButtonBasePropsType,
+ ButtonDefault,
+ ButtonAlterAs,
+} from "./Button.types";
+
+// export hook
+export { useButton };
+
+// export styles
+export { buttonVariants };
+
+// export component
+export default Button;
diff --git a/src/components/atoms/Button/useButton.ts b/src/components/atoms/Button/useButton.ts
new file mode 100644
index 0000000..914f096
--- /dev/null
+++ b/src/components/atoms/Button/useButton.ts
@@ -0,0 +1,47 @@
+import type { ButtonAlterAs, ButtonDefault, ButtonProps } from "./Button.types";
+
+/** Button에서 사용하는 tv 속성들만 따로 빼서 정리 후 styleVariant에 넣어 return하는 hook */
+const useButtonStyleVariant = (props: ButtonProps) => {
+ const { variant, color, size, rounded, isIconButton, disabled, className, ...otherProps } = props;
+
+ return {
+ styleVariant: {
+ className,
+ variant,
+ color,
+ size,
+ rounded,
+ disabled,
+ isIconButton,
+ focusOutlineOffset: true,
+ },
+ disabled,
+ ...otherProps,
+ };
+};
+
+const useButtonDefaultProps = (props: ButtonProps) => {
+ const { renderAs, disabled, role, tabIndex, onClick, label, ...otherProps } = props;
+
+ const isAnchor = renderAs === "a";
+
+ return {
+ renderAs: renderAs || "button",
+ disabled,
+ role: isAnchor ? "button" : role,
+ tabIndex: isAnchor && !disabled ? 0 : tabIndex,
+ onClick: isAnchor && disabled ? undefined : onClick,
+ "aria-label": label,
+ "aria-disabled": disabled,
+ ...otherProps,
+ };
+};
+
+const useButton = (props: ButtonProps) => {
+ const { styleVariant, ...defaultProps } = useButtonStyleVariant(props);
+ const convertProps = useButtonDefaultProps(defaultProps);
+
+ return { ...convertProps, styleVariant };
+};
+
+export default useButton;
diff --git a/src/components/atoms/Chip/Chip.stories.tsx b/src/components/atoms/Chip/Chip.stories.tsx
new file mode 100644
index 0000000..a39a16f
--- /dev/null
+++ b/src/components/atoms/Chip/Chip.stories.tsx
@@ -0,0 +1,87 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import React from "react";
+import Chip from "./Chip";
+
+const meta = {
+ title: "Atom/Chip",
+ component: Chip,
+ parameters: {
+ layout: "centered",
+ },
+ tags: ["autodocs"],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => Select A,
+};
+
+export const Disabled: Story = {
+ render: () => (
+
+ Select A
+
+ ),
+};
+
+export const ChipGroup: Story = {
+ render: () => (
+
+ Select A
+ Select B
+ Select C
+ Select D
+
+ ),
+};
+
+export const DefaultSelected: Story = {
+ render: () => (
+
+ Select A
+ Select B
+ Select C
+ Select D
+
+ ),
+};
+
+export const Variant: Story = {
+ render: () => (
+
+
+ Select A
+ Select B
+ Select C
+ Select D
+
+
+
+ Select A
+
+
+ Select B
+
+
+ Select C
+
+
+ Select D
+
+
+
+ ),
+};
+
+export const MultiSelect: Story = {
+ render: () => (
+
+ Select A
+ Select B
+ Select C
+ Select D
+
+ ),
+};
diff --git a/src/components/atoms/Chip/Chip.styles.ts b/src/components/atoms/Chip/Chip.styles.ts
new file mode 100644
index 0000000..e079ae3
--- /dev/null
+++ b/src/components/atoms/Chip/Chip.styles.ts
@@ -0,0 +1,38 @@
+import { tv } from "@utils/customTV";
+import { buttonVariants } from "@atoms/Button";
+
+export const chipVariants = tv({
+ extend: buttonVariants,
+ variants: {
+ variant: {
+ filled: "",
+ outlined: "text-surface-on-variant",
+ },
+ selected: {
+ true: "",
+ },
+ },
+ defaultVariants: {
+ size: "sm",
+ rounded: "circular",
+ },
+ compoundVariants: [
+ {
+ selected: false,
+ variant: "filled",
+ className: "bg-surface-container-highest text-surface-on-variant",
+ },
+ {
+ selected: false,
+ variant: "outlined",
+ className: "text-surface-on-variant",
+ },
+ {
+ selected: true,
+ variant: ["filled", "outlined"],
+ className: "bg-secondary text-secondary-on",
+ },
+ ],
+});
+
+export default chipVariants;
diff --git a/src/components/atoms/Chip/Chip.tsx b/src/components/atoms/Chip/Chip.tsx
new file mode 100644
index 0000000..364788f
--- /dev/null
+++ b/src/components/atoms/Chip/Chip.tsx
@@ -0,0 +1,33 @@
+import { SelectedListProvider } from "@contexts/SelectedContext/SelectedListContext";
+import React from "react";
+import InteractionState from "@atoms/InteractionState";
+import Slot from "@atoms/Slot/Slot";
+import chipVariants from "./Chip.styles";
+import type { ChipGroupProps, ChipProps } from "./Chip.types";
+import useChip from "./useChip";
+
+// TODO dismiss 기능 만들기 role: toolbar?
+const ChipGroup = ({ multiSelect, defaultSelected, ...props }: ChipGroupProps) => {
+ return (
+
+
+
+ );
+};
+
+// TODO dismiss 기능 만들기
+// TODO interaction이 아니면 span interaction이면 button
+const Chip = (props: ChipProps) => {
+ const { styleVariant, children, ...otherProps } = useChip(props);
+
+ return (
+
+
+ {children}
+
+ );
+};
+
+Chip.ChipGroup = ChipGroup;
+
+export default Chip;
diff --git a/src/components/atoms/Chip/Chip.types.ts b/src/components/atoms/Chip/Chip.types.ts
new file mode 100644
index 0000000..fa1beb8
--- /dev/null
+++ b/src/components/atoms/Chip/Chip.types.ts
@@ -0,0 +1,12 @@
+import type { SelectedListProviderProps } from "@contexts/SelectedContext";
+import type { VariantProps } from "tailwind-variants";
+import type { ComponentPropsWithInnerRef } from "@customTypes/utilType";
+import chipVariants from "./Chip.styles";
+
+export type ChipProps = ComponentPropsWithInnerRef<"button"> &
+ Omit, "color" | "variant"> & {
+ value: string;
+ variant?: "filled" | "outlined";
+ };
+
+export type ChipGroupProps = SelectedListProviderProps;
diff --git a/src/components/atoms/Chip/__test__/Chip.test.tsx b/src/components/atoms/Chip/__test__/Chip.test.tsx
new file mode 100644
index 0000000..d11fdc2
--- /dev/null
+++ b/src/components/atoms/Chip/__test__/Chip.test.tsx
@@ -0,0 +1,130 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import React from "react";
+import Chip from "../Chip";
+
+describe("Chip 및 ChipGroup 컴포넌트 테스트", () => {
+ it("Chip 컴포넌트는 정상적으로 렌더링되어야 합니다.", () => {
+ render(Chip);
+ const chipElement = screen.getByRole("button");
+ expect(chipElement).toBeInTheDocument();
+ });
+
+ it("ChipGroup 컴포넌트는 정상적으로 렌더링되어야 합니다.", () => {
+ render();
+ const chipGroupElement = screen.getByRole("group");
+ expect(chipGroupElement).toBeInTheDocument();
+ });
+
+ it("ChipGroup에 Chip을 추가하고 정상적으로 렌더링되어야 합니다.", () => {
+ render(
+
+ Chip 1
+ Chip 2
+ ,
+ );
+ const chipElements = screen.getAllByRole("button");
+ expect(chipElements).toHaveLength(2);
+ });
+
+ it("Chip 클릭 시 상태가 변경 및 해제되어야 합니다.", async () => {
+ render(
+
+ Chip
+ ,
+ );
+ const chipElement = screen.getByRole("button");
+ expect(chipElement).toHaveAttribute("aria-selected", "false");
+
+ await userEvent.click(chipElement);
+ expect(chipElement).toHaveAttribute("aria-selected", "true");
+
+ await userEvent.click(chipElement);
+ expect(chipElement).toHaveAttribute("aria-selected", "false");
+ });
+
+ it("multiSelect가 아닐 경우, Chip 클릭 시 기존 선택된 Chip 상태가 해제되어야 합니다.", async () => {
+ render(
+
+ Chip 1
+ Chip 2
+ ,
+ );
+ const chipElement1 = screen.getByText("Chip 1");
+ const chipElement2 = screen.getByText("Chip 2");
+
+ await userEvent.click(chipElement1);
+ expect(chipElement1).toHaveAttribute("aria-selected", "true");
+
+ await userEvent.click(chipElement2);
+ expect(chipElement2).toHaveAttribute("aria-selected", "true");
+ expect(chipElement1).toHaveAttribute("aria-selected", "false");
+ });
+
+ it("multiSelect이 true인 경우, Chip 클릭 시 클릭한 모든 Chip이 선택되어야합니다.", async () => {
+ render(
+
+ Chip 1
+ Chip 2
+ ,
+ );
+ const chipElement1 = screen.getByText("Chip 1");
+ const chipElement2 = screen.getByText("Chip 2");
+
+ await userEvent.click(chipElement1);
+ expect(chipElement1).toHaveAttribute("aria-selected", "true");
+
+ await userEvent.click(chipElement2);
+ expect(chipElement2).toHaveAttribute("aria-selected", "true");
+ expect(chipElement1).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("ChipGroup에 Chip을 추가하고 Chip을 클릭 시 클릭 이벤트가 올바르게 전달되어야 합니다.", async () => {
+ const handleClick = jest.fn();
+ const handleClick2 = jest.fn();
+ render(
+
+
+ Chip 1
+
+
+ Chip 2
+
+ ,
+ );
+ const chipElements = screen.getAllByRole("button");
+
+ await userEvent.click(chipElements[0]);
+ expect(handleClick).toHaveBeenCalled();
+ expect(handleClick2).not.toHaveBeenCalled();
+ });
+
+ it("defaultSelected prop이 있는 경우 초기 선택 상태가 올바르게 설정되어야 합니다.", () => {
+ render(
+
+ Chip 1
+ Chip 2
+ ,
+ );
+ const chipElements = screen.getAllByRole("button");
+
+ expect(chipElements[0]).toHaveAttribute("aria-selected", "true");
+ expect(chipElements[1]).toHaveAttribute("aria-selected", "false");
+ });
+
+ it("defaultSelected prop이 배열인 경우 초기 선택 상태가 올바르게 설정되어야 합니다.", () => {
+ render(
+
+ Chip 1
+ Chip 2
+ Chip 3
+ ,
+ );
+ const chipElements = screen.getAllByRole("button");
+
+ expect(chipElements[0]).toHaveAttribute("aria-selected", "true");
+ expect(chipElements[1]).toHaveAttribute("aria-selected", "true");
+ expect(chipElements[2]).toHaveAttribute("aria-selected", "false");
+ });
+});
diff --git a/src/components/atoms/Chip/index.ts b/src/components/atoms/Chip/index.ts
new file mode 100644
index 0000000..d392b64
--- /dev/null
+++ b/src/components/atoms/Chip/index.ts
@@ -0,0 +1,12 @@
+import Chip from "./Chip";
+import chipVariants from "./Chip.styles";
+import type { ChipGroupProps, ChipProps } from "./Chip.types";
+import useChip from "./useChip";
+
+export type { ChipGroupProps, ChipProps };
+
+export { chipVariants };
+
+export { useChip };
+
+export default Chip;
diff --git a/src/components/atoms/Chip/useChip.ts b/src/components/atoms/Chip/useChip.ts
new file mode 100644
index 0000000..32f7158
--- /dev/null
+++ b/src/components/atoms/Chip/useChip.ts
@@ -0,0 +1,13 @@
+import useSelectedList from "@hooks/useSelected";
+import { ButtonProps, useButton } from "@atoms/Button";
+import { ChipProps } from "./Chip.types";
+
+const useChip = (props: ChipProps) => {
+ const buttonProps = useButton(props as ButtonProps<"button">);
+
+ const selectedProps = useSelectedList(buttonProps);
+
+ return selectedProps;
+};
+
+export default useChip;
diff --git a/src/components/atoms/Divider/Divider.stories.tsx b/src/components/atoms/Divider/Divider.stories.tsx
new file mode 100644
index 0000000..7424a6d
--- /dev/null
+++ b/src/components/atoms/Divider/Divider.stories.tsx
@@ -0,0 +1,52 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import React from "react";
+import Divider from "./Divider";
+
+const meta = {
+ title: "Atom/Divider",
+ component: Divider,
+ parameters: {
+ layout: "centered",
+ a11y: { disable: true },
+ },
+ tags: ["autodocs"],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+ ),
+};
+
+export const Direction: Story = {
+ render: () => (
+
+ ),
+};
+
+/**
+ * Playground에서 Divider 컴포넌트를 직접 테스트해보세요.
+ *
+ * [Divider Playground로 이동](?path=/story/atom-divider--playground)
+ */
+export const Playground: Story = {
+ args: {
+ className: "w-[200rem]",
+ },
+ render: (args) => (
+ <>
+
+ >
+ ),
+};
diff --git a/src/components/atoms/Divider/Divider.styles.ts b/src/components/atoms/Divider/Divider.styles.ts
new file mode 100644
index 0000000..8a2d802
--- /dev/null
+++ b/src/components/atoms/Divider/Divider.styles.ts
@@ -0,0 +1,39 @@
+import { tv } from "@utils/customTV";
+
+export const dividerVariants = tv({
+ base: [
+ "flex",
+ "text-body2",
+ "text-surface-on-variant",
+ "before:grow",
+ "before:border",
+ "after:grow",
+ "after:border",
+ "before:border-outline-variant",
+ "after:border-outline-variant",
+ ],
+ variants: {
+ vertical: {
+ false: ["items-center", "before:h-[0rem]", "after:h-[0rem]"],
+ true: ["flex-col", "justify-center", "items-center", "before:w-[0rem]", "after:w-[0rem]"],
+ },
+ hasContent: {
+ true: "",
+ },
+ },
+ compoundVariants: [
+ {
+ vertical: false,
+ hasContent: true,
+ className: "before:mr-sm after:ml-sm",
+ },
+ {
+ vertical: true,
+ hasContent: true,
+ className: "before:mb-sm after:mt-sm",
+ },
+ ],
+ defaultVariants: {
+ vertical: false,
+ },
+});
diff --git a/src/components/atoms/Divider/Divider.tsx b/src/components/atoms/Divider/Divider.tsx
new file mode 100644
index 0000000..457f9a4
--- /dev/null
+++ b/src/components/atoms/Divider/Divider.tsx
@@ -0,0 +1,22 @@
+import React from "react";
+import Slot from "@atoms/Slot/Slot";
+import { dividerVariants } from "./Divider.styles";
+import type { DividerProps } from "./Divider.types";
+import useDivider from "./useDivider";
+
+/**
+ * 영역을 구분하기 위한 구분선을 표시하는 컴포넌트입니다.
+ *
+ * `div` 태그를 사용하여 구현됩니다.
+ */
+const Divider = (props: DividerProps) => {
+ const { styleVariant, children, ...otherProps } = useDivider(props);
+
+ return (
+
+ {children && {children}
}
+
+ );
+};
+
+export default Divider;
diff --git a/src/components/atoms/Divider/Divider.types.ts b/src/components/atoms/Divider/Divider.types.ts
new file mode 100644
index 0000000..65b11f7
--- /dev/null
+++ b/src/components/atoms/Divider/Divider.types.ts
@@ -0,0 +1,21 @@
+import type { ComponentPropsWithInnerRef } from "@customTypes/utilType";
+
+/** Divider이 가지는 공통 속성 타입*/
+type DividerBaseType = {
+ /** Divider의 방향을 결정하는 속성
+ *
+ * @default 'false'
+ */
+ vertical?: boolean;
+};
+
+/**
+ * Divider에 들어가는 Props 타입
+ *
+ * direction에 따라서 HorizontalDividerType 혹은 VerticalDividerType이 포함됩니다.
+ *
+ * @example
+ * direction = 'horizontal' : HorizontalDividerType & DividerBaseType & divProps
+ * direction = 'vertical' : VerticalDividerType & DividerBaseType & divProps
+ * */
+export type DividerProps = DividerBaseType & ComponentPropsWithInnerRef<"div">;
diff --git a/src/components/atoms/Divider/__test__/Divider.test.tsx b/src/components/atoms/Divider/__test__/Divider.test.tsx
new file mode 100644
index 0000000..9c510a9
--- /dev/null
+++ b/src/components/atoms/Divider/__test__/Divider.test.tsx
@@ -0,0 +1,40 @@
+import { render, screen } from "@testing-library/react";
+import React from "react";
+import Divider from "@atoms/Divider/Divider";
+
+describe("Divider 컴포넌트", () => {
+ // eslint-disable-next-line jest/expect-expect
+ it("에러 없이 렌더링되어야 합니다.", () => {
+ render();
+ });
+
+ it("커스텀 클래스가 제대로 적용되어야 합니다.", () => {
+ render();
+ const dividerElement = screen.getByRole("separator");
+ expect(dividerElement).toHaveClass("custom-divider");
+ });
+
+ it("수직 구분선일 경우, aria-orientation이 'vertical'이어야 합니다.", () => {
+ render();
+ const dividerElement = screen.getByRole("separator");
+ expect(dividerElement).toHaveAttribute("aria-orientation", "vertical");
+ });
+
+ it("수평 구분선일 경우, aria-orientation이 'horizontal'이어야 합니다.", () => {
+ render();
+ const dividerElement = screen.getByRole("separator");
+ expect(dividerElement).toHaveAttribute("aria-orientation", "horizontal");
+ });
+
+ it("자식 요소가 있는 경우, 자식 요소가 렌더링되어야 합니다.", () => {
+ render(내용);
+ const childElement = screen.getByText("내용");
+ expect(childElement).toBeInTheDocument();
+ });
+
+ it("자식 요소가 없는 경우, 내용이 렌더링되지 않아야 합니다.", () => {
+ render();
+ const childElement = screen.queryByText("내용");
+ expect(childElement).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/Divider/index.ts b/src/components/atoms/Divider/index.ts
new file mode 100644
index 0000000..03b4a61
--- /dev/null
+++ b/src/components/atoms/Divider/index.ts
@@ -0,0 +1,12 @@
+import Divider from "./Divider";
+import { dividerVariants } from "./Divider.styles";
+import { DividerProps } from "./Divider.types";
+import useDivider from "./useDivider";
+
+export type { DividerProps };
+
+export { useDivider };
+
+export { dividerVariants };
+
+export default Divider;
diff --git a/src/components/atoms/Divider/useDivider.ts b/src/components/atoms/Divider/useDivider.ts
new file mode 100644
index 0000000..cf6b503
--- /dev/null
+++ b/src/components/atoms/Divider/useDivider.ts
@@ -0,0 +1,31 @@
+import type { DividerProps } from "./Divider.types";
+
+const useDividerStyle = (props: DividerProps) => {
+ const { className, vertical, color, children, ...otherProps } = props;
+
+ return {
+ styleVariant: {
+ className,
+ vertical,
+ color,
+ hasContent: children ? true : false,
+ },
+ children,
+ ...otherProps,
+ };
+};
+
+const useDivider = (props: DividerProps) => {
+ const { styleVariant, ...otherProps } = useDividerStyle(props);
+
+ const { vertical } = styleVariant;
+
+ return {
+ role: "separator",
+ "aria-orientation": vertical ? ("vertical" as const) : ("horizontal" as const),
+ styleVariant,
+ ...otherProps,
+ };
+};
+
+export default useDivider;
diff --git a/src/components/atoms/ExpandTile/ExpandTile.stories.tsx b/src/components/atoms/ExpandTile/ExpandTile.stories.tsx
new file mode 100644
index 0000000..27c2e8d
--- /dev/null
+++ b/src/components/atoms/ExpandTile/ExpandTile.stories.tsx
@@ -0,0 +1,159 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import React from "react";
+import { exceptProperty } from "@utils/exceptProperty";
+import ExpandTile from "../ExpandTile/ExpandTile";
+
+const meta = {
+ title: "Atom/Tile/ExpandTile",
+ component: ExpandTile,
+ parameters: {
+ layout: "centered",
+ },
+ argTypes: {
+ renderAs: {
+ table: {
+ defaultValue: { summary: `"div"` },
+ },
+ },
+ ...exceptProperty(["innerRef"]),
+ },
+ tags: ["autodocs"],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ExpaneTileChildren = () => {
+ return (
+
+
+ 제목
+ 본문
+
+
+ 제목
+ 본문
+
+
+ 제목
+ 본문
+
+
+ 제목
+ 본문
+
+
+ 제목
+ 본문
+
+
+ 제목
+ 본문
+
+
+ 제목
+ 본문
+
+
+ 제목
+ 본문
+
+
+ 제목
+ 본문
+
+
+ 제목
+ 본문
+
+
+ 제목
+ 본문
+
+
+ 제목
+ 본문
+
+
+ );
+};
+
+/**
+ * ExpandTile은 Tile을 확장하여 만든 컴포넌트로 대부분의 속성을 Tile과 공유합니다.
+ *
+ * 하단에 나오지 않은 속성에 대한 설명은 Tile 컴포넌트를 참조하세요.
+ *
+ * [Tile 컴포넌트 가기](?path=/docs/atom-tile-tile--docs)
+ */
+export const Default: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const Expanded: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+/**
+ * `max-h-` class를 통해 확장 전 타일의 길이를 지정할 수 있습니다.
+ *
+ * **필수적으로 지정해야하는 속성**입니다.
+ */
+export const ExpandTileCollapseHeight: Story = {
+ name: "CollapseHeight",
+ render: () => (
+
+
+
+
+
+
+
+
+ ),
+};
+
+export const HideWithGradient: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const ChangeButtonspan: Story = {
+ render: () => (
+
+
+
+ ),
+};
+/**
+ * Playground에서 Expand Tile 컴포넌트를 직접 테스트해보세요.
+ *
+ * [Expand Tile Playground로 이동](?path=/story/atom-tile-expandtile--playground)
+ */
+export const Playground: Story = {
+ args: {
+ className: "w-[200rem] max-h-[256rem]",
+ },
+ argTypes: {
+ renderAs: {
+ options: ["div", "header", "footer", "nav", "aside", "main", "section", "article"],
+ control: { type: "select" },
+ },
+ },
+ parameters: { a11y: { disable: true } },
+ render: (args) => (
+
+
+
+ ),
+};
diff --git a/src/components/atoms/ExpandTile/ExpandTile.styles.ts b/src/components/atoms/ExpandTile/ExpandTile.styles.ts
new file mode 100644
index 0000000..39c27c4
--- /dev/null
+++ b/src/components/atoms/ExpandTile/ExpandTile.styles.ts
@@ -0,0 +1,27 @@
+import { tv } from "@utils/customTV";
+import { tileVariants } from "../Tile";
+
+export const expandTileVariants = tv({
+ extend: tileVariants,
+ base: "flex flex-col gap-sm transition-[max-height]",
+ variants: {
+ padding: {
+ none: "",
+ "3xs": "px-3xs pt-3xs pb-[1rem]",
+ "2xs": "px-2xs pt-2xs pb-3xs",
+ xs: "px-xs pt-xs pb-2xs",
+ s: "px-sm pt-sm pb-[6rem]",
+ m: "px-md pt-md pb-xs",
+ l: "px-lg pt-lg pb-[10rem]",
+ xl: "px-xl pt-xl pb-sm",
+ "2xl": "px-2xl pt-2xl pb-md",
+ "3xl": "px-3xl pt-3xl pb-lg",
+ },
+ isExpand: {
+ true: "!max-h-screen",
+ },
+ },
+ defaultVariants: {
+ padding: "2xl",
+ },
+});
diff --git a/src/components/atoms/ExpandTile/ExpandTile.tsx b/src/components/atoms/ExpandTile/ExpandTile.tsx
new file mode 100644
index 0000000..53f08fb
--- /dev/null
+++ b/src/components/atoms/ExpandTile/ExpandTile.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import Button from "@atoms/Button";
+import Slot from "@atoms/Slot";
+import { TileAlterAs, TileDefault } from "@atoms/Tile";
+import { expandTileVariants } from "./ExpandTile.styles";
+import type { ExpandTileProps } from "./ExpandTile.types";
+import useExpandTile from "./useExpandTile";
+
+/** 길이를 확장할 수 있는 타일 */
+const ExpandTile = <
+ T extends TileDefault | TileAlterAs = TileDefault,
+ A extends TileAlterAs = TileAlterAs,
+>(
+ props: ExpandTileProps,
+) => {
+ {
+ const { styleVariant, children, showGradient, buttonProps, ...otherProps } =
+ useExpandTile(props);
+
+ return (
+
+
+ {children}
+ {showGradient && (
+
+ )}
+
+
+
+
+
+ );
+ }
+};
+
+export default ExpandTile;
diff --git a/src/components/atoms/ExpandTile/ExpandTile.types.ts b/src/components/atoms/ExpandTile/ExpandTile.types.ts
new file mode 100644
index 0000000..42da473
--- /dev/null
+++ b/src/components/atoms/ExpandTile/ExpandTile.types.ts
@@ -0,0 +1,16 @@
+import { VariantProps } from "tailwind-variants";
+import { PolymorphicPropsWithInnerRefType } from "@customTypes/polymorphicType";
+import { expandTileVariants } from "./ExpandTile.styles";
+
+export type ExpandTileBaseProps = {
+ closeText?: string;
+ expandText?: string;
+ hideWithGradient?: boolean;
+ expanded?: boolean;
+};
+
+export type ExpandTileProps<
+ T extends React.ElementType,
+ A extends React.ElementType = T,
+> = PolymorphicPropsWithInnerRefType &
+ VariantProps;
diff --git a/src/components/atoms/ExpandTile/__test__/ExpandTile.test.tsx b/src/components/atoms/ExpandTile/__test__/ExpandTile.test.tsx
new file mode 100644
index 0000000..8b3fd22
--- /dev/null
+++ b/src/components/atoms/ExpandTile/__test__/ExpandTile.test.tsx
@@ -0,0 +1,60 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import React from "react";
+import ExpandTile from "../ExpandTile";
+
+describe("ExpandTile", () => {
+ it("렌더링 테스트", () => {
+ render();
+ expect(screen.getByText("더 보기")).toBeInTheDocument();
+ });
+
+ it("본문 렌더링 테스트", () => {
+ render(본문);
+
+ expect(screen.getByText("본문")).toBeInTheDocument();
+ });
+
+ it("더 보기 버튼 클릭 후 텍스트 변경 테스트", async () => {
+ render();
+
+ const button = screen.getByRole("button");
+ await userEvent.click(button);
+
+ expect(button).toHaveTextContent("닫기");
+ });
+
+ it("ARIA 속성 테스트", async () => {
+ render();
+
+ const button = screen.getByRole("button");
+ expect(button).toHaveAttribute("aria-expanded", "false");
+
+ await userEvent.click(button);
+
+ expect(button).toHaveAttribute("aria-expanded", "true");
+ });
+
+ it("사용자 정의 closeText 및 expandText 테스트", async () => {
+ render();
+
+ const button = screen.getByRole("button");
+ expect(screen.getByText("열기 버튼")).toBeInTheDocument();
+ await userEvent.click(button);
+
+ expect(screen.getByText("닫기 버튼")).toBeInTheDocument();
+ });
+
+ it("초기 상태가 확장된 상태로 시작하는지 테스트", () => {
+ render();
+ expect(screen.getByText("닫기")).toBeInTheDocument();
+ });
+
+ it("다른 props가 올바르게 전달되는지 테스트", () => {
+ render();
+
+ const component = screen.getByTestId("custom-test-id");
+
+ expect(component).toHaveClass("custom-class");
+ });
+});
diff --git a/src/components/atoms/ExpandTile/__test__/useExpandTile.test.ts b/src/components/atoms/ExpandTile/__test__/useExpandTile.test.ts
new file mode 100644
index 0000000..ca2f3a8
--- /dev/null
+++ b/src/components/atoms/ExpandTile/__test__/useExpandTile.test.ts
@@ -0,0 +1,44 @@
+import { act, renderHook } from "@testing-library/react";
+import useExpandTile from "../useExpandTile";
+
+describe("useExpandTile", () => {
+ it("초기화 테스트", () => {
+ const { result } = renderHook(() => useExpandTile({}));
+ expect(result.current.styleVariant.isExpand).toBe(false);
+ });
+
+ it("상태 토글 테스트", () => {
+ const { result } = renderHook(() => useExpandTile({}));
+ const { buttonProps } = result.current;
+
+ expect(result.current.styleVariant.isExpand).toBe(false);
+
+ act(() => {
+ buttonProps.onClick();
+ });
+
+ expect(result.current.styleVariant.isExpand).toBe(true);
+ });
+
+ it("aria 속성 테스트", () => {
+ const { result } = renderHook(() => useExpandTile({}));
+ const { buttonProps } = result.current;
+
+ expect(result.current.buttonProps["aria-expanded"]).toBe(false);
+ act(() => {
+ buttonProps.onClick();
+ });
+ expect(result.current.buttonProps["aria-expanded"]).toBe(true);
+ });
+
+ it("그라데이션 표시 테스트", () => {
+ const { result } = renderHook(() => useExpandTile({ hideWithGradient: true }));
+ const { buttonProps } = result.current;
+
+ expect(result.current.showGradient).toBe(true);
+ act(() => {
+ buttonProps.onClick();
+ });
+ expect(result.current.showGradient).toBe(false);
+ });
+});
diff --git a/src/components/atoms/ExpandTile/index.ts b/src/components/atoms/ExpandTile/index.ts
new file mode 100644
index 0000000..681c33f
--- /dev/null
+++ b/src/components/atoms/ExpandTile/index.ts
@@ -0,0 +1,12 @@
+import ExpandTile from "./ExpandTile";
+import { expandTileVariants } from "./ExpandTile.styles";
+import type { ExpandTileBaseProps, ExpandTileProps } from "./ExpandTile.types";
+import useExpandTile from "./useExpandTile";
+
+export type { ExpandTileProps, ExpandTileBaseProps };
+
+export { expandTileVariants };
+
+export { useExpandTile };
+
+export default ExpandTile;
diff --git a/src/components/atoms/ExpandTile/useExpandTile.ts b/src/components/atoms/ExpandTile/useExpandTile.ts
new file mode 100644
index 0000000..774f97e
--- /dev/null
+++ b/src/components/atoms/ExpandTile/useExpandTile.ts
@@ -0,0 +1,46 @@
+import { useState } from "react";
+import { TileAlterAs, TileDefault } from "@atoms/Tile";
+import { ExpandTileProps } from "./ExpandTile.types";
+
+const useExpandTileStyle = (
+ props: ExpandTileProps,
+ isExpand: boolean,
+) => {
+ const { padding, className, ...otherProps } = props;
+
+ return { styleVariant: { padding, isExpand, className }, ...otherProps };
+};
+
+const useExpandTile = ({ expanded, ...props }: ExpandTileProps) => {
+ const [isExpand, setIsExpand] = useState(expanded || false);
+
+ const changeTileState = () => {
+ setIsExpand((current) => !current);
+ };
+
+ const {
+ styleVariant,
+ closeText = "닫기",
+ expandText = "더 보기",
+ hideWithGradient,
+ ...otherProps
+ } = useExpandTileStyle(props, isExpand);
+
+ const showGradient = !isExpand && hideWithGradient;
+ const BUTTON_TEXT = isExpand ? closeText : expandText;
+
+ const buttonProps = {
+ children: [BUTTON_TEXT],
+ "aria-expanded": isExpand,
+ onClick: changeTileState,
+ };
+
+ return {
+ styleVariant,
+ buttonProps,
+ showGradient,
+ ...otherProps,
+ };
+};
+
+export default useExpandTile;
diff --git a/src/components/atoms/FloatingButton/FloatingButton.styles.ts b/src/components/atoms/FloatingButton/FloatingButton.styles.ts
new file mode 100644
index 0000000..414dba0
--- /dev/null
+++ b/src/components/atoms/FloatingButton/FloatingButton.styles.ts
@@ -0,0 +1,16 @@
+import { tv } from "@utils/customTV";
+
+export const floatingButtonVariant = tv({
+ base: "absolute shadow-floating z-50",
+ variants: {
+ variant: {
+ primary: "bg-primary text-primary-on",
+ primaryContainer: "bg-primary-container text-primary-container-on",
+ secondary: "bg-secondary text-secondary-on",
+ secondaryContainer: "bg-secondary-container text-secondary-container-on",
+ },
+ },
+ defaultVariants: {
+ variant: "primary",
+ },
+});
diff --git a/src/components/atoms/FloatingButton/FloatingButton.tsx b/src/components/atoms/FloatingButton/FloatingButton.tsx
new file mode 100644
index 0000000..5349e9c
--- /dev/null
+++ b/src/components/atoms/FloatingButton/FloatingButton.tsx
@@ -0,0 +1,16 @@
+import React from "react";
+import Button, { type ButtonProps } from "../Button";
+import { floatingButtonVariant } from "./FloatingButton.styles";
+import type { FloatingButtonProps } from "./FloatingButton.types";
+
+const FloatingButton = ({ variant, className, ...props }: FloatingButtonProps) => {
+ return (
+