Вивчай

Практичний проєкт -- Todo List

JavaScript Todo List — блокнот зі списком справ де завдання відмічені як виконаніJavaScript Todo List — блокнот зі списком справ де завдання відмічені як виконані

Це фінальний урок Block 5. Ми застосуємо все, що вивчили: DOM-селектори, маніпуляцію DOM, події, делегування подій, форми та localStorage. Крок за кроком побудуємо повноцінний Todo List.

Інфо

Todo List -- класичний проєкт для вивчення будь-якого фреймворку або мови. Він містить усі базові операції: створення, читання, оновлення та видалення (CRUD). Якщо ти зможеш побудувати Todo List -- ти зможеш побудувати щось більше.


Що ми побудуємо

Наш додаток буде вміти:

  • Додавати нові задачі через форму
  • Позначати задачі як виконані (toggle)
  • Видаляти задачі
  • Фільтрувати: всі / активні / виконані
  • Зберігати дані у localStorage (дані не зникнуть після перезавантаження)

Крок 1: HTML-структура

<!DOCTYPE html>
<html lang="uk">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Todo List</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div class="todo-app">
    <h1>Todo List</h1>

    <form id="todo-form">
      <input
        type="text"
        id="todo-input"
        placeholder="Що потрібно зробити?"
        required
      />
      <button type="submit">Додати</button>
    </form>

    <div class="filters">
      <button class="filter-btn active" data-filter="all">Всі</button>
      <button class="filter-btn" data-filter="active">Активні</button>
      <button class="filter-btn" data-filter="completed">Виконані</button>
    </div>

    <ul id="todo-list"></ul>

    <p class="counter">
      Залишилось: <span id="todo-count">0</span>
    </p>
  </div>

  <script src="app.js"></script>
</body>
</html>

Крок 2: CSS (базові стилі)

Стилі не є головною темою, але допоможуть бачити результат:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: system-ui, sans-serif;
  background: #f0f0f0;
  display: flex;
  justify-content: center;
  padding: 40px 20px;
}

.todo-app {
  background: white;
  border-radius: 12px;
  padding: 30px;
  width: 100%;
  max-width: 500px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

h1 {
  text-align: center;
  margin-bottom: 20px;
}

#todo-form {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

#todo-input {
  flex: 1;
  padding: 10px 14px;
  border: 2px solid #ddd;
  border-radius: 8px;
  font-size: 16px;
}

#todo-form button {
  padding: 10px 20px;
  background: #3182ce;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  font-size: 16px;
}

.filters {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}

.filter-btn {
  padding: 6px 14px;
  border: 1px solid #ddd;
  border-radius: 6px;
  background: white;
  cursor: pointer;
}

.filter-btn.active {
  background: #3182ce;
  color: white;
  border-color: #3182ce;
}

#todo-list {
  list-style: none;
}

.todo-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px;
  border-bottom: 1px solid #eee;
}

.todo-item.completed .todo-text {
  text-decoration: line-through;
  color: #999;
}

.todo-text {
  flex: 1;
  cursor: pointer;
}

.delete-btn {
  background: none;
  border: none;
  color: #e53e3e;
  cursor: pointer;
  font-size: 18px;
}

.counter {
  margin-top: 16px;
  text-align: center;
  color: #666;
}

Крок 3: JavaScript -- стан додатку

Починаємо з масиву задач -- це наш "стан" (state). Кожна задача -- об'єкт з id, текстом та статусом:

// Стан додатку
let todos = [];
let currentFilter = "all";
Порада

Зверни увагу на підхід: ми зберігаємо дані в масиві JavaScript, а DOM оновлюємо окремою функцією render(). Це важливий патерн -- дані окремо, відображення окремо. Він стане основою React у Block 8.


Крок 4: Функція render

Функція render() бере масив todos, фільтрує за поточним фільтром та відображає у DOM:

const todoList = document.querySelector("#todo-list");
const todoCount = document.querySelector("#todo-count");

function render() {
  // Фільтруємо задачі
  const filtered = todos.filter((todo) => {
    if (currentFilter === "active") return !todo.completed;
    if (currentFilter === "completed") return todo.completed;
    return true; // "all"
  });

  // Генеруємо HTML
  todoList.innerHTML = filtered
    .map(
      (todo) => `
      <li class="todo-item ${todo.completed ? "completed" : ""}" data-id="${todo.id}">
        <input type="checkbox" ${todo.completed ? "checked" : ""} />
        <span class="todo-text">${todo.text}</span>
        <button class="delete-btn">&times;</button>
      </li>
    `
    )
    .join("");

  // Оновлюємо лічильник активних задач
  const activeCount = todos.filter((t) => !t.completed).length;
  todoCount.textContent = activeCount;
}

Крок 5: Додавання задачі

Обробляємо submit форми -- створюємо нову задачу та додаємо до масиву:

const form = document.querySelector("#todo-form");
const input = document.querySelector("#todo-input");

form.addEventListener("submit", (event) => {
  event.preventDefault();

  const text = input.value.trim();
  if (!text) return;

  const newTodo = {
    id: Date.now(), // унікальний id на основі часу
    text: text,
    completed: false,
  };

  todos.push(newTodo);
  input.value = ""; // очищаємо поле
  saveTodos();       // зберігаємо в localStorage
  render();          // оновлюємо DOM
});

Крок 6: Toggle та Delete через делегування подій

Замість додавати обробник на кожну кнопку, використовуємо делегування подій (пам'ятаєш з уроку 5.4?):

todoList.addEventListener("click", (event) => {
  const item = event.target.closest(".todo-item");
  if (!item) return;

  const id = Number(item.dataset.id);

  // Toggle complete -- клік на checkbox або текст
  if (
    event.target.matches("input[type='checkbox']") ||
    event.target.matches(".todo-text")
  ) {
    const todo = todos.find((t) => t.id === id);
    if (todo) {
      todo.completed = !todo.completed;
      saveTodos();
      render();
    }
  }

  // Delete -- клік на кнопку видалення
  if (event.target.matches(".delete-btn")) {
    todos = todos.filter((t) => t.id !== id);
    saveTodos();
    render();
  }
});
Увага

Ми шукаємо задачу за id з data-id атрибуту. dataset.id повертає рядок, тому конвертуємо його через Number(). Без цього порівняння === не спрацює, бо "123" !== 123.


Крок 7: Фільтрація

Обробляємо кліки по кнопках фільтрів:

const filtersContainer = document.querySelector(".filters");

filtersContainer.addEventListener("click", (event) => {
  if (!event.target.matches(".filter-btn")) return;

  // Оновлюємо активну кнопку
  filtersContainer.querySelector(".active").classList.remove("active");
  event.target.classList.add("active");

  // Оновлюємо фільтр і перемальовуємо
  currentFilter = event.target.dataset.filter;
  render();
});

Крок 8: localStorage -- збереження та завантаження

Фінальний крок -- дані зберігаються у localStorage і відновлюються при завантаженні сторінки:

function saveTodos() {
  localStorage.setItem("todos", JSON.stringify(todos));
}

function loadTodos() {
  const saved = localStorage.getItem("todos");
  todos = saved ? JSON.parse(saved) : [];
}

Викликаємо loadTodos() при старті і робимо перший рендер:

// Ініціалізація при завантаженні
loadTodos();
render();

Повний код app.js

Ось весь JavaScript разом:

// === Стан ===
let todos = [];
let currentFilter = "all";

// === DOM-елементи ===
const form = document.querySelector("#todo-form");
const input = document.querySelector("#todo-input");
const todoList = document.querySelector("#todo-list");
const todoCount = document.querySelector("#todo-count");
const filtersContainer = document.querySelector(".filters");

// === Render ===
function render() {
  const filtered = todos.filter((todo) => {
    if (currentFilter === "active") return !todo.completed;
    if (currentFilter === "completed") return todo.completed;
    return true;
  });

  todoList.innerHTML = filtered
    .map(
      (todo) => `
      <li class="todo-item ${todo.completed ? "completed" : ""}" data-id="${todo.id}">
        <input type="checkbox" ${todo.completed ? "checked" : ""} />
        <span class="todo-text">${todo.text}</span>
        <button class="delete-btn">&times;</button>
      </li>
    `
    )
    .join("");

  const activeCount = todos.filter((t) => !t.completed).length;
  todoCount.textContent = activeCount;
}

// === localStorage ===
function saveTodos() {
  localStorage.setItem("todos", JSON.stringify(todos));
}

function loadTodos() {
  const saved = localStorage.getItem("todos");
  todos = saved ? JSON.parse(saved) : [];
}

// === Додавання ===
form.addEventListener("submit", (event) => {
  event.preventDefault();

  const text = input.value.trim();
  if (!text) return;

  todos.push({
    id: Date.now(),
    text: text,
    completed: false,
  });

  input.value = "";
  saveTodos();
  render();
});

// === Toggle та Delete (делегування) ===
todoList.addEventListener("click", (event) => {
  const item = event.target.closest(".todo-item");
  if (!item) return;

  const id = Number(item.dataset.id);

  if (
    event.target.matches("input[type='checkbox']") ||
    event.target.matches(".todo-text")
  ) {
    const todo = todos.find((t) => t.id === id);
    if (todo) {
      todo.completed = !todo.completed;
      saveTodos();
      render();
    }
  }

  if (event.target.matches(".delete-btn")) {
    todos = todos.filter((t) => t.id !== id);
    saveTodos();
    render();
  }
});

// === Фільтри ===
filtersContainer.addEventListener("click", (event) => {
  if (!event.target.matches(".filter-btn")) return;

  filtersContainer.querySelector(".active").classList.remove("active");
  event.target.classList.add("active");

  currentFilter = event.target.dataset.filter;
  render();
});

// === Старт ===
loadTodos();
render();

Ідеї для розширення

Якщо хочеш попрактикуватися більше, спробуй додати:

  • Редагування -- подвійний клік на текст задачі перетворює його на input
  • Очистити виконані -- кнопка, яка видаляє всі completed задачі
  • Дедлайн -- додати поле дати та підсвічувати прострочені задачі
  • Drag & Drop -- перетягування для зміни порядку задач
  • Категорії -- теги або групи для задач (робота, навчання, особисте)
Порада

Не намагайся реалізувати все одразу. Вибери одну ідею, реалізуй її, потім переходь до наступної. Саме так працюють професійні розробники -- маленькими ітераціями.


Підсумок Block 5

Вітаю! Ти пройшов увесь Block 5: JavaScript & DOM. Ось що ти тепер вмієш:

  • DOM-селектори -- знаходити будь-який елемент на сторінці
  • Маніпуляція DOM -- створювати, змінювати та видаляти елементи
  • Події -- реагувати на дії користувача (кліки, введення, submit)
  • Делегування подій -- ефективно обробляти події на динамічних елементах
  • Форми -- отримувати дані, валідувати, обробляти submit
  • localStorage -- зберігати дані у браузері

Ці навички -- фундамент для будь-якого фронтенд-розробника. У наступному блоці (Block 6: JavaScript Advanced) ми вивчимо ES6+, промісі, async/await та роботу з API.

Інфо

Корисні посилання:

  • TodoMVC -- колекція Todo List на різних фреймворках, щоб побачити як ту саму задачу вирішують по-різному
  • javascript.info: Делегування подій -- поглиблений матеріал (українською)
  • Спробуй відкрити DevTools (F12) -> Application -> Local Storage і подивись, як зберігаються твої задачі