diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 0000000..b91aa23
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,48 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+language: "ja-JP"
+tone_instructions: "日本語で、初学者にも伝わるやさしい口調。指摘は一般人向けの説明とエンジニア向けの説明を分けて、理由と修正方針を簡潔に示してください。"
+early_access: false
+
+reviews:
+ profile: "chill"
+ request_changes_workflow: false
+ high_level_summary: true
+ review_status: true
+ review_details: true
+ collapse_walkthrough: false
+ auto_review:
+ enabled: true
+ auto_incremental_review: true
+ auto_pause_after_reviewed_commits: 5
+ drafts: true
+ base_branches:
+ - ".*"
+ ignore_title_keywords:
+ - "WIP"
+ - "[skip review]"
+ ignore_usernames:
+ - "dependabot[bot]"
+ - "renovate[bot]"
+ - "github-actions[bot]"
+ path_instructions:
+ - path: "src/**/*.{ts,tsx}"
+ instructions: |
+ React / TypeScript 初学者の学習用リポジトリとしてレビューしてください。
+
+ コメントを書くときは、可能な限り次の2段構成にしてください。
+ - 一般人向けの説明: 何が困るのか、何のために直すのかを専門用語少なめで説明する。
+ - エンジニア向けの説明: React、TypeScript、状態管理、イベント、props、テスト観点から具体的に説明する。
+
+ 重大な不具合、型安全性、Reactの基本パターン、アクセシビリティ、テスト不足を優先して指摘してください。
+ 学習目的の小さなPRでは、過度な抽象化や大規模リファクタは強く求めず、次の一歩がわかる提案にしてください。
+ - path: "src/components/__tests__/**/*.{ts,tsx}"
+ instructions: |
+ テストコードは、ユーザー操作に近い観点で読めるかを重視してください。
+ 実装詳細に依存しすぎるテストより、画面上の入力、クリック、表示結果、親コンポーネントへの通知を検証する方針を優先してください。
+ - path: "*.{config.ts,config.js}"
+ instructions: |
+ Vite / Vitest 設定では、このリポジトリが package.json の "type": "module" を使う前提で確認してください。
+ Node.js の ESM 環境で壊れやすい __dirname や CommonJS 前提の書き方があれば、import.meta.url を使った安全な書き方を提案してください。
+
+chat:
+ auto_reply: true
diff --git a/package.json b/package.json
index 6b021b9..954ce75 100644
--- a/package.json
+++ b/package.json
@@ -7,14 +7,20 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest"
},
"dependencies": {
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@mui/material": "^9.0.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -23,8 +29,10 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
+ "jsdom": "^29.1.1",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
- "vite": "^8.0.10"
+ "vite": "^8.0.10",
+ "vitest": "^4.1.5"
}
}
diff --git a/src/components/TodoCreate.tsx b/src/components/TodoCreate.tsx
new file mode 100644
index 0000000..e6ac65f
--- /dev/null
+++ b/src/components/TodoCreate.tsx
@@ -0,0 +1,90 @@
+import { useState } from 'react';
+import { Box, TextField, Button, Typography, Alert } from '@mui/material';
+import type { Todo } from '../types/todo';
+
+/**
+ * Todo作成コンポーネントのProps
+ */
+interface TodoCreateProps {
+ /** Todo作成時のコールバック関数 */
+ onTodoCreate: (todo: Todo) => void;
+}
+
+/**
+ * Todo作成コンポーネント
+ *
+ * ユーザーが新しいTodoを作成するためのフォームを提供する
+ */
+export const TodoCreate = ({ onTodoCreate }: TodoCreateProps) => {
+ // 入力フィールドの状態管理
+ const [title, setTitle] = useState('');
+ // エラーメッセージの状態管理
+ const [error, setError] = useState('');
+
+ /**
+ * フォーム送信ハンドラー
+ *
+ * @param e - フォーム送信イベント
+ */
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+
+ // バリデーション: タイトルが空の場合はエラー
+ if (!title.trim()) {
+ setError('タイトルを入力してください');
+ return;
+ }
+
+ // 新しいTodoオブジェクトを作成
+ const newTodo: Todo = {
+ id: crypto.randomUUID(), // 一意なIDを生成
+ title: title.trim(),
+ completed: false,
+ createdAt: new Date(),
+ };
+
+ // 親コンポーネントにTodo作成を通知
+ onTodoCreate(newTodo);
+
+ // フォームをリセット
+ setTitle('');
+ setError('');
+ };
+
+ return (
+
+
+ 新しいTodoを作成
+
+
+ {/* フォーム */}
+
+ {/* エラーメッセージ表示 */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* タイトル入力フィールド */}
+ {
+ setTitle(e.target.value);
+ if (error) setError('');
+ }}
+ placeholder="Todoのタイトルを入力"
+ fullWidth
+ error={!!error}
+ helperText={error ? ' ' : ''}
+ />
+
+ {/* 送信ボタン */}
+
+
+
+ );
+};
diff --git a/src/components/__tests__/TodoCreate.test.tsx b/src/components/__tests__/TodoCreate.test.tsx
new file mode 100644
index 0000000..55d77bc
--- /dev/null
+++ b/src/components/__tests__/TodoCreate.test.tsx
@@ -0,0 +1,107 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { TodoCreate } from '../TodoCreate';
+import type { Todo } from '../../types/todo';
+
+/**
+ * TodoCreateコンポーネントのテスト
+ */
+describe('TodoCreate', () => {
+ /**
+ * 正常にTodoを作成できること
+ */
+ it('should create a todo when form is submitted with valid title', () => {
+ // モック関数を作成
+ const mockOnTodoCreate = vi.fn();
+
+ // コンポーネントをレンダリング
+ render();
+
+ // 入力フィールドを取得
+ const input = screen.getByLabelText(/タイトル/i);
+ // ボタンを取得
+ const button = screen.getByRole('button', { name: /作成/i });
+
+ // タイトルを入力
+ fireEvent.change(input, { target: { value: 'テストTodo' } });
+
+ // フォームを送信
+ fireEvent.click(button);
+
+ // onTodoCreateが呼ばれたことを確認
+ expect(mockOnTodoCreate).toHaveBeenCalledTimes(1);
+
+ // 作成されたTodoの内容を確認
+ const createdTodo = mockOnTodoCreate.mock.calls[0][0] as Todo;
+ expect(createdTodo.title).toBe('テストTodo');
+ expect(createdTodo.completed).toBe(false);
+ expect(createdTodo.id).toEqual(expect.any(String));
+ expect(createdTodo.createdAt).toBeInstanceOf(Date);
+ });
+
+ /**
+ * 空のタイトルで送信するとエラーが表示されること
+ */
+ it('should show error when submitting with empty title', () => {
+ const mockOnTodoCreate = vi.fn();
+
+ render();
+
+ const button = screen.getByRole('button', { name: /作成/i });
+
+ // 空のタイトルで送信
+ fireEvent.click(button);
+
+ // onTodoCreateが呼ばれないことを確認
+ expect(mockOnTodoCreate).not.toHaveBeenCalled();
+
+ // エラーメッセージが表示されることを確認
+ expect(screen.getByText(/タイトルを入力してください/i)).toBeInTheDocument();
+ });
+
+ /**
+ * 空白のみのタイトルで送信するとエラーが表示されること
+ */
+ it('should show error when submitting with whitespace-only title', () => {
+ const mockOnTodoCreate = vi.fn();
+
+ render();
+
+ const input = screen.getByLabelText(/タイトル/i);
+ const button = screen.getByRole('button', { name: /作成/i });
+
+ // 空白のみを入力
+ fireEvent.change(input, { target: { value: ' ' } });
+
+ // 送信
+ fireEvent.click(button);
+
+ // onTodoCreateが呼ばれないことを確認
+ expect(mockOnTodoCreate).not.toHaveBeenCalled();
+
+ // エラーメッセージが表示されることを確認
+ expect(screen.getByText(/タイトルを入力してください/i)).toBeInTheDocument();
+ });
+
+ /**
+ * 送信後に入力フィールドがクリアされること
+ */
+ it('should clear input field after successful submission', () => {
+ const mockOnTodoCreate = vi.fn();
+
+ render();
+
+ const input = screen.getByLabelText(/タイトル/i) as HTMLInputElement;
+ const button = screen.getByRole('button', { name: /作成/i });
+
+ // タイトルを入力
+ fireEvent.change(input, { target: { value: 'テストTodo' } });
+ expect(input.value).toBe('テストTodo');
+
+ // 送信
+ fireEvent.click(button);
+
+ // 入力フィールドがクリアされたことを確認
+ expect(input.value).toBe('');
+ });
+});
diff --git a/src/test/setup.ts b/src/test/setup.ts
new file mode 100644
index 0000000..21b5f01
--- /dev/null
+++ b/src/test/setup.ts
@@ -0,0 +1,6 @@
+import '@testing-library/jest-dom';
+
+/**
+ * Vitestのセットアップファイル
+ * テストで@testing-library/jest-domのマッチャーを使用できるようにする
+ */
diff --git a/src/types/todo.ts b/src/types/todo.ts
new file mode 100644
index 0000000..699a472
--- /dev/null
+++ b/src/types/todo.ts
@@ -0,0 +1,21 @@
+/**
+ * Todoアイテムの型定義
+ */
+export interface Todo {
+ /** Todoの一意識別子 */
+ id: string;
+ /** Todoのタイトル */
+ title: string;
+ /** Todoの完了状態 */
+ completed: boolean;
+ /** 作成日時 */
+ createdAt: Date;
+}
+
+/**
+ * 新規Todo作成時の入力データ型
+ */
+export interface CreateTodoInput {
+ /** Todoのタイトル */
+ title: string;
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..69ce8ac
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+import { fileURLToPath } from 'node:url';
+
+/**
+ * Vitest設定
+ * Reactコンポーネントのテストを実行するための設定
+ */
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: ['./src/test/setup.ts'],
+ css: true,
+ },
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
+});