La todo list est le grand classique pour apprendre JavaScript, et ce n'est pas un hasard : en une centaine de lignes, on manipule le DOM, on gère des évènements, on filtre des données et on les sauvegarde. Tu vas en construire une complète, en JavaScript pur (sans framework), accessible au clavier et qui garde tes tâches quand tu fermes l'onglet. Durée : environ 45 minutes.
Ce que tu vas construire
- un champ pour ajouter une tâche (bouton ou touche Entrée) ;
- une case à cocher pour marquer une tâche comme faite, et un bouton pour la supprimer ;
- des filtres « Toutes », « À faire » et « Faites » ;
- un compteur des tâches restantes ;
- une sauvegarde dans le navigateur avec
localStorage.
Crée un dossier avec index.html, style.css et script.js.
1. Le HTML
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ma todo list</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="app">
<h1>Ma todo list</h1>
<form id="todo-form" class="todo-form">
<label for="todo-input" class="visually-hidden">Nouvelle tâche</label>
<input id="todo-input" name="label" placeholder="Ex. : relire le chapitre 3" required maxlength="120" autocomplete="off">
<button type="submit">Ajouter</button>
</form>
<div class="filters" role="group" aria-label="Filtrer les tâches">
<button type="button" data-filter="all" aria-pressed="true">Toutes</button>
<button type="button" data-filter="active" aria-pressed="false">À faire</button>
<button type="button" data-filter="done" aria-pressed="false">Faites</button>
</div>
<ul id="todo-list" class="todo-list"></ul>
<p id="counter" class="counter" aria-live="polite"></p>
</main>
<script src="script.js"></script>
</body>
</html>
Quelques choix qui comptent :
- un vrai
<form>: la touche Entrée envoie le formulaire sans une ligne de code en plus, etrequiredempêche d'ajouter une tâche vide ; - le
<label>du champ est masqué visuellement (classevisually-hidden) mais reste lu par les lecteurs d'écran : unplaceholderne le remplace pas ; - les filtres sont des boutons avec
aria-pressed, qui indique lequel est actif ; aria-live="polite"sur le compteur : les lecteurs d'écran annoncent le nouveau nombre de tâches.
2. Le CSS
body {
margin: 0;
background: #f6f4ef;
font-family: system-ui, sans-serif;
line-height: 1.5;
}
.app {
max-width: 32rem;
margin: 0 auto;
padding: 2rem 1rem;
}
.todo-form {
display: flex;
gap: 0.5rem;
}
.todo-form input {
flex: 1;
padding: 0.5rem;
border: 1px solid #bbb;
border-radius: 6px;
font: inherit;
}
button {
padding: 0.5rem 1rem;
border: 0;
border-radius: 6px;
background: #25737d;
color: #fff;
font: inherit;
cursor: pointer;
}
.filters {
display: flex;
gap: 0.5rem;
margin: 1rem 0;
}
.filters button {
background: #e6ecec;
color: #1d2b2d;
}
.filters button[aria-pressed="true"] {
background: #25737d;
color: #fff;
}
.todo-list {
margin: 0;
padding: 0;
list-style: none;
}
.todo-list li {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 8px;
background: #fff;
}
.todo-list label {
flex: 1;
}
.todo-list .done label {
color: #888;
text-decoration: line-through;
}
.todo-list .remove {
padding: 0.25rem 0.6rem;
background: transparent;
color: #a33;
font-size: 1.25rem;
line-height: 1;
}
.counter {
color: #666;
font-size: 0.875rem;
}
/* Invisible à l'écran, mais lu par les lecteurs d'écran. */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
Le style des filtres repose sur l'attribut aria-pressed : le même attribut sert à l'accessibilité et à l'apparence, impossible qu'ils se contredisent. Une tâche faite est barrée grâce à la classe done sur sa ligne.
3. Le JavaScript
const STORAGE_KEY = 'ma-todo-list';
const form = document.querySelector('#todo-form');
const input = document.querySelector('#todo-input');
const list = document.querySelector('#todo-list');
const counter = document.querySelector('#counter');
const filterButtons = document.querySelectorAll('[data-filter]');
let todos = load();
let filter = 'all';
function load() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
} catch {
return [];
}
}
function save() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
function createItem(todo) {
const item = document.createElement('li');
item.dataset.id = todo.id;
item.classList.toggle('done', todo.done);
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = 'todo-' + todo.id;
checkbox.checked = todo.done;
// Le label relié à la case : cliquer sur le texte coche la tâche.
const label = document.createElement('label');
label.htmlFor = checkbox.id;
label.textContent = todo.label;
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'remove';
remove.textContent = '×';
remove.setAttribute('aria-label', `Supprimer « ${todo.label} »`);
item.append(checkbox, label, remove);
return item;
}
function renderCounter() {
const left = todos.filter((todo) => !todo.done).length;
if (todos.length === 0) {
counter.textContent = 'Aucune tâche : profites-en !';
} else if (left === 0) {
counter.textContent = 'Tout est fait, bravo !';
} else {
counter.textContent = `${left} tâche${left > 1 ? 's' : ''} à faire`;
}
}
function render() {
const visible = todos.filter((todo) => {
if (filter === 'active') return !todo.done;
if (filter === 'done') return todo.done;
return true;
});
list.replaceChildren(...visible.map(createItem));
renderCounter();
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const label = input.value.trim();
if (label === '') {
return;
}
todos.push({ id: Date.now().toString(36), label, done: false });
save();
render();
form.reset();
input.focus();
});
// Cocher ou décocher une tâche.
list.addEventListener('change', (event) => {
const item = event.target.closest('li');
const done = event.target.checked;
todos = todos.map((todo) => (todo.id === item.dataset.id ? { ...todo, done } : todo));
save();
// Avec le filtre « Toutes », on met juste la ligne à jour : la case garde le focus.
if (filter === 'all') {
item.classList.toggle('done', done);
renderCounter();
} else {
render();
}
});
// Supprimer une tâche.
list.addEventListener('click', (event) => {
if (!event.target.classList.contains('remove')) {
return;
}
const id = event.target.closest('li').dataset.id;
todos = todos.filter((todo) => todo.id !== id);
save();
render();
input.focus();
});
filterButtons.forEach((button) => {
button.addEventListener('click', () => {
filter = button.dataset.filter;
filterButtons.forEach((other) => other.setAttribute('aria-pressed', String(other === button)));
render();
});
});
render();
Comme dans toute application, les données commandent l'affichage : le tableau todos contient les tâches ({ id, label, done }), et render() reconstruit la liste à partir de lui, en tenant compte du filtre.
createItem()fabrique une ligne aveccreateElementettextContent. Le texte saisi n'est jamais interprété comme du HTML : pas de faille XSS possible.- Le
labelrelié à la case parhtmlFor: cliquer sur le texte coche la tâche. La zone cliquable est plus grande, sur mobile comme à la souris. - Le bouton de suppression affiche « × », mais son
aria-labelnomme la tâche (Supprimer « Faire les exercices ») : un lecteur d'écran sait ce qu'il supprime. - La délégation d'évènements : un écouteur
changeet un écouteurclicksur la liste suffisent pour toutes les tâches, même celles ajoutées plus tard. - Le focus : avec le filtre « Toutes », cocher une tâche met seulement sa ligne à jour. Reconstruire toute la liste ferait perdre le focus à la personne qui navigue au clavier.
4. Tester
- Ajoute trois tâches avec la touche Entrée : le champ se vide et garde le focus.
- Coche une tâche en cliquant sur son texte : elle est barrée, le compteur baisse.
- Essaie les filtres, puis recharge la page : tout est conservé.
- Fais tout au clavier : Tab pour avancer, Espace pour cocher, Entrée sur « × » pour supprimer.
Pour aller plus loin
- Modifier une tâche par un double-clic sur son texte : remplace le
labelpar un champ, et enregistre à la touche Entrée. Le tutoriel application de prise de notes montre comment gérer la modification. - Un bouton « Effacer les tâches faites » : une ligne avec
filter(). - Des dates d'échéance : un
<input type="date">, et les tâches en retard en rouge. - Tout savoir sur la sauvegarde dans le tutoriel localStorage en JavaScript.
Ta todo list a son propre style ? Montre-la dans la vitrine des lecteurs.