Revoir l'ergonomie des panneaux et de la colonne des projets

Les panneaux d'édition étaient modaux et la colonne de gauche tronquait
tout ce qu'elle portait. Sept corrections, mesurées sur data/exemple.json.

Panneaux
- Le voile qui recouvrait la page disparaît : la frise reste manipulable
  pendant l'édition, et cliquer une autre barre y bascule la sélection.
  Il rendait aussi le raccourci flèches inatteignable, le focus partant
  dans le champ « Nom » sans qu'on puisse cliquer ailleurs pour en sortir.
- Un clic dans une zone inerte referme le panneau : ce que rendait le
  voile, sans le calque qui figeait la frise.
- Les paramètres d'un projet quittent le <dialog> modal pour le même
  panneau qu'une phase, avec la même édition au fil de la saisie.
  « Nouveau projet » crée donc le projet aussitôt, comme le fait déjà
  l'ajout d'une phase : plus aucune création ne passe par un formulaire.

Colonne des projets
- Le nom ne disposait que de 56 px sur 250, et « client » s'affichait
  « c. ». Les commandes passent en surimpression au survol, les tags
  cèdent en bloc derrière un compteur, et le nom cède en dernier.
- La colonne se redimensionne, largeur conservée dans localStorage.
- L'œil disparaît : il faisait doublon avec l'entrée « Masquer » du menu.
  Un « + » le remplace, l'ajout d'une phase étant l'action la plus
  fréquente, et le nom du projet devient cliquable — il portait déjà un
  curseur « main » sans aucune action derrière.
- Plus de commande « Ajouter un jalon » : un jalon est une phase dont on
  coche la case, pas un autre objet.

Frise
- Le nom d'une phase reste accroché au bord visible quand sa barre
  s'enfonce sous la colonne.
- Cliquer un nom de phase hors écran ramène la frise sur elle.
- La barre cumulative d'un projet s'affiche aussi quand il est déplié,
  amincie pour ne pas se faire passer pour une barre de phase.

Arbitrages consignés dans docs/decisions.md, sections 8 et 17 à 21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 10:42:35 +02:00
parent a2a5c5cb9c
commit eaca2f7813
9 changed files with 1032 additions and 333 deletions

View File

@@ -328,11 +328,16 @@ function libelleProjet(projet, tagsActifs = []) {
const nom = document.createElement('span');
nom.className = 'libelle-projet__nom';
nom.textContent = projet.name;
nom.dataset.action = 'parametres';
nom.dataset.projet = projet.id;
// Les pastilles rognent le nom quand la colonne se remplit : l'infobulle
// reste le seul endroit où les deux se lisent toujours en entier.
nom.title = projet.tags.length
? `${projet.name}\nTags : ${projet.tags.join(', ')}`
: projet.name;
nom.title =
(projet.tags.length ? `${projet.name}\nTags : ${projet.tags.join(', ')}` : projet.name) +
// Le grisé de la ligne dit qu'il se passe quelque chose ; encore faut-il
// pouvoir apprendre quoi, et comment revenir en arrière.
(projet.hidden ? '\n\nMasqué de la frise — voir le menu ⋯ pour le rétablir.' : '') +
'\n\nCliquer pour renommer, changer la couleur et les tags.';
ligne.append(nom);
if (projet.tags.length) ligne.append(pastillesTags(projet.tags, tagsActifs));
@@ -349,19 +354,30 @@ function libelleProjet(projet, tagsActifs = []) {
ligne.append(etiquette);
}
const oeil = document.createElement('button');
oeil.className = 'icone-oeil';
oeil.type = 'button';
oeil.dataset.action = 'masquer';
oeil.dataset.projet = projet.id;
oeil.textContent = projet.hidden ? '🚫' : '👁';
oeil.setAttribute('aria-label', projet.hidden ? `Afficher ${projet.name}` : `Masquer ${projet.name}`);
ligne.append(oeil);
// Les commandes sont posées *au-dessus* de la ligne, révélées au survol,
// plutôt que rangées dans son flux. Elles y occupaient une soixantaine de
// pixels en permanence — de quoi tronquer le nom de tous les projets dans une
// colonne de largeur courante. En surimpression, le nom dispose de toute la
// colonne et rien ne se déplace à l'approche du curseur.
const commandes = document.createElement('span');
commandes.className = 'libelle-projet__commandes';
// Ajouter une phase est de loin l'action la plus fréquente : elle mérite son
// bouton, pas une entrée à aller chercher dans un menu.
const ajout = document.createElement('button');
ajout.className = 'commande-projet';
ajout.type = 'button';
ajout.dataset.action = 'ajouter';
ajout.dataset.projet = projet.id;
ajout.textContent = '+';
ajout.title = `Ajouter une phase à ${projet.name}`;
ajout.setAttribute('aria-label', ajout.title);
commandes.append(ajout);
// Bouton de menu explicite : les actions du projet passaient auparavant par
// un clic sur son nom, ce que rien n'indiquait.
const menu = document.createElement('button');
menu.className = 'menu-projet';
menu.className = 'commande-projet';
menu.type = 'button';
menu.dataset.action = 'menu';
menu.dataset.projet = projet.id;
@@ -369,7 +385,9 @@ function libelleProjet(projet, tagsActifs = []) {
menu.title = `Actions sur ${projet.name}`;
menu.setAttribute('aria-label', `Actions sur ${projet.name}`);
menu.setAttribute('aria-haspopup', 'menu');
ligne.append(menu);
commandes.append(menu);
ligne.append(commandes);
return ligne;
}
@@ -381,7 +399,15 @@ function libelleProjet(projet, tagsActifs = []) {
* recliquer l'en retire. C'est le geste le plus court pour passer d'un projet
* repéré à « montre-moi tous ceux qui lui ressemblent », sans remonter à la
* barre de filtres.
*
* Au-delà de `PASTILLES_VISIBLES`, les tags se résument à un compteur. La
* colonne est étroite et disputée : montrer quatre pastilles y revenait à les
* rogner toutes jusqu'à l'initiale, ce qui coûtait la place d'un tag sans en
* livrer le nom. Les tags masqués restent lisibles dans l'infobulle, et tous
* sont cliquables depuis la barre de filtres.
*/
const PASTILLES_VISIBLES = 2;
function pastillesTags(tags, tagsActifs) {
const conteneur = document.createElement('span');
conteneur.className = 'tags';
@@ -390,7 +416,16 @@ function pastillesTags(tags, tagsActifs) {
// c'est la clé, pas la graphie, qui décide s'il est actif.
const clesActives = new Set(tagsActifs.map(cleTag));
for (const tag of tags) {
// Un tag présent dans le filtre courant passe devant : c'est celui qui
// explique pourquoi le projet est à l'écran, il ne doit pas tomber dans le
// compteur.
const ordonnes = [...tags].sort(
(a, b) => Number(clesActives.has(cleTag(b))) - Number(clesActives.has(cleTag(a)))
);
const montres = ordonnes.slice(0, PASTILLES_VISIBLES);
const restants = ordonnes.slice(PASTILLES_VISIBLES);
for (const tag of montres) {
const pastille = document.createElement('button');
pastille.type = 'button';
pastille.className = 'tag';
@@ -406,6 +441,14 @@ function pastillesTags(tags, tagsActifs) {
conteneur.append(pastille);
}
if (restants.length) {
const compteur = document.createElement('span');
compteur.className = 'tag--reste';
compteur.textContent = `+${restants.length}`;
compteur.title = restants.join(', ');
conteneur.append(compteur);
}
return conteneur;
}
@@ -421,12 +464,14 @@ function construireCouloirs(projets, echelle, selection) {
couloirProjet.className = 'couloir couloir--projet';
couloirProjet.style.setProperty('--couleur-projet', projet.color);
if (projet.collapsed) {
const barre = construireBarrePliee(projet, echelle);
if (barre) couloirProjet.append(barre);
const fantome = construireFantome(projet, echelle);
if (fantome) couloirProjet.append(fantome);
}
// La barre cumulative n'est plus réservée au mode plié : dépliée, elle
// coiffe les phases du projet et garde sa forme d'ensemble sous les yeux
// pendant qu'on travaille sur le détail. Sans elle, il fallait replier pour
// savoir où en était le projet entier, donc perdre la vue qu'on éditait.
const barre = construireBarreCumulative(projet, echelle);
if (barre) couloirProjet.append(barre);
const fantome = construireFantome(projet, echelle);
if (fantome) couloirProjet.append(fantome);
elements.push(couloirProjet);
@@ -486,12 +531,20 @@ function construirePhase(projet, phase, echelle, selection) {
barre.dataset.projet = projet.id;
barre.dataset.phase = phase.id;
barre.dataset.role = 'barre';
barre.textContent = phase.name;
barre.title =
`${phase.name}\n${formaterDateLongue(phase.start)}${formaterDateLongue(phase.end)}` +
`\n${LIBELLES_STATUT[phase.status]}` +
(phase.notes ? `\n\n${phase.notes}` : '');
// Le nom vit dans son propre élément plutôt que directement dans la barre :
// il est `sticky`, ce qui le maintient visible quand la barre s'enfonce sous
// la colonne des libellés. Une phase de plusieurs mois pouvait autrement
// occuper tout l'écran sans qu'on lise son nom, parti hors cadre à gauche.
const nom = document.createElement('span');
nom.className = 'barre__nom';
nom.textContent = phase.name;
barre.append(nom);
for (const bord of ['debut', 'fin']) {
const poignee = document.createElement('div');
poignee.className = `poignee poignee--${bord}`;
@@ -505,15 +558,20 @@ function construirePhase(projet, phase, echelle, selection) {
}
/**
* Barre unique du mode plié, segmentée par phase.
* Lecture seule : aucun `data-role`, donc drag.js l'ignore.
* Barre unique du projet, segmentée par phase — sa forme d'ensemble.
*
* Affichée qu'il soit plié ou déplié. Lecture seule dans les deux cas : aucun
* `data-role`, donc drag.js l'ignore (docs/decisions.md, section 8).
*/
function construireBarrePliee(projet, echelle) {
function construireBarreCumulative(projet, echelle) {
const bornes = bornesProjet(projet);
if (!bornes) return null;
const conteneur = document.createElement('div');
conteneur.className = 'barre-pliee';
// Dépliée, elle voisine avec les barres de phases : elle s'amincit pour
// qu'on ne la prenne pas pour l'une d'elles et qu'on n'essaie pas de la
// glisser. Pliée, elle est seule sur sa ligne et porte toute l'information.
conteneur.className = `barre-cumulative${projet.collapsed ? '' : ' barre-cumulative--fine'}`;
conteneur.style.left = `${echelle.x(bornes.start)}px`;
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
@@ -522,7 +580,9 @@ function construireBarrePliee(projet, echelle) {
`${projet.name}\n${formaterDateLongue(bornes.start)}${formaterDateLongue(bornes.end)}` +
`\n${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}` +
(derive ? `\nÉcart à la référence : ${derive}` : '') +
'\n\nDéplier le projet pour modifier les dates.';
// Déplié, les barres modifiables sont juste en dessous : le dire serait
// enfoncer une porte ouverte.
(projet.collapsed ? '\n\nDéplier le projet pour modifier les dates.' : '');
const origine = echelle.x(bornes.start);
for (const phase of projet.phases) {
@@ -539,13 +599,15 @@ function construireBarrePliee(projet, echelle) {
return conteneur;
}
/** Barre fantôme du projet plié, reflétant l'étendue de référence. */
/** Barre fantôme du projet, reflétant l'étendue de référence. */
function construireFantome(projet, echelle) {
const reference = bornesReference(projet);
if (!reference) return null;
const fantome = document.createElement('div');
fantome.className = 'fantome';
// Le fantôme se cale sous la barre qu'il double : il doit suivre celle-ci
// quand elle s'amincit, sans quoi il flotterait sous elle.
fantome.className = `fantome${projet.collapsed ? '' : ' fantome--fin'}`;
fantome.style.left = `${echelle.x(reference.start)}px`;
fantome.style.width = `${echelle.largeurDe(reference.start, reference.end)}px`;
fantome.title = `Référence : ${formaterDateLongue(reference.start)}${formaterDateLongue(reference.end)}`;