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:
236
js/projet.js
Normal file
236
js/projet.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user