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