Создание Todo List на чистом JavaScript

Создание Todo List на чистом JavaScript
Содержание:
  1. Что такое Todo List?
  2. Исходные коды SimpleTodoList
  3. Описание процесса создания SimpleTodoList
  4. Преобразование JavaScript для запуска в Internet Explorer 11
  5. Задачи
  6. Комментарии

В этой статье попрактикуемся на чистом на JavaScript на примере создания программы «Списка дел (Todo List)». При написании кода будем использовать современный синтаксис, но также сделаем так чтобы он работал в старых браузерах, включая Internet Explorer 11.

Что такое Todo List?

Todo List – это список дел, которые вам нужно выполнить или того, что вы хотите сделать.

Традиционно их пишут на листке бумаги и организовывают в порядке приоритета. При выполнении задачи, её обычно вычеркивают из списка.

Но такой список можно вести не только на листке бумаги, но и в электронном виде, например, браузере.

Исходные коды SimpleTodoList

SimpleTodoList – это название проекта, который мы создадим в рамках данной статьи для ведения списка задач. Напишем его он на HTML, CSS и чистом JavaScript.

Пошаговый процесс его создания приведён в следующем разделе этой статьи, а в этом его демо и исходные коды.

Todo List на чистом JavaScript

Демо SimpleTodoList

Исходные коды SimpleTodoList расположены в соответствующей папки проекта «ui-components» на GitHub.

Состоит SimpleTodoList из 3 файлов: «index.html» (вёрстки), «simple-todo-list.css» (стилей) и «simple-todo-list.js» (скрипта).

SimpleTodoList использует localStorage для хранения задач. Это позволяет при повторном открытии этой страницы или её обновлении считывать данные с веб-хранилища и на их основе воссоздавать последнее состояние списка.

Сохранение данных в хранилище происходит всякий раз, когда изменяется состояния этого списка. Это необходимо для того, чтобы в localStorage всегда находились актуальные сведения.

SimpleTodoList позволяет:

  • добавлять новые задачи в список;
  • отмечать выполненные задачи (при этом они сразу исключаются из списка активных задач и переводятся в завершённый);
  • удалять элементы в корзину;
  • удалять окончательно задачи в корзине, а также при необходимости восстанавливать их;
  • переключаться между делами (активными, завершёнными и удалёнными);
  • автоматически сохранять списки дел в localStorage (необходимо для восстановления последнего состояния списка при повторном открытии страницы).

Описание процесса создания SimpleTodoList

Разработку SimpleTodoList выполним за 5 шагов.

Шаг 1. Создание файловой структуры

Файловая структура проекта:

Файловая структура Todo List

Шаг 2. Добавление в index.html базовой структуры

Откроем «index.html», добавим в него базовую разметку, а также подключим файлы со стилями и JavaScript.

HTML
<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <title>Todo List on pure JavaScript</title>
  <link rel="stylesheet" href="simple-todo-list.css">
  <script defer src="simple-todo-list.js"></script>
  <style>
    *, *::before, *::after {
      box-sizing: border-box;
    }
    body {
      margin: 0;
      font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
      font-size: 1rem;
      font-weight: 400;
      line-height: 1.5;
      color: #212529;
      background-color: #fff;
      -webkit-text-size-adjust: 100%;
      -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
    }
  </style>
</head>
<body>

</body>
</html>

Шаг 3. Выполнение разметки самого todo

Разметим блок todo:

HTML
<div class="todo">
  <div class="todo__input">
    <input class="todo__text" type="text">
    <span class="todo__add"></span>
  </div>
  <select class="todo__options">
    <option value="active">активные</option>
    <option value="completed">завершённые</option>
    <option value="deleted">удалённые</option>
  </select>
  <ul class="todo__items" data-todo-option="active"></ul>
</div>

.todo содержит:

  • текстовое поле (<input> с классом todo__text) для ввода новой задачи;
  • кнопку .todo__add для добавления новой задачи в .todo__items;
  • элемент управления <select> с выпадающим список задач для переключения между ними;
  • список задач .todo__items, добавлять их в него будем с помощью JavaScript.

Скриншот того, что у нас вышло:

Скриншот Todo List после выполнения разметки

HTML-код самой задачи:

HTML
<li class="todo__item" data-todo-state="active">
  <span class="todo__task">Текст задачи</span>
  <span class="todo__action todo__action_restore" data-todo-action="active"></span>
  <span class="todo__action todo__action_complete" data-todo-action="completed"></span>
  <span class="todo__action todo__action_delete" data-todo-action="deleted"></span>
</li>

Его формирование и вставку в .todo__items будем осуществлять с помощью JavaScript.

Значение атрибута data-todo-state будет определять состояние задачи:

  • active – активная;
  • completed – выполненная;
  • deleted – удалённая.

.todo__task – это элемент, который содержит текст задачи, а .todo__action – это кнопки для выполнения действий над задачей. С помощью них мы можем восстановить задачу (перевести её в активное состояние), отметить задачу как завершённую и удалить. Действие, которое выполняет кнопка .todo__action определяется значением атрибута data-todo-action.

Шаг 4. Написание стилей

Написать стили можно по-разному. Пример того, что получилось:

Скриншот Todo List после выполнения разметки

Конечный CSS код можно посмотреть на GitHub.

Разберём некоторые интересные моменты в этом коде.

1. Переключение отображение задач в .todo__items будем выполнять в зависимости от выбранной опции <select>. Для этого в вышеприведённом файле прописаны следующие стили:

CSS
[data-todo-option="active"] .todo__item:not([data-todo-state="active"]),
[data-todo-option="completed"] .todo__item:not([data-todo-state="completed"]),
[data-todo-option="deleted"] .todo__item:not([data-todo-state="deleted"]) {
  display: none;
}

Устанавливать значение атрибуту data-todo-option элемента .todo__items будем с помощью JavaScript при изменении выбранной опции <select>.

2. Скрытие кнопок для задач, которые не должны показываться для определённых состояний, осуществляется следующим образом:

CSS
[data-todo-state="active"] .todo__action_restore,
[data-todo-state="completed"] .todo__action_complete,
[data-todo-state="deleted"] .todo__action_complete {
  display: none;
}

Например, кнопку восстановить .todo__action_restore не будем отображать для активных задач [data-todo-state="active"]. Т.к. зачем переводить задачу в активное состояние, если она и так активна.

Шаг 5. Напишем JavaScript

Написание кода начнём с создания объекта todo:

JavaScript
const todo = {};

Он нужен только для того, чтобы лучше организовать наш код и не создавать кучу отдельных функций.

Поместим в todo следующие методы:

JavaScript
const todo = {
  action(e) {},
  add() {},
  create(text) {},
  init() {},
  update() {},
  save() {}
};

Начнём с init(). Данный метод будет осуществлять инициализацию Todo List.

Он выполняет следующие вещи:

  • получает из localStorage сохранённый список дел и если он есть, то вставляет его в .todo__items;
  • назначает обработчик события change на элемент .todo__options; в качестве обработчика используется update;
  • назначает обработчик события click на document; в качестве обработчика выступает action.

Код метода init():

JavaScript
init() {
  const fromStorage = localStorage.getItem('todo');
  if (fromStorage) {
    document.querySelector('.todo__items').innerHTML = fromStorage;
  }
  document.querySelector('.todo__options').addEventListener('change', this.update);
  document.addEventListener('click', this.action.bind(this));
}

Когда мы указываем в качестве обработчика функцию или метод объекта, то нужно просто передать ссылку, а не вызов.

При указании this.action мы с помощью bind установили в качестве this текущий контекст. Это нужно чтобы мы могли в action() получить объект todo с помощью this.

Метод create() очень простой, он будет просто возвращать HTML код самой задачи с указанным текстом:

JavaScript
create(text) {
  return `<li class="todo__item" data-todo-state="active">
    <span class="todo__task">${text}</span>
    <span class="todo__action todo__action_restore" data-todo-action="active"></span>
    <span class="todo__action todo__action_complete" data-todo-action="completed"></span>
    <span class="todo__action todo__action_delete" data-todo-action="deleted"></span></li>`;
}

save() получает содержимое .todo__items и устанавливает его в localStorage:

JavaScript
save() {
  localStorage.setItem('todo', document.querySelector('.todo__items').innerHTML);
}

update() используется в качестве обработчика:

JavaScript
update() {
  const option = document.querySelector('.todo__options').value;
  document.querySelector('.todo__items').dataset.todoOption = option;
  document.querySelector('.todo__text').disabled = option !== 'active';
}

При вызове устанавливает атрибуту data-todo-option элемента .todo__items значение выбранной опции <select>, а также значение свойству disabled элемента .todo__text:

add() добавляет при нажатии на кнопку задачу в список .todo__items. Для создания HTML-кода задачи используется create():

JavaScript
add() {
  const elemText = document.querySelector('.todo__text');
  if (elemText.disabled || !elemText.value.length) {
    return;
  }
  document.querySelector('.todo__items').insertAdjacentHTML('beforeend', this.create(elemText.value));
  elemText.value = '';
}

action вызывается, когда происходит событие click на документе:

JavaScript
action(e) {
  const target = e.target;
  if (target.classList.contains('todo__action')) {
    const action = target.dataset.todoAction;
    const elemItem = target.closest('.todo__item');
    if (action === 'deleted' && elemItem.dataset.todoState === 'deleted') {
      elemItem.remove();
    } else {
      elemItem.dataset.todoState = action;
    }
    this.save();
  } else if (target.classList.contains('todo__add')) {
    this.add();
    this.save();
  }
}

Параметр e – это объект события event. Его создаёт браузер и передаёт его в качестве первого аргумента action.

В коде e.target – это элемент, по которому кликнули. Так как нам нужны не любые клики, а только по определённым элементам, то используем следующие условия:

JavaScript
if (target.classList.contains('todo__action')) {
  // ...
} else if (target.classList.contains('todo__add')) {
  // ...
}

Если пользователь кликнул по .todo__add, то выполним следующие действия:

JavaScript
this.add();
this.save();

add() добавляет в список .todo__items новую задачу, а save() сохраняет все задачи (содержимое .todo__items) в localStorage.

Когда пользователь кликнул на .todo__action выполняется следующий код:

JavaScript
const action = target.dataset.todoAction;
const elemItem = target.closest('.todo__item');
if (action === 'deleted' && elemItem.dataset.todoState === 'deleted') {
  elemItem.remove();
} else {
  elemItem.dataset.todoState = action;
}
this.save();

Первое – получаем действие, которое нужно выполнить. Оно у нас находится в атрибуте data-todo-action. Далее находим элемент .todo__item и сохраняем его в переменную elemItem. Этот элемент нам понадобится дальше. После этого, если действие delete и состояние задачи delete, то удаляем элемент. В противном случае установим атрибуту data-todo-state элемента .todo__item значение action. В конце сохраним все изменения в localStorage с помощью save().

Последнее что нужно сделать чтобы Todo работал это вызвать init:

JavaScript
todo.init();

Преобразование JavaScript для запуска в Internet Explorer 11

1. Выполним транспилирование, т.е. преобразуем исходный синтаксис в такой, который понимают старые браузеры, включая Internet Explorer 11.

Для этого воспользуемся онлайн инструментом Babel REPL:

Преобразование исходного синтаксиса в такой, который поддерживает Internet Explorer 11

В targets указали: defaults, ie 11.

Полученный код скопируем и вставим в файл simple-todo-list.es5.js.

Теперь полученный синтаксис понимает Internet Explorer 11. Но этого не достаточно, т.к. в коде у нас остались два метода, который данный браузер не поддерживает. Это closest и remove.

2. Выполним полифилинг, т.е. добавим эти недостающие методы к старым браузерам путем предоставления им собственной версии.

JavaScript
// polyfill closest
if (!Element.prototype.matches) {
  Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
}
if (!Element.prototype.closest) {
  Element.prototype.closest = function(s) {
    var el = this;
    do {
      if (Element.prototype.matches.call(el, s)) return el;
      el = el.parentElement || el.parentNode;
    } while (el !== null && el.nodeType === 1);
    return null;
  };
}
// polyfill remove
if (!('remove' in Element.prototype)) {
  Element.prototype.remove = function() {
    if (this.parentNode) {
      this.parentNode.removeChild(this);
    }
  };
}

В результате получился следующий код: simple-todo-list.es5.js.

Задачи

1. Добавить возможность создавать задачу при нажатии Enter.

2. Переписать код так, чтобы в localStorage сохранялись задачи не в виде кода HTML, а в формате массива объектов:

JavaScript
[
  {
    task: 'Задача 1',
    state: 'active'
  },
  {
    task: 'Задача 2',
    state: 'completed'
  },
  ...
];

3. Внести в код возможность сортировки задач посредством перетаскивания (drag и drop).

4. Добавить всплывающие сообщения для информирования пользователя при выполнении действий над задачами.

5. Для любителей jQuery переписать весь код с использованием функций этой библиотеки.

Комментарии: 16

saba
saba
Здравствуйте Александр. Посетила ещё одна идея, возможно, Вам будет интересна.

Ниже от даты создания добавить поле для комментария с ограничением (например) в 100 символов касаемо только выбранного пункта.

Или, добавить выпадающий список, в котором человек выбирает один из вариантов, например: купить картошку, а в выпадающем выбрать «на рынке». Здесь, я так полагаю Todo должен иметь какое-то определенное направление. Хотя мне кажется, что первый вариант более практичней.
saba
saba
Здравствуйте Александр.

Возможно ли каким-то образом добавлять текущую дату к элементам списка.

Получилось добавлять дату при добавлении, но не могу понять как сделать так, что-бы эта дата менялась тогда, когда я из «активного» списка помечаю элемент как завершённый или удалённый?!

То есть, когда добавил в список, то дата например: «добавлено: 19.07.2022, 14:35». Когда пометил как «завершённый», то «завершено: 20.07.2022, 10:00», если удаляю, то: «удаленно: 21.07.2022, 18:20».

(даты разумеется для примера)

Если из (например) «завершённые» я отмечаю как «возобновить», то «завершено: 20.07.2022, 10:00» меняется на «добавлено: 21.07.2022, 16:12».

То, что я сделал:


create(text) {
	let idtext = text.toLowerCase();
	let dateList = new Date(); // создал дату
 	let now = dateList.toLocaleString().slice(0, -3); // убрал секунды
 	return `<li class="todo__item" data-todo-state="active">
    	   <span class="todo__task">${text}</span>
    	   <span class="todo__action todo__action_restore" data-todo-action="active"><br\ /><span id="dateMyList">добавлено: ${now}</span></span>
    	   <span class="todo__action todo__action_complete" data-todo-action="completed"></span>
    	   <span class="todo__action todo__action_delete" data-todo-action="deleted"></span></li>`;
}
Так же пытался сделать по другому, добавив вызов функции при клике по кнопкам «завершено» и «удалить», но если я нажму «завершено» (или «удалить») на два элемента по очереди, то «завершено: 20.07.2022, 10:00» добавится только к первому элементу, в области которого я совершил клик. То, что я сделал:


function changeDateComp () {
	let dateChangeComp = new Date();
	let nowDateChangeComp = dateChangeComp.toLocaleString().slice(0, -3);
	document.getElementById('dateMyList').innerHTML = 'завершено: ' + nowDateChangeComp;
}

function changeDateDel () {
 	let dateChangeDel = new Date();
 	let nowDateChangeDel = dateChangeDel.toLocaleString().slice(0, -3);
 	document.getElementById('dateMyList').innerHTML = 'удаленно: ' + nowDateChangeDel;
}

const crypto = {

	...

	create(text) {
		let idtext = text.toLowerCase();
		let dateList = new Date(); // создал дату
		let now = dateList.toLocaleString().slice(0, -3); // убрал секунды
		return `<li class="todo__item" data-todo-state="active">
	    		<span class="todo__task">${text}</span>
	    		<span class="todo__action todo__action_restore" data-todo-action="active"><br\ /><span id="dateMyList">добавлено: ${now}</span></span>
	    		<span class="todo__action todo__action_complete" data-todo-action="completed" onclick="changeDateComp()"></span>
	    		<span class="todo__action todo__action_delete" data-todo-action="deleted" onclick="changeDateDel()"></span></li>`;
	}

	...
}
Возможно ли вообще это сделать?!
Александр Мальцев
Александр Мальцев
Добрый день! Посмотрю как это сделать в ближайшее время.
saba
saba
Спасибо Александр, буду следить за обновлением или ответом в комментарии.
Александр Мальцев
Александр Мальцев
Понравились идея, решил внести это изменение в базовую версию. Но сделал немного по другому:
Todo List с историей
Если нужно как хотели именно вы, то закомментируйте указанную строчку и добавьте после неё новую:
// elTodoDate.insertAdjacentHTML('beforeend', html);
elTodoDate.innerHTML = html;
Новая версия уже на GitHub.
saba
saba
Спасибо Александр, как на меня — отличное решение!
saba
saba
Александр, а Вы у себя проверяли работоспособность обновленного кода с добавлением ссылок в элементам списка, так как у меня ссылки не подставляются?!
Александр Мальцев
Александр Мальцев
Внес такие же изменения в код на GitHub, который работает с ссылками.
saba
saba
Понял. Видимо я некорректно обновил код.
IG
IG
Здравствуйте, Александр. Спасибо, за подробный пример. Очень наглядно.
У меня есть вопрос: какие есть способы выгрузки todo-списка, скажем, на компьютер в формат.тхт?

п.с. небольшое замечание по работе сайта. При нажатии ссылки «сменить аватар» в личном кабинете, происходит просто переход на главную страницу.
Александр Мальцев
Александр Мальцев
Привет!
Можно, например, так (также добавил на Github):
<button class="btn" id="save"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"></path><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"></path></svg> Сохранить</button>
<script>
  document.querySelector('#save').addEventListener('click', (e) => {
    var a = document.createElement('a');
    a.download = 'tasks.txt';
    let content = '1. Активные задачи';
    let items = document.querySelectorAll('.todo__item[data-todo-state="active"]');
    items.forEach((item, index) => {
      content += '\n1.' + (index + 1) + '. ' + item.querySelector('.todo__task').textContent;
    });
    content += '\n\n2. Завершённые задачи';
    items = document.querySelectorAll('.todo__item[data-todo-state="completed"]');
    items.forEach((item, index) => {
      content += '\n2.' + (index + 1) + '. ' + item.querySelector('.todo__task').textContent;
    });
    content += '\n\n3. Удалённые задачи';
    items = document.querySelectorAll('.todo__item[data-todo-state="deleted"]');
    items.forEach((item, index) => {
      content += '\n3.' + (index + 1) + '. ' + item.querySelector('.todo__task').textContent;
    });
    a.href = window.URL.createObjectURL(new Blob([content], { type: 'text/plain' }));
    a.click();
  });
</script>
Спасибо, момент на сайте поправил.
saba
saba
Здравствуйте. Если это возможно, могли бы Вы на почту скинуть ответы, так как в программировании не разбираюсь, а данные Вами задачи действительно полезны?!
saba
saba
Пытался сделать дополнение по коду из сети, что-бы текст менялся на ссылку, но после этого не работают кнопки «удаленные» и «завершенные». Подскажите пожалуйте еще, возможно ли это сделать по другому, что-бы все работало?!

Код, который использовал:


<script type="text/javascript">
  window.onload = function() {
    document.body.innerHTML = document.body.innerHTML.replace(/Text1/, '<a href="#">Link1</a>');
    document.body.innerHTML = document.body.innerHTML.replace(/Text2/, '<a href="#">Link2</a>');
    document.body.innerHTML = document.body.innerHTML.replace(/Text3/, '<a href="#">Link 3</a>');
  }
</script>
Александр Мальцев
Александр Мальцев
Если хотите чтобы текст задачи был ссылкой, то оберните ${text} в <a href="#"></a> в файле «simple-todo-list.js»:
create(text) {
  return `<li class="todo__item" data-todo-state="active">
    <span class="todo__task"><a href="#">${text}</a></span>
    <span class="todo__action todo__action_restore" data-todo-action="active"></span>
    <span class="todo__action todo__action_complete" data-todo-action="completed"></span>
    <span class="todo__action todo__action_delete" data-todo-action="deleted"></span></li>`;
},
Александр Мальцев
Александр Мальцев
Привет! Ответов нет, на Github есть наброски для 3 задачи. Когда сделаю добавлю ссылки на эти решения.
Александр Мальцев
Александр Мальцев
Чтобы изменить href у ссылки на основании её текста после добавления таска на страницу можно посредством следующего скрипта (пример):
data = {
  'text1': '#href1',
  'text2': '#href2',
  'text3': '#href3'
};
document.addEventListener('todo-item-add', (e) => {
  const elTodo = document.querySelector('.todo');
  const links = [... elTodo.querySelectorAll('.todo__task a')];
  links.forEach(link => {
    if (data[link.textContent]) {
      link.href = data[link.textContent];
    }
  });
});
Выполняться код будет при наступлении события todo-item-add. Для генерирования этого события, его нужно прописать в add():
add() {
  // ...
  document.dispatchEvent(new Event('todo-item-add'));
},