Vanilla HTML+JS to-do list

by mcsf

HTML

<meta charset=utf8>
<link rel=stylesheet href=style.css>
<script defer src=script.js></script>

<dialog id=help onclick="help.close()">
	<ul>
		<li class=row><span>Add new task</span><kbd>Enter</kbd></li>
		<li class=row><span>Toggle status</span><kbd>Meta + Enter</kbd></li>
		<li class=row><span>Delete task, if empty</span><kbd>Backspace</kbd></li>
    <li class=row><span>Move through tasks</span><span><kbd>↑</kbd> <kbd>↓</kbd></span></li>
	</ul>
</dialog>
<button onclick="help.showModal()">Show keyboard shortcuts </button>

<form id=todos>
	<ul>
		<li draggable=true>
			<input name="1:done" type=checkbox>
			<input name="1:text" type=text value="Cross an item off">
		</li>
		<li draggable=true>
			<input name="2:done" type=checkbox>
			<input name="2:text" type=text value="Drag it around">
		</li>
		<li draggable=true>
			<input name="3:done" type=checkbox>
			<input name="3:text" type=text placeholder="Add a new task…">
		</li>
	</ul>
</form>

<template id=liTemplate>
	<li draggable=true>
		<input name="0:done" type=checkbox>
		<input name="0:text" type=text placeholder="Add a new task…">
	</li>
</template>

<pre id=out></pre>

CSS

@media (prefers-color-scheme: dark) {
	body {
		color: #f8f8f2;
		background: #282a36;
	}
}

ul {
	padding-inline: 0;
}

li {
	list-style: none;
}

kbd {
	border: 1px solid #444;
	border-radius: 3px;
	padding-inline: 3px;
}

.row {
	display: flex;
	justify-content: space-between;
	gap: 3rem;
	margin-block: 0.5rem;
}

.transparent {
	opacity: 0.4;
}

input[type="text"] {
	border: none;
	background: transparent;
	color: inherit;
}

dialog:modal {
	border-radius: 3px;
}

JavaScript

// LI element of a task as it is being dragged.
let dragTarget;

// Infer ID from DOM. Used to identify new tasks.
let lastId = Math.max.apply(null,
	Array.from(document.querySelectorAll('[name]'))
		.map(el => +el.name.split(':')[0]));

todos.addEventListener('input', (e) => display(e.currentTarget));
todos.addEventListener('submit', (e) => e.preventDefault());
todos.addEventListener('keydown', handleKeys);

function handleKeys(e) {
	if (e.target.tagName !== 'INPUT' || e.target.type !== 'text') return;
	switch (e.key) {
		case 'Enter':
			e.preventDefault();
			if (e.metaKey) {
				e.target.previousElementSibling.click();
			} else {
				const id = ++lastId;
				const li = liTemplate.content
					.firstElementChild.cloneNode(true);
				for (const input of li.querySelectorAll('[name]')) {
					input.name = input.name.replace('0', id);
				}
				e.target.closest('li').after(li);
				li.lastElementChild.focus();
			}
			break;

		case 'Backspace':
			if (
				e.target.value.length === 0
				&& e.target.closest('ul').children.length > 1
			) {
        e.preventDefault();
				const li = e.target.closest('li');
				const prev = li.previousElementSibling;
				li.remove();
				prev.querySelector('[type="text"]').focus();
			}
			break;
      
    case 'ArrowDown':
      e.target.closest('li').nextElementSibling
        ?.querySelector('[type="text"]').focus();
      break;
      
    case 'ArrowUp':
      e.target.closest('li').previousElementSibling
      	?.querySelector('[type="text"]').focus();
      break;
	}
}

todos.addEventListener('dragstart', (e) => {
	dragTarget = e.target;
	e.dataTransfer.effectAllowed = 'move';
	setTimeout(() => dragTarget.classList.add('transparent'), 0);
});

todos.addEventListener('dragend', (e) => {
	e.dataTransfer.effectAllowed = 'move';
	e.preventDefault();
	dragTarget.classList.remove('transparent');
	dragTarget = null;
	display(todos);
});

todos.addEventListener('dragover', (e) => {
	e.dataTransfer.dropEffect = 'move';
	const...