Files
gestion_projets/js/projet.js
Bertrand Benjamin eaca2f7813 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>
2026-08-01 10:58:47 +02:00

237 lines
7.6 KiB
JavaScript

/**
* 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;
}