Leçon 8/12 15 min

Projet 8 : un tableau Kanban avec l'IA

TD corrigé : un tableau de tâches en glisser-déposer, construit avec l'IA. Les vrais prompts, et la relecture qui rend le drag & drop utilisable au clavier et gère les pièges du dépôt.

FR EN

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é.

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.

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 preventDefault est 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

Ouvrir le projet en plein écran

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

  • 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 ‹ › suivent).

À chaque ajout : l'action est-elle possible au clavier ? le texte est-il échappé ? l'état est-il sauvegardé ?

Projet suivant
Explorer une base SQL →

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.

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.

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 preventDefault is 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

Open the project full screen

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

  • 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 follow).

On every addition: is the action possible with the keyboard? is the text escaped? is the state saved?

Next project
Explore a SQL database →
Accepter ou rejeter le code de l'IA

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);
});
À rejeter : ce 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.
Rappel libre

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à ?

Par défaut, un élément n'est pas une zone "déposable" : il faut appeler 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.
Prochaine étape

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 →
Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement