はじめに
こちらの記事は、@Sicut_studyさんがアップしている【Reactアプリ100本ノック】シリーズに相乗りし、アウトプットを行うための記事になります。
- 実装ルールや成果物の達成条件は元記事に従うものとします。
- 元記事との差別点として、具体的に自分がどんな実装を行ったのか(と必要に応じて解説)を記載します。
@Sicut_studyさんのノック100本についていき、Reactを100日間学ぶのが目標です。
今回の元記事はこちら
前回の記事
問題
TODOリストアプリを作成する
ルール
元記事より引用
- 主要なライブラリやフレームワークはReactである必要がありますが、その他のツールやライブラリ(例: Redux, Next.js, Styled Componentsなど)を組み合わせて使用することは自由
- TypeScriptを利用する
達成条件
元記事より引用
- 入力フィールド: ユーザーがタスクを入力するためのテキストフィールドが存在する。タスク名が1文字以上でない場合はバリデーションする
- 追加ボタン: 入力されたタスクをTODOリストに追加するためのボタン。
- タスクリスト表示: 追加されたタスクがリストとして表示される。
- 削除ボタン: 各タスクの隣には削除ボタンがあり、それをクリックすることで該当のタスクをリストから削除する
実装
本記事では以下の方針で実装していきます。
- 元記事と同様に、タスクの登録はモーダルで実現する
- 各パーツ(ボタン、モーダル、TODOリスト)はコンポーネント化する
それでは、
npx create-react-app react-todo-app --template typescript
で新規プロジェクトを作成し、各コンポーネントとApp.tsxの実装を行います。
src/components/Button.tsx
src/components/Button.tsx
/** @jsxImportSource @emotion/react */
import styled from "@emotion/styled";
interface ButtonProps {
color?: string;
}
const Button = styled.button<ButtonProps>`
padding: 10px 15px;
min-width: 120px;
border: none;
border-radius: 20px;
color: white;
cursor: pointer;
background-color: ${(props) => props.color || "#757575"};
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.15);
transition: background-color 0.3s, box-shadow 0.3s;
&:hover {
box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.2);
opacity: 0.85;
}
`;
Button.defaultProps = {
color: "#757575",
};
export default Button;
src/components/Modal.tsx
src/components/Modal.tsx
/** @jsxImportSource @emotion/react */
import { useState, ChangeEvent } from "react";
import styled from "@emotion/styled";
import Button from "./Button";
const ButtonContainer = styled.div`
display: flex;
gap: 10px;
justify-content: center;
`;
const StyledModal = styled.div`
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
`;
const ModalContent = styled.div`
background-color: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
width: 90%;
max-width: 500px;
display: flex;
flex-direction: column;
gap: 20px;
`;
const StyledInput = styled.input`
padding: 15px;
border: 2px solid #ccc;
border-radius: 8px;
font-size: 16px;
width: 100%;
box-sizing: border-box;
`;
const ErrorMessage = styled.div`
color: #f44336;
margin-top: -10px;
margin-bottom: 10px;
`;
type ModalProps = {
onAdd: (task: string) => void;
onCancel: () => void;
}
const Modal = ({ onAdd, onCancel }: ModalProps) => {
const [task, setTask] = useState("");
const [error, setError] = useState("");
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setTask(e.target.value);
if (error) setError("");
};
const handleAdd = () => {
if (task.trim().length > 0) {
onAdd(task);
setTask("");
} else {
setError("タスク名は1文字以上でなければなりません。");
}
};
const stopPropagation = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
};
return (
<StyledModal>
<ModalContent onClick={stopPropagation}>
<StyledInput
type="text"
value={task}
onChange={handleInputChange}
placeholder="新しいタスクを入力"
aria-describedby="error-message"
/>
{error && <ErrorMessage id="error-message">{error}</ErrorMessage>}
<ButtonContainer>
<Button color="#2979ff" onClick={handleAdd}>
追加
</Button>
<Button color="#f44336" onClick={onCancel}>
キャンセル
</Button>
</ButtonContainer>
</ModalContent>
</StyledModal>
);
};
export default Modal;
src/components/TaskList.tsx
src/components/TaskList.tsx
/** @jsxImportSource @emotion/react */
import styled from "@emotion/styled";
import Button from "./Button";
const ListContainer = styled.div`
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
background-color: white;
padding: 20px;
margin: 20px 0;
`;
const ListItem = styled.div`
padding: 10px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
&:last-child {
border-bottom: none;
}
`;
const ListTitle = styled.div`
font-size: 20px;
font-weight: bold;
margin-bottom: 10px;
text-align: center;
`;
type TaskListProps = {
tasks: string[];
onDelete: (index: number) => void;
};
const TaskList = ({ tasks, onDelete }: TaskListProps) => {
return (
<ListContainer>
<ListTitle>TODOリスト</ListTitle>
{tasks.length > 0 ? (
tasks.map((task, index) => (
<ListItem key={index}>
{task}
<Button color="#f44336" onClick={() => onDelete(index)}>
削除
</Button>
</ListItem>
))
) : (
<ListItem>タスクがありません。</ListItem>
)}
</ListContainer>
);
};
export default TaskList;
src/App.tsx
src/App.tsx
/** @jsxImportSource @emotion/react */
import { useState } from "react";
import styled from "@emotion/styled";
import Button from "./components/Button";
import Modal from "./components/Modal";
import TaskList from "./components/TaskList";
const AppContainer = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f7f7f7;
`;
const App = () => {
const [tasks, setTasks] = useState<string[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
// タスクの追加処理
const addTask = (newTask: string) => {
setTasks((prevTasks) => [...prevTasks, newTask]);
setIsModalOpen(false); // モーダルを閉じる
};
// タスクの削除処理
const deleteTask = (index: number) => {
setTasks((prevTasks) => prevTasks.filter((_, i) => i !== index));
};
// モーダルの開閉処理
const toggleModal = () => {
setIsModalOpen(!isModalOpen);
};
return (
<AppContainer>
{isModalOpen && <Modal onAdd={addTask} onCancel={toggleModal} />}
<Button color="#4caf50" onClick={toggleModal}>
新規登録
</Button>
<TaskList tasks={tasks} onDelete={deleteTask} />
</AppContainer>
);
};
export default App;
完成
今回もデザインはだいぶ改善の余地がありそうですが、要件を満たしているのでOKとします。
最後に
Reactアプリ100本ノックを100回分完走するつもりです。
応援してくれる方はぜひフォローいただけると嬉しいです。
いいね、ストックもお待ちしております。
ではまた。
次回の記事