Files
gestion_projets/js/menu.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

165 lines
5.3 KiB
JavaScript

/**
* 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.
*
* 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;
// ---------------------------------------------------------------------------
// Menu contextuel
// ---------------------------------------------------------------------------
/**
* @param {HTMLElement} racine l'élément `.menu` du document
*/
export function creerMenu(racine) {
let fermerCourant = null;
function fermer() {
if (!fermerCourant) return;
const rendreLeFocus = fermerCourant;
fermerCourant = null;
racine.hidden = true;
racine.replaceChildren();
rendreLeFocus();
}
// En capture : un clic ailleurs referme le menu avant que la cible ne le
// traite, sans quoi on pourrait activer un bouton et laisser le menu ouvert.
document.addEventListener(
'pointerdown',
(evenement) => {
if (fermerCourant && !racine.contains(evenement.target)) fermer();
},
true
);
document.addEventListener('keydown', (evenement) => {
if (!fermerCourant) return;
if (evenement.key === 'Escape') {
evenement.preventDefault();
fermer();
return;
}
const entrees = [...racine.querySelectorAll('button:not(:disabled)')];
if (!entrees.length) return;
const index = entrees.indexOf(document.activeElement);
if (evenement.key === 'ArrowDown') {
evenement.preventDefault();
entrees[(index + 1) % entrees.length].focus();
} else if (evenement.key === 'ArrowUp') {
evenement.preventDefault();
entrees[(index - 1 + entrees.length) % entrees.length].focus();
}
});
window.addEventListener('resize', fermer);
return {
/**
* @param {HTMLElement} ancre élément près duquel afficher le menu
* @param {Array} entrees { libelle, action, danger?, separateurAvant?, desactive? }
*/
ouvrir(ancre, entrees) {
fermer();
const aRendre = document.activeElement;
fermerCourant = () => aRendre?.focus?.();
for (const entree of entrees) {
if (entree.separateurAvant) {
const separateur = document.createElement('hr');
separateur.className = 'menu__separateur';
racine.append(separateur);
}
const bouton = document.createElement('button');
bouton.type = 'button';
bouton.className = `menu__entree${entree.danger ? ' menu__entree--danger' : ''}`;
bouton.setAttribute('role', 'menuitem');
bouton.textContent = entree.libelle;
bouton.disabled = Boolean(entree.desactive);
bouton.addEventListener('click', () => {
fermer();
entree.action();
});
racine.append(bouton);
}
racine.hidden = false;
positionner(racine, ancre);
racine.querySelector('button:not(:disabled)')?.focus();
},
fermer,
};
}
/** Place le menu sous son ancre, en le rabattant s'il déborde de la fenêtre. */
function positionner(menu, ancre) {
const cadre = ancre.getBoundingClientRect();
// Mesure d'abord au coin supérieur gauche : la taille du menu dépend de son
// contenu, et la lire avant de le placer évite un saut visible.
menu.style.left = '0px';
menu.style.top = '0px';
const taille = menu.getBoundingClientRect();
let gauche = cadre.left;
let haut = cadre.bottom + 2;
if (gauche + taille.width > window.innerWidth - MARGE_ECRAN) {
gauche = Math.max(MARGE_ECRAN, window.innerWidth - taille.width - MARGE_ECRAN);
}
if (haut + taille.height > window.innerHeight - MARGE_ECRAN) {
// Pas la place en dessous : on bascule au-dessus de l'ancre.
haut = Math.max(MARGE_ECRAN, cadre.top - taille.height - 2);
}
menu.style.left = `${Math.round(gauche)}px`;
menu.style.top = `${Math.round(haut)}px`;
}
// ---------------------------------------------------------------------------
// Dialogues
// ---------------------------------------------------------------------------
/**
* Demande confirmation avant une action irréversible.
* @returns {Promise<boolean>}
*/
export function creerDialogueConfirmation(refs) {
const { dialogue, titre, message, valider } = refs;
return function demander({ titreTexte, messageTexte, libelleValider = 'Confirmer', danger = false }) {
titre.textContent = titreTexte;
message.textContent = messageTexte;
valider.textContent = libelleValider;
valider.classList.toggle('bouton--danger', danger);
valider.classList.toggle('bouton--principal', !danger);
dialogue.showModal();
// Le focus part sur « Annuler » : sur une action destructrice, un appui
// réflexe sur Entrée ne doit pas valider.
dialogue.querySelector('button[value="annuler"]').focus();
return new Promise((resoudre) => {
dialogue.addEventListener('close', () => resoudre(dialogue.returnValue === 'ok'), {
once: true,
});
});
};
}