Compare commits

..

5 Commits

4 changed files with 632 additions and 248 deletions

View File

@@ -1,160 +1,368 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import TodoList from '@/components/TodoList';
import FilterButtons from '@/components/FilterButtons';
// Todo型の定義 // Todo型の定義
type Todo = { type Todo = {
id: number; // ユニークな識別子。今回はDate.now()で一意性をある程度確保。 id: number;
text: string; // TODOの内容。ユーザーが入力する文字列。 text: string;
completed: boolean; // 完了したかどうかのフラグ。チェックボックスに対応。 completed: boolean;
}; };
type FilterType = 'all' | 'active' | 'completed'; // フィルターの種類。全て、未完了、完了の3種類。 type FilterType = 'all' | 'active' | 'completed';
// エラー型の定義
type AppError = {
type: 'STORAGE_ERROR' | 'IMPORT_ERROR' | 'VALIDATION_ERROR';
message: string;
};
// 型ガード関数
const isTodo = (obj: unknown): obj is Todo => {
return (
obj !== null &&
typeof obj === 'object' &&
typeof (obj as any).id === 'number' &&
typeof (obj as any).text === 'string' &&
typeof (obj as any).completed === 'boolean'
);
};
const isTodoArray = (data: unknown): data is Todo[] => {
return Array.isArray(data) && data.every(isTodo);
};
export default function Home() { export default function Home() {
// Todoリスト本体の状態管理 // Local Storage のキー
const [todos, setTodos] = useState<Todo[]>([]); // Todo配列を保持 const STORAGE_KEY = 'todo-app-data';
// 入力欄の状態管理 // 状態管理
const [inputValue, setInputValue] = useState<string>(''); // 入力中のテキスト const [todos, setTodos] = useState<Todo[]>([]);
const [inputValue, setInputValue] = useState<string>('');
// 削除確認ダイアログの表示状態 const [showConfirm, setShowConfirm] = useState(false);
const [showConfirm, setShowConfirm] = useState(false); // ダイアログ表示フラグ const [targetId, setTargetId] = useState<number | null>(null);
const [showInputAlert, setShowInputAlert] = useState(false);
// 削除対象のTodoのID
const [targetId, setTargetId] = useState<number | null>(null); // 削除対象ID
// 空欄入力時の警告ダイアログ表示状態
const [showInputAlert, setShowInputAlert] = useState(false); // 空欄警告フラグ
// 編集機能のための状態管理
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
// 編集中のテキスト
const [editText, setEditText] = useState(''); const [editText, setEditText] = useState('');
// フィルター状態の管理(全て、未完了、完了)
const [filter, setFilter] = useState<FilterType>('all'); const [filter, setFilter] = useState<FilterType>('all');
// フィルターに応じたTodoの数をカウント // エラー状態の管理
const activeCount = todos.filter(todo => !todo.completed).length; const [error, setError] = useState<AppError | null>(null);
const completedCount = todos.filter(todo => todo.completed).length; const [showErrorDialog, setShowErrorDialog] = useState(false);
// フィルターに応じたTodoの数をメモ化
const { activeCount, completedCount } = useMemo(() => ({
activeCount: todos.filter(todo => !todo.completed).length,
completedCount: todos.filter(todo => todo.completed).length,
}), [todos]);
// フィルターを変更する関数 // エラー処理用のヘルパー関数をメモ化
const getFilteredTodos = () => { // フィルターに応じてTodoリストを返す関数 const handleError = useCallback((error: AppError) => {
switch (filter) { // フィルターの種類に応じて処理を分岐 console.error('アプリケーションエラー:', error);
case 'active': // 未完了のTodoのみを返す setError(error);
return todos.filter(todo => !todo.completed); setShowErrorDialog(true);
case 'completed': // 完了したTodoのみを返す }, []);
return todos.filter(todo => todo.completed);
default: // 'all'の場合は全てのTodoを返す
return todos; // 'all'の場合は全てのTodoを返す
}
};
const startEdit = (todo: Todo) => { // 編集開始関数 // エラーダイアログを閉じる
setEditingId(todo.id); // 編集対象のIDをセット const closeErrorDialog = useCallback(() => {
setEditText(todo.text); // 編集用のテキストをセット setShowErrorDialog(false);
}; setError(null);
}, []);
const saveEdit = () => { // 編集内容を保存する関数 // バリデーション関数をメモ化
if (editText.trim() === '') { // 編集内容が空欄だったら警告ダイアログ表示 const validateTodoText = useCallback((text: string): boolean => {
alert('TODOが空です'); // 簡易的なアラート表示 if (!text || text.trim().length === 0) {
return; // 空欄の場合は保存しない handleError({
type: 'VALIDATION_ERROR',
message: 'TODOの内容を入力してください'
});
return false;
} }
const updatedTodos = todos.map(todo => { // Todoリストを更新 if (text.trim().length > 100) {
if (todo.id === editingId) { // 編集対象のTodoを見つけたら handleError({
return { ...todo, text: editText }; // 編集内容で更新 type: 'VALIDATION_ERROR',
} message: 'TODOは100文字以内で入力してください'
return todo; // 他のTodoはそのまま返す });
}); return false;
}
setTodos(updatedTodos); // 更新されたTodoリストをセット return true;
setEditingId(null); // 編集モードを終了 }, [handleError]);
setEditText(''); // 編集用テキストをクリア
};
// 新しいTODOを追加する関数 // Local Storage操作をメモ化
const addTodo = () => { const safeLocalStorageGet = useCallback((key: string): string | null => {
// 入力が空欄だったら警告ダイアログ表示 try {
if (inputValue.trim() === '') { //trim()で前後の空白を除去 return localStorage.getItem(key);
setShowInputAlert(true); // 警告ダイアログを表示 } catch (error) {
handleError({
type: 'STORAGE_ERROR',
message: 'データの読み込みに失敗しました'
});
return null;
}
}, [handleError]);
const safeLocalStorageSet = useCallback((key: string, value: string): boolean => {
try {
localStorage.setItem(key, value);
return true;
} catch (error) {
handleError({
type: 'STORAGE_ERROR',
message: 'データの保存に失敗しました'
});
return false;
}
}, [handleError]);
// 編集関連の関数をメモ化
const startEdit = useCallback((todo: Todo) => {
setEditingId(todo.id);
setEditText(todo.text);
}, []);
const saveEdit = useCallback(() => {
if (!validateTodoText(editText)) {
return; return;
} }
// 新しいTodoオブジェクトを作成
const newTodo: Todo = { // 新規Todoオブジェクト
id: Date.now(), // 現在時刻のミリ秒をIDとして使用することで、ほかのTODOと重複しないようにする
text: inputValue, // 入力値
completed: false // 初期状態を未完了に
};
setTodos([...todos, newTodo]); // Todoリストに追加
setInputValue(''); // 入力欄をクリア
};
const cancelEdit = () => { // 編集キャンセル関数 try {
setEditingId(null); // 編集モードを終了 const updatedTodos = todos.map(todo => {
setEditText(''); // 編集用テキストをクリア if (todo.id === editingId) {
}; return { ...todo, text: editText.trim() };
}
return todo;
});
// フォーム送信時の処理 setTodos(updatedTodos);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { // フォーム送信イベント setEditingId(null);
e.preventDefault(); // ページリロード防止 setEditText('');
addTodo(); // Todo追加処理 } catch (error) {
}; handleError({
type: 'VALIDATION_ERROR',
// Todoの完了状態をトグルする関数 message: 'TODOの更新に失敗しました'
const toggleTodo = (id: number) => { // Todoの完了状態を切り替える });
const updatedTodos = todos.map(todo => { // 現在のTodoリストをマッピング
if (todo.id === id) { // 対象のTodoを見つけたら
return { ...todo, completed: !todo.completed }; // そのtodoの完了状態を反転
}
return todo; // 対象外はそのまま返す
});
setTodos(updatedTodos); // 状態更新
};
// 削除ボタンが押されたときの処理(確認ダイアログ表示)
const handleDeleteClick = (id: number) => { // 削除ボタン押下時の処理
setShowConfirm(true); // 削除確認ダイアログ表示
setTargetId(id); // 削除対象IDをセット
};
// ダイアログで「はい」が押されたときの本削除処理
const confirmDelete = () => { // 削除確認ダイアログで「はい」が押されたときの処理
if (targetId !== null) { // 削除対象IDがセットされているか確認
setTodos(todos.filter(todo => todo.id !== targetId)); // 該当ID以外を残す
setShowConfirm(false); // ダイアログを閉じる
setTargetId(null); // 削除対象IDをリセット
} }
}; }, [editText, editingId, todos, validateTodoText, handleError]);
// ダイアログで「いいえ」が押されたときの処理 const cancelEdit = useCallback(() => {
const cancelDelete = () => { // 削除確認ダイアログで「いいえ」が押されたときの処理 setEditingId(null);
setShowConfirm(false); // ダイアログを閉じる setEditText('');
setTargetId(null); // 削除対象IDをリセット }, []);
};
// 空欄入力時ダイアログを閉じる関数 // TODO操作の関数をメモ化
const closeInputAlert = () => { // const addTodo = useCallback(() => {
setShowInputAlert(false); // 警告ダイアログを閉じる if (!validateTodoText(inputValue)) {
}; return;
}
try {
const newTodo: Todo = {
id: Date.now(),
text: inputValue.trim(),
completed: false
};
setTodos(prev => [...prev, newTodo]);
setInputValue('');
} catch (error) {
handleError({
type: 'VALIDATION_ERROR',
message: 'TODOの追加に失敗しました'
});
}
}, [inputValue, validateTodoText, handleError]);
const handleSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
addTodo();
}, [addTodo]);
const toggleTodo = useCallback((id: number) => {
try {
setTodos(prev => prev.map(todo => {
if (todo.id === id) {
return { ...todo, completed: !todo.completed };
}
return todo;
}));
} catch (error) {
handleError({
type: 'VALIDATION_ERROR',
message: 'TODO状態の更新に失敗しました'
});
}
}, [handleError]);
const handleDeleteClick = useCallback((id: number) => {
setShowConfirm(true);
setTargetId(id);
}, []);
const confirmDelete = useCallback(() => {
if (targetId !== null) {
try {
setTodos(prev => prev.filter(todo => todo.id !== targetId));
setShowConfirm(false);
setTargetId(null);
} catch (error) {
handleError({
type: 'VALIDATION_ERROR',
message: 'TODOの削除に失敗しました'
});
}
}
}, [targetId, handleError]);
const cancelDelete = useCallback(() => {
setShowConfirm(false);
setTargetId(null);
}, []);
const closeInputAlert = useCallback(() => {
setShowInputAlert(false);
}, []);
const clearCompleted = useCallback(() => {
try {
setTodos(prev => prev.filter(todo => !todo.completed));
} catch (error) {
handleError({
type: 'VALIDATION_ERROR',
message: '完了済みTODOの削除に失敗しました'
});
}
}, [handleError]);
// エクスポート・インポート機能をメモ化
const exportData = useCallback(() => {
try {
const dataStr = JSON.stringify(todos, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = 'todos.json';
link.click();
URL.revokeObjectURL(url);
} catch (error) {
handleError({
type: 'STORAGE_ERROR',
message: 'データのエクスポートに失敗しました'
});
}
}, [todos, handleError]);
// 型安全なインポート機能
const importData = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e: ProgressEvent<FileReader>) => {
try {
const result = e.target?.result;
if (typeof result !== 'string') {
throw new Error('ファイルの読み込みに失敗しました');
}
const parsedData: unknown = JSON.parse(result);
if (!isTodoArray(parsedData)) {
throw new Error('無効なTODOデータ形式です');
}
setTodos(parsedData);
event.target.value = '';
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '不明なエラー';
handleError({
type: 'IMPORT_ERROR',
message: `ファイルの読み込みに失敗しました: ${errorMessage}`
});
event.target.value = '';
}
};
reader.onerror = () => {
handleError({
type: 'IMPORT_ERROR',
message: 'ファイルの読み込み中にエラーが発生しました'
});
event.target.value = '';
};
reader.readAsText(file);
}, [handleError]);
// 型安全なLocal Storage読み込み
useEffect(() => {
const savedTodos = safeLocalStorageGet(STORAGE_KEY);
if (savedTodos) {
try {
const parsedData: unknown = JSON.parse(savedTodos);
if (isTodoArray(parsedData)) {
setTodos(parsedData);
} else {
throw new Error('保存されたデータの形式が正しくありません');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '不明なエラー';
handleError({
type: 'STORAGE_ERROR',
message: `データ読み込みエラー: ${errorMessage}`
});
}
}
}, [safeLocalStorageGet, handleError]);
// TODOが変更されるたびに保存デバウンス処理付き
useEffect(() => {
const timeoutId = setTimeout(() => {
safeLocalStorageSet(STORAGE_KEY, JSON.stringify(todos));
}, 300); // 300ms後に保存
return () => clearTimeout(timeoutId);
}, [todos, safeLocalStorageSet]);
// JSX: 実際に表示されるUI定義
return ( return (
<main className="min-h-screen p-8 bg-gray-50"> {/* 全体の背景や余白 */} <main className="min-h-screen p-8 bg-gray-50">
<div className="max-w-md mx-auto bg-white rounded-lg shadow-md p-6"> {/* カード風の白背景 */} <div className="max-w-md mx-auto bg-white rounded-lg shadow-md p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6">TODOアプリ</h1> {/* タイトル */} {/* ヘッダー部分 */}
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800">TODOアプリ</h1>
<div className="flex gap-2">
<button
onClick={exportData}
className="text-blue-500 hover:text-blue-700 px-3 py-1 rounded transition-colors text-sm"
>
</button>
<label className="text-blue-500 hover:text-blue-700 px-3 py-1 rounded transition-colors cursor-pointer text-sm">
<input
type="file"
accept=".json"
onChange={importData}
className="hidden"
/>
</label>
</div>
</div>
{/* 入力フォーム */} {/* 入力フォーム */}
<form onSubmit={handleSubmit} className="mb-4"> {/* フォーム全体のスタイル */} <form onSubmit={handleSubmit} className="mb-4">
<input <input
type="text" type="text"
value={inputValue} // 入力欄の値を状態と連動 value={inputValue}
onChange={(e) => setInputValue(e.target.value)} // 入力値変更時に状態更新 onChange={(e) => setInputValue(e.target.value)}
placeholder="TODOを入力してEnterキー" placeholder="TODOを入力してEnterキー"
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
maxLength={100}
/> />
<button <button
type="submit" type="submit"
@@ -163,136 +371,38 @@ export default function Home() {
</button> </button>
</form> </form>
<div className="flex justify-between items-center mb-4 p-4 bg-gray-100 rounded-lg"> {/* フィルターと削除ボタンのコンテナ */}
<div className="flex gap-2"> {/* フィルター用のボタン */}
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded transition-colors ${
filter === 'all'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-700 hover:bg-gray-200'
}`}
>
({todos.length})
</button>
<button
onClick={() => setFilter('active')}
className={`px-4 py-2 rounded transition-colors ${
filter === 'active'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-700 hover:bg-gray-200'
}`}
>
({activeCount})
</button>
<button
onClick={() => setFilter('completed')}
className={`px-4 py-2 rounded transition-colors ${
filter === 'completed'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-700 hover:bg-gray-200'
}`}
>
({completedCount})
</button>
</div>
{/* 完了済みTODOをすべて削除 */} {/* フィルターボタン */}
{completedCount > 0 && ( <FilterButtons
<button filter={filter}
onClick={() => setTodos(todos.filter(todo => !todo.completed))} totalCount={todos.length}
className="text-red-500 hover:text-red-700 px-3 py-1 rounded transition-colors" activeCount={activeCount}
> completedCount={completedCount}
onFilterChange={setFilter}
</button> onClearCompleted={clearCompleted}
)} />
</div>
<div className="space-y-2"> {/* TODOリスト */}
{getFilteredTodos().length === 0 ? ( <TodoList
<p className="text-gray-500 text-center py-8"> todos={todos}
{filter === 'active' && 'すべて完了しました!'} filter={filter}
{filter === 'completed' && '完了したTODOがありません'} editingId={editingId}
{filter === 'all' && 'TODOがありません'} editText={editText}
</p> onToggleTodo={toggleTodo}
) : ( onStartEdit={startEdit}
getFilteredTodos().map((todo) => ( onSaveEdit={saveEdit}
<div key={todo.id} className="flex items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"> onCancelEdit={cancelEdit}
<input onDeleteClick={handleDeleteClick}
type="checkbox" onEditTextChange={setEditText}
checked={todo.completed} />
onChange={() => toggleTodo(todo.id)}
className="mr-3 h-5 w-5 cursor-pointer"
/>
{editingId === todo.id ? (
// 編集モード
<input
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit();
if (e.key === 'Escape') cancelEdit();
}}
className="flex-1 border border-blue-500 p-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
/>
) : (
// 通常モード
<span
onClick={() => startEdit(todo)}
className={`flex-1 cursor-pointer p-2 rounded hover:bg-gray-200 transition-colors ${
todo.completed ? 'line-through text-gray-500' : 'text-gray-800'
}`}
>
{todo.text}
</span>
)}
{editingId === todo.id ? (
// 編集モードのボタン
<div className="flex gap-2 ml-2">
<button
onClick={saveEdit}
className="text-green-600 hover:text-green-800 px-2 py-1 rounded transition-colors"
>
</button>
<button
onClick={cancelEdit}
className="text-gray-600 hover:text-gray-800 px-2 py-1 rounded transition-colors"
>
</button>
</div>
) : (
// 通常モードのボタン
<div className="flex gap-2 ml-2">
<button
onClick={() => startEdit(todo)}
className="text-blue-500 hover:text-blue-700 px-2 py-1 rounded transition-colors"
>
</button>
<button
onClick={() => handleDeleteClick(todo.id)}
className="text-red-500 hover:text-red-700 px-2 py-1 rounded transition-colors"
>
</button>
</div>
)}
</div>
))
)}
</div>
{/* 確認ダイアログ */} {/* 確認ダイアログ */}
{showConfirm && ( {showConfirm && (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50"> <div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
<div className="bg-white p-6 rounded shadow"> <div className="bg-white p-6 rounded shadow">
<p className="mb-4"></p> <p className="mb-4"></p>
<button onClick={confirmDelete} className="mr-2 px-4 py-2 bg-red-500 text-white rounded"></button> {/* 削除確定 */} <button onClick={confirmDelete} className="mr-2 px-4 py-2 bg-red-500 text-white rounded"></button>
<button onClick={cancelDelete} className="px-4 py-2 bg-gray-300 rounded"></button> {/* 削除キャンセル */} <button onClick={cancelDelete} className="px-4 py-2 bg-gray-300 rounded"></button>
</div> </div>
</div> </div>
)} )}
@@ -303,7 +413,7 @@ export default function Home() {
<div className="bg-white p-6 rounded shadow flex flex-col items-center"> <div className="bg-white p-6 rounded shadow flex flex-col items-center">
<p className="mb-4">TODOを入力してください</p> <p className="mb-4">TODOを入力してください</p>
<button <button
onClick={closeInputAlert} // 警告ダイアログを閉じる onClick={closeInputAlert}
className="w-full px-4 py-2 bg-blue-500 text-white rounded text-center" className="w-full px-4 py-2 bg-blue-500 text-white rounded text-center"
style={{ maxWidth: "240px" }} style={{ maxWidth: "240px" }}
> >
@@ -312,6 +422,29 @@ export default function Home() {
</div> </div>
</div> </div>
)} )}
{/* エラーダイアログ */}
{showErrorDialog && error && (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
<div className="bg-white p-6 rounded shadow max-w-sm mx-4">
<div className="flex items-center mb-4">
<div className="bg-red-100 p-2 rounded-full mr-3">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900"></h3>
</div>
<p className="mb-4 text-gray-700">{error.message}</p>
<button
onClick={closeErrorDialog}
className="w-full px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
</button>
</div>
</div>
)}
</div> </div>
</main> </main>
); );

View File

@@ -0,0 +1,69 @@
import React from 'react';
type FilterType = 'all' | 'active' | 'completed';
type FilterButtonsProps = {
filter: FilterType;
totalCount: number;
activeCount: number;
completedCount: number;
onFilterChange: (filter: FilterType) => void;
onClearCompleted: () => void;
};
const FilterButtons = React.memo(function FilterButtons({
filter,
totalCount,
activeCount,
completedCount,
onFilterChange,
onClearCompleted,
}: FilterButtonsProps) {
return (
<div className="flex justify-between items-center mb-4 p-4 bg-gray-100 rounded-lg">
<div className="flex gap-2">
<button
onClick={() => onFilterChange('all')}
className={`px-4 py-2 rounded transition-colors ${
filter === 'all'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-700 hover:bg-gray-200'
}`}
>
({totalCount})
</button>
<button
onClick={() => onFilterChange('active')}
className={`px-4 py-2 rounded transition-colors ${
filter === 'active'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-700 hover:bg-gray-200'
}`}
>
({activeCount})
</button>
<button
onClick={() => onFilterChange('completed')}
className={`px-4 py-2 rounded transition-colors ${
filter === 'completed'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-700 hover:bg-gray-200'
}`}
>
({completedCount})
</button>
</div>
{completedCount > 0 && (
<button
onClick={onClearCompleted}
className="text-red-500 hover:text-red-700 px-3 py-1 rounded transition-colors"
>
</button>
)}
</div>
);
});
export default FilterButtons;

105
src/components/TodoItem.tsx Normal file
View File

@@ -0,0 +1,105 @@
import React from 'react';
type Todo = {
id: number;
text: string;
completed: boolean;
};
type TodoItemProps = {
todo: Todo;
isEditing: boolean;
editText: string;
onToggle: () => void;
onStartEdit: () => void;
onSaveEdit: () => void;
onCancelEdit: () => void;
onDelete: () => void;
onEditTextChange: (text: string) => void;
};
const TodoItem = React.memo(function TodoItem({
todo,
isEditing,
editText,
onToggle,
onStartEdit,
onSaveEdit,
onCancelEdit,
onDelete,
onEditTextChange,
}: TodoItemProps) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') onSaveEdit();
if (e.key === 'Escape') onCancelEdit();
};
return (
<div className="flex items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<input
type="checkbox"
checked={todo.completed}
onChange={onToggle}
className="mr-3 h-5 w-5 cursor-pointer"
/>
{isEditing ? (
// 編集モード
<input
type="text"
value={editText}
onChange={(e) => onEditTextChange(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 border border-blue-500 p-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
/>
) : (
// 通常モード
<span
onClick={onStartEdit}
className={`flex-1 cursor-pointer p-2 rounded hover:bg-gray-200 transition-colors ${
todo.completed ? 'line-through text-gray-500' : 'text-gray-800'
}`}
>
{todo.text}
</span>
)}
{isEditing ? (
// 編集モードのボタン
<div className="flex gap-2 ml-2">
<button
onClick={onSaveEdit}
className="text-green-600 hover:text-green-800 px-2 py-1 rounded transition-colors"
>
</button>
<button
onClick={onCancelEdit}
className="text-gray-600 hover:text-gray-800 px-2 py-1 rounded transition-colors"
>
</button>
</div>
) : (
// 通常モードのボタン
<div className="flex gap-2 ml-2">
<button
onClick={onStartEdit}
className="text-blue-500 hover:text-blue-700 px-2 py-1 rounded transition-colors"
>
</button>
<button
onClick={onDelete}
className="text-red-500 hover:text-red-700 px-2 py-1 rounded transition-colors"
>
</button>
</div>
)}
</div>
);
});
export default TodoItem;

View File

@@ -0,0 +1,77 @@
import React, { useMemo } from 'react';
import TodoItem from './TodoItem';
type Todo = {
id: number;
text: string;
completed: boolean;
};
type FilterType = 'all' | 'active' | 'completed';
type TodoListProps = {
todos: Todo[];
filter: FilterType;
editingId: number | null;
editText: string;
onToggleTodo: (id: number) => void;
onStartEdit: (todo: Todo) => void;
onSaveEdit: () => void;
onCancelEdit: () => void;
onDeleteClick: (id: number) => void;
onEditTextChange: (text: string) => void;
};
const TodoList = React.memo(function TodoList({
todos,
filter,
editingId,
editText,
onToggleTodo,
onStartEdit,
onSaveEdit,
onCancelEdit,
onDeleteClick,
onEditTextChange,
}: TodoListProps) {
// フィルタリング処理をメモ化
const filteredTodos = useMemo(() => {
switch (filter) {
case 'active':
return todos.filter(todo => !todo.completed);
case 'completed':
return todos.filter(todo => todo.completed);
default:
return todos;
}
}, [todos, filter]);
return (
<div className="space-y-2">
{filteredTodos.length === 0 ? (
<p className="text-gray-500 text-center py-8">
{filter === 'active' && 'すべて完了しました!'}
{filter === 'completed' && '完了したTODOがありません'}
{filter === 'all' && 'TODOがありません'}
</p>
) : (
filteredTodos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
isEditing={editingId === todo.id}
editText={editText}
onToggle={() => onToggleTodo(todo.id)}
onStartEdit={() => onStartEdit(todo)}
onSaveEdit={onSaveEdit}
onCancelEdit={onCancelEdit}
onDelete={() => onDeleteClick(todo.id)}
onEditTextChange={onEditTextChange}
/>
))
)}
</div>
);
});
export default TodoList;