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>
165 lines
5.3 KiB
JavaScript
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,
|
|
});
|
|
});
|
|
};
|
|
}
|