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

View File

@@ -1,13 +1,15 @@
/**
* Menu contextuel et boîtes de dialogue.
* Menu contextuel et dialogue de confirmation.
*
* Remplace les `prompt()` et `confirm()` natifs, qui bloquaient le fil
* d'exécution, ne se stylaient pas, et — pour le menu d'un projet — obligeaient
* à saisir un numéro d'action au clavier.
*
* Les dialogues s'appuient sur l'élément `<dialog>` natif, qui apporte
* gratuitement le voile, le piège à focus, la fermeture par Échap et la
* restauration du focus à la fermeture.
* Le dialogue s'appuie sur l'élément `<dialog>` natif, qui apporte gratuitement
* le voile, le piège à focus, la fermeture par Échap et la restauration du
* focus. Il ne reste que pour la confirmation d'une action irréversible, seul
* cas où interrompre franchement est le but : les paramètres d'un projet sont
* passés à un panneau (projet.js).
*/
const MARGE_ECRAN = 8;
@@ -134,117 +136,6 @@ function positionner(menu, ancre) {
// Dialogues
// ---------------------------------------------------------------------------
/**
* Demande le nom, la couleur et les tags d'un projet.
* @returns {Promise<{nom: string, couleur: string, tags: string[]} | null>} null si annulé.
*/
export function creerDialogueProjet(refs, couleurs, outilsTags) {
const {
dialogue,
titre,
champNom,
conteneurCouleurs,
champTags,
suggestionsTags,
blocSuggestions,
erreur,
valider,
} = refs;
const { normaliserTags, cleTag, teinteTag } = outilsTags;
let choisie = couleurs[0];
function peindrePalette() {
conteneurCouleurs.replaceChildren();
for (const couleur of couleurs) {
const bouton = document.createElement('button');
bouton.type = 'button';
bouton.className = 'pastille';
bouton.style.background = couleur;
bouton.setAttribute('aria-label', `Couleur ${couleur}`);
bouton.setAttribute('aria-pressed', String(couleur === choisie));
bouton.addEventListener('click', () => {
choisie = couleur;
peindrePalette();
});
conteneurCouleurs.append(bouton);
}
}
/** Tags saisis dans le champ, tels qu'ils seront enregistrés. */
function tagsSaisis() {
return normaliserTags(champTags.value.split(','));
}
/**
* 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(tagsConnus) {
suggestionsTags.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 restants = tagsSaisis().filter((pose) => cleTag(pose) !== cleTag(tag));
const suivants = restants.length === tagsSaisis().length ? [...restants, tag] : restants;
champTags.value = normaliserTags(suivants).join(', ');
peindreSuggestions(tagsConnus);
});
suggestionsTags.append(bouton);
}
blocSuggestions.hidden = tagsConnus.length === 0;
}
return function demander({
titreTexte,
libelleValider,
nom = '',
couleur = couleurs[0],
tags = [],
tagsConnus = [],
}) {
titre.textContent = titreTexte;
valider.textContent = libelleValider;
champNom.value = nom;
champTags.value = tags.join(', ');
choisie = couleur;
erreur.hidden = true;
peindrePalette();
peindreSuggestions(tagsConnus);
const rafraichirSuggestions = () => peindreSuggestions(tagsConnus);
champTags.addEventListener('input', rafraichirSuggestions);
dialogue.showModal();
champNom.focus();
champNom.select();
return new Promise((resoudre) => {
dialogue.addEventListener(
'close',
() => {
champTags.removeEventListener('input', rafraichirSuggestions);
// `returnValue` vaut '' quand le dialogue est fermé par Échap.
if (dialogue.returnValue !== 'ok') return resoudre(null);
const saisi = champNom.value.trim();
if (!saisi) return resoudre(null);
resoudre({ nom: saisi, couleur: choisie, tags: tagsSaisis() });
},
{ once: true }
);
});
};
}
/**
* Demande confirmation avant une action irréversible.
* @returns {Promise<boolean>}