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>
237 lines
7.6 KiB
JavaScript
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;
|
|
}
|