Le projet : organiser ses tâches à la glisse
Huitième projet, et l'interaction la plus « riche » de la série : un tableau Kanban. Trois colonnes (À faire, En cours, Fait) et des cartes qu'on fait glisser de l'une à l'autre. Tu ajoutes une tâche, tu la déplaces au fur et à mesure, et tout se mémorise. C'est l'outil que tu retrouves dans Trello ou Notion, en version minuscule.
Le glisser-déposer, c'est exactement le genre de fonctionnalité que l'IA produit en deux temps trois mouvements… pour la souris. Le problème, c'est que le glisser-déposer natif du navigateur ne marche qu'à la souris : au clavier, on est coincé. Et l'API a deux ou trois pièges qui font que « ça ne marche pas » sans qu'on comprenne pourquoi. On code vite, on relit lentement.
Le glisser-déposer HTML repose sur quelques événements : dragstart (on attrape), dragover (on survole une cible), drop (on lâche). On va voir qu'il manque un détail crucial pour que le dépôt soit même autorisé.
- Implémenter le glisser-déposer natif (
dragstart/dragover/drop) en comprenant pourquoipreventDefaultsurdragoverest obligatoire pour que le dépôt se déclenche. - Doubler l'interaction souris par un chemin clavier (boutons ‹ ›) qui appelle exactement la même logique de déplacement.
- Persister l'état sans XSS :
textContentpour afficher le texte,try/catchautour delocalStorage.
- tu peux déposer une carte sur une colonne (le
dropse déclenche effectivement) ; - tu peux déplacer une carte de colonne en colonne en n'utilisant que le clavier ;
- une tâche contenant
<script>alert(1)</script>s'affiche en texte brut, sans exécuter quoi que ce soit.
Prompt 1 : poser le cadre
On cadre : trois colonnes, un champ pour ajouter une tâche, et des cartes qu'on glisse entre colonnes, le tout sauvegardé.
Crée une mini-app web autonome, un seul fichier HTML (zéro dépendance). Un tableau Kanban à 3 colonnes (À faire, En cours, Fait). Un champ ajoute une tâche dans « À faire ». On peut glisser-déposer une carte d'une colonne à l'autre. Sauvegarde dans localStorage. Bilingue FR/EN. Code simple et lisible.
L'IA met en place le glisser-déposer de la façon classique :
card.addEventListener('dragstart', function (e) {
e.dataTransfer.setData('text/plain', card.id);
});
// sur chaque colonne :
col.addEventListener('drop', function (e) { // ⚠️ le drop ne se déclenchera jamais…
var id = e.dataTransfer.getData('text/plain');
moveTo(id, col.dataset.col);
});
On essaie de déposer une carte… et rien ne se passe. Pas d'erreur, juste : ça ne tombe pas. Et bien sûr, impossible de déplacer quoi que ce soit au clavier.
Relis ce code : l'IA a bien posé dragstart et drop sur les colonnes. À ton avis, quand tu relâches la carte sur une colonne, que se passe-t-il ? Pourquoi l'événement drop ne se déclenche-t-il jamais ?
Vérifier ma prédiction
Par défaut, le navigateur refuse le dépôt sur la plupart des éléments. Il faut lui signaler explicitement qu'une zone accepte les éléments glissés : on appelle e.preventDefault() sur l'événement dragover. C'est ce preventDefault-là (sur dragover, pas sur drop) qui lève l'interdiction. Tant qu'il manque, l'événement drop n'arrive jamais, même si le handler est bien en place.
Ma relecture humaine : 3 trucs que l'IA a laissés passer
Le glisser-déposer a la réputation d'être pénible, et ce n'est pas volé : l'API est pleine de petits pièges, et elle oublie complètement le clavier. Voici les trois points.
1. Sans preventDefault, le dépôt est refusé
C'est LE piège du glisser-déposer HTML : par défaut, la plupart des éléments n'autorisent pas qu'on lâche quelque chose dessus. Pour rendre une zone « déposable », il faut appeler e.preventDefault() sur l'événement dragover. Sans ça, l'événement drop ne se déclenche jamais, et on cherche le bug pendant dix minutes. Une ligne, mais elle change tout.
2. Le glisser-déposer exclut le clavier
Même réparé, le glisser-déposer reste inutilisable sans souris : pas de glisser au clavier, rien pour un lecteur d'écran. Plutôt que de réinventer une mécanique clavier compliquée, on ajoute le plus simple et le plus robuste : sur chaque carte, deux boutons ‹ › qui la déplacent vers la colonne précédente ou suivante. La souris garde le glisser ; le clavier a les boutons. Tout le monde peut jouer.
3. Le texte saisi et le stockage, toujours avec méfiance
Les réflexes des projets précédents restent valables : le texte d'une carte vient de l'utilisateur, donc textContent (jamais innerHTML) ; et on relit localStorage dans un try/catch en vérifiant que c'est bien un tableau. Un bon réflexe ne s'oublie pas d'un projet à l'autre.
Prompt 2 : durcir après relecture
Le piège du dépôt et l'oubli du clavier, on corrige les deux d'un coup :
Corrige : (1) ajoute e.preventDefault() sur l'événement dragover de chaque colonne, sinon le drop ne se déclenche pas. (2) Ajoute sur chaque carte deux boutons « ‹ » et « › » (avec aria-label) qui la déplacent vers la colonne précédente/suivante, désactivés aux extrémités, pour que ce soit utilisable au clavier. (3) Affiche le texte avec textContent et lis localStorage dans un try/catch.
// 1. SANS ça, le navigateur refuse le dépôt :
col.addEventListener('dragover', function (e) { e.preventDefault(); });
col.addEventListener('drop', function (e) {
e.preventDefault();
moveTo(e.dataTransfer.getData('text/plain'), colName);
});
// 2. l'alternative clavier : la MÊME fonction de déplacement, via des boutons
function nudge(id, dir) { // dir = -1 (‹) ou +1 (›)
var c = cards.find(function (x) { return x.id === id; });
var i = COLS.indexOf(c.col) + dir;
if (i < 0 || i > COLS.length - 1) return; // déjà à un bout
c.col = COLS[i]; save(); render();
}
Le réflexe à garder : une interaction « jolie » (le glisser) ne doit jamais être la seule façon de faire une action. On double toujours par un chemin simple et accessible. C'est plus de travail que l'IA n'en fait spontanément, et c'est exactement ce qui sépare une démo d'un vrai produit.
Héberger et tester
- Fichier statique, en ligne d'un dépôt.
- Glisse une carte d'une colonne à l'autre à la souris : elle doit se déposer (preuve que le
preventDefaultest en place). - Range la souris : avec Tab, atteins une carte, puis déplace-la avec les boutons ‹ ›. C'est le test d'accessibilité du projet.
- Recharge la page : les cartes sont dans les bonnes colonnes (persistance).
- Mets
<b>test</b>comme tâche : doit s'afficher tel quel. Bascule FR / EN, console à zéro.
Le rendu final
Le code complet (et téléchargeable)
Le fichier entier, exactement celui qui tourne au-dessus.
Télécharger le code (.html · 256 lignes)
Voir le code complet
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tableau Kanban</title>
<meta name="description" content="Un tableau de tâches qu'on réorganise à la glisse, et au clavier. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<!--
============================================================================
TABLEAU KANBAN — un seul fichier : HTML + CSS + JS, zéro dépendance.
1. <style> : l'apparence (3 colonnes, les cartes, le retour visuel au survol).
2. <body> : la structure (champ d'ajout + 3 colonnes À faire / En cours / Fait).
3. <script> : ajouter, déplacer (à la souris ET au clavier), supprimer, sauvegarder.
Point clé du projet : le glisser-déposer natif ne marche QU'À LA SOURIS.
On ajoute donc des boutons de déplacement, pour que ça reste utilisable au clavier.
============================================================================
-->
<style>
:root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; --soft:#eef1f4; }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; }
body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
.app { width:100%; max-width:880px; }
header { text-align:center; margin-bottom:18px; }
h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
.sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
.lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
.lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; }
.lang-btn[aria-pressed="true"] { background:var(--accent); color:#fff; }
.lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
.add-row { display:flex; gap:10px; margin-bottom:20px; }
.add-input { flex:1; min-height:48px; padding:0 14px; border:1.5px solid var(--border); border-radius:10px; font:inherit; font-size:1rem; color:var(--ink); }
.add-input:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
.btn { min-height:48px; padding:0 20px; border-radius:10px; border:1.5px solid transparent; font:inherit; font-weight:700; cursor:pointer; }
.btn-primary { background:var(--accent); color:#fff; }
.btn-primary:hover { background:#1f6a37; }
.btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
/* Le tableau : 3 colonnes côte à côte (et empilées sur mobile). */
.board { display:grid; grid-template-columns:repeat(3,1fr); gap:14px; }
.col { background:var(--soft); border:1.5px solid var(--border); border-radius:14px; padding:12px; min-height:160px; transition:background .15s, border-color .15s; }
.col.over { background:#e6f3ec; border-color:var(--accent); } /* survol pendant un glisser : on éclaire la colonne */
.col h2 { font-size:0.8rem; text-transform:uppercase; letter-spacing:0.05em; color:var(--muted); margin:2px 4px 10px; }
.col-cards { display:flex; flex-direction:column; gap:8px; min-height:40px; }
.card { background:#fff; border:1px solid var(--border); border-radius:10px; padding:10px 10px 8px; box-shadow:0 2px 6px rgba(15,25,35,0.06); cursor:grab; }
.card:focus-visible { outline:3px solid rgba(38,125,66,0.45); }
.card.dragging { opacity:0.5; }
.card-text { display:block; word-break:break-word; margin-bottom:8px; }
.card-actions { display:flex; gap:4px; }
.icon-btn { width:30px; height:30px; border:1px solid var(--border); background:#fff; border-radius:7px; cursor:pointer; font-size:0.95rem; line-height:1; color:var(--muted); }
.icon-btn:hover:not([disabled]) { border-color:var(--accent); color:var(--accent); }
.icon-btn[disabled] { opacity:0.35; cursor:default; }
.icon-btn.del { margin-left:auto; }
.icon-btn.del:hover { border-color:#a8341f; color:#a8341f; }
.icon-btn:focus-visible { outline:3px solid rgba(38,125,66,0.45); }
.credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:24px; }
.credit a { color:var(--accent); }
@media (max-width:620px){ .board { grid-template-columns:1fr; } }
@media (prefers-reduced-motion: reduce){ .col, .card { transition:none; } }
</style>
</head>
<body>
<main class="app">
<header>
<h1 data-i18n="title">Tableau Kanban</h1>
<p class="sub" data-i18n="sub">Glisse une carte d'une colonne à l'autre, ou déplace-la au clavier.</p>
<div class="lang-switch" role="group" aria-label="Langue / Language">
<button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
<button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
</div>
</header>
<form class="add-row" id="add-form">
<input class="add-input" id="add-input" type="text" maxlength="80" data-i18n-ph="ph" placeholder="Nouvelle tâche (ex. Relire le code de l'IA)" aria-label="Nouvelle tâche">
<button class="btn btn-primary" type="submit" data-i18n="add">Ajouter</button>
</form>
<!-- 3 colonnes fixes. Chaque .col est une zone où l'on peut déposer une carte. -->
<div class="board">
<section class="col" data-col="todo" aria-label="À faire"><h2 data-i18n="todo">À faire</h2><div class="col-cards" id="col-todo"></div></section>
<section class="col" data-col="doing" aria-label="En cours"><h2 data-i18n="doing">En cours</h2><div class="col-cards" id="col-doing"></div></section>
<section class="col" data-col="done" aria-label="Fait"><h2 data-i18n="done">Fait</h2><div class="col-cards" id="col-done"></div></section>
</div>
<p class="credit" data-i18n="credit">Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com</p>
</main>
<script>
(function () {
'use strict';
/* ---------------------------------------------------------------------------
1. LES TEXTES (FR / EN). "cols" donne le titre de chaque colonne.
--------------------------------------------------------------------------- */
var UI = {
fr: { title:"Tableau Kanban", sub:"Glisse une carte d'une colonne à l'autre, ou déplace-la au clavier.",
add:"Ajouter", ph:"Nouvelle tâche (ex. Relire le code de l'IA)",
todo:"À faire", doing:"En cours", done:"Fait",
prev:"Déplacer vers la colonne précédente", next:"Déplacer vers la colonne suivante", del:"Supprimer la tâche",
credit:'Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com' },
en: { title:"Kanban board", sub:"Drag a card from one column to another — or move it with the keyboard.",
add:"Add", ph:"New task (e.g. Review the AI's code)",
todo:"To do", doing:"Doing", done:"Done",
prev:"Move to previous column", next:"Move to next column", del:"Delete task",
credit:'A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course — web-developpeur.com' }
};
var COLS = ['todo', 'doing', 'done']; // l'ordre des colonnes, de gauche à droite
/* ---------------------------------------------------------------------------
2. LES ÉLÉMENTS + L'ÉTAT.
--------------------------------------------------------------------------- */
var form = document.getElementById('add-form');
var input = document.getElementById('add-input');
var lists = { todo: document.getElementById('col-todo'), doing: document.getElementById('col-doing'), done: document.getElementById('col-done') };
var lang = 'fr';
try { lang = localStorage.getItem('kanban-lang') || 'fr'; } catch (e) {}
if (lang !== 'fr' && lang !== 'en') lang = 'fr';
// Chargement PRUDENT (le stockage peut être vide ou corrompu).
function load() {
try { var raw = localStorage.getItem('kanban-cards'); if (!raw) return []; var d = JSON.parse(raw); return Array.isArray(d) ? d : []; }
catch (e) { return []; }
}
function save() { try { localStorage.setItem('kanban-cards', JSON.stringify(cards)); } catch (e) {} }
var cards = load(); // [{ id, text, col }]
function uid() { return 'c' + Date.now() + Math.floor(Math.random() * 1000); }
/* ---------------------------------------------------------------------------
3. DÉPLACER UNE CARTE — la même fonction sert à la souris ET au clavier.
--------------------------------------------------------------------------- */
function moveTo(id, colName) {
var c = cards.find(function (x) { return x.id === id; });
if (c && c.col !== colName) { c.col = colName; save(); render(); }
}
// dir = -1 (gauche) ou +1 (droite). On reste dans les limites des colonnes.
function nudge(id, dir) {
var c = cards.find(function (x) { return x.id === id; });
if (!c) return;
var i = COLS.indexOf(c.col) + dir;
if (i < 0 || i > COLS.length - 1) return; // déjà à un bout : on ne fait rien
c.col = COLS[i]; save(); render();
}
/* ---------------------------------------------------------------------------
4. L'AFFICHAGE — on reconstruit les 3 colonnes à partir du tableau "cards".
--------------------------------------------------------------------------- */
function render() {
COLS.forEach(function (col) { lists[col].innerHTML = ''; });
cards.forEach(function (c) {
var card = document.createElement('div');
card.className = 'card';
card.setAttribute('draggable', 'true'); // déplaçable à la souris
card.tabIndex = 0; // focusable au clavier
var span = document.createElement('span');
span.className = 'card-text';
span.textContent = c.text; // textContent : le texte est saisi, jamais interprété comme du HTML
var actions = document.createElement('div');
actions.className = 'card-actions';
// Boutons de déplacement = l'alternative CLAVIER au glisser-déposer.
var i = COLS.indexOf(c.col);
var left = document.createElement('button');
left.className = 'icon-btn'; left.type = 'button'; left.textContent = '‹';
left.setAttribute('aria-label', UI[lang].prev); left.disabled = (i === 0);
left.addEventListener('click', function () { nudge(c.id, -1); });
var right = document.createElement('button');
right.className = 'icon-btn'; right.type = 'button'; right.textContent = '›';
right.setAttribute('aria-label', UI[lang].next); right.disabled = (i === COLS.length - 1);
right.addEventListener('click', function () { nudge(c.id, 1); });
var del = document.createElement('button');
del.className = 'icon-btn del'; del.type = 'button'; del.textContent = '✕';
del.setAttribute('aria-label', UI[lang].del);
del.addEventListener('click', function () { cards = cards.filter(function (x) { return x.id !== c.id; }); save(); render(); });
// Glisser : on retient l'id de la carte qu'on déplace.
card.addEventListener('dragstart', function (e) {
e.dataTransfer.setData('text/plain', c.id);
card.classList.add('dragging');
});
card.addEventListener('dragend', function () { card.classList.remove('dragging'); });
actions.appendChild(left); actions.appendChild(right); actions.appendChild(del);
card.appendChild(span); card.appendChild(actions);
lists[c.col].appendChild(card);
});
}
/* ---------------------------------------------------------------------------
5. LES ZONES DE DÉPÔT — branchées une seule fois sur chaque colonne.
--------------------------------------------------------------------------- */
document.querySelectorAll('.col').forEach(function (col) {
var colName = col.getAttribute('data-col');
// IMPORTANT : sans preventDefault sur dragover, le navigateur REFUSE le dépôt.
col.addEventListener('dragover', function (e) { e.preventDefault(); col.classList.add('over'); });
col.addEventListener('dragleave', function () { col.classList.remove('over'); });
col.addEventListener('drop', function (e) {
e.preventDefault();
col.classList.remove('over');
var id = e.dataTransfer.getData('text/plain');
moveTo(id, colName);
});
});
/* ---------------------------------------------------------------------------
6. LES ACTIONS + LE DÉMARRAGE.
--------------------------------------------------------------------------- */
form.addEventListener('submit', function (e) {
e.preventDefault();
var v = input.value.trim();
if (!v) return;
cards.push({ id: uid(), text: v, col: 'todo' }); // toute nouvelle tâche arrive dans "À faire"
save(); input.value = ''; render();
});
function setLang(next) {
lang = next;
try { localStorage.setItem('kanban-lang', lang); } catch (e) {}
document.documentElement.lang = lang;
var t = UI[lang];
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n'); if (!t[k]) return;
if (k === 'credit') el.innerHTML = t[k]; else el.textContent = t[k];
});
var ph = document.querySelector('[data-i18n-ph]'); if (ph) ph.placeholder = t.ph;
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
});
render(); // les libellés des boutons (aria-label) changent avec la langue
}
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); });
});
setLang(lang);
})();
</script>
</body>
</html>
À toi de jouer
La meilleure façon de retenir tout ça, c'est de faire grandir le projet à ta main, en appliquant les mêmes réflexes à chaque ajout. Quelques pistes ouvertes :
- Permets de réordonner les cartes à l'intérieur d'une colonne (et pas seulement de changer de colonne).
- Affiche un compteur de cartes par colonne.
- Ajoute une 4e colonne « En attente » : vérifie que les boutons ‹ › se désactivent correctement aux deux nouvelles extrémités.
Ajoute un bouton « Supprimer » sur chaque carte, qui demande confirmation avant de l'effacer (utilise confirm() ou un état interne), puis met à jour localStorage. Tente-le seul d'abord : c'est en produisant sans modèle qu'on sort vraiment du tutoriel. Tu ne déplies le corrigé qu'après avoir essayé.
Tu as réussi si :
- le clic demande confirmation avant de supprimer (pas de suppression accidentelle) ;
- la suppression met à jour
localStorageet le rendu sans recharger la page ; - le bouton a un
aria-labelexplicite (pas juste « × ») pour les utilisateurs de lecteur d'écran.
Voir une solution possible
// Dans la fonction render(), sur chaque carte :
var delBtn = document.createElement('button');
delBtn.className = 'icon-btn del';
// aria-label explicite : le lecteur d'écran annonce "Supprimer Titre de la tâche"
delBtn.setAttribute('aria-label', 'Supprimer ' + card.text);
delBtn.textContent = '×'; // textContent, pas innerHTML — réflexe du parcours
delBtn.addEventListener('click', function () {
// Confirmation avant de détruire — erreur irréversible sans ça
if (!confirm('Supprimer cette tâche ?')) return;
cards = cards.filter(function (c) { return c.id !== card.id; });
// try/catch : si localStorage est plein ou désactivé, l'UI reste cohérente
try { localStorage.setItem('kanban', JSON.stringify(cards)); } catch (e) {}
render();
});
cardEl.querySelector('.card-actions').appendChild(delBtn);
Le réflexe clé : toute action destructive mérite une confirmation. aria-label porte le contexte complet (pas juste « × ») pour que la personne au clavier sache quelle carte elle va effacer. Et try/catch autour de localStorage.setItem : le stockage peut être plein ou refusé en mode privé, on ne laisse pas l'erreur silencieuse remonter.
À chaque ajout : l'action est-elle possible au clavier ? le texte est-il échappé ? l'état est-il sauvegardé ?
The project: organizing tasks by dragging
Eighth project, and the "richest" interaction of the series: a Kanban board. Three columns — To do, Doing, Done — and cards you drag from one to another. You add a task, move it along, and everything is saved. It's the tool you find in Trello or Notion, in a tiny version.
Drag and drop is exactly the kind of feature the AI whips up in no time… for the mouse. The problem is that the browser's native drag and drop only works with a mouse: with the keyboard, you're stuck. And the API has two or three gotchas that make it "not work" without you understanding why. Code fast, review slowly.
HTML drag and drop relies on a few events: dragstart (you grab), dragover (you hover a target), drop (you release). We'll see there's a crucial detail missing for the drop to even be allowed.
- Implement native drag and drop (
dragstart/dragover/drop) — understanding whypreventDefaultondragoveris mandatory for the drop to ever fire. - Back up the mouse interaction with a keyboard path (‹ › buttons) that calls the exact same move logic.
- Persist state without XSS:
textContentto display user text,try/catcharoundlocalStorage.
- you can drop a card onto a column (the
dropevent actually fires); - you can move a card from column to column using only the keyboard;
- a task containing
<script>alert(1)</script>shows as plain text, executing nothing.
Prompt 1: set the frame
We frame it: three columns, a field to add a task, and cards you drag between columns, all saved.
Create a standalone web mini-app, a single HTML file (zero dependencies). A 3-column Kanban board (To do, Doing, Done). A field adds a task to "To do". You can drag and drop a card from one column to another. Save to localStorage. Bilingual FR/EN. Simple, readable code.
The AI sets up drag and drop the classic way:
card.addEventListener('dragstart', function (e) {
e.dataTransfer.setData('text/plain', card.id);
});
// on each column:
col.addEventListener('drop', function (e) { // ⚠️ drop will never fire…
var id = e.dataTransfer.getData('text/plain');
moveTo(id, col.dataset.col);
});
You try to drop a card… and nothing happens. No error, just: it won't drop. And of course, you can't move anything with the keyboard.
Re-read this code: the AI correctly wired dragstart and drop on the columns. In your view, when you release the card over a column, what happens? Why does the drop event never fire?
Check my prediction
By default, the browser refuses the drop on most elements. You must explicitly signal that a zone accepts dragged items: call e.preventDefault() on the dragover event. It's that preventDefault — on dragover, not on drop — that lifts the restriction. Without it, the drop event never arrives, even though the handler is properly attached.
My human review: 3 things the AI let slip
Drag and drop has a reputation for being painful, and it's earned: the API is full of little traps, and it forgets the keyboard entirely. Here are the three points.
1. Without preventDefault, the drop is refused
This is THE gotcha of HTML drag and drop: by default, most elements don't allow something to be dropped on them. To make a zone "droppable", you must call e.preventDefault() on the dragover event. Without it, the drop event never fires — and you hunt the bug for ten minutes. One line, but it changes everything.
2. Drag and drop excludes the keyboard
Even fixed, drag and drop stays unusable without a mouse: no keyboard dragging, nothing for a screen reader. Rather than reinventing a complicated keyboard mechanic, we add the simplest, most robust thing: on each card, two ‹ › buttons that move it to the previous or next column. The mouse keeps the dragging; the keyboard gets the buttons. Everyone can play.
3. Typed text and storage, always with caution
The reflexes from earlier projects still hold: a card's text comes from the user, so textContent (never innerHTML); and we read localStorage inside a try/catch, checking it's actually an array. A good reflex isn't forgotten from one project to the next.
Prompt 2: harden after review
The drop gotcha and the forgotten keyboard — we fix both at once:
Fix: (1) add e.preventDefault() on each column's dragover event, otherwise the drop doesn't fire. (2) Add on each card two "‹" and "›" buttons (with aria-label) that move it to the previous/next column, disabled at the ends, so it's keyboard-usable. (3) Display the text with textContent and read localStorage inside a try/catch.
// 1. WITHOUT this, the browser refuses the drop:
col.addEventListener('dragover', function (e) { e.preventDefault(); });
col.addEventListener('drop', function (e) {
e.preventDefault();
moveTo(e.dataTransfer.getData('text/plain'), colName);
});
// 2. the keyboard alternative: the SAME move function, via buttons
function nudge(id, dir) { // dir = -1 (‹) or +1 (›)
var c = cards.find(function (x) { return x.id === id; });
var i = COLS.indexOf(c.col) + dir;
if (i < 0 || i > COLS.length - 1) return; // already at an end
c.col = COLS[i]; save(); render();
}
The reflex to keep: a "pretty" interaction (dragging) must never be the only way to do an action. We always double it with a simple, accessible path. It's more work than the AI does on its own — and it's exactly what separates a demo from a real product.
Host and test
- Static file, online in one drop.
- Drag a card from one column to another with the mouse: it must drop (proof the
preventDefaultis in place). - Put the mouse away: with Tab, reach a card, then move it with the ‹ › buttons. That's the project's accessibility test.
- Reload the page: the cards are in the right columns (persistence).
- Put
<b>test</b>as a task: it must show as-is. Toggle FR / EN, console at zero.
The finished result
The full code (and downloadable)
The entire file, exactly the one running above.
Download the code (.html · 256 lines)
View the full code
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tableau Kanban</title>
<meta name="description" content="Un tableau de tâches qu'on réorganise à la glisse, et au clavier. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<!--
============================================================================
TABLEAU KANBAN — un seul fichier : HTML + CSS + JS, zéro dépendance.
1. <style> : l'apparence (3 colonnes, les cartes, le retour visuel au survol).
2. <body> : la structure (champ d'ajout + 3 colonnes À faire / En cours / Fait).
3. <script> : ajouter, déplacer (à la souris ET au clavier), supprimer, sauvegarder.
Point clé du projet : le glisser-déposer natif ne marche QU'À LA SOURIS.
On ajoute donc des boutons de déplacement, pour que ça reste utilisable au clavier.
============================================================================
-->
<style>
:root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; --soft:#eef1f4; }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; }
body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
.app { width:100%; max-width:880px; }
header { text-align:center; margin-bottom:18px; }
h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
.sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
.lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
.lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; }
.lang-btn[aria-pressed="true"] { background:var(--accent); color:#fff; }
.lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
.add-row { display:flex; gap:10px; margin-bottom:20px; }
.add-input { flex:1; min-height:48px; padding:0 14px; border:1.5px solid var(--border); border-radius:10px; font:inherit; font-size:1rem; color:var(--ink); }
.add-input:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
.btn { min-height:48px; padding:0 20px; border-radius:10px; border:1.5px solid transparent; font:inherit; font-weight:700; cursor:pointer; }
.btn-primary { background:var(--accent); color:#fff; }
.btn-primary:hover { background:#1f6a37; }
.btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
/* Le tableau : 3 colonnes côte à côte (et empilées sur mobile). */
.board { display:grid; grid-template-columns:repeat(3,1fr); gap:14px; }
.col { background:var(--soft); border:1.5px solid var(--border); border-radius:14px; padding:12px; min-height:160px; transition:background .15s, border-color .15s; }
.col.over { background:#e6f3ec; border-color:var(--accent); } /* survol pendant un glisser : on éclaire la colonne */
.col h2 { font-size:0.8rem; text-transform:uppercase; letter-spacing:0.05em; color:var(--muted); margin:2px 4px 10px; }
.col-cards { display:flex; flex-direction:column; gap:8px; min-height:40px; }
.card { background:#fff; border:1px solid var(--border); border-radius:10px; padding:10px 10px 8px; box-shadow:0 2px 6px rgba(15,25,35,0.06); cursor:grab; }
.card:focus-visible { outline:3px solid rgba(38,125,66,0.45); }
.card.dragging { opacity:0.5; }
.card-text { display:block; word-break:break-word; margin-bottom:8px; }
.card-actions { display:flex; gap:4px; }
.icon-btn { width:30px; height:30px; border:1px solid var(--border); background:#fff; border-radius:7px; cursor:pointer; font-size:0.95rem; line-height:1; color:var(--muted); }
.icon-btn:hover:not([disabled]) { border-color:var(--accent); color:var(--accent); }
.icon-btn[disabled] { opacity:0.35; cursor:default; }
.icon-btn.del { margin-left:auto; }
.icon-btn.del:hover { border-color:#a8341f; color:#a8341f; }
.icon-btn:focus-visible { outline:3px solid rgba(38,125,66,0.45); }
.credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:24px; }
.credit a { color:var(--accent); }
@media (max-width:620px){ .board { grid-template-columns:1fr; } }
@media (prefers-reduced-motion: reduce){ .col, .card { transition:none; } }
</style>
</head>
<body>
<main class="app">
<header>
<h1 data-i18n="title">Tableau Kanban</h1>
<p class="sub" data-i18n="sub">Glisse une carte d'une colonne à l'autre, ou déplace-la au clavier.</p>
<div class="lang-switch" role="group" aria-label="Langue / Language">
<button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
<button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
</div>
</header>
<form class="add-row" id="add-form">
<input class="add-input" id="add-input" type="text" maxlength="80" data-i18n-ph="ph" placeholder="Nouvelle tâche (ex. Relire le code de l'IA)" aria-label="Nouvelle tâche">
<button class="btn btn-primary" type="submit" data-i18n="add">Ajouter</button>
</form>
<!-- 3 colonnes fixes. Chaque .col est une zone où l'on peut déposer une carte. -->
<div class="board">
<section class="col" data-col="todo" aria-label="À faire"><h2 data-i18n="todo">À faire</h2><div class="col-cards" id="col-todo"></div></section>
<section class="col" data-col="doing" aria-label="En cours"><h2 data-i18n="doing">En cours</h2><div class="col-cards" id="col-doing"></div></section>
<section class="col" data-col="done" aria-label="Fait"><h2 data-i18n="done">Fait</h2><div class="col-cards" id="col-done"></div></section>
</div>
<p class="credit" data-i18n="credit">Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com</p>
</main>
<script>
(function () {
'use strict';
/* ---------------------------------------------------------------------------
1. LES TEXTES (FR / EN). "cols" donne le titre de chaque colonne.
--------------------------------------------------------------------------- */
var UI = {
fr: { title:"Tableau Kanban", sub:"Glisse une carte d'une colonne à l'autre, ou déplace-la au clavier.",
add:"Ajouter", ph:"Nouvelle tâche (ex. Relire le code de l'IA)",
todo:"À faire", doing:"En cours", done:"Fait",
prev:"Déplacer vers la colonne précédente", next:"Déplacer vers la colonne suivante", del:"Supprimer la tâche",
credit:'Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com' },
en: { title:"Kanban board", sub:"Drag a card from one column to another — or move it with the keyboard.",
add:"Add", ph:"New task (e.g. Review the AI's code)",
todo:"To do", doing:"Doing", done:"Done",
prev:"Move to previous column", next:"Move to next column", del:"Delete task",
credit:'A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course — web-developpeur.com' }
};
var COLS = ['todo', 'doing', 'done']; // l'ordre des colonnes, de gauche à droite
/* ---------------------------------------------------------------------------
2. LES ÉLÉMENTS + L'ÉTAT.
--------------------------------------------------------------------------- */
var form = document.getElementById('add-form');
var input = document.getElementById('add-input');
var lists = { todo: document.getElementById('col-todo'), doing: document.getElementById('col-doing'), done: document.getElementById('col-done') };
var lang = 'fr';
try { lang = localStorage.getItem('kanban-lang') || 'fr'; } catch (e) {}
if (lang !== 'fr' && lang !== 'en') lang = 'fr';
// Chargement PRUDENT (le stockage peut être vide ou corrompu).
function load() {
try { var raw = localStorage.getItem('kanban-cards'); if (!raw) return []; var d = JSON.parse(raw); return Array.isArray(d) ? d : []; }
catch (e) { return []; }
}
function save() { try { localStorage.setItem('kanban-cards', JSON.stringify(cards)); } catch (e) {} }
var cards = load(); // [{ id, text, col }]
function uid() { return 'c' + Date.now() + Math.floor(Math.random() * 1000); }
/* ---------------------------------------------------------------------------
3. DÉPLACER UNE CARTE — la même fonction sert à la souris ET au clavier.
--------------------------------------------------------------------------- */
function moveTo(id, colName) {
var c = cards.find(function (x) { return x.id === id; });
if (c && c.col !== colName) { c.col = colName; save(); render(); }
}
// dir = -1 (gauche) ou +1 (droite). On reste dans les limites des colonnes.
function nudge(id, dir) {
var c = cards.find(function (x) { return x.id === id; });
if (!c) return;
var i = COLS.indexOf(c.col) + dir;
if (i < 0 || i > COLS.length - 1) return; // déjà à un bout : on ne fait rien
c.col = COLS[i]; save(); render();
}
/* ---------------------------------------------------------------------------
4. L'AFFICHAGE — on reconstruit les 3 colonnes à partir du tableau "cards".
--------------------------------------------------------------------------- */
function render() {
COLS.forEach(function (col) { lists[col].innerHTML = ''; });
cards.forEach(function (c) {
var card = document.createElement('div');
card.className = 'card';
card.setAttribute('draggable', 'true'); // déplaçable à la souris
card.tabIndex = 0; // focusable au clavier
var span = document.createElement('span');
span.className = 'card-text';
span.textContent = c.text; // textContent : le texte est saisi, jamais interprété comme du HTML
var actions = document.createElement('div');
actions.className = 'card-actions';
// Boutons de déplacement = l'alternative CLAVIER au glisser-déposer.
var i = COLS.indexOf(c.col);
var left = document.createElement('button');
left.className = 'icon-btn'; left.type = 'button'; left.textContent = '‹';
left.setAttribute('aria-label', UI[lang].prev); left.disabled = (i === 0);
left.addEventListener('click', function () { nudge(c.id, -1); });
var right = document.createElement('button');
right.className = 'icon-btn'; right.type = 'button'; right.textContent = '›';
right.setAttribute('aria-label', UI[lang].next); right.disabled = (i === COLS.length - 1);
right.addEventListener('click', function () { nudge(c.id, 1); });
var del = document.createElement('button');
del.className = 'icon-btn del'; del.type = 'button'; del.textContent = '✕';
del.setAttribute('aria-label', UI[lang].del);
del.addEventListener('click', function () { cards = cards.filter(function (x) { return x.id !== c.id; }); save(); render(); });
// Glisser : on retient l'id de la carte qu'on déplace.
card.addEventListener('dragstart', function (e) {
e.dataTransfer.setData('text/plain', c.id);
card.classList.add('dragging');
});
card.addEventListener('dragend', function () { card.classList.remove('dragging'); });
actions.appendChild(left); actions.appendChild(right); actions.appendChild(del);
card.appendChild(span); card.appendChild(actions);
lists[c.col].appendChild(card);
});
}
/* ---------------------------------------------------------------------------
5. LES ZONES DE DÉPÔT — branchées une seule fois sur chaque colonne.
--------------------------------------------------------------------------- */
document.querySelectorAll('.col').forEach(function (col) {
var colName = col.getAttribute('data-col');
// IMPORTANT : sans preventDefault sur dragover, le navigateur REFUSE le dépôt.
col.addEventListener('dragover', function (e) { e.preventDefault(); col.classList.add('over'); });
col.addEventListener('dragleave', function () { col.classList.remove('over'); });
col.addEventListener('drop', function (e) {
e.preventDefault();
col.classList.remove('over');
var id = e.dataTransfer.getData('text/plain');
moveTo(id, colName);
});
});
/* ---------------------------------------------------------------------------
6. LES ACTIONS + LE DÉMARRAGE.
--------------------------------------------------------------------------- */
form.addEventListener('submit', function (e) {
e.preventDefault();
var v = input.value.trim();
if (!v) return;
cards.push({ id: uid(), text: v, col: 'todo' }); // toute nouvelle tâche arrive dans "À faire"
save(); input.value = ''; render();
});
function setLang(next) {
lang = next;
try { localStorage.setItem('kanban-lang', lang); } catch (e) {}
document.documentElement.lang = lang;
var t = UI[lang];
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n'); if (!t[k]) return;
if (k === 'credit') el.innerHTML = t[k]; else el.textContent = t[k];
});
var ph = document.querySelector('[data-i18n-ph]'); if (ph) ph.placeholder = t.ph;
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
});
render(); // les libellés des boutons (aria-label) changent avec la langue
}
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); });
});
setLang(lang);
})();
</script>
</body>
</html>
Your turn
The best way to cement all this is to grow the project yourself, applying the same reflexes to every addition. Some open ideas to make it yours:
- Allow reordering cards within a column (not just changing column).
- Show a count of cards per column.
- Add a 4th "Waiting" column — check the ‹ › buttons disable correctly at both new endpoints.
Add a "Delete" button on each card that asks for confirmation before removing it (use confirm() or an internal state), then updates localStorage. Try it on your own first — producing without a model is how you truly leave the tutorial behind. Only unfold the solution after you've tried.
You've succeeded if:
- the click asks for confirmation before deleting (no accidental removal);
- the deletion updates
localStorageand the rendered board without a page reload; - the button has an explicit
aria-label(not just "×") so screen-reader users know which card they're deleting.
See one possible solution
// Inside the render() function, for each card:
var delBtn = document.createElement('button');
delBtn.className = 'icon-btn del';
// Explicit aria-label: the screen reader announces "Delete Task title"
delBtn.setAttribute('aria-label', 'Delete ' + card.text);
delBtn.textContent = '×'; // textContent, not innerHTML — the course's reflex
delBtn.addEventListener('click', function () {
// Confirm before destroying — a mistake here is irreversible
if (!confirm('Delete this task?')) return;
cards = cards.filter(function (c) { return c.id !== card.id; });
// try/catch: localStorage can be full or blocked in private mode
try { localStorage.setItem('kanban', JSON.stringify(cards)); } catch (e) {}
render();
});
cardEl.querySelector('.card-actions').appendChild(delBtn);
The key reflex: any destructive action deserves a confirmation step. The aria-label carries the full context — not just "×" — so the keyboard user knows exactly which card they're about to delete. And try/catch around localStorage.setItem: storage can be full or refused in private mode; never let that error go silently unhandled.
On every addition: is the action possible with the keyboard? is the text escaped? is the state saved?
L'IA te rend ce gestionnaire de dépôt pour les colonnes du Kanban. Ton rôle de relecteur : l'accepter tel quel ou le rejeter, et dire pourquoi.
col.addEventListener('drop', function (e) {
e.preventDefault();
var id = e.dataTransfer.getData('text/plain');
moveTo(id, col.dataset.col);
});
drop est correct (il appelle bien e.preventDefault()), mais il ne sert à rien sans son binôme. Tant qu'on n'ajoute pas col.addEventListener("dragover", e => e.preventDefault()), la colonne n'est jamais une zone "déposable" et l'événement drop ne se déclenche pas. Le piège classique : c'est le preventDefault sur dragover, pas celui sur drop, qui autorise le dépôt. Et même complété, ça reste souris uniquement : pas de chemin clavier.Sans remonter dans la leçon : pourquoi un événement drop ne se déclenche-t-il pas tant qu'on n'a rien fait sur dragover, et pourquoi a-t-on ajouté des boutons ‹ › alors que le glisser fonctionnait déjà ?
e.preventDefault() sur dragover pour annuler ce refus, sinon le drop ne se déclenche jamais (le preventDefault() dans le drop lui-même ne suffit pas). Les boutons ‹ › sont un second chemin pour la même action : le glisser-déposer natif ne marche qu'à la souris, donc sans eux le tableau serait inutilisable au clavier et pour un lecteur d'écran. La règle : une interaction "jolie" ne doit jamais être la seule façon d'agir.Ton tableau Kanban laisse glisser les cartes d'une colonne à l'autre, comme un vrai outil pro. Au projet suivant, tu passes derrière le rideau des données : explorer une base SQL et lui poser tes premières vraies questions.
Leçon 9 : Explorer une base SQL →