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

236
js/projet.js Normal file
View File

@@ -0,0 +1,236 @@
/**
* Panneau des paramètres d'un projet : nom, couleur, tags.
*
* Jumeau de `detail.js`, et volontairement : renommer un projet et renommer une
* phase sont la même opération à un niveau de granularité près. Elles se
* présentent donc pareil — même panneau latéral, même édition au fil de la
* saisie, même bouton de suppression au bas du formulaire.
*
* Ces paramètres passaient auparavant par un `<dialog>` modal à valider. Voir
* docs/decisions.md, section 21.
*/
export function creerPanneauProjet(refs, outils, rappels) {
const {
panneau,
formulaire,
titre,
erreur,
conteneurCouleurs,
suggestions,
blocSuggestions,
supprimer,
fermer,
} = refs;
const { couleurs, normaliserTags, cleTag, teinteTag } = outils;
let idCourant = null;
let projetCourant = null;
function afficherErreur(message) {
erreur.textContent = message || '';
erreur.hidden = !message;
}
/** Tags saisis dans le champ, tels qu'ils seront enregistrés. */
function tagsSaisis() {
return normaliserTags(formulaire.tags.value.split(','));
}
/**
* La palette est bâtie une fois pour toutes, et seul l'état pressé change
* ensuite. La reconstruire à chaque frappe ferait perdre le focus à qui
* parcourt les couleurs au clavier — et l'édition en direct provoque un
* rafraîchissement à chaque caractère saisi dans le nom.
*/
function peindrePalette(choisie) {
if (!conteneurCouleurs.childElementCount) {
for (const couleur of couleurs) {
const bouton = document.createElement('button');
bouton.type = 'button';
bouton.className = 'pastille';
bouton.dataset.couleur = couleur;
bouton.style.background = couleur;
bouton.setAttribute('aria-label', `Couleur ${couleur}`);
// Une couleur se choisit d'un clic et s'applique aussitôt : la frise
// change sous les yeux, ce qui est le seul moyen de juger si la teinte
// se distingue bien de celle des couloirs voisins.
bouton.addEventListener('click', () => appliquer({ color: couleur }));
conteneurCouleurs.append(bouton);
}
}
for (const bouton of conteneurCouleurs.children) {
bouton.setAttribute('aria-pressed', String(bouton.dataset.couleur === choisie));
}
}
/**
* Tags déjà employés ailleurs dans le planning, proposés en un clic.
*
* Sans eux, chaque projet inventerait sa propre orthographe et le filtre se
* remplirait de « client », « Clients » et « client final ».
*/
function peindreSuggestions() {
const tagsConnus = rappels.tagsConnus();
// La liste change dès qu'un tag apparaît ou disparaît du planning : on la
// reconstruit, mais on rend le focus au bouton de même rang, sans quoi
// cliquer une suggestion au clavier éjecterait vers le début du document.
const focalise = [...suggestions.children].indexOf(document.activeElement);
suggestions.replaceChildren();
const posees = new Set(tagsSaisis().map(cleTag));
for (const tag of tagsConnus) {
const bouton = document.createElement('button');
bouton.type = 'button';
bouton.className = 'tag';
bouton.style.setProperty('--teinte-tag', String(teinteTag(tag)));
bouton.textContent = tag;
bouton.setAttribute('aria-pressed', String(posees.has(cleTag(tag))));
bouton.addEventListener('click', () => {
const saisis = tagsSaisis();
const restants = saisis.filter((pose) => cleTag(pose) !== cleTag(tag));
const suivants = restants.length === saisis.length ? [...saisis, tag] : restants;
formulaire.tags.value = normaliserTags(suivants).join(', ');
appliquer();
});
suggestions.append(bouton);
}
if (focalise >= 0) suggestions.children[focalise]?.focus();
blocSuggestions.hidden = tagsConnus.length === 0;
}
function remplir(projet) {
titre.textContent = projet.name;
formulaire.name.value = projet.name;
formulaire.tags.value = projet.tags.join(', ');
peindrePalette(projet.color);
peindreSuggestions();
afficherErreur(null);
}
/**
* Lit le formulaire et remonte le changement.
*
* @param {object} forces champs imposés hors formulaire — la couleur, qui se
* choisit dans la palette et n'a pas de champ de saisie.
*/
function appliquer(forces = {}) {
if (!idCourant || !projetCourant) return;
const nom = formulaire.name.value.trim();
if (!nom) {
// Le projet garde son nom précédent : on signale sans rien écraser, comme
// le fait le panneau d'une phase.
afficherErreur('Le nom ne peut pas être vide.');
return;
}
afficherErreur(null);
const suivant = {
...projetCourant,
name: nom,
tags: tagsSaisis(),
...forces,
};
// Comparaison terme à terme plutôt que par concaténation : un tag peut
// contenir une espace, et « a b » ne doit pas passer pour « a », « b ».
const memesTags =
suivant.tags.length === projetCourant.tags.length &&
suivant.tags.every((tag, rang) => tag === projetCourant.tags[rang]);
if (
suivant.name === projetCourant.name &&
suivant.color === projetCourant.color &&
memesTags
) {
return;
}
projetCourant = suivant;
titre.textContent = suivant.name;
peindrePalette(suivant.color);
peindreSuggestions();
rappels.surModification(idCourant, suivant);
}
formulaire.addEventListener('input', () => appliquer());
formulaire.addEventListener('change', () => appliquer());
formulaire.addEventListener('submit', (evenement) => evenement.preventDefault());
supprimer.addEventListener('click', async () => {
if (!idCourant) return;
const cible = idCourant;
const projet = projetCourant;
if (!(await rappels.demanderSuppression(cible, projet))) return;
// Le panneau a pu être fermé ou changer de projet pendant la confirmation.
if (idCourant !== cible) return;
api.fermer();
rappels.surSuppression(cible);
});
fermer.addEventListener('click', () => api.fermer());
document.addEventListener('keydown', (evenement) => {
if (evenement.key === 'Escape' && !panneau.hidden) api.fermer();
});
const api = {
/**
* @param {object} options
* - nomAEcraser : place le curseur dans le champ « Nom », texte
* présélectionné. Réservé à un projet qui vient d'être créé, dont le nom
* provisoire est justement là pour être remplacé.
*/
ouvrir(projet, { nomAEcraser = false } = {}) {
idCourant = projet.id;
projetCourant = projet;
remplir(projet);
panneau.hidden = false;
if (nomAEcraser) {
formulaire.name.focus();
formulaire.name.select();
} else {
panneau.focus();
}
},
/** Recharge le contenu après une modification venue d'ailleurs. */
rafraichir(projet) {
if (idCourant !== projet.id) return;
projetCourant = projet;
titre.textContent = projet.name;
// On ne réécrit pas le champ qui a le focus : cela déplacerait le curseur
// au milieu d'une frappe.
const actif = document.activeElement;
if (formulaire.name !== actif) formulaire.name.value = projet.name;
if (formulaire.tags !== actif) formulaire.tags.value = projet.tags.join(', ');
peindrePalette(projet.color);
peindreSuggestions();
},
fermer() {
if (panneau.hidden) return;
idCourant = null;
projetCourant = null;
panneau.hidden = true;
rappels.surFermeture();
},
estOuvert() {
return idCourant !== null;
},
projetOuvert() {
return idCourant;
},
};
return api;
}