7
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Reactアプリ100本ノックを実践する 〜03 TODO〜

Last updated at Posted at 2023-11-08

はじめに

こちらの記事は、@Sicut_studyさんがアップしている【Reactアプリ100本ノック】シリーズに相乗りし、アウトプットを行うための記事になります。

  • 実装ルールや成果物の達成条件は元記事に従うものとします。
  • 元記事との差別点として、具体的に自分がどんな実装を行ったのか(と必要に応じて解説)を記載します。

@Sicut_studyさんのノック100本についていき、Reactを100日間学ぶのが目標です。

今回の元記事はこちら

前回の記事

問題

TODOリストアプリを作成する

ルール

元記事より引用

  • 主要なライブラリやフレームワークはReactである必要がありますが、その他のツールやライブラリ(例: Redux, Next.js, Styled Componentsなど)を組み合わせて使用することは自由
  • TypeScriptを利用する

達成条件

元記事より引用

  1. 入力フィールド: ユーザーがタスクを入力するためのテキストフィールドが存在する。タスク名が1文字以上でない場合はバリデーションする
  2. 追加ボタン: 入力されたタスクをTODOリストに追加するためのボタン。
  3. タスクリスト表示: 追加されたタスクがリストとして表示される。
  4. 削除ボタン: 各タスクの隣には削除ボタンがあり、それをクリックすることで該当のタスクをリストから削除する

実装

本記事では以下の方針で実装していきます。

  • 元記事と同様に、タスクの登録はモーダルで実現する
  • 各パーツ(ボタン、モーダル、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とします。

image.png

image.png

image.png

最後に

Reactアプリ100本ノックを100回分完走するつもりです。
応援してくれる方はぜひフォローいただけると嬉しいです。
いいね、ストックもお待ちしております。

ではまた。

次回の記事

7
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?